From a32e2f0d5da2aa663cce7b0e25c02294ca133486 Mon Sep 17 00:00:00 2001 From: Mai Tan Loi Date: Sat, 29 Aug 2026 14:24:15 +0700 Subject: [PATCH] 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. --- .../providers/openai_extra_headers_test.go | 53 +++++++++++++++++++ internal/providers/openai_request.go | 19 ++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal/providers/openai_extra_headers_test.go b/internal/providers/openai_extra_headers_test.go index d46593ed..223afa9c 100644 --- a/internal/providers/openai_extra_headers_test.go +++ b/internal/providers/openai_extra_headers_test.go @@ -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") + } +} diff --git a/internal/providers/openai_request.go b/internal/providers/openai_request.go index 8cfa6172..55a3eab0 100644 --- a/internal/providers/openai_request.go +++ b/internal/providers/openai_request.go @@ -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"] = "" }