mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
fix(translator): restore original tool name casing in Gemini response translators (#9568)
Closes #9568
This commit is contained in:
committed by
GitHub
parent
fad3539a69
commit
c9debe92bd
1
changelog.d/fixes/9568-gemini-tool-casing.md
Normal file
1
changelog.d/fixes/9568-gemini-tool-casing.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
138
tests/unit/9568-gemini-tool-casing-mismatch.test.ts
Normal file
138
tests/unit/9568-gemini-tool-casing-mismatch.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user