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..fca3cf1f 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 +}