From 86dd22f3835ab6c2c8a71a7b52bf5268bc512009 Mon Sep 17 00:00:00 2001 From: "ryan.h.park" Date: Sun, 26 Apr 2026 12:26:22 +0900 Subject: [PATCH 1/2] fix: preserve existing reasoning_content on second interleaved pass PR #24146 unconditionally sets reasoning_content to the extracted value. On the second pass through transform (after DB storage), content parts no longer have reasoning (already extracted in first pass), so the extracted value is empty. This overwrites the previously correct providerOptions.reasoning_content, causing DeepSeek 400: The 'reasoning_content' in the thinking mode must be passed back to the API. Fix: when reasoningText is empty, fall back to the existing providerOptions[sdk][field] value before defaulting to empty string. This preserves non-empty reasoning from the first pass while still sending empty reasoning_content when DeepSeek requires it (the case PR #24146 was fixing). Also use dynamic SDK key ([sdk]) instead of hardcoded openaiCompatible for provider flexibility. --- packages/opencode/src/provider/transform.ts | 58 ++++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 67b02c089602..05b352da5a81 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -176,7 +176,8 @@ function normalizeMessages( } // Deepseek requires all assistant messages to have reasoning on them - if (model.api.id.includes("deepseek")) { + // Check both API ID and model ID to cover OpenRouter-routed DeepSeek models + if (model.api.id.includes("deepseek") || model.id.includes("deepseek")) { msgs = msgs.map((msg) => { if (msg.role !== "assistant") return msg if (Array.isArray(msg.content)) { @@ -195,6 +196,7 @@ function normalizeMessages( if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) { const field = model.capabilities.interleaved.field + const sdk = sdkKey(model.api.npm) ?? "openaiCompatible" return msgs.map((msg) => { if (msg.role === "assistant" && Array.isArray(msg.content)) { const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") @@ -203,17 +205,21 @@ function normalizeMessages( // Filter out reasoning parts from content const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") - // Include reasoning_content | reasoning_details directly on the message for all assistant messages. - // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty - // reasoning_content which still needs to be sent back in subsequent requests. + // Preserve existing providerOptions[field] when content no longer has reasoning parts + // (e.g. after a prior transform pass already extracted them). The @ai-sdk yG converter + // resolves reasoning_content from providerOptions → overwriting it with empty string + // on the second pass causes DeepSeek 400: "reasoning_content must be passed back". + const existingField = msg.providerOptions?.[sdk]?.[field] + const resolvedText = reasoningText || existingField || "" + return { ...msg, content: filteredContent, providerOptions: { ...msg.providerOptions, - openaiCompatible: { - ...msg.providerOptions?.openaiCompatible, - [field]: reasoningText, + [sdk]: { + ...msg.providerOptions?.[sdk], + [field]: resolvedText, }, }, } @@ -223,6 +229,44 @@ function normalizeMessages( }) } + // When reasoning is active but interleaved is not configured, still inject empty reasoning_content + // for ALL assistant messages. This covers historical messages from DB that were stored before + // reasoning mode was enabled — they have no reasoning part to extract but DeepSeek's API still + // requires reasoning_content on every assistant turn in thinking mode. + if (model.capabilities.reasoning) { + msgs = msgs.map((msg) => { + if (msg.role !== "assistant") return msg + if (Array.isArray(msg.content)) { + const sdk = sdkKey(model.api.npm) ?? "openaiCompatible" + return { + ...msg, + providerOptions: { + ...msg.providerOptions, + [sdk]: { + ...msg.providerOptions?.[sdk], + reasoning_content: "", + }, + }, + } + } + if (typeof msg.content === "string") { + const sdk = sdkKey(model.api.npm) ?? "openaiCompatible" + return { + ...msg, + content: [{ type: "text" as const, text: msg.content }, { type: "reasoning" as const, text: "" }], + providerOptions: { + ...msg.providerOptions, + [sdk]: { + ...msg.providerOptions?.[sdk], + reasoning_content: "", + }, + }, + } + } + return msg + }) + } + return msgs } From fa478297f13d87ae544034a07398d5a0bda7f336 Mon Sep 17 00:00:00 2001 From: "ryan.h.park" Date: Sun, 26 Apr 2026 12:26:41 +0900 Subject: [PATCH 2/2] fix: default interleaved for reasoning models in fromModelsDevModel Models.dev may have reasoning:true but no explicit interleaved config. Default interleaved to {field: 'reasoning_content'} so the transform pipeline correctly extracts reasoning parts into providerOptions. --- packages/opencode/src/provider/provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9aa1b6304c12..830f5da1a883 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1006,7 +1006,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model video: model.modalities?.output?.includes("video") ?? false, pdf: model.modalities?.output?.includes("pdf") ?? false, }, - interleaved: model.interleaved ?? false, + interleaved: model.interleaved ?? (model.reasoning ? { field: "reasoning_content" } : false), }, release_date: model.release_date ?? "", variants: {}, @@ -1177,7 +1177,7 @@ const layer: Layer.Layer< model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, }, - interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ?? false, + interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ?? (model.reasoning ? { field: "reasoning_content" } : false), }, cost: { input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,