diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index d1f3bccc00..46cfda43b3 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -88,10 +88,11 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, out.Tools = convertChatToolsToResponses(req.Tools, req.Functions) } - // tool_choice: already compatible format — pass through directly. + // tool_choice: strings and Responses-shaped objects pass through; a named + // Chat choice nests the name under "function" and must be flattened. // Legacy function_call needs mapping. if len(req.ToolChoice) > 0 { - out.ToolChoice = req.ToolChoice + out.ToolChoice = convertChatToolChoiceToResponses(req.ToolChoice) } else if len(req.FunctionCall) > 0 { tc, err := convertChatFunctionCallToToolChoice(req.FunctionCall) if err != nil { @@ -490,6 +491,34 @@ func defaultStrictFalse(src *bool) *bool { return src } +// convertChatToolChoiceToResponses maps a Chat Completions tool_choice to the +// Responses API shape. +// +// {"type":"function","function":{"name":"X"}} → {"type":"function","name":"X"} +// +// Strings ("auto", "none", "required") and objects that already use the +// Responses shape are returned unchanged. +func convertChatToolChoiceToResponses(raw json.RawMessage) json.RawMessage { + var choice struct { + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + } `json:"function"` + } + if err := json.Unmarshal(raw, &choice); err != nil || choice.Type != "function" || choice.Function == nil { + return raw + } + name := strings.TrimSpace(choice.Function.Name) + if name == "" { + return raw + } + flat, err := json.Marshal(map[string]string{"type": "function", "name": name}) + if err != nil { + return raw + } + return flat +} + // convertChatFunctionCallToToolChoice maps the legacy function_call field to a // Responses API tool_choice value. // diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses_tool_choice_test.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses_tool_choice_test.go new file mode 100644 index 0000000000..736a0b5acf --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses_tool_choice_test.go @@ -0,0 +1,43 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// Chat Completions nests a named choice under "function"; the Responses API +// expects the name at the top level and rejects the nested shape. +func TestChatCompletionsToResponses_ToolChoiceShape(t *testing.T) { + tests := []struct { + name string + choice string + want string + }{ + {name: "named chat function is flattened", choice: `{"type":"function","function":{"name":"get_weather"}}`, want: `{"type":"function","name":"get_weather"}`}, + {name: "flat responses function is kept", choice: `{"type":"function","name":"get_weather"}`, want: `{"type":"function","name":"get_weather"}`}, + {name: "auto string is kept", choice: `"auto"`, want: `"auto"`}, + {name: "required string is kept", choice: `"required"`, want: `"required"`}, + {name: "none string is kept", choice: `"none"`, want: `"none"`}, + {name: "allowed_tools policy is kept", choice: `{"type":"allowed_tools","mode":"auto","tools":[{"type":"function","name":"get_weather"}]}`, want: `{"type":"allowed_tools","mode":"auto","tools":[{"type":"function","name":"get_weather"}]}`}, + {name: "function without name is kept", choice: `{"type":"function","function":{}}`, want: `{"type":"function","function":{}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"weather?"`)}}, + Tools: []ChatTool{{ + Type: "function", + Function: &ChatFunction{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object"}`)}, + }}, + ToolChoice: json.RawMessage(tt.choice), + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.JSONEq(t, tt.want, string(resp.ToolChoice)) + }) + } +}