From a8a1a3a958a0df8544aaa90729b5e4c178aa4090 Mon Sep 17 00:00:00 2001 From: laoxiechuzheng Date: Sat, 19 Sep 2026 07:34:21 +0800 Subject: [PATCH] fix(apicompat): flatten root union tool schemas for Anthropic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 会把 codex_app 等内置工具的 parameters 根节点声明成对象分支的 oneOf/anyOf,Anthropic 拒绝工具 input_schema 顶层的 oneOf/anyOf/allOf,导致 /v1/responses -> /v1/messages 直接 400。 Responses->Anthropic 转换时摊平顶层联合:属性取各分支并集,同名属性用嵌套联合保留两侧约束,required 在 oneOf/anyOf 下取分支交集、allOf 下取并集;嵌套联合保持不变。 --- .../responses_to_anthropic_request.go | 5 + .../responses_to_anthropic_tool_schema.go | 213 ++++++++++++++++++ .../responses_to_anthropic_tools_test.go | 177 +++++++++++++++ .../gateway_forward_as_responses_test.go | 47 ++++ 4 files changed, 442 insertions(+) create mode 100644 backend/internal/pkg/apicompat/responses_to_anthropic_tool_schema.go diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 51ffe8baaf..484fa98dba 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -646,6 +646,9 @@ func convertResponsesToAnthropicTools(tools []ResponsesTool) []AnthropicTool { } // normalizeAnthropicInputSchema ensures input_schema is a valid object schema. +// Codex 会把部分内置工具(例如 codex_app 的 automation_update)的 parameters +// 根节点声明成对象分支的 oneOf/anyOf,Anthropic 只接受 object 根节点,这里把 +// 顶层联合摊平成单个 object schema。 func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage { const emptyObjectSchema = `{"type":"object","properties":{}}` @@ -659,6 +662,8 @@ func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage { return json.RawMessage(`{"type":"object","properties":{}}`) } + flattenAnthropicRootUnions(m) + typeRaw, ok := m["type"] if !ok || strings.TrimSpace(string(typeRaw)) == "" || string(typeRaw) == "null" { m["type"] = json.RawMessage(`"object"`) diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_schema.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_schema.go new file mode 100644 index 0000000000..417d39e171 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_schema.go @@ -0,0 +1,213 @@ +package apicompat + +import ( + "bytes" + "encoding/json" +) + +// anthropicInputSchemaUnionKeywords 是 Anthropic 在工具 input_schema 顶层拒绝的 +// JSON Schema 联合关键字;嵌套层不受限制,因此只摊平根节点。 +var anthropicInputSchemaUnionKeywords = []string{"oneOf", "anyOf", "allOf"} + +// flattenAnthropicRootUnions 用单一 object schema 替换顶层的 oneOf/anyOf/allOf: +// 属性取各分支并集,同名属性用嵌套联合保留两侧约束;required 在 oneOf/anyOf +// 下取分支交集、在 allOf 下取分支并集。非对象分支无法用 Anthropic 的 object +// schema 表达,视为不贡献属性与必填字段。 +func flattenAnthropicRootUnions(schema map[string]json.RawMessage) { + present := make([]string, 0, len(anthropicInputSchemaUnionKeywords)) + for _, keyword := range anthropicInputSchemaUnionKeywords { + if _, ok := schema[keyword]; ok { + present = append(present, keyword) + } + } + if len(present) == 0 { + return + } + + properties := anthropicSchemaProperties(schema) + rootRequired := anthropicBranchRequired(schema) + var ( + intersections [][]string + allOfRequired []string + ) + for _, keyword := range present { + raw := schema[keyword] + delete(schema, keyword) + + branches, ok := decodeAnthropicUnionBranches(raw) + if !ok { + continue + } + mergeAnthropicObjectBranchProperties(properties, branches) + if keyword == "allOf" { + for _, branch := range branches { + allOfRequired = appendAnthropicRequired(allOfRequired, anthropicBranchRequired(branch)) + } + continue + } + intersections = append(intersections, intersectAnthropicRequired(branches)) + } + + if len(properties) > 0 { + if encoded, err := json.Marshal(properties); err == nil { + schema["properties"] = encoded + } + } + required := intersectAnthropicRequiredLists(intersections) + required = appendAnthropicRequired(required, allOfRequired) + required = appendAnthropicRequired(required, rootRequired) + if len(required) > 0 { + if encoded, err := json.Marshal(required); err == nil { + schema["required"] = encoded + } + } +} + +func anthropicSchemaProperties(schema map[string]json.RawMessage) map[string]json.RawMessage { + properties := make(map[string]json.RawMessage) + raw, ok := schema["properties"] + if !ok { + return properties + } + if err := json.Unmarshal(raw, &properties); err != nil || properties == nil { + return make(map[string]json.RawMessage) + } + return properties +} + +// decodeAnthropicUnionBranches 解析联合分支并递归摊平分支自身的顶层联合。 +// 非对象 JSON 分支以 nil 占位:它不贡献属性,也不要求任何字段。 +func decodeAnthropicUnionBranches(raw json.RawMessage) ([]map[string]json.RawMessage, bool) { + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil || len(items) == 0 { + return nil, false + } + branches := make([]map[string]json.RawMessage, 0, len(items)) + for _, item := range items { + var branch map[string]json.RawMessage + if err := json.Unmarshal(item, &branch); err != nil || branch == nil { + branches = append(branches, nil) + continue + } + flattenAnthropicRootUnions(branch) + branches = append(branches, branch) + } + return branches, true +} + +func mergeAnthropicObjectBranchProperties(into map[string]json.RawMessage, branches []map[string]json.RawMessage) { + for _, branch := range branches { + if !anthropicSchemaBranchIsObject(branch) { + continue + } + raw, ok := branch["properties"] + if !ok { + continue + } + var properties map[string]json.RawMessage + if err := json.Unmarshal(raw, &properties); err != nil { + continue + } + for name, property := range properties { + existing, seen := into[name] + if !seen { + into[name] = property + continue + } + if anthropicSchemaJSONEqual(existing, property) { + continue + } + merged, err := json.Marshal(map[string][]json.RawMessage{"anyOf": {existing, property}}) + if err != nil { + continue + } + into[name] = merged + } + } +} + +// anthropicSchemaBranchIsObject 判断分支是否可能是对象:显式 type=object,或省略 +// type 但声明了 properties(缺失 type 等价于不约束,不能据此排除对象分支)。 +func anthropicSchemaBranchIsObject(branch map[string]json.RawMessage) bool { + if branch == nil { + return false + } + if raw, ok := branch["type"]; ok { + var typ string + return json.Unmarshal(raw, &typ) == nil && typ == "object" + } + _, ok := branch["properties"] + return ok +} + +func anthropicBranchRequired(branch map[string]json.RawMessage) []string { + if branch == nil { + return nil + } + raw, ok := branch["required"] + if !ok { + return nil + } + var required []string + if err := json.Unmarshal(raw, &required); err != nil { + return nil + } + return required +} + +// intersectAnthropicRequired 返回所有分支共同要求的字段,顺序沿用第一个分支。 +func intersectAnthropicRequired(branches []map[string]json.RawMessage) []string { + lists := make([][]string, 0, len(branches)) + for _, branch := range branches { + lists = append(lists, anthropicBranchRequired(branch)) + } + return intersectAnthropicRequiredLists(lists) +} + +func intersectAnthropicRequiredLists(lists [][]string) []string { + if len(lists) == 0 { + return nil + } + result := lists[0] + for _, list := range lists[1:] { + if len(result) == 0 { + return nil + } + next := make([]string, 0, len(result)) + for _, name := range result { + if containsAnthropicName(list, name) { + next = append(next, name) + } + } + result = next + } + return result +} + +func appendAnthropicRequired(required []string, names []string) []string { + merged := make([]string, 0, len(required)) + merged = append(merged, required...) + for _, name := range names { + if !containsAnthropicName(merged, name) { + merged = append(merged, name) + } + } + return merged +} + +func containsAnthropicName(names []string, target string) bool { + for _, name := range names { + if name == target { + return true + } + } + return false +} + +func anthropicSchemaJSONEqual(left, right json.RawMessage) bool { + var leftBuf, rightBuf bytes.Buffer + if json.Compact(&leftBuf, left) != nil || json.Compact(&rightBuf, right) != nil { + return false + } + return leftBuf.String() == rightBuf.String() +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go index 54873b547e..d458f30d5a 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go @@ -138,3 +138,180 @@ func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) { assert.Equal(t, "shell", tools[0].Name) assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[0].InputSchema)) } + +// Codex 的 codex_app 命名空间工具(如 automation_update)把 parameters 根节点声明为 +// 对象分支的 oneOf/anyOf;Anthropic 拒绝 input_schema 顶层的 oneOf/anyOf/allOf, +// 转换时必须摊平成单个 object schema。 +func TestResponsesToAnthropic_ObjectUnionRootFlattened(t *testing.T) { + tools := convertResponsesToAnthropicTools([]ResponsesTool{{ + Type: "function", + Name: "codex_app__automation_update", + Parameters: json.RawMessage(`{ + "oneOf": [ + {"type":"object","properties":{"id":{"type":"string"}}}, + {"anyOf":[{"type":"object"},{"type":"object","properties":{}}]} + ] + }`), + }}) + + require.Len(t, tools, 1) + schema := requireObjectInputSchema(t, tools[0].InputSchema) + assert.NotContains(t, schema, "oneOf") + assert.NotContains(t, schema, "anyOf") + assert.NotContains(t, schema, "allOf") + assert.JSONEq(t, `{"id":{"type":"string"}}`, string(schema["properties"])) + assert.NotContains(t, schema, "required") +} + +func TestResponsesToAnthropic_ObjectUnionRootMergesBranches(t *testing.T) { + tools := convertResponsesToAnthropicTools([]ResponsesTool{{ + Type: "function", + Name: "codex_app__automation_update", + Parameters: json.RawMessage(`{ + "anyOf": [ + {"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]}, + {"type":"object","properties":{"mode":{"enum":["update"]},"prompt":{"type":"string"}},"required":["mode","prompt"]} + ] + }`), + }}) + + require.Len(t, tools, 1) + schema := requireObjectInputSchema(t, tools[0].InputSchema) + assert.NotContains(t, schema, "anyOf") + + // 属性取各分支并集;同名属性保留两侧约束(嵌套联合本身是允许的)。 + var properties map[string]json.RawMessage + require.NoError(t, json.Unmarshal(schema["properties"], &properties)) + require.Contains(t, properties, "id") + require.Contains(t, properties, "prompt") + var mode map[string]json.RawMessage + require.NoError(t, json.Unmarshal(properties["mode"], &mode)) + assert.Contains(t, mode, "anyOf") + + // required 取各分支交集,只保留所有分支都要求的字段。 + assert.JSONEq(t, `["mode"]`, string(schema["required"])) +} + +func TestResponsesToAnthropic_AllOfRootKeepsUnionRequired(t *testing.T) { + tools := convertResponsesToAnthropicTools([]ResponsesTool{{ + Type: "function", + Name: "strict_tool", + Parameters: json.RawMessage(`{ + "allOf": [ + {"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}, + {"type":"object","properties":{"encoding":{"type":"string"}},"required":["encoding"]} + ] + }`), + }}) + + require.Len(t, tools, 1) + schema := requireObjectInputSchema(t, tools[0].InputSchema) + assert.NotContains(t, schema, "allOf") + assert.JSONEq(t, `["path","encoding"]`, string(schema["required"])) +} + +// 非对象分支无法用 Anthropic 的 object schema 表达,直接丢弃分支但保留属性词汇。 +func TestResponsesToAnthropic_RootUnionDropsNonObjectBranches(t *testing.T) { + tools := convertResponsesToAnthropicTools([]ResponsesTool{{ + Type: "function", + Name: "mixed_tool", + Parameters: json.RawMessage(`{"oneOf":[{"type":"object","properties":{"path":{"type":"string"}}},{"type":"string"}]}`), + }}) + + require.Len(t, tools, 1) + schema := requireObjectInputSchema(t, tools[0].InputSchema) + assert.NotContains(t, schema, "oneOf") + var properties map[string]json.RawMessage + require.NoError(t, json.Unmarshal(schema["properties"], &properties)) + assert.Contains(t, properties, "path") +} + +func TestResponsesToAnthropic_NestedUnionPreserved(t *testing.T) { + parameters := json.RawMessage(`{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]}}}`) + tools := convertResponsesToAnthropicTools([]ResponsesTool{{ + Type: "function", + Name: "get_value", + Parameters: parameters, + }}) + + require.Len(t, tools, 1) + assert.JSONEq(t, string(parameters), string(tools[0].InputSchema)) +} + +// 根节点自带 properties/required 时,与分支约束合并而不是被覆盖。 +func TestResponsesToAnthropic_RootUnionKeepsRootPropertiesAndRequired(t *testing.T) { + tools := convertResponsesToAnthropicTools([]ResponsesTool{{ + Type: "function", + Name: "merge_root_tool", + Parameters: json.RawMessage(`{ + "type":"object", + "properties":{"shared":{"type":"string"}}, + "required":["shared"], + "oneOf":[{"type":"object","properties":{"extra":{"type":"integer"}}}] + }`), + }}) + + require.Len(t, tools, 1) + schema := requireObjectInputSchema(t, tools[0].InputSchema) + assert.NotContains(t, schema, "oneOf") + var properties map[string]json.RawMessage + require.NoError(t, json.Unmarshal(schema["properties"], &properties)) + assert.Contains(t, properties, "shared") + assert.Contains(t, properties, "extra") + assert.JSONEq(t, `["shared"]`, string(schema["required"])) +} + +// 端到端:Codex 命名空间工具经降级/摊平后,最终 Anthropic 工具 schema 顶部不再有联合关键字。 +func TestResponsesToAnthropic_CodexNamespaceRootUnionTool(t *testing.T) { + body := []byte(`{ + "model": "claude-opus-5", + "input": "create an automation", + "tools": [{ + "type": "namespace", + "name": "codex_app", + "tools": [{ + "type": "function", + "name": "automation_update", + "description": "Create, update, view, or delete recurring automations", + "parameters": { + "oneOf": [ + {"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]}, + {"type":"object","properties":{"mode":{"enum":["update"]},"id":{"type":"string"}},"required":["mode","id"]} + ] + } + }] + }] + }`) + + var requestBody map[string]any + require.NoError(t, json.Unmarshal(body, &requestBody)) + + _, changed, err := AdaptResponsesClientTools(requestBody) + require.NoError(t, err) + require.True(t, changed) + + adapted, err := json.Marshal(requestBody) + require.NoError(t, err) + + var responsesReq ResponsesRequest + require.NoError(t, json.Unmarshal(adapted, &responsesReq)) + + anthropicReq, err := ResponsesToAnthropicRequest(&responsesReq) + require.NoError(t, err) + require.Len(t, anthropicReq.Tools, 1) + + tool := anthropicReq.Tools[0] + assert.Equal(t, "codex_app__automation_update", tool.Name) + schema := requireObjectInputSchema(t, tool.InputSchema) + assert.NotContains(t, schema, "oneOf") + assert.NotContains(t, schema, "anyOf") + assert.NotContains(t, schema, "allOf") + assert.JSONEq(t, `["mode","id"]`, string(schema["required"])) + + wire, err := json.Marshal(tool) + require.NoError(t, err) + assert.NotContains(t, string(wire), `"oneOf"`) + assert.NotContains(t, string(wire), `"allOf"`) + // 同名属性两侧的约束用嵌套联合保留,只允许出现在 properties 内部。 + assert.Contains(t, string(wire), `"mode":{"anyOf":[{"enum":["view"]},{"enum":["update"]}]}`) +} diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index e62a330a83..972c6bc1a6 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -74,6 +74,53 @@ func TestAdaptResponsesClientToolsForAnthropic_LiftsAdditionalTools(t *testing.T require.Equal(t, "message", input[0].(map[string]any)["type"]) } +// Codex 的 codex_app 工具(如 automation_update)把 parameters 根节点声明成对象 +// 联合;Responses→Anthropic 转换后 input_schema 顶部不能再出现联合关键字。 +func TestAdaptResponsesClientToolsForAnthropic_FlattensRootUnionSchema(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model":"claude-opus-5", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"create an automation"}]}], + "tools":[{"type":"namespace","name":"codex_app","tools":[{ + "type":"function", + "name":"automation_update", + "description":"Create, update, view, or delete recurring automations", + "parameters":{"oneOf":[ + {"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]}, + {"type":"object","properties":{"mode":{"enum":["update"]},"id":{"type":"string"}},"required":["mode","id"]} + ]} + }]}] + }`) + + adapted, _, err := adaptResponsesClientToolsForAnthropic(body) + require.NoError(t, err) + + var responsesReq apicompat.ResponsesRequest + require.NoError(t, json.Unmarshal(adapted, &responsesReq)) + + claudeReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) + require.NoError(t, err) + require.Len(t, claudeReq.Tools, 1) + + tool := claudeReq.Tools[0] + require.Equal(t, "codex_app__automation_update", tool.Name) + + var schema map[string]json.RawMessage + require.NoError(t, json.Unmarshal(tool.InputSchema, &schema)) + require.JSONEq(t, `"object"`, string(schema["type"])) + require.NotContains(t, schema, "oneOf") + require.NotContains(t, schema, "anyOf") + require.NotContains(t, schema, "allOf") + require.JSONEq(t, `["mode","id"]`, string(schema["required"])) + + wire, err := json.Marshal(tool) + require.NoError(t, err) + require.NotContains(t, string(wire), `"input_schema":{"oneOf"`) + require.NotContains(t, string(wire), `"input_schema":{"anyOf"`) + require.NotContains(t, string(wire), `"input_schema":{"allOf"`) +} + func namespaceToolAnthropicStream() string { return strings.Join([]string{ `event: message_start`,