From c9debe92bd2c830b3cb639f6bccd2f05794eb138 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 22:55:50 -0300 Subject: [PATCH] fix(translator): restore original tool name casing in Gemini response translators (#9568) Closes #9568 --- changelog.d/fixes/9568-gemini-tool-casing.md | 1 + open-sse/handlers/chatCore.ts | 16 +- .../request/openai-to-gemini/helpers.ts | 20 ++- .../translator/response/gemini-to-claude.ts | 8 +- .../translator/response/gemini-to-openai.ts | 15 +- .../9568-gemini-tool-casing-mismatch.test.ts | 138 ++++++++++++++++++ .../openai-to-gemini-helpers-split.test.ts | 14 +- 7 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixes/9568-gemini-tool-casing.md create mode 100644 tests/unit/9568-gemini-tool-casing-mismatch.test.ts diff --git a/changelog.d/fixes/9568-gemini-tool-casing.md b/changelog.d/fixes/9568-gemini-tool-casing.md new file mode 100644 index 0000000000..0629e40538 --- /dev/null +++ b/changelog.d/fixes/9568-gemini-tool-casing.md @@ -0,0 +1 @@ +- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568)) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 26120abce3..3a0baf33f5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2306,10 +2306,24 @@ export async function handleChatCore({ const nativeClaudeToolNameMap = isClaudePassthrough ? buildClaudePassthroughToolNameMap(body) : null; - const toolNameMap = + let toolNameMap: Map | null = translatedToolNameMap instanceof Map && translatedToolNameMap.size > 0 ? translatedToolNameMap : nativeClaudeToolNameMap; + + // For providers whose _toolNameMap was extracted as requestToolIdentityMap + // before the Kiro merge block (Gemini/Antigravity), merge it into the + // response toolNameMap so the response translator can restore tool names + // from their lowercased form (#9568). Only merge string-valued entries + // (tool name aliases), not object-valued namespace identities (#7936). + if (!toolNameMap && requestToolIdentityMap instanceof Map && requestToolIdentityMap.size > 0) { + const hasStringValues = [...requestToolIdentityMap.values()].every( + (v: unknown) => typeof v === "string" + ); + if (hasStringValues) { + toolNameMap = requestToolIdentityMap; + } + } delete translatedBody._toolNameMap; delete translatedBody._disableToolPrefix; diff --git a/open-sse/translator/request/openai-to-gemini/helpers.ts b/open-sse/translator/request/openai-to-gemini/helpers.ts index 3dfd35cef1..810620d8f9 100644 --- a/open-sse/translator/request/openai-to-gemini/helpers.ts +++ b/open-sse/translator/request/openai-to-gemini/helpers.ts @@ -37,10 +37,22 @@ type OpenAIToolCallLike = { export function buildChangedToolNameMap( toolNameMap: Map ): Map | null { - const changedEntries = [...toolNameMap.entries()].filter( - ([sanitizedName, originalName]) => sanitizedName !== originalName - ); - return changedEntries.length > 0 ? new Map(changedEntries) : null; + if (toolNameMap.size === 0) return null; + + const result = new Map(); + for (const [sanitizedName, originalName] of toolNameMap.entries()) { + result.set(sanitizedName, originalName); + // Add lowercase-keyed alias so Gemini's lowercased tool names find the original. + // Gemini always lowercases tool names in functionCall responses, so even identity + // entries (Bash → Bash) need a lowercase key ("bash" → "Bash") for the response + // translator to look them up (#9568). + const lower = sanitizedName.toLowerCase(); + if (lower !== sanitizedName && !result.has(lower)) { + result.set(lower, originalName); + } + } + + return result; } export function extractClientThoughtSignature(toolCall: unknown): string | null { diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 5bc9482508..3af3c48418 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -108,9 +108,11 @@ export function geminiToClaudeResponse(chunk, state) { } const fc = part.functionCall; const rawToolName = fc.name; - const restoredToolName = normalizeToolName( - state.toolNameMap?.get(rawToolName) || rawToolName - ); + const mappedName = state.toolNameMap?.get(rawToolName); + // When the toolNameMap provides a match (e.g., lowercase "bash" → "Bash"), + // use it directly without passing through normalizeToolName(), which would + // reverse TitleCase back to lowercase via REVERSE_MAP (#9568). + const restoredToolName = mappedName || normalizeToolName(rawToolName); const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 3f915b5ec2..1c0a899c98 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -256,7 +256,20 @@ function emitFunctionCallPart( results: Array> ) { const rawToolName = part.functionCall.name; - const fcName = state.toolNameMap?.get(rawToolName) || rawToolName; + const fcName = (() => { + const direct = state.toolNameMap?.get(rawToolName); + if (direct) return direct; + // Case-insensitive fallback: Gemini always lowercases tool names in + // functionCall responses, so a direct match by lowercase key may have + // been missed if the map entry somehow didn't include the lowercase + // alias (#9568). + if (state.toolNameMap) { + for (const [key, val] of state.toolNameMap) { + if (key.toLowerCase() === rawToolName.toLowerCase()) return val; + } + } + return rawToolName; + })(); const fcArgs = normalizeToolCallArgs(part.functionCall.args || {}); const toolCallIndex = state.functionIndex++; const toolCall = { diff --git a/tests/unit/9568-gemini-tool-casing-mismatch.test.ts b/tests/unit/9568-gemini-tool-casing-mismatch.test.ts new file mode 100644 index 0000000000..854fe77f41 --- /dev/null +++ b/tests/unit/9568-gemini-tool-casing-mismatch.test.ts @@ -0,0 +1,138 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { geminiToOpenAIResponse } = + await import("../../open-sse/translator/response/gemini-to-openai.ts"); +const { geminiToClaudeResponse } = + await import("../../open-sse/translator/response/gemini-to-claude.ts"); + +function flatten(items) { + return items.flatMap((item) => item || []); +} + +// ── Gemini -> OpenAI tool name casing fix (#9568) ────────────────────── + +test("gemini-to-openai: no toolNameMap — Gemini returns lowercase 'bash', translator outputs 'bash' (bug)", () => { + const state = { toolCalls: new Map(), toolNameMap: null }; + const result = geminiToOpenAIResponse( + { + responseId: "resp-9568-1", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "bash", args: { code: "echo hi" } }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const toolCall = result.find((c) => c.choices?.[0]?.delta?.tool_calls); + const name = toolCall?.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name; + assert.equal(name, "bash", "Without toolNameMap, lowercase tool name should pass through as-is"); +}); + +test("gemini-to-openai: toolNameMap has lowercase alias — Gemini returns 'bash', translator outputs 'Bash' (fix)", () => { + const state = { + toolCalls: new Map(), + toolNameMap: new Map([["bash", "Bash"]]), + }; + const result = geminiToOpenAIResponse( + { + responseId: "resp-9568-2", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "bash", args: { code: "echo hi" } }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const toolCall = result.find((c) => c.choices?.[0]?.delta?.tool_calls); + const name = toolCall?.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name; + assert.equal( + name, + "Bash", + "With toolNameMap={{'bash','Bash'}}, lowercase tool name should be restored to TitleCase" + ); +}); + +// ── Gemini -> Claude tool name casing fix (#9568) ────────────────────── + +test("gemini-to-claude: no toolNameMap — Gemini returns 'bash', translator outputs 'bash' (bug)", () => { + const state = {}; + const result = geminiToClaudeResponse( + { + responseId: "resp-9568-3", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "bash", args: { code: "echo hi" } }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const toolUse = result.find((c) => c.type === "content_block_start"); + assert.equal( + toolUse?.content_block?.name, + "bash", + "Without toolNameMap, lowercase tool name should pass through as-is (gemini-to-claude)" + ); +}); + +test("gemini-to-claude: toolNameMap has lowercase alias — Gemini returns 'bash', translator outputs 'Bash' (fix)", () => { + const state = { + toolNameMap: new Map([["bash", "Bash"]]), + }; + const result = geminiToClaudeResponse( + { + responseId: "resp-9568-4", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "bash", args: { code: "echo hi" } }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const toolUse = result.find((c) => c.type === "content_block_start"); + assert.equal( + toolUse?.content_block?.name, + "Bash", + "With toolNameMap={{'bash','Bash'}}, lowercase tool name should be restored to TitleCase without normalizeToolName reversing it" + ); +}); diff --git a/tests/unit/openai-to-gemini-helpers-split.test.ts b/tests/unit/openai-to-gemini-helpers-split.test.ts index 7ad469b849..28c9d1ab43 100644 --- a/tests/unit/openai-to-gemini-helpers-split.test.ts +++ b/tests/unit/openai-to-gemini-helpers-split.test.ts @@ -19,15 +19,21 @@ test("isVertexGeminiProvider matches only the vertex provider ids", () => { assert.equal(h.isVertexGeminiProvider(undefined), false); }); -test("buildChangedToolNameMap keeps only renamed entries, else null", () => { +test("buildChangedToolNameMap includes all entries with lowercase aliases", () => { const changed = h.buildChangedToolNameMap( new Map([ - ["a", "a"], + ["Bash", "Bash"], ["b_sanitized", "b"], ]) ); - assert.deepEqual([...(changed ?? new Map()).entries()], [["b_sanitized", "b"]]); - assert.equal(h.buildChangedToolNameMap(new Map([["a", "a"]])), null); + const entries = [...(changed ?? new Map()).entries()]; + // Identity entry ("Bash" → "Bash") is included, plus lowercase alias ("bash" → "Bash") + assert.ok(entries.some(([k]) => k === "Bash")); + assert.ok(entries.some(([k, v]) => k === "bash" && v === "Bash")); + // Renamed entry is included as before + assert.ok(entries.some(([k, v]) => k === "b_sanitized" && v === "b")); + // Empty map still returns null + assert.equal(h.buildChangedToolNameMap(new Map()), null); }); test("extractClientThoughtSignature reads the first non-empty signature field", () => {