fix(claude): restore canonical tool names so Claude Code accepts tool calls (#11085)

Merged after conflict resolution validated on the combined board (50/50 casing tests green, typecheck:core clean). Two pre-merge adjustments on the branch: (1) the utilization route conflict resolved to the tip shape — its asNullableString/displayName version is newer than the branch's; (2) dropped the newly-added src/lib/db/connections.ts, orphaned once the route kept the tip shape (tip already uses getProviderConnectionById) — nothing imported it. The casing fix itself lands intact: non-streaming OpenAI→Claude conversion now restores canonical tool names, identity echoes no longer pin lowercase, and TOOL_RENAME_MAP gained the Task* tools. Fixes the live-reproduced Claude Code 'No such tool available: bash' failures. Thank you @linhdmn — outstanding repro and root-cause writeup!
This commit is contained in:
Harvey Doan
2026-08-23 05:00:39 +07:00
committed by GitHub
parent 445121f61c
commit c19c73fdc8
7 changed files with 313 additions and 47 deletions

View File

@@ -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

View File

@@ -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<string, string> | 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 || {},
});

View File

@@ -57,6 +57,10 @@ const TOOL_RENAME_MAP: Record<string, string> = {
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;
}

View File

@@ -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<string, unknown>;
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<Record<string, unknown>> } {
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");
});
});

View File

@@ -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: "" } }],
},
},
],

View File

@@ -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<Record<string, unknown>> | null): string | undefined {
const start = (events || []).find(
@@ -27,9 +26,7 @@ function toolUseName(events: Array<Record<string, unknown>> | null): string | un
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;
return (start?.content_block as Record<string, unknown> | 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", () => {

View File

@@ -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 <invoke> 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</parameter></invoke>' },
delta: { content: "hosts</parameter></invoke>" },
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" });
});