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) {