Skip to content

Commit 2c2f708

Browse files
committed
fix(deepseek): reasoning_content handler across deepseek model providers
1 parent 67df9f9 commit 2c2f708

5 files changed

Lines changed: 201 additions & 5 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: 26 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,

‎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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,109 @@ 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 prefer top-level reasoning_content over content block", () => {
1153+
const anthropicMessages = [
1154+
{
1155+
role: "assistant" as const,
1156+
content: [
1157+
{ type: "reasoning", text: "block reasoning", summary: [] },
1158+
{ type: "text", text: "answer" },
1159+
],
1160+
reasoning_content: "top-level reasoning",
1161+
},
1162+
] as any as Anthropic.Messages.MessageParam[]
1163+
1164+
const result = convertToOpenAiMessages(anthropicMessages)
1165+
1166+
expect((result[0] as any).reasoning_content).toBe("top-level reasoning")
1167+
})
1168+
1169+
it("should not set reasoning_content when there is none", () => {
1170+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
1171+
{
1172+
role: "assistant",
1173+
content: [
1174+
{
1175+
type: "tool_use",
1176+
id: "call_abc",
1177+
name: "read_file",
1178+
input: { path: "foo.txt" },
1179+
},
1180+
],
1181+
},
1182+
]
1183+
1184+
const result = convertToOpenAiMessages(anthropicMessages)
1185+
1186+
expect(result).toHaveLength(1)
1187+
expect((result[0] as any).reasoning_content).toBeUndefined()
1188+
})
1189+
})
10871190
})
10881191

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

‎src/api/transform/openai-format.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,10 @@ export function convertToOpenAiMessages(
307307
// If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.),
308308
// we must preserve it here as well.
309309
const messageWithDetails = anthropicMessage as any
310-
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = {
310+
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & {
311+
reasoning_details?: any[]
312+
reasoning_content?: string
313+
} = {
311314
role: anthropicMessage.role,
312315
content: anthropicMessage.content,
313316
}
@@ -317,6 +320,10 @@ export function convertToOpenAiMessages(
317320
if (mapped) {
318321
;(baseMessage as any).reasoning_details = mapped
319322
}
323+
// Pass through reasoning_content for DeepSeek / Z.ai thinking mode.
324+
if (typeof messageWithDetails.reasoning_content === "string" && messageWithDetails.reasoning_content) {
325+
baseMessage.reasoning_content = messageWithDetails.reasoning_content
326+
}
320327
}
321328

322329
openAiMessages.push(baseMessage)
@@ -450,6 +457,9 @@ export function convertToOpenAiMessages(
450457
}
451458
}
452459
} else if (anthropicMessage.role === "assistant") {
460+
const messageWithDetails = anthropicMessage as any
461+
462+
let extractedReasoning: string | undefined
453463
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
454464
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
455465
toolMessages: Anthropic.ToolUseBlockParam[]
@@ -459,6 +469,11 @@ export function convertToOpenAiMessages(
459469
acc.toolMessages.push(part)
460470
} else if (part.type === "text" || part.type === "image") {
461471
acc.nonToolMessages.push(part)
472+
} else if ((part as any).type === "reasoning" && (part as any).text) {
473+
// Extract reasoning stored as a content block (DeepSeek / Z.ai interleaved thinking).
474+
// Must be passed back as top-level reasoning_content so providers like DeepSeek
475+
// don't reject the request with "reasoning_content must be passed back to the API".
476+
extractedReasoning = (part as any).text
462477
} // assistant cannot send tool_result messages
463478
return acc
464479
},
@@ -489,14 +504,12 @@ export function convertToOpenAiMessages(
489504
},
490505
}))
491506

492-
// Check if the message has reasoning_details (used by Gemini 3, xAI, etc.)
493-
const messageWithDetails = anthropicMessage as any
494-
495507
// Build message with reasoning_details BEFORE tool_calls to preserve
496508
// the order expected by providers like Roo. Property order matters
497509
// when sending messages back to some APIs.
498510
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & {
499511
reasoning_details?: any[]
512+
reasoning_content?: string
500513
} = {
501514
role: "assistant",
502515
// Use empty string instead of undefined for providers like Gemini (via OpenRouter)
@@ -511,6 +524,18 @@ export function convertToOpenAiMessages(
511524
baseMessage.reasoning_details = mapped
512525
}
513526

527+
// Pass through reasoning_content for providers that require it in history
528+
// (e.g. DeepSeek thinking mode: "reasoning_content must be passed back to the API").
529+
// Prefer top-level field (already round-tripped); fall back to reasoning from content blocks.
530+
const outgoingReasoningContent: string | undefined =
531+
(typeof messageWithDetails.reasoning_content === "string" &&
532+
messageWithDetails.reasoning_content.length > 0
533+
? messageWithDetails.reasoning_content
534+
: undefined) ?? extractedReasoning
535+
if (outgoingReasoningContent) {
536+
baseMessage.reasoning_content = outgoingReasoningContent
537+
}
538+
514539
// Add tool_calls after reasoning_details
515540
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
516541
if (tool_calls.length > 0) {

0 commit comments

Comments
 (0)