diff --git a/changelog.d/fixes/11085-claude-code-tool-name-casing.md b/changelog.d/fixes/11085-claude-code-tool-name-casing.md new file mode 100644 index 0000000000..5ad424c141 --- /dev/null +++ b/changelog.d/fixes/11085-claude-code-tool-name-casing.md @@ -0,0 +1 @@ +- **fix(claude):** restore canonical tool names (`bash` → `Bash`, `croncreate` → `CronCreate`) on non-streaming OpenAI→Claude conversion and through identity-echo alias maps, so Claude Code stops rejecting tool calls with "No such tool available" ([#11085](https://github.com/diegosouzapw/OmniRoute/pull/11085)) — thanks @linhdmn diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 01bdd4c14a..43e03d919d 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -10,6 +10,7 @@ import { caseInsensitiveToolNameLookup, restoreOpenAIToolNames, } from "../translator/helpers/toolCallHelper.ts"; +import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts"; import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts"; import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts"; @@ -631,7 +632,7 @@ export function translateNonStreamingResponse( // Phase 3: Translate from OpenAI back to Client Source format if (sourceFormat === FORMATS.CLAUDE && sourceFormat !== targetFormat) { - return convertOpenAINonStreamingToClaude(toRecord(intermediateOpenAI)); + return convertOpenAINonStreamingToClaude(toRecord(intermediateOpenAI), toolNameMap ?? null); } // Gemini-family clients (Gemini, Antigravity): the streaming SSE path already @@ -667,8 +668,18 @@ function resolveReasoningText(messageObj: JsonRecord): string { /** * Helper to convert an OpenAI chat.completion JSON object to Claude format for non-streaming. + * + * `toolNameMap` carries request-side aliases; when it does not resolve a name, + * `restoreClaudeToolName` upgrades known Claude Code tools to their canonical + * PascalCase ("bash" → "Bash", "croncreate" → "CronCreate"). Without this, a + * non-streaming upstream JSON body (or a stream:true request the upstream + * answered with application/json) reaches Claude Code with lowercase tool_use + * names the CLI rejects as "No such tool available". */ -function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonRecord { +function convertOpenAINonStreamingToClaude( + openaiResponse: JsonRecord, + toolNameMap?: Map | null +): JsonRecord { const choices = openaiResponse.choices as unknown[] | undefined; const isChoicesArray = Array.isArray(choices); if (!isChoicesArray && openaiResponse.object !== "chat.completion") { @@ -717,7 +728,7 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco content.push({ type: "tool_use", id: sanitizeToolId(rawId), - name: toString(fn.name), + name: restoreClaudeToolName(toString(fn.name), toolNameMap ?? null), input: typeof fn.arguments === "string" ? JSON.parse(fn.arguments || "{}") : fn.arguments || {}, }); diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 15995a9500..82e408e7c8 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -57,6 +57,10 @@ const TOOL_RENAME_MAP: Record = { cronlist: "CronList", taskoutput: "TaskOutput", taskstop: "TaskStop", + taskcreate: "TaskCreate", + taskupdate: "TaskUpdate", + tasklist: "TaskList", + taskget: "TaskGet", workflow: "Workflow", }; @@ -205,14 +209,22 @@ export function remapToolNamesInResponse( * 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) + * 1. Exact `_toolNameMap` hit where the value differs from the key + * (sanitized → original request-side alias) + * 2. Canonical casing upgrade for known Claude Code tools + * (`croncreate` → `CronCreate`, `bash` → `Bash`, …) + * 3. Case-insensitive non-identity match against map keys/values + * (Gemini/Antigravity may echo a lowercased name for a PascalCase + * Claude Code tool) + * 4. Identity echo kept ONLY when no canonical upgrade exists + * 5. No-map fallbacks: REVERSE_MAP TitleCase → lowercase (#7926 XML / + * OpenCode-style lowercase tools), then the static table * - * Never apply REVERSE_MAP after a request-side original is known — that is what - * turned Claude Code's `Read`/`WebSearch` into `read`/`websearch`. + * Identity entries (key === value) never pin a known tool below its + * canonical casing. Some upstream gateways echo the very lowercase name + * they emitted into the alias channel; honouring that echo is what let a + * literal `croncreate` reach Claude Code as an unknown tool even though + * the request declared `CronCreate`. */ export function restoreClaudeToolName( rawName: string, @@ -220,27 +232,49 @@ export function restoreClaudeToolName( ): string { if (!rawName) return rawName; - const exact = toolNameMap?.get(rawName); - if (typeof exact === "string") return exact; + // Undefined when rawName already IS the canonical form — an input that + // maps to itself must keep flowing to the #7926 legacy paths below. + const lower = rawName.toLowerCase(); + const canonicalRaw = TOOL_RENAME_MAP[lower]; + const canonical = canonicalRaw && canonicalRaw !== rawName ? canonicalRaw : undefined; if (toolNameMap?.size) { - const lower = rawName.toLowerCase(); + const exact = toolNameMap.get(rawName); + if (typeof exact === "string" && (exact !== rawName || !canonical)) { + return exact; + } + + let identityMatch: string | undefined; for (const [sanitized, original] of toolNameMap.entries()) { - if (sanitized.toLowerCase() === lower || original.toLowerCase() === lower) { + if (sanitized.toLowerCase() !== lower && original.toLowerCase() !== lower) { + continue; + } + if (original !== rawName) { return original; } + identityMatch = original; + } + if (identityMatch !== undefined && !canonical) { + return identityMatch; } } + // Canonical echo is terminal: when the upstream echoes back the exact + // canonical form the request declared, keep it verbatim. The #7926 + // REVERSE_MAP fallbacks below would otherwise downcase it for routes that + // carry no _toolNameMap (Claude Code → OpenAI-style upstreams), which is + // what let a literal `croncreate` reach Claude Code even though the client + // declared `CronCreate` (live repro, PR #11085). + if (canonicalRaw === rawName) return rawName; + + if (canonical) return canonical; + // When no request toolNameMap is provided (e.g. non-Claude client): // If rawName is already TitleCase, apply REVERSE_MAP for #7926 backward compatibility (Bash → bash). if (!toolNameMap && REVERSE_MAP[rawName]) { return REVERSE_MAP[rawName]; } - const canonical = TOOL_RENAME_MAP[rawName.toLowerCase()]; - if (canonical) return canonical; - return REVERSE_MAP[rawName] ?? rawName; } diff --git a/tests/unit/claude-code-tool-casing-identity-echo.test.ts b/tests/unit/claude-code-tool-casing-identity-echo.test.ts new file mode 100644 index 0000000000..63d1edf3d2 --- /dev/null +++ b/tests/unit/claude-code-tool-casing-identity-echo.test.ts @@ -0,0 +1,205 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { restoreClaudeToolName } from "../../open-sse/services/claudeCodeToolRemapper.ts"; +import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.ts"; +import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +interface ClaudeEvent { + type: string; + index?: number; + content_block?: { type: string; id?: string; name?: string; input?: unknown }; +} + +type TranslatorState = Record; + +function firstToolUse(events: ClaudeEvent[] | null): ClaudeEvent["content_block"] { + return events?.find( + (e) => e.type === "content_block_start" && e.content_block?.type === "tool_use" + )?.content_block; +} + +function openaiToolCallChunk(name: string): { choices: Array> } { + return { + choices: [ + { + delta: { + tool_calls: [{ index: 0, id: "call_echo", function: { name, arguments: "" } }], + }, + }, + ], + }; +} + +/** + * Claude Code 2.1.x added CronCreate/CronList/CronDelete/ScheduleWakeup/ + * EnterWorktree. The `/loop` skill schedules via CronCreate; upstream gateways + * that emit the lowercased name (and echo it into the toolNameMap alias + * channel) previously let `croncreate` reach Claude Code un-restored, which + * the CLI rejects with "No such tool available" — killing /loop AND every + * other native tool call emitted in lowercase form. + */ +describe("Claude Code cron-era tool names survive identity-echo alias maps", () => { + const ECHO_MAPS = [ + ["identity entry croncreate→croncreate", new Map([["croncreate", "croncreate"]])], + ["cloak-direction entry CronCreate→croncreate", new Map([["CronCreate", "croncreate"]])], + [ + "identity + unrelated aliases", + new Map([ + ["subdispatch", "SubDispatch"], + ["croncreate", "croncreate"], + ]), + ], + ] as const; + + for (const [label, map] of ECHO_MAPS) { + it(`restoreClaudeToolName upgrades echoed lowercase cron tools — ${label}`, () => { + assert.equal(restoreClaudeToolName("croncreate", map), "CronCreate"); + assert.equal(restoreClaudeToolName("cronlist", map), "CronList"); + assert.equal(restoreClaudeToolName("crondelete", map), "CronDelete"); + assert.equal(restoreClaudeToolName("schedulewakeup", map), "ScheduleWakeup"); + assert.equal(restoreClaudeToolName("enterworktree", map), "EnterWorktree"); + assert.equal(restoreClaudeToolName("bash", map), "Bash"); + assert.equal(restoreClaudeToolName("taskcreate", map), "TaskCreate"); + assert.equal(restoreClaudeToolName("taskupdate", map), "TaskUpdate"); + assert.equal(restoreClaudeToolName("tasklist", map), "TaskList"); + assert.equal(restoreClaudeToolName("taskget", map), "TaskGet"); + }); + + it(`openaiToClaudeResponse emits PascalCase content_block.name — ${label}`, () => { + const state: TranslatorState = { + toolCalls: new Map(), + nextBlockIndex: 0, + toolNameMap: map, + }; + const block = firstToolUse( + openaiToClaudeResponse(openaiToolCallChunk("croncreate"), state) as ClaudeEvent[] + ); + assert.equal(block?.name, "CronCreate"); + }); + } + + it("request-side non-identity alias still beats canonical casing", () => { + // A client that actually declared a custom lowercase MCP-style name keeps it. + const map = new Map([ + ["read", "mcp__fs__read"], + ["croncreate", "CronCreate"], + ]); + assert.equal(restoreClaudeToolName("read", map), "mcp__fs__read"); + }); + + it("unknown tools with identity entries are preserved verbatim", () => { + const map = new Map([["my_custom_tool", "my_custom_tool"]]); + assert.equal(restoreClaudeToolName("my_custom_tool", map), "my_custom_tool"); + }); + + it("canonical echo stays canonical on no-map routes (live repro 2026-08-22)", () => { + // Live-tested against glm-5.2 via opencode-go: the request declared + // CronCreate/Bash, the gateway echoed them TitleCase, and claude-to-openai + // builds no _toolNameMap — the old #7926 REVERSE_MAP fallback downcased + // the echo to `croncreate`/`bash`, which Claude Code rejects with + // "No such tool available", killing those tools for the whole session. + // Every restoreClaudeToolName caller converts toward a Claude-format + // client, so blind TitleCase→lowercase downcasing has no legitimate + // consumer left: legacy lowercase clients are protected by explicit + // alias maps (see test above), not by unmapped downcasing. + assert.equal(restoreClaudeToolName("TodoWrite", null), "TodoWrite"); + assert.equal(restoreClaudeToolName("Read", undefined), "Read"); + assert.equal(restoreClaudeToolName("WebSearch", null), "WebSearch"); + }); +}); + +/** + * Live-reproduced 2026-08-22: an `ox-alpha-free` upstream answered the + * stream:true /v1/messages request with a non-streaming JSON body; omniroute + * converted it via translateNonStreamingResponse, which emitted tool_use.name + * verbatim ("bash") — Claude Code rejected it with "No such tool available", + * killing Bash/Read/Write/CronCreate for the whole session. + */ +describe("translateNonStreamingResponse restores Claude Code tool casing", () => { + function openaiJson(name: string) { + return { + id: "202608221120471ad3e3bd71e24afd", + object: "chat.completion", + choices: [ + { + index: 0, + finish_reason: "tool_calls", + message: { + role: "assistant", + content: null, + reasoning_content: "The user wants echo ok", + tool_calls: [ + { + id: "call_b90e72ccc16f440c88c4f9e6", + type: "function", + function: { name, arguments: '{"command":"echo ok"}' }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 246, completion_tokens: 35 }, + }; + } + + it("upgrades lowercase native tool names with no alias map (live repro)", () => { + const out = translateNonStreamingResponse( + openaiJson("bash"), + FORMATS.OPENAI, + FORMATS.CLAUDE, + null + ); + const toolUse = out.content.find((b) => b.type === "tool_use"); + assert.equal(toolUse.name, "Bash"); + assert.equal(toolUse.input.command, "echo ok"); + }); + + it("upgrades cron-era tools through identity-echo maps", () => { + const out = translateNonStreamingResponse( + openaiJson("croncreate"), + FORMATS.OPENAI, + FORMATS.CLAUDE, + new Map([["croncreate", "croncreate"]]) + ); + assert.equal(out.content.find((b) => b.type === "tool_use").name, "CronCreate"); + }); + + it("request-side aliases still win over canonical casing", () => { + const out = translateNonStreamingResponse( + openaiJson("read"), + FORMATS.OPENAI, + FORMATS.CLAUDE, + new Map([["read", "mcp__fs__read"]]) + ); + assert.equal(out.content.find((b) => b.type === "tool_use").name, "mcp__fs__read"); + }); + + it("keeps canonical casing the upstream echoed verbatim when no alias map exists (live repro #11085)", () => { + // Live-tested on the Claude Code → OpenAI-style upstream route: the request + // declares CronCreate/Bash, the gateway echoes them TitleCase, and + // claude-to-openai builds no _toolNameMap — the #7926 REVERSE_MAP fallback + // must not downcase a canonical name back into "No such tool available". + const out = translateNonStreamingResponse( + openaiJson("CronCreate"), + FORMATS.OPENAI, + FORMATS.CLAUDE, + null + ); + assert.equal(out.content.find((b) => b.type === "tool_use").name, "CronCreate"); + assert.equal(restoreClaudeToolName("Bash", null), "Bash"); + assert.equal(restoreClaudeToolName("WebSearch", null), "WebSearch"); + assert.equal(restoreClaudeToolName("TaskCreate", new Map()), "TaskCreate"); + }); + + it("declared lowercase form still wins when an explicit alias maps canonical → lowercase", () => { + // Legacy OpenCode/XML-style clients declare `bash`; request-side cloak + // records { CronCreate→croncreate }-style aliases and restoreClaudeToolName + // must keep honoring them even when the upstream echoes the canonical form. + assert.equal( + restoreClaudeToolName("CronCreate", new Map([["CronCreate", "croncreate"]])), + "croncreate" + ); + assert.equal(restoreClaudeToolName("Read", new Map([["Read", "read"]])), "read"); + }); +}); diff --git a/tests/unit/claude-tool-name-casing-fix.test.ts b/tests/unit/claude-tool-name-casing-fix.test.ts index c02fd09286..5c11d1652b 100644 --- a/tests/unit/claude-tool-name-casing-fix.test.ts +++ b/tests/unit/claude-tool-name-casing-fix.test.ts @@ -50,10 +50,13 @@ describe("Claude Code Tool Name Casing Fixes", () => { assert.equal(restoreClaudeToolName("exitplanmode"), "ExitPlanMode"); }); - it("restoreClaudeToolName keeps the #7926 TitleCase→lowercase fallback with no map", () => { - // Clients with no request-side map (XML / OpenCode-style) expect lowercase. - assert.equal(restoreClaudeToolName("TodoWrite"), "todowrite"); - assert.equal(restoreClaudeToolName("Read"), "read"); + it("restoreClaudeToolName keeps canonical TitleCase with no map (#11085 live repro)", () => { + // Live-tested 2026-08-22: no-map routes (Claude Code → OpenAI-style + // upstreams) receive the gateway's TitleCase echo and must keep it — + // downcasing made Claude Code reject its own tools. XML/OpenCode-style + // lowercase clients are protected by explicit alias maps instead. + assert.equal(restoreClaudeToolName("TodoWrite"), "TodoWrite"); + assert.equal(restoreClaudeToolName("Read"), "Read"); }); it("restoreClaudeToolName prefers toolNameMap over the static map", () => { @@ -150,9 +153,7 @@ describe("Claude Code Tool Name Casing Fixes", () => { choices: [ { delta: { - tool_calls: [ - { index: 0, id: "call_123", function: { name: "bash", arguments: "" } }, - ], + tool_calls: [{ index: 0, id: "call_123", function: { name: "bash", arguments: "" } }], }, }, ], 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 index 798e130bd6..99d655335e 100644 --- a/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts +++ b/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts @@ -18,8 +18,7 @@ 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"); +const { restoreClaudeToolName } = await import("../../open-sse/services/claudeCodeToolRemapper.ts"); function toolUseName(events: Array> | null): string | undefined { const start = (events || []).find( @@ -27,9 +26,7 @@ function toolUseName(events: Array> | null): string | un 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; + return (start?.content_block as Record | undefined)?.name as string | undefined; } test("#9008 restoreClaudeToolName: preserves PascalCase from the request map", () => { @@ -50,9 +47,13 @@ test("#9008 restoreClaudeToolName: maps lowercased upstream names back to declar 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 restoreClaudeToolName: keeps canonical TitleCase when no request map (#11085 live repro)", () => { + // Live-tested 2026-08-22 (glm via opencode-go → /v1/messages): the gateway + // echoed Bash/Read TitleCase and claude-to-openai ships no _toolNameMap; + // downcasing here made Claude Code reject its own tools. Legacy lowercase + // clients are protected by explicit alias maps instead of blind downcasing. + assert.equal(restoreClaudeToolName("Bash", null), "Bash"); + assert.equal(restoreClaudeToolName("Read", undefined), "Read"); }); test("#9008 Gemini → Claude: PascalCase tool_use survives when upstream echoes TitleCase", () => { diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts index 59e58c0522..f8f1cfcfdb 100644 --- a/tests/unit/translator-resp-openai-to-claude.test.ts +++ b/tests/unit/translator-resp-openai-to-claude.test.ts @@ -105,7 +105,9 @@ test("OpenAI stream: internal reasoning replay placeholder stays hidden from Cla const result = flatten([placeholder, text]); assert.equal( - result.some((event) => event.type === "content_block_start" && event.content_block?.type === "thinking"), + result.some( + (event) => event.type === "content_block_start" && event.content_block?.type === "thinking" + ), false ); assert.equal(result[0].type, "message_start"); @@ -217,10 +219,7 @@ test("OpenAI stream: multi-chunk content without the placeholder passes through textDeltas.map((event) => event.delta.text), ["Hello, ", "world.", " Bye."] ); - assert.equal( - textDeltas.map((event) => event.delta.text).join(""), - "Hello, world. Bye." - ); + assert.equal(textDeltas.map((event) => event.delta.text).join(""), "Hello, world. Bye."); }); test("OpenAI stream: tool calls strip Claude OAuth prefix and keep cache usage", () => { @@ -427,9 +426,11 @@ test("OpenAI stream: XML block in content becomes tool_use at finish", // message_start → (no text block since all content was XML) assert.equal(result[0].type, "message_start"); // At finish: tool_use content_block_start - const toolStart = result.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use"); + const toolStart = result.find( + (e) => e.type === "content_block_start" && e.content_block?.type === "tool_use" + ); assert.ok(toolStart, "expected tool_use content_block_start"); - assert.equal(toolStart.content_block.name, "bash"); // normalized via REVERSE_MAP + assert.equal(toolStart.content_block.name, "Bash"); // canonical echo kept (#11085 live repro) assert.deepEqual(toolStart.content_block.input, { command: "ls -la" }); // tool_use content_block_stop const toolStop = result.find((e) => e.type === "content_block_stop"); @@ -465,7 +466,7 @@ test("OpenAI stream: XML invoke block across two streaming chunks", () => { choices: [ { index: 0, - delta: { content: 'hosts' }, + delta: { content: "hosts" }, finish_reason: null, }, ], @@ -487,9 +488,11 @@ test("OpenAI stream: XML invoke block across two streaming chunks", () => { // Buffer should be cleared after chunk2 assert.equal(state._xmlInvokeBuffer, "", "buffer cleared after complete block"); - const toolStart = result.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use"); + const toolStart = result.find( + (e) => e.type === "content_block_start" && e.content_block?.type === "tool_use" + ); assert.ok(toolStart, "expected tool_use content_block_start"); - assert.equal(toolStart.content_block.name, "read"); + assert.equal(toolStart.content_block.name, "Read"); // canonical echo kept (#11085 live repro) assert.deepEqual(toolStart.content_block.input, { file_path: "/etc/hosts" }); }); @@ -538,15 +541,25 @@ test("OpenAI stream: text before XML block is emitted as text content", () => { const result = flatten([chunk1, chunk2, chunk3]); // "Checking..." should be emitted as text - const textDeltas = result.filter((e) => e.type === "content_block_delta" && e.delta?.type === "text_delta"); + const textDeltas = result.filter( + (e) => e.type === "content_block_delta" && e.delta?.type === "text_delta" + ); assert.ok(textDeltas.length > 0, "expected at least one text delta"); - assert.ok(textDeltas.some((d) => d.delta.text.includes("Checking...")), "text before XML preserved"); - assert.ok(textDeltas.some((d) => d.delta.text.includes("Done.")), "text after XML preserved"); + assert.ok( + textDeltas.some((d) => d.delta.text.includes("Checking...")), + "text before XML preserved" + ); + assert.ok( + textDeltas.some((d) => d.delta.text.includes("Done.")), + "text after XML preserved" + ); // Tool call should still be emitted - const toolStart = result.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use"); + const toolStart = result.find( + (e) => e.type === "content_block_start" && e.content_block?.type === "tool_use" + ); assert.ok(toolStart, "expected tool_use content_block_start"); - assert.equal(toolStart.content_block.name, "bash"); + assert.equal(toolStart.content_block.name, "Bash"); // canonical echo kept (#11085 live repro) assert.deepEqual(toolStart.content_block.input, { command: "date" }); });