Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/vscode-e2e/fixtures/deepseek-v4.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"sequenceIndex": 0
},
"response": {
"content": "",
"reasoning": "I should read the file to find the marker.",
"toolCalls": [
{
"name": "read_file",
Expand Down Expand Up @@ -69,6 +71,8 @@
"sequenceIndex": 0
},
"response": {
"content": "",
"reasoning": "I should read the file to find the marker.",
"toolCalls": [
{
"name": "read_file",
Expand Down
38 changes: 37 additions & 1 deletion apps/vscode-e2e/src/suite/providers/deepseek-v4.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type CapturedDeepSeekRequest = {
maxCompletionTokens?: number
probeTag?: string
lastUserMessage: string
/** True if any assistant message in the conversation history has a non-empty reasoning_content field. */
hasReasoningContentInHistory: boolean
}

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

const hasReasoningContentInHistory = (body.messages ?? []).some(
(message) =>
message.role === "assistant" &&
typeof message.reasoning_content === "string" &&
message.reasoning_content.length > 0,
)

const request = {
model: body.model,
thinkingType: body.thinking?.type,
reasoningEffort: body.reasoning_effort,
maxCompletionTokens: body.max_completion_tokens,
probeTag,
lastUserMessage,
hasReasoningContentInHistory,
} satisfies CapturedDeepSeekRequest

capture.push(request)
Expand Down Expand Up @@ -123,6 +133,7 @@ function formatDiagnostics(result: DeepSeekProbeResult) {
thinkingType: request.thinkingType,
reasoningEffort: request.reasoningEffort,
maxCompletionTokens: request.maxCompletionTokens,
hasReasoningContentInHistory: request.hasReasoningContentInHistory,
probeTag: request.probeTag,
lastUserMessage: request.lastUserMessage.slice(0, 160),
}
Expand Down Expand Up @@ -368,6 +379,20 @@ suite("DeepSeek V4 provider", function () {
firstRequest.reasoningEffort === "high" || firstRequest.reasoningEffort === "max",
`Reasoning-enabled probe should send a DeepSeek reasoning_effort.\n${diagnostics}`,
)

// Verify that reasoning_content from turn 1 is round-tripped in the turn 2 request.
// DeepSeek's API spec requires reasoning_content to be passed back when thinking mode
// is active — omitting it may cause a 400 error depending on model version (issue #201).
const secondRequest = result.requests[1]
assert.ok(
secondRequest,
`Reasoning-enabled probe should issue a second request (after tool call).\n${diagnostics}`,
)
assert.ok(
secondRequest.hasReasoningContentInHistory,
`Turn 2 request must include reasoning_content on the assistant message from turn 1 ` +
`(required by DeepSeek API spec when thinking mode is active — issue #201).\n${diagnostics}`,
)
} else {
assert.strictEqual(
firstRequest.thinkingType,
Expand All @@ -379,6 +404,17 @@ suite("DeepSeek V4 provider", function () {
undefined,
`Reasoning-disabled probe should omit reasoning_effort.\n${diagnostics}`,
)

// Negative guard: reasoning-off requests must never carry reasoning_content,
// which would indicate the capture flag itself is broken.
const secondRequestOff = result.requests[1]
if (secondRequestOff) {
assert.strictEqual(
secondRequestOff.hasReasoningContentInHistory,
false,
`Turn 2 request must NOT include reasoning_content when thinking is disabled.\n${diagnostics}`,
)
}
}

assert.ok(result.completed, `Task should complete cleanly.\n${diagnostics}`)
Expand Down
39 changes: 39 additions & 0 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,45 @@ describe("OpenAiHandler", () => {
])
})
})

it("should include reasoning_content on assistant history messages when preserveReasoning is set", async () => {
// Regression guard for issue #201: OpenAI-compatible providers (e.g. DeepSeek via custom
// base URL) must pass reasoning_content back in history when thinking mode is active.
// This exercises OpenAiHandler -> convertToOpenAiMessages directly.
const thinkingHandler = new OpenAiHandler({
...mockOptions,
openAiCustomModelInfo: {
contextWindow: 128_000,
supportsPromptCache: false,
preserveReasoning: true,
},
})

const messagesWithReasoning: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "What files are in the project?" },
{
role: "assistant",
content: [
{ type: "reasoning", text: "I should use the read_file tool.", summary: [] } as any,
{ type: "tool_use", id: "call_001", name: "read_file", input: { path: "README.md" } },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "call_001", content: "# Project\nHello." }],
},
]

const stream = thinkingHandler.createMessage(systemPrompt, messagesWithReasoning)
for await (const _chunk of stream) {
}

expect(mockCreate).toHaveBeenCalled()
const sentMessages: any[] = mockCreate.mock.calls[0][0].messages
const assistantMsg = sentMessages.find((m: any) => m.role === "assistant" && m.tool_calls?.length)
expect(assistantMsg).toBeDefined()
expect(assistantMsg.reasoning_content).toBe("I should use the read_file tool.")
})
})

describe("error handling", () => {
Expand Down
103 changes: 103 additions & 0 deletions src/api/transform/__tests__/openai-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,109 @@ describe("convertToOpenAiMessages", () => {
expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data")
})
})

describe("reasoning_content round-trip for DeepSeek / Z.ai thinking mode", () => {
it("should pass through top-level reasoning_content on assistant messages", () => {
const anthropicMessages = [
{
role: "assistant" as const,
content: "Here is my answer.",
reasoning_content: "Let me think about this carefully...",
},
] as any as Anthropic.Messages.MessageParam[]

const result = convertToOpenAiMessages(anthropicMessages)

expect(result).toHaveLength(1)
expect((result[0] as any).reasoning_content).toBe("Let me think about this carefully...")
})

it("should extract reasoning_content from reasoning content block", () => {
// buildCleanConversationHistory stores reasoning as a content block when preserveReasoning=true
const anthropicMessages = [
{
role: "assistant" as const,
content: [
{ type: "reasoning", text: "Let me think...", summary: [] },
{ type: "text", text: "My answer." },
],
},
] as any as Anthropic.Messages.MessageParam[]

const result = convertToOpenAiMessages(anthropicMessages)

expect(result).toHaveLength(1)
const msg = result[0] as any
expect(msg.reasoning_content).toBe("Let me think...")
expect(msg.content).toBe("My answer.")
})

it("should extract reasoning_content from reasoning block alongside tool calls", () => {
// The critical case: DeepSeek thinking + tool call in the same turn.
// Without reasoning_content on the second request, DeepSeek returns 400:
// "The reasoning_content in the thinking mode must be passed back to the API."
const anthropicMessages = [
{
role: "assistant" as const,
content: [
{ type: "reasoning", text: "I need to read a file.", summary: [] },
{
type: "tool_use",
id: "call_abc",
name: "read_file",
input: { path: "foo.txt" },
},
],
},
] as any as Anthropic.Messages.MessageParam[]

const result = convertToOpenAiMessages(anthropicMessages)

expect(result).toHaveLength(1)
const msg = result[0] as any
expect(msg.reasoning_content).toBe("I need to read a file.")
expect(msg.tool_calls).toHaveLength(1)
expect(msg.tool_calls[0].id).toBe("call_abc")
})

it("should prefer top-level reasoning_content over content block", () => {
const anthropicMessages = [
{
role: "assistant" as const,
content: [
{ type: "reasoning", text: "block reasoning", summary: [] },
{ type: "text", text: "answer" },
],
reasoning_content: "top-level reasoning",
},
] as any as Anthropic.Messages.MessageParam[]

const result = convertToOpenAiMessages(anthropicMessages)

expect((result[0] as any).reasoning_content).toBe("top-level reasoning")
})

it("should not set reasoning_content when there is none", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call_abc",
name: "read_file",
input: { path: "foo.txt" },
},
],
},
]

const result = convertToOpenAiMessages(anthropicMessages)

expect(result).toHaveLength(1)
expect((result[0] as any).reasoning_content).toBeUndefined()
})
})
})

describe("consolidateReasoningDetails", () => {
Expand Down
36 changes: 32 additions & 4 deletions src/api/transform/openai-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,10 @@ export function convertToOpenAiMessages(
// If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.),
// we must preserve it here as well.
const messageWithDetails = anthropicMessage as any
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = {
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & {
reasoning_details?: any[]
reasoning_content?: string
} = {
role: anthropicMessage.role,
content: anthropicMessage.content,
}
Expand All @@ -317,6 +320,10 @@ export function convertToOpenAiMessages(
if (mapped) {
;(baseMessage as any).reasoning_details = mapped
}
// Pass through reasoning_content for DeepSeek / Z.ai thinking mode.
if (typeof messageWithDetails.reasoning_content === "string" && messageWithDetails.reasoning_content) {
baseMessage.reasoning_content = messageWithDetails.reasoning_content
}
}

openAiMessages.push(baseMessage)
Expand Down Expand Up @@ -450,6 +457,9 @@ export function convertToOpenAiMessages(
}
}
} else if (anthropicMessage.role === "assistant") {
const messageWithDetails = anthropicMessage as any

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not a big fan of this any here... maybe we should better define the type here? It's not really a blocker, but something to consider.


let extractedReasoning: string | undefined
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
Expand All @@ -459,6 +469,14 @@ export function convertToOpenAiMessages(
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} else if ((part as any).type === "reasoning" && (part as any).text) {
// Extract reasoning stored as a content block (DeepSeek / Z.ai interleaved thinking).
// Must be passed back as top-level reasoning_content so providers like DeepSeek
// don't reject the request with "reasoning_content must be passed back to the API".
// Accumulate all blocks (a turn may have more than one) to preserve order.
extractedReasoning = extractedReasoning
? extractedReasoning + (part as any).text
: (part as any).text
} // assistant cannot send tool_result messages
return acc
},
Expand Down Expand Up @@ -489,14 +507,12 @@ export function convertToOpenAiMessages(
},
}))

// Check if the message has reasoning_details (used by Gemini 3, xAI, etc.)
const messageWithDetails = anthropicMessage as any

// Build message with reasoning_details BEFORE tool_calls to preserve
// the order expected by providers like Roo. Property order matters
// when sending messages back to some APIs.
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & {
reasoning_details?: any[]
reasoning_content?: string
} = {
role: "assistant",
// Use empty string instead of undefined for providers like Gemini (via OpenRouter)
Expand All @@ -511,6 +527,18 @@ export function convertToOpenAiMessages(
baseMessage.reasoning_details = mapped
}

// Pass through reasoning_content for providers that require it in history
// (e.g. DeepSeek thinking mode: "reasoning_content must be passed back to the API").
// Prefer top-level field (already round-tripped); fall back to reasoning from content blocks.
const outgoingReasoningContent: string | undefined =
(typeof messageWithDetails.reasoning_content === "string" &&
messageWithDetails.reasoning_content.length > 0
? messageWithDetails.reasoning_content
: undefined) ?? extractedReasoning
if (outgoingReasoningContent) {
baseMessage.reasoning_content = outgoingReasoningContent
}

// Add tool_calls after reasoning_details
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
if (tool_calls.length > 0) {
Expand Down
Loading