diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go --- a/internal/util/sanitize_test.go +++ b/internal/util/sanitize_test.go @@ -2,6 +2,8 @@ import ( "testing" + + "github.com/tidwall/gjson" ) func TestSanitizeFunctionName(t *testing.T) { @@ -94,7 +96,17 @@ } }) - t.Run("collision keeps first mapping", func(t *testing.T) { + t.Run("legacy map ignores nested OpenAI tools", func(t *testing.T) { + raw := []byte(`{"tools":[ + {"type":"function","function":{"name":"web/search"}}, + {"type":"web_search","name":"web_search"} + ]}`) + if m := SanitizedToolNameMap(raw); m != nil { + t.Fatalf("legacy map = %v, want nil", m) + } + }) + + t.Run("collision keeps first legacy mapping", func(t *testing.T) { raw := []byte(`{"tools":[ {"name":"read/file","input_schema":{}}, {"name":"read@file","input_schema":{}} @@ -103,10 +115,83 @@ if m == nil { t.Fatal("expected non-nil map") } - if m["read_file"] != "read/file" { - t.Errorf("expected first mapping read/file, got %q", m["read_file"]) + if got := m["read_file"]; got != "read/file" { + t.Errorf("legacy collision mapping = %q, want read/file", got) } }) +} + +func TestSanitizedFunctionNameMapDisambiguatesCollisions(t *testing.T) { + first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build" + second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs" + raw := []byte(`{"tools":[ + {"name":"` + first + `"}, + {"name":"` + first + `"}, + {"name":"` + second + `"} + ]}`) + + forward := SanitizedFunctionNameMap(raw) + firstMapped := forward[first] + secondMapped := forward[second] + if firstMapped == "" || secondMapped == "" || secondMapped == firstMapped { + t.Fatalf("mapped names = %q and %q, want distinct non-empty names", firstMapped, secondMapped) + } + if len(firstMapped) > 64 || len(secondMapped) > 64 { + t.Fatalf("mapped name lengths = %d and %d, want <= 64", len(firstMapped), len(secondMapped)) + } + + reversed := []byte(`{"tools":[{"name":"` + second + `"},{"name":"` + first + `"}]}`) + reversedForward := SanitizedFunctionNameMap(reversed) + if reversedForward[first] != firstMapped || reversedForward[second] != secondMapped { + t.Fatalf("mapping changed with declaration order: forward=%v reversed=%v", forward, reversedForward) + } + + reverse := DisambiguatedToolNameMap(raw) + if got := reverse[firstMapped]; got != first { + t.Fatalf("reverse[%q] = %q, want %q", firstMapped, got, first) + } + if got := reverse[secondMapped]; got != second { + t.Fatalf("reverse[%q] = %q, want %q", secondMapped, got, second) + } +} + +func TestSanitizedFunctionNameMapReadsSupportedToolShapes(t *testing.T) { + raw := []byte(`{"tools":[ + {"type":"function","function":{"name":"nested/name"}}, + { + "functionDeclarations":[{"name":"camel@name"}], + "function_declarations":[{"name":"snake name"}] + } + ]}`) + forward := SanitizedFunctionNameMap(raw) + for original, want := range map[string]string{ + "nested/name": "nested_name", + "camel@name": "camel_name", + "snake name": "snake_name", + } { + if got := forward[original]; got != want { + t.Errorf("forward[%q] = %q, want %q", original, got, want) + } + } +} + +func TestDeduplicateFunctionDeclarations(t *testing.T) { + raw := []byte(`[ + {"name":"lookup","description":"first"}, + {"name":"other"}, + {"name":"lookup","description":"second"} + ]`) + deduped := DeduplicateFunctionDeclarations(raw) + declarations := gjson.ParseBytes(deduped).Array() + if len(declarations) != 2 { + t.Fatalf("declaration count = %d, want 2: %s", len(declarations), deduped) + } + if got := declarations[0].Get("description").String(); got != "first" { + t.Fatalf("first duplicate description = %q, want first", got) + } + if got := declarations[1].Get("name").String(); got != "other" { + t.Fatalf("second declaration name = %q, want other", got) + } } func TestRestoreSanitizedToolName(t *testing.T) { diff --git a/internal/util/translator.go b/internal/util/translator.go --- a/internal/util/translator.go +++ b/internal/util/translator.go @@ -5,7 +5,10 @@ import ( "bytes" + "crypto/sha256" + "encoding/hex" "fmt" + "sort" "strings" log "github.com/sirupsen/logrus" @@ -276,17 +279,89 @@ return name } -// SanitizedToolNameMap builds a sanitized-name → original-name map from Claude request tools. -// It is used to restore exact tool names for clients (e.g. Claude Code) after the proxy -// sanitizes tool names for Gemini/Vertex API compatibility via SanitizeFunctionName. -// Only entries where sanitization actually changes the name are included. +// SanitizedFunctionNameMap builds an original-name → sanitized-name map from request tools. +// Exact duplicate names share a mapping. Distinct names that sanitize to the same value receive +// deterministic hash suffixes so every declaration remains addressable within the 64-byte limit. +func SanitizedFunctionNameMap(rawJSON []byte) map[string]string { + names := functionNamesFromRequest(rawJSON) + if len(names) == 0 { + return nil + } + + uniqueNames := make(map[string]struct{}, len(names)) + baseCounts := make(map[string]int, len(names)) + for _, name := range names { + if name == "" { + continue + } + if _, exists := uniqueNames[name]; exists { + continue + } + uniqueNames[name] = struct{}{} + baseCounts[SanitizeFunctionName(name)]++ + } + + sortedNames := make([]string, 0, len(uniqueNames)) + for name := range uniqueNames { + sortedNames = append(sortedNames, name) + } + sort.Strings(sortedNames) + + out := make(map[string]string, len(sortedNames)) + used := make(map[string]string, len(sortedNames)) + for _, name := range sortedNames { + base := SanitizeFunctionName(name) + mapped := base + _, baseUsed := used[base] + if baseCounts[base] > 1 || baseUsed { + mapped = disambiguateSanitizedFunctionName(base, name, used) + } + out[name] = mapped + used[mapped] = name + } + if len(out) == 0 { + return nil + } + return out +} + +// MapSanitizedFunctionName returns the request-specific sanitized name when available. +func MapSanitizedFunctionName(nameMap map[string]string, name string) string { + if mapped := nameMap[name]; mapped != "" { + return mapped + } + return SanitizeFunctionName(name) +} + +// DisambiguatedToolNameMap builds a sanitized-name → original-name map using the +// same collision-aware mapping as SanitizedFunctionNameMap. +func DisambiguatedToolNameMap(rawJSON []byte) map[string]string { + forward := SanitizedFunctionNameMap(rawJSON) + if len(forward) == 0 { + return nil + } + + out := make(map[string]string, len(forward)) + for original, sanitized := range forward { + if sanitized != original { + out[sanitized] = original + } + } + if len(out) == 0 { + return nil + } + return out +} + +// SanitizedToolNameMap builds the legacy sanitized-name → original-name map from +// top-level Claude-style tools. Collision-aware translators should use +// DisambiguatedToolNameMap instead. func SanitizedToolNameMap(rawJSON []byte) map[string]string { if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) { return nil } - tools := gjson.GetBytes(rawJSON, "tools") - if !tools.Exists() || !tools.IsArray() { + if !tools.IsArray() { return nil } @@ -300,18 +375,111 @@ if sanitized == name { return true } - if _, exists := out[sanitized]; !exists { + if existing, exists := out[sanitized]; !exists { out[sanitized] = name } else { - log.Warnf("sanitized tool name collision: %q and %q both map to %q, keeping first", out[sanitized], name, sanitized) + log.Warnf("sanitized tool name collision: %q and %q both map to %q, keeping first", existing, name, sanitized) } return true }) - if len(out) == 0 { return nil } return out +} + +func functionNamesFromRequest(rawJSON []byte) []string { + if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) { + return nil + } + tools := gjson.GetBytes(rawJSON, "tools") + if !tools.IsArray() { + return nil + } + + names := make([]string, 0, len(tools.Array())) + var collectTool func(gjson.Result) + collectDeclarations := func(declarations gjson.Result) { + if !declarations.IsArray() { + return + } + declarations.ForEach(func(_, declaration gjson.Result) bool { + if name := declaration.Get("name").String(); name != "" { + names = append(names, name) + } + return true + }) + } + collectTool = func(tool gjson.Result) { + if nestedTools := tool.Get("tools"); nestedTools.IsArray() { + nestedTools.ForEach(func(_, nestedTool gjson.Result) bool { + collectTool(nestedTool) + return true + }) + return + } + hasDeclarations := false + if declarations := tool.Get("functionDeclarations"); declarations.IsArray() { + collectDeclarations(declarations) + hasDeclarations = true + } + if declarations := tool.Get("function_declarations"); declarations.IsArray() { + collectDeclarations(declarations) + hasDeclarations = true + } + if hasDeclarations { + return + } + if name := tool.Get("function.name").String(); name != "" { + names = append(names, name) + return + } + if name := tool.Get("name").String(); name != "" { + names = append(names, name) + } + } + tools.ForEach(func(_, tool gjson.Result) bool { + collectTool(tool) + return true + }) + return names +} + +func disambiguateSanitizedFunctionName(base, original string, used map[string]string) string { + for attempt := 0; ; attempt++ { + digest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", original, attempt))) + suffix := "_" + hex.EncodeToString(digest[:6]) + prefix := base + if maxPrefix := 64 - len(suffix); len(prefix) > maxPrefix { + prefix = prefix[:maxPrefix] + } + candidate := prefix + suffix + if _, exists := used[candidate]; !exists { + return candidate + } + } +} + +// DeduplicateFunctionDeclarations removes duplicate named declarations while preserving order. +func DeduplicateFunctionDeclarations(raw []byte) []byte { + result := gjson.ParseBytes(raw) + if !result.IsArray() { + return raw + } + + seen := make(map[string]struct{}, len(result.Array())) + parts := make([]string, 0, len(result.Array())) + for _, declaration := range result.Array() { + name := declaration.Get("name").String() + if name != "" { + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + } + parts = append(parts, declaration.Raw) + } + return []byte("[" + strings.Join(parts, ",") + "]") } // RestoreSanitizedToolName looks up a sanitized function name in the provided map diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -313,6 +313,7 @@ if shouldBuildAntigravityWebSearchRequest(modelName, rawJSON) { return buildAntigravityWebSearchRequest(modelName, rawJSON) } + functionNameMap := util.SanitizedFunctionNameMap(rawJSON) // system instruction var systemInstructionJSON []byte @@ -436,12 +437,13 @@ // NOTE: Do NOT inject dummy thinking blocks here. // Antigravity API validates signatures, so dummy values are rejected. - functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) + originalFunctionName := contentResult.Get("name").String() + functionName := util.MapSanitizedFunctionName(functionNameMap, originalFunctionName) argsResult := contentResult.Get("input") functionID := contentResult.Get("id").String() - if functionID != "" && functionName != "" { - toolNameByID[functionID] = functionName + if functionID != "" && originalFunctionName != "" { + toolNameByID[functionID] = originalFunctionName } // Handle both object and string input formats @@ -494,7 +496,7 @@ functionResponseJSON := []byte(`{}`) functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "id", toolCallID) - functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "name", util.SanitizeFunctionName(funcName)) + functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "name", util.MapSanitizedFunctionName(functionNameMap, funcName)) responseData := "" if functionResponseResult.Type == gjson.String { @@ -675,7 +677,7 @@ inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw) tool, _ := sjson.DeleteBytes([]byte(toolResult.Raw), "input_schema") tool, _ = sjson.SetRawBytes(tool, "parametersJsonSchema", []byte(inputSchema)) - tool, _ = sjson.SetBytes(tool, "name", util.SanitizeFunctionName(gjson.GetBytes(tool, "name").String())) + tool, _ = sjson.SetBytes(tool, "name", util.MapSanitizedFunctionName(functionNameMap, gjson.GetBytes(tool, "name").String())) for toolKey := range gjson.ParseBytes(tool).Map() { if util.InArray(allowedToolKeys, toolKey) { continue @@ -687,8 +689,14 @@ } } if toolDeclCount > 0 { - toolsJSON = []byte(`[]`) - toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", functionToolNode) + declarations := gjson.GetBytes(functionToolNode, "functionDeclarations") + deduplicated := util.DeduplicateFunctionDeclarations([]byte(declarations.Raw)) + functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated) + toolDeclCount = len(gjson.ParseBytes(deduplicated).Array()) + if toolDeclCount > 0 { + toolsJSON = []byte(`[]`) + toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", functionToolNode) + } } } @@ -753,7 +761,7 @@ case "tool": out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "ANY") if toolChoiceName != "" { - out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)}) + out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.MapSanitizedFunctionName(functionNameMap, toolChoiceName)}) } } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -1191,6 +1191,64 @@ } } +func TestConvertClaudeRequestToAntigravity_DeduplicatesAndDisambiguatesTools(t *testing.T) { + first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build" + second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs" + inputJSON := []byte(`{ + "messages":[ + {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"` + second + `","input":{}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"ok"}]} + ], + "tools":[ + {"name":"lookup","input_schema":{"type":"object"}}, + {"name":"lookup","description":"duplicate","input_schema":{"type":"object"}}, + {"name":"` + first + `","input_schema":{"type":"object"}}, + {"name":"` + second + `","input_schema":{"type":"object"}} + ], + "tool_choice":{"type":"tool","name":"` + second + `"} + }`) + + out := ConvertClaudeRequestToAntigravity("gemini-3-flash", inputJSON, false) + declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array() + if len(declarations) != 3 { + t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out) + } + firstMapped := declarations[1].Get("name").String() + secondMapped := declarations[2].Get("name").String() + if firstMapped == secondMapped || len(secondMapped) > 64 { + t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped) + } + if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped { + t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out) + } + if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped { + t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out) + } + if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped { + t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out) + } +} + +func TestConvertClaudeRequestToAntigravity_MapsToolResultNameOnce(t *testing.T) { + inputJSON := []byte(`{ + "messages":[ + {"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read/file","input":{}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"ok"}]} + ], + "tools":[ + {"name":"read/file","input_schema":{"type":"object"}}, + {"name":"read_file","input_schema":{"type":"object"}} + ] + }`) + + out := ConvertClaudeRequestToAntigravity("gemini-3-flash", inputJSON, false) + callName := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String() + responseName := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String() + if callName == "" || responseName != callName { + t.Fatalf("function names call=%q response=%q, want the same non-empty mapping. Output: %s", callName, responseName, out) + } +} + func TestConvertClaudeRequestToAntigravity_ToolChoice_SpecificTool(t *testing.T) { inputJSON := []byte(`{ "model": "gemini-3-flash-preview", diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -106,7 +106,7 @@ HasFirstResponse: false, ResponseType: 0, ResponseIndex: 0, - ToolNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), + ToolNameMap: util.DisambiguatedToolNameMap(originalRequestRawJSON), } } modelName := gjson.GetBytes(requestRawJSON, "model").String() @@ -433,7 +433,7 @@ // Returns: // - []byte: A Claude-compatible JSON response. func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { - toolNameMap := util.SanitizedToolNameMap(originalRequestRawJSON) + toolNameMap := util.DisambiguatedToolNameMap(originalRequestRawJSON) modelName := gjson.GetBytes(requestRawJSON, "model").String() root := gjson.ParseBytes(rawJSON) diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -36,6 +36,7 @@ // - []byte: The transformed request data in Gemini API format func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte { rawJSON := inputRawJSON + functionNameMap := util.SanitizedFunctionNameMap(inputRawJSON) template := `{"project":"","request":{},"model":""}` templateBytes, _ := sjson.SetRawBytes([]byte(template), "request", rawJSON) templateBytes, _ = sjson.SetBytes(templateBytes, "model", modelName) @@ -83,22 +84,46 @@ } toolsResult := gjson.GetBytes(rawJSON, "request.tools") - if toolsResult.Exists() && toolsResult.IsArray() { - toolResults := toolsResult.Array() - for i := 0; i < len(toolResults); i++ { - functionDeclarationsResult := gjson.GetBytes(rawJSON, fmt.Sprintf("request.tools.%d.function_declarations", i)) - if functionDeclarationsResult.Exists() && functionDeclarationsResult.IsArray() { - functionDeclarationsResults := functionDeclarationsResult.Array() - for j := 0; j < len(functionDeclarationsResults); j++ { - parametersResult := gjson.GetBytes(rawJSON, fmt.Sprintf("request.tools.%d.function_declarations.%d.parameters", i, j)) - if parametersResult.Exists() { - strJson, _ := util.RenameKey(string(rawJSON), fmt.Sprintf("request.tools.%d.function_declarations.%d.parameters", i, j), fmt.Sprintf("request.tools.%d.function_declarations.%d.parametersJsonSchema", i, j)) - rawJSON = []byte(strJson) + if toolsResult.IsArray() { + seenFunctionNames := make(map[string]struct{}) + for toolIndex := range toolsResult.Array() { + for _, key := range []string{"functionDeclarations", "function_declarations"} { + path := fmt.Sprintf("request.tools.%d.%s", toolIndex, key) + declarations := gjson.GetBytes(rawJSON, path) + if !declarations.IsArray() { + continue + } + + parts := make([]string, 0, len(declarations.Array())) + for _, declaration := range declarations.Array() { + name := declaration.Get("name").String() + mappedName := util.MapSanitizedFunctionName(functionNameMap, name) + if mappedName != "" { + if _, exists := seenFunctionNames[mappedName]; exists { + continue + } + seenFunctionNames[mappedName] = struct{}{} } + + declarationJSON := []byte(declaration.Raw) + declarationJSON, _ = sjson.SetBytes(declarationJSON, "name", mappedName) + if parameters := declaration.Get("parameters"); parameters.Exists() { + declarationJSON, _ = sjson.SetRawBytes(declarationJSON, "parametersJsonSchema", []byte(parameters.Raw)) + declarationJSON, _ = sjson.DeleteBytes(declarationJSON, "parameters") + } + parts = append(parts, string(declarationJSON)) + } + deduplicated := []byte("[" + strings.Join(parts, ",") + "]") + var errSet error + rawJSON, errSet = sjson.SetRawBytes(rawJSON, path, deduplicated) + if errSet != nil { + log.Warnf("failed to normalize function declarations in tool %d: %v", toolIndex, errSet) } } } + rawJSON = removeEmptyGeminiFunctionTools(rawJSON) } + rawJSON = rewriteGeminiFunctionNames(rawJSON, functionNameMap) if strings.Contains(strings.ToLower(modelName), "claude") { rawJSON = sanitizeAntigravityClaudeGeminiRequestSignatures(modelName, rawJSON) @@ -107,6 +132,58 @@ } return common.AttachDefaultSafetySettings(rawJSON, "request.safetySettings") +} + +func removeEmptyGeminiFunctionTools(rawJSON []byte) []byte { + tools := gjson.GetBytes(rawJSON, "request.tools") + cleanedTools := []byte(`[]`) + for _, tool := range tools.Array() { + toolJSON := []byte(tool.Raw) + if tool.IsObject() { + for _, key := range []string{"functionDeclarations", "function_declarations"} { + if declarations := tool.Get(key); declarations.IsArray() && len(declarations.Array()) == 0 { + toolJSON, _ = sjson.DeleteBytes(toolJSON, key) + } + } + if len(gjson.ParseBytes(toolJSON).Map()) == 0 { + continue + } + } + cleanedTools, _ = sjson.SetRawBytes(cleanedTools, "-1", toolJSON) + } + if len(gjson.ParseBytes(cleanedTools).Array()) == 0 { + rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.tools") + return rawJSON + } + rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.tools", cleanedTools) + return rawJSON +} + +func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]string) []byte { + contents := gjson.GetBytes(rawJSON, "request.contents") + for contentIndex, content := range contents.Array() { + for partIndex, part := range content.Get("parts").Array() { + for _, field := range []string{"functionCall", "functionResponse", "function_call", "function_response"} { + name := part.Get(field + ".name").String() + if name == "" { + continue + } + path := fmt.Sprintf("request.contents.%d.parts.%d.%s.name", contentIndex, partIndex, field) + rawJSON, _ = sjson.SetBytes(rawJSON, path, util.MapSanitizedFunctionName(functionNameMap, name)) + } + } + } + for _, allowedPath := range []string{ + "request.toolConfig.functionCallingConfig.allowedFunctionNames", + "request.tool_config.function_calling_config.allowed_function_names", + } { + allowedNames := gjson.GetBytes(rawJSON, allowedPath) + for index, name := range allowedNames.Array() { + path := fmt.Sprintf("%s.%d", allowedPath, index) + rawJSON, _ = sjson.SetBytes(rawJSON, path, util.MapSanitizedFunctionName(functionNameMap, name.String())) + } + } + return rawJSON } func sanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON []byte) []byte { diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go --- a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go @@ -639,3 +639,82 @@ t.Errorf("Expected second group name 'Grep', got '%s'", name1) } } + +func TestConvertGeminiRequestToAntigravityDeduplicatesRequestWideAndDisambiguatesTools(t *testing.T) { + first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build" + second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs" + inputJSON := []byte(`{ + "contents":[ + {"role":"model","parts":[{"functionCall":{"name":"` + second + `","args":{}}}]}, + {"role":"user","parts":[{"functionResponse":{"name":"` + second + `","response":{}}}]} + ], + "tools":[ + {"functionDeclarations":[ + {"name":"lookup","parameters":{"type":"object"}}, + {"name":"` + first + `","parameters":{"type":"object"}} + ]}, + {"function_declarations":[ + {"name":"lookup","parameters":{"type":"object"}}, + {"name":"` + second + `","parameters":{"type":"object"}} + ]}, + {"functionDeclarations":[{"name":"lookup","parameters":{"type":"object"}}]} + ], + "toolConfig":{"functionCallingConfig":{"mode":"ANY","allowedFunctionNames":["` + second + `"]}} + }`) + + out := ConvertGeminiRequestToAntigravity("gemini-3-flash", inputJSON, false) + if got := len(gjson.GetBytes(out, "request.tools").Array()); got != 2 { + t.Fatalf("tool count = %d, want 2 after removing the empty duplicate node. Output: %s", got, out) + } + camel := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array() + snake := gjson.GetBytes(out, "request.tools.1.function_declarations").Array() + if len(camel)+len(snake) != 3 { + t.Fatalf("declaration count = %d, want 3. Output: %s", len(camel)+len(snake), out) + } + if len(camel) != 2 || len(snake) != 1 { + t.Fatalf("declaration distribution = %d/%d, want 2/1. Output: %s", len(camel), len(snake), out) + } + firstMapped := camel[1].Get("name").String() + secondMapped := snake[0].Get("name").String() + if firstMapped == secondMapped || len(secondMapped) > 64 { + t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped) + } + if !camel[0].Get("parametersJsonSchema").Exists() || !snake[0].Get("parametersJsonSchema").Exists() { + t.Fatalf("parameters were not normalized. Output: %s", out) + } + if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped { + t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out) + } + if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped { + t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out) + } + if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped { + t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out) + } +} + +func TestConvertGeminiRequestToAntigravityMapsSnakeCaseFunctionReferences(t *testing.T) { + inputJSON := []byte(`{ + "contents":[ + {"role":"model","parts":[{"function_call":{"name":"read_file","args":{}}}]}, + {"role":"user","parts":[{"function_response":{"name":"read_file","response":{}}}]} + ], + "tools":[{"function_declarations":[{"name":"read/file"},{"name":"read_file"}]}], + "tool_config":{"function_calling_config":{"allowed_function_names":["read_file"]}} + }`) + + out := ConvertGeminiRequestToAntigravity("gemini-3-flash", inputJSON, false) + mapped := gjson.GetBytes(out, "request.tools.0.function_declarations.1.name").String() + if mapped == "" { + t.Fatalf("mapped declaration name is empty. Output: %s", out) + } + for _, path := range []string{ + "request.contents.0.parts.0.function_call.name", + "request.contents.1.parts.0.function_response.name", + "request.tool_config.function_calling_config.allowed_function_names.0", + } { + if got := gjson.GetBytes(out, path).String(); got != mapped { + t.Fatalf("%s = %q, want %q. Output: %s", path, got, mapped, out) + } + } +} diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_response.go b/internal/translator/antigravity/gemini/antigravity_gemini_response.go --- a/internal/translator/antigravity/gemini/antigravity_gemini_response.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_response.go @@ -8,8 +8,10 @@ import ( "bytes" "context" + "fmt" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -42,6 +44,7 @@ if responseResult.Exists() { chunk = []byte(responseResult.Raw) chunk = restoreUsageMetadata(chunk) + chunk = restoreGeminiFunctionNames(chunk, originalRequestRawJSON) } } else { chunkTemplate := []byte("[]") @@ -78,9 +81,30 @@ responseResult := gjson.GetBytes(rawJSON, "response") if responseResult.Exists() { chunk := restoreUsageMetadata([]byte(responseResult.Raw)) + return restoreGeminiFunctionNames(chunk, originalRequestRawJSON) + } + return restoreGeminiFunctionNames(rawJSON, originalRequestRawJSON) +} + +func restoreGeminiFunctionNames(chunk, originalRequestRawJSON []byte) []byte { + nameMap := util.DisambiguatedToolNameMap(originalRequestRawJSON) + if len(nameMap) == 0 { return chunk } - return rawJSON + candidates := gjson.GetBytes(chunk, "candidates") + for candidateIndex, candidate := range candidates.Array() { + for partIndex, part := range candidate.Get("content.parts").Array() { + for _, field := range []string{"functionCall", "functionResponse", "function_call", "function_response"} { + name := part.Get(field + ".name").String() + if name == "" { + continue + } + path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field) + chunk, _ = sjson.SetBytes(chunk, path, util.RestoreSanitizedToolName(nameMap, name)) + } + } + } + return chunk } func GeminiTokenCount(ctx context.Context, count int64) []byte { diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_response_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_response_test.go --- a/internal/translator/antigravity/gemini/antigravity_gemini_response_test.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_response_test.go @@ -3,6 +3,9 @@ import ( "context" "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/tidwall/gjson" ) func TestRestoreUsageMetadata(t *testing.T) { @@ -63,6 +66,19 @@ t.Errorf("ConvertAntigravityResponseToGeminiNonStream() = %s, want %s", string(result), tt.expected) } }) + } +} + +func TestConvertAntigravityResponseToGeminiNonStreamRestoresDisambiguatedName(t *testing.T) { + first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build" + second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs" + original := []byte(`{"tools":[{"functionDeclarations":[{"name":"` + first + `"},{"name":"` + second + `"}]}]}`) + mapped := util.SanitizedFunctionNameMap(original)[second] + raw := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`) + + out := ConvertAntigravityResponseToGeminiNonStream(context.Background(), "", original, nil, raw, nil) + if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.name").String(); got != second { + t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, second, out) } } diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go --- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -13,6 +13,7 @@ func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte { root := gjson.ParseBytes(inputRawJSON) + functionNameMap := util.SanitizedFunctionNameMap(inputRawJSON) out := []byte(`{"project":"","request":{"contents":[]},"model":""}`) out, _ = sjson.SetBytes(out, "model", modelName) if stream || root.Get("stream").Bool() { @@ -21,8 +22,31 @@ out = copyInteractionsSystemToAntigravity(out, root) out = copyInteractionsGenerationConfigToAntigravity(out, root) out = appendInteractionsInputToAntigravity(out, root.Get("input")) - out = copyInteractionsToolsToAntigravity(out, root) + out = copyInteractionsToolsToAntigravity(out, root, functionNameMap) + out = rewriteInteractionsFunctionNames(out, functionNameMap) out = attachDefaultAntigravitySafetySettings(out) + return out +} + +func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]string) []byte { + contents := gjson.GetBytes(out, "request.contents") + for contentIndex, content := range contents.Array() { + for partIndex, part := range content.Get("parts").Array() { + for _, field := range []string{"functionCall", "functionResponse"} { + name := part.Get(field + ".name").String() + if name == "" { + continue + } + path := fmt.Sprintf("request.contents.%d.parts.%d.%s.name", contentIndex, partIndex, field) + out, _ = sjson.SetBytes(out, path, util.MapSanitizedFunctionName(functionNameMap, name)) + } + } + } + allowedNames := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames") + for index, name := range allowedNames.Array() { + path := fmt.Sprintf("request.toolConfig.functionCallingConfig.allowedFunctionNames.%d", index) + out, _ = sjson.SetBytes(out, path, util.MapSanitizedFunctionName(functionNameMap, name.String())) + } return out } @@ -169,12 +193,12 @@ mode = "ANY" case "function": mode = "ANY" - if name := strings.TrimSpace(toolChoice.Get("function.name").String()); name != "" { + if name := toolChoice.Get("function.name").String(); strings.TrimSpace(name) != "" { allowedNames = append(allowedNames, name) } case "tool": mode = "ANY" - if name := strings.TrimSpace(toolChoice.Get("name").String()); name != "" { + if name := toolChoice.Get("name").String(); strings.TrimSpace(name) != "" { allowedNames = append(allowedNames, name) } } @@ -434,7 +458,7 @@ return out } -func copyInteractionsToolsToAntigravity(out []byte, root gjson.Result) []byte { +func copyInteractionsToolsToAntigravity(out []byte, root gjson.Result, functionNameMap map[string]string) []byte { tools := root.Get("tools") if !tools.Exists() { return out @@ -449,25 +473,31 @@ tools.ForEach(func(_, tool gjson.Result) bool { if decls := tool.Get("functionDeclarations"); decls.Exists() && decls.IsArray() { decls.ForEach(func(_, decl gjson.Result) bool { - functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, decl, hasFunction) + functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, decl, hasFunction, functionNameMap) return true }) return true } if decls := tool.Get("function_declarations"); decls.Exists() && decls.IsArray() { decls.ForEach(func(_, decl gjson.Result) bool { - functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, decl, hasFunction) + functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, decl, hasFunction, functionNameMap) return true }) return true } if tool.Get("type").String() == "function" || tool.Get("name").Exists() { - functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, tool, hasFunction) + functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, tool, hasFunction, functionNameMap) return true } otherTools = append(otherTools, []byte(tool.Raw)) return true }) + if hasFunction { + declarations := gjson.GetBytes(functionToolNode, "functionDeclarations") + deduplicated := util.DeduplicateFunctionDeclarations([]byte(declarations.Raw)) + functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated) + hasFunction = len(gjson.ParseBytes(deduplicated).Array()) > 0 + } toolsNode := []byte(`[]`) if hasFunction { toolsNode, _ = sjson.SetRawBytes(toolsNode, "-1", functionToolNode) @@ -481,8 +511,8 @@ return out } -func appendAntigravityFunctionDeclaration(functionToolNode []byte, decl gjson.Result, hasFunction bool) ([]byte, bool) { - fnRaw := antigravityFunctionDeclarationJSON(decl) +func appendAntigravityFunctionDeclaration(functionToolNode []byte, decl gjson.Result, hasFunction bool, functionNameMap map[string]string) ([]byte, bool) { + fnRaw := antigravityFunctionDeclarationJSON(decl, functionNameMap) if len(fnRaw) == 0 { return functionToolNode, hasFunction } @@ -493,17 +523,17 @@ return functionToolNode, true } -func antigravityFunctionDeclarationJSON(decl gjson.Result) []byte { +func antigravityFunctionDeclarationJSON(decl gjson.Result, functionNameMap map[string]string) []byte { fn := decl if nested := decl.Get("function"); nested.Exists() && nested.IsObject() { fn = nested } - name := strings.TrimSpace(fn.Get("name").String()) - if name == "" { + name := fn.Get("name").String() + if strings.TrimSpace(name) == "" { return nil } out := []byte(`{"name":"","parametersJsonSchema":{"type":"object","properties":{}}}`) - out, _ = sjson.SetBytes(out, "name", util.SanitizeFunctionName(name)) + out, _ = sjson.SetBytes(out, "name", util.MapSanitizedFunctionName(functionNameMap, name)) if desc := fn.Get("description"); desc.Exists() { out, _ = sjson.SetBytes(out, "description", desc.String()) } diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_response.go b/internal/translator/antigravity/interactions/interactions_antigravity_response.go --- a/internal/translator/antigravity/interactions/interactions_antigravity_response.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_response.go @@ -8,6 +8,7 @@ "time" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -23,6 +24,7 @@ ActiveStepType string ActiveStepIndex int StepIndex int + ToolNameMap map[string]string } func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { @@ -34,7 +36,10 @@ param = &local } if *param == nil { - *param = &antigravityToInteractionsStreamState{ID: fmt.Sprintf("interaction_%d", time.Now().UnixNano())} + *param = &antigravityToInteractionsStreamState{ + ID: fmt.Sprintf("interaction_%d", time.Now().UnixNano()), + ToolNameMap: util.DisambiguatedToolNameMap(originalRequestRawJSON), + } } st := (*param).(*antigravityToInteractionsStreamState) payloads := antigravityStreamPayloads(rawJSON) @@ -49,6 +54,7 @@ continue } root := unwrapAntigravityResponse(gjson.ParseBytes(payload)) + root = restoreInteractionsFunctionNames(root, st.ToolNameMap) if !root.Exists() { continue } @@ -79,6 +85,7 @@ _ = originalRequestRawJSON _ = requestRawJSON root := unwrapAntigravityResponse(gjson.ParseBytes(rawJSON)) + root = restoreInteractionsFunctionNames(root, util.DisambiguatedToolNameMap(originalRequestRawJSON)) out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`) id := root.Get("responseId").String() if id == "" { @@ -125,6 +132,27 @@ return response } return restoreAntigravityUsageMetadata(root) +} + +func restoreInteractionsFunctionNames(root gjson.Result, nameMap map[string]string) gjson.Result { + if !root.Exists() || len(nameMap) == 0 { + return root + } + raw := []byte(root.Raw) + candidates := root.Get("candidates") + for candidateIndex, candidate := range candidates.Array() { + for partIndex, part := range candidate.Get("content.parts").Array() { + for _, field := range []string{"functionCall", "functionResponse"} { + name := part.Get(field + ".name").String() + if name == "" { + continue + } + path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field) + raw, _ = sjson.SetBytes(raw, path, util.RestoreSanitizedToolName(nameMap, name)) + } + } + } + return gjson.ParseBytes(raw) } func restoreAntigravityUsageMetadata(root gjson.Result) gjson.Result { diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_test.go b/internal/translator/antigravity/interactions/interactions_antigravity_test.go --- a/internal/translator/antigravity/interactions/interactions_antigravity_test.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_test.go @@ -5,6 +5,7 @@ "context" "testing" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" ) @@ -100,6 +101,71 @@ payload := findAntigravityInteractionsEventPayload(events, "step.start") if got := gjson.GetBytes(payload, "step.call_id").String(); got != "call_1" { t.Fatalf("step.call_id = %q, want call_1. Payload: %s", got, string(payload)) + } +} + +func TestConvertInteractionsRequestToAntigravityDeduplicatesAndDisambiguatesTools(t *testing.T) { + first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build" + second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs" + inputJSON := []byte(`{ + "input":[ + {"type":"function_call","name":"` + second + `","call_id":"call_1","arguments":{}}, + {"type":"function_result","name":"` + second + `","call_id":"call_1","result":{}} + ], + "tools":[ + {"functionDeclarations":[{"name":"lookup"},{"name":"` + first + `"}]}, + {"function_declarations":[{"name":"lookup"},{"name":"` + second + `"}]} + ], + "tool_choice":{"type":"function","function":{"name":"` + second + `"}} + }`) + + out := ConvertInteractionsRequestToAntigravity("antigravity-test", inputJSON, false) + declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array() + if len(declarations) != 3 { + t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out) + } + firstMapped := declarations[1].Get("name").String() + secondMapped := declarations[2].Get("name").String() + if firstMapped == secondMapped || len(secondMapped) > 64 { + t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped) + } + if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped { + t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out) + } + if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped { + t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out) + } + if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped { + t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out) + } +} + +func TestConvertInteractionsRequestToAntigravityPreservesNameMappingWhitespace(t *testing.T) { + inputJSON := []byte(`{ + "input":[{"type":"function_call","name":" read/file ","arguments":{}}], + "tools":[{"type":"function","name":" read/file ","parameters":{"type":"object"}}], + "tool_choice":{"type":"function","function":{"name":" read/file "}} + }`) + + out := ConvertInteractionsRequestToAntigravity("antigravity-test", inputJSON, false) + declarationName := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.name").String() + callName := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String() + allowedName := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String() + if declarationName == "" || callName != declarationName || allowedName != declarationName { + t.Fatalf("mapped names declaration=%q call=%q allowed=%q. Output: %s", declarationName, callName, allowedName, out) + } +} + +func TestConvertAntigravityResponseToInteractionsRestoresDisambiguatedName(t *testing.T) { + first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build" + second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs" + original := []byte(`{"tools":[{"name":"` + first + `"},{"name":"` + second + `"}]}`) + mapped := util.SanitizedFunctionNameMap(original)[second] + raw := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`) + + out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "antigravity-test", original, nil, raw, nil) + if got := gjson.GetBytes(out, "steps.0.name").String(); got != second { + t.Fatalf("function call name = %q, want %q. Output: %s", got, second, out) } } diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -28,6 +28,7 @@ // - []byte: The transformed request data in Antigravity API format func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte { rawJSON := inputRawJSON + functionNameMap := util.SanitizedFunctionNameMap(rawJSON) // Base envelope (no default thinkingConfig) out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`) @@ -295,7 +296,7 @@ continue } fid := tc.Get("id").String() - fname := util.SanitizeFunctionName(tc.Get("function.name").String()) + fname := util.MapSanitizedFunctionName(functionNameMap, tc.Get("function.name").String()) if fname == "" { continue } @@ -323,7 +324,7 @@ for _, fid := range fIDs { if name, ok := tcID2Name[fid]; ok { toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid) - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", util.SanitizeFunctionName(name)) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", util.MapSanitizedFunctionName(functionNameMap, name)) resp := toolResponses[fid] if resp == "" { resp = "{}" @@ -399,7 +400,7 @@ fnRaw = string(fnRawBytes) } fnRawBytes := []byte(fnRaw) - fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", util.SanitizeFunctionName(fn.Get("name").String())) + fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", util.MapSanitizedFunctionName(functionNameMap, fn.Get("name").String())) fnRaw, _ = sjson.Delete(string(fnRawBytes), "strict") if !hasFunction { functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", []byte("[]")) @@ -444,6 +445,12 @@ urlContextNodes = append(urlContextNodes, urlToolNode) } } + if hasFunction { + declarations := gjson.GetBytes(functionToolNode, "functionDeclarations") + deduplicated := util.DeduplicateFunctionDeclarations([]byte(declarations.Raw)) + functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated) + hasFunction = len(gjson.ParseBytes(deduplicated).Array()) > 0 + } if hasFunction || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 { toolsNode := []byte("[]") if hasFunction { @@ -462,7 +469,41 @@ } } + out = applyOpenAIToolChoiceToAntigravity(out, rawJSON, functionNameMap) return common.AttachDefaultSafetySettings(out, "request.safetySettings") +} + +func applyOpenAIToolChoiceToAntigravity(out, rawJSON []byte, functionNameMap map[string]string) []byte { + toolChoice := gjson.GetBytes(rawJSON, "tool_choice") + if !toolChoice.Exists() { + return out + } + + mode := "" + allowedName := "" + if toolChoice.Type == gjson.String { + switch strings.ToLower(strings.TrimSpace(toolChoice.String())) { + case "none": + mode = "NONE" + case "auto": + mode = "AUTO" + case "required", "any": + mode = "ANY" + } + } else if toolChoice.IsObject() && strings.EqualFold(toolChoice.Get("type").String(), "function") { + mode = "ANY" + allowedName = toolChoice.Get("function.name").String() + } + if mode == "" { + return out + } + + out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", mode) + if strings.TrimSpace(allowedName) != "" { + mappedName := util.MapSanitizedFunctionName(functionNameMap, allowedName) + out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{mappedName}) + } + return out } func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte, modelName string) []byte { diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -207,3 +207,60 @@ }) } } + +func TestConvertOpenAIRequestToAntigravityDeduplicatesAndDisambiguatesTools(t *testing.T) { + first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build" + second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs" + inputJSON := `{ + "messages":[ + {"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"` + second + `","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"call_1","content":"{}"} + ], + "tools":[ + {"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"lookup","description":"duplicate","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"` + first + `","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"` + second + `","parameters":{"type":"object"}}} + ], + "tool_choice":{"type":"function","function":{"name":"` + second + `"}} + }` + + out := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false) + declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array() + if len(declarations) != 3 { + t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out) + } + firstMapped := declarations[1].Get("name").String() + secondMapped := declarations[2].Get("name").String() + if firstMapped == secondMapped || len(secondMapped) > 64 { + t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped) + } + if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped { + t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out) + } + if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped { + t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out) + } + if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped { + t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out) + } +} + +func TestConvertOpenAIRequestToAntigravityMapsToolChoiceModes(t *testing.T) { + for _, tt := range []struct { + choice string + mode string + }{ + {choice: `"none"`, mode: "NONE"}, + {choice: `"auto"`, mode: "AUTO"}, + {choice: `"required"`, mode: "ANY"}, + } { + t.Run(tt.mode+tt.choice, func(t *testing.T) { + inputJSON := []byte(`{"messages":[{"role":"user","content":"hi"}],"tool_choice":` + tt.choice + `}`) + out := ConvertOpenAIRequestToAntigravity("gemini-3-flash", inputJSON, false) + if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != tt.mode { + t.Fatalf("tool choice mode = %q, want %q. Output: %s", got, tt.mode, out) + } + }) + } +} diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go @@ -52,11 +52,11 @@ *param = &convertCliResponseToOpenAIChatParams{ UnixTimestamp: 0, FunctionIndex: 0, - SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), + SanitizedNameMap: util.DisambiguatedToolNameMap(originalRequestRawJSON), } } if (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap == nil { - (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap = util.DisambiguatedToolNameMap(originalRequestRawJSON) } if bytes.Equal(rawJSON, []byte("[DONE]")) { @@ -241,7 +241,29 @@ func ConvertAntigravityResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { responseResult := gjson.GetBytes(rawJSON, "response") if responseResult.Exists() { - return ConvertGeminiResponseToOpenAINonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, []byte(responseResult.Raw), param) + responseJSON := restoreAntigravityOpenAIFunctionNames([]byte(responseResult.Raw), originalRequestRawJSON) + return ConvertGeminiResponseToOpenAINonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, responseJSON, param) } return []byte{} +} + +func restoreAntigravityOpenAIFunctionNames(rawJSON, originalRequestRawJSON []byte) []byte { + nameMap := util.DisambiguatedToolNameMap(originalRequestRawJSON) + if len(nameMap) == 0 { + return rawJSON + } + candidates := gjson.GetBytes(rawJSON, "candidates") + for candidateIndex, candidate := range candidates.Array() { + for partIndex, part := range candidate.Get("content.parts").Array() { + for _, field := range []string{"functionCall", "functionResponse"} { + name := part.Get(field + ".name").String() + if name == "" { + continue + } + path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field) + rawJSON, _ = sjson.SetBytes(rawJSON, path, util.RestoreSanitizedToolName(nameMap, name)) + } + } + } + return rawJSON } diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go @@ -4,6 +4,7 @@ "context" "testing" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" ) @@ -124,6 +125,22 @@ fr2 := gjson.GetBytes(result2[0], "choices.0.finish_reason") if fr2.Exists() && fr2.String() != "" && fr2.Type.String() != "Null" { t.Errorf("Expected no finish_reason on intermediate chunk, got: %v", fr2) + } +} + +func TestConvertAntigravityResponseToOpenAINonStreamRestoresDisambiguatedName(t *testing.T) { + first := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build" + second := "mcp__plugin_cloudflare_cloudflare-builds__workers_builds_get_build_logs" + original := []byte(`{"tools":[ + {"type":"function","function":{"name":"` + first + `"}}, + {"type":"function","function":{"name":"` + second + `"}} + ]}`) + mapped := util.SanitizedFunctionNameMap(original)[second] + responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`) + + output := ConvertAntigravityResponseToOpenAINonStream(context.Background(), "gemini-3-flash", original, nil, responseJSON, nil) + if got := gjson.GetBytes(output, "choices.0.message.tool_calls.0.function.name").String(); got != second { + t.Fatalf("function.name = %q, want %q. Output: %s", got, second, output) } }