mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 17:08:33 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user