chatcompletions_anthropic_reasoning_passback_test.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/require"
  6. )
  7. // issue #5528:/v1/messages 客户端(Claude Code 等)打到只会 Chat Completions 的
  8. // OpenAI 兼容上游时,历史 assistant 消息里的 thinking 块被整块丢弃。DeepSeek 的
  9. // thinking mode 要求产生工具调用的 reasoning_content 随该 assistant 消息回传,
  10. // 于是「单轮正常、一进多轮工具对话必现 400」。
  11. func anthropicAssistantMsg(t *testing.T, blocks string) *AnthropicRequest {
  12. t.Helper()
  13. return &AnthropicRequest{
  14. Model: "deepseek-v4-flash",
  15. MaxTokens: 256,
  16. Messages: []AnthropicMessage{
  17. {Role: "user", Content: json.RawMessage(`"what's the weather?"`)},
  18. {Role: "assistant", Content: json.RawMessage(blocks)},
  19. {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"}]`)},
  20. },
  21. }
  22. }
  23. const anthropicThinkingToolTurn = `[
  24. {"type":"thinking","thinking":"user wants weather, call the tool"},
  25. {"type":"text","text":"checking"},
  26. {"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}
  27. ]`
  28. func TestAnthropicToChatCompletionsRequest_ThinkingBecomesReasoningContentOnToolTurn(t *testing.T) {
  29. out, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, anthropicThinkingToolTurn))
  30. require.NoError(t, err)
  31. var assistant *ChatMessage
  32. for i := range out.Messages {
  33. if out.Messages[i].Role == "assistant" {
  34. assistant = &out.Messages[i]
  35. break
  36. }
  37. }
  38. require.NotNil(t, assistant, "assistant message must survive the bridge")
  39. require.Equal(t, "user wants weather, call the tool", assistant.ReasoningContent,
  40. "产生工具调用的 thinking 必须作为 reasoning_content 回传,否则 DeepSeek 400")
  41. require.Len(t, assistant.ToolCalls, 1)
  42. require.Equal(t, `"checking"`, string(assistant.Content), "text/tool_use 处理保持不变")
  43. }
  44. // 上游线格式才是上游看到的东西:字段没序列化出去,等于没修。
  45. func TestAnthropicToChatCompletionsRequest_ReasoningContentSerializesOnWire(t *testing.T) {
  46. out, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, anthropicThinkingToolTurn))
  47. require.NoError(t, err)
  48. payload, err := json.Marshal(out)
  49. require.NoError(t, err)
  50. require.Contains(t, string(payload), `"reasoning_content":"user wants weather, call the tool"`)
  51. }
  52. // 闭环不变式:thinking 块本来就是本桥出站时用上游 reasoning_content 生成的
  53. // (chatMessageToAnthropicBlocks),客户端只是原样回传。出站造、入站丢 = 自己丢自己的东西。
  54. func TestAnthropicChatBridge_ReasoningSurvivesOutboundInboundRoundTrip(t *testing.T) {
  55. upstream := ChatMessage{
  56. Role: "assistant",
  57. ReasoningContent: "step 1: need the weather tool",
  58. Content: json.RawMessage(`"checking"`),
  59. ToolCalls: []ChatToolCall{{
  60. ID: "call_1",
  61. Type: "function",
  62. Function: ChatFunctionCall{Name: "get_weather", Arguments: `{"city":"SF"}`},
  63. }},
  64. }
  65. // 出站:Chat 响应 → Anthropic content blocks
  66. blocks := chatMessageToAnthropicBlocks(upstream)
  67. require.Equal(t, "thinking", blocks[0].Type)
  68. require.Equal(t, upstream.ReasoningContent, blocks[0].Thinking)
  69. // 客户端下一轮把同一组 blocks 原样回传
  70. raw, err := json.Marshal(blocks)
  71. require.NoError(t, err)
  72. // 入站:Anthropic content blocks → Chat 请求
  73. back, err := anthropicAssistantToChatMessages(raw)
  74. require.NoError(t, err)
  75. require.Len(t, back, 1)
  76. require.Equal(t, upstream.ReasoningContent, back[0].ReasoningContent,
  77. "出站生成的 thinking 必须能原样还原回 reasoning_content")
  78. require.Len(t, back[0].ToolCalls, 1)
  79. }
  80. // 兄弟不变式:Responses→Chat 桥(buildChatMessagesFromItems 的 pendingReasoning)
  81. // 早就把 reasoning 挂到带 tool_calls 的 assistant 消息上了。等价历史下两条桥必须一致。
  82. func TestAnthropicChatBridge_MatchesResponsesChatBridgeReasoningPlacement(t *testing.T) {
  83. responsesReq := &ResponsesRequest{
  84. Model: "deepseek-v4-flash",
  85. Input: json.RawMessage(`[
  86. {"type":"message","role":"user","content":[{"type":"input_text","text":"what's the weather?"}]},
  87. {"type":"reasoning","summary":[{"type":"summary_text","text":"call the tool"}]},
  88. {"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"SF\"}"},
  89. {"type":"function_call_output","call_id":"call_1","output":"sunny"}
  90. ]`),
  91. }
  92. viaResponses, err := ResponsesToChatCompletionsRequest(responsesReq)
  93. require.NoError(t, err)
  94. viaAnthropic, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, `[
  95. {"type":"thinking","thinking":"call the tool"},
  96. {"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}
  97. ]`))
  98. require.NoError(t, err)
  99. reasoningOnToolCallMessage := func(msgs []ChatMessage) string {
  100. for _, m := range msgs {
  101. if m.Role == "assistant" && len(m.ToolCalls) > 0 {
  102. return m.ReasoningContent
  103. }
  104. }
  105. return ""
  106. }
  107. require.Equal(t, "call the tool", reasoningOnToolCallMessage(viaResponses.Messages),
  108. "前置条件:兄弟桥本来就带 reasoning_content")
  109. require.Equal(t, reasoningOnToolCallMessage(viaResponses.Messages),
  110. reasoningOnToolCallMessage(viaAnthropic.Messages),
  111. "两条桥对等价历史必须产出同样的 reasoning_content 位置")
  112. }
  113. // 作用域守卫:不带工具调用的纯文本轮次维持现状(与兄弟桥一致 —— reasoning 只随
  114. // 工具调用回传),避免把 reasoning_content 撒到不需要它的上游请求上。
  115. func TestAnthropicToChatCompletionsRequest_ThinkingWithoutToolCallsStaysDropped(t *testing.T) {
  116. req := &AnthropicRequest{
  117. Model: "deepseek-v4-flash",
  118. MaxTokens: 100,
  119. Messages: []AnthropicMessage{
  120. {Role: "assistant", Content: json.RawMessage(
  121. `[{"type":"thinking","thinking":"secret thoughts"},{"type":"text","text":"answer"}]`)},
  122. },
  123. }
  124. out, err := AnthropicToChatCompletionsRequest(req)
  125. require.NoError(t, err)
  126. require.Len(t, out.Messages, 1)
  127. require.Empty(t, out.Messages[0].ReasoningContent)
  128. require.Equal(t, `"answer"`, string(out.Messages[0].Content))
  129. payload, err := json.Marshal(out)
  130. require.NoError(t, err)
  131. require.NotContains(t, string(payload), "reasoning_content")
  132. }
  133. func TestAnthropicThinkingToReasoningContent(t *testing.T) {
  134. blocksOf := func(t *testing.T, raw string) []AnthropicContentBlock {
  135. t.Helper()
  136. var blocks []AnthropicContentBlock
  137. require.NoError(t, json.Unmarshal([]byte(raw), &blocks))
  138. return blocks
  139. }
  140. cases := []struct {
  141. name string
  142. raw string
  143. hasToolCalls bool
  144. want string
  145. }{
  146. {
  147. name: "single_thinking_block",
  148. raw: `[{"type":"thinking","thinking":"a"}]`,
  149. hasToolCalls: true,
  150. want: "a",
  151. },
  152. {
  153. // 多个 thinking 块用 "\n" 连接,与 extractResponsesReasoningText 一致。
  154. name: "multiple_blocks_join_with_newline",
  155. raw: `[{"type":"thinking","thinking":"a"},{"type":"text","text":"x"},{"type":"thinking","thinking":"b"}]`,
  156. hasToolCalls: true,
  157. want: "a\nb",
  158. },
  159. {
  160. // redacted_thinking 没有明文可回传。
  161. name: "redacted_thinking_has_no_plaintext",
  162. raw: `[{"type":"redacted_thinking","signature":"abc"}]`,
  163. hasToolCalls: true,
  164. want: "",
  165. },
  166. {
  167. // 只带 signature 的 thinking 占位块(xAI/Codex 密文回放形态)同样无明文。
  168. name: "signature_only_thinking",
  169. raw: `[{"type":"thinking","thinking":"","signature":"gAAAAxxx"}]`,
  170. hasToolCalls: true,
  171. want: "",
  172. },
  173. {
  174. name: "no_tool_calls_returns_empty",
  175. raw: `[{"type":"thinking","thinking":"a"}]`,
  176. hasToolCalls: false,
  177. want: "",
  178. },
  179. {
  180. name: "no_thinking_blocks",
  181. raw: `[{"type":"text","text":"x"}]`,
  182. hasToolCalls: true,
  183. want: "",
  184. },
  185. {
  186. name: "empty_blocks",
  187. raw: `[]`,
  188. hasToolCalls: true,
  189. want: "",
  190. },
  191. }
  192. for _, tc := range cases {
  193. t.Run(tc.name, func(t *testing.T) {
  194. require.Equal(t, tc.want,
  195. anthropicThinkingToReasoningContent(blocksOf(t, tc.raw), tc.hasToolCalls))
  196. })
  197. }
  198. }
  199. // 纯字符串形态的 assistant content 没有 blocks 可读,走早返回分支,不得 panic。
  200. func TestAnthropicAssistantToChatMessages_PlainStringContentUnaffected(t *testing.T) {
  201. msgs, err := anthropicAssistantToChatMessages(json.RawMessage(`"just text"`))
  202. require.NoError(t, err)
  203. require.Len(t, msgs, 1)
  204. require.Empty(t, msgs[0].ReasoningContent)
  205. require.Equal(t, `"just text"`, string(msgs[0].Content))
  206. }