From 161e377ec129c933f0d9ed060f06f34f017c2f75 Mon Sep 17 00:00:00 2001 From: cai kerui <63952080+coobabm@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:56:03 +0900 Subject: [PATCH] Fix Claude native tool names for Claude Code --- open-sse/handlers/chatCore.ts | 101 ++++++++++++++++-- open-sse/utils/stream.ts | 24 +++++ .../claude-native-passthrough-tools.test.mjs | 41 +++++++ 3 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 tests/unit/claude-native-passthrough-tools.test.mjs diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index bb501133ea..369ae1c0ab 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -25,6 +25,7 @@ import { } from "@/lib/usageDb"; import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb"; import { getExecutor } from "../executors/index.ts"; +import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "./sseParser.ts"; @@ -65,6 +66,51 @@ export function shouldUseNativeCodexPassthrough({ return /(?:^|\/)responses(?:\/.*)?$/i.test(normalizedEndpoint); } +function buildClaudePassthroughToolNameMap(body: Record | null | undefined) { + if (!body || !Array.isArray(body.tools)) return null; + + const toolNameMap = new Map(); + for (const tool of body.tools) { + const toolRecord = tool as Record; + const toolData = + toolRecord?.type === "function" && + toolRecord.function && + typeof toolRecord.function === "object" + ? (toolRecord.function as Record) + : toolRecord; + const originalName = typeof toolData?.name === "string" ? toolData.name.trim() : ""; + if (!originalName) continue; + toolNameMap.set(`${CLAUDE_OAUTH_TOOL_PREFIX}${originalName}`, originalName); + } + + return toolNameMap.size > 0 ? toolNameMap : null; +} + +function restoreClaudePassthroughToolNames( + responseBody: Record, + toolNameMap: Map | null +) { + if (!toolNameMap || !Array.isArray(responseBody?.content)) return responseBody; + + let changed = false; + const content = responseBody.content.map((block: Record) => { + if (block?.type !== "tool_use" || typeof block?.name !== "string") return block; + const restoredName = toolNameMap.get(block.name) ?? block.name; + if (restoredName === block.name) return block; + changed = true; + return { + ...block, + name: restoredName, + }; + }); + + if (!changed) return responseBody; + return { + ...responseBody, + content, + }; +} + /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -219,11 +265,42 @@ export async function handleChatCore({ translatedBody = { ...body, _nativeCodexPassthrough: true }; log?.debug?.("FORMAT", "native codex passthrough enabled"); } else if (isClaudePassthrough) { - // Claude-to-Claude passthrough: forward body completely untouched. - // No translation, no field stripping, no thinking normalization. - // We are just a gateway -- do not interfere with the request in the slightest. - translatedBody = { ...body }; - log?.debug?.("FORMAT", "claude->claude passthrough -- forwarding untouched"); + // Claude OAuth expects the same Claude Code prompt + structural normalization + // as the OpenAI-compatible chat path. Round-trip through OpenAI to reuse the + // working Claude translator instead of forwarding raw Messages payloads. + const normalizeToolCallId = getModelNormalizeToolCallId( + provider || "", + model || "", + sourceFormat + ); + const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole( + provider || "", + model || "", + sourceFormat + ); + translatedBody = translateRequest( + FORMATS.CLAUDE, + FORMATS.OPENAI, + model, + { ...body }, + stream, + credentials, + provider, + reqLogger, + { normalizeToolCallId, preserveDeveloperRole } + ); + translatedBody = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + model, + { ...translatedBody, _disableToolPrefix: true }, + stream, + credentials, + provider, + reqLogger, + { normalizeToolCallId, preserveDeveloperRole } + ); + log?.debug?.("FORMAT", "claude->openai->claude normalized passthrough"); } else { translatedBody = { ...body }; @@ -378,7 +455,14 @@ export async function handleChatCore({ } // Extract toolNameMap for response translation (Claude OAuth) - const toolNameMap = translatedBody._toolNameMap; + const translatedToolNameMap = translatedBody._toolNameMap; + const nativeClaudeToolNameMap = isClaudePassthrough + ? buildClaudePassthroughToolNameMap(body) + : null; + const toolNameMap = + translatedToolNameMap instanceof Map && translatedToolNameMap.size > 0 + ? translatedToolNameMap + : nativeClaudeToolNameMap; delete translatedBody._toolNameMap; delete translatedBody._disableToolPrefix; @@ -770,6 +854,10 @@ export async function handleChatCore({ } } + if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) { + responseBody = restoreClaudePassthroughToolNames(responseBody, toolNameMap); + } + // Notify success - caller can clear error status if needed if (onRequestSuccess) { await onRequestSuccess(); @@ -961,6 +1049,7 @@ export async function handleChatCore({ transformStream = createPassthroughStreamWithLogger( provider, reqLogger, + toolNameMap, model, connectionId, body, diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index e58adc0bf7..2172d137c2 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -63,6 +63,22 @@ function getOpenAIIntermediateChunks(value: unknown): unknown[] { return Array.isArray(candidate) ? candidate : []; } +function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: unknown): boolean { + if (!(toolNameMap instanceof Map)) return false; + if (!parsed || typeof parsed !== "object") return false; + + const block = + parsed.content_block && typeof parsed.content_block === "object" + ? (parsed.content_block as JsonRecord) + : null; + if (!block || block.type !== "tool_use" || typeof block.name !== "string") return false; + + const restoredName = toolNameMap.get(block.name) ?? block.name; + if (restoredName === block.name) return false; + block.name = restoredName; + return true; +} + // Note: TextDecoder/TextEncoder are created per-stream inside createSSEStream() // to avoid shared state issues with concurrent streams (TextDecoder with {stream:true} // maintains internal buffering state between decode() calls). @@ -233,6 +249,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (extracted.cache_creation_input_tokens) usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens; } + const restoredToolName = restoreClaudePassthroughToolUseName(parsed, toolNameMap); // Track content length and accumulate from Claude format if (parsed.delta?.text) { totalContentLength += parsed.delta.text.length; @@ -242,6 +259,11 @@ export function createSSEStream(options: StreamOptions = {}) { totalContentLength += parsed.delta.thinking.length; passthroughAccumulatedContent += parsed.delta.thinking; } + if (restoredToolName) { + output = `data: ${JSON.stringify(parsed)} +`; + injectedUsage = true; + } } else { // Chat Completions: full sanitization pipeline parsed = sanitizeStreamingChunk(parsed); @@ -683,6 +705,7 @@ export function createSSETransformStreamWithLogger( export function createPassthroughStreamWithLogger( provider: string | null = null, reqLogger: StreamLogger | null = null, + toolNameMap: unknown = null, model: string | null = null, connectionId: string | null = null, body: unknown = null, @@ -693,6 +716,7 @@ export function createPassthroughStreamWithLogger( mode: STREAM_MODE.PASSTHROUGH, provider, reqLogger, + toolNameMap, model, connectionId, apiKeyInfo, diff --git a/tests/unit/claude-native-passthrough-tools.test.mjs b/tests/unit/claude-native-passthrough-tools.test.mjs new file mode 100644 index 0000000000..0bedf327d7 --- /dev/null +++ b/tests/unit/claude-native-passthrough-tools.test.mjs @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +test("Claude native passthrough normalization keeps original tool names", () => { + const body = { + model: "claude-sonnet-4-6", + max_tokens: 64, + messages: [ + { + role: "user", + content: [{ type: "text", text: "Write a todo item" }], + }, + ], + tools: [ + { + name: "TodoWrite", + description: "Create or update a todo list", + input_schema: { + type: "object", + properties: { + todos: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["todos"], + }, + }, + ], + }; + + const openaiBody = translateRequest( + FORMATS.CLAUDE, + FORMATS.OPENAI, + body.model, + structuredClone(body), + false, + null,