responses_to_anthropic_invalid_blocks_test.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "strings"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. )
  8. // anthropicInboundBlockTypes 是 Anthropic Messages 请求体里合法的 content block
  9. // 类型。转换结果只能落在这个集合内——发出集合外的类型,上游一律回
  10. // 400 "Request body format invalid"(见 issue #5329)。
  11. var anthropicInboundBlockTypes = map[string]bool{
  12. "text": true,
  13. "image": true,
  14. "document": true,
  15. "tool_use": true,
  16. "tool_result": true,
  17. "thinking": true,
  18. "redacted_thinking": true,
  19. }
  20. func responsesToAnthropicMessages(t *testing.T, input string) []AnthropicMessage {
  21. t.Helper()
  22. var req ResponsesRequest
  23. require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":`+input+`}`), &req))
  24. out, err := ResponsesToAnthropicRequest(&req)
  25. require.NoError(t, err)
  26. return out.Messages
  27. }
  28. // requireAnthropicMessagesAreSendable 断言消息序列不含 Anthropic 会拒收的形态:
  29. // 未知 block 类型、空内容消息、纯空白 text 块。
  30. func requireAnthropicMessagesAreSendable(t *testing.T, messages []AnthropicMessage) {
  31. t.Helper()
  32. for i, m := range messages {
  33. raw := strings.TrimSpace(string(m.Content))
  34. require.NotContains(t, []string{"", "null", `""`, "[]"}, raw,
  35. "messages[%d] 内容为空,Anthropic 拒收空内容消息", i)
  36. var s string
  37. if err := json.Unmarshal(m.Content, &s); err == nil {
  38. require.NotEmpty(t, strings.TrimSpace(s), "messages[%d] 字符串内容不能全为空白", i)
  39. continue
  40. }
  41. blocks := parseContentBlocks(m.Content)
  42. require.NotEmpty(t, blocks, "messages[%d] 解析不出任何 block", i)
  43. for j, b := range blocks {
  44. require.True(t, anthropicInboundBlockTypes[b.Type],
  45. "messages[%d].content[%d] 是 Anthropic 不认识的 block 类型 %q", i, j, b.Type)
  46. if b.Type == "text" {
  47. require.NotEmpty(t, strings.TrimSpace(b.Text),
  48. "messages[%d].content[%d] 是空白 text 块,Anthropic 拒收", i, j)
  49. }
  50. }
  51. }
  52. }
  53. // issue #5329:工具执行后的下一轮,Codex 会把 reasoning item 一起回放。
  54. // 该 item 带 content 数组时,reasoning_text 块以前会被原样塞进 Anthropic 请求体。
  55. func TestResponsesToAnthropic_ReasoningItemWithContentIsDropped(t *testing.T) {
  56. messages := responsesToAnthropicMessages(t, `[
  57. {"type":"message","role":"user","content":[{"type":"input_text","text":"run a shell command"}]},
  58. {"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"let me think"}]}
  59. ]`)
  60. requireAnthropicMessagesAreSendable(t, messages)
  61. require.Len(t, messages, 1)
  62. require.NotContains(t, string(messages[0].Content), "reasoning_text")
  63. require.NotContains(t, string(messages[0].Content), "let me think")
  64. }
  65. // Codex 的常见 reasoning 形态(只有 summary + encrypted_content)本来就会被丢弃,
  66. // 这条守卫确保行为没有被改变。
  67. func TestResponsesToAnthropic_ReasoningItemSummaryOnlyStillDropped(t *testing.T) {
  68. messages := responsesToAnthropicMessages(t, `[
  69. {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
  70. {"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"s"}],"encrypted_content":"gAAAA"}
  71. ]`)
  72. requireAnthropicMessagesAreSendable(t, messages)
  73. require.Len(t, messages, 1)
  74. require.NotContains(t, string(messages[0].Content), "gAAAA")
  75. }
  76. // 未知 item type 的 content 以前会被逐字透传,把 Responses 专有分片带进上游请求。
  77. func TestResponsesToAnthropic_UnknownItemTypeContentIsSanitized(t *testing.T) {
  78. messages := responsesToAnthropicMessages(t, `[
  79. {"type":"web_search_call","id":"ws_1","content":[{"type":"web_search_result","text":"payload"}]}
  80. ]`)
  81. requireAnthropicMessagesAreSendable(t, messages)
  82. require.Empty(t, messages, "整条内容都无法映射时不应发出消息")
  83. }
  84. // 未知 item type 里夹带的可识别文本仍然保留,不做无谓丢弃。
  85. func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T) {
  86. messages := responsesToAnthropicMessages(t, `[
  87. {"type":"some_future_item","content":[
  88. {"type":"input_text","text":"keep me"},
  89. {"type":"reasoning_text","text":"drop me"}
  90. ]}
  91. ]`)
  92. requireAnthropicMessagesAreSendable(t, messages)
  93. require.Len(t, messages, 1)
  94. require.Contains(t, string(messages[0].Content), "keep me")
  95. require.NotContains(t, string(messages[0].Content), "drop me")
  96. }
  97. // user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。
  98. func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
  99. messages := responsesToAnthropicMessages(t, `[
  100. {"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]}
  101. ]`)
  102. requireAnthropicMessagesAreSendable(t, messages)
  103. require.Empty(t, messages)
  104. }
  105. // assistant 侧同理:以前会退化成单个空 text 块,Anthropic 同样拒收。
  106. func TestResponsesToAnthropic_AssistantMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
  107. messages := responsesToAnthropicMessages(t, `[
  108. {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
  109. {"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"no"}]}
  110. ]`)
  111. requireAnthropicMessagesAreSendable(t, messages)
  112. require.Len(t, messages, 1)
  113. require.Equal(t, "user", messages[0].Role)
  114. }
  115. // 完整的 Codex 工具续接回放:tool_use / tool_result 配对必须保持不变,
  116. // 同时整个序列满足可发送不变式。
  117. func TestResponsesToAnthropic_CodexToolRoundStaysIntactAndSendable(t *testing.T) {
  118. messages := responsesToAnthropicMessages(t, `[
  119. {"type":"message","role":"user","content":[{"type":"input_text","text":"run ls"}]},
  120. {"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"plan"}],"encrypted_content":"gAAAA"},
  121. {"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell","arguments":"{\"cmd\":\"ls\"}"},
  122. {"type":"function_call_output","call_id":"call_1","output":"file1"},
  123. {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
  124. ]`)
  125. requireAnthropicMessagesAreSendable(t, messages)
  126. var sawToolUse, sawToolResult bool
  127. for _, m := range messages {
  128. for _, b := range parseContentBlocks(m.Content) {
  129. switch b.Type {
  130. case "tool_use":
  131. sawToolUse = true
  132. require.Equal(t, "call_1", b.ID)
  133. require.Equal(t, "shell", b.Name)
  134. case "tool_result":
  135. sawToolResult = true
  136. require.Equal(t, "call_1", b.ToolUseID)
  137. }
  138. }
  139. }
  140. require.True(t, sawToolUse, "function_call 必须转成 tool_use")
  141. require.True(t, sawToolResult, "function_call_output 必须转成 tool_result")
  142. require.NotContains(t, string(mustMarshal(t, messages)), "reasoning_text")
  143. require.NotContains(t, string(mustMarshal(t, messages)), "gAAAA")
  144. }
  145. func mustMarshal(t *testing.T, v any) []byte {
  146. t.Helper()
  147. b, err := json.Marshal(v)
  148. require.NoError(t, err)
  149. return b
  150. }
  151. func TestAnthropicContentIsEmpty(t *testing.T) {
  152. cases := []struct {
  153. raw string
  154. want bool
  155. }{
  156. {``, true},
  157. {`""`, true},
  158. {`null`, true},
  159. {`[]`, true},
  160. {` [] `, true},
  161. {`"hi"`, false},
  162. {`[{"type":"text","text":"hi"}]`, false},
  163. }
  164. for _, tc := range cases {
  165. require.Equal(t, tc.want, anthropicContentIsEmpty(json.RawMessage(tc.raw)), "raw=%q", tc.raw)
  166. }
  167. }
  168. func TestAnthropicContentIsOnlyBlankText(t *testing.T) {
  169. cases := []struct {
  170. raw string
  171. want bool
  172. }{
  173. {`[{"type":"text","text":""}]`, true},
  174. {`[{"type":"text","text":" "}]`, true},
  175. {`[{"type":"text","text":""},{"type":"text","text":" "}]`, true},
  176. {`[{"type":"text","text":"hi"}]`, false},
  177. {`[{"type":"text","text":""},{"type":"image","source":{}}]`, false},
  178. {`[]`, false},
  179. }
  180. for _, tc := range cases {
  181. require.Equal(t, tc.want, anthropicContentIsOnlyBlankText(json.RawMessage(tc.raw)), "raw=%q", tc.raw)
  182. }
  183. }