From 0f9469160c3a87a1869de910ef7432763945bcfb Mon Sep 17 00:00:00 2001 From: Prudhvivuda Date: Thu, 30 Jul 2026 16:32:11 -0400 Subject: [PATCH] fix(sse): preserve Claude Code tool-name casing via Gemini/Antigravity (#9008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop blindly lowercasing PascalCase tool_use names on the Gemini→Claude path so Claude Code no longer rejects Read/WebSearch as missing tools. --- open-sse/services/claudeCodeToolRemapper.ts | 34 ++++ .../translator/request/claude-to-gemini.ts | 11 +- .../translator/response/gemini-to-claude.ts | 18 +- .../translator/response/openai-to-claude.ts | 13 +- ...mini-to-claude-tool-name-case-9008.test.ts | 160 ++++++++++++++++++ .../unit/translator-openai-to-gemini.test.ts | 3 + 6 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 tests/unit/gemini-to-claude-tool-name-case-9008.test.ts diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 1f9bb745cc3b..4d9365040140 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -175,6 +175,40 @@ export function remapToolNamesInResponse( return text; } +/** + * Restore a tool name for Claude-format clients (#9008). + * + * Preference order: + * 1. Exact `_toolNameMap` hit (sanitized → original) + * 2. Case-insensitive match against map keys/values (Gemini/Antigravity may + * echo a lowercased name for a PascalCase Claude Code tool) + * 3. REVERSE_MAP TitleCase → lowercase fallback for clients with no request map + * (#7926 XML / OpenCode-style lowercase tools) + * + * Never apply REVERSE_MAP after a request-side original is known — that is what + * turned Claude Code's `Read`/`WebSearch` into `read`/`websearch`. + */ +export function restoreClaudeToolName( + rawName: string, + toolNameMap?: Map | null +): string { + if (!rawName) return rawName; + + const exact = toolNameMap?.get(rawName); + if (typeof exact === "string") return exact; + + if (toolNameMap?.size) { + const lower = rawName.toLowerCase(); + for (const [sanitized, original] of toolNameMap.entries()) { + if (sanitized.toLowerCase() === lower || original.toLowerCase() === lower) { + return original; + } + } + } + + return REVERSE_MAP[rawName] ?? rawName; +} + export { TOOL_RENAME_MAP, REVERSE_MAP }; /** diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index a69540e49d49..1b043e82cc17 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -302,13 +302,10 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } } - const changedToolNameMap = new Map( - [...toolNameMap.entries()].filter( - ([sanitizedName, originalName]) => sanitizedName !== originalName - ) - ); - if (changedToolNameMap.size > 0) { - result._toolNameMap = changedToolNameMap; + // #9008: keep identity mappings so Claude Code PascalCase tool names can be + // restored when Gemini/Antigravity echoes a different case. + if (toolNameMap.size > 0) { + result._toolNameMap = new Map(toolNameMap); } return result; diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 07187d489cd4..18fb6ca6fb67 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -1,16 +1,12 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { isAbortFinishReason } from "../../utils/finishReason.ts"; -import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts"; +import { restoreClaudeToolName } from "../../services/claudeCodeToolRemapper.ts"; import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; -function normalizeToolName(name: string): string { - return REVERSE_MAP[name] ?? name; -} - /** * Direct Gemini → Claude response translator. * Converts Gemini streaming chunks directly to Claude Messages API @@ -108,11 +104,13 @@ export function geminiToClaudeResponse(chunk, state) { } const fc = part.functionCall; const rawToolName = fc.name; - 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); + // #9008: honor the request's original casing via toolNameMap before any + // REVERSE_MAP lowercase fallback (#7926). Blind REVERSE_MAP broke Claude + // Code (Read/WebSearch → read/websearch → "No such tool available"). + const restoredToolName = restoreClaudeToolName( + typeof rawToolName === "string" ? rawToolName : "", + state.toolNameMap instanceof Map ? state.toolNameMap : null + ); const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index cac48d5848e9..b8e4f103c0f6 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -9,11 +9,7 @@ import { isInternalReasoningPlaceholder, stripInternalReasoningPlaceholder, } from "../../utils/reasoningPlaceholder.ts"; -import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts"; - -function normalizeToolName(name: string): string { - return REVERSE_MAP[name] ?? name; -} +import { restoreClaudeToolName } from "../../services/claudeCodeToolRemapper.ts"; interface XmlToolCall { id: string; @@ -432,7 +428,12 @@ export function openaiToClaudeResponse(chunk, state) { content_block: { type: "tool_use", id: tc.id, - name: normalizeToolName(tc.name), + // #9008: prefer request-side original casing; REVERSE_MAP only when + // no map entry exists (#7926 XML TitleCase → lowercase clients). + name: restoreClaudeToolName( + tc.name, + state.toolNameMap instanceof Map ? state.toolNameMap : null + ), input: tc.args, }, }); diff --git a/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts b/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts new file mode 100644 index 000000000000..798e130bd6c4 --- /dev/null +++ b/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts @@ -0,0 +1,160 @@ +/** + * #9008 — Claude Code → OmniRoute → Gemini/Antigravity must preserve the + * caller's PascalCase tool names in tool_use responses. + * + * Regression from #7926: gemini-to-claude applied REVERSE_MAP unconditionally + * (Read → read, WebSearch → websearch). Claude Code then rejects the call with + * "No such tool available: read". + * + * When the request declared PascalCase tools, the response must restore that + * exact casing — including when the upstream model echoes a lowercased name. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { geminiToClaudeResponse } = + await import("../../open-sse/translator/response/gemini-to-claude.ts"); +const { claudeToGeminiRequest } = + await import("../../open-sse/translator/request/claude-to-gemini.ts"); +const { openaiToGeminiRequest } = + await import("../../open-sse/translator/request/openai-to-gemini.ts"); +const { restoreClaudeToolName } = + await import("../../open-sse/services/claudeCodeToolRemapper.ts"); + +function toolUseName(events: Array> | null): string | undefined { + const start = (events || []).find( + (e) => + e.type === "content_block_start" && + (e.content_block as Record | undefined)?.type === "tool_use" + ); + return (start?.content_block as Record | undefined)?.name as + | string + | undefined; +} + +test("#9008 restoreClaudeToolName: preserves PascalCase from the request map", () => { + const map = new Map([ + ["Read", "Read"], + ["WebSearch", "WebSearch"], + ]); + assert.equal(restoreClaudeToolName("Read", map), "Read"); + assert.equal(restoreClaudeToolName("WebSearch", map), "WebSearch"); +}); + +test("#9008 restoreClaudeToolName: maps lowercased upstream names back to declared PascalCase", () => { + const map = new Map([ + ["Read", "Read"], + ["WebSearch", "WebSearch"], + ]); + assert.equal(restoreClaudeToolName("read", map), "Read"); + assert.equal(restoreClaudeToolName("websearch", map), "WebSearch"); +}); + +test("#9008 restoreClaudeToolName: still lowercases TitleCase when no request map (#7926)", () => { + assert.equal(restoreClaudeToolName("Bash", null), "bash"); + assert.equal(restoreClaudeToolName("Read", undefined), "read"); +}); + +test("#9008 Gemini → Claude: PascalCase tool_use survives when upstream echoes TitleCase", () => { + const state = { + toolNameMap: new Map([ + ["Read", "Read"], + ["WebSearch", "WebSearch"], + ]), + }; + const result = geminiToClaudeResponse( + { + responseId: "resp-9008-a", + modelVersion: "gemini-3.6-flash", + candidates: [ + { + content: { + parts: [{ functionCall: { name: "Read", args: { path: "/tmp/a" } } }], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + assert.equal(toolUseName(result), "Read"); +}); + +test("#9008 Gemini → Claude: lowercased upstream name restored to declared PascalCase", () => { + const state = { + toolNameMap: new Map([ + ["Read", "Read"], + ["WebSearch", "WebSearch"], + ]), + }; + const result = geminiToClaudeResponse( + { + responseId: "resp-9008-b", + modelVersion: "gemini-3.6-flash", + candidates: [ + { + content: { + parts: [{ functionCall: { name: "websearch", args: { query: "omniroute" } } }], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + assert.equal(toolUseName(result), "WebSearch"); +}); + +test("#9008 Claude → Gemini keeps identity toolNameMap entries for PascalCase tools", () => { + const result = claudeToGeminiRequest( + "gemini-3.6-flash", + { + messages: [{ role: "user", content: "read the file" }], + tools: [ + { + name: "Read", + description: "Read a file", + input_schema: { type: "object", properties: { path: { type: "string" } } }, + }, + { + name: "WebSearch", + description: "Search the web", + input_schema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + }, + false + ); + + assert.ok(result._toolNameMap instanceof Map); + assert.equal(result._toolNameMap.get("Read"), "Read"); + assert.equal(result._toolNameMap.get("WebSearch"), "WebSearch"); +}); + +test("#9008 OpenAI → Gemini keeps identity toolNameMap entries for PascalCase tools", () => { + const result = openaiToGeminiRequest( + "gemini-3.6-flash", + { + messages: [{ role: "user", content: "search" }], + tools: [ + { + type: "function", + function: { + name: "WebSearch", + description: "Search the web", + parameters: { type: "object", properties: { query: { type: "string" } } }, + }, + }, + ], + }, + false + ); + + assert.ok((result as { _toolNameMap?: Map })._toolNameMap instanceof Map); + assert.equal( + (result as { _toolNameMap: Map })._toolNameMap.get("WebSearch"), + "WebSearch" + ); +}); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 403d1d895e7c..cf81c24de182 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -575,6 +575,9 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () ); assert.equal(result.project, "proj-1"); + // #9008: identity tool-name mappings (weather → weather) are retained so the + // response path can restore the caller's original casing. The Antigravity + // executor strips `_toolNameMap` before the upstream wire body. assert.deepEqual(Object.keys(result), [ "project", "requestId",