diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 7a58219aba..47a8d37836 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -32,6 +32,7 @@ import { getModelUpstreamExtraHeaders, } from "@/lib/localDb"; import { getExecutor } from "../executors/index.ts"; +import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts"; import { parseCodexQuotaHeaders, getCodexResetTime, @@ -84,6 +85,51 @@ export function shouldUseNativeCodexPassthrough({ return segments.includes("responses"); } +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 @@ -415,6 +461,7 @@ export async function handleChatCore({ FORMATS.OPENAI, FORMATS.CLAUDE, model, + { ...translatedBody, _disableToolPrefix: true }, translatedBody, stream, credentials, @@ -566,7 +613,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; @@ -1014,6 +1068,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(); @@ -1235,6 +1293,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 e3e3898ee9..54e03f352a 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -72,6 +72,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). @@ -248,6 +264,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (eu.cache_creation_input_tokens) u.cache_creation_input_tokens = eu.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; @@ -257,6 +274,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); @@ -771,6 +793,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, @@ -781,6 +804,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,