mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 15:42:12 +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:
1
changelog.d/fixes/11085-claude-code-tool-name-casing.md
Normal file
1
changelog.d/fixes/11085-claude-code-tool-name-casing.md
Normal 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
|
||||
@@ -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 || {},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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