diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index facfe572..e8b25c2b 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -991,9 +991,40 @@ func TestAnthropicToResponses_ToolChoiceSpecific(t *testing.T) { var tc map[string]any require.NoError(t, json.Unmarshal(resp.ToolChoice, &tc)) assert.Equal(t, "function", tc["type"]) - fn, ok := tc["function"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "get_weather", fn["name"]) + assert.Equal(t, "get_weather", tc["name"]) + assert.NotContains(t, tc, "function") +} + +func TestResponsesToAnthropicRequest_ToolChoiceFunctionName(t *testing.T) { + req := &ResponsesRequest{ + Model: "gpt-5.2", + Input: json.RawMessage(`[{"role":"user","content":"Hello"}]`), + ToolChoice: json.RawMessage(`{"type":"function","name":"get_weather"}`), + } + + resp, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + + var tc map[string]string + require.NoError(t, json.Unmarshal(resp.ToolChoice, &tc)) + assert.Equal(t, "tool", tc["type"]) + assert.Equal(t, "get_weather", tc["name"]) +} + +func TestResponsesToAnthropicRequest_ToolChoiceLegacyFunctionName(t *testing.T) { + req := &ResponsesRequest{ + Model: "gpt-5.2", + Input: json.RawMessage(`[{"role":"user","content":"Hello"}]`), + ToolChoice: json.RawMessage(`{"type":"function","function":{"name":"get_weather"}}`), + } + + resp, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + + var tc map[string]string + require.NoError(t, json.Unmarshal(resp.ToolChoice, &tc)) + assert.Equal(t, "tool", tc["type"]) + assert.Equal(t, "get_weather", tc["name"]) } // --------------------------------------------------------------------------- diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index 485262e8..268f9f22 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -75,7 +75,7 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { // {"type":"auto"} → "auto" // {"type":"any"} → "required" // {"type":"none"} → "none" -// {"type":"tool","name":"X"} → {"type":"function","function":{"name":"X"}} +// {"type":"tool","name":"X"} → {"type":"function","name":"X"} func convertAnthropicToolChoiceToResponses(raw json.RawMessage) (json.RawMessage, error) { var tc struct { Type string `json:"type"` @@ -94,8 +94,8 @@ func convertAnthropicToolChoiceToResponses(raw json.RawMessage) (json.RawMessage return json.Marshal("none") case "tool": return json.Marshal(map[string]any{ - "type": "function", - "function": map[string]string{"name": tc.Name}, + "type": "function", + "name": tc.Name, }) default: // Pass through unknown types as-is diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index c140449a..35d42999 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -281,6 +281,8 @@ func TestChatCompletionsToResponses_LegacyFunctions(t *testing.T) { var tc map[string]any require.NoError(t, json.Unmarshal(resp.ToolChoice, &tc)) assert.Equal(t, "function", tc["type"]) + assert.Equal(t, "get_weather", tc["name"]) + assert.NotContains(t, tc, "function") } func TestChatCompletionsToResponses_ServiceTier(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index c2725406..64ef5781 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -420,7 +420,7 @@ func convertChatToolsToResponses(tools []ChatTool, functions []ChatFunction) []R // // "auto" → "auto" // "none" → "none" -// {"name":"X"} → {"type":"function","function":{"name":"X"}} +// {"name":"X"} → {"type":"function","name":"X"} func convertChatFunctionCallToToolChoice(raw json.RawMessage) (json.RawMessage, error) { // Try string first ("auto", "none", etc.) — pass through as-is. var s string @@ -436,7 +436,7 @@ func convertChatFunctionCallToToolChoice(raw json.RawMessage) (json.RawMessage, return nil, err } return json.Marshal(map[string]any{ - "type": "function", - "function": map[string]string{"name": obj.Name}, + "type": "function", + "name": obj.Name, }) } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 49426b88..8fa652f2 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -428,7 +428,8 @@ func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage { // "auto" → {"type":"auto"} // "required" → {"type":"any"} // "none" → {"type":"none"} -// {"type":"function","function":{"name":"X"}} → {"type":"tool","name":"X"} +// {"type":"function","name":"X"} → {"type":"tool","name":"X"} +// {"type":"function","function":{"name":"X"}} → {"type":"tool","name":"X"} // legacy func convertResponsesToAnthropicToolChoice(raw json.RawMessage) (json.RawMessage, error) { // Try as string first var s string @@ -448,14 +449,22 @@ func convertResponsesToAnthropicToolChoice(raw json.RawMessage) (json.RawMessage // Try as object with type=function var tc struct { Type string `json:"type"` + Name string `json:"name"` Function struct { Name string `json:"name"` } `json:"function"` } - if err := json.Unmarshal(raw, &tc); err == nil && tc.Type == "function" && tc.Function.Name != "" { + if err := json.Unmarshal(raw, &tc); err == nil && tc.Type == "function" { + name := strings.TrimSpace(tc.Name) + if name == "" { + name = strings.TrimSpace(tc.Function.Name) + } + if name == "" { + return raw, nil + } return json.Marshal(map[string]string{ "type": "tool", - "name": tc.Function.Name, + "name": name, }) } diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index e765d7e9..0fda16b0 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -141,9 +141,7 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact if name, ok := fcObj["name"].(string); ok && strings.TrimSpace(name) != "" { reqBody["tool_choice"] = map[string]any{ "type": "function", - "function": map[string]any{ - "name": name, - }, + "name": name, } } } @@ -219,9 +217,38 @@ func normalizeCodexToolChoice(reqBody map[string]any) bool { return false } choiceType := strings.TrimSpace(firstNonEmptyString(choiceMap["type"])) - if choiceType == "" || codexToolsContainType(reqBody["tools"], choiceType) { + if choiceType == "" { return false } + modified := false + if choiceType == "function" { + name := strings.TrimSpace(firstNonEmptyString(choiceMap["name"])) + if name == "" { + if function, ok := choiceMap["function"].(map[string]any); ok { + name = strings.TrimSpace(firstNonEmptyString(function["name"])) + } + } + if name == "" { + reqBody["tool_choice"] = "auto" + return true + } + if strings.TrimSpace(firstNonEmptyString(choiceMap["name"])) != name { + choiceMap["name"] = name + modified = true + } + if _, ok := choiceMap["function"]; ok { + delete(choiceMap, "function") + modified = true + } + if !codexToolsContainFunctionName(reqBody["tools"], name) { + reqBody["tool_choice"] = "auto" + return true + } + return modified + } + if codexToolsContainType(reqBody["tools"], choiceType) { + return modified + } reqBody["tool_choice"] = "auto" return true } @@ -243,6 +270,33 @@ func codexToolsContainType(rawTools any, toolType string) bool { return false } +func codexToolsContainFunctionName(rawTools any, name string) bool { + tools, ok := rawTools.([]any) + if !ok || strings.TrimSpace(name) == "" { + return false + } + normalizedName := strings.TrimSpace(name) + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + if strings.TrimSpace(firstNonEmptyString(tool["type"])) != "function" { + continue + } + toolName := strings.TrimSpace(firstNonEmptyString(tool["name"])) + if toolName == "" { + if function, ok := tool["function"].(map[string]any); ok { + toolName = strings.TrimSpace(firstNonEmptyString(function["name"])) + } + } + if toolName == normalizedName { + return true + } + } + return false +} + func normalizeCodexToolRoleMessages(input []any) ([]any, bool) { if len(input) == 0 { return input, false diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 75f5c55c..8d9f8574 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -249,6 +249,44 @@ func TestApplyCodexOAuthTransform_PreservesKnownToolChoice(t *testing.T) { require.Equal(t, "custom", choice["type"]) } +func TestApplyCodexOAuthTransform_NormalizesLegacyFunctionToolChoice(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.4", + "tools": []any{ + map[string]any{"type": "function", "name": "shell"}, + }, + "tool_choice": map[string]any{ + "type": "function", + "function": map[string]any{"name": "shell"}, + }, + } + + applyCodexOAuthTransform(reqBody, true, false) + + choice, ok := reqBody["tool_choice"].(map[string]any) + require.True(t, ok) + require.Equal(t, "function", choice["type"]) + require.Equal(t, "shell", choice["name"]) + require.NotContains(t, choice, "function") +} + +func TestApplyCodexOAuthTransform_DowngradesMissingFunctionToolChoice(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.4", + "tools": []any{ + map[string]any{"type": "function", "name": "shell"}, + }, + "tool_choice": map[string]any{ + "type": "function", + "function": map[string]any{"name": "missing"}, + }, + } + + applyCodexOAuthTransform(reqBody, true, false) + + require.Equal(t, "auto", reqBody["tool_choice"]) +} + func TestApplyCodexOAuthTransform_AddsFallbackNameForFunctionCallInput(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.4",