fix(translator): restore original tool name casing in Gemini response translators (#9568)

Closes #9568
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-06 22:55:50 -03:00
committed by GitHub
parent fad3539a69
commit c9debe92bd
7 changed files with 199 additions and 13 deletions

View File

@@ -0,0 +1 @@
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))

View File

@@ -2306,10 +2306,24 @@ export async function handleChatCore({
const nativeClaudeToolNameMap = isClaudePassthrough
? buildClaudePassthroughToolNameMap(body)
: null;
const toolNameMap =
let toolNameMap: Map<string, string> | 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;

View File

@@ -37,10 +37,22 @@ type OpenAIToolCallLike = {
export function buildChangedToolNameMap(
toolNameMap: Map<string, string>
): Map<string, string> | 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<string, string>();
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 {

View File

@@ -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}`;

View File

@@ -256,7 +256,20 @@ function emitFunctionCallPart(
results: Array<Record<string, unknown>>
) {
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 = {

View File

@@ -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"
);
});

View File

@@ -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", () => {