Skip to content

Commit 3878a5a

Browse files
authored
Merge pull request Wei-Shaw#1164 from GuangYiDing/fix/normalize-tool-parameters-schema
fix: Anthropic tool schema 转换时补充缺失的 properties 字段
2 parents 525cdb8 + e443a6a commit 3878a5a

2 files changed

Lines changed: 145 additions & 1 deletion

File tree

backend/internal/pkg/apicompat/anthropic_responses_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,3 +1008,114 @@ func TestAnthropicToResponses_ImageEmptyMediaType(t *testing.T) {
10081008
// Should default to image/png when media_type is empty.
10091009
assert.Equal(t, "data:image/png;base64,iVBOR", parts[0].ImageURL)
10101010
}
1011+
1012+
// ---------------------------------------------------------------------------
1013+
// normalizeToolParameters tests
1014+
// ---------------------------------------------------------------------------
1015+
1016+
func TestNormalizeToolParameters(t *testing.T) {
1017+
tests := []struct {
1018+
name string
1019+
input json.RawMessage
1020+
expected string
1021+
}{
1022+
{
1023+
name: "nil input",
1024+
input: nil,
1025+
expected: `{"type":"object","properties":{}}`,
1026+
},
1027+
{
1028+
name: "empty input",
1029+
input: json.RawMessage(``),
1030+
expected: `{"type":"object","properties":{}}`,
1031+
},
1032+
{
1033+
name: "null input",
1034+
input: json.RawMessage(`null`),
1035+
expected: `{"type":"object","properties":{}}`,
1036+
},
1037+
{
1038+
name: "object without properties",
1039+
input: json.RawMessage(`{"type":"object"}`),
1040+
expected: `{"type":"object","properties":{}}`,
1041+
},
1042+
{
1043+
name: "object with properties",
1044+
input: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`),
1045+
expected: `{"type":"object","properties":{"city":{"type":"string"}}}`,
1046+
},
1047+
{
1048+
name: "non-object type",
1049+
input: json.RawMessage(`{"type":"string"}`),
1050+
expected: `{"type":"string"}`,
1051+
},
1052+
{
1053+
name: "object with additional fields preserved",
1054+
input: json.RawMessage(`{"type":"object","required":["name"]}`),
1055+
expected: `{"type":"object","required":["name"],"properties":{}}`,
1056+
},
1057+
{
1058+
name: "invalid JSON passthrough",
1059+
input: json.RawMessage(`not json`),
1060+
expected: `not json`,
1061+
},
1062+
}
1063+
1064+
for _, tt := range tests {
1065+
t.Run(tt.name, func(t *testing.T) {
1066+
result := normalizeToolParameters(tt.input)
1067+
if tt.name == "invalid JSON passthrough" {
1068+
assert.Equal(t, tt.expected, string(result))
1069+
} else {
1070+
assert.JSONEq(t, tt.expected, string(result))
1071+
}
1072+
})
1073+
}
1074+
}
1075+
1076+
func TestAnthropicToResponses_ToolWithoutProperties(t *testing.T) {
1077+
req := &AnthropicRequest{
1078+
Model: "gpt-5.2",
1079+
MaxTokens: 1024,
1080+
Messages: []AnthropicMessage{
1081+
{Role: "user", Content: json.RawMessage(`"Hello"`)},
1082+
},
1083+
Tools: []AnthropicTool{
1084+
{Name: "mcp__pencil__get_style_guide_tags", Description: "Get style tags", InputSchema: json.RawMessage(`{"type":"object"}`)},
1085+
},
1086+
}
1087+
1088+
resp, err := AnthropicToResponses(req)
1089+
require.NoError(t, err)
1090+
1091+
require.Len(t, resp.Tools, 1)
1092+
assert.Equal(t, "function", resp.Tools[0].Type)
1093+
assert.Equal(t, "mcp__pencil__get_style_guide_tags", resp.Tools[0].Name)
1094+
1095+
// Parameters must have "properties" field after normalization.
1096+
var params map[string]json.RawMessage
1097+
require.NoError(t, json.Unmarshal(resp.Tools[0].Parameters, &params))
1098+
assert.Contains(t, params, "properties")
1099+
}
1100+
1101+
func TestAnthropicToResponses_ToolWithNilSchema(t *testing.T) {
1102+
req := &AnthropicRequest{
1103+
Model: "gpt-5.2",
1104+
MaxTokens: 1024,
1105+
Messages: []AnthropicMessage{
1106+
{Role: "user", Content: json.RawMessage(`"Hello"`)},
1107+
},
1108+
Tools: []AnthropicTool{
1109+
{Name: "simple_tool", Description: "A tool"},
1110+
},
1111+
}
1112+
1113+
resp, err := AnthropicToResponses(req)
1114+
require.NoError(t, err)
1115+
1116+
require.Len(t, resp.Tools, 1)
1117+
var params map[string]json.RawMessage
1118+
require.NoError(t, json.Unmarshal(resp.Tools[0].Parameters, &params))
1119+
assert.JSONEq(t, `"object"`, string(params["type"]))
1120+
assert.JSONEq(t, `{}`, string(params["properties"]))
1121+
}

backend/internal/pkg/apicompat/anthropic_to_responses.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,8 +409,41 @@ func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool {
409409
Type: "function",
410410
Name: t.Name,
411411
Description: t.Description,
412-
Parameters: t.InputSchema,
412+
Parameters: normalizeToolParameters(t.InputSchema),
413413
})
414414
}
415415
return out
416416
}
417+
418+
// normalizeToolParameters ensures the tool parameter schema is valid for
419+
// OpenAI's Responses API, which requires "properties" on object schemas.
420+
//
421+
// - nil/empty → {"type":"object","properties":{}}
422+
// - type=object without properties → adds "properties": {}
423+
// - otherwise → returned unchanged
424+
func normalizeToolParameters(schema json.RawMessage) json.RawMessage {
425+
if len(schema) == 0 || string(schema) == "null" {
426+
return json.RawMessage(`{"type":"object","properties":{}}`)
427+
}
428+
429+
var m map[string]json.RawMessage
430+
if err := json.Unmarshal(schema, &m); err != nil {
431+
return schema
432+
}
433+
434+
typ := m["type"]
435+
if string(typ) != `"object"` {
436+
return schema
437+
}
438+
439+
if _, ok := m["properties"]; ok {
440+
return schema
441+
}
442+
443+
m["properties"] = json.RawMessage(`{}`)
444+
out, err := json.Marshal(m)
445+
if err != nil {
446+
return schema
447+
}
448+
return out
449+
}

0 commit comments

Comments
 (0)