mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 06:56:58 +00:00
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:
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user