From b6bbfe063b32058166e5f2b921c8b251cb6030e0 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 28 Mar 2026 22:01:38 -0300 Subject: [PATCH] fix(sse): preserve cache_control in Claude passthrough mode (#708) --- CHANGELOG.md | 4 +++ open-sse/translator/helpers/claudeHelper.ts | 37 ++++++++++++--------- open-sse/translator/index.ts | 4 ++- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66eddf1562..102f8e218f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ | `tests/unit/t40-opencode-cli-tools-integration.test.mjs` | CLI tool integration tests | | `COVERAGE_PLAN.md` | Test coverage planning document | +### 🐛 Bug Fixes + +- **Claude Prompt Caching Passthrough** — Fixed cache_control markers being stripped in Claude passthrough mode (Claude → OmniRoute → Claude), which caused Claude Code users to deplete their Anthropic API quota 5-10x faster than direct connections. OmniRoute now preserves client's cache_control markers when sourceFormat and targetFormat are both Claude, ensuring prompt caching works correctly and dramatically reducing token consumption. + ## [3.1.8] - 2026-03-27 ### 🐛 Bug Fixes & Features diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index e03d21e584..0b1a614ecb 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -105,13 +105,14 @@ function markMessageCacheControl(msg, ttl) { } // Prepare request for Claude format endpoints -// - Cleanup cache_control +// - Cleanup cache_control (unless preserveCacheControl=true for passthrough) // - Filter empty messages // - Add thinking block for Anthropic endpoint (provider === "claude") // - Fix tool_use/tool_result ordering -export function prepareClaudeRequest(body, provider = null) { +export function prepareClaudeRequest(body, provider = null, preserveCacheControl = false) { // 1. System: remove all cache_control, add only to last block with ttl 1h - if (body.system && Array.isArray(body.system)) { + // In passthrough mode, preserve existing cache_control markers + if (body.system && Array.isArray(body.system) && !preserveCacheControl) { body.system = body.system.map((block, i) => { const { cache_control, ...rest } = block; if (i === body.system.length - 1) { @@ -127,11 +128,12 @@ export function prepareClaudeRequest(body, provider = null) { let filtered = []; // Pass 1: remove cache_control + filter empty messages + // In passthrough mode, preserve existing cache_control markers for (let i = 0; i < len; i++) { const msg = body.messages[i]; - // Remove cache_control from content blocks - if (Array.isArray(msg.content)) { + // Remove cache_control from content blocks (skip in passthrough mode) + if (Array.isArray(msg.content) && !preserveCacheControl) { for (const block of msg.content) { delete block.cache_control; } @@ -177,14 +179,17 @@ export function prepareClaudeRequest(body, provider = null) { // Claude Code-style prompt caching: // - cache the second-to-last user turn for conversation reuse // - cache the last assistant turn so the next user turn can reuse it - const userMessageIndexes = filtered.reduce((indexes, msg, index) => { - if (msg?.role === "user") indexes.push(index); - return indexes; - }, []); - const secondToLastUserIndex = - userMessageIndexes.length >= 2 ? userMessageIndexes[userMessageIndexes.length - 2] : -1; - if (secondToLastUserIndex >= 0) { - markMessageCacheControl(filtered[secondToLastUserIndex]); + // Skip in passthrough mode to preserve client's cache_control markers + if (!preserveCacheControl) { + const userMessageIndexes = filtered.reduce((indexes, msg, index) => { + if (msg?.role === "user") indexes.push(index); + return indexes; + }, []); + const secondToLastUserIndex = + userMessageIndexes.length >= 2 ? userMessageIndexes[userMessageIndexes.length - 2] : -1; + if (secondToLastUserIndex >= 0) { + markMessageCacheControl(filtered[secondToLastUserIndex]); + } } // Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic @@ -194,7 +199,8 @@ export function prepareClaudeRequest(body, provider = null) { if (msg.role === "assistant" && Array.isArray(ensureMessageContentArray(msg))) { // Add cache_control to last block of first (from end) assistant with content - if (!lastAssistantProcessed && markMessageCacheControl(msg)) { + // Skip in passthrough mode to preserve client's cache_control markers + if (!preserveCacheControl && !lastAssistantProcessed && markMessageCacheControl(msg)) { lastAssistantProcessed = true; } @@ -227,7 +233,8 @@ export function prepareClaudeRequest(body, provider = null) { // 3. Tools: remove all cache_control, add only to last non-deferred tool with ttl 1h // Tools with defer_loading=true cannot have cache_control (API rejects it) - if (body.tools && Array.isArray(body.tools)) { + // In passthrough mode, preserve existing cache_control markers + if (body.tools && Array.isArray(body.tools) && !preserveCacheControl) { body.tools = body.tools.map((tool) => { const { cache_control, ...rest } = tool; return rest; diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index d8d558abde..10f3675272 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -149,8 +149,10 @@ export function translateRequest( } // Final step: prepare request for Claude format endpoints + // In Claude passthrough mode (Claude → Claude), preserve cache_control markers if (targetFormat === FORMATS.CLAUDE) { - result = prepareClaudeRequest(result, provider); + const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE; + result = prepareClaudeRequest(result, provider, isClaudePassthrough); } // Normalize openai-responses input shape for providers that require list input.