Skip to content

Commit 8e76b8d

Browse files
fix(deepseek): round-trip reasoning_content in thinking mode to prevent 400 errors (#775)
* fix(deepseek): reasoning_content handler across deepseek model providers * fix(openai-format): accumulate all parts * fix(openai-format): update types for deepseek * test(openai-format): cover getReasoningBlockText non-object guard Add a regression test that passes a non-object content part (a stray string) through convertToOpenAiMessages, exercising the defensive early-return in getReasoningBlockText (line 276). This was the only uncovered line in the PR patch, causing the codecov/patch check to fail. --------- Co-authored-by: Naved Merchant <naved.merchant@gmail.com>
1 parent 1c728e7 commit 8e76b8d

5 files changed

Lines changed: 311 additions & 10 deletions

File tree

apps/vscode-e2e/fixtures/deepseek-v4.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"sequenceIndex": 0
88
},
99
"response": {
10+
"content": "",
11+
"reasoning": "I should read the file to find the marker.",
1012
"toolCalls": [
1113
{
1214
"name": "read_file",
@@ -69,6 +71,8 @@
6971
"sequenceIndex": 0
7072
},
7173
"response": {
74+
"content": "",
75+
"reasoning": "I should read the file to find the marker.",
7276
"toolCalls": [
7377
{
7478
"name": "read_file",

apps/vscode-e2e/src/suite/providers/deepseek-v4.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type CapturedDeepSeekRequest = {
1919
maxCompletionTokens?: number
2020
probeTag?: string
2121
lastUserMessage: string
22+
/** True if any assistant message in the conversation history has a non-empty reasoning_content field. */
23+
hasReasoningContentInHistory: boolean
2224
}
2325

2426
type DeepSeekProbeResult = {
@@ -57,7 +59,7 @@ function getRequestBody(init?: RequestInit):
5759
thinking?: { type?: "enabled" | "disabled" }
5860
reasoning_effort?: string
5961
max_completion_tokens?: number
60-
messages?: Array<{ role?: string; content?: unknown }>
62+
messages?: Array<{ role?: string; content?: unknown; reasoning_content?: string }>
6163
}
6264
| undefined {
6365
if (!init?.body || typeof init.body !== "string") {
@@ -83,13 +85,21 @@ function installDeepSeekRequestCapture(capture: CapturedDeepSeekRequest[], baseU
8385
const allMessagesText = JSON.stringify(body.messages ?? [])
8486
const probeTag = allMessagesText.match(/deepseek-v4-e2e:[^"\s]+/)?.[0]
8587

88+
const hasReasoningContentInHistory = (body.messages ?? []).some(
89+
(message) =>
90+
message.role === "assistant" &&
91+
typeof message.reasoning_content === "string" &&
92+
message.reasoning_content.length > 0,
93+
)
94+
8695
const request = {
8796
model: body.model,
8897
thinkingType: body.thinking?.type,
8998
reasoningEffort: body.reasoning_effort,
9099
maxCompletionTokens: body.max_completion_tokens,
91100
probeTag,
92101
lastUserMessage,
102+
hasReasoningContentInHistory,
93103
} satisfies CapturedDeepSeekRequest
94104

95105
capture.push(request)
@@ -123,6 +133,7 @@ function formatDiagnostics(result: DeepSeekProbeResult) {
123133
thinkingType: request.thinkingType,
124134
reasoningEffort: request.reasoningEffort,
125135
maxCompletionTokens: request.maxCompletionTokens,
136+
hasReasoningContentInHistory: request.hasReasoningContentInHistory,
126137
probeTag: request.probeTag,
127138
lastUserMessage: request.lastUserMessage.slice(0, 160),
128139
}
@@ -368,6 +379,20 @@ suite("DeepSeek V4 provider", function () {
368379
firstRequest.reasoningEffort === "high" || firstRequest.reasoningEffort === "max",
369380
`Reasoning-enabled probe should send a DeepSeek reasoning_effort.\n${diagnostics}`,
370381
)
382+
383+
// Verify that reasoning_content from turn 1 is round-tripped in the turn 2 request.
384+
// DeepSeek's API spec requires reasoning_content to be passed back when thinking mode
385+
// is active — omitting it may cause a 400 error depending on model version (issue #201).
386+
const secondRequest = result.requests[1]
387+
assert.ok(
388+
secondRequest,
389+
`Reasoning-enabled probe should issue a second request (after tool call).\n${diagnostics}`,
390+
)
391+
assert.ok(
392+
secondRequest.hasReasoningContentInHistory,
393+
`Turn 2 request must include reasoning_content on the assistant message from turn 1 ` +
394+
`(required by DeepSeek API spec when thinking mode is active — issue #201).\n${diagnostics}`,
395+
)
371396
} else {
372397
assert.strictEqual(
373398
firstRequest.thinkingType,
@@ -379,6 +404,17 @@ suite("DeepSeek V4 provider", function () {
379404
undefined,
380405
`Reasoning-disabled probe should omit reasoning_effort.\n${diagnostics}`,
381406
)
407+
408+
// Negative guard: reasoning-off requests must never carry reasoning_content,
409+
// which would indicate the capture flag itself is broken.
410+
const secondRequestOff = result.requests[1]
411+
if (secondRequestOff) {
412+
assert.strictEqual(
413+
secondRequestOff.hasReasoningContentInHistory,
414+
false,
415+
`Turn 2 request must NOT include reasoning_content when thinking is disabled.\n${diagnostics}`,
416+
)
417+
}
382418
}
383419

384420
assert.ok(result.completed, `Task should complete cleanly.\n${diagnostics}`)

src/api/providers/__tests__/openai.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,45 @@ describe("OpenAiHandler", () => {
805805
])
806806
})
807807
})
808+
809+
it("should include reasoning_content on assistant history messages when preserveReasoning is set", async () => {
810+
// Regression guard for issue #201: OpenAI-compatible providers (e.g. DeepSeek via custom
811+
// base URL) must pass reasoning_content back in history when thinking mode is active.
812+
// This exercises OpenAiHandler -> convertToOpenAiMessages directly.
813+
const thinkingHandler = new OpenAiHandler({
814+
...mockOptions,
815+
openAiCustomModelInfo: {
816+
contextWindow: 128_000,
817+
supportsPromptCache: false,
818+
preserveReasoning: true,
819+
},
820+
})
821+
822+
const messagesWithReasoning: Anthropic.Messages.MessageParam[] = [
823+
{ role: "user", content: "What files are in the project?" },
824+
{
825+
role: "assistant",
826+
content: [
827+
{ type: "reasoning", text: "I should use the read_file tool.", summary: [] } as any,
828+
{ type: "tool_use", id: "call_001", name: "read_file", input: { path: "README.md" } },
829+
],
830+
},
831+
{
832+
role: "user",
833+
content: [{ type: "tool_result", tool_use_id: "call_001", content: "# Project\nHello." }],
834+
},
835+
]
836+
837+
const stream = thinkingHandler.createMessage(systemPrompt, messagesWithReasoning)
838+
for await (const _chunk of stream) {
839+
}
840+
841+
expect(mockCreate).toHaveBeenCalled()
842+
const sentMessages: any[] = mockCreate.mock.calls[0][0].messages
843+
const assistantMsg = sentMessages.find((m: any) => m.role === "assistant" && m.tool_calls?.length)
844+
expect(assistantMsg).toBeDefined()
845+
expect(assistantMsg.reasoning_content).toBe("I should use the read_file tool.")
846+
})
808847
})
809848

810849
describe("error handling", () => {

src/api/transform/__tests__/openai-format.spec.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,157 @@ describe("convertToOpenAiMessages", () => {
10841084
expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data")
10851085
})
10861086
})
1087+
1088+
describe("reasoning_content round-trip for DeepSeek / Z.ai thinking mode", () => {
1089+
it("should pass through top-level reasoning_content on assistant messages", () => {
1090+
const anthropicMessages = [
1091+
{
1092+
role: "assistant" as const,
1093+
content: "Here is my answer.",
1094+
reasoning_content: "Let me think about this carefully...",
1095+
},
1096+
] as any as Anthropic.Messages.MessageParam[]
1097+
1098+
const result = convertToOpenAiMessages(anthropicMessages)
1099+
1100+
expect(result).toHaveLength(1)
1101+
expect((result[0] as any).reasoning_content).toBe("Let me think about this carefully...")
1102+
})
1103+
1104+
it("should extract reasoning_content from reasoning content block", () => {
1105+
// buildCleanConversationHistory stores reasoning as a content block when preserveReasoning=true
1106+
const anthropicMessages = [
1107+
{
1108+
role: "assistant" as const,
1109+
content: [
1110+
{ type: "reasoning", text: "Let me think...", summary: [] },
1111+
{ type: "text", text: "My answer." },
1112+
],
1113+
},
1114+
] as any as Anthropic.Messages.MessageParam[]
1115+
1116+
const result = convertToOpenAiMessages(anthropicMessages)
1117+
1118+
expect(result).toHaveLength(1)
1119+
const msg = result[0] as any
1120+
expect(msg.reasoning_content).toBe("Let me think...")
1121+
expect(msg.content).toBe("My answer.")
1122+
})
1123+
1124+
it("should extract reasoning_content from reasoning block alongside tool calls", () => {
1125+
// The critical case: DeepSeek thinking + tool call in the same turn.
1126+
// Without reasoning_content on the second request, DeepSeek returns 400:
1127+
// "The reasoning_content in the thinking mode must be passed back to the API."
1128+
const anthropicMessages = [
1129+
{
1130+
role: "assistant" as const,
1131+
content: [
1132+
{ type: "reasoning", text: "I need to read a file.", summary: [] },
1133+
{
1134+
type: "tool_use",
1135+
id: "call_abc",
1136+
name: "read_file",
1137+
input: { path: "foo.txt" },
1138+
},
1139+
],
1140+
},
1141+
] as any as Anthropic.Messages.MessageParam[]
1142+
1143+
const result = convertToOpenAiMessages(anthropicMessages)
1144+
1145+
expect(result).toHaveLength(1)
1146+
const msg = result[0] as any
1147+
expect(msg.reasoning_content).toBe("I need to read a file.")
1148+
expect(msg.tool_calls).toHaveLength(1)
1149+
expect(msg.tool_calls[0].id).toBe("call_abc")
1150+
})
1151+
1152+
it("should accumulate multiple reasoning blocks in order, separated by a tool call", () => {
1153+
// DeepSeek / Z.ai interleaved thinking can emit more than one reasoning block per
1154+
// turn. A regression that overwrites (instead of accumulates) would silently drop
1155+
// all but the last block.
1156+
const anthropicMessages = [
1157+
{
1158+
role: "assistant" as const,
1159+
content: [
1160+
{ type: "reasoning", text: "First, I should check the file.", summary: [] },
1161+
{
1162+
type: "tool_use",
1163+
id: "call_abc",
1164+
name: "read_file",
1165+
input: { path: "foo.txt" },
1166+
},
1167+
{ type: "reasoning", text: "Now I know what to do next.", summary: [] },
1168+
],
1169+
},
1170+
] as any as Anthropic.Messages.MessageParam[]
1171+
1172+
const result = convertToOpenAiMessages(anthropicMessages)
1173+
1174+
expect(result).toHaveLength(1)
1175+
const msg = result[0] as any
1176+
expect(msg.reasoning_content).toBe("First, I should check the file.Now I know what to do next.")
1177+
expect(msg.tool_calls).toHaveLength(1)
1178+
expect(msg.tool_calls[0].id).toBe("call_abc")
1179+
})
1180+
1181+
it("should prefer top-level reasoning_content over content block", () => {
1182+
const anthropicMessages = [
1183+
{
1184+
role: "assistant" as const,
1185+
content: [
1186+
{ type: "reasoning", text: "block reasoning", summary: [] },
1187+
{ type: "text", text: "answer" },
1188+
],
1189+
reasoning_content: "top-level reasoning",
1190+
},
1191+
] as any as Anthropic.Messages.MessageParam[]
1192+
1193+
const result = convertToOpenAiMessages(anthropicMessages)
1194+
1195+
expect((result[0] as any).reasoning_content).toBe("top-level reasoning")
1196+
})
1197+
1198+
it("should not set reasoning_content when there is none", () => {
1199+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
1200+
{
1201+
role: "assistant",
1202+
content: [
1203+
{
1204+
type: "tool_use",
1205+
id: "call_abc",
1206+
name: "read_file",
1207+
input: { path: "foo.txt" },
1208+
},
1209+
],
1210+
},
1211+
]
1212+
1213+
const result = convertToOpenAiMessages(anthropicMessages)
1214+
1215+
expect(result).toHaveLength(1)
1216+
expect((result[0] as any).reasoning_content).toBeUndefined()
1217+
})
1218+
1219+
it("should ignore non-object content parts without crashing (defensive guard)", () => {
1220+
// getReasoningBlockText guards against non-object parts (e.g. a stray
1221+
// string in the content array). Such parts are not reasoning blocks and
1222+
// must be skipped rather than crashing or being misread as reasoning.
1223+
const anthropicMessages = [
1224+
{
1225+
role: "assistant" as const,
1226+
content: ["not-a-block" as any, { type: "text", text: "answer" }],
1227+
},
1228+
] as any as Anthropic.Messages.MessageParam[]
1229+
1230+
const result = convertToOpenAiMessages(anthropicMessages)
1231+
1232+
expect(result).toHaveLength(1)
1233+
const msg = result[0] as any
1234+
expect(msg.content).toBe("answer")
1235+
expect(msg.reasoning_content).toBeUndefined()
1236+
})
1237+
})
10871238
})
10881239

10891240
describe("consolidateReasoningDetails", () => {

0 commit comments

Comments
 (0)