From 0300a9d01d30bdf5a3c1271eae26d9a236022d9d Mon Sep 17 00:00:00 2001 From: Burke T <54682710+rburketaylor@users.noreply.github.com> Date: Fri, 1 May 2026 11:56:55 -0300 Subject: [PATCH 1/4] fix(ai): resolve DeepSeek V4 reasoning_content 400 errors from three root causes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent failure modes observed in HTTP 400 logs: 1. reasoningEffortMap only mapped xhigh→max for the deepseek provider, not for DeepSeek-family models on NVIDIA/OpenCode-Go/etc. Sending reasoning_effort: "xhigh" to these endpoints caused 400 errors. 2. convertMessages filtered thinking blocks by nonEmptyThinkingBlocks, excluding blocks with valid thinkingSignature but empty text. These signatures identify the correct field name for reasoning_content replay but were lost in the filter. 3. When a proxy (OpenCode-Go, NVIDIA) returns a tool-call response without any reasoning_content at all, no thinking blocks exist to recover from. Added empty-string fallback so the required field is present even when no reasoning was captured. Adds allowsSyntheticReasoningContentForToolCalls compat flag to distinguish DeepSeek (rejects synthetic "." placeholder) from Kimi and OpenRouter (accept it). Credit: builds on the approach from PR #902 by @edmand46. --- packages/ai/CHANGELOG.md | 7 +++ .../providers/openai-completions-compat.ts | 9 ++- .../ai/src/providers/openai-completions.ts | 60 +++++++++++++++---- packages/ai/src/types.ts | 2 + 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fac1f7a2b..574a928a0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Fixed + +- Fixed DeepSeek V4 tool-call follow-up 400 errors from three root causes: + - Mapped `reasoning_effort` "xhigh" to "max" for DeepSeek-family models on any provider (NVIDIA, OpenCode-Go, etc.), not just `deepseek` + - Recovered `reasoning_content` from thinking blocks with valid signatures that were filtered by the non-empty-text check +- Added empty-string fallback when `reasoning_content` is genuinely absent (e.g. proxy-stripped) but the provider requires the field + ## [14.5.13] - 2026-05-01 ### Breaking Changes diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index d27c6d0be..e60025bb4 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -107,7 +107,9 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB medium: "default", high: "default", xhigh: "default", - } satisfies Partial>) + } satisfies Partial>) + : isDeepseekFamily && Boolean(model.reasoning) + ? { xhigh: "max" } : {}; return { @@ -141,6 +143,9 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB isKimiModel || (isDeepseekFamily && Boolean(model.reasoning)) || ((provider === "openrouter" || baseUrl.includes("openrouter.ai")) && Boolean(model.reasoning)), + // DeepSeek V4 rejects synthetic reasoning_content placeholders (".") on tool-call turns. + // Kimi and OpenRouter accept them when actual reasoning is unavailable. + allowsSyntheticReasoningContentForToolCalls: !isDeepseekFamily || !Boolean(model.reasoning), requiresAssistantContentForToolCalls: isKimiModel, openRouterRouting: undefined, vercelGatewayRouting: undefined, @@ -183,6 +188,8 @@ export function resolveOpenAICompat( reasoningContentField: model.compat.reasoningContentField ?? detected.reasoningContentField, requiresReasoningContentForToolCalls: model.compat.requiresReasoningContentForToolCalls ?? detected.requiresReasoningContentForToolCalls, + allowsSyntheticReasoningContentForToolCalls: + model.compat.allowsSyntheticReasoningContentForToolCalls ?? detected.allowsSyntheticReasoningContentForToolCalls, requiresAssistantContentForToolCalls: model.compat.requiresAssistantContentForToolCalls ?? detected.requiresAssistantContentForToolCalls, disableReasoningOnForcedToolChoice: diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ffb23d13f..00e941bfe 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1234,25 +1234,59 @@ export function convertMessages( } const toolCalls = msg.content.filter(b => b.type === "toolCall") as ToolCall[]; - // Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend - // rejects history without it. The compat flag captures the rule: - // - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field. - // - Reasoning models reached through OpenRouter (e.g. DeepSeek V4 Pro): the underlying - // provider's thinking-mode validator demands it on every prior assistant turn. omp - // cannot synthesize real reasoning when the conversation was warmed up by another - // provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we - // emit a placeholder. Real captured reasoning, when present, is preserved earlier via - // the `thinkingSignature` echo path and short-circuits via `hasReasoningField`. - // `thinkingFormat` is gated to formats that consume the field (openai/openrouter chat - // completions); formats with their own conventions (zai, qwen) are excluded. - const stubsReasoningContent = + // Replay reasoning_content on assistant tool-call turns for backends that validate + // thinking-mode history. The replay logic has three tiers: + // 1. Recover from thinking blocks with valid signatures (covers same-model replay + // where nonEmptyThinkingBlocks may have filtered out empty-text blocks) + // 2. For providers that require the field but returned no reasoning at all + // (e.g. proxy-stripped reasoning_content), emit an empty string + // 3. For providers that accept synthetic placeholders (Kimi, OpenRouter), emit "." + // DeepSeek V4 rejects synthetic "." placeholders — it validates the exact value — + // so the allowsSyntheticReasoningContentForToolCalls flag controls tier 3. + const canUseSyntheticReasoningContent = compat.requiresReasoningContentForToolCalls && + compat.allowsSyntheticReasoningContentForToolCalls && (compat.thinkingFormat === "openai" || compat.thinkingFormat === "openrouter"); let hasReasoningField = (assistantMsg as any).reasoning_content !== undefined || (assistantMsg as any).reasoning !== undefined || (assistantMsg as any).reasoning_text !== undefined; - if (toolCalls.length > 0 && stubsReasoningContent && !hasReasoningField) { + // Tier 1: Recover reasoning_content from ALL thinking blocks (including empty-text + // ones) when the provider requires exact replay and rejects synthetic placeholders. + // This covers the case where thinking blocks have valid signatures but were excluded + // by the nonEmptyThinkingBlocks filter above, or where thinking text is empty but + // the signature identifies the correct field name for replay. + if ( + toolCalls.length > 0 && + !hasReasoningField && + compat.requiresReasoningContentForToolCalls && + !compat.allowsSyntheticReasoningContentForToolCalls + ) { + const allThinkingBlocks = msg.content.filter(b => b.type === "thinking") as ThinkingContent[]; + if (allThinkingBlocks.length > 0) { + const signature = allThinkingBlocks[0].thinkingSignature; + if (signature) { + (assistantMsg as any)[signature] = allThinkingBlocks.map(b => b.thinking).join("\n"); + hasReasoningField = true; + } + } + } + // Tier 2: When the provider requires reasoning_content but there are genuinely no + // thinking blocks at all (e.g. proxy stripped reasoning_content from the response), + // emit an empty string. The field must be present; an empty string is the most honest + // representation of "no reasoning was captured." + if ( + toolCalls.length > 0 && + !hasReasoningField && + compat.requiresReasoningContentForToolCalls && + !compat.allowsSyntheticReasoningContentForToolCalls + ) { + const reasoningField = compat.reasoningContentField ?? "reasoning_content"; + (assistantMsg as any)[reasoningField] = ""; + hasReasoningField = true; + } + // Tier 3: For providers that accept synthetic placeholders (Kimi, OpenRouter). + if (toolCalls.length > 0 && canUseSyntheticReasoningContent && !hasReasoningField) { const reasoningField = compat.reasoningContentField ?? "reasoning_content"; (assistantMsg as any)[reasoningField] = "."; hasReasoningField = true; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 935d4038e..951aecb6d 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -553,6 +553,8 @@ export interface OpenAICompat { reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text"; /** Whether assistant tool-call messages must include reasoning content. Default: false. */ requiresReasoningContentForToolCalls?: boolean; + /** Whether the provider accepts a synthetic placeholder (e.g. ".") for missing reasoning_content on tool-call turns. Default: true. Set to false for providers like DeepSeek that validate the exact reasoning_content value. */ + allowsSyntheticReasoningContentForToolCalls?: boolean; /** Whether assistant tool-call messages must include non-empty content. Default: false. */ requiresAssistantContentForToolCalls?: boolean; /** Whether the provider supports the `tool_choice` parameter. Default: true. */ From ee7e6f67c1e3dad16675eab3752a8c7ea3877e11 Mon Sep 17 00:00:00 2001 From: Burke T <54682710+rburketaylor@users.noreply.github.com> Date: Fri, 1 May 2026 11:57:04 -0300 Subject: [PATCH 2/4] test(ai): add comprehensive tests for DeepSeek reasoning_content replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16 tests covering all three failure modes: - reasoningEffortMap xhigh→max for DeepSeek-family on any provider - allowsSyntheticReasoningContentForToolCalls flag detection - Tier 1: signature recovery from empty thinking blocks - Tier 2: empty-string fallback when no thinking blocks exist - Tier 3: synthetic placeholder for non-DeepSeek providers (Kimi) Updates existing issue-883 tests to match new behavior (empty string instead of synthetic "." for DeepSeek). --- .../test/deepseek-reasoning-content.test.ts | 309 ++++++++++++++++++ packages/ai/test/issue-883-repro.test.ts | 7 +- 2 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 packages/ai/test/deepseek-reasoning-content.test.ts diff --git a/packages/ai/test/deepseek-reasoning-content.test.ts b/packages/ai/test/deepseek-reasoning-content.test.ts new file mode 100644 index 000000000..e782b515d --- /dev/null +++ b/packages/ai/test/deepseek-reasoning-content.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "bun:test"; +import { getBundledModel } from "../src/models"; +import { convertMessages, detectCompat } from "../src/providers/openai-completions"; +import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "../src/types"; + +function deepseekModel(overrides: Partial>): Model<"openai-completions"> { + return { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + reasoning: true, + ...overrides, + }; +} + +function assistantToolCall(model: Model<"openai-completions">, content?: Array<{ type: string; [key: string]: unknown }>): AssistantMessage { + return { + role: "assistant", + content: content ?? [ + { + type: "toolCall", + id: "call_test_1", + name: "read", + arguments: { path: "/tmp/test" }, + }, + ], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; +} + +describe("DeepSeek reasoning_content tool-call replay", () => { + // ---------------------------------------------------------------- + // Fix 1: reasoningEffortMap for DeepSeek-family on any provider + // ---------------------------------------------------------------- + describe("reasoningEffortMap (Fix 1)", () => { + it("maps xhigh → max for DeepSeek-family on opencode-go", () => { + const compat = detectCompat( + deepseekModel({ + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id: "deepseek-v4-flash", + }), + ); + expect(compat.reasoningEffortMap.xhigh).toBe("max"); + }); + + it("maps xhigh → max for DeepSeek-family on NVIDIA", () => { + const compat = detectCompat( + deepseekModel({ + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + id: "deepseek-ai/deepseek-v4-flash", + }), + ); + expect(compat.reasoningEffortMap.xhigh).toBe("max"); + }); + + it("maps xhigh → max for DeepSeek on the official endpoint", () => { + const compat = detectCompat( + deepseekModel({ + provider: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + id: "deepseek-v4-pro", + }), + ); + expect(compat.reasoningEffortMap.xhigh).toBe("max"); + }); + + it("does NOT map xhigh for non-DeepSeek models", () => { + const compat = detectCompat( + deepseekModel({ + provider: "openai", + baseUrl: "https://api.openai.com/v1", + id: "gpt-4o-mini", + reasoning: false, + }), + ); + expect(compat.reasoningEffortMap.xhigh).toBeUndefined(); + }); + }); + + // ---------------------------------------------------------------- + // allowsSyntheticReasoningContentForToolCalls flag + // ---------------------------------------------------------------- + describe("allowsSyntheticReasoningContentForToolCalls flag", () => { + it("is false for DeepSeek-family reasoning models", () => { + const compat = detectCompat( + deepseekModel({ + provider: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + id: "deepseek-v4-pro", + }), + ); + expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(false); + }); + + it("is false for DeepSeek-family on NVIDIA", () => { + const compat = detectCompat( + deepseekModel({ + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + id: "deepseek-ai/deepseek-v4-flash", + }), + ); + expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(false); + }); + + it("is true for non-DeepSeek reasoning models on OpenRouter", () => { + const compat = detectCompat({ + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + id: "qwen/qwq-32b", + reasoning: true, + }); + // Qwen is not isDeepseekFamily, so synthetic is allowed + expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(true); + }); + }); + + // ---------------------------------------------------------------- + // Fix 2: reasoning_content from empty thinking blocks with signature + // ---------------------------------------------------------------- + describe("thinking-block signature recovery (Fix 2)", () => { + it("recovers reasoning_content from empty thinking block with valid signature", () => { + const model = deepseekModel({ + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id: "deepseek-v4-flash", + }); + const compat = detectCompat(model); + // Simulate a tool-call turn with an empty thinking block that has a valid + // signature — this happens when reasoning text was lost but the signature + // (field name) is preserved. + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "", + thinkingSignature: "reasoning_content", + } as ThinkingContent, + { + type: "toolCall", + id: "call_empty_thinking", + name: "read", + arguments: { path: "/tmp/test" }, + } as ToolCall, + ], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + // The reasoning_content field should be set from the signature, even if empty. + expect(Reflect.get(assistant as object, "reasoning_content")).toBe(""); + }); + + it("recovers reasoning_content from non-empty thinking block with signature", () => { + const model = deepseekModel({ + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id: "deepseek-v4-flash", + }); + const compat = detectCompat(model); + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "I need to read the file first.", + thinkingSignature: "reasoning_content", + } as ThinkingContent, + { + type: "toolCall", + id: "call_with_thinking", + name: "read", + arguments: { path: "/tmp/test" }, + } as ToolCall, + ], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(Reflect.get(assistant as object, "reasoning_content")).toBe("I need to read the file first."); + }); + }); + + // ---------------------------------------------------------------- + // Fix 3: Empty-string fallback when NO thinking blocks exist + // (matches the actual observed 400 failure: proxy-stripped reasoning) + // ---------------------------------------------------------------- + describe("empty-string fallback for missing reasoning_content (Fix 3)", () => { + it("sets reasoning_content to empty string when no thinking blocks exist for DeepSeek", () => { + const model = deepseekModel({ + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id: "deepseek-v4-flash", + }); + const compat = detectCompat(model); + // Tool-call turn with NO thinking blocks at all — matches the actual + // observed 400 error pattern where proxy stripped reasoning_content. + const msg = assistantToolCall(model, [ + { + type: "toolCall", + id: "call_no_thinking", + name: "read", + arguments: { path: "/tmp/test" }, + } as ToolCall, + ]); + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + // reasoning_content must be present (empty string) — not absent and not "." + const rc = Reflect.get(assistant as object, "reasoning_content"); + expect(rc).toBeDefined(); + expect(rc).toBe(""); + }); + + it("sets content to empty string (not null) when reasoning_content is present", () => { + const model = deepseekModel({ + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + id: "deepseek-ai/deepseek-v4-flash", + }); + const compat = detectCompat(model); + const msg = assistantToolCall(model, [ + { + type: "toolCall", + id: "call_no_content", + name: "list_files", + arguments: { path: "." }, + } as ToolCall, + ]); + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect((assistant as { content: unknown }).content).toBe(""); + }); + }); + + // ---------------------------------------------------------------- + // Tier 3: Synthetic placeholder for non-DeepSeek providers + // ---------------------------------------------------------------- + describe("synthetic placeholder for non-DeepSeek providers (Tier 3)", () => { + it("still uses \".\" placeholder for Kimi models that accept it", () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id: "moonshotai/kimi-k2.5", + reasoning: true, + }; + const compat = detectCompat(model); + expect(compat.requiresReasoningContentForToolCalls).toBe(true); + expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(true); + const msg = assistantToolCall(model, [ + { + type: "toolCall", + id: "call_kimi", + name: "read", + arguments: { path: "/tmp" }, + } as ToolCall, + ]); + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(Reflect.get(assistant as object, "reasoning_content")).toBe("."); + }); + }); +}); diff --git a/packages/ai/test/issue-883-repro.test.ts b/packages/ai/test/issue-883-repro.test.ts index 8a37ab5bd..236a99f2b 100644 --- a/packages/ai/test/issue-883-repro.test.ts +++ b/packages/ai/test/issue-883-repro.test.ts @@ -63,7 +63,7 @@ describe("issue #883 / #810 — DeepSeek V4 reasoning_content tool-call replay", expect(compat.requiresReasoningContentForToolCalls).toBe(true); }); - it("injects reasoning_content placeholder on assistant tool-call turn for deepseek-v4-pro", () => { + it("sets reasoning_content to empty string for deepseek-v4-pro tool-call turn with no thinking blocks", () => { const model = deepseekModel({ provider: "deepseek", baseUrl: "https://api.deepseek.com/v1", @@ -74,8 +74,9 @@ describe("issue #883 / #810 — DeepSeek V4 reasoning_content tool-call replay", const assistant = messages.find(m => m.role === "assistant"); expect(assistant).toBeDefined(); const reasoningContent = Reflect.get(assistant as object, "reasoning_content"); - expect(typeof reasoningContent).toBe("string"); - expect((reasoningContent as string).length).toBeGreaterThan(0); + expect(reasoningContent).toBeDefined(); + // DeepSeek rejects synthetic "." — when no thinking blocks exist, we emit empty string + expect(reasoningContent).toBe(""); }); it("normalizes assistant content to '' when reasoning_content placeholder is injected (DeepSeek invariant)", () => { From 17b7db664329294d5ea2e5d44785e3e9180053ef Mon Sep 17 00:00:00 2001 From: Burke T <54682710+rburketaylor@users.noreply.github.com> Date: Fri, 1 May 2026 12:57:11 -0300 Subject: [PATCH 3/4] fix(ai): restrict thinkingSignature to recognized field names in reasoning_content replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review feedback: thinkingSignature can be an opaque value (Anthropic encrypted signature, OpenAI Responses JSON item ID, etc.) not just a field name. Using it as a property name on the wire message writes to an arbitrary key and marks hasReasoningField=true, skipping both the empty-string fallback and the synthetic placeholder — the outgoing message still misses reasoning_content and triggers the same 400 on DeepSeek follow-ups. Both the pre-existing nonEmptyThinkingBlocks path and the new Tier 1 path now validate against recognized keys: reasoning_content, reasoning, reasoning_text. --- .../ai/src/providers/openai-completions.ts | 13 ++- .../test/deepseek-reasoning-content.test.ts | 94 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 00e941bfe..7c91a0f86 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1203,9 +1203,12 @@ export function convertMessages( assistantMsg.content = [{ type: "text", text: thinkingText }]; } } else { - // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) + // Use the signature from the first thinking block if available, but only for + // recognized OpenAI-compat reasoning field names. Opaque signatures from other + // providers (Anthropic encrypted, OpenAI Responses JSON) are not valid property names. const signature = nonEmptyThinkingBlocks[0].thinkingSignature; - if (signature && signature.length > 0) { + const recognizedFields = ["reasoning_content", "reasoning", "reasoning_text"]; + if (signature && recognizedFields.includes(signature)) { (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n"); } } @@ -1256,6 +1259,9 @@ export function convertMessages( // This covers the case where thinking blocks have valid signatures but were excluded // by the nonEmptyThinkingBlocks filter above, or where thinking text is empty but // the signature identifies the correct field name for replay. + // Only recognized OpenAI-compat reasoning field names qualify — opaque signatures + // from other providers (Anthropic encrypted, OpenAI Responses JSON, etc.) are not + // valid property names for the wire message. if ( toolCalls.length > 0 && !hasReasoningField && @@ -1265,7 +1271,8 @@ export function convertMessages( const allThinkingBlocks = msg.content.filter(b => b.type === "thinking") as ThinkingContent[]; if (allThinkingBlocks.length > 0) { const signature = allThinkingBlocks[0].thinkingSignature; - if (signature) { + const recognizedFields = ["reasoning_content", "reasoning", "reasoning_text"]; + if (signature && recognizedFields.includes(signature)) { (assistantMsg as any)[signature] = allThinkingBlocks.map(b => b.thinking).join("\n"); hasReasoningField = true; } diff --git a/packages/ai/test/deepseek-reasoning-content.test.ts b/packages/ai/test/deepseek-reasoning-content.test.ts index e782b515d..ea135c7b2 100644 --- a/packages/ai/test/deepseek-reasoning-content.test.ts +++ b/packages/ai/test/deepseek-reasoning-content.test.ts @@ -221,6 +221,100 @@ describe("DeepSeek reasoning_content tool-call replay", () => { expect(assistant).toBeDefined(); expect(Reflect.get(assistant as object, "reasoning_content")).toBe("I need to read the file first."); }); + it("does not use opaque signature as property name but still sets reasoning_content from thinking text", () => { + const model = deepseekModel({ + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id: "deepseek-v4-flash", + }); + const compat = detectCompat(model); + // Simulate a thinking block with an opaque signature from another provider + // (e.g. Anthropic encrypted signature, OpenAI Responses JSON item). + // The code should NOT write to a property named after the opaque signature. + // It should still set reasoning_content from the thinking text via the + // existing thinkingFormat="openai" path. + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "some reasoning", + thinkingSignature: "rs_6f3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c", + } as ThinkingContent, + { + type: "toolCall", + id: "call_opaque_sig", + name: "read", + arguments: { path: "/tmp/test" }, + } as ToolCall, + ], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + // Should NOT have used the opaque signature as a property name. + expect(Reflect.get(assistant as object, "rs_6f3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c")).toBeUndefined(); + // Should have set reasoning_content from the thinking text via the openai path. + expect(Reflect.get(assistant as object, "reasoning_content")).toBe("some reasoning"); + }); + it("falls through to empty-string when thinking block has opaque signature and empty text", () => { + const model = deepseekModel({ + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id: "deepseek-v4-flash", + }); + const compat = detectCompat(model); + // Empty-text thinking block with opaque signature — Tier 1 should reject the + // opaque signature, nonEmptyThinkingBlocks won't include it, and the openai path + // won't set anything. Tier 2 should then emit empty reasoning_content. + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "", + thinkingSignature: "rs_6f3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c", + } as ThinkingContent, + { + type: "toolCall", + id: "call_empty_opaque", + name: "read", + arguments: { path: "/tmp/test" }, + } as ToolCall, + ], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(Reflect.get(assistant as object, "rs_6f3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c")).toBeUndefined(); + expect(Reflect.get(assistant as object, "reasoning_content")).toBe(""); + }); }); // ---------------------------------------------------------------- From 1c31ef015d4c9a25e6401af1ea742d902b83b072 Mon Sep 17 00:00:00 2001 From: Burke T <54682710+rburketaylor@users.noreply.github.com> Date: Fri, 1 May 2026 13:31:00 -0300 Subject: [PATCH 4/4] fix(ai): extend reasoning_content replay to all assistant turns for DeepSeek V4 DeepSeek V4 requires reasoning_content on EVERY assistant turn once any prior turn included it, not just tool-call turns. Previously the replay logic only triggered for tool-call turns, which caused 400 errors on plain-text assistant follow-ups in multi-turn conversations. Key change: the replay conditions now use needsReasoningField (all turns for strict providers) instead of toolCalls.length > 0. --- .../providers/openai-completions-compat.ts | 7 +- .../ai/src/providers/openai-completions.ts | 16 ++- .../test/deepseek-reasoning-content.test.ts | 121 +++++++++++++++++- 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index e60025bb4..5d91d89aa 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -107,10 +107,10 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB medium: "default", high: "default", xhigh: "default", - } satisfies Partial>) + } satisfies Partial>) : isDeepseekFamily && Boolean(model.reasoning) ? { xhigh: "max" } - : {}; + : {}; return { supportsStore: !isNonStandard, @@ -189,7 +189,8 @@ export function resolveOpenAICompat( requiresReasoningContentForToolCalls: model.compat.requiresReasoningContentForToolCalls ?? detected.requiresReasoningContentForToolCalls, allowsSyntheticReasoningContentForToolCalls: - model.compat.allowsSyntheticReasoningContentForToolCalls ?? detected.allowsSyntheticReasoningContentForToolCalls, + model.compat.allowsSyntheticReasoningContentForToolCalls ?? + detected.allowsSyntheticReasoningContentForToolCalls, requiresAssistantContentForToolCalls: model.compat.requiresAssistantContentForToolCalls ?? detected.requiresAssistantContentForToolCalls, disableReasoningOnForcedToolChoice: diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 7c91a0f86..2895510a7 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1237,8 +1237,10 @@ export function convertMessages( } const toolCalls = msg.content.filter(b => b.type === "toolCall") as ToolCall[]; - // Replay reasoning_content on assistant tool-call turns for backends that validate - // thinking-mode history. The replay logic has three tiers: + // Replay reasoning_content on assistant turns for backends that validate + // thinking-mode history. DeepSeek V4 requires reasoning_content on EVERY + // assistant turn once any prior turn included it — not just tool-call turns. + // The replay logic has three tiers: // 1. Recover from thinking blocks with valid signatures (covers same-model replay // where nonEmptyThinkingBlocks may have filtered out empty-text blocks) // 2. For providers that require the field but returned no reasoning at all @@ -1250,6 +1252,12 @@ export function convertMessages( compat.requiresReasoningContentForToolCalls && compat.allowsSyntheticReasoningContentForToolCalls && (compat.thinkingFormat === "openai" || compat.thinkingFormat === "openrouter"); + // DeepSeek reasoning models require reasoning_content on ALL assistant turns, + // not just tool-call turns. Other providers (Kimi, OpenRouter) only require it + // on tool-call turns. + const needsReasoningOnAllTurns = + compat.requiresReasoningContentForToolCalls && !compat.allowsSyntheticReasoningContentForToolCalls; + const needsReasoningField = needsReasoningOnAllTurns || toolCalls.length > 0; let hasReasoningField = (assistantMsg as any).reasoning_content !== undefined || (assistantMsg as any).reasoning !== undefined || @@ -1263,7 +1271,7 @@ export function convertMessages( // from other providers (Anthropic encrypted, OpenAI Responses JSON, etc.) are not // valid property names for the wire message. if ( - toolCalls.length > 0 && + needsReasoningField && !hasReasoningField && compat.requiresReasoningContentForToolCalls && !compat.allowsSyntheticReasoningContentForToolCalls @@ -1283,7 +1291,7 @@ export function convertMessages( // emit an empty string. The field must be present; an empty string is the most honest // representation of "no reasoning was captured." if ( - toolCalls.length > 0 && + needsReasoningField && !hasReasoningField && compat.requiresReasoningContentForToolCalls && !compat.allowsSyntheticReasoningContentForToolCalls diff --git a/packages/ai/test/deepseek-reasoning-content.test.ts b/packages/ai/test/deepseek-reasoning-content.test.ts index ea135c7b2..ac6ee5b91 100644 --- a/packages/ai/test/deepseek-reasoning-content.test.ts +++ b/packages/ai/test/deepseek-reasoning-content.test.ts @@ -12,7 +12,10 @@ function deepseekModel(overrides: Partial>): Model<" }; } -function assistantToolCall(model: Model<"openai-completions">, content?: Array<{ type: string; [key: string]: unknown }>): AssistantMessage { +function assistantToolCall( + model: Model<"openai-completions">, + content?: Array<{ type: string; [key: string]: unknown }>, +): AssistantMessage { return { role: "assistant", content: content ?? [ @@ -370,11 +373,125 @@ describe("DeepSeek reasoning_content tool-call replay", () => { }); }); + // ---------------------------------------------------------------- + // Fix 4: reasoning_content on ALL assistant turns, not just tool-call turns + // DeepSeek V4 requires reasoning_content on every assistant message once any + // prior turn included it — including plain text responses with no tool calls. + // ---------------------------------------------------------------- + describe("reasoning_content on non-tool-call assistant turns (Fix 4)", () => { + it("injects empty reasoning_content on plain text assistant turn for DeepSeek", () => { + const model = deepseekModel({ + provider: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + id: "deepseek-v4-pro", + }); + const compat = detectCompat(model); + // Plain text assistant response — no tool calls, no thinking blocks. + // This is the exact pattern from the observed 400 error. + const msg: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Here is the answer to your question." }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + // reasoning_content must be present — even on non-tool-call turns + const rc = Reflect.get(assistant as object, "reasoning_content"); + expect(rc).toBeDefined(); + expect(rc).toBe(""); + }); + + it("injects reasoning_content from thinking blocks on plain text assistant turn", () => { + const model = deepseekModel({ + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + id: "deepseek-v4-flash", + }); + const compat = detectCompat(model); + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Let me think about this.", + thinkingSignature: "reasoning_content", + } as ThinkingContent, + { type: "text", text: "The answer is 42." }, + ], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(Reflect.get(assistant as object, "reasoning_content")).toBe("Let me think about this."); + expect((assistant as { content: unknown }).content).toBe("The answer is 42."); + }); + + it("does NOT inject reasoning_content on non-tool-call turn for non-DeepSeek providers", () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + id: "qwen/qwq-32b", + reasoning: true, + }; + const compat = detectCompat(model); + const msg: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Plain answer." }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + const messages = convertMessages(model, { messages: [msg] }, compat); + const assistant = messages.find(m => m.role === "assistant"); + expect(assistant).toBeDefined(); + // OpenRouter reasoning models only need reasoning_content on tool-call turns + expect(Reflect.get(assistant as object, "reasoning_content")).toBeUndefined(); + }); + }); + // ---------------------------------------------------------------- // Tier 3: Synthetic placeholder for non-DeepSeek providers // ---------------------------------------------------------------- describe("synthetic placeholder for non-DeepSeek providers (Tier 3)", () => { - it("still uses \".\" placeholder for Kimi models that accept it", () => { + it('still uses "." placeholder for Kimi models that accept it', () => { const model: Model<"openai-completions"> = { ...getBundledModel("openai", "gpt-4o-mini"), api: "openai-completions",