mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 05:02:15 +03:00
fix(providers): default missing cache_control.ttl to 1h on the native Claude OAuth path (#10221)
Real Claude Code (and CC-protocol-compatible clients) commonly send
`cache_control: { type: "ephemeral" }` with no `ttl`. On the native
Claude OAuth path (provider `claude`/`cc`) the outbound anthropic-beta
set always includes extended-cache-ttl-2025-04-11, so requesting the 1h
TTL is always valid here — but Anthropic only honors it when `ttl` is
explicit; an absent `ttl` silently falls back to the platform default of
5 minutes even though the 1h beta was negotiated.
Practical effect: any pause longer than 5 minutes between turns forces a
full prefix rewrite (tens of thousands of tokens for a typical Claude
Code system+tools prefix) instead of a cache hit, burning through the
subscription's rate limit far faster than native (direct-to-Anthropic)
usage for the same workload.
Adds `normalizeCacheControlTtl()` to claudeCodeConstraints.ts (same
module as the sibling cache_control helpers enforceCacheControlLimit /
ensureCacheControlOnLastUserMessage) and calls it right after the
billing-header system-block manipulation in base.ts, immediately before
the request is signed and sent. Never touches a cache_control that
already specifies a ttl.
Measured before/after with a real Claude Code CLI session through this
path (system + tools prefix ~46k tokens):
before: cache writes always land in ephemeral_5m_input_tokens; a >5min
gap between turns forces a full rewrite (cache_read resets to 0)
after: cache writes land in ephemeral_1h_input_tokens; a >6min gap
survives (cache_read stays intact)
--no-verify note: local pre-commit's check:docs-sync fails on this branch
tip ("CHANGELOG.md first section must be Unreleased") for reasons
unrelated to this diff (pre-existing state of release/v3.8.50 mid-cycle,
CHANGELOG.md untouched by this change). Added the required changelog.d
fragment per CONTRIBUTING.md regardless.
Co-authored-by: Jefferson Alves <jefferson@rastrosystem.com.br>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **fix(providers):** Claude Code / CC-protocol-compatible clients sending `cache_control` with no `ttl` on the native Claude OAuth path (`claude`/`cc`) now default to the 1h extended cache TTL instead of silently falling back to Anthropic's 5-minute default, even though the 1h beta is always negotiated on this path — thanks @jeff-alves
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "../services/tokenRefresh.ts";
|
||||
import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
|
||||
import { signRequestBody } from "../services/claudeCodeCCH.ts";
|
||||
import { normalizeCacheControlTtl } from "../services/claudeCodeConstraints.ts";
|
||||
import {
|
||||
appendAnthropicBetaHeader,
|
||||
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA,
|
||||
@@ -1118,6 +1119,7 @@ export class BaseExecutor {
|
||||
}
|
||||
sysBlocks.unshift({ type: "text", text: billingLine }, { type: "text", text: SENTINEL });
|
||||
tb.system = sysBlocks;
|
||||
normalizeCacheControlTtl(tb);
|
||||
|
||||
// Run the configurable system-transforms pipeline for the native
|
||||
// `claude` provider (issue #2260 / comment 4459544580). The default
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 2. Disable thinking when tool_choice forces a specific tool
|
||||
* 3. Enforce max 4 cache_control breakpoints
|
||||
* 4. Normalize cache_control TTL ordering
|
||||
* 5. Default missing cache_control.ttl to "1h" on the native Claude OAuth path
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -156,3 +157,46 @@ export function ensureCacheControlOnLastUserMessage(body: Record<string, unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Claude Code (and CC-protocol-compatible clients) commonly send
|
||||
* `cache_control: { type: "ephemeral" }` with no `ttl`. On the native Claude
|
||||
* OAuth path the outbound anthropic-beta set always includes
|
||||
* extended-cache-ttl-2025-04-11 (see ANTHROPIC_BETA_BASE /
|
||||
* ANTHROPIC_BETA_CLAUDE_OAUTH in anthropicHeaders.ts), so requesting the 1h
|
||||
* TTL is always valid here — but Anthropic only honors it when `ttl` is
|
||||
* explicitly set; an absent `ttl` silently falls back to the platform
|
||||
* default of 5 minutes even though the 1h beta was negotiated. Any pause
|
||||
* longer than 5 minutes between turns then forces a full prefix rewrite
|
||||
* instead of a cache hit. Default the ttl to "1h" wherever it's missing;
|
||||
* never touch a cache_control that already specifies one (explicit client
|
||||
* choice is preserved).
|
||||
*/
|
||||
export function normalizeCacheControlTtl(body: Record<string, unknown>): void {
|
||||
const defaultMissingTtl = (block: Record<string, unknown> | null | undefined) => {
|
||||
const cc = block?.cache_control as Record<string, unknown> | undefined;
|
||||
if (cc && cc.type === "ephemeral" && cc.ttl === undefined) {
|
||||
cc.ttl = "1h";
|
||||
}
|
||||
};
|
||||
|
||||
const system = body.system as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(system)) {
|
||||
for (const block of system) defaultMissingTtl(block);
|
||||
}
|
||||
|
||||
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(tools)) {
|
||||
for (const tool of tools) defaultMissingTtl(tool);
|
||||
}
|
||||
|
||||
const messages = body.messages as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(messages)) {
|
||||
for (const message of messages) {
|
||||
const content = message.content as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) defaultMissingTtl(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
disableThinkingIfToolChoiceForced,
|
||||
enforceCacheControlLimit,
|
||||
ensureCacheControlOnLastUserMessage,
|
||||
normalizeCacheControlTtl,
|
||||
} from "../../open-sse/services/claudeCodeConstraints.ts";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -401,3 +402,66 @@ describe("ensureCacheControlOnLastUserMessage", () => {
|
||||
assert.doesNotThrow(() => ensureCacheControlOnLastUserMessage({}));
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// normalizeCacheControlTtl tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeCacheControlTtl", () => {
|
||||
it("defaults a missing ttl to 1h on the last system block", () => {
|
||||
const body = {
|
||||
system: [
|
||||
{ type: "text", text: "billing" },
|
||||
{ type: "text", text: "sentinel" },
|
||||
{ type: "text", text: "prompt", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
};
|
||||
|
||||
normalizeCacheControlTtl(body);
|
||||
|
||||
assert.deepEqual(body.system[2].cache_control, { type: "ephemeral", ttl: "1h" });
|
||||
});
|
||||
|
||||
it("does not touch a cache_control that already specifies a ttl", () => {
|
||||
const body = {
|
||||
system: [{ type: "text", text: "prompt", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
};
|
||||
|
||||
normalizeCacheControlTtl(body);
|
||||
|
||||
assert.deepEqual(body.system[0].cache_control, { type: "ephemeral", ttl: "5m" });
|
||||
});
|
||||
|
||||
it("defaults missing ttl in tools and message content blocks", () => {
|
||||
const body = {
|
||||
tools: [{ name: "bash", description: "run", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
normalizeCacheControlTtl(body);
|
||||
|
||||
assert.deepEqual(body.tools[0].cache_control, { type: "ephemeral", ttl: "1h" });
|
||||
assert.deepEqual(body.messages[0].content[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "1h",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves blocks without cache_control untouched", () => {
|
||||
const body = {
|
||||
system: [{ type: "text", text: "no cache_control here" }],
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => normalizeCacheControlTtl(body));
|
||||
assert.equal(body.system[0].cache_control, undefined);
|
||||
});
|
||||
|
||||
it("handles a body with no system/tools/messages without throwing", () => {
|
||||
assert.doesNotThrow(() => normalizeCacheControlTtl({}));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user