diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts
index 4e5aef23a53..4929b787493 100644
--- a/src/api/providers/deepseek.ts
+++ b/src/api/providers/deepseek.ts
@@ -54,12 +54,17 @@ export class DeepSeekHandler extends OpenAiHandler {
// Convert messages to R1 format (merges consecutive same-role messages)
// This is required for DeepSeek which does not support successive messages with the same role
- // For thinking models (deepseek-reasoner), enable mergeToolResultText to preserve reasoning_content
- // during tool call sequences. Without this, environment_details text after tool_results would
- // create user messages that cause DeepSeek to drop all previous reasoning_content.
+ // For thinking models (deepseek-reasoner):
+ // - Enable mergeToolResultText to preserve reasoning_content during tool call sequences.
+ // Without this, environment_details text after tool_results would create user messages
+ // that cause DeepSeek to drop all previous reasoning_content.
+ // - Enable addEmptyReasoning to add empty reasoning_content ("") to assistant messages
+ // that don't have reasoning. This is required when switching providers mid-conversation,
+ // as DeepSeek's API requires all assistant messages to have reasoning_content in thinking mode.
// See: https://api-docs.deepseek.com/guides/thinking_mode
const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], {
mergeToolResultText: isThinkingModel,
+ addEmptyReasoning: isThinkingModel,
})
const requestOptions: DeepSeekChatCompletionParams = {
diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts
index 3d875e9392f..a228723360c 100644
--- a/src/api/transform/__tests__/r1-format.spec.ts
+++ b/src/api/transform/__tests__/r1-format.spec.ts
@@ -615,5 +615,186 @@ describe("convertToR1Format", () => {
expect(result.filter((m) => m.role === "user")).toHaveLength(1)
})
})
+
+ describe("addEmptyReasoning option for provider switching compatibility", () => {
+ it("should add empty reasoning_content to assistant messages without reasoning when addEmptyReasoning is true", () => {
+ const input: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "Hi there" },
+ { role: "user", content: "How are you?" },
+ { role: "assistant", content: "I'm doing well" },
+ ]
+
+ const result = convertToR1Format(input, { addEmptyReasoning: true })
+
+ expect(result).toHaveLength(4)
+ // All assistant messages should have reasoning_content (empty string)
+ expect((result[1] as any).reasoning_content).toBe("")
+ expect((result[3] as any).reasoning_content).toBe("")
+ })
+
+ it("should NOT add empty reasoning_content when addEmptyReasoning is false (default)", () => {
+ const input: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "Hello" },
+ { role: "assistant", content: "Hi there" },
+ ]
+
+ const result = convertToR1Format(input)
+
+ expect(result).toHaveLength(2)
+ // Assistant message should NOT have reasoning_content field
+ expect((result[1] as any).reasoning_content).toBeUndefined()
+ })
+
+ it("should preserve existing reasoning_content when addEmptyReasoning is true", () => {
+ const input = [
+ { role: "user" as const, content: "Hello" },
+ {
+ role: "assistant" as const,
+ content: "Hi there",
+ reasoning_content: "Let me think...",
+ },
+ ]
+
+ const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], {
+ addEmptyReasoning: true,
+ })
+
+ expect(result).toHaveLength(2)
+ // Should preserve the existing reasoning_content, not replace with empty
+ expect((result[1] as any).reasoning_content).toBe("Let me think...")
+ })
+
+ it("should add empty reasoning_content to assistant messages with array content", () => {
+ const input: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "Hello" },
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "Hi there" }],
+ },
+ ]
+
+ const result = convertToR1Format(input, { addEmptyReasoning: true })
+
+ expect(result).toHaveLength(2)
+ expect((result[1] as any).reasoning_content).toBe("")
+ })
+
+ it("should add empty reasoning_content to assistant messages with tool_calls", () => {
+ const input: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "What's the weather?" },
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "Let me check." },
+ {
+ type: "tool_use",
+ id: "call_123",
+ name: "get_weather",
+ input: { location: "SF" },
+ },
+ ],
+ },
+ ]
+
+ const result = convertToR1Format(input, { addEmptyReasoning: true })
+
+ expect(result).toHaveLength(2)
+ expect((result[1] as any).reasoning_content).toBe("")
+ expect((result[1] as any).tool_calls).toHaveLength(1)
+ })
+
+ it("should handle provider switch scenario: messages from OpenAI now sent to DeepSeek", () => {
+ // Simulates a conversation that started with OpenAI (no reasoning_content)
+ // and is now being sent to DeepSeek reasoner (requires reasoning_content on all assistant messages)
+ const input: Anthropic.Messages.MessageParam[] = [
+ { role: "user", content: "System prompt here" },
+ { role: "user", content: "Write a hello world program" },
+ {
+ role: "assistant",
+ content: "Here's a hello world program:\n```python\nprint('Hello, World!')\n```",
+ },
+ { role: "user", content: "Now modify it to say goodbye" },
+ ]
+
+ const result = convertToR1Format(input, { addEmptyReasoning: true })
+
+ // Should have merged consecutive user messages
+ expect(result).toHaveLength(3)
+ expect(result[0].role).toBe("user")
+ expect(result[1].role).toBe("assistant")
+ expect(result[2].role).toBe("user")
+
+ // The assistant message from OpenAI should now have empty reasoning_content
+ expect((result[1] as any).reasoning_content).toBe("")
+ })
+
+ it("should work with both mergeToolResultText and addEmptyReasoning options", () => {
+ const input = [
+ { role: "user" as const, content: "Start" },
+ {
+ role: "assistant" as const,
+ content: [
+ {
+ type: "tool_use" as const,
+ id: "call_123",
+ name: "test_tool",
+ input: {},
+ },
+ ],
+ },
+ {
+ role: "user" as const,
+ content: [
+ {
+ type: "tool_result" as const,
+ tool_use_id: "call_123",
+ content: "Result",
+ },
+ {
+ type: "text" as const,
+ text: "Context",
+ },
+ ],
+ },
+ ]
+
+ const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], {
+ mergeToolResultText: true,
+ addEmptyReasoning: true,
+ })
+
+ // Should have: user, assistant (with empty reasoning + tool_calls), tool
+ expect(result).toHaveLength(3)
+ expect(result[0]).toEqual({ role: "user", content: "Start" })
+
+ // Assistant message should have empty reasoning_content and tool_calls
+ expect((result[1] as any).reasoning_content).toBe("")
+ expect((result[1] as any).tool_calls).toBeDefined()
+
+ // Tool message should have merged content
+ expect(result[2]).toEqual({
+ role: "tool",
+ tool_call_id: "call_123",
+ content: "Result\n\nContext",
+ })
+ })
+
+ it("should handle merged assistant messages with addEmptyReasoning", () => {
+ const input: Anthropic.Messages.MessageParam[] = [
+ { role: "assistant", content: "First part" },
+ { role: "assistant", content: "Second part" },
+ ]
+
+ const result = convertToR1Format(input, { addEmptyReasoning: true })
+
+ // Should merge into one assistant message
+ expect(result).toHaveLength(1)
+ expect(result[0].role).toBe("assistant")
+ expect(result[0].content).toBe("First part\nSecond part")
+ // Should have empty reasoning_content
+ expect((result[0] as any).reasoning_content).toBe("")
+ })
+ })
})
})
diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts
index 8231e24f76f..1252a6bb6e8 100644
--- a/src/api/transform/r1-format.ts
+++ b/src/api/transform/r1-format.ts
@@ -34,11 +34,15 @@ export type DeepSeekAssistantMessage = AssistantMessage & {
* @param options.mergeToolResultText If true, merge text content after tool_results into the last
* tool message instead of creating a separate user message.
* This is critical for DeepSeek's interleaved thinking mode.
+ * @param options.addEmptyReasoning If true, add empty reasoning_content ("") to assistant messages
+ * that don't have reasoning content. This is required for DeepSeek
+ * thinking mode when switching providers mid-conversation, as
+ * DeepSeek's API requires all assistant messages to have reasoning_content.
* @returns Array of OpenAI messages where consecutive messages with the same role are combined
*/
export function convertToR1Format(
messages: AnthropicMessage[],
- options?: { mergeToolResultText?: boolean },
+ options?: { mergeToolResultText?: boolean; addEmptyReasoning?: boolean },
): Message[] {
const result: Message[] = []
@@ -190,12 +194,20 @@ export function convertToR1Format(
// Use reasoning from content blocks if not provided at top level
const finalReasoning = reasoningContent || extractedReasoning
+ // Determine the reasoning_content value:
+ // - Use finalReasoning if it exists
+ // - Use empty string if addEmptyReasoning is enabled and no reasoning exists
+ // - Otherwise undefined (don't include the field)
+ const reasoningValue =
+ finalReasoning !== undefined ? finalReasoning : options?.addEmptyReasoning ? "" : undefined
+
const assistantMessage: DeepSeekAssistantMessage = {
role: "assistant",
content: textParts.length > 0 ? textParts.join("\n") : null,
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
// Preserve reasoning_content for DeepSeek interleaved thinking
- ...(finalReasoning && { reasoning_content: finalReasoning }),
+ // or add empty reasoning_content if addEmptyReasoning option is enabled
+ ...(reasoningValue !== undefined && { reasoning_content: reasoningValue }),
}
// Check if we can merge with the last message (only if no tool calls)
@@ -208,15 +220,23 @@ export function convertToR1Format(
const lastContent = lastMessage.content || ""
lastMessage.content = `${lastContent}\n${assistantMessage.content}`
}
- // Preserve reasoning_content from the new message if present
- if (finalReasoning) {
- ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = finalReasoning
+ // Preserve reasoning_content from the new message if present,
+ // or add empty reasoning if addEmptyReasoning is enabled
+ if (reasoningValue !== undefined) {
+ ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = reasoningValue
}
} else {
result.push(assistantMessage)
}
} else {
// Simple string content
+ // Determine the reasoning_content value for simple string content:
+ // - Use reasoningContent if it exists
+ // - Use empty string if addEmptyReasoning is enabled and no reasoning exists
+ // - Otherwise undefined (don't include the field)
+ const simpleReasoningValue =
+ reasoningContent !== undefined ? reasoningContent : options?.addEmptyReasoning ? "" : undefined
+
const lastMessage = result[result.length - 1]
if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) {
if (typeof lastMessage.content === "string") {
@@ -224,15 +244,16 @@ export function convertToR1Format(
} else {
lastMessage.content = message.content
}
- // Preserve reasoning_content from the new message if present
- if (reasoningContent) {
- ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = reasoningContent
+ // Preserve reasoning_content from the new message if present,
+ // or add empty reasoning if addEmptyReasoning is enabled
+ if (simpleReasoningValue !== undefined) {
+ ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = simpleReasoningValue
}
} else {
const assistantMessage: DeepSeekAssistantMessage = {
role: "assistant",
content: message.content,
- ...(reasoningContent && { reasoning_content: reasoningContent }),
+ ...(simpleReasoningValue !== undefined && { reasoning_content: simpleReasoningValue }),
}
result.push(assistantMessage)
}