diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 073f6f1dc..ea002873f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,10 @@ ### Fixed -- Fixed Vercel AI Gateway dynamic model discovery to use the versioned `/v1/models` catalog endpoint, restoring newly published models. +- 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 diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index d27c6d0be..5d91d89aa 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -108,7 +108,9 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB high: "default", xhigh: "default", } satisfies Partial>) - : {}; + : isDeepseekFamily && Boolean(model.reasoning) + ? { xhigh: "max" } + : {}; return { supportsStore: !isNonStandard, @@ -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,9 @@ 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..2895510a7 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"); } } @@ -1234,25 +1237,71 @@ 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 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 + // (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"); + // 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 || (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. + // 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 ( + needsReasoningField && + !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; + 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; + } + } + } + // 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 ( + needsReasoningField && + !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. */ 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..ac6ee5b91 --- /dev/null +++ b/packages/ai/test/deepseek-reasoning-content.test.ts @@ -0,0 +1,520 @@ +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."); + }); + 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(""); + }); + }); + + // ---------------------------------------------------------------- + // 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(""); + }); + }); + + // ---------------------------------------------------------------- + // 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', () => { + 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)", () => {