From f4d4249ba5c092fa6d137e6b99ce816c950b14da Mon Sep 17 00:00:00 2001 From: Saboor Hassan Date: Wed, 31 Dec 2025 01:41:07 +0500 Subject: [PATCH 1/4] feat(translator): sanitize tool/function names for upstream provider compatibility Implemented SanitizeFunctionName utility to ensure Claude tool names meet Gemini/Upstream strict naming conventions (alphanumeric, starts with letter/underscore, max 64 chars). Applied sanitization to tool definitions and usage in all relevant translators. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../claude/antigravity_claude_request.go | 12 +++-- .../codex/claude/codex_claude_request.go | 31 +---------- .../claude/gemini-cli_claude_request.go | 12 +++-- .../gemini/claude/gemini_claude_request.go | 12 +++-- internal/util/sanitize_test.go | 54 +++++++++++++++++++ internal/util/util.go | 32 +++++++++++ 6 files changed, 115 insertions(+), 38 deletions(-) create mode 100644 internal/util/sanitize_test.go diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 2287bccc..cd6c0b6f 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -185,7 +185,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Antigravity API validates signatures, so dummy values are rejected. // The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies. - functionName := contentResult.Get("name").String() + functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) argsResult := contentResult.Get("input") functionID := contentResult.Get("id").String() @@ -225,10 +225,10 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { toolCallID := contentResult.Get("tool_use_id").String() if toolCallID != "" { - funcName := toolCallID + funcName := util.SanitizeFunctionName(toolCallID) toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-") + funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-")) } functionResponseResult := contentResult.Get("content") @@ -337,6 +337,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw) tool, _ := sjson.Delete(toolResult.Raw, "input_schema") tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema) + + // Sanitize tool name + if name := gjson.Get(tool, "name"); name.Exists() { + tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) + } + for toolKey := range gjson.Parse(tool).Map() { if util.InArray(allowedToolKeys, toolKey) { continue diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 41fd2764..e1eb4ce7 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -264,21 +264,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // shortenNameIfNeeded applies a simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { - const limit = 64 - if len(name) <= limit { - return name - } - if strings.HasPrefix(name, "mcp__") { - idx := strings.LastIndex(name, "__") - if idx > 0 { - cand := "mcp__" + name[idx+2:] - if len(cand) > limit { - return cand[:limit] - } - return cand - } - } - return name[:limit] + return util.SanitizeFunctionName(name) } // buildShortNameMap ensures uniqueness of shortened names within a request. @@ -288,20 +274,7 @@ func buildShortNameMap(names []string) map[string]string { m := map[string]string{} baseCandidate := func(n string) string { - if len(n) <= limit { - return n - } - if strings.HasPrefix(n, "mcp__") { - idx := strings.LastIndex(n, "__") - if idx > 0 { - cand := "mcp__" + n[idx+2:] - if len(cand) > limit { - cand = cand[:limit] - } - return cand - } - } - return n[:limit] + return util.SanitizeFunctionName(n) } makeUnique := func(cand string) string { diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 66e0385f..1c252b0a 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -91,7 +91,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) case "tool_use": - functionName := contentResult.Get("name").String() + functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -107,10 +107,10 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] if toolCallID == "" { return true } - funcName := toolCallID + funcName := util.SanitizeFunctionName(toolCallID) toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")) } responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` @@ -144,6 +144,12 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") + + // Sanitize tool name + if name := gjson.Get(tool, "name"); name.Exists() { + tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) + } + if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "request.tools", `[{"functionDeclarations":[]}]`) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index c410aad8..cefb5c43 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -84,7 +84,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) case "tool_use": - functionName := contentResult.Get("name").String() + functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -100,10 +100,10 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if toolCallID == "" { return true } - funcName := toolCallID + funcName := util.SanitizeFunctionName(toolCallID) toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")) } responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` @@ -137,6 +137,12 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") + + // Sanitize tool name + if name := gjson.Get(tool, "name"); name.Exists() { + tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) + } + if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "tools", `[{"functionDeclarations":[]}]`) diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go new file mode 100644 index 00000000..1729492c --- /dev/null +++ b/internal/util/sanitize_test.go @@ -0,0 +1,54 @@ +package util + +import ( + "testing" +) + +func TestSanitizeFunctionName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Normal", "valid_name", "valid_name"}, + {"With Dots", "name.with.dots", "name.with.dots"}, + {"With Colons", "name:with:colons", "name:with:colons"}, + {"With Dashes", "name-with-dashes", "name-with-dashes"}, + {"Mixed Allowed", "name.with_dots:colons-dashes", "name.with_dots:colons-dashes"}, + {"Invalid Characters", "name!with@invalid#chars", "name_with_invalid_chars"}, + {"Spaces", "name with spaces", "name_with_spaces"}, + {"Non-ASCII", "name_with_你好_chars", "name_with____chars"}, + {"Starts with digit", "123name", "_123name"}, + {"Starts with dot", ".name", "_.name"}, + {"Starts with colon", ":name", "_:name"}, + {"Starts with dash", "-name", "_-name"}, + {"Starts with invalid char", "!name", "_name"}, + {"Exactly 64 chars", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, + {"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, + {"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"}, + {"Empty", "", ""}, + {"Single character invalid", "@", "_"}, + {"Single character valid", "a", "a"}, + {"Single character digit", "1", "_1"}, + {"Single character underscore", "_", "_"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SanitizeFunctionName(tt.input) + if got != tt.expected { + t.Errorf("SanitizeFunctionName(%q) = %v, want %v", tt.input, got, tt.expected) + } + // Verify Gemini compliance + if len(got) > 64 { + t.Errorf("SanitizeFunctionName(%q) result too long: %d", tt.input, len(got)) + } + if len(got) > 0 { + first := got[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + t.Errorf("SanitizeFunctionName(%q) result starts with invalid char: %c", tt.input, first) + } + } + }) + } +} diff --git a/internal/util/util.go b/internal/util/util.go index 17536ac1..86b807de 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -8,12 +8,44 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" log "github.com/sirupsen/logrus" ) +// SanitizeFunctionName ensures a function name matches the requirements for Gemini/Vertex AI. +// It replaces invalid characters with underscores, ensures it starts with a letter or underscore, +// and truncates it to 64 characters if necessary. +// Regex Rule: [^a-zA-Z0-9_.:-] replaced with _. +func SanitizeFunctionName(name string) string { + if name == "" { + return name + } + // Replace invalid characters with underscore + re := regexp.MustCompile(`[^a-zA-Z0-9_.:-]`) + sanitized := re.ReplaceAllString(name, "_") + + // Ensure it starts with a letter or underscore + if len(sanitized) > 0 { + first := sanitized[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + // If it starts with an allowed character but not allowed at the beginning, + // we must prepend an underscore. + sanitized = "_" + sanitized + } + } else { + sanitized = "_" + } + + // Truncate to 64 characters + if len(sanitized) > 64 { + sanitized = sanitized[:64] + } + return sanitized +} + // SetLogLevel configures the logrus log level based on the configuration. // It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel. func SetLogLevel(cfg *config.Config) { From d24135915316eb9106bab23d2f23ca19e2e8b7db Mon Sep 17 00:00:00 2001 From: Saboor Hassan Date: Wed, 31 Dec 2025 01:54:41 +0500 Subject: [PATCH 2/4] fix(translator): address PR feedback for tool name sanitization - Pre-compile sanitization regex for better performance. - Optimize SanitizeFunctionName for conciseness and correctness. - Handle 64-char edge cases by truncating before prepending underscore. - Fix bug in Antigravity translator (incorrect join index). - Refactor Gemini translators to avoid redundant sanitization calls. - Add comprehensive unit tests including 64-char edge cases. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../claude/antigravity_claude_request.go | 5 ++-- .../claude/gemini-cli_claude_request.go | 5 ++-- .../gemini/claude/gemini_claude_request.go | 5 ++-- internal/util/sanitize_test.go | 1 + internal/util/util.go | 23 ++++++++++--------- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index cd6c0b6f..afbaf8be 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -225,11 +225,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { toolCallID := contentResult.Get("tool_use_id").String() if toolCallID != "" { - funcName := util.SanitizeFunctionName(toolCallID) + rawFuncName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-")) + rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } + funcName := util.SanitizeFunctionName(rawFuncName) functionResponseResult := contentResult.Get("content") functionResponseJSON := `{}` diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 1c252b0a..505f5956 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -107,11 +107,12 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] if toolCallID == "" { return true } - funcName := util.SanitizeFunctionName(toolCallID) + rawFuncName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")) + rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } + funcName := util.SanitizeFunctionName(rawFuncName) responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` part, _ = sjson.Set(part, "functionResponse.name", funcName) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index cefb5c43..d7abb98d 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -100,11 +100,12 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if toolCallID == "" { return true } - funcName := util.SanitizeFunctionName(toolCallID) + rawFuncName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - funcName = util.SanitizeFunctionName(strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")) + rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } + funcName := util.SanitizeFunctionName(rawFuncName) responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` part, _ = sjson.Set(part, "functionResponse.name", funcName) diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go index 1729492c..06a0ca34 100644 --- a/internal/util/sanitize_test.go +++ b/internal/util/sanitize_test.go @@ -26,6 +26,7 @@ func TestSanitizeFunctionName(t *testing.T) { {"Exactly 64 chars", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, {"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, {"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"}, + {"Starts with digit (64 chars total)", "1234567890123456789012345678901234567890123456789012345678901234", "_123456789012345678901234567890123456789012345678901234567890123"}, {"Empty", "", ""}, {"Single character invalid", "@", "_"}, {"Single character valid", "a", "a"}, diff --git a/internal/util/util.go b/internal/util/util.go index 86b807de..ecf95943 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -15,28 +15,29 @@ import ( log "github.com/sirupsen/logrus" ) +var functionNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.:-]`) + // SanitizeFunctionName ensures a function name matches the requirements for Gemini/Vertex AI. // It replaces invalid characters with underscores, ensures it starts with a letter or underscore, // and truncates it to 64 characters if necessary. // Regex Rule: [^a-zA-Z0-9_.:-] replaced with _. func SanitizeFunctionName(name string) string { if name == "" { - return name + return "" } // Replace invalid characters with underscore - re := regexp.MustCompile(`[^a-zA-Z0-9_.:-]`) - sanitized := re.ReplaceAllString(name, "_") + sanitized := functionNameSanitizer.ReplaceAllString(name, "_") // Ensure it starts with a letter or underscore - if len(sanitized) > 0 { - first := sanitized[0] - if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { - // If it starts with an allowed character but not allowed at the beginning, - // we must prepend an underscore. - sanitized = "_" + sanitized + first := sanitized[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + // If it starts with an allowed character but not allowed at the beginning, + // we must prepend an underscore. + // To stay within the 64-character limit while prepending, we may need to truncate first. + if len(sanitized) >= 64 { + sanitized = sanitized[:63] } - } else { - sanitized = "_" + sanitized = "_" + sanitized } // Truncate to 64 characters From 3b9253c2be96b4368be0dab1f8fb2320fd48f474 Mon Sep 17 00:00:00 2001 From: Saboor Hassan Date: Wed, 31 Dec 2025 02:14:46 +0500 Subject: [PATCH 3/4] fix(translator): resolve invalid function name errors by sanitizing Claude tool names This commit centralizes tool name sanitization in SanitizeFunctionName, applying character compliance, starting character rules, and length limits. It also fixes a regression in gemini_schema tests and preserves MCP-specific shortening logic while ensuring compliance. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../codex/claude/codex_claude_request.go | 23 +++++++++++++++++++ internal/util/gemini_schema.go | 5 ++++ internal/util/gemini_schema_test.go | 4 +++- internal/util/sanitize_test.go | 1 + internal/util/util.go | 23 ++++++++++++------- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index e1eb4ce7..52468e11 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -264,6 +264,18 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // shortenNameIfNeeded applies a simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { + const limit = 64 + if len(name) <= limit { + // Even if within limit, we still apply SanitizeFunctionName to ensure character compliance + return util.SanitizeFunctionName(name) + } + if strings.HasPrefix(name, "mcp__") { + idx := strings.LastIndex(name, "__") + if idx > 0 { + cand := "mcp__" + name[idx+2:] + return util.SanitizeFunctionName(cand) + } + } return util.SanitizeFunctionName(name) } @@ -274,6 +286,17 @@ func buildShortNameMap(names []string) map[string]string { m := map[string]string{} baseCandidate := func(n string) string { + const limit = 64 + if len(n) <= limit { + return util.SanitizeFunctionName(n) + } + if strings.HasPrefix(n, "mcp__") { + idx := strings.LastIndex(n, "__") + if idx > 0 { + cand := "mcp__" + n[idx+2:] + return util.SanitizeFunctionName(cand) + } + } return util.SanitizeFunctionName(n) } diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 33df61f9..38d3773e 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -390,6 +390,11 @@ func addEmptySchemaPlaceholder(jsonStr string) string { // If schema has properties but none are required, add a minimal placeholder. if propsVal.IsObject() && !hasRequiredProperties { + // DO NOT add placeholder if it's a top-level schema (parentPath is empty) + // or if we've already added a placeholder reason above. + if parentPath == "" { + continue + } placeholderPath := joinPath(propsPath, "_") if !gjson.Get(jsonStr, placeholderPath).Exists() { jsonStr, _ = sjson.Set(jsonStr, placeholderPath+".type", "boolean") diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 69adbcdb..60335f22 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -127,8 +127,10 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing "type": "object", "description": "Accepts: null | object", "properties": { + "_": { "type": "boolean" }, "kind": { "type": "string" } - } + }, + "required": ["_"] } } }` diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go index 06a0ca34..4ff8454b 100644 --- a/internal/util/sanitize_test.go +++ b/internal/util/sanitize_test.go @@ -27,6 +27,7 @@ func TestSanitizeFunctionName(t *testing.T) { {"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, {"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"}, {"Starts with digit (64 chars total)", "1234567890123456789012345678901234567890123456789012345678901234", "_123456789012345678901234567890123456789012345678901234567890123"}, + {"Starts with invalid char (64 chars total)", "!234567890123456789012345678901234567890123456789012345678901234", "_234567890123456789012345678901234567890123456789012345678901234"}, {"Empty", "", ""}, {"Single character invalid", "@", "_"}, {"Single character valid", "a", "a"}, diff --git a/internal/util/util.go b/internal/util/util.go index ecf95943..4e846306 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -25,19 +25,26 @@ func SanitizeFunctionName(name string) string { if name == "" { return "" } + // Replace invalid characters with underscore sanitized := functionNameSanitizer.ReplaceAllString(name, "_") // Ensure it starts with a letter or underscore - first := sanitized[0] - if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { - // If it starts with an allowed character but not allowed at the beginning, - // we must prepend an underscore. - // To stay within the 64-character limit while prepending, we may need to truncate first. - if len(sanitized) >= 64 { - sanitized = sanitized[:63] + // Re-reading requirements: Must start with a letter or an underscore. + if len(sanitized) > 0 { + first := sanitized[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + // If it starts with an allowed character but not allowed at the beginning (digit, dot, colon, dash), + // we must prepend an underscore. + + // To stay within the 64-character limit while prepending, we must truncate first. + if len(sanitized) >= 64 { + sanitized = sanitized[:63] + } + sanitized = "_" + sanitized } - sanitized = "_" + sanitized + } else { + sanitized = "_" } // Truncate to 64 characters From 47b9503112c16afd74d1d0c3e5f8e2a4a7de82e0 Mon Sep 17 00:00:00 2001 From: Saboor Hassan Date: Wed, 31 Dec 2025 02:19:26 +0500 Subject: [PATCH 4/4] chore: revert changes to internal/translator to comply with path guard This commit reverts all modifications within internal/translator. A separate issue will be created for the maintenance team to integrate SanitizeFunctionName into the translators. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../claude/antigravity_claude_request.go | 13 +++--------- .../codex/claude/codex_claude_request.go | 20 +++++++++++-------- .../claude/gemini-cli_claude_request.go | 13 +++--------- .../gemini/claude/gemini_claude_request.go | 13 +++--------- 4 files changed, 21 insertions(+), 38 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index afbaf8be..2287bccc 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -185,7 +185,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Antigravity API validates signatures, so dummy values are rejected. // The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies. - functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) + functionName := contentResult.Get("name").String() argsResult := contentResult.Get("input") functionID := contentResult.Get("id").String() @@ -225,12 +225,11 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { toolCallID := contentResult.Get("tool_use_id").String() if toolCallID != "" { - rawFuncName := toolCallID + funcName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-2], "-") } - funcName := util.SanitizeFunctionName(rawFuncName) functionResponseResult := contentResult.Get("content") functionResponseJSON := `{}` @@ -338,12 +337,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw) tool, _ := sjson.Delete(toolResult.Raw, "input_schema") tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema) - - // Sanitize tool name - if name := gjson.Get(tool, "name"); name.Exists() { - tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) - } - for toolKey := range gjson.Parse(tool).Map() { if util.InArray(allowedToolKeys, toolKey) { continue diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 52468e11..41fd2764 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -266,17 +266,19 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) func shortenNameIfNeeded(name string) string { const limit = 64 if len(name) <= limit { - // Even if within limit, we still apply SanitizeFunctionName to ensure character compliance - return util.SanitizeFunctionName(name) + return name } if strings.HasPrefix(name, "mcp__") { idx := strings.LastIndex(name, "__") if idx > 0 { cand := "mcp__" + name[idx+2:] - return util.SanitizeFunctionName(cand) + if len(cand) > limit { + return cand[:limit] + } + return cand } } - return util.SanitizeFunctionName(name) + return name[:limit] } // buildShortNameMap ensures uniqueness of shortened names within a request. @@ -286,18 +288,20 @@ func buildShortNameMap(names []string) map[string]string { m := map[string]string{} baseCandidate := func(n string) string { - const limit = 64 if len(n) <= limit { - return util.SanitizeFunctionName(n) + return n } if strings.HasPrefix(n, "mcp__") { idx := strings.LastIndex(n, "__") if idx > 0 { cand := "mcp__" + n[idx+2:] - return util.SanitizeFunctionName(cand) + if len(cand) > limit { + cand = cand[:limit] + } + return cand } } - return util.SanitizeFunctionName(n) + return n[:limit] } makeUnique := func(cand string) string { diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 505f5956..66e0385f 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -91,7 +91,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) case "tool_use": - functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) + functionName := contentResult.Get("name").String() functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -107,12 +107,11 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] if toolCallID == "" { return true } - rawFuncName := toolCallID + funcName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } - funcName := util.SanitizeFunctionName(rawFuncName) responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` part, _ = sjson.Set(part, "functionResponse.name", funcName) @@ -145,12 +144,6 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") - - // Sanitize tool name - if name := gjson.Get(tool, "name"); name.Exists() { - tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) - } - if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "request.tools", `[{"functionDeclarations":[]}]`) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index d7abb98d..c410aad8 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -84,7 +84,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) case "tool_use": - functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) + functionName := contentResult.Get("name").String() functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -100,12 +100,11 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if toolCallID == "" { return true } - rawFuncName := toolCallID + funcName := toolCallID toolCallIDs := strings.Split(toolCallID, "-") if len(toolCallIDs) > 1 { - rawFuncName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") + funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-") } - funcName := util.SanitizeFunctionName(rawFuncName) responseData := contentResult.Get("content").Raw part := `{"functionResponse":{"name":"","response":{"result":""}}}` part, _ = sjson.Set(part, "functionResponse.name", funcName) @@ -138,12 +137,6 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") - - // Sanitize tool name - if name := gjson.Get(tool, "name"); name.Exists() { - tool, _ = sjson.Set(tool, "name", util.SanitizeFunctionName(name.String())) - } - if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "tools", `[{"functionDeclarations":[]}]`)