Files
OmniRoute/tests/unit/probe-claude-gemini-tool-casing.test.ts
engmarcosjr 1d33025c70 fix(translator): restore TitleCase tool names on the Claude to Gemini path (#9993)
Gemini lowercases tool names in functionCall responses, so the request
translator must publish a lowercase alias (read -> Read) for
gemini-to-claude to restore the casing Claude Code registered.

claude-to-gemini.ts filtered identity entries (Read -> Read) out of
_toolNameMap, so no alias reached the response translator and
normalizeToolName() - whose REVERSE_MAP is keyed by TitleCase - left the
lowercase name untouched, surfacing as 'No such tool available: read'.

Reuse buildChangedToolNameMap(), which #9568 already introduced for the
openai-to-gemini path.

Closes #9713

Co-authored-by: Marcos Jr <engenheiromarcosjr@gmail.com>
2026-08-11 09:52:40 -03:00

114 lines
4.1 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
const { claudeToGeminiRequest } =
await import("../../open-sse/translator/request/claude-to-gemini.ts");
const { geminiToClaudeResponse } =
await import("../../open-sse/translator/response/gemini-to-claude.ts");
// Claude Code (Anthropic Messages format) → OmniRoute → Gemini.
//
// Gemini always lowercases tool names in its functionCall responses, so the
// round trip only works if the request translator publishes a lowercase alias
// ("read" → "Read") in `_toolNameMap`. Claude Code registers its tools in
// TitleCase and rejects anything else with "No such tool available: read".
//
// The identity-entry filter in claude-to-gemini.ts dropped `Read → Read`
// (sanitized === original), so no alias reached the response translator and
// `normalizeToolName()` — whose REVERSE_MAP is keyed by TitleCase — left the
// lowercase name untouched. Same class as #9568, which only fixed the
// openai-to-gemini request path.
function claudeBodyWithTools(names: string[]) {
return {
messages: [{ role: "user", content: "read package.json" }],
tools: names.map((name) => ({
name,
description: `${name} tool`,
input_schema: { type: "object", properties: {} },
})),
};
}
function firstToolUseName(events) {
for (const event of events || []) {
if (event?.type === "content_block_start" && event.content_block?.type === "tool_use") {
return event.content_block.name;
}
}
return null;
}
function geminiFunctionCallChunk(name: string) {
return {
candidates: [
{
content: { parts: [{ functionCall: { name, args: { file_path: "package.json" } } }] },
finishReason: "STOP",
},
],
};
}
test("claude-to-gemini: TitleCase tool name publishes a lowercase alias in _toolNameMap", () => {
const translated = claudeToGeminiRequest("gemini-2.5-pro", claudeBodyWithTools(["Read"]), false);
const map = translated._toolNameMap;
assert.ok(map instanceof Map, "_toolNameMap must be present so the response can be remapped");
assert.equal(
map.get("read"),
"Read",
"Gemini lowercases functionCall names — the map needs a 'read' key to restore 'Read'"
);
});
test("Claude Code round trip: Gemini's lowercase 'read' is restored to 'Read'", () => {
const translated = claudeToGeminiRequest("gemini-2.5-pro", claudeBodyWithTools(["Read"]), true);
// chatCore extracts `_toolNameMap` from the translated request body and hands
// it to the response translator as `state.toolNameMap`.
const state = { toolNameMap: translated._toolNameMap, contentBlockIndex: 0 };
const events = geminiToClaudeResponse(geminiFunctionCallChunk("read"), state);
assert.equal(
firstToolUseName(events),
"Read",
"Claude Code registers 'Read'; emitting 'read' fails with 'No such tool available: read'"
);
});
test("Claude Code round trip: multiple TitleCase tools survive the lowercase round trip", () => {
const translated = claudeToGeminiRequest(
"gemini-2.5-pro",
claudeBodyWithTools(["Read", "Bash", "WebFetch"]),
true
);
for (const [lowered, expected] of [
["read", "Read"],
["bash", "Bash"],
["webfetch", "WebFetch"],
]) {
const state = { toolNameMap: translated._toolNameMap, contentBlockIndex: 0 };
const events = geminiToClaudeResponse(geminiFunctionCallChunk(lowered), state);
assert.equal(firstToolUseName(events), expected, `${lowered} should restore to ${expected}`);
}
});
test("claude-to-gemini: names actually sanitized for Gemini keep their original mapping", () => {
// A tool name Gemini cannot accept verbatim must still map back to the original.
const translated = claudeToGeminiRequest(
"gemini-2.5-pro",
claudeBodyWithTools(["mcp__server__do-thing"]),
false
);
const map = translated._toolNameMap;
assert.ok(map instanceof Map, "sanitized names must still publish a map");
const restored = [...map.values()];
assert.ok(
restored.includes("mcp__server__do-thing"),
"the original (unsanitized) name must remain reachable for the response translator"
);
});