fix(apicompat): flatten root union tool schemas for Anthropic

Codex 会把 codex_app 等内置工具的 parameters 根节点声明成对象分支的 oneOf/anyOf,Anthropic 拒绝工具 input_schema 顶层的 oneOf/anyOf/allOf,导致 /v1/responses -> /v1/messages 直接 400。

Responses->Anthropic 转换时摊平顶层联合:属性取各分支并集,同名属性用嵌套联合保留两侧约束,required 在 oneOf/anyOf 下取分支交集、allOf 下取并集;嵌套联合保持不变。
This commit is contained in:
laoxiechuzheng
2026-09-19 07:34:21 +08:00
parent efe9aab1e4
commit a8a1a3a958
4 changed files with 442 additions and 0 deletions
@@ -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"`)
@@ -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()
}
@@ -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"]}]}`)
}
@@ -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`,