mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(sse): preserve server-tool literal names in message history and tool_choice (#6586)
* fix(sse): preserve server-tool literal names in message history and tool_choice The v3.8.36 guard (isAnthropicServerToolType, #2943) protects Anthropic server tools (web_search_20250305, bash_20250124, ...) from the tool-name cloak only in the tools[] array. The same reserved literal names were still rewritten in message-history tool_use blocks and in tool_choice, and remapToolNamesInRequest had no guard at all (bash -> Bash). The resulting asymmetry — tools[] keeps 'web_search' while the history reference becomes 'WebSearch' — makes Anthropic reject every follow-up turn of a native web-search conversation: [400] Tool 'WebSearch' not found in provided tools Collect the declared server-tool names once per request and skip them in every rewrite path of both remapToolNamesInRequest and cloakThirdPartyToolNames (tools[], message history, tool_choice). Plain custom tools with the same names (no server type) remain remapped/cloaked exactly as before, symmetrically in all sections. Surfaced on Claude Code 2.1.x native WebSearch; same class as CLIProxyAPI #1094/#1179. TDD: 5 failing repro tests -> guard -> 7/7 green (92/92 across the remapper suite), typecheck:core clean. * fix(sse): skip null entries in tools[] before server-tool type check Review follow-up (gemini-code-assist): a null element in tools[] made the new isAnthropicServerToolType(tool.type) check throw. The crash path is pre-existing (String(tool.name) on the next line threw identically), but the guard is cheap and mirrors the null checks already used in cloakThirdPartyToolNames. Adds a regression test (8/8 green).
This commit is contained in:
@@ -57,14 +57,41 @@ function trackToolName(
|
||||
getRequestToolNameMap(body).set(titleCaseName, originalName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of Anthropic server-side tools declared in this request's tools[].
|
||||
* A server tool's `name` is a reserved literal validated against its `type`
|
||||
* (web_search_20250305 ⇒ "web_search", bash_20250124 ⇒ "bash", …), so every
|
||||
* rewrite below must leave both the declaration AND any history/tool_choice
|
||||
* reference to it untouched — renaming only one side produces
|
||||
* `Tool 'WebSearch' not found in provided tools` (history renamed, tools[]
|
||||
* preserved) or `tools.N.<type>.name: Input should be '<literal>'` (tools[]
|
||||
* renamed).
|
||||
*/
|
||||
function collectServerToolNames(tools: unknown): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (!Array.isArray(tools)) return names;
|
||||
for (const tool of tools) {
|
||||
const t = tool as Record<string, unknown> | null;
|
||||
if (t && isAnthropicServerToolType(t.type) && typeof t.name === "string") {
|
||||
names.add(t.name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function remapToolNamesInRequest(body: Record<string, unknown>): boolean {
|
||||
let hasLowercase = false;
|
||||
let hasTitleCase = false;
|
||||
const serverToolNames = collectServerToolNames(body.tools);
|
||||
|
||||
// Remap tool definitions
|
||||
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(tools)) {
|
||||
for (const tool of tools) {
|
||||
if (!tool) continue;
|
||||
// Server tools (bash_20250124 / web_search_20250305 / …) keep their
|
||||
// type-bound literal name.
|
||||
if (isAnthropicServerToolType(tool.type)) continue;
|
||||
const name = String(tool.name || "");
|
||||
if (TOOL_RENAME_MAP[name]) {
|
||||
const mapped = TOOL_RENAME_MAP[name];
|
||||
@@ -85,6 +112,7 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): boolean
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const block of content) {
|
||||
if (block.type === "tool_use" && typeof block.name === "string") {
|
||||
if (serverToolNames.has(block.name)) continue;
|
||||
const mapped = TOOL_RENAME_MAP[block.name];
|
||||
if (mapped) {
|
||||
const originalName = block.name;
|
||||
@@ -101,7 +129,11 @@ export function remapToolNamesInRequest(body: Record<string, unknown>): boolean
|
||||
|
||||
// Remap tool_choice
|
||||
const toolChoice = body.tool_choice as Record<string, unknown> | undefined;
|
||||
if (toolChoice?.type === "tool" && typeof toolChoice.name === "string") {
|
||||
if (
|
||||
toolChoice?.type === "tool" &&
|
||||
typeof toolChoice.name === "string" &&
|
||||
!serverToolNames.has(toolChoice.name)
|
||||
) {
|
||||
const mapped = TOOL_RENAME_MAP[toolChoice.name];
|
||||
if (mapped) {
|
||||
const originalName = toolChoice.name;
|
||||
@@ -248,6 +280,11 @@ export function cloakThirdPartyToolNames(
|
||||
const shouldCloak = (name: string): boolean =>
|
||||
needsThirdPartyCloak(name) && !(options?.skip ? options.skip(name) : false);
|
||||
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
||||
// Reserved literal names of declared server tools — never cloaked, neither
|
||||
// in tools[] (guarded below) nor in message-history / tool_choice references
|
||||
// (renaming only the reference yields "Tool 'WebSearch' not found in
|
||||
// provided tools").
|
||||
const serverToolNames = collectServerToolNames(tools);
|
||||
|
||||
const used = new Set<string>();
|
||||
if (Array.isArray(tools)) {
|
||||
@@ -274,7 +311,9 @@ export function cloakThirdPartyToolNames(
|
||||
// subagents->SubDispatch, session_status->CheckStatus, webfetch->WebFetch, …
|
||||
// Then harness-canonical (read_file->Read), then a generic PascalCase.
|
||||
const base =
|
||||
TOOL_RENAME_MAP[original] ?? HARNESS_CANONICAL_MAP[original] ?? toPascalCaseToolName(original);
|
||||
TOOL_RENAME_MAP[original] ??
|
||||
HARNESS_CANONICAL_MAP[original] ??
|
||||
toPascalCaseToolName(original);
|
||||
let alias = base;
|
||||
let suffix = 2;
|
||||
while (alias !== original && used.has(alias)) {
|
||||
@@ -315,6 +354,7 @@ export function cloakThirdPartyToolNames(
|
||||
if (
|
||||
block?.type === "tool_use" &&
|
||||
typeof block.name === "string" &&
|
||||
!serverToolNames.has(block.name) &&
|
||||
shouldCloak(block.name)
|
||||
) {
|
||||
changed = true;
|
||||
@@ -330,6 +370,7 @@ export function cloakThirdPartyToolNames(
|
||||
if (
|
||||
toolChoice?.type === "tool" &&
|
||||
typeof toolChoice.name === "string" &&
|
||||
!serverToolNames.has(toolChoice.name) &&
|
||||
shouldCloak(toolChoice.name)
|
||||
) {
|
||||
body.tool_choice = { ...toolChoice, name: aliasFor(toolChoice.name) };
|
||||
|
||||
158
tests/unit/claude-server-tool-name-preserve.test.ts
Normal file
158
tests/unit/claude-server-tool-name-preserve.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Anthropic server (built-in) tools must keep their literal `name` in EVERY
|
||||
* request section — tools[], message-history `tool_use` blocks, and
|
||||
* `tool_choice` — not just in the tools array.
|
||||
*
|
||||
* Anthropic's server tools are identified by a versioned `type`
|
||||
* (e.g. `web_search_20250305`) paired with a FIXED literal `name`
|
||||
* (`web_search`, `bash`, …) that the API validates as a pair. The tools-array
|
||||
* rewrite is already guarded by `isAnthropicServerToolType`, but the
|
||||
* message-history and `tool_choice` rewrites were not. That asymmetry renames
|
||||
* only the history/tool_choice reference (`web_search` → `WebSearch`) while
|
||||
* tools[] keeps the literal `web_search`, so Anthropic rejects the request:
|
||||
*
|
||||
* [400] Tool 'WebSearch' not found in provided tools
|
||||
*
|
||||
* Same class for the fixed Claude Code rename map: `bash_20250124` carries the
|
||||
* literal name `bash`, which `remapToolNamesInRequest` would rewrite to `Bash`
|
||||
* (→ `tools.0.bash_20250124.name: Input should be 'bash'`).
|
||||
*
|
||||
* Regression surfaced on Claude Code 2.1.x native web-search calls; same class
|
||||
* as CLIProxyAPI #1094/#1179.
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
cloakThirdPartyToolNames,
|
||||
remapToolNamesInRequest,
|
||||
} from "../../open-sse/services/claudeCodeToolRemapper.ts";
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
describe("cloakThirdPartyToolNames — server-tool names in message history", () => {
|
||||
it("keeps a history tool_use reference to a declared web_search server tool", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 8 }],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_1", name: "web_search", input: { query: "x" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "web_search");
|
||||
const block = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0];
|
||||
assert.equal(block.name, "web_search");
|
||||
});
|
||||
|
||||
it("keeps a tool_choice reference to a declared web_search server tool", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ type: "web_search_20250305", name: "web_search" }],
|
||||
tool_choice: { type: "tool", name: "web_search" },
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.equal((body.tool_choice as AnyRecord).name, "web_search");
|
||||
});
|
||||
|
||||
it("still cloaks a third-party history tool_use next to a server tool", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ type: "web_search_20250305", name: "web_search" }, { name: "mixture_of_agents" }],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_1", name: "web_search", input: {} },
|
||||
{ type: "tool_use", id: "toolu_2", name: "mixture_of_agents", input: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
const blocks = (body.messages as AnyRecord[])[0].content as AnyRecord[];
|
||||
assert.equal(blocks[0].name, "web_search");
|
||||
assert.equal(blocks[1].name, "MixtureOfAgents");
|
||||
assert.deepEqual(
|
||||
(body.tools as AnyRecord[]).map((t) => t.name),
|
||||
["web_search", "MixtureOfAgents"]
|
||||
);
|
||||
});
|
||||
|
||||
it("still cloaks a snake_case history name when no server tool declares it", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ name: "web_search", input_schema: { type: "object" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_1", name: "web_search", input: {} }],
|
||||
},
|
||||
],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
// Plain custom tool named web_search (no server type) remains cloakable —
|
||||
// symmetrically in tools[] and history.
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "WebSearch");
|
||||
const block = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0];
|
||||
assert.equal(block.name, "WebSearch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapToolNamesInRequest — Anthropic server tools", () => {
|
||||
it("does not rename a bash server tool to Bash in tools[]", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ type: "bash_20250124", name: "bash" }],
|
||||
};
|
||||
remapToolNamesInRequest(body);
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "bash");
|
||||
assert.equal((body._toolNameMap as Map<string, string> | undefined)?.size ?? 0, 0);
|
||||
});
|
||||
|
||||
it("does not rename history/tool_choice references to a declared bash server tool", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ type: "bash_20250124", name: "bash" }],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_1", name: "bash", input: { command: "ls" } }],
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "tool", name: "bash" },
|
||||
};
|
||||
remapToolNamesInRequest(body);
|
||||
const block = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0];
|
||||
assert.equal(block.name, "bash");
|
||||
assert.equal((body.tool_choice as AnyRecord).name, "bash");
|
||||
});
|
||||
|
||||
it("tolerates null entries in tools[] without throwing", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [null, { type: "bash_20250124", name: "bash" }],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_1", name: "bash", input: {} }],
|
||||
},
|
||||
],
|
||||
};
|
||||
remapToolNamesInRequest(body);
|
||||
assert.equal((body.tools as AnyRecord[])[1].name, "bash");
|
||||
const block = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0];
|
||||
assert.equal(block.name, "bash");
|
||||
});
|
||||
|
||||
it("still renames a plain lowercase custom bash tool to Bash", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ name: "bash", input_schema: { type: "object" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_1", name: "bash", input: {} }],
|
||||
},
|
||||
],
|
||||
};
|
||||
remapToolNamesInRequest(body);
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "Bash");
|
||||
const block = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0];
|
||||
assert.equal(block.name, "Bash");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user