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