diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 6835115f7d..4bb5ad83e5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -422,6 +422,36 @@ export function shouldUseNativeCodexPassthrough({ return segments.includes("responses"); } +export function isClaudeCodeSemanticPassthroughRequest({ + provider, + sourceFormat, + targetFormat, + headers, + userAgent, +}: { + provider?: string | null; + sourceFormat?: string | null; + targetFormat?: string | null; + headers?: Record | Headers | null; + userAgent?: string | null; +}): boolean { + const isDirectClaudeCodeProvider = + provider === "claude" || isClaudeCodeCompatibleProvider(provider); + if (!isDirectClaudeCodeProvider) return false; + if (sourceFormat !== FORMATS.CLAUDE) return false; + if (targetFormat !== FORMATS.CLAUDE) return false; + + const headerUserAgent = getHeaderValueCaseInsensitive(headers, "user-agent"); + const ua = `${userAgent || ""} ${headerUserAgent || ""}`.toLowerCase(); + if (ua.includes("claude-code") || ua.includes("claude-cli")) return true; + + const appHeader = getHeaderValueCaseInsensitive(headers, "x-app"); + if (typeof appHeader === "string" && appHeader.trim().toLowerCase() === "cli") return true; + + const sessionId = getHeaderValueCaseInsensitive(headers, "x-claude-code-session-id"); + return typeof sessionId === "string" && sessionId.trim().length > 0; +} + function buildClaudePassthroughToolNameMap(body: Record | null | undefined) { if (!body || !Array.isArray(body.tools)) return null; @@ -2408,6 +2438,13 @@ export async function handleChatCore({ let translatedBody = body; const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE; const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider); + const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({ + provider, + sourceFormat, + targetFormat, + headers: clientRawRequest?.headers, + userAgent, + }); const upstreamStream = stream || isClaudeCodeCompatible; let ccSessionId: string | null = null; const stripTypes = getStripTypesForProviderModel(provider || "", model || ""); @@ -2568,7 +2605,9 @@ export async function handleChatCore({ // Claude Code-compatible providers expect Anthropic Messages-shaped payloads, // but we extract only role/text/max_tokens/effort from an OpenAI-like view first. - if (sourceFormat !== FORMATS.OPENAI) { + if (sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough) { + log?.debug?.("FORMAT", "claude-code semantic passthrough enabled for compatible bridge"); + } else if (sourceFormat !== FORMATS.OPENAI) { const normalizeToolCallId = getModelNormalizeToolCallId( provider || "", model || "", @@ -2603,6 +2642,7 @@ export async function handleChatCore({ cwd: process.cwd(), now: new Date(), preserveCacheControl, + preserveClaudeMessages: sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough, }); log?.debug?.("FORMAT", "claude-code-compatible bridge enabled"); } else if (isClaudePassthrough) { @@ -2613,7 +2653,11 @@ export async function handleChatCore({ // regardless of combo strategy or cache_control settings. translatedBody = { ...body }; translatedBody._disableToolPrefix = true; - normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true }); + if (!isClaudeCodeSemanticPassthrough) { + normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true }); + } else { + log?.debug?.("FORMAT", "claude-code semantic passthrough enabled"); + } log?.debug?.("FORMAT", `claude passthrough (preserveCache=${preserveCacheControl})`); diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 832927aecc..45e5b27945 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -79,6 +79,7 @@ type BuildRequestOptions = { now?: Date; sessionId?: string | null; preserveCacheControl?: boolean; + preserveClaudeMessages?: boolean; }; function supportsClaudeXHighEffort(model: string | null | undefined): boolean { @@ -228,10 +229,13 @@ export function buildClaudeCodeCompatibleRequest({ cwd = process.cwd(), sessionId, preserveCacheControl = false, + preserveClaudeMessages = false, }: BuildRequestOptions) { const normalized = normalizedBody || {}; const preparedClaudeBody = claudeBody - ? prepareClaudeCodeCompatibleBody(claudeBody, preserveCacheControl) + ? preserveClaudeMessages + ? prepareClaudeCodeCompatibleSemanticBody(claudeBody) + : prepareClaudeCodeCompatibleBody(claudeBody, preserveCacheControl) : null; const normalizedMessages = Array.isArray(normalized.messages) ? (normalized.messages as MessageLike[]) @@ -242,13 +246,18 @@ export function buildClaudeCodeCompatibleRequest({ : null; const effectiveClaudeBody = preparedClaudeBody || extractedClaudeBody; const messages = effectiveClaudeBody - ? buildClaudeCodeCompatibleMessagesFromClaude( - effectiveClaudeBody.messages as MessageLike[], - preserveCacheControl - ) + ? preserveClaudeMessages && preparedClaudeBody + ? cloneClaudeCodeCompatibleMessagesFromClaude( + effectiveClaudeBody.messages as MessageLike[], + preserveCacheControl + ) + : buildClaudeCodeCompatibleMessagesFromClaude( + effectiveClaudeBody.messages as MessageLike[], + preserveCacheControl + ) : buildClaudeCodeCompatibleMessages(normalizedMessages); const system = buildClaudeCodeCompatibleSystemBlocks({ - messages: normalizedMessages, + messages: preserveClaudeMessages ? [] : normalizedMessages, systemBlocks: effectiveClaudeBody?.system as Record[] | undefined, preserveCacheControl, }); @@ -616,6 +625,25 @@ function buildClaudeCodeCompatibleMessagesFromClaude( return merged; } +function cloneClaudeCodeCompatibleMessagesFromClaude( + messages: MessageLike[] | undefined, + preserveCacheControl: boolean +) { + const cloned = Array.isArray(messages) + ? messages.map((message) => cloneValue(message) as MessageLike) + : []; + + if (!preserveCacheControl) { + for (const message of cloned) { + if (Array.isArray(message.content)) { + stripCacheControlFromContentBlocks(message.content as Array>); + } + } + } + + return cloned; +} + function buildClaudeCodeCompatibleSystemBlocks({ messages, systemBlocks, @@ -794,6 +822,28 @@ function prepareClaudeCodeCompatibleBody( return readRecord(prepared); } +function prepareClaudeCodeCompatibleSemanticBody(claudeBody: Record) { + const prepared: Record = { + system: normalizeClaudeSystemInput(claudeBody.system), + messages: Array.isArray(claudeBody.messages) + ? (cloneValue(claudeBody.messages) as Array>) + : [], + tools: normalizeClaudeToolInput(claudeBody.tools), + thinking: (readRecord(cloneValue(claudeBody.thinking)) || null) as Record< + string, + unknown + > | null, + }; + + const metadata = readRecord(cloneValue(claudeBody.metadata)); + if (metadata) prepared.metadata = metadata; + + const outputConfig = readRecord(cloneValue(claudeBody.output_config)); + if (outputConfig) prepared.output_config = outputConfig; + + return prepared; +} + function extractClaudeBodyFromSource( sourceBody: Record, preserveCacheControl: boolean diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 57ba1b41ec..bce3355a64 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -37,6 +37,7 @@ const { getCallLogs, getCallLogById } = await import("../../src/lib/usage/callLo const { handleChatCore, shouldUseNativeCodexPassthrough, + isClaudeCodeSemanticPassthroughRequest, isTokenExpiringSoon, clearUpstreamProxyConfigCache, buildStreamingResponseHeaders, @@ -488,6 +489,46 @@ test("chatCore helper exports detect responses passthrough paths and token expir assert.equal(isTokenExpiringSoon(null), false); }); +test("chatCore helper detects Claude Code semantic passthrough only for direct Claude-Code routes", () => { + assert.equal( + isClaudeCodeSemanticPassthroughRequest({ + provider: "claude", + sourceFormat: FORMATS.CLAUDE, + targetFormat: FORMATS.CLAUDE, + userAgent: "claude-cli/2.1.137", + }), + true + ); + assert.equal( + isClaudeCodeSemanticPassthroughRequest({ + provider: "anthropic-compatible-cc-test", + sourceFormat: FORMATS.CLAUDE, + targetFormat: FORMATS.CLAUDE, + headers: new Headers({ "x-app": "cli" }), + userAgent: "unit-test", + }), + true + ); + assert.equal( + isClaudeCodeSemanticPassthroughRequest({ + provider: "anthropic-compatible-test", + sourceFormat: FORMATS.CLAUDE, + targetFormat: FORMATS.CLAUDE, + userAgent: "claude-cli/2.1.137", + }), + false + ); + assert.equal( + isClaudeCodeSemanticPassthroughRequest({ + provider: "claude", + sourceFormat: FORMATS.CLAUDE, + targetFormat: FORMATS.CLAUDE, + userAgent: "generic-client", + }), + false + ); +}); + test("chatCore applies payload rules after translating Responses input into Chat payloads", async () => { setPayloadRulesConfig({ default: [ @@ -565,6 +606,169 @@ test("chatCore builds Claude Code-compatible upstream requests for CC providers" assert.equal(call.body.messages[0].content[0].text, "Ping"); }); +test("chatCore preserves native Claude Code messages for native Claude OAuth passthrough", async () => { + const clientMessages = [ + { + role: "system", + content: [{ type: "text", text: "system-message-that-should-stay-in-messages" }], + }, + { + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Run pwd", cache_control: { type: "ephemeral" } }, + { type: "document", name: "README.md", content: "Do not flatten me" }, + { type: "future_block", payload: { keep: true } }, + ], + }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_pwd", name: "Bash", input: { command: "pwd" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_pwd", content: "ok" }], + }, + ]; + + const { call, result } = await invokeChatCore({ + provider: "claude", + model: "claude-sonnet-4-6", + endpoint: "/v1/messages", + credentials: { apiKey: "claude-key", providerSpecificData: {} }, + body: { + model: "omniroute/alias-that-should-resolve", + max_tokens: 64, + system: [{ type: "text", text: "top-level-system" }], + messages: clientMessages, + tools: [{ name: "Bash", input_schema: { type: "object", properties: {} } }], + }, + userAgent: "claude-cli/2.1.137", + requestHeaders: { "x-app": "cli", "x-claude-code-session-id": "session-123" }, + responseFormat: "claude", + }); + + assert.equal(result.success, true); + assert.equal(call.body.model, "claude-sonnet-4-6"); + assert.deepEqual(call.body.messages, clientMessages); + assert.equal( + call.body.system.some( + (block: { text?: string }) => block.text === "system-message-that-should-stay-in-messages" + ), + false + ); + assert.equal(call.body.messages[1].content[0].text, ""); + assert.equal(call.body.messages[1].content[2].type, "document"); + assert.equal(call.body.messages[1].content[3].type, "future_block"); + assert.equal(call.body.messages[3].content[0].type, "tool_result"); +}); + +test("chatCore keeps Claude normalization for non-Claude-Code Claude passthrough", async () => { + const { call, result } = await invokeChatCore({ + provider: "claude", + model: "claude-sonnet-4-6", + endpoint: "/v1/messages", + credentials: { apiKey: "claude-key", providerSpecificData: {} }, + body: { + model: "claude-sonnet-4-6", + max_tokens: 64, + messages: [ + { role: "system", content: "system role should move" }, + { + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "hello" }, + { type: "document", name: "README.md", content: "Read me" }, + { type: "future_block", payload: { drop: true } }, + ], + }, + ], + }, + userAgent: "generic-client/1.0", + responseFormat: "claude", + }); + + assert.equal(result.success, true); + assert.equal( + call.body.messages.some((message) => message.role === "system"), + false + ); + assert.equal(call.body.system.at(-1).text, "system role should move"); + assert.deepEqual(call.body.messages[0].content, [ + { type: "text", text: "hello" }, + { type: "text", text: "[README.md]\nRead me" }, + ]); +}); + +test("chatCore preserves native Claude Code messages before CC-compatible relay transforms", async () => { + const clientMessages = [ + { + role: "system", + content: [{ type: "text", text: "system-message-remains-in-source-history" }], + }, + { + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Inspect project", cache_control: { type: "ephemeral" } }, + { type: "document", name: "design.md", content: "Keep as document block" }, + { type: "future_block", payload: { keep: true } }, + ], + }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_read", name: "Read", input: { file_path: "a.ts" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_read", content: "file contents" }], + }, + ]; + + const { call, result } = await invokeChatCore({ + provider: "anthropic-compatible-cc-test", + model: "claude-sonnet-4-6", + endpoint: "/v1/messages", + credentials: { + apiKey: "sk-test", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1/messages?beta=true", + chatPath: "/v1/messages?beta=true", + }, + }, + body: { + model: "claude-sonnet-4-6", + max_tokens: 64, + system: [{ type: "text", text: "top-level-system" }], + messages: clientMessages, + tools: [{ name: "Read", input_schema: { type: "object", properties: {} } }], + }, + userAgent: "Claude-Code/2.1.137", + requestHeaders: { "x-app": "cli", "x-claude-code-session-id": "cc-session-123" }, + responseFormat: "claude", + }); + + assert.equal(result.success, true); + assert.match(call.url, /\/v1\/messages\?beta=true$/); + assert.equal(call.body.stream, true); + assert.deepEqual(call.body.messages, clientMessages); + assert.equal( + call.body.system[0].text, + "You are a Claude agent, built on Anthropic's Claude Agent SDK." + ); + assert.equal( + call.body.system.some( + (block: { text?: string }) => block.text === "system-message-remains-in-source-history" + ), + false + ); + assert.equal(call.body.messages[1].content[0].text, ""); + assert.equal(call.body.messages[1].content[2].type, "document"); + assert.equal(call.body.messages[1].content[3].type, "future_block"); + assert.equal(call.body.messages[3].content[0].type, "tool_result"); +}); + test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => { await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" }); invalidateCacheControlSettingsCache();