Skip to content
Draft
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
11 changes: 8 additions & 3 deletions src/api/providers/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
181 changes: 181 additions & 0 deletions src/api/transform/__tests__/r1-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<environment_details>Context</environment_details>",
},
],
},
]

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\n<environment_details>Context</environment_details>",
})
})

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("")
})
})
})
})
39 changes: 30 additions & 9 deletions src/api/transform/r1-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand Down Expand Up @@ -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)
Expand All @@ -208,31 +220,40 @@ 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") {
lastMessage.content += `\n${message.content}`
} 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)
}
Expand Down
Loading