| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- 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))
- }
|