Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions open-sse/services/claudeCodeToolRemapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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 };

/**
Expand Down
11 changes: 4 additions & 7 deletions open-sse/translator/request/claude-to-gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 8 additions & 10 deletions open-sse/translator/response/gemini-to-claude.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}`;

Expand Down
13 changes: 7 additions & 6 deletions open-sse/translator/response/openai-to-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
},
});
Expand Down
160 changes: 160 additions & 0 deletions tests/unit/gemini-to-claude-tool-name-case-9008.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> | null): string | undefined {
const start = (events || []).find(
(e) =>
e.type === "content_block_start" &&
(e.content_block as Record<string, unknown> | undefined)?.type === "tool_use"
);
return (start?.content_block as Record<string, unknown> | undefined)?.name as
| string
| undefined;
}

test("#9008 restoreClaudeToolName: preserves PascalCase from the request map", () => {
const map = new Map<string, string>([
["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<string, string>([
["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<string, string>([
["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<string, string>([
["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<string, string> })._toolNameMap instanceof Map);
assert.equal(
(result as { _toolNameMap: Map<string, string> })._toolNameMap.get("WebSearch"),
"WebSearch"
);
});
3 changes: 3 additions & 0 deletions tests/unit/translator-openai-to-gemini.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading