From 2398ebad555600eb2ebc20330748b750c278b54c Mon Sep 17 00:00:00 2001 From: sususu98 Date: Sun, 22 Mar 2026 13:10:53 +0800 Subject: [PATCH 1/3] fix(translator): sanitize tool names for Gemini function_declarations compatibility Claude Code and MCP clients may send tool names containing characters invalid for Gemini's function_declarations (e.g. '/', '@', spaces). Sanitize on request via SanitizeFunctionName and restore original names on response for both antigravity/claude and gemini-cli/claude translators. --- .../claude/antigravity_claude_request.go | 7 ++- .../claude/antigravity_claude_response.go | 14 ++++- .../claude/gemini-cli_claude_request.go | 7 ++- .../claude/gemini-cli_claude_response.go | 10 +++- internal/util/sanitize_test.go | 60 +++++++++++++++++++ internal/util/translator.go | 49 +++++++++++++++ 6 files changed, 135 insertions(+), 12 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 6b7f8c00..234aeb14 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -171,7 +171,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // NOTE: Do NOT inject dummy thinking blocks here. // Antigravity API validates signatures, so dummy values are rejected. - functionName := contentResult.Get("name").String() + functionName := util.SanitizeFunctionName(contentResult.Get("name").String()) argsResult := contentResult.Get("input") functionID := contentResult.Get("id").String() @@ -233,7 +233,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ functionResponseJSON := []byte(`{}`) functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "id", toolCallID) - functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "name", funcName) + functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "name", util.SanitizeFunctionName(funcName)) responseData := "" if functionResponseResult.Type == gjson.String { @@ -398,6 +398,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ 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())) for toolKey := range gjson.ParseBytes(tool).Map() { if util.InArray(allowedToolKeys, toolKey) { continue @@ -471,7 +472,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ case "tool": out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "ANY") if toolChoiceName != "" { - out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName}) + out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)}) } } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 6ffea7cb..9b0e2756 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -44,6 +44,10 @@ type Params struct { // Signature caching support CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching + + // Reverse map: sanitized Gemini function name → original Claude tool name. + // Populated lazily on the first response chunk from the original request JSON. + ToolNameMap map[string]string } // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. @@ -77,6 +81,10 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq params := (*param).(*Params) + if params.ToolNameMap == nil { + params.ToolNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + } + if bytes.Equal(rawJSON, []byte("[DONE]")) { output := make([]byte, 0, 256) // Only send final events if we have actually output content @@ -212,7 +220,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq // Handle function/tool calls from the AI model // This processes tool usage requests and formats them for Claude Code API compatibility params.HasToolUse = true - fcName := functionCallResult.Get("name").String() + fcName := util.RestoreSanitizedToolName(params.ToolNameMap, functionCallResult.Get("name").String()) // Handle state transitions when switching to function calls // Close any existing function call block first @@ -348,7 +356,7 @@ func resolveStopReason(params *Params) string { // Returns: // - []byte: A Claude-compatible JSON response. func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { - _ = originalRequestRawJSON + toolNameMap := util.SanitizedToolNameMap(originalRequestRawJSON) modelName := gjson.GetBytes(requestRawJSON, "model").String() root := gjson.ParseBytes(rawJSON) @@ -450,7 +458,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or flushText() hasToolCall = true - name := functionCall.Get("name").String() + name := util.RestoreSanitizedToolName(toolNameMap, functionCall.Get("name").String()) toolIDCounter++ toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) toolBlock, _ = sjson.SetBytes(toolBlock, "id", fmt.Sprintf("tool_%d", toolIDCounter)) 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 d2567a03..57ebbc2c 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -89,7 +89,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] contentJSON, _ = sjson.SetRawBytes(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) { @@ -112,7 +112,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] } responseData := contentResult.Get("content").Raw part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`) - part, _ = sjson.SetBytes(part, "functionResponse.name", funcName) + part, _ = sjson.SetBytes(part, "functionResponse.name", util.SanitizeFunctionName(funcName)) part, _ = sjson.SetBytes(part, "functionResponse.response.result", responseData) contentJSON, _ = sjson.SetRawBytes(contentJSON, "parts.-1", part) @@ -151,6 +151,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] inputSchema := util.CleanJSONSchemaForGemini(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.DeleteBytes(tool, "strict") tool, _ = sjson.DeleteBytes(tool, "input_examples") tool, _ = sjson.DeleteBytes(tool, "type") @@ -194,7 +195,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] case "tool": out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "ANY") if toolChoiceName != "" { - out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName}) + out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)}) } } } diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go index b5809632..0bf4d622 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go @@ -28,6 +28,9 @@ type Params struct { ResponseType int // Current response type: 0=none, 1=content, 2=thinking, 3=function ResponseIndex int // Index counter for content blocks in the streaming response HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output + + // Reverse map: sanitized Gemini function name → original Claude tool name. + ToolNameMap map[string]string } // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. @@ -55,6 +58,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque HasFirstResponse: false, ResponseType: 0, ResponseIndex: 0, + ToolNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), } } @@ -165,7 +169,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque // Handle function/tool calls from the AI model // This processes tool usage requests and formats them for Claude Code API compatibility usedTool = true - fcName := functionCallResult.Get("name").String() + fcName := util.RestoreSanitizedToolName((*param).(*Params).ToolNameMap, functionCallResult.Get("name").String()) // Handle state transitions when switching to function calls // Close any existing function call block first @@ -248,7 +252,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque // Returns: // - []byte: A Claude-compatible JSON response. func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { - _ = originalRequestRawJSON + toolNameMap := util.SanitizedToolNameMap(originalRequestRawJSON) _ = requestRawJSON root := gjson.ParseBytes(rawJSON) @@ -306,7 +310,7 @@ func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, orig flushText() hasToolCall = true - name := functionCall.Get("name").String() + name := util.RestoreSanitizedToolName(toolNameMap, functionCall.Get("name").String()) toolIDCounter++ toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) toolBlock, _ = sjson.SetBytes(toolBlock, "id", fmt.Sprintf("tool_%d", toolIDCounter)) diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go index 4ff8454b..3b7714cf 100644 --- a/internal/util/sanitize_test.go +++ b/internal/util/sanitize_test.go @@ -54,3 +54,63 @@ func TestSanitizeFunctionName(t *testing.T) { }) } } + +func TestSanitizedToolNameMap(t *testing.T) { + t.Run("returns map for tools needing sanitization", func(t *testing.T) { + raw := []byte(`{"tools":[ + {"name":"valid_tool","input_schema":{}}, + {"name":"mcp/server/read","input_schema":{}}, + {"name":"tool@v2","input_schema":{}} + ]}`) + m := SanitizedToolNameMap(raw) + if m == nil { + t.Fatal("expected non-nil map") + } + if m["mcp_server_read"] != "mcp/server/read" { + t.Errorf("expected mcp_server_read → mcp/server/read, got %q", m["mcp_server_read"]) + } + if m["tool_v2"] != "tool@v2" { + t.Errorf("expected tool_v2 → tool@v2, got %q", m["tool_v2"]) + } + if _, exists := m["valid_tool"]; exists { + t.Error("valid_tool should not be in the map (no sanitization needed)") + } + }) + + t.Run("returns nil when no tools need sanitization", func(t *testing.T) { + raw := []byte(`{"tools":[{"name":"Read","input_schema":{}},{"name":"Write","input_schema":{}}]}`) + m := SanitizedToolNameMap(raw) + if m != nil { + t.Errorf("expected nil, got %v", m) + } + }) + + t.Run("returns nil for empty/missing tools", func(t *testing.T) { + if m := SanitizedToolNameMap([]byte(`{}`)); m != nil { + t.Error("expected nil for no tools") + } + if m := SanitizedToolNameMap(nil); m != nil { + t.Error("expected nil for nil input") + } + }) +} + +func TestRestoreSanitizedToolName(t *testing.T) { + m := map[string]string{ + "mcp_server_read": "mcp/server/read", + "tool_v2": "tool@v2", + } + + if got := RestoreSanitizedToolName(m, "mcp_server_read"); got != "mcp/server/read" { + t.Errorf("expected mcp/server/read, got %q", got) + } + if got := RestoreSanitizedToolName(m, "unknown"); got != "unknown" { + t.Errorf("expected passthrough for unknown, got %q", got) + } + if got := RestoreSanitizedToolName(nil, "name"); got != "name" { + t.Errorf("expected passthrough for nil map, got %q", got) + } + if got := RestoreSanitizedToolName(m, ""); got != "" { + t.Errorf("expected empty for empty name, got %q", got) + } +} diff --git a/internal/util/translator.go b/internal/util/translator.go index 4a1a1d80..81400eee 100644 --- a/internal/util/translator.go +++ b/internal/util/translator.go @@ -271,3 +271,52 @@ func MapToolName(toolNameMap map[string]string, name string) string { } 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. +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() { + return nil + } + + out := make(map[string]string) + tools.ForEach(func(_, tool gjson.Result) bool { + name := strings.TrimSpace(tool.Get("name").String()) + if name == "" { + return true + } + sanitized := SanitizeFunctionName(name) + if sanitized == name { + return true + } + if _, exists := out[sanitized]; !exists { + out[sanitized] = name + } + return true + }) + + if len(out) == 0 { + return nil + } + return out +} + +// RestoreSanitizedToolName looks up a sanitized function name in the provided map +// and returns the original client-facing name. If no mapping exists, it returns +// the sanitized name unchanged. +func RestoreSanitizedToolName(toolNameMap map[string]string, sanitizedName string) string { + if sanitizedName == "" || toolNameMap == nil { + return sanitizedName + } + if original, ok := toolNameMap[sanitizedName]; ok { + return original + } + return sanitizedName +} From 755ca758791e41191d5ba0b1dfcc356c30f119d0 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Sun, 22 Mar 2026 13:24:03 +0800 Subject: [PATCH 2/3] fix: address review feedback - init ToolNameMap eagerly, log collisions, add collision test --- .../claude/antigravity_claude_response.go | 5 +---- internal/util/sanitize_test.go | 14 ++++++++++++++ internal/util/translator.go | 3 +++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 9b0e2756..e6fd810a 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -75,16 +75,13 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq HasFirstResponse: false, ResponseType: 0, ResponseIndex: 0, + ToolNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), } } modelName := gjson.GetBytes(requestRawJSON, "model").String() params := (*param).(*Params) - if params.ToolNameMap == nil { - params.ToolNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) - } - if bytes.Equal(rawJSON, []byte("[DONE]")) { output := make([]byte, 0, 256) // Only send final events if we have actually output content diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go index 3b7714cf..f589aff4 100644 --- a/internal/util/sanitize_test.go +++ b/internal/util/sanitize_test.go @@ -93,6 +93,20 @@ func TestSanitizedToolNameMap(t *testing.T) { t.Error("expected nil for nil input") } }) + + t.Run("collision keeps first mapping", func(t *testing.T) { + raw := []byte(`{"tools":[ + {"name":"read/file","input_schema":{}}, + {"name":"read@file","input_schema":{}} + ]}`) + m := SanitizedToolNameMap(raw) + 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"]) + } + }) } func TestRestoreSanitizedToolName(t *testing.T) { diff --git a/internal/util/translator.go b/internal/util/translator.go index 81400eee..a40609f1 100644 --- a/internal/util/translator.go +++ b/internal/util/translator.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -298,6 +299,8 @@ func SanitizedToolNameMap(rawJSON []byte) map[string]string { } if _, 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) } return true }) From e8bb350467e3ff25b6e52815d00b774cde39e185 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Sun, 22 Mar 2026 14:06:46 +0800 Subject: [PATCH 3/3] fix: extend tool name sanitization to all remaining Gemini-bound translators Apply SanitizeFunctionName on request and RestoreSanitizedToolName on response for: gemini/claude, gemini/openai/chat-completions, gemini/openai/responses, antigravity/openai/chat-completions, gemini-cli/openai/chat-completions. Also update SanitizedToolNameMap to handle OpenAI format (tools[].function.name) in addition to Claude format (tools[].name). --- .../antigravity_openai_request.go | 8 +++-- .../antigravity_openai_response.go | 12 ++++++-- .../gemini-cli_openai_request.go | 5 ++-- .../gemini-cli_openai_response.go | 16 ++++++---- .../gemini/claude/gemini_claude_request.go | 6 +++- .../gemini/claude/gemini_claude_response.go | 5 ++++ .../chat-completions/gemini_openai_request.go | 7 +++-- .../gemini_openai_response.go | 17 +++++++---- .../gemini_openai-responses_request.go | 6 ++-- .../gemini_openai-responses_response.go | 29 ++++++++++++------- internal/util/translator.go | 3 ++ 11 files changed, 80 insertions(+), 34 deletions(-) diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index 6eff85f2..b33be50b 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -286,7 +286,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ continue } fid := tc.Get("id").String() - fname := tc.Get("function.name").String() + fname := util.SanitizeFunctionName(tc.Get("function.name").String()) fargs := tc.Get("function.arguments").String() node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid) node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) @@ -309,7 +309,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ 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", name) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", util.SanitizeFunctionName(name)) resp := toolResponses[fid] if resp == "" { resp = "{}" @@ -384,7 +384,9 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ } fnRaw = string(fnRawBytes) } - fnRaw, _ = sjson.Delete(fnRaw, "strict") + fnRawBytes := []byte(fnRaw) + fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", util.SanitizeFunctionName(fn.Get("name").String())) + fnRaw, _ = sjson.Delete(string(fnRawBytes), "strict") if !hasFunction { functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", []byte("[]")) } diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go index 4f9445cd..9188c75a 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "time" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" log "github.com/sirupsen/logrus" . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions" @@ -26,6 +27,7 @@ type convertCliResponseToOpenAIChatParams struct { FunctionIndex int SawToolCall bool // Tracks if any tool call was seen in the entire stream UpstreamFinishReason string // Caches the upstream finish reason for final chunk + SanitizedNameMap map[string]string } // functionCallIDCounter provides a process-wide unique counter for function call identifiers. @@ -48,10 +50,14 @@ var functionCallIDCounter uint64 func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { if *param == nil { *param = &convertCliResponseToOpenAIChatParams{ - UnixTimestamp: 0, - FunctionIndex: 0, + UnixTimestamp: 0, + FunctionIndex: 0, + SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), } } + if (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap == nil { + (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + } if bytes.Equal(rawJSON, []byte("[DONE]")) { return [][]byte{} @@ -159,7 +165,7 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq } functionCallTemplate := []byte(`{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`) - fcName := functionCallResult.Get("name").String() + fcName := util.RestoreSanitizedToolName((*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap, functionCallResult.Get("name").String()) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName) diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index 0fed7623..95bca2d7 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -251,7 +251,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo continue } fid := tc.Get("id").String() - fname := tc.Get("function.name").String() + fname := util.SanitizeFunctionName(tc.Get("function.name").String()) fargs := tc.Get("function.arguments").String() node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) @@ -268,7 +268,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo pp := 0 for _, fid := range fIDs { if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", util.SanitizeFunctionName(name)) resp := toolResponses[fid] if resp == "" { resp = "{}" @@ -331,6 +331,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo continue } } + fnRaw, _ = sjson.SetBytes(fnRaw, "name", util.SanitizeFunctionName(fn.Get("name").String())) fnRaw, _ = sjson.DeleteBytes(fnRaw, "strict") if !hasFunction { functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", []byte("[]")) diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go index faec3b35..0947371a 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go @@ -14,6 +14,7 @@ import ( "time" . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -21,8 +22,9 @@ import ( // convertCliResponseToOpenAIChatParams holds parameters for response conversion. type convertCliResponseToOpenAIChatParams struct { - UnixTimestamp int64 - FunctionIndex int + UnixTimestamp int64 + FunctionIndex int + SanitizedNameMap map[string]string } // functionCallIDCounter provides a process-wide unique counter for function call identifiers. @@ -45,10 +47,14 @@ var functionCallIDCounter uint64 func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { if *param == nil { *param = &convertCliResponseToOpenAIChatParams{ - UnixTimestamp: 0, - FunctionIndex: 0, + UnixTimestamp: 0, + FunctionIndex: 0, + SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), } } + if (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap == nil { + (*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + } if bytes.Equal(rawJSON, []byte("[DONE]")) { return [][]byte{} @@ -163,7 +169,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ } functionCallTemplate := []byte(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`) - fcName := functionCallResult.Get("name").String() + fcName := util.RestoreSanitizedToolName((*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap, functionCallResult.Get("name").String()) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index bd3eaffe..4a52d4a8 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -11,6 +11,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -90,6 +91,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) functionName = derived } } + functionName = util.SanitizeFunctionName(functionName) functionArgs := contentResult.Get("input").String() argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() && gjson.Valid(functionArgs) { @@ -109,6 +111,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) if funcName == "" { funcName = toolCallID } + funcName = util.SanitizeFunctionName(funcName) responseData := contentResult.Get("content").Raw part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`) part, _ = sjson.SetBytes(part, "functionResponse.name", funcName) @@ -165,6 +168,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.DeleteBytes(tool, "type") tool, _ = sjson.DeleteBytes(tool, "cache_control") tool, _ = sjson.DeleteBytes(tool, "defer_loading") + tool, _ = sjson.SetBytes(tool, "name", util.SanitizeFunctionName(gjson.GetBytes(tool, "name").String())) if gjson.ValidBytes(tool) && gjson.ParseBytes(tool).IsObject() { if !hasTools { out, _ = sjson.SetRawBytes(out, "tools", []byte(`[{"functionDeclarations":[]}]`)) @@ -202,7 +206,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) case "tool": out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY") if toolChoiceName != "" { - out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName}) + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)}) } } } diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index 9b21b009..28722de1 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -27,6 +27,7 @@ type Params struct { ResponseIndex int HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output ToolNameMap map[string]string + SanitizedNameMap map[string]string SawToolCall bool } @@ -57,6 +58,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR ResponseType: 0, ResponseIndex: 0, ToolNameMap: util.ToolNameMapFromClaudeRequest(originalRequestRawJSON), + SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), SawToolCall: false, } } @@ -167,6 +169,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR // This processes tool usage requests and formats them for Claude API compatibility (*param).(*Params).SawToolCall = true upstreamToolName := functionCallResult.Get("name").String() + upstreamToolName = util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, upstreamToolName) clientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName) // FIX: Handle streaming split/delta where name might be empty in subsequent chunks. @@ -260,6 +263,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina root := gjson.ParseBytes(rawJSON) toolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON) + sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON) out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`) out, _ = sjson.SetBytes(out, "id", root.Get("responseId").String()) @@ -315,6 +319,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina hasToolCall = true upstreamToolName := functionCall.Get("name").String() + upstreamToolName = util.RestoreSanitizedToolName(sanitizedNameMap, upstreamToolName) clientToolName := util.MapToolName(toolNameMap, upstreamToolName) toolIDCounter++ toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index 39b08d9d..c0c4d329 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -257,7 +257,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) continue } fid := tc.Get("id").String() - fname := tc.Get("function.name").String() + fname := util.SanitizeFunctionName(tc.Get("function.name").String()) fargs := tc.Get("function.arguments").String() node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) @@ -274,7 +274,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) pp := 0 for _, fid := range fIDs { if name, ok := tcID2Name[fid]; ok { - toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name) + toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", util.SanitizeFunctionName(name)) resp := toolResponses[fid] if resp == "" { resp = "{}" @@ -341,6 +341,9 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } fnRaw = string(fnRawBytes) } + fnRawBytes := []byte(fnRaw) + fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", util.SanitizeFunctionName(fn.Get("name").String())) + fnRaw = string(fnRawBytes) fnRaw, _ = sjson.Delete(fnRaw, "strict") if !hasFunction { functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", []byte("[]")) diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go index 29be1d3a..3dc5b095 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "time" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -22,7 +23,8 @@ import ( type convertGeminiResponseToOpenAIChatParams struct { UnixTimestamp int64 // FunctionIndex tracks tool call indices per candidate index to support multiple candidates. - FunctionIndex map[int]int + FunctionIndex map[int]int + SanitizedNameMap map[string]string } // functionCallIDCounter provides a process-wide unique counter for function call identifiers. @@ -46,8 +48,9 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR // Initialize parameters if nil. if *param == nil { *param = &convertGeminiResponseToOpenAIChatParams{ - UnixTimestamp: 0, - FunctionIndex: make(map[int]int), + UnixTimestamp: 0, + FunctionIndex: make(map[int]int), + SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), } } @@ -56,6 +59,9 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR if p.FunctionIndex == nil { p.FunctionIndex = make(map[int]int) } + if p.SanitizedNameMap == nil { + p.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + } if bytes.HasPrefix(rawJSON, []byte("data:")) { rawJSON = bytes.TrimSpace(rawJSON[5:]) @@ -191,7 +197,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR } functionCallTemplate := []byte(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`) - fcName := functionCallResult.Get("name").String() + fcName := util.RestoreSanitizedToolName(p.SanitizedNameMap, functionCallResult.Get("name").String()) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName) @@ -265,6 +271,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR // Returns: // - []byte: An OpenAI-compatible JSON response containing all message content and metadata func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON) var unixTimestamp int64 // Initialize template with an empty choices array to support multiple candidates. template := []byte(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[]}`) @@ -358,7 +365,7 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.tool_calls", []byte(`[]`)) } functionCallItemTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) - fcName := functionCallResult.Get("name").String() + fcName := util.RestoreSanitizedToolName(sanitizedNameMap, functionCallResult.Get("name").String()) functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", fcName) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index b4754029..8f3a59fa 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -291,7 +292,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte case "function_call": // Handle function calls - convert to model message with functionCall - name := item.Get("name").String() + name := util.SanitizeFunctionName(item.Get("name").String()) arguments := item.Get("arguments").String() modelContent := []byte(`{"role":"model","parts":[]}`) @@ -333,6 +334,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte return true }) } + functionName = util.SanitizeFunctionName(functionName) functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.name", functionName) functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.id", callID) @@ -375,7 +377,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte funcDecl := []byte(`{"name":"","description":"","parametersJsonSchema":{}}`) if name := tool.Get("name"); name.Exists() { - funcDecl, _ = sjson.SetBytes(funcDecl, "name", name.String()) + funcDecl, _ = sjson.SetBytes(funcDecl, "name", util.SanitizeFunctionName(name.String())) } if desc := tool.Get("description"); desc.Exists() { funcDecl, _ = sjson.SetBytes(funcDecl, "description", desc.String()) diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go index 30e50325..15729aae 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go @@ -9,6 +9,7 @@ import ( "time" translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -36,11 +37,12 @@ type geminiToResponsesState struct { ReasoningClosed bool // function call aggregation (keyed by output_index) - NextIndex int - FuncArgsBuf map[int]*strings.Builder - FuncNames map[int]string - FuncCallIDs map[int]string - FuncDone map[int]bool + NextIndex int + FuncArgsBuf map[int]*strings.Builder + FuncNames map[int]string + FuncCallIDs map[int]string + FuncDone map[int]bool + SanitizedNameMap map[string]string } // responseIDCounter provides a process-wide unique counter for synthesized response identifiers. @@ -90,10 +92,11 @@ func emitEvent(event string, payload []byte) []byte { func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { if *param == nil { *param = &geminiToResponsesState{ - FuncArgsBuf: make(map[int]*strings.Builder), - FuncNames: make(map[int]string), - FuncCallIDs: make(map[int]string), - FuncDone: make(map[int]bool), + FuncArgsBuf: make(map[int]*strings.Builder), + FuncNames: make(map[int]string), + FuncCallIDs: make(map[int]string), + FuncDone: make(map[int]bool), + SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), } } st := (*param).(*geminiToResponsesState) @@ -109,6 +112,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if st.FuncDone == nil { st.FuncDone = make(map[int]bool) } + if st.SanitizedNameMap == nil { + st.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + } if bytes.HasPrefix(rawJSON, []byte("data:")) { rawJSON = bytes.TrimSpace(rawJSON[5:]) @@ -306,7 +312,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, // Responses streaming requires message done events before the next output_item.added. finalizeReasoning() finalizeMessage() - name := fc.Get("name").String() + name := util.RestoreSanitizedToolName(st.SanitizedNameMap, fc.Get("name").String()) idx := st.NextIndex st.NextIndex++ // Ensure buffers @@ -565,6 +571,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { root := gjson.ParseBytes(rawJSON) root = unwrapGeminiResponseRoot(root) + sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON) // Base response scaffold resp := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`) @@ -694,7 +701,7 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string return true } if fc := p.Get("functionCall"); fc.Exists() { - name := fc.Get("name").String() + name := util.RestoreSanitizedToolName(sanitizedNameMap, fc.Get("name").String()) args := fc.Get("args") callID := fmt.Sprintf("call_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1)) itemJSON := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) diff --git a/internal/util/translator.go b/internal/util/translator.go index a40609f1..34aa35ed 100644 --- a/internal/util/translator.go +++ b/internal/util/translator.go @@ -244,6 +244,9 @@ func ToolNameMapFromClaudeRequest(rawJSON []byte) map[string]string { out := make(map[string]string, len(toolResults)) tools.ForEach(func(_, tool gjson.Result) bool { name := strings.TrimSpace(tool.Get("name").String()) + if name == "" { + name = strings.TrimSpace(tool.Get("function.name").String()) + } if name == "" { return true }