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:
MikeTuev
2026-07-10 00:49:59 +05:00
committed by GitHub
parent edae0cf33f
commit 0d20205f92
2 changed files with 201 additions and 2 deletions

View File

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

View 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");
});
});