Files
sub2api/backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go
T
li 901a77cfb1 fix(apicompat): Anthropic→Chat 桥回传工具调用的 thinking,修复 DeepSeek 多轮 400
/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
2026-08-14 10:33:47 +08:00

230 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}