From 901a77cfb1097cc9808ee2f6a041b044d3c278dd Mon Sep 17 00:00:00 2001 From: li Date: Fri, 14 Aug 2026 10:33:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(apicompat):=20Anthropic=E2=86=92Chat=20?= =?UTF-8?q?=E6=A1=A5=E5=9B=9E=E4=BC=A0=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E7=9A=84=20thinking=EF=BC=8C=E4=BF=AE=E5=A4=8D=20DeepSeek=20?= =?UTF-8?q?=E5=A4=9A=E8=BD=AE=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v1/messages 打到只支持 Chat Completions 的 OpenAI 兼容上游时, anthropicAssistantToChatMessages 把历史 assistant 消息里的 thinking 块整块丢弃。 DeepSeek 的 thinking mode 要求产生工具调用的 reasoning_content 随该 assistant 消息回传,缺失即 400 —— 表现为「单轮正常,一进多轮工具对话必现失败」。 这些 thinking 块本来就是同一个文件的出站方向 (chatMessageToAnthropicBlocks) 用上游 reasoning_content 生成的,客户端只是 原样回传:桥自己造出来的东西,回来时被自己丢掉。 兄弟路径 Responses→Chat (buildChatMessagesFromItems 的 pendingReasoning) 早已 处理同一约束。本次把同一策略补到漏掉的这条桥上,作用域严格对齐:reasoning 只挂 到带 tool_calls 的 assistant 消息,纯文本轮次维持现状。 Fixes #5528 --- .../chatcompletions_anthropic_bridge.go | 35 ++- .../chatcompletions_anthropic_bridge_test.go | 5 +- ...tions_anthropic_reasoning_passback_test.go | 229 ++++++++++++++++++ 3 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index 1a62f5620f..47d4601c24 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -249,8 +249,8 @@ func anthropicUserToChatMessages(raw json.RawMessage) ([]ChatMessage, error) { // anthropicAssistantToChatMessages handles an Anthropic assistant message. // Text content → assistant message content; tool_use blocks → tool_calls on the -// same assistant message; thinking blocks are dropped (Chat Completions has no -// inbound thinking field, matching anthropicAssistantToResponses). +// same assistant message; thinking blocks → reasoning_content, but only on a +// message that carries tool calls (see anthropicThinkingToReasoningContent). func anthropicAssistantToChatMessages(raw json.RawMessage) ([]ChatMessage, error) { // Plain string → single assistant message. var s string @@ -289,9 +289,40 @@ func anthropicAssistantToChatMessages(raw json.RawMessage) ([]ChatMessage, error }) } + msg.ReasoningContent = anthropicThinkingToReasoningContent(blocks, len(msg.ToolCalls) > 0) + return []ChatMessage{msg}, nil } +// anthropicThinkingToReasoningContent folds thinking blocks back into the +// Chat Completions reasoning_content field. +// +// chatMessageToAnthropicBlocks emits the upstream's reasoning_content as a +// thinking block on the way out, so a multi-turn client echoes it back on the +// next request; dropping it here made the bridge lose exactly what it had just +// produced. DeepSeek's thinking mode requires the reasoning_content that +// produced a tool call to be replayed on that assistant message and answers +// 400 otherwise, which is why buildChatMessagesFromItems already carries +// pendingReasoning onto assistant tool-call messages in the Responses→Chat +// bridge. hasToolCalls keeps the scope identical to that sibling: reasoning +// rides along with tool calls only, never on a plain assistant text turn. +// +// redacted_thinking blocks and signature-only placeholders carry no plaintext +// and contribute nothing. Multiple blocks join with "\n", matching +// extractResponsesReasoningText. +func anthropicThinkingToReasoningContent(blocks []AnthropicContentBlock, hasToolCalls bool) string { + if !hasToolCalls { + return "" + } + var parts []string + for _, b := range blocks { + if b.Type == "thinking" && b.Thinking != "" { + parts = append(parts, b.Thinking) + } + } + return strings.Join(parts, "\n") +} + // anthropicToolsToChatTools maps Anthropic tool definitions to Chat Completions // function tools. Server-side tools (web_search_*) are dropped — they have no // Chat Completions equivalent. diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index 00726f5ef7..eb979f70e0 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -143,8 +143,11 @@ func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) { out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Len(t, out.Messages, 1) - // Only text survives; thinking is dropped + // Only text survives. Thinking is dropped because this turn carries no tool + // calls — reasoning rides along with tool calls only, matching the + // Responses→Chat bridge (see anthropicThinkingToReasoningContent). require.Equal(t, `"answer"`, string(out.Messages[0].Content)) + require.Empty(t, out.Messages[0].ReasoningContent) } func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go new file mode 100644 index 0000000000..007a5face6 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go @@ -0,0 +1,229 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// issue #5528:/v1/messages 客户端(Claude Code 等)打到只会 Chat Completions 的 +// OpenAI 兼容上游时,历史 assistant 消息里的 thinking 块被整块丢弃。DeepSeek 的 +// thinking mode 要求产生工具调用的 reasoning_content 随该 assistant 消息回传, +// 于是「单轮正常、一进多轮工具对话必现 400」。 + +func anthropicAssistantMsg(t *testing.T, blocks string) *AnthropicRequest { + t.Helper() + return &AnthropicRequest{ + Model: "deepseek-v4-flash", + MaxTokens: 256, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`"what's the weather?"`)}, + {Role: "assistant", Content: json.RawMessage(blocks)}, + {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"}]`)}, + }, + } +} + +const anthropicThinkingToolTurn = `[ + {"type":"thinking","thinking":"user wants weather, call the tool"}, + {"type":"text","text":"checking"}, + {"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}} +]` + +func TestAnthropicToChatCompletionsRequest_ThinkingBecomesReasoningContentOnToolTurn(t *testing.T) { + out, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, anthropicThinkingToolTurn)) + require.NoError(t, err) + + var assistant *ChatMessage + for i := range out.Messages { + if out.Messages[i].Role == "assistant" { + assistant = &out.Messages[i] + break + } + } + require.NotNil(t, assistant, "assistant message must survive the bridge") + require.Equal(t, "user wants weather, call the tool", assistant.ReasoningContent, + "产生工具调用的 thinking 必须作为 reasoning_content 回传,否则 DeepSeek 400") + require.Len(t, assistant.ToolCalls, 1) + require.Equal(t, `"checking"`, string(assistant.Content), "text/tool_use 处理保持不变") +} + +// 上游线格式才是上游看到的东西:字段没序列化出去,等于没修。 +func TestAnthropicToChatCompletionsRequest_ReasoningContentSerializesOnWire(t *testing.T) { + out, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, anthropicThinkingToolTurn)) + require.NoError(t, err) + + payload, err := json.Marshal(out) + require.NoError(t, err) + require.Contains(t, string(payload), `"reasoning_content":"user wants weather, call the tool"`) +} + +// 闭环不变式:thinking 块本来就是本桥出站时用上游 reasoning_content 生成的 +// (chatMessageToAnthropicBlocks),客户端只是原样回传。出站造、入站丢 = 自己丢自己的东西。 +func TestAnthropicChatBridge_ReasoningSurvivesOutboundInboundRoundTrip(t *testing.T) { + upstream := ChatMessage{ + Role: "assistant", + ReasoningContent: "step 1: need the weather tool", + Content: json.RawMessage(`"checking"`), + ToolCalls: []ChatToolCall{{ + ID: "call_1", + Type: "function", + Function: ChatFunctionCall{Name: "get_weather", Arguments: `{"city":"SF"}`}, + }}, + } + + // 出站:Chat 响应 → Anthropic content blocks + blocks := chatMessageToAnthropicBlocks(upstream) + require.Equal(t, "thinking", blocks[0].Type) + require.Equal(t, upstream.ReasoningContent, blocks[0].Thinking) + + // 客户端下一轮把同一组 blocks 原样回传 + raw, err := json.Marshal(blocks) + require.NoError(t, err) + + // 入站:Anthropic content blocks → Chat 请求 + back, err := anthropicAssistantToChatMessages(raw) + require.NoError(t, err) + require.Len(t, back, 1) + require.Equal(t, upstream.ReasoningContent, back[0].ReasoningContent, + "出站生成的 thinking 必须能原样还原回 reasoning_content") + require.Len(t, back[0].ToolCalls, 1) +} + +// 兄弟不变式:Responses→Chat 桥(buildChatMessagesFromItems 的 pendingReasoning) +// 早就把 reasoning 挂到带 tool_calls 的 assistant 消息上了。等价历史下两条桥必须一致。 +func TestAnthropicChatBridge_MatchesResponsesChatBridgeReasoningPlacement(t *testing.T) { + responsesReq := &ResponsesRequest{ + Model: "deepseek-v4-flash", + Input: json.RawMessage(`[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"what's the weather?"}]}, + {"type":"reasoning","summary":[{"type":"summary_text","text":"call the tool"}]}, + {"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"SF\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"sunny"} + ]`), + } + viaResponses, err := ResponsesToChatCompletionsRequest(responsesReq) + require.NoError(t, err) + + viaAnthropic, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, `[ + {"type":"thinking","thinking":"call the tool"}, + {"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}} + ]`)) + require.NoError(t, err) + + reasoningOnToolCallMessage := func(msgs []ChatMessage) string { + for _, m := range msgs { + if m.Role == "assistant" && len(m.ToolCalls) > 0 { + return m.ReasoningContent + } + } + return "" + } + require.Equal(t, "call the tool", reasoningOnToolCallMessage(viaResponses.Messages), + "前置条件:兄弟桥本来就带 reasoning_content") + require.Equal(t, reasoningOnToolCallMessage(viaResponses.Messages), + reasoningOnToolCallMessage(viaAnthropic.Messages), + "两条桥对等价历史必须产出同样的 reasoning_content 位置") +} + +// 作用域守卫:不带工具调用的纯文本轮次维持现状(与兄弟桥一致 —— reasoning 只随 +// 工具调用回传),避免把 reasoning_content 撒到不需要它的上游请求上。 +func TestAnthropicToChatCompletionsRequest_ThinkingWithoutToolCallsStaysDropped(t *testing.T) { + req := &AnthropicRequest{ + Model: "deepseek-v4-flash", + MaxTokens: 100, + Messages: []AnthropicMessage{ + {Role: "assistant", Content: json.RawMessage( + `[{"type":"thinking","thinking":"secret thoughts"},{"type":"text","text":"answer"}]`)}, + }, + } + + out, err := AnthropicToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, out.Messages, 1) + require.Empty(t, out.Messages[0].ReasoningContent) + require.Equal(t, `"answer"`, string(out.Messages[0].Content)) + + payload, err := json.Marshal(out) + require.NoError(t, err) + require.NotContains(t, string(payload), "reasoning_content") +} + +func TestAnthropicThinkingToReasoningContent(t *testing.T) { + blocksOf := func(t *testing.T, raw string) []AnthropicContentBlock { + t.Helper() + var blocks []AnthropicContentBlock + require.NoError(t, json.Unmarshal([]byte(raw), &blocks)) + return blocks + } + + cases := []struct { + name string + raw string + hasToolCalls bool + want string + }{ + { + name: "single_thinking_block", + raw: `[{"type":"thinking","thinking":"a"}]`, + hasToolCalls: true, + want: "a", + }, + { + // 多个 thinking 块用 "\n" 连接,与 extractResponsesReasoningText 一致。 + name: "multiple_blocks_join_with_newline", + raw: `[{"type":"thinking","thinking":"a"},{"type":"text","text":"x"},{"type":"thinking","thinking":"b"}]`, + hasToolCalls: true, + want: "a\nb", + }, + { + // redacted_thinking 没有明文可回传。 + name: "redacted_thinking_has_no_plaintext", + raw: `[{"type":"redacted_thinking","signature":"abc"}]`, + hasToolCalls: true, + want: "", + }, + { + // 只带 signature 的 thinking 占位块(xAI/Codex 密文回放形态)同样无明文。 + name: "signature_only_thinking", + raw: `[{"type":"thinking","thinking":"","signature":"gAAAAxxx"}]`, + hasToolCalls: true, + want: "", + }, + { + name: "no_tool_calls_returns_empty", + raw: `[{"type":"thinking","thinking":"a"}]`, + hasToolCalls: false, + want: "", + }, + { + name: "no_thinking_blocks", + raw: `[{"type":"text","text":"x"}]`, + hasToolCalls: true, + want: "", + }, + { + name: "empty_blocks", + raw: `[]`, + hasToolCalls: true, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, + anthropicThinkingToReasoningContent(blocksOf(t, tc.raw), tc.hasToolCalls)) + }) + } +} + +// 纯字符串形态的 assistant content 没有 blocks 可读,走早返回分支,不得 panic。 +func TestAnthropicAssistantToChatMessages_PlainStringContentUnaffected(t *testing.T) { + msgs, err := anthropicAssistantToChatMessages(json.RawMessage(`"just text"`)) + require.NoError(t, err) + require.Len(t, msgs, 1) + require.Empty(t, msgs[0].ReasoningContent) + require.Equal(t, `"just text"`, string(msgs[0].Content)) +}