fix(apicompat): Responses→Anthropic 转换不再发出上游会拒收的 content block

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
This commit is contained in:
li
2026-08-07 23:09:03 +08:00
parent e88fc52ce6
commit 64090de664
2 changed files with 268 additions and 6 deletions
@@ -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)
}
}
@@ -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