From 64090de6645f738e1a6dbdcf84cc08495304dd0a Mon Sep 17 00:00:00 2001 From: li Date: Fri, 7 Aug 2026 23:09:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(apicompat):=20Responses=E2=86=92Anthropic?= =?UTF-8?q?=20=E8=BD=AC=E6=8D=A2=E4=B8=8D=E5=86=8D=E5=8F=91=E5=87=BA?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E4=BC=9A=E6=8B=92=E6=94=B6=E7=9A=84=20conten?= =?UTF-8?q?t=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convertResponsesInputToAnthropic 的 default 分支把未知 item 的 content 逐字 透传成 Anthropic user 消息,Responses 专有的分片类型会原样进入上游请求体。 最典型的是工具执行后回放的 reasoning item:带 content 数组时,reasoning_text 块直接发给 Anthropic,上游回 400 Request body format invalid,而该 item 会一直 留在会话历史里,导致此后每一轮都继续失败。 同时修正两处会产出空内容消息的路径——Anthropic 拒收空内容消息与空白 text 块: 分片全部不可识别时,user 消息退化成 content:""、assistant 消息退化成单个空 text 块。 改动: - type=reasoning 显式跳过。Anthropic 无法摄入 OpenAI reasoning:encrypted_content 不透明,thinking 重放需要上游签发的 signature。Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃,这里让带 content 的形态行为一致。 - default 分支改走 convertResponsesUserToAnthropicContent 白名单转换,保留其中 可识别的文本/图片,丢弃其余分片。 - user / assistant 分支在转换结果为空内容或纯空白文本时跳过该消息。 Fixes #5329 --- ...ponses_to_anthropic_invalid_blocks_test.go | 207 ++++++++++++++++++ .../responses_to_anthropic_request.go | 67 +++++- 2 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go new file mode 100644 index 0000000000..ed410f6851 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go @@ -0,0 +1,207 @@ +package apicompat + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// anthropicInboundBlockTypes 是 Anthropic Messages 请求体里合法的 content block +// 类型。转换结果只能落在这个集合内——发出集合外的类型,上游一律回 +// 400 "Request body format invalid"(见 issue #5329)。 +var anthropicInboundBlockTypes = map[string]bool{ + "text": true, + "image": true, + "document": true, + "tool_use": true, + "tool_result": true, + "thinking": true, + "redacted_thinking": true, +} + +func responsesToAnthropicMessages(t *testing.T, input string) []AnthropicMessage { + t.Helper() + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":`+input+`}`), &req)) + out, err := ResponsesToAnthropicRequest(&req) + require.NoError(t, err) + return out.Messages +} + +// requireAnthropicMessagesAreSendable 断言消息序列不含 Anthropic 会拒收的形态: +// 未知 block 类型、空内容消息、纯空白 text 块。 +func requireAnthropicMessagesAreSendable(t *testing.T, messages []AnthropicMessage) { + t.Helper() + for i, m := range messages { + raw := strings.TrimSpace(string(m.Content)) + require.NotContains(t, []string{"", "null", `""`, "[]"}, raw, + "messages[%d] 内容为空,Anthropic 拒收空内容消息", i) + + var s string + if err := json.Unmarshal(m.Content, &s); err == nil { + require.NotEmpty(t, strings.TrimSpace(s), "messages[%d] 字符串内容不能全为空白", i) + continue + } + blocks := parseContentBlocks(m.Content) + require.NotEmpty(t, blocks, "messages[%d] 解析不出任何 block", i) + for j, b := range blocks { + require.True(t, anthropicInboundBlockTypes[b.Type], + "messages[%d].content[%d] 是 Anthropic 不认识的 block 类型 %q", i, j, b.Type) + if b.Type == "text" { + require.NotEmpty(t, strings.TrimSpace(b.Text), + "messages[%d].content[%d] 是空白 text 块,Anthropic 拒收", i, j) + } + } + } +} + +// issue #5329:工具执行后的下一轮,Codex 会把 reasoning item 一起回放。 +// 该 item 带 content 数组时,reasoning_text 块以前会被原样塞进 Anthropic 请求体。 +func TestResponsesToAnthropic_ReasoningItemWithContentIsDropped(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"run a shell command"}]}, + {"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"let me think"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.NotContains(t, string(messages[0].Content), "reasoning_text") + require.NotContains(t, string(messages[0].Content), "let me think") +} + +// Codex 的常见 reasoning 形态(只有 summary + encrypted_content)本来就会被丢弃, +// 这条守卫确保行为没有被改变。 +func TestResponsesToAnthropic_ReasoningItemSummaryOnlyStillDropped(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}, + {"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"s"}],"encrypted_content":"gAAAA"} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.NotContains(t, string(messages[0].Content), "gAAAA") +} + +// 未知 item type 的 content 以前会被逐字透传,把 Responses 专有分片带进上游请求。 +func TestResponsesToAnthropic_UnknownItemTypeContentIsSanitized(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"web_search_call","id":"ws_1","content":[{"type":"web_search_result","text":"payload"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Empty(t, messages, "整条内容都无法映射时不应发出消息") +} + +// 未知 item type 里夹带的可识别文本仍然保留,不做无谓丢弃。 +func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"some_future_item","content":[ + {"type":"input_text","text":"keep me"}, + {"type":"reasoning_text","text":"drop me"} + ]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.Contains(t, string(messages[0].Content), "keep me") + require.NotContains(t, string(messages[0].Content), "drop me") +} + +// user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。 +func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Empty(t, messages) +} + +// assistant 侧同理:以前会退化成单个空 text 块,Anthropic 同样拒收。 +func TestResponsesToAnthropic_AssistantMessageWithOnlyUnknownPartsIsDropped(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}, + {"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"no"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.Equal(t, "user", messages[0].Role) +} + +// 完整的 Codex 工具续接回放:tool_use / tool_result 配对必须保持不变, +// 同时整个序列满足可发送不变式。 +func TestResponsesToAnthropic_CodexToolRoundStaysIntactAndSendable(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"run ls"}]}, + {"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"plan"}],"encrypted_content":"gAAAA"}, + {"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell","arguments":"{\"cmd\":\"ls\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"file1"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + + var sawToolUse, sawToolResult bool + for _, m := range messages { + for _, b := range parseContentBlocks(m.Content) { + switch b.Type { + case "tool_use": + sawToolUse = true + require.Equal(t, "call_1", b.ID) + require.Equal(t, "shell", b.Name) + case "tool_result": + sawToolResult = true + require.Equal(t, "call_1", b.ToolUseID) + } + } + } + require.True(t, sawToolUse, "function_call 必须转成 tool_use") + require.True(t, sawToolResult, "function_call_output 必须转成 tool_result") + require.NotContains(t, string(mustMarshal(t, messages)), "reasoning_text") + require.NotContains(t, string(mustMarshal(t, messages)), "gAAAA") +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} + +func TestAnthropicContentIsEmpty(t *testing.T) { + cases := []struct { + raw string + want bool + }{ + {``, true}, + {`""`, true}, + {`null`, true}, + {`[]`, true}, + {` [] `, true}, + {`"hi"`, false}, + {`[{"type":"text","text":"hi"}]`, false}, + } + for _, tc := range cases { + require.Equal(t, tc.want, anthropicContentIsEmpty(json.RawMessage(tc.raw)), "raw=%q", tc.raw) + } +} + +func TestAnthropicContentIsOnlyBlankText(t *testing.T) { + cases := []struct { + raw string + want bool + }{ + {`[{"type":"text","text":""}]`, true}, + {`[{"type":"text","text":" "}]`, true}, + {`[{"type":"text","text":""},{"type":"text","text":" "}]`, true}, + {`[{"type":"text","text":"hi"}]`, false}, + {`[{"type":"text","text":""},{"type":"image","source":{}}]`, false}, + {`[]`, false}, + } + for _, tc := range cases { + require.Equal(t, tc.want, anthropicContentIsOnlyBlankText(json.RawMessage(tc.raw)), "raw=%q", tc.raw) + } +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 7a28b44abc..51ffe8baaf 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -164,11 +164,24 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess Content: blockJSON, }) + case item.Type == "reasoning": + // Anthropic 无法摄入 OpenAI 的 reasoning:encrypted_content 是不透明的, + // 而 thinking 块的重放需要 Anthropic 自己签发的 signature,无法伪造。 + // Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃, + // 这里让带 content 数组的形态保持同样行为——否则 reasoning_text 块会被 + // 原样塞进 Anthropic 请求体,上游直接回 400。 + case item.Role == "user": content, err := convertResponsesUserToAnthropicContent(item.Content) if err != nil { return nil, nil, err } + // 内容里只有网关不认识的分片时,sanitize 会得到空串。Anthropic 拒收 + // 空内容消息("all messages must have non-empty content"),整条丢掉 + // 比发一条必然 400 的消息更可用。 + if anthropicContentIsEmpty(content) { + continue + } messages = append(messages, AnthropicMessage{ Role: "user", Content: content, @@ -179,19 +192,35 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess if err != nil { return nil, nil, err } + // 同上:分片全不认识时会退化成单个空 text 块,而 Anthropic 拒收 + // 空文本块("text content blocks must contain non-whitespace text")。 + if anthropicContentIsEmpty(content) || anthropicContentIsOnlyBlankText(content) { + continue + } messages = append(messages, AnthropicMessage{ Role: "assistant", Content: content, }) default: - // Unknown role/type — attempt as user message - if item.Content != nil { - messages = append(messages, AnthropicMessage{ - Role: "user", - Content: item.Content, - }) + // 未知 role/type —— 尽量当作 user 消息保留其中的文本/图片。 + // 必须走与真实 user 消息同一套白名单转换:直接透传 item.Content 会把 + // Responses 专有的分片类型(reasoning_text、web_search_call 的载荷等) + // 原样发给 Anthropic,上游只会回 400 把整轮打挂。 + if item.Content == nil { + continue } + content, err := convertResponsesUserToAnthropicContent(item.Content) + if err != nil { + return nil, nil, err + } + if anthropicContentIsEmpty(content) { + continue + } + messages = append(messages, AnthropicMessage{ + Role: "user", + Content: content, + }) } } @@ -393,6 +422,32 @@ func extractTextFromContent(raw json.RawMessage) string { // convertResponsesUserToAnthropicContent converts a Responses user message // content field into Anthropic content blocks JSON. +// anthropicContentIsEmpty 判断转换结果是否为"空内容"。 +// convertResponsesUserToAnthropicContent 在没有任何可识别分片时返回 JSON 空串, +// 而 Anthropic 拒收空内容消息。 +func anthropicContentIsEmpty(content json.RawMessage) bool { + trimmed := strings.TrimSpace(string(content)) + switch trimmed { + case "", "null", `""`, "[]": + return true + } + return false +} + +// anthropicContentIsOnlyBlankText 判断内容是否只由空白 text 块组成。 +func anthropicContentIsOnlyBlankText(content json.RawMessage) bool { + blocks := parseContentBlocks(content) + if len(blocks) == 0 { + return false + } + for _, b := range blocks { + if b.Type != "text" || strings.TrimSpace(b.Text) != "" { + return false + } + } + return true +} + func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessage, error) { if len(raw) == 0 { return json.Marshal("") // empty string content