diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 754ecc978a..1f9bb745cc 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -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..name: Input should be ''` (tools[] + * renamed). + */ +function collectServerToolNames(tools: unknown): Set { + const names = new Set(); + if (!Array.isArray(tools)) return names; + for (const tool of tools) { + const t = tool as Record | null; + if (t && isAnthropicServerToolType(t.type) && typeof t.name === "string") { + names.add(t.name); + } + } + return names; +} + export function remapToolNamesInRequest(body: Record): boolean { let hasLowercase = false; let hasTitleCase = false; + const serverToolNames = collectServerToolNames(body.tools); // Remap tool definitions const tools = body.tools as Array> | 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): 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): boolean // Remap tool_choice const toolChoice = body.tool_choice as Record | 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> | 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(); 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) }; diff --git a/tests/unit/claude-server-tool-name-preserve.test.ts b/tests/unit/claude-server-tool-name-preserve.test.ts new file mode 100644 index 0000000000..84f41311d3 --- /dev/null +++ b/tests/unit/claude-server-tool-name-preserve.test.ts @@ -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; + +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 | 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"); + }); +});