From b6a7cdb43686f8aadbfec6fff7b80d2f5aedd10a Mon Sep 17 00:00:00 2001 From: knef Date: Sat, 25 Apr 2026 12:21:16 +0800 Subject: [PATCH 1/3] fix(provider): complete DeepSeek reasoning_content round-trip for multi-turn conversations Fixes the two-layer bug where reasoning_content is dropped on conversation replay for DeepSeek thinking mode and OpenRouter-routed DeepSeek models. Three changes: 1. provider.ts: Auto-enable interleaved for reasoning models - When model.reasoning is true but interleaved is not explicitly set, default to { field: "reasoning_content" } instead of false - This triggers the interleaved transform that extracts reasoning and passes it via providerOptions 2. transform.ts: Use dynamic SDK key in interleaved transform - Replace hardcoded "openaiCompatible" with sdkKey(model.api.npm) - Fixes OpenRouter provider which expects "openrouter" key, not "openaiCompatible" (prevents key mismatch in providerOptions) 3. transform.ts: Inject reasoning_content for ALL assistant messages - New fallback transform fires when capabilities.reasoning is true - Sets reasoning_content: "" in providerOptions for every assistant message, including historical messages stored before reasoning mode was enabled (no reasoning part to extract from) - Also expands DeepSeek detection to check model.id in addition to model.api.id, covering OpenRouter-routed DeepSeek models Closes #24104 Related: #24203 (OpenRouter users still affected by PR #24218 alone) Supersedes partial fix from PR #24146 (merged but incomplete) --- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/provider/transform.ts | 38 +++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0fe53e6e47f0..f65a02693f23 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 50529c4dd7ad..6ae4b665b7c5 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") @@ -211,8 +213,8 @@ function normalizeMessages( content: filteredContent, providerOptions: { ...msg.providerOptions, - openaiCompatible: { - ...msg.providerOptions?.openaiCompatible, + [sdk]: { + ...msg.providerOptions?.[sdk], [field]: reasoningText, }, }, @@ -223,6 +225,36 @@ 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") { + return { + ...msg, + content: [{ type: "text" as const, text: msg.content }, { type: "reasoning" as const, text: "" }], + } + } + return msg + }) + } + return msgs } From a4373c8babb4ac34f2db4b32bd746095982c50ab Mon Sep 17 00:00:00 2001 From: knef Date: Sat, 25 Apr 2026 12:29:41 +0800 Subject: [PATCH 2/3] fix(provider): set providerOptions for string-content assistant messages in reasoning fallback The fallback transform only set reasoning_content in providerOptions for array-content messages. String-content assistant messages (e.g., "It's 4.") were converted to array form but didn't get providerOptions set. Now both content types get reasoning_content: "" injected with the correct SDK key, ensuring DeepSeek's API receives it on all assistant turns. --- packages/opencode/src/provider/transform.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6ae4b665b7c5..ef9a60d54659 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -246,9 +246,17 @@ function normalizeMessages( } } 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 From 41eb35a12d3f55339b593d248797a2130960d96c Mon Sep 17 00:00:00 2001 From: knef Date: Sun, 26 Apr 2026 16:45:11 +0800 Subject: [PATCH 3/3] fix(provider): preserve reasoning_content on second interleaved pass When the interleaved transform runs on subsequent requests (after DB round-trip), content parts no longer contain reasoning blocks (they were extracted on the first pass). The unconditional [field]: reasoningText overwrites the previously correct providerOptions.reasoning_content with empty string, causing DeepSeek 400: 'The reasoning_content in the thinking mode must be passed back to the API.' Fix: set [field]: reasoningText first, then spread existing providerOptions so that preserved values from DB take priority over empty reasoningText. Closes #24442 (co-discovered with @claudianus) --- packages/opencode/src/provider/transform.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ef9a60d54659..5d431000b6d4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -208,14 +208,18 @@ function normalizeMessages( // 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 first + // pass sets the field from reasoning parts; on subsequent passes reasoningText + // is empty and must not overwrite the preserved value from DB. return { ...msg, content: filteredContent, providerOptions: { ...msg.providerOptions, [sdk]: { - ...msg.providerOptions?.[sdk], [field]: reasoningText, + ...msg.providerOptions?.[sdk], }, }, }