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({})); + }); +});