Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions open-sse/config/anthropicHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,17 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [
* claude.ai backend on top of OmniRoute's own set. Kept to betas the backend
* actually accepts and that OmniRoute does not otherwise emit — so a blind
* passthrough cannot reintroduce the over-sending fingerprint/rejection bugs
* (#3415, #2454). Currently: deferred-tool negotiation (#3974).
* (#3415, #2454). Currently: deferred-tool negotiation (#3974) and the
* client's own `[1m]` long-context negotiation (context-1m). selectBetaFlags
* deliberately emits context-1m only for Opus (never FORCE it — long-context
* credit gate); forwarding it when the CLIENT negotiated it matches what real
* Claude Code sends for `/model <id>[1m]` and is required for >200K-context
* requests on models/accounts where the beta is enforced.
*/
export const FORWARDABLE_CLIENT_BETAS = Object.freeze(["tool-search-tool-2025-10-19"]);
export const FORWARDABLE_CLIENT_BETAS = Object.freeze([
"tool-search-tool-2025-10-19",
"context-1m-2025-08-07",
]);

/**
* Union an `anthropic-beta` comma-list with the allowlisted client-negotiated
Expand Down
10 changes: 10 additions & 0 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,16 @@ export async function handleChatCore({
"QUOTA_SHARE",
`[quotaShare] blocked apiKeyId=${apiKeyInfo.id} provider=${provider ?? "unknown"}: ${decision.reason}`
);
// Finalize the pending-request slot registered at handler entry — this
// return path never reaches the upstream, and without the decrement the
// pending detail lingers as an orphaned status-0 call-log row until the
// reaper sweeps it (mirrors the other pre-upstream error returns).
trackPendingRequest(
model,
provider,
connectionId || credentials?.connectionId || null,
false
);
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (decision.retryAfterSeconds) {
headers["Retry-After"] = String(decision.retryAfterSeconds);
Expand Down
17 changes: 15 additions & 2 deletions open-sse/handlers/responseTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,13 +539,26 @@ export function translateNonStreamingResponse(

const usage = toRecord(root.usage);
if (Object.keys(usage).length > 0) {
const promptTokens = toNumber(usage.input_tokens, 0);
// Mirror the streaming translator's usage contract (#1426/#2215):
// cache_read folds into prompt_tokens (it is billed prompt input);
// cache_creation stays out of prompt_tokens and is exposed via
// prompt_tokens_details, alongside cached_tokens (OpenAI field name).
const cachedTokens = toNumber(usage.cache_read_input_tokens, 0);
const cacheCreationTokens = toNumber(usage.cache_creation_input_tokens, 0);
const promptTokens = toNumber(usage.input_tokens, 0) + cachedTokens;
const completionTokens = toNumber(usage.output_tokens, 0);
result.usage = {
const usageOut: JsonRecord = {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
};
if (cachedTokens > 0 || cacheCreationTokens > 0) {
const details: JsonRecord = {};
if (cachedTokens > 0) details.cached_tokens = cachedTokens;
if (cacheCreationTokens > 0) details.cache_creation_tokens = cacheCreationTokens;
usageOut.prompt_tokens_details = details;
}
result.usage = usageOut;
}

intermediateOpenAI = result;
Expand Down
44 changes: 43 additions & 1 deletion open-sse/translator/helpers/claudeHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,32 @@ function markMessageCacheControl(msg: ClaudeMessage, ttl?: string): boolean {
return true;
}

/** True when the body carries at least one cache_control marker anywhere
* (system blocks, message content blocks, or tools). Used to decide whether
* preserve-mode has anything to preserve. */
function bodyHasAnyCacheControl(body: ClaudeRequestBody): boolean {
if (Array.isArray(body.system)) {
for (const block of body.system) {
if (block && typeof block === "object" && block.cache_control) return true;
}
}
if (Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (!Array.isArray(msg?.content)) continue;
for (const block of msg.content) {
if (block && typeof block === "object" && block.cache_control) return true;
}
}
}
if (Array.isArray(body.tools)) {
for (const tool of body.tools) {
if (tool && typeof tool === "object" && (tool as { cache_control?: unknown }).cache_control)
return true;
}
}
return false;
}

// Prepare request for Claude format endpoints
// - Cleanup cache_control (unless preserveCacheControl=true for passthrough)
// - Filter empty messages
Expand All @@ -248,7 +274,8 @@ export function prepareClaudeRequest(
body: ClaudeRequestBody,
provider: string | null = null,
preserveCacheControl = false,
model: string | null = null
model: string | null = null,
opts: { fallbackToHeuristicWhenNoMarkers?: boolean } = {}
): ClaudeRequestBody {
// 0. Strip Anthropic `output_config` for providers that reject it on their
// Claude-compatible endpoints (MiniMax). Must run before any downstream
Expand All @@ -257,6 +284,21 @@ export function prepareClaudeRequest(
delete body.output_config;
}

// preserveCacheControl means "the client manages its own cache markers".
// A body with NO cache_control anywhere has nothing to preserve — shipping
// it untouched would mean zero prompt-cache breakpoints (every token billed
// uncached on every turn). The translator path opts into falling back to the
// standard heuristic in that case; the claude-code-compatible relay path
// keeps its long-standing "never supplement missing markers" contract
// (cc-compatible-provider.test.ts) and does not pass the flag.
if (
preserveCacheControl &&
opts.fallbackToHeuristicWhenNoMarkers === true &&
!bodyHasAnyCacheControl(body)
) {
preserveCacheControl = false;
}

// 1. System: remove all cache_control, add only to last block with ttl 1h
// In passthrough mode, preserve existing cache_control markers
const supportsPromptCaching =
Expand Down
8 changes: 7 additions & 1 deletion open-sse/translator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,16 @@ export function translateRequest(
// Preserve cache_control when:
// 1. Claude passthrough mode (Claude → Claude), OR
// 2. Explicitly requested via options (for caching-aware clients like Claude Code)
// When preserve-mode has nothing to preserve (the client sent no cache_control
// anywhere), fall back to the standard heuristic so the request never ships
// with zero prompt-cache breakpoints. Translator-path only — the CC relay
// path keeps its "never supplement" contract.
if (targetFormat === FORMATS.CLAUDE) {
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE;
const preserveCache = isClaudePassthrough || options?.preserveCacheControl === true;
result = prepareClaudeRequest(result, provider, preserveCache, model);
result = prepareClaudeRequest(result, provider, preserveCache, model, {
fallbackToHeuristicWhenNoMarkers: true,
});
}

// Normalize openai-responses input shape for providers that require list input.
Expand Down
46 changes: 25 additions & 21 deletions open-sse/utils/cacheControlPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
* Determines when to preserve client-side prompt caching headers (cache_control)
* vs. applying OmniRoute's own caching strategy.
*
* Client-side caching (e.g., Claude Code) should be preserved when:
* Client-side caching (e.g., Claude Code) is preserved when:
* 1. Client is Claude Code or similar caching-aware client
* 2. Request will hit a deterministic target (single model or deterministic combo strategy)
* 3. Provider supports prompt caching (Anthropic, etc.)
* 2. Provider supports prompt caching (Anthropic, etc.)
*
* Combo membership / routing strategy no longer gates preservation: rewriting a
* caching-aware client's markers produced per-request breakpoint positions that
* thrashed the provider prompt cache, while preserving them is never worse
* (see shouldPreserveCacheControl).
*/

import type { RoutingStrategyValue } from "../../src/shared/constants/routingStrategies";
Expand Down Expand Up @@ -231,17 +235,28 @@ export function isDeterministicStrategy(
/**
* Determine if client-side cache_control headers should be preserved
*
* Auto mode preserves for every caching-aware client talking to a
* caching-capable provider — regardless of combo membership or routing
* strategy. The old gate (combos only preserved on "deterministic"
* strategies) forced OmniRoute to strip the client's markers and re-derive
* breakpoints per request; the re-derived positions are not stable
* turn-over-turn, which thrashed the provider prompt cache (observed in
* production as ~200k cache_write tokens per turn on quota-share combos).
* Preserving the client's markers is never worse than rewriting them: on a
* stable target the client's breakpoints advance deterministically, and on a
* target switch both approaches miss equally.
*
* @param userAgent - User-Agent header from the request
* @param isCombo - Whether this is a combo model
* @param comboStrategy - The combo's routing strategy (if applicable)
* @param isCombo - Whether this is a combo model (kept for callers/telemetry;
* no longer gates preservation)
* @param comboStrategy - The combo's routing strategy (kept for
* callers/telemetry; no longer gates preservation)
* @param targetProvider - The target provider for the request
* @param settings - Cache control settings from database (optional)
* @returns true if cache_control should be preserved, false if OmniRoute should manage it
*/
export function shouldPreserveCacheControl({
userAgent,
isCombo,
comboStrategy,
targetProvider,
targetFormat,
settings,
Expand All @@ -263,24 +278,13 @@ export function shouldPreserveCacheControl({
return false;
}

// Auto mode: use automatic detection (existing logic)
// Must be a caching-aware client
// Auto mode: must be a caching-aware client…
if (!isClaudeCodeClient(userAgent)) {
return false;
}

// Target provider must support caching
if (!providerSupportsCaching(targetProvider, targetFormat, connectionCacheOverride)) {
return false;
}

// Single model: always preserve (deterministic)
if (!isCombo) {
return true;
}

// Combo: only preserve if strategy is deterministic
return isDeterministicStrategy(comboStrategy);
// …talking to a provider that supports prompt caching.
return providerSupportsCaching(targetProvider, targetFormat, connectionCacheOverride);
}

/**
Expand Down
108 changes: 40 additions & 68 deletions tests/unit/cache-control-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,96 +155,68 @@ describe("Cache Control Policy", () => {
);
});

test("rejects combo with non-deterministic strategy (weighted)", () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
isCombo: true,
comboStrategy: "weighted",
targetProvider: "claude",
}),
false
);
});

test("rejects combo with non-deterministic strategy (round-robin)", () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
isCombo: true,
comboStrategy: "round-robin",
targetProvider: "claude",
}),
false
);
});

test("rejects combo with non-deterministic strategy (random)", () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
isCombo: true,
comboStrategy: "random",
targetProvider: "claude",
}),
false
);
});

test("rejects combo with fill-first strategy", () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
isCombo: true,
comboStrategy: "fill-first",
targetProvider: "claude",
}),
false
);
});

test("rejects combo with p2c strategy", () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
isCombo: true,
comboStrategy: "p2c",
targetProvider: "claude",
}),
false
);
});
// Client cache_control markers are preserved for EVERY combo strategy, not
// only the "deterministic" ones. Rewriting a caching-aware client's markers
// never beats preserving them: on a stable target the client's breakpoints
// advance deterministically turn-over-turn (the proxy heuristic recomputes
// positions and thrashes the provider prompt cache — observed in production
// as ~200k cache_write per turn on qtSd/ quota-share combos), and on a
// target switch both approaches miss equally.
for (const strategy of [
"weighted",
"round-robin",
"random",
"fill-first",
"p2c",
"least-used",
"strict-random",
"quota-share",
]) {
test(`preserves for combo with ${strategy} strategy (markers win over routing strategy)`, () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
isCombo: true,
comboStrategy: strategy as Parameters<
typeof shouldPreserveCacheControl
>[0]["comboStrategy"],
targetProvider: "claude",
}),
true
);
});
}

test("rejects combo with least-used strategy", () => {
test("preserves for combo with null strategy (strategy is irrelevant to preservation)", () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
isCombo: true,
comboStrategy: "least-used",
comboStrategy: null,
targetProvider: "claude",
}),
false
true
);
});

test("rejects combo with strict-random strategy", () => {
test("still rejects combo when the provider does not support caching", () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
isCombo: true,
comboStrategy: "strict-random",
targetProvider: "claude",
comboStrategy: "quota-share",
targetProvider: "gemini",
}),
false
);
});

test("rejects combo with null strategy", () => {
test("still rejects combo for non-caching-aware clients", () => {
assert.equal(
shouldPreserveCacheControl({
userAgent: "claude-code/0.1.0",
userAgent: "curl/7.68.0",
isCombo: true,
comboStrategy: null,
comboStrategy: "quota-share",
targetProvider: "claude",
}),
false
Expand Down
Loading
Loading