mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
fix: add empty reasoning_content for DeepSeek when switching providers
When switching from another provider to DeepSeek reasoner mid-conversation, the DeepSeek API requires all assistant messages to have a reasoning_content field (even if empty). Previously, messages from other providers that lacked this field would cause a 400 error. This fix adds a new addEmptyReasoning option to convertToR1Format() that adds an empty reasoning_content string to assistant messages that do not already have reasoning content. The option is enabled for DeepSeek thinking models (deepseek-reasoner). Closes #10171
This commit is contained in:
parent
45dbe4d028
commit
d248301527
3 changed files with 219 additions and 12 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue