fix: keep Claude tool remap metadata off wire

This commit is contained in:
Kahramanov
2026-05-14 17:00:28 +03:00
parent c6f5b394f8
commit e244fd51d4
2 changed files with 121 additions and 15 deletions

View File

@@ -1,9 +1,8 @@
/**
* Claude Code tool name remapping.
*
* Anthropic uses tool name fingerprinting to detect third-party clients.
* Real Claude Code uses TitleCase tool names (Bash, Read, Write, etc.)
* while third-party clients like OpenCode use lowercase.
* Claude Code-compatible requests use TitleCase tool names (Bash, Read,
* Write, etc.) while OpenAI-compatible clients commonly use lowercase names.
*
* This module remaps tool names in both directions:
* - Request path: lowercase → TitleCase (before sending to Anthropic)
@@ -38,17 +37,35 @@ for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) {
REVERSE_MAP[v] = k;
}
function attachToolNameMap(body: Record<string, unknown>, toolNameMap: Map<string, string>): void {
if (toolNameMap.size === 0) return;
Object.defineProperty(body, "_toolNameMap", {
value: toolNameMap,
enumerable: false,
configurable: true,
writable: true,
});
}
export function remapToolNamesInRequest(body: Record<string, unknown>): boolean {
let hasLowercase = false;
let hasTitleCase = false;
const existingToolNameMap = body._toolNameMap instanceof Map ? body._toolNameMap : null;
const toolNameMap = new Map<string, string>(existingToolNameMap ?? []);
const recordRemap = (upstreamName: string, originalName: string): void => {
toolNameMap.set(upstreamName, originalName);
};
// Remap tool definitions
const tools = body.tools as Array<Record<string, unknown>> | undefined;
if (Array.isArray(tools)) {
for (const tool of tools) {
const name = String(tool.name || "");
if (TOOL_RENAME_MAP[name]) {
tool.name = TOOL_RENAME_MAP[name];
const mapped = TOOL_RENAME_MAP[name];
if (mapped) {
tool.name = mapped;
recordRemap(mapped, name);
hasLowercase = true;
} else if (REVERSE_MAP[name]) {
hasTitleCase = true;
@@ -64,11 +81,13 @@ 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") {
const mapped = TOOL_RENAME_MAP[block.name];
const name = block.name;
const mapped = TOOL_RENAME_MAP[name];
if (mapped) {
block.name = mapped;
recordRemap(mapped, name);
hasLowercase = true;
} else if (REVERSE_MAP[block.name]) {
} else if (REVERSE_MAP[name]) {
hasTitleCase = true;
}
}
@@ -79,27 +98,38 @@ 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") {
const mapped = TOOL_RENAME_MAP[toolChoice.name];
const name = toolChoice.name;
const mapped = TOOL_RENAME_MAP[name];
if (mapped) {
toolChoice.name = mapped;
recordRemap(mapped, name);
hasLowercase = true;
} else if (REVERSE_MAP[toolChoice.name]) {
} else if (REVERSE_MAP[name]) {
hasTitleCase = true;
}
}
if (hasLowercase && !hasTitleCase) {
body._claudeCodeRequiresLowercaseToolNames = true;
}
attachToolNameMap(body, toolNameMap);
return hasLowercase && !hasTitleCase;
}
export function remapToolNamesInResponse(text: string, forceLowercase = true): string {
export function remapToolNamesInResponse(
text: string,
forceLowercase = true,
toolNameMap: Map<string, string> | null = null
): string {
if (!forceLowercase) return text;
const replacements = new Map<string, string>(Object.entries(REVERSE_MAP));
if (toolNameMap instanceof Map) {
for (const [upstreamName, originalName] of toolNameMap.entries()) {
replacements.set(upstreamName, originalName);
}
}
// Replace TitleCase tool names back to lowercase in SSE chunks
for (const [titleCase, lower] of Object.entries(REVERSE_MAP)) {
for (const [titleCase, lower] of replacements.entries()) {
// Match in "name":"ToolName" patterns
text = text.replaceAll(`"name":"${titleCase}"`, `"name":"${lower}"`);
text = text.replaceAll(`"name": "${titleCase}"`, `"name": "${lower}"`);

View File

@@ -23,7 +23,10 @@ import {
} from "../../open-sse/services/claudeCodeFingerprint.ts";
// ── Tool remapper ─────────────────────────────────────────────────────────────
import { remapToolNamesInRequest } from "../../open-sse/services/claudeCodeToolRemapper.ts";
import {
remapToolNamesInRequest,
remapToolNamesInResponse,
} from "../../open-sse/services/claudeCodeToolRemapper.ts";
// ── Constraints ───────────────────────────────────────────────────────────────
import {
@@ -165,6 +168,61 @@ describe("remapToolNamesInRequest", () => {
assert.ok(Array.isArray(body.tools), "tools array must still be present after remap");
});
it("tracks remapped tool names without leaking helper fields into the wire payload", () => {
const body = {
tools: [
{ name: "bash", description: "Run bash commands" },
{ name: "glob", description: "Search files" },
],
tool_choice: { type: "tool", name: "glob" },
messages: [
{
role: "assistant",
content: [{ type: "tool_use", name: "read", input: { file_path: "README.md" } }],
},
],
};
remapToolNamesInRequest(body);
const mappedBody = body as Record<string, unknown> & {
_toolNameMap?: Map<string, string>;
_claudeCodeRequiresLowercaseToolNames?: boolean;
};
assert.equal(body.tools[0].name, "Bash");
assert.equal(body.tools[1].name, "Glob");
assert.equal(body.tool_choice.name, "Glob");
assert.equal(body.messages[0].content[0].name, "Read");
assert.equal(mappedBody._toolNameMap?.get("Bash"), "bash");
assert.equal(mappedBody._toolNameMap?.get("Glob"), "glob");
assert.equal(mappedBody._toolNameMap?.get("Read"), "read");
assert.equal(Object.keys(body).includes("_toolNameMap"), false);
assert.equal(mappedBody._claudeCodeRequiresLowercaseToolNames, undefined);
const wirePayload = JSON.stringify(body);
assert.equal(wirePayload.includes("_toolNameMap"), false);
assert.equal(wirePayload.includes("_claudeCodeRequiresLowercaseToolNames"), false);
assert.match(wirePayload, /"name":"Bash"/);
assert.match(wirePayload, /"name":"Glob"/);
});
it("merges an existing in-memory tool name map and keeps it non-enumerable", () => {
const body: Record<string, unknown> = {
tools: [{ name: "bash", description: "Run bash commands" }],
messages: [],
};
body._toolNameMap = new Map([["proxy_read_file", "read_file"]]);
remapToolNamesInRequest(body);
const toolNameMap = body._toolNameMap as Map<string, string>;
assert.equal(toolNameMap.get("proxy_read_file"), "read_file");
assert.equal(toolNameMap.get("Bash"), "bash");
assert.equal(Object.keys(body).includes("_toolNameMap"), false);
assert.equal(JSON.stringify(body).includes("_toolNameMap"), false);
});
it("handles body without tools without throwing", () => {
const body = { messages: [{ role: "user", content: "hello" }] };
assert.doesNotThrow(() => remapToolNamesInRequest(body));
@@ -172,6 +230,24 @@ describe("remapToolNamesInRequest", () => {
// Note: remapToolNamesInRequest requires a non-null body (callers always provide one)
});
describe("remapToolNamesInResponse", () => {
it("restores response tool names from the request-side map", () => {
const text = 'data: {"name":"Bash","other":{"name": "Glob"}}\n\n';
const restored = remapToolNamesInResponse(
text,
true,
new Map([
["Bash", "shell"],
["Glob", "glob"],
])
);
assert.match(restored, /"name":"shell"/);
assert.match(restored, /"name": "glob"/);
assert.equal(remapToolNamesInResponse(text, false), text);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// API constraints tests
// ─────────────────────────────────────────────────────────────────────────────