From 10c622afa6fd3f5130472f25525b99d01e28965a Mon Sep 17 00:00:00 2001 From: Jefferson Alves Date: Thu, 13 Aug 2026 00:40:05 -0300 Subject: [PATCH] fix(providers): default missing cache_control.ttl to 1h on the native Claude OAuth path (#10221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../pending-cc-cache-control-ttl-default.md | 1 + open-sse/executors/base.ts | 2 + open-sse/services/claudeCodeConstraints.ts | 44 +++++++++++++ tests/unit/claude-code-parity.test.ts | 64 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 changelog.d/fixes/pending-cc-cache-control-ttl-default.md diff --git a/changelog.d/fixes/pending-cc-cache-control-ttl-default.md b/changelog.d/fixes/pending-cc-cache-control-ttl-default.md new file mode 100644 index 0000000000..0d89bee475 --- /dev/null +++ b/changelog.d/fixes/pending-cc-cache-control-ttl-default.md @@ -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 diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index d1486263cb..8fe668b622 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -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 diff --git a/open-sse/services/claudeCodeConstraints.ts b/open-sse/services/claudeCodeConstraints.ts index ddac177b32..42ed52f996 100644 --- a/open-sse/services/claudeCodeConstraints.ts +++ b/open-sse/services/claudeCodeConstraints.ts @@ -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): void { + const defaultMissingTtl = (block: Record | null | undefined) => { + const cc = block?.cache_control as Record | undefined; + if (cc && cc.type === "ephemeral" && cc.ttl === undefined) { + cc.ttl = "1h"; + } + }; + + const system = body.system as Array> | undefined; + if (Array.isArray(system)) { + for (const block of system) defaultMissingTtl(block); + } + + const tools = body.tools as Array> | undefined; + if (Array.isArray(tools)) { + for (const tool of tools) defaultMissingTtl(tool); + } + + const messages = body.messages as Array> | undefined; + if (Array.isArray(messages)) { + for (const message of messages) { + const content = message.content as Array> | undefined; + if (Array.isArray(content)) { + for (const block of content) defaultMissingTtl(block); + } + } + } +} diff --git a/tests/unit/claude-code-parity.test.ts b/tests/unit/claude-code-parity.test.ts index 480f76a177..0e58e9a3f3 100644 --- a/tests/unit/claude-code-parity.test.ts +++ b/tests/unit/claude-code-parity.test.ts @@ -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({})); + }); +});