mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
* 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).
159 lines
6.0 KiB
TypeScript
159 lines
6.0 KiB
TypeScript
/**
|
|
* 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");
|
|
});
|
|
});
|