fix(ai): resolve DeepSeek V4 reasoning_content 400 errors from three root causes
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -107,7 +107,9 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
||||
medium: "default",
|
||||
high: "default",
|
||||
xhigh: "default",
|
||||
} satisfies Partial<Record<OpenAIReasoningEffort, string>>)
|
||||
} satisfies Partial<Record<OpenAIReasoningEffort, string>>)
|
||||
: 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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user