fix(providers): carry reasoning_content on assistant turns without thinking

DeepSeek rejects a request whose assistant history omits reasoning_content
once the conversation is in thinking mode:

  HTTP 400: The `reasoning_content` in the thinking mode must be passed
  back to the API.

A thinking-mode turn that only emits tool calls produces no reasoning of
its own, so Message.Thinking is empty and the wire field was dropped. The
next request then mixes assistant messages with and without the field and
upstream fails. Only kimi_coding had an empty-string fallback.

Detect thinking mode from the conversation itself: when any assistant
message carries reasoning, every replayed assistant message must carry the
field, empty string included. Narrower than matching on model name, which
would also inject the key for non-thinking models such as deepseek-chat.

Reproduces from a run where iteration 1 returned reasoning plus an edit
call, the edit failed, iteration 2 returned a bare read_file call, and
iteration 3 was rejected.
This commit is contained in:
Mai Tan Loi committed 2026-08-29 14:24:15 +07:00
1 parent be573f411b
commit a32e2f0d5d
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"] = ""
}