fix(apicompat): flatten named chat tool_choice for Responses

Chat Completions names a forced function as
{"type":"function","function":{"name":"X"}}, while the Responses API
expects {"type":"function","name":"X"}. ChatCompletionsToResponses passed
the Chat shape through unchanged, so OpenAI API-key accounts on
/v1/chat/completions forwarded the nested shape to /v1/responses and the
upstream rejected the request.

Flatten the named Chat choice; strings and objects already in the
Responses shape are kept as-is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPHINX998
2026-10-01 21:51:37 +08:00
co-authored by Claude Opus 5.5
parent d6adebd22d
commit f3d752693f
2 changed files with 74 additions and 2 deletions
@@ -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.
//
@@ -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))
})
}
}