mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 08:02:14 +03:00
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:
205
tests/unit/claude-code-tool-casing-identity-echo.test.ts
Normal file
205
tests/unit/claude-code-tool-casing-identity-echo.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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: "" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user