From e244fd51d40cc318152f8419d2bcb43780039fe2 Mon Sep 17 00:00:00 2001 From: Kahramanov Date: Thu, 14 May 2026 17:00:28 +0300 Subject: [PATCH] fix: keep Claude tool remap metadata off wire --- open-sse/services/claudeCodeToolRemapper.ts | 58 +++++++++++---- tests/unit/claude-code-parity.test.ts | 78 ++++++++++++++++++++- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 034cba4f7e..959111f3cc 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -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, toolNameMap: Map): void { + if (toolNameMap.size === 0) return; + Object.defineProperty(body, "_toolNameMap", { + value: toolNameMap, + enumerable: false, + configurable: true, + writable: true, + }); +} + export function remapToolNamesInRequest(body: Record): boolean { let hasLowercase = false; let hasTitleCase = false; + const existingToolNameMap = body._toolNameMap instanceof Map ? body._toolNameMap : null; + const toolNameMap = new Map(existingToolNameMap ?? []); + + const recordRemap = (upstreamName: string, originalName: string): void => { + toolNameMap.set(upstreamName, originalName); + }; // Remap tool definitions const tools = body.tools as Array> | 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): 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): boolean // Remap tool_choice const toolChoice = body.tool_choice as Record | 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 | null = null +): string { if (!forceLowercase) return text; + const replacements = new Map(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}"`); diff --git a/tests/unit/claude-code-parity.test.ts b/tests/unit/claude-code-parity.test.ts index ec57737f86..e06f65be8c 100644 --- a/tests/unit/claude-code-parity.test.ts +++ b/tests/unit/claude-code-parity.test.ts @@ -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 & { + _toolNameMap?: Map; + _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 = { + 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; + 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 // ─────────────────────────────────────────────────────────────────────────────