Skip to content

Commit 7007cf4

Browse files
author
Luis Carmona
committed
fix: prevent DeepSeek 400 error on direct mode switch from reasoning-capable model
1 parent 7af1a8a commit 7007cf4

12 files changed

Lines changed: 388 additions & 12 deletions

File tree

packages/types/src/message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export const clineSays = [
170170
"codebase_search_result",
171171
"user_edit_todos",
172172
"too_many_tools_warning",
173+
"mode_switch_compatibility_warning",
173174
"tool",
174175
] as const
175176

src/api/providers/deepseek.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import OpenAI from "openai"
33

4+
import { logger } from "../../utils/logging"
5+
46
import {
57
deepSeekModels,
68
deepSeekDefaultModelId,
@@ -14,6 +16,8 @@ import type { ApiHandlerOptions } from "../../shared/api"
1416
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1517
import { getModelParams } from "../transform/model-params"
1618
import { convertToR1Format } from "../transform/r1-format"
19+
import { historyHasToolCallsWithoutReasoning } from "./utils/reasoning-history-guard"
20+
import { historyHasToolCallsWithoutReasoning } from "./utils/reasoning-history-guard"
1721

1822
import { OpenAiHandler } from "./openai"
1923
import { extractReasoningFromDelta } from "./utils/extract-reasoning"
@@ -96,12 +100,6 @@ export class DeepSeekHandler extends OpenAiHandler {
96100
const { info: modelInfo, temperature, reasoningEffort, maxTokens } = this.getModel()
97101

98102
const isThinkingModel = isDeepSeekThinkingEnabled(modelId, this.options)
99-
const thinking = supportsDeepSeekThinkingToggle(modelId)
100-
? ({ type: isThinkingModel ? "enabled" : "disabled" } as const)
101-
: isThinkingModel
102-
? ({ type: "enabled" } as const)
103-
: undefined
104-
const deepSeekReasoningEffort = isThinkingModel ? normalizeDeepSeekReasoningEffort(reasoningEffort) : undefined
105103

106104
// Convert messages to R1 format (merges consecutive same-role messages)
107105
// This is required for DeepSeek which does not support successive messages with the same role
@@ -113,9 +111,30 @@ export class DeepSeekHandler extends OpenAiHandler {
113111
mergeToolResultText: isThinkingModel,
114112
})
115113

114+
// Layer 1 Guard: detect incompatible history (tool_calls without reasoning_content)
115+
// and disable thinking mode for this request to prevent a 400 error from the API.
116+
const hasIncompatibleHistory = historyHasToolCallsWithoutReasoning(convertedMessages)
117+
const effectiveThinkingEnabled = isThinkingModel && !hasIncompatibleHistory
118+
119+
if (hasIncompatibleHistory) {
120+
logger.warn("provider_reasoning_guard_triggered", {
121+
ctx: "deepseek",
122+
provider: "deepseek",
123+
modelId,
124+
taskId: metadata?.taskId,
125+
})
126+
}
127+
128+
const thinking = supportsDeepSeekThinkingToggle(modelId)
129+
? ({ type: effectiveThinkingEnabled ? "enabled" : "disabled" } as const)
130+
: effectiveThinkingEnabled
131+
? ({ type: "enabled" } as const)
132+
: undefined
133+
const deepSeekReasoningEffort = effectiveThinkingEnabled ? normalizeDeepSeekReasoningEffort(reasoningEffort) : undefined
134+
116135
const requestOptions: DeepSeekChatCompletionParams = {
117136
model: modelId,
118-
...(!isThinkingModel && { temperature: temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE }),
137+
...(!effectiveThinkingEnabled && { temperature: temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE }),
119138
messages: convertedMessages,
120139
stream: true as const,
121140
stream_options: { include_usage: true },

src/api/providers/mimo.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { extractReasoningFromDelta } from "./utils/extract-reasoning"
1414
import { OpenAiHandler } from "./openai"
1515
import type { ApiHandlerCreateMessageMetadata } from "../index"
1616
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
17+
import { historyHasToolCallsWithoutReasoning } from "./utils/reasoning-history-guard"
18+
import { logger } from "../../utils/logging"
1719

1820
/**
1921
* MiMoHandler extends OpenAiHandler with MiMo-specific adaptations.
@@ -81,6 +83,20 @@ export class MimoHandler extends OpenAiHandler {
8183

8284
const tools = metadata?.tools
8385

86+
// Layer 1 Guard: detect incompatible history (tool_calls without reasoning_content)
87+
// and disable thinking mode for this request to prevent a 400 error from the API.
88+
// MiMo previously had NO disable path — this closes that gap.
89+
const hasIncompatibleHistory = historyHasToolCallsWithoutReasoning(convertedMessages)
90+
91+
if (hasIncompatibleHistory) {
92+
logger.warn("provider_reasoning_guard_triggered", {
93+
ctx: "mimo",
94+
provider: "mimo",
95+
modelId,
96+
taskId: metadata?.taskId,
97+
})
98+
}
99+
84100
// Build request per MiMo's OpenAI-compatible API
85101
// https://developer.puter.com/ai/xiaomi/mimo-v2.5-pro/
86102
// Note: temperature is omitted because MiMo forces it to 1.0 when thinking mode
@@ -91,7 +107,7 @@ export class MimoHandler extends OpenAiHandler {
91107
stream: true,
92108
stream_options: { include_usage: true },
93109
// MiMo requires thinking to be enabled via extra_body
94-
extra_body: { thinking: { type: "enabled" } },
110+
extra_body: { thinking: { type: hasIncompatibleHistory ? "disabled" : "enabled" } },
95111
}
96112

97113
if (tools && tools.length > 0) {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// npx vitest run api/providers/utils/__tests__/reasoning-history-guard.spec.ts
2+
3+
import { historyHasToolCallsWithoutReasoning } from "../reasoning-history-guard"
4+
5+
describe("historyHasToolCallsWithoutReasoning", () => {
6+
it("returns false for empty messages", () => {
7+
expect(historyHasToolCallsWithoutReasoning([])).toBe(false)
8+
})
9+
10+
it("returns false when no assistant messages have tool_calls", () => {
11+
const messages = [
12+
{ role: "user", content: "hello" },
13+
{ role: "assistant", content: "hi there" },
14+
]
15+
expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false)
16+
})
17+
18+
it("returns false when assistant messages have tool_calls with reasoning_content", () => {
19+
const messages = [
20+
{
21+
role: "assistant",
22+
content: null,
23+
tool_calls: [{ id: "call_1", function: { name: "test" } }],
24+
reasoning_content: "I should call test because...",
25+
},
26+
]
27+
expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false)
28+
})
29+
30+
it("returns true when assistant messages have tool_calls but no reasoning_content field", () => {
31+
const messages = [
32+
{
33+
role: "assistant",
34+
content: null,
35+
tool_calls: [{ id: "call_1", function: { name: "test" } }],
36+
},
37+
]
38+
expect(historyHasToolCallsWithoutReasoning(messages)).toBe(true)
39+
})
40+
41+
it("returns true when assistant messages have tool_calls with empty reasoning_content", () => {
42+
const messages = [
43+
{
44+
role: "assistant",
45+
content: null,
46+
tool_calls: [{ id: "call_1", function: { name: "test" } }],
47+
reasoning_content: "",
48+
},
49+
]
50+
expect(historyHasToolCallsWithoutReasoning(messages)).toBe(true)
51+
})
52+
53+
it("returns true when assistant messages have empty tool_calls array", () => {
54+
const messages = [
55+
{
56+
role: "assistant",
57+
content: "hello",
58+
tool_calls: [],
59+
},
60+
]
61+
expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false)
62+
})
63+
64+
it("returns false for non-assistant messages with tool_calls", () => {
65+
const messages = [
66+
{
67+
role: "user",
68+
content: "hello",
69+
tool_calls: [{ id: "call_1" }],
70+
},
71+
]
72+
expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false)
73+
})
74+
75+
it("returns true when at least one assistant message is missing reasoning_content despite tool_calls", () => {
76+
const messages = [
77+
{
78+
role: "assistant",
79+
content: "Let me think...",
80+
reasoning_content: "thinking step 1",
81+
},
82+
{
83+
role: "assistant",
84+
content: null,
85+
tool_calls: [{ id: "call_1", function: { name: "read_file" } }],
86+
// no reasoning_content — this is the problematic message
87+
},
88+
{
89+
role: "tool",
90+
content: "file content",
91+
tool_call_id: "call_1",
92+
},
93+
]
94+
expect(historyHasToolCallsWithoutReasoning(messages)).toBe(true)
95+
})
96+
97+
it("handles non-array tool_calls gracefully", () => {
98+
const messages = [
99+
{
100+
role: "assistant",
101+
content: null,
102+
tool_calls: "not-an-array" as unknown as unknown[],
103+
},
104+
]
105+
expect(historyHasToolCallsWithoutReasoning(messages)).toBe(false)
106+
})
107+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Detects whether a converted OpenAI-format message history contains any
3+
* assistant message with `tool_calls` but no non-empty `reasoning_content`.
4+
* Used as a guard before enabling strict provider "thinking" modes that
5+
* require reasoning_content to accompany every tool-call turn.
6+
*
7+
* This function operates on messages *after* conversion to the provider's
8+
* OpenAI-compatible format (e.g. `convertToR1Format`, `convertToZAiFormat`),
9+
* because it is only in the converted format that `reasoning_content`
10+
* presence can be reliably determined.
11+
*
12+
* @param messages - Array of converted messages in OpenAI-compatible format
13+
* @returns `true` if any assistant message has tool_calls but lacks
14+
* non-empty reasoning_content
15+
*/
16+
export function historyHasToolCallsWithoutReasoning(
17+
messages: Array<{ role?: string; tool_calls?: unknown[]; reasoning_content?: unknown }>,
18+
): boolean {
19+
return messages.some(
20+
(m) =>
21+
m.role === "assistant" &&
22+
Array.isArray(m.tool_calls) &&
23+
m.tool_calls.length > 0 &&
24+
(typeof m.reasoning_content !== "string" || m.reasoning_content.length === 0),
25+
)
26+
}

src/api/providers/zai.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import {
1313

1414
import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/api"
1515
import { convertToZAiFormat } from "../transform/zai-format"
16+
import { historyHasToolCallsWithoutReasoning } from "./utils/reasoning-history-guard"
1617

1718
import type { ApiHandlerCreateMessageMetadata } from "../index"
1819
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
1920
import { handleOpenAIError } from "./utils/error-handler"
21+
import { logger } from "../../utils/logging"
2022

2123
// Custom interface for Z.ai params to support thinking mode and reasoning effort tiers.
2224
// Z.ai accepts the standard `reasoning_effort` ladder (none/minimal/low/medium/high/xhigh/max)
@@ -107,6 +109,22 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider<string> {
107109
// Use Z.ai format to preserve reasoning_content and merge post-tool text into tool messages
108110
const convertedMessages = convertToZAiFormat(messages, { mergeToolResultText: true })
109111

112+
// Layer 1 Guard: detect incompatible history (tool_calls without reasoning_content)
113+
// and disable thinking mode for this request to prevent a 400 error from the API.
114+
const hasIncompatibleHistory = historyHasToolCallsWithoutReasoning(convertedMessages)
115+
116+
if (hasIncompatibleHistory) {
117+
logger.warn("provider_reasoning_guard_triggered", {
118+
ctx: "zai",
119+
provider: "zai",
120+
model,
121+
taskId: metadata?.taskId,
122+
})
123+
}
124+
125+
// When incompatible history is detected, force reasoning off regardless of user settings.
126+
const effectiveReasoning = hasIncompatibleHistory ? false : useReasoning
127+
110128
const params: ZAiChatCompletionParams = {
111129
model,
112130
max_tokens,
@@ -115,8 +133,8 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider<string> {
115133
stream: true,
116134
stream_options: { include_usage: true },
117135
// Thinking is ON by default for these models, so explicitly disable it when needed.
118-
thinking: useReasoning ? { type: "enabled" } : { type: "disabled" },
119-
reasoning_effort: reasoningEffort,
136+
thinking: effectiveReasoning ? { type: "enabled" } : { type: "disabled" },
137+
reasoning_effort: effectiveReasoning ? reasoningEffort : undefined,
120138
tools: this.convertToolsForOpenAI(metadata?.tools),
121139
tool_choice: metadata?.tool_choice,
122140
parallel_tool_calls: metadata?.parallelToolCalls ?? true,

src/core/tools/SwitchModeTool.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> {
5656
}
5757

5858
// Switch the mode using shared handler
59-
await task.providerRef.deref()?.handleModeSwitch(mode_slug)
59+
// via: "switch_mode" — explicit tool call
60+
await task.providerRef.deref()?.handleModeSwitch(mode_slug, "switch_mode")
6061

6162
pushToolResult(
6263
`Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${

src/core/webview/ClineProvider.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ import { t } from "../../i18n"
9595
import { buildApiHandler } from "../../api"
9696
import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio"
9797

98+
import { logger } from "../../utils/logging"
99+
import { modeSwitchRisksReasoningIncompatibility } from "../../shared/reasoning-mode-compatibility"
98100
import { ContextProxy } from "../config/ContextProxy"
99101
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
100102
import { CustomModesManager } from "../config/CustomModesManager"
@@ -1481,14 +1483,68 @@ export class ClineProvider
14811483
/**
14821484
* Handle switching to a new mode, including updating the associated API configuration
14831485
* @param newMode The mode to switch to
1486+
* @param via - The origin of the mode switch:
1487+
* "switch_mode" — explicit switch_mode tool call
1488+
* "new_task_delegation" — delegation via new_task
1489+
* "unknown_bypass" — cannot be attributed to explicit tool call (default)
14841490
*/
1485-
public async handleModeSwitch(newMode: Mode) {
1491+
public async handleModeSwitch(newMode: Mode, via: "switch_mode" | "new_task_delegation" | "unknown_bypass" = "unknown_bypass") {
14861492
const task = this.getCurrentTask()
1493+
const fromMode = (await this.getState())?.mode ?? "unknown"
14871494

14881495
if (task) {
14891496
TelemetryService.instance.captureModeSwitch(task.taskId, newMode)
14901497
task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode)
14911498

1499+
// Layer 2 — Orchestration safety: check for reasoning-mode incompatibility
1500+
// when switching modes without proper delegation (only relevant for bypass switches).
1501+
if (via !== "new_task_delegation") {
1502+
try {
1503+
const fromProviderName = task.apiConfiguration?.apiProvider
1504+
const toConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
1505+
const listApiConfig = await this.providerSettingsManager.listConfig()
1506+
const toProfile = toConfigId
1507+
? listApiConfig.find((c) => c.id === toConfigId)
1508+
: undefined
1509+
const toProviderName = toProfile
1510+
? (await this.providerSettingsManager.getProfile({ name: toProfile.name })).apiProvider
1511+
: fromProviderName
1512+
1513+
if (
1514+
toProviderName &&
1515+
modeSwitchRisksReasoningIncompatibility(fromProviderName, toProviderName)
1516+
) {
1517+
// Behavior A — always show visible warning in chat
1518+
await task.say(
1519+
"mode_switch_compatibility_warning",
1520+
JSON.stringify({
1521+
fromMode,
1522+
toMode: newMode,
1523+
fromProvider: fromProviderName,
1524+
toProvider: toProviderName,
1525+
via,
1526+
}),
1527+
undefined,
1528+
undefined,
1529+
undefined,
1530+
undefined,
1531+
{ isNonInteractive: true },
1532+
)
1533+
1534+
// Behavior B — optional auto-condense when setting is enabled
1535+
const autoCondense = this.context.workspaceState.get("autoCondenseOnRiskyModeSwitch", false)
1536+
if (autoCondense) {
1537+
await task.condenseContext()
1538+
}
1539+
}
1540+
} catch (innerError) {
1541+
// Non-fatal: log but don't block the mode switch if the check fails.
1542+
this.log(
1543+
\`Mode-switch compatibility check failed: ${innerError instanceof Error ? innerError.message : String(innerError)}\`,
1544+
)
1545+
}
1546+
}
1547+
14921548
try {
14931549
// Update the task history with the new mode first.
14941550
const taskHistoryItem =

0 commit comments

Comments
 (0)