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) }