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"] = "" }