mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
feat(util): add NormalizeClaudeToolInputSchema for input schema normalization
- Introduced `NormalizeClaudeToolInputSchema` in `util` package to standardize tool input schemas for compatibility with Claude. - Replaced local `normalizeClaudeToolInputSchema` implementations in translator functions with the new utility method. - Improved handling of schema validation, union elimination, and property merging. - Removed redundant code in response and chat-completions translators, streamlining schema normalization logic. Closes: #4428
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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__") {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
122
internal/util/claude_schema.go
Normal file
122
internal/util/claude_schema.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
114
internal/util/claude_schema_test.go
Normal file
114
internal/util/claude_schema_test.go
Normal file
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user