From 963494ec6f88cacde8fb9f047de5b72e0ebe5154 Mon Sep 17 00:00:00 2001 From: Rose Ding Date: Thu, 19 Mar 2026 21:08:20 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Anthropic=20tool=20schema=20?= =?UTF-8?q?=E8=BD=AC=20Responses=20API=20=E6=97=B6=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=20properties=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 Claude Code 发来的 MCP tool 的 input_schema 为 {"type":"object"} 且缺少 properties 字段时,OpenAI Codex 后端会拒绝并报错: Invalid schema for function '...': object schema missing properties. 新增 normalizeToolParameters 函数,在 convertAnthropicToolsToResponses 中 对每个 tool 的 InputSchema 做规范化处理后再赋给 Parameters。 Co-Authored-By: Claude Opus 4.6 --- .../pkg/apicompat/anthropic_responses_test.go | 111 ++++++++++++++++++ .../pkg/apicompat/anthropic_to_responses.go | 35 +++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 2db65572..34f5b60c 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1008,3 +1008,114 @@ func TestAnthropicToResponses_ImageEmptyMediaType(t *testing.T) { // Should default to image/png when media_type is empty. assert.Equal(t, "data:image/png;base64,iVBOR", parts[0].ImageURL) } + +// --------------------------------------------------------------------------- +// normalizeToolParameters tests +// --------------------------------------------------------------------------- + +func TestNormalizeToolParameters(t *testing.T) { + tests := []struct { + name string + input json.RawMessage + expected string + }{ + { + name: "nil input", + input: nil, + expected: `{"type":"object","properties":{}}`, + }, + { + name: "empty input", + input: json.RawMessage(``), + expected: `{"type":"object","properties":{}}`, + }, + { + name: "null input", + input: json.RawMessage(`null`), + expected: `{"type":"object","properties":{}}`, + }, + { + name: "object without properties", + input: json.RawMessage(`{"type":"object"}`), + expected: `{"type":"object","properties":{}}`, + }, + { + name: "object with properties", + input: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`), + expected: `{"type":"object","properties":{"city":{"type":"string"}}}`, + }, + { + name: "non-object type", + input: json.RawMessage(`{"type":"string"}`), + expected: `{"type":"string"}`, + }, + { + name: "object with additional fields preserved", + input: json.RawMessage(`{"type":"object","required":["name"]}`), + expected: `{"type":"object","required":["name"],"properties":{}}`, + }, + { + name: "invalid JSON passthrough", + input: json.RawMessage(`not json`), + expected: `not json`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeToolParameters(tt.input) + if tt.name == "invalid JSON passthrough" { + assert.Equal(t, tt.expected, string(result)) + } else { + assert.JSONEq(t, tt.expected, string(result)) + } + }) + } +} + +func TestAnthropicToResponses_ToolWithoutProperties(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.2", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Tools: []AnthropicTool{ + {Name: "mcp__pencil__get_style_guide_tags", Description: "Get style tags", InputSchema: json.RawMessage(`{"type":"object"}`)}, + }, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + + require.Len(t, resp.Tools, 1) + assert.Equal(t, "function", resp.Tools[0].Type) + assert.Equal(t, "mcp__pencil__get_style_guide_tags", resp.Tools[0].Name) + + // Parameters must have "properties" field after normalization. + var params map[string]json.RawMessage + require.NoError(t, json.Unmarshal(resp.Tools[0].Parameters, ¶ms)) + assert.Contains(t, params, "properties") +} + +func TestAnthropicToResponses_ToolWithNilSchema(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.2", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`"Hello"`)}, + }, + Tools: []AnthropicTool{ + {Name: "simple_tool", Description: "A tool"}, + }, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + + require.Len(t, resp.Tools, 1) + var params map[string]json.RawMessage + require.NoError(t, json.Unmarshal(resp.Tools[0].Parameters, ¶ms)) + assert.JSONEq(t, `"object"`, string(params["type"])) + assert.JSONEq(t, `{}`, string(params["properties"])) +} diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index 0a747869..a8106da4 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -409,8 +409,41 @@ func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool { Type: "function", Name: t.Name, Description: t.Description, - Parameters: t.InputSchema, + Parameters: normalizeToolParameters(t.InputSchema), }) } return out } + +// normalizeToolParameters ensures the tool parameter schema is valid for +// OpenAI's Responses API, which requires "properties" on object schemas. +// +// - nil/empty → {"type":"object","properties":{}} +// - type=object without properties → adds "properties": {} +// - otherwise → returned unchanged +func normalizeToolParameters(schema json.RawMessage) json.RawMessage { + if len(schema) == 0 || string(schema) == "null" { + return json.RawMessage(`{"type":"object","properties":{}}`) + } + + var m map[string]json.RawMessage + if err := json.Unmarshal(schema, &m); err != nil { + return schema + } + + typ, _ := m["type"] + if string(typ) != `"object"` { + return schema + } + + if _, ok := m["properties"]; ok { + return schema + } + + m["properties"] = json.RawMessage(`{}`) + out, err := json.Marshal(m) + if err != nil { + return schema + } + return out +} From e443a6a1eaa66b3b32a12df15a534e5a5b460ccc Mon Sep 17 00:00:00 2001 From: Rose Ding Date: Thu, 19 Mar 2026 21:14:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=20staticcheck=20S?= =?UTF-8?q?1005=20=E8=AD=A6=E5=91=8A=E7=9A=84=E5=A4=9A=E4=BD=99=20blank=20?= =?UTF-8?q?identifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- backend/internal/pkg/apicompat/anthropic_to_responses.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index a8106da4..fca3cf1f 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -431,7 +431,7 @@ func normalizeToolParameters(schema json.RawMessage) json.RawMessage { return schema } - typ, _ := m["type"] + typ := m["type"] if string(typ) != `"object"` { return schema }