fix(apicompat): 规范化 custom 工具 schema

This commit is contained in:
wucm667
2026-06-26 14:08:03 +08:00
parent 5f022663ac
commit 40c8252734
2 changed files with 174 additions and 4 deletions
@@ -524,25 +524,58 @@ func convertResponsesToAnthropicTools(tools []ResponsesTool) []AnthropicTool {
Description: t.Description,
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
})
case "custom":
out = append(out, AnthropicTool{
Name: t.Name,
Description: t.Description,
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
})
default:
// Pass through unknown tool types
out = append(out, AnthropicTool{
Type: t.Type,
Name: t.Name,
Description: t.Description,
InputSchema: t.Parameters,
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
})
}
}
return out
}
// normalizeAnthropicInputSchema ensures the input_schema has a "type" field.
// normalizeAnthropicInputSchema ensures input_schema is a valid object schema.
func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
if len(schema) == 0 || string(schema) == "null" {
const emptyObjectSchema = `{"type":"object","properties":{}}`
trimmed := strings.TrimSpace(string(schema))
if trimmed == "" || trimmed == "null" {
return json.RawMessage(emptyObjectSchema)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(schema, &m); err != nil {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
return schema
typeRaw, ok := m["type"]
if !ok || strings.TrimSpace(string(typeRaw)) == "" || string(typeRaw) == "null" {
m["type"] = json.RawMessage(`"object"`)
} else {
var typ string
if err := json.Unmarshal(typeRaw, &typ); err != nil || typ != "object" {
return json.RawMessage(emptyObjectSchema)
}
}
if _, ok := m["properties"]; !ok {
m["properties"] = json.RawMessage(`{}`)
}
out, err := json.Marshal(m)
if err != nil {
return json.RawMessage(emptyObjectSchema)
}
return out
}
// convertResponsesToAnthropicToolChoice maps Responses tool_choice to Anthropic format.
@@ -0,0 +1,137 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func requireObjectInputSchema(t *testing.T, schema json.RawMessage) map[string]json.RawMessage {
t.Helper()
require.NotEmpty(t, schema)
var parsed map[string]json.RawMessage
require.NoError(t, json.Unmarshal(schema, &parsed))
require.JSONEq(t, `"object"`, string(parsed["type"]))
require.Contains(t, parsed, "properties")
var properties map[string]json.RawMessage
require.NoError(t, json.Unmarshal(parsed["properties"], &properties))
return parsed
}
func TestResponsesToAnthropic_CustomGrammarToolUsesObjectSchema(t *testing.T) {
body := []byte(`{
"model": "gpt-5.2",
"input": "apply this patch",
"tools": [{
"type": "custom",
"name": "apply_patch",
"description": "Apply a patch to the working tree",
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "start: /.+/"
}
}]
}`)
var req ResponsesRequest
require.NoError(t, json.Unmarshal(body, &req))
anthropicReq, err := ResponsesToAnthropicRequest(&req)
require.NoError(t, err)
require.Len(t, anthropicReq.Tools, 1)
tool := anthropicReq.Tools[0]
assert.Empty(t, tool.Type)
assert.Equal(t, "apply_patch", tool.Name)
assert.Equal(t, "Apply a patch to the working tree", tool.Description)
requireObjectInputSchema(t, tool.InputSchema)
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tool.InputSchema))
wire, err := json.Marshal(tool)
require.NoError(t, err)
assert.NotContains(t, string(wire), `"type":"custom"`)
assert.NotContains(t, string(wire), `"format"`)
assert.NotContains(t, string(wire), `"grammar"`)
}
func TestResponsesToAnthropic_CustomToolPreservesSchemaParameters(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "custom",
Name: "edit_file",
Description: "Edit a file",
Parameters: json.RawMessage(`{"type":"object","properties":{"patch":{"type":"string"}},"required":["patch"]}`),
}})
require.Len(t, tools, 1)
assert.Empty(t, tools[0].Type)
assert.Equal(t, "edit_file", tools[0].Name)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.JSONEq(t, `{"patch":{"type":"string"}}`, string(schema["properties"]))
assert.JSONEq(t, `["patch"]`, string(schema["required"]))
}
func TestResponsesToAnthropic_FunctionToolSchemaUnchanged(t *testing.T) {
parameters := json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`)
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "get_weather",
Description: "Get weather",
Parameters: parameters,
}})
require.Len(t, tools, 1)
assert.Empty(t, tools[0].Type)
assert.Equal(t, "get_weather", tools[0].Name)
assert.Equal(t, "Get weather", tools[0].Description)
assert.JSONEq(t, string(parameters), string(tools[0].InputSchema))
}
func TestResponsesToAnthropic_MixedToolsProduceValidAnthropicTools(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{
{
Type: "function",
Name: "read_file",
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`),
},
{
Type: "custom",
Name: "apply_patch",
},
{
Type: "web_search",
},
})
require.Len(t, tools, 3)
assert.Empty(t, tools[0].Type)
assert.Equal(t, "read_file", tools[0].Name)
requireObjectInputSchema(t, tools[0].InputSchema)
assert.Empty(t, tools[1].Type)
assert.Equal(t, "apply_patch", tools[1].Name)
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[1].InputSchema))
assert.Equal(t, "web_search_20250305", tools[2].Type)
assert.Equal(t, "web_search", tools[2].Name)
assert.Empty(t, tools[2].InputSchema)
}
func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "local_shell",
Name: "shell",
}})
require.Len(t, tools, 1)
assert.Equal(t, "local_shell", tools[0].Type)
assert.Equal(t, "shell", tools[0].Name)
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[0].InputSchema))
}