mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(sse): stop thrashing the provider prompt cache for caching-aware clients (#8705)
* fix(sse): stop thrashing the provider prompt cache for caching-aware clients - shouldPreserveCacheControl: preserve client cache_control markers for every combo strategy. The deterministic-strategy gate forced marker rewrites whose per-request breakpoint positions are not stable turn-over-turn, thrashing the upstream prompt cache (observed in production as ~200k cache_write tokens per turn on quota-share combos). Preserving is never worse: on a stable target the client's breakpoints advance deterministically; on a target switch both approaches miss equally. - prepareClaudeRequest: translator-path opt-in fallback — when preserve-mode has nothing to preserve (client sent no cache_control anywhere), apply the standard heuristic so requests never ship with zero cache breakpoints. The claude-code-compatible relay path keeps its no-supplement contract. - anthropic-beta: forward the client-negotiated context-1m-2025-08-07 through the allowlist merge so a /model <id>[1m] client keeps its long-context negotiation behind the proxy (never forced when the client did not send it). * fix(sse): surface cache tokens in non-streaming OpenAI usage + finalize pending on quota-share block - translateNonStreamingResponse (claude→openai): fold cache_read into prompt_tokens and expose prompt_tokens_details.cached_tokens / cache_creation_tokens, mirroring the streaming contract (#1426/#2215). Non-streaming OpenAI clients behind a cached Claude upstream previously saw prompt_tokens=<uncached remainder> (e.g. 23 for a ~9k request) with no cache visibility. - chatCore quota-share block: finalize the pending-request slot before returning the policy 429 — the path never reaches upstream and the orphaned pending lingered as a status-0 call-log row until the reaper swept it. Validated live on the staging box (repro: blocked key → orphan row; after: clean). --------- Co-authored-by: diegosouzapw <diegosouzapw24@gmail.com>
This commit is contained in:
committed by
GitHub
parent
f2d7e9a783
commit
7db3ba615b
@@ -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
|
||||
|
||||
@@ -2512,6 +2512,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -155,91 +155,39 @@ 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
|
||||
);
|
||||
});
|
||||
// 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 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
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects combo with least-used strategy", () => {
|
||||
assert.equal(
|
||||
shouldPreserveCacheControl({
|
||||
userAgent: "claude-code/0.1.0",
|
||||
isCombo: true,
|
||||
comboStrategy: "least-used",
|
||||
targetProvider: "claude",
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects combo with strict-random strategy", () => {
|
||||
assert.equal(
|
||||
shouldPreserveCacheControl({
|
||||
userAgent: "claude-code/0.1.0",
|
||||
isCombo: true,
|
||||
comboStrategy: "strict-random",
|
||||
targetProvider: "claude",
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects combo with null strategy", () => {
|
||||
test("preserves for combo with null strategy (strategy is irrelevant to preservation)", () => {
|
||||
assert.equal(
|
||||
shouldPreserveCacheControl({
|
||||
userAgent: "claude-code/0.1.0",
|
||||
@@ -247,6 +195,30 @@ describe("Cache Control Policy", () => {
|
||||
comboStrategy: null,
|
||||
targetProvider: "claude",
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("still rejects combo when the provider does not support caching", () => {
|
||||
assert.equal(
|
||||
shouldPreserveCacheControl({
|
||||
userAgent: "claude-code/0.1.0",
|
||||
isCombo: true,
|
||||
comboStrategy: "quota-share",
|
||||
targetProvider: "gemini",
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("still rejects combo for non-caching-aware clients", () => {
|
||||
assert.equal(
|
||||
shouldPreserveCacheControl({
|
||||
userAgent: "curl/7.68.0",
|
||||
isCombo: true,
|
||||
comboStrategy: "quota-share",
|
||||
targetProvider: "claude",
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
74
tests/unit/claude-context-1m-client-beta-forward.test.ts
Normal file
74
tests/unit/claude-context-1m-client-beta-forward.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Client-negotiated `context-1m-2025-08-07` must be forwarded upstream.
|
||||
*
|
||||
* When Claude Code runs with a `[1m]` model (e.g. `/model sonnet[1m]` or
|
||||
* `ANTHROPIC_DEFAULT_SONNET_MODEL='<id>[1m]'`), the client sizes its context
|
||||
* window at 1M AND negotiates `anthropic-beta: context-1m-2025-08-07` itself.
|
||||
* Behind OmniRoute that beta was silently dropped for non-Opus models:
|
||||
* selectBetaFlags only emits context-1m for `claude-opus*` (deliberate — never
|
||||
* FORCE it, see the long-context credit-gate note there), and the
|
||||
* FORWARDABLE_CLIENT_BETAS allowlist did not include it, so the client's own
|
||||
* negotiation was stripped. Real Claude Code sends the beta in this setup, so
|
||||
* forwarding what the client asked for is the fingerprint-faithful behavior —
|
||||
* and it is required for >200K-context requests on models/accounts where the
|
||||
* beta is enforced.
|
||||
*
|
||||
* Guard preserved: the beta is only APPENDED when the client sent it — a
|
||||
* client without `[1m]` keeps the exact same beta set as before.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { selectBetaFlags } = await import("../../open-sse/executors/claudeIdentity.ts");
|
||||
const { mergeClientAnthropicBeta, FORWARDABLE_CLIENT_BETAS } =
|
||||
await import("../../open-sse/config/anthropicHeaders.ts");
|
||||
|
||||
const CONTEXT_1M = "context-1m-2025-08-07";
|
||||
|
||||
function fullAgentBody(model: string) {
|
||||
return {
|
||||
model,
|
||||
system: "You are a coding agent.",
|
||||
tools: [{ name: "read_file", description: "x", input_schema: { type: "object" } }],
|
||||
};
|
||||
}
|
||||
|
||||
test("mergeClientAnthropicBeta forwards a client-negotiated context-1m beta", () => {
|
||||
const out = mergeClientAnthropicBeta(
|
||||
"claude-code-20250219,oauth-2025-04-20",
|
||||
`oauth-2025-04-20,${CONTEXT_1M}`
|
||||
);
|
||||
assert.ok(
|
||||
out.split(",").includes(CONTEXT_1M),
|
||||
"client [1m] negotiation must survive the allowlist merge"
|
||||
);
|
||||
assert.ok(FORWARDABLE_CLIENT_BETAS.includes(CONTEXT_1M));
|
||||
});
|
||||
|
||||
test("selectBetaFlags + merge keeps context-1m for a Sonnet [1m] client", () => {
|
||||
const clientBeta = `claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,${CONTEXT_1M}`;
|
||||
const out = mergeClientAnthropicBeta(
|
||||
selectBetaFlags(fullAgentBody("claude-sonnet-5"), null, clientBeta),
|
||||
clientBeta
|
||||
);
|
||||
assert.ok(
|
||||
out.split(",").includes(CONTEXT_1M),
|
||||
"Sonnet client that negotiated [1m] must reach upstream with context-1m"
|
||||
);
|
||||
});
|
||||
|
||||
test("context-1m is never forced when the client did not negotiate it", () => {
|
||||
const clientBeta = "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14";
|
||||
const out = mergeClientAnthropicBeta(
|
||||
selectBetaFlags(fullAgentBody("claude-sonnet-5"), null, clientBeta),
|
||||
clientBeta
|
||||
);
|
||||
assert.ok(
|
||||
!out.split(",").includes(CONTEXT_1M),
|
||||
"a client without [1m] keeps the exact same beta set as before"
|
||||
);
|
||||
});
|
||||
|
||||
test("tool-search forwarding keeps working (regression #3974)", () => {
|
||||
assert.ok(FORWARDABLE_CLIENT_BETAS.includes("tool-search-tool-2025-10-19"));
|
||||
});
|
||||
114
tests/unit/claude-prepare-request-preserve-fallback.test.ts
Normal file
114
tests/unit/claude-prepare-request-preserve-fallback.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* prepareClaudeRequest preserve-mode fallback.
|
||||
*
|
||||
* preserveCacheControl=true must mean "the client manages its own cache
|
||||
* markers" — but when the client body carries NO cache_control anywhere,
|
||||
* preserving "nothing" would ship a request with zero prompt-cache
|
||||
* breakpoints (every token billed uncached on every turn). In that case the
|
||||
* standard heuristic must still apply, exactly as if preserveCacheControl
|
||||
* were false. When the client DID place markers, they are preserved verbatim
|
||||
* and the heuristic must not add any extra ones.
|
||||
*/
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { prepareClaudeRequest } from "../../open-sse/translator/helpers/claudeHelper.ts";
|
||||
|
||||
type Block = Record<string, unknown>;
|
||||
|
||||
function baseBody(): Record<string, unknown> {
|
||||
return {
|
||||
model: "claude-sonnet-5",
|
||||
system: [
|
||||
{ type: "text", text: "You are a helpful assistant." },
|
||||
{ type: "text", text: "Project context goes here." },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "First question" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "First answer" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Second question" }] },
|
||||
],
|
||||
max_tokens: 128,
|
||||
};
|
||||
}
|
||||
|
||||
function collectCacheControls(body: Record<string, unknown>): Block[] {
|
||||
const found: Block[] = [];
|
||||
for (const block of (body.system as Block[]) ?? []) {
|
||||
if (block.cache_control) found.push(block);
|
||||
}
|
||||
for (const msg of (body.messages as Array<{ content?: Block[] }>) ?? []) {
|
||||
for (const block of msg.content ?? []) {
|
||||
if (block.cache_control) found.push(block);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("prepareClaudeRequest preserve-mode fallback", () => {
|
||||
test("falls back to the heuristic when the client sent no markers at all (translator opt-in)", () => {
|
||||
const body = baseBody();
|
||||
const result = prepareClaudeRequest(body, "claude", true, "claude-sonnet-5", {
|
||||
fallbackToHeuristicWhenNoMarkers: true,
|
||||
});
|
||||
|
||||
const system = result.system as Block[];
|
||||
const lastSystem = system[system.length - 1];
|
||||
assert.ok(
|
||||
lastSystem.cache_control,
|
||||
"last system block should carry the heuristic cache_control when the client sent none"
|
||||
);
|
||||
assert.ok(
|
||||
collectCacheControls(result as Record<string, unknown>).length > 0,
|
||||
"request must not go upstream with zero cache breakpoints"
|
||||
);
|
||||
});
|
||||
|
||||
test("without the opt-in flag, preserve-mode never supplements markers (CC relay contract)", () => {
|
||||
const body = baseBody();
|
||||
const result = prepareClaudeRequest(body, "claude", true, "claude-sonnet-5");
|
||||
assert.equal(
|
||||
collectCacheControls(result as Record<string, unknown>).length,
|
||||
0,
|
||||
"the claude-code-compatible relay path keeps its no-supplement contract"
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves client markers verbatim and adds none (system marker)", () => {
|
||||
const body = baseBody();
|
||||
(body.system as Block[])[0].cache_control = { type: "ephemeral" };
|
||||
const result = prepareClaudeRequest(body, "claude", true, "claude-sonnet-5");
|
||||
|
||||
const system = result.system as Block[];
|
||||
assert.deepEqual(
|
||||
system[0].cache_control,
|
||||
{ type: "ephemeral" },
|
||||
"client marker must survive untouched"
|
||||
);
|
||||
assert.equal(
|
||||
collectCacheControls(result as Record<string, unknown>).length,
|
||||
1,
|
||||
"no extra heuristic markers may be added when the client manages its own"
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves client markers verbatim and adds none (message marker)", () => {
|
||||
const body = baseBody();
|
||||
const messages = body.messages as Array<{ content: Block[] }>;
|
||||
messages[2].content[0].cache_control = { type: "ephemeral" };
|
||||
const result = prepareClaudeRequest(body, "claude", true, "claude-sonnet-5");
|
||||
|
||||
const controls = collectCacheControls(result as Record<string, unknown>);
|
||||
assert.equal(controls.length, 1, "only the client's own marker may remain");
|
||||
const resultMessages = result.messages as Array<{ content: Block[] }>;
|
||||
assert.deepEqual(resultMessages[resultMessages.length - 1].content[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
test("non-preserve mode keeps applying the heuristic (regression)", () => {
|
||||
const body = baseBody();
|
||||
const result = prepareClaudeRequest(body, "claude", false, "claude-sonnet-5");
|
||||
const system = result.system as Block[];
|
||||
assert.ok(system[system.length - 1].cache_control, "heuristic marker on last system block");
|
||||
});
|
||||
});
|
||||
68
tests/unit/nonstream-usage-cache-tokens.test.ts
Normal file
68
tests/unit/nonstream-usage-cache-tokens.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Non-streaming claude→openai usage must surface prompt-cache tokens.
|
||||
*
|
||||
* The streaming translator already folds cache_read into prompt_tokens and
|
||||
* exposes prompt_tokens_details (#1426/#2215). The non-streaming path built the
|
||||
* usage object from input_tokens/output_tokens only, so an OpenAI-format client
|
||||
* behind a cached Claude upstream saw prompt_tokens=<uncached remainder> (e.g.
|
||||
* 23 for a ~9k-token request) and no cache visibility at all — billing/telemetry
|
||||
* built on the OpenAI usage contract under-reported by orders of magnitude.
|
||||
*
|
||||
* Contract mirrored from the streaming path:
|
||||
* prompt_tokens = input_tokens + cache_read_input_tokens
|
||||
* prompt_tokens_details.cached_tokens = cache_read_input_tokens
|
||||
* prompt_tokens_details.cache_creation_tokens = cache_creation_input_tokens
|
||||
* (cache_creation stays OUT of prompt_tokens — #2215: providers price it
|
||||
* differently and folding it in broke min-token accounting.)
|
||||
*/
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
|
||||
function claudeResponse(usage: Record<string, number>) {
|
||||
return {
|
||||
id: "msg_test",
|
||||
model: "claude-sonnet-5",
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
describe("non-streaming claude→openai usage cache tokens", () => {
|
||||
test("folds cache_read into prompt_tokens and exposes details", () => {
|
||||
const out = translateNonStreamingResponse(
|
||||
claudeResponse({
|
||||
input_tokens: 2,
|
||||
output_tokens: 6,
|
||||
cache_read_input_tokens: 8941,
|
||||
cache_creation_input_tokens: 27,
|
||||
}),
|
||||
FORMATS.CLAUDE,
|
||||
FORMATS.OPENAI
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const usage = out.usage as Record<string, unknown>;
|
||||
assert.equal(usage.prompt_tokens, 8943, "prompt_tokens = input + cache_read");
|
||||
assert.equal(usage.completion_tokens, 6);
|
||||
assert.equal(usage.total_tokens, 8949);
|
||||
const details = usage.prompt_tokens_details as Record<string, unknown>;
|
||||
assert.ok(details, "prompt_tokens_details must be present when cache fields exist");
|
||||
assert.equal(details.cached_tokens, 8941);
|
||||
assert.equal(details.cache_creation_tokens, 27);
|
||||
});
|
||||
|
||||
test("no cache fields → plain usage without details (unchanged shape)", () => {
|
||||
const out = translateNonStreamingResponse(
|
||||
claudeResponse({ input_tokens: 10, output_tokens: 5 }),
|
||||
FORMATS.CLAUDE,
|
||||
FORMATS.OPENAI
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const usage = out.usage as Record<string, unknown>;
|
||||
assert.equal(usage.prompt_tokens, 10);
|
||||
assert.equal(usage.total_tokens, 15);
|
||||
assert.equal(usage.prompt_tokens_details, undefined);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user