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] 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. */