Skip to content

Commit f42a3f0

Browse files
Fix responses adapter tool schema normalization
1 parent 72315cd commit f42a3f0

3 files changed

Lines changed: 180 additions & 2 deletions

File tree

internal/providers/anthropic/anthropic_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3668,6 +3668,52 @@ func TestConvertResponsesRequestToAnthropic_TypedInputPromotesSystemRole(t *test
36683668
}
36693669
}
36703670

3671+
func TestConvertResponsesRequestToAnthropic_PreservesMultimodalImageInput(t *testing.T) {
3672+
req, err := convertResponsesRequestToAnthropic(&core.ResponsesRequest{
3673+
Model: "claude-sonnet-4-5-20250929",
3674+
Input: []interface{}{
3675+
map[string]interface{}{
3676+
"role": "user",
3677+
"content": []interface{}{
3678+
map[string]interface{}{
3679+
"type": "input_text",
3680+
"text": "Describe the image.",
3681+
},
3682+
map[string]interface{}{
3683+
"type": "input_image",
3684+
"image_url": map[string]interface{}{
3685+
"url": "data:image/png;base64,ZmFrZQ==",
3686+
},
3687+
},
3688+
},
3689+
},
3690+
},
3691+
})
3692+
if err != nil {
3693+
t.Fatalf("convertResponsesRequestToAnthropic() error = %v", err)
3694+
}
3695+
if len(req.Messages) != 1 {
3696+
t.Fatalf("len(Messages) = %d, want 1", len(req.Messages))
3697+
}
3698+
3699+
blocks, ok := req.Messages[0].Content.([]anthropicContentBlock)
3700+
if !ok {
3701+
t.Fatalf("Messages[0].Content = %#v, want []anthropicContentBlock", req.Messages[0].Content)
3702+
}
3703+
if len(blocks) != 2 {
3704+
t.Fatalf("len(blocks) = %d, want 2", len(blocks))
3705+
}
3706+
if blocks[0].Type != "text" || blocks[0].Text != "Describe the image." {
3707+
t.Fatalf("unexpected text block: %+v", blocks[0])
3708+
}
3709+
if blocks[1].Type != "image" || blocks[1].Source == nil {
3710+
t.Fatalf("unexpected image block: %+v", blocks[1])
3711+
}
3712+
if blocks[1].Source.Type != "base64" || blocks[1].Source.MediaType != "image/png" || blocks[1].Source.Data != "ZmFrZQ==" {
3713+
t.Fatalf("unexpected image source: %+v", blocks[1].Source)
3714+
}
3715+
}
3716+
36713717
func TestConvertResponsesRequestToAnthropic_ToolRoleRequiresToolCallID(t *testing.T) {
36723718
_, err := convertResponsesRequestToAnthropic(&core.ResponsesRequest{
36733719
Model: "claude-sonnet-4-5-20250929",

internal/providers/responses_adapter.go

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ func ConvertResponsesRequestToChat(req *core.ResponsesRequest) (*core.ChatReques
3232
Model: req.Model,
3333
Provider: req.Provider,
3434
Messages: make([]core.Message, 0),
35-
Tools: req.Tools,
36-
ToolChoice: req.ToolChoice,
35+
Tools: normalizeResponsesToolsForChat(req.Tools),
36+
ToolChoice: normalizeResponsesToolChoiceForChat(req.ToolChoice),
3737
ParallelToolCalls: req.ParallelToolCalls,
3838
Temperature: req.Temperature,
3939
Stream: req.Stream,
@@ -61,6 +61,83 @@ func ConvertResponsesRequestToChat(req *core.ResponsesRequest) (*core.ChatReques
6161
return chatReq, nil
6262
}
6363

64+
func normalizeResponsesToolsForChat(tools []map[string]any) []map[string]any {
65+
if len(tools) == 0 {
66+
return nil
67+
}
68+
69+
normalized := make([]map[string]any, 0, len(tools))
70+
for _, tool := range tools {
71+
normalized = append(normalized, normalizeResponsesToolForChat(tool))
72+
}
73+
return normalized
74+
}
75+
76+
func normalizeResponsesToolForChat(tool map[string]any) map[string]any {
77+
if len(tool) == 0 {
78+
return tool
79+
}
80+
81+
toolType, _ := tool["type"].(string)
82+
if strings.TrimSpace(toolType) != "function" {
83+
return cloneStringAnyMap(tool)
84+
}
85+
if _, ok := tool["function"].(map[string]any); ok {
86+
return cloneStringAnyMap(tool)
87+
}
88+
89+
normalized := cloneStringAnyMap(tool)
90+
function := map[string]any{}
91+
for _, key := range []string{"name", "description", "parameters", "strict"} {
92+
if value, ok := normalized[key]; ok {
93+
function[key] = value
94+
delete(normalized, key)
95+
}
96+
}
97+
if len(function) == 0 {
98+
return normalized
99+
}
100+
101+
normalized["function"] = function
102+
return normalized
103+
}
104+
105+
func normalizeResponsesToolChoiceForChat(choice any) any {
106+
choiceMap, ok := choice.(map[string]any)
107+
if !ok {
108+
return choice
109+
}
110+
111+
choiceType, _ := choiceMap["type"].(string)
112+
if strings.TrimSpace(choiceType) != "function" {
113+
return choice
114+
}
115+
if _, ok := choiceMap["function"].(map[string]any); ok {
116+
return cloneStringAnyMap(choiceMap)
117+
}
118+
119+
name, hasName := choiceMap["name"]
120+
if !hasName {
121+
return cloneStringAnyMap(choiceMap)
122+
}
123+
124+
normalized := cloneStringAnyMap(choiceMap)
125+
delete(normalized, "name")
126+
normalized["function"] = map[string]any{"name": name}
127+
return normalized
128+
}
129+
130+
func cloneStringAnyMap(src map[string]any) map[string]any {
131+
if src == nil {
132+
return nil
133+
}
134+
dst := make(map[string]any, len(src))
135+
for key, value := range src {
136+
dst[key] = value
137+
}
138+
return dst
139+
}
140+
64141
// ConvertResponsesInputToMessages converts a Responses API input payload into Chat API messages.
65142
func ConvertResponsesInputToMessages(input interface{}) ([]core.Message, error) {
66143
switch in := input.(type) {

internal/providers/responses_adapter_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,61 @@ func TestConvertResponsesRequestToChat(t *testing.T) {
9898
}
9999
},
100100
},
101+
{
102+
name: "normalizes native responses tool format",
103+
input: &core.ResponsesRequest{
104+
Model: "test-model",
105+
Input: "Hello",
106+
Tools: []map[string]any{
107+
{
108+
"type": "function",
109+
"name": "lookup_weather",
110+
"description": "Get weather by city.",
111+
"parameters": map[string]any{
112+
"type": "object",
113+
"properties": map[string]any{
114+
"city": map[string]any{"type": "string"},
115+
},
116+
},
117+
},
118+
},
119+
ToolChoice: map[string]any{
120+
"type": "function",
121+
"name": "lookup_weather",
122+
},
123+
},
124+
checkFn: func(t *testing.T, req *core.ChatRequest) {
125+
if len(req.Tools) != 1 {
126+
t.Fatalf("len(Tools) = %d, want 1", len(req.Tools))
127+
}
128+
129+
function, ok := req.Tools[0]["function"].(map[string]any)
130+
if !ok {
131+
t.Fatalf("Tools[0].function = %#v, want object", req.Tools[0]["function"])
132+
}
133+
if function["name"] != "lookup_weather" {
134+
t.Fatalf("Tools[0].function.name = %#v, want lookup_weather", function["name"])
135+
}
136+
if _, ok := req.Tools[0]["name"]; ok {
137+
t.Fatalf("Tools[0].name should be wrapped into function, got %+v", req.Tools[0])
138+
}
139+
140+
toolChoice, ok := req.ToolChoice.(map[string]any)
141+
if !ok {
142+
t.Fatalf("ToolChoice = %#v, want object", req.ToolChoice)
143+
}
144+
selected, ok := toolChoice["function"].(map[string]any)
145+
if !ok {
146+
t.Fatalf("ToolChoice.function = %#v, want object", toolChoice["function"])
147+
}
148+
if selected["name"] != "lookup_weather" {
149+
t.Fatalf("ToolChoice.function.name = %#v, want lookup_weather", selected["name"])
150+
}
151+
if _, ok := toolChoice["name"]; ok {
152+
t.Fatalf("ToolChoice.name should be wrapped into function, got %+v", toolChoice)
153+
}
154+
},
155+
},
101156
{
102157
name: "typed multimodal input",
103158
input: &core.ResponsesRequest{

0 commit comments

Comments
 (0)