From 40c8252734b6282cc41cf846cf9cdac9cbd305b7 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Fri, 26 Jun 2026 14:08:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(apicompat):=20=E8=A7=84=E8=8C=83=E5=8C=96?= =?UTF-8?q?=20custom=20=E5=B7=A5=E5=85=B7=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../responses_to_anthropic_request.go | 41 +++++- .../responses_to_anthropic_tools_test.go | 137 ++++++++++++++++++ 2 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 672ad80c44..6da249ed25 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -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. diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go new file mode 100644 index 0000000000..1b732a10e2 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go @@ -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)) +}