mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
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:
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")
|
||||
}
|
||||
}
|
||||
@@ -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"] = ""
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user