diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 7d16eaf6a..e0957b2d2 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -305,9 +305,9 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream // Convert parameters schema for the tool if parameters := function.Get("parameters"); parameters.Exists() { - anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", []byte(parameters.Raw)) + anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw))) } else if parameters := function.Get("parametersJsonSchema"); parameters.Exists() { - anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", []byte(parameters.Raw)) + anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw))) } anthropicTool = common.AttachCacheControl(anthropicTool, tool) if !gjson.GetBytes(anthropicTool, "cache_control").Exists() { diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index 84ae0e27c..801b8e39e 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -382,6 +382,57 @@ func TestConvertOpenAIRequestToClaude_PreservesToolCacheControl(t *testing.T) { } } +func TestConvertOpenAIRequestToClaude_NormalizesRootToolSchemaUnions(t *testing.T) { + inputJSON := `{ + "model":"claude-sonnet-4-5", + "messages":[{"role":"user","content":"hi"}], + "tools":[ + { + "type":"function", + "function":{ + "name":"without_type", + "parameters":{ + "anyOf":[ + {"type":"object","properties":{"a":{"type":"string"}}}, + {"type":"object","properties":{"b":{"type":"string"}}} + ] + } + } + }, + { + "type":"function", + "function":{ + "name":"constraint_union", + "parametersJsonSchema":{ + "type":"object", + "properties":{"a":{"type":"string"},"b":{"type":"string"}}, + "anyOf":[{"required":["a"]},{"required":["b"]}] + } + } + } + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + root := gjson.ParseBytes(result) + + for _, toolName := range []string{"without_type", "constraint_union"} { + schema := root.Get(`tools.#(name=="` + toolName + `").input_schema`) + if got := schema.Get("type").String(); got != "object" { + t.Fatalf("%s input_schema.type = %q, want object. Output: %s", toolName, got, result) + } + if schema.Get("anyOf").Exists() { + t.Fatalf("%s input_schema should not contain root anyOf. Output: %s", toolName, result) + } + if !schema.Get("properties.a").Exists() || !schema.Get("properties.b").Exists() { + t.Fatalf("%s input_schema should contain properties a and b. Output: %s", toolName, result) + } + if schema.Get("required").Exists() { + t.Fatalf("%s input_schema should not merge alternative required fields. Output: %s", toolName, result) + } + } +} + func TestConvertOpenAIRequestToClaude_PartCacheControlWinsOverMessageLevel(t *testing.T) { inputJSON := `{ "model": "gpt-4.1", diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 57d536450..310ac488e 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -684,7 +684,7 @@ func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string if d := responsesToolDescription(tool); d != "" { tJSON, _ = sjson.SetBytes(tJSON, "description", d) } - tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool))) + tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(responsesToolParameters(tool).Raw))) tJSON = common.AttachCacheControl(tJSON, tool) if !gjson.GetBytes(tJSON, "cache_control").Exists() { tJSON = common.AttachCacheControl(tJSON, tool.Get("function")) @@ -744,27 +744,6 @@ func responsesToolParameters(tool gjson.Result) gjson.Result { return gjson.Result{} } -func normalizeClaudeToolInputSchema(parameters gjson.Result) []byte { - raw := strings.TrimSpace(parameters.Raw) - if raw == "" || raw == "null" || !gjson.Valid(raw) { - return []byte(`{"type":"object","properties":{}}`) - } - result := gjson.Parse(raw) - if !result.IsObject() { - return []byte(`{"type":"object","properties":{}}`) - } - schema := []byte(raw) - schemaType := result.Get("type").String() - if schemaType == "" { - schema, _ = sjson.SetBytes(schema, "type", "object") - schemaType = "object" - } - if schemaType == "object" && !result.Get("properties").Exists() { - schema, _ = sjson.SetRawBytes(schema, "properties", []byte(`{}`)) - } - return schema -} - func qualifyResponsesNamespaceToolName(namespaceName, childName string) string { childName = strings.TrimSpace(childName) if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") { diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index cf38ef7ee..556bb2519 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -284,6 +284,38 @@ func TestConvertOpenAIResponsesRequestToClaude_DropsApplyPatchCustomTool(t *test } } +func TestConvertOpenAIResponsesRequestToClaude_NormalizesRootToolSchemaUnion(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}], + "tools":[{ + "type":"function", + "name":"lookup", + "parameters":{ + "type":"object", + "properties":{"query":{"type":"string"},"id":{"type":"string"}}, + "oneOf":[{"required":["query"]},{"required":["id"]}] + } + }] + }`) + + out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false) + schema := gjson.GetBytes(out, "tools.0.input_schema") + + if got := schema.Get("type").String(); got != "object" { + t.Fatalf("input_schema.type = %q, want object. Output: %s", got, string(out)) + } + if schema.Get("oneOf").Exists() { + t.Fatalf("input_schema should not contain root oneOf. Output: %s", string(out)) + } + if !schema.Get("properties.query").Exists() || !schema.Get("properties.id").Exists() { + t.Fatalf("input_schema should preserve query and id properties. Output: %s", string(out)) + } + if schema.Get("required").Exists() { + t.Fatalf("input_schema should not merge alternative required fields. Output: %s", string(out)) + } +} + func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) { t.Helper() channelBlock := []byte{} diff --git a/internal/util/claude_schema.go b/internal/util/claude_schema.go new file mode 100644 index 000000000..c8ea0a7d2 --- /dev/null +++ b/internal/util/claude_schema.go @@ -0,0 +1,122 @@ +package util + +import "encoding/json" + +const emptyClaudeToolInputSchema = `{"type":"object","properties":{}}` + +// NormalizeClaudeToolInputSchema makes a JSON Schema compatible with Claude's +// requirement that a tool input schema is an object without root-level unions. +func NormalizeClaudeToolInputSchema(schema []byte) []byte { + var root map[string]json.RawMessage + if len(schema) == 0 || json.Unmarshal(schema, &root) != nil || root == nil { + return []byte(emptyClaudeToolInputSchema) + } + + properties := claudeSchemaObject(root["properties"]) + for _, unionName := range []string{"anyOf", "oneOf", "allOf"} { + unionRaw, exists := root[unionName] + if !exists { + continue + } + delete(root, unionName) + + var branches []json.RawMessage + if json.Unmarshal(unionRaw, &branches) != nil { + continue + } + for _, branchRaw := range branches { + var branch map[string]json.RawMessage + if json.Unmarshal(branchRaw, &branch) != nil || !claudeSchemaCanBeObject(branch) { + continue + } + for name, property := range claudeSchemaObject(branch["properties"]) { + if _, exists = properties[name]; !exists { + properties[name] = property + } + } + if unionName == "allOf" { + mergeClaudeSchemaRequired(root, branch["required"]) + } + } + } + + root["type"] = json.RawMessage(`"object"`) + propertiesRaw, errMarshalProperties := json.Marshal(properties) + if errMarshalProperties != nil { + return []byte(emptyClaudeToolInputSchema) + } + root["properties"] = propertiesRaw + + normalized, errMarshalRoot := json.Marshal(root) + if errMarshalRoot != nil { + return []byte(emptyClaudeToolInputSchema) + } + return normalized +} + +func claudeSchemaObject(raw json.RawMessage) map[string]json.RawMessage { + object := make(map[string]json.RawMessage) + if len(raw) == 0 { + return object + } + if errUnmarshal := json.Unmarshal(raw, &object); errUnmarshal != nil || object == nil { + return make(map[string]json.RawMessage) + } + return object +} + +func claudeSchemaCanBeObject(schema map[string]json.RawMessage) bool { + typeRaw, exists := schema["type"] + if !exists { + return true + } + + var schemaType string + if json.Unmarshal(typeRaw, &schemaType) == nil { + return schemaType == "object" + } + + var schemaTypes []string + if json.Unmarshal(typeRaw, &schemaTypes) != nil { + return false + } + for _, candidate := range schemaTypes { + if candidate == "object" { + return true + } + } + return false +} + +func mergeClaudeSchemaRequired(root map[string]json.RawMessage, branchRequired json.RawMessage) { + var required []string + if rootRequired, exists := root["required"]; exists { + if errUnmarshal := json.Unmarshal(rootRequired, &required); errUnmarshal != nil { + required = nil + } + } + + var branchNames []string + if json.Unmarshal(branchRequired, &branchNames) != nil { + return + } + + seen := make(map[string]struct{}, len(required)+len(branchNames)) + for _, name := range required { + seen[name] = struct{}{} + } + for _, name := range branchNames { + if _, exists := seen[name]; exists { + continue + } + required = append(required, name) + seen[name] = struct{}{} + } + if len(required) == 0 { + return + } + requiredRaw, errMarshal := json.Marshal(required) + if errMarshal == nil { + root["required"] = requiredRaw + } +} diff --git a/internal/util/claude_schema_test.go b/internal/util/claude_schema_test.go new file mode 100644 index 000000000..b10836c1f --- /dev/null +++ b/internal/util/claude_schema_test.go @@ -0,0 +1,114 @@ +package util + +import "testing" + +func TestNormalizeClaudeToolInputSchema(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "root anyOf without type", + input: `{ + "anyOf": [ + {"type":"object","properties":{"a":{"type":"string"}}}, + {"type":"object","properties":{"b":{"type":"integer"}}} + ] + }`, + expected: `{ + "type":"object", + "properties":{ + "a":{"type":"string"}, + "b":{"type":"integer"} + } + }`, + }, + { + name: "root oneOf keeps nested union", + input: `{ + "type":"object", + "properties":{ + "nested":{"oneOf":[{"type":"string"},{"type":"number"}]} + }, + "oneOf":[ + {"properties":{"a":{"type":"string"}},"required":["a"]}, + {"properties":{"b":{"type":"string"}},"required":["b"]} + ] + }`, + expected: `{ + "type":"object", + "properties":{ + "nested":{"oneOf":[{"type":"string"},{"type":"number"}]}, + "a":{"type":"string"}, + "b":{"type":"string"} + } + }`, + }, + { + name: "root anyOf drops alternative required fields", + input: `{ + "type":"object", + "properties":{"a":{"type":"string"},"b":{"type":"string"}}, + "anyOf":[{"required":["a"]},{"required":["b"]}] + }`, + expected: `{ + "type":"object", + "properties":{"a":{"type":"string"},"b":{"type":"string"}} + }`, + }, + { + name: "root allOf merges properties and required fields", + input: `{ + "type":"object", + "properties":{"base":{"type":"boolean"}}, + "required":["base"], + "allOf":[ + {"type":"object","properties":{"a":{"type":"string"}},"required":["a"]}, + {"properties":{"b":{"type":"integer"}},"required":["a","b"]} + ] + }`, + expected: `{ + "type":"object", + "properties":{ + "base":{"type":"boolean"}, + "a":{"type":"string"}, + "b":{"type":"integer"} + }, + "required":["base","a","b"] + }`, + }, + { + name: "ordinary object schema", + input: `{ + "type":"object", + "properties":{"query":{"type":"string"}}, + "required":["query"], + "additionalProperties":false + }`, + expected: `{ + "type":"object", + "properties":{"query":{"type":"string"}}, + "required":["query"], + "additionalProperties":false + }`, + }, + { + name: "invalid schema", + input: `{"type":`, + expected: `{"type":"object","properties":{}}`, + }, + { + name: "boolean schema", + input: `true`, + expected: `{"type":"object","properties":{}}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := NormalizeClaudeToolInputSchema([]byte(test.input)) + compareJSON(t, test.expected, string(actual)) + }) + } +}