Merge pull request #1540 from maitanloi/fix/deepseek-reasoning-content

fix(providers): carry reasoning_content on assistant turns without thinking
This commit is contained in:
Clark Cant authored and GitHub committed 2026-08-29 17:15:17 +07:00
commit 645ab3329a
2 files changed
+70 -2

No files matched your search

@@ -201,3 +201,56 @@ func TestNonKimi_ReasoningContentNotAddedWhenEmpty(t *testing.T) {
t.Error("non-kimi providers must not inject empty reasoning_content; key should be absent")
}
}
// TestDeepSeek_ReasoningContentPresentOnLaterToolCallTurns reproduces upstream
// "The `reasoning_content` in the thinking mode must be passed back to the API."
// — once a DeepSeek turn has emitted reasoning, a later assistant tool-call turn
// that emitted none must still carry the key, or the next request is rejected.
// Trace: 01a04c34-a74b-7be7-973e-2cb66ea68f3d.
func TestDeepSeek_ReasoningContentPresentOnLaterToolCallTurns(t *testing.T) {
p := NewOpenAIProvider("deepseek", "sk", "https://api.deepseek.com/v1", "deepseek-v4-pro").
WithProviderType("deepseek")
body := p.buildRequestBody("deepseek-v4-pro", ChatRequest{
Messages: []Message{
{Role: "user", Content: "thêm khoản thu 300k"},
{Role: "assistant", Thinking: "the user wants an income entry", ToolCalls: []ToolCall{{ID: "call_1", Name: "edit", Arguments: map[string]any{}}}},
{Role: "tool", Content: "old_string not found in file", ToolCallID: "call_1"},
{Role: "assistant", ToolCalls: []ToolCall{{ID: "call_2", Name: "read_file", Arguments: map[string]any{}}}},
{Role: "tool", Content: "# Sổ sách chi tiêu", ToolCallID: "call_2"},
},
}, true)
msgs := body["messages"].([]map[string]any)
if got := msgs[1]["reasoning_content"]; got != "the user wants an income entry" {
t.Errorf("captured reasoning clobbered: got %q", got)
}
rc, present := msgs[3]["reasoning_content"]
if !present {
t.Fatalf("assistant turn without reasoning must still carry reasoning_content; got %v", msgs[3])
}
if rc != "" {
t.Errorf("reasoning_content = %q, want empty string when Thinking unset", rc)
}
}
// TestDeepSeek_ReasoningContentAbsentWithoutThinkingMode is the negative control:
// a conversation that never carried reasoning is not in thinking mode, so the
// empty-string key must not be injected.
func TestDeepSeek_ReasoningContentAbsentWithoutThinkingMode(t *testing.T) {
p := NewOpenAIProvider("deepseek", "sk", "https://api.deepseek.com/v1", "deepseek-chat").
WithProviderType("deepseek")
body := p.buildRequestBody("deepseek-chat", ChatRequest{
Messages: []Message{
{Role: "user", Content: "hi"},
{Role: "assistant", ToolCalls: []ToolCall{{ID: "call_1", Name: "exec", Arguments: map[string]any{}}}},
{Role: "tool", Content: "...", ToolCallID: "call_1"},
},
}, true)
msgs := body["messages"].([]map[string]any)
if _, present := msgs[1]["reasoning_content"]; present {
t.Error("non-thinking conversation must not inject empty reasoning_content")
}
}
+17 -2
View File
@@ -40,6 +40,19 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream
// Matching OpenClaw TS: model-compat.ts → isOpenAINativeEndpoint().
useDevRole := isOpenAINativeEndpoint(p.apiBase)
// A conversation where some assistant turn captured reasoning is running in
// thinking mode, so every replayed assistant message must carry the field —
// turns that produced no reasoning of their own included. DeepSeek rejects
// the gap with HTTP 400 "The `reasoning_content` in the thinking mode must
// be passed back to the API." Trace: 01a04c34-a74b-7be7-973e-2cb66ea68f3d.
historyHasReasoning := false
for _, m := range inputMessages {
if m.Role == "assistant" && m.Thinking != "" {
historyHasReasoning = true
break
}
}
// Convert messages to proper OpenAI wire format.
// This is necessary because our internal Message/ToolCall structs don't match
// the OpenAI API format (tool_calls need type+function wrapper, arguments as JSON string).
@@ -63,12 +76,14 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream
// kimi-k2-turbo-preview), assistant tool-call messages MUST carry
// reasoning_content even if empty — otherwise upstream returns 400 "thinking
// is enabled but reasoning_content is missing in assistant tool call message".
// DeepSeek enforces the same rule, but only once the conversation is in
// thinking mode (historyHasReasoning).
if m.Role == "assistant" && openAIWireAssistantReasoningContent(model) {
switch {
case m.Thinking != "":
msg["reasoning_content"] = m.Thinking
case p.providerType == "kimi_coding":
// Send empty string rather than omit the field — satisfies Kimi's
case p.providerType == "kimi_coding" || historyHasReasoning:
// Send empty string rather than omit the field — satisfies the
// "must be present" check without inventing reasoning content.
msg["reasoning_content"] = ""
}