Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 26 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 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
33 changes: 29 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,11 @@ 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".
extractedReasoning = (part as any).text
} // assistant cannot send tool_result messages
return acc
},
Expand Down Expand Up @@ -489,14 +504,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 +524,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