From b5bcb15efde90dc6fec4376acfdf28042c926758 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 15 Oct 2026 18:53:08 +0800 Subject: [PATCH 1/6] fix(claude): keep the caller's 1h cache TTL on the translated lane The translated Messages lane drops the caller's cache_control breakpoints and the anthropic adapter places its own, with TTL from the global cacheRetention only. Claude Code on a subscription marks its breakpoints ttl:"1h"; the translated lane downgraded them to 5m, so any pause over 5 minutes re-wrote the whole prefix. When no operator cacheRetention is set and any caller breakpoint asks for ttl:"1h", run that request with cacheRetention "long". Precedence: operator config > caller hint > default. Breakpoint positions are unchanged. Co-Authored-By: Claude Code --- scripts/test-layout/layout.json | 2 +- src/claude/inbound.ts | 20 ++++ src/server/claude-messages.ts | 8 +- .../claude-translated-cache-ttl.test.ts | 94 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 5 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 tests/claude-integration/claude-translated-cache-ttl.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9925e111815..6dbdae304ce 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -411,7 +411,7 @@ "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", - "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", + "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", "claude-translated-cache-ttl.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 478be44037e..ea513586983 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -387,6 +387,26 @@ function canonicalJson(value: unknown): string { /** Provenance of the generated prompt_cache_key (never serialized into the wire body). */ export type ClaudeCacheKeySource = "metadata" | "system" | null; +/** + * The translated lane places its own prompt-cache breakpoints, so the caller's are dropped; + * their TTL is the one thing worth keeping. Any caller breakpoint asking for `ttl: "1h"` + * (Claude Code on a subscription marks them so) maps to `cacheRetention: "long"`; otherwise + * undefined, leaving the default. Read out of body: the Responses body has no field for it. + */ +export function callerCacheRetention(raw: unknown): "long" | undefined { + if (!isRec(raw)) return undefined; + const blocks: unknown[] = [ + ...(Array.isArray(raw.system) ? raw.system : []), + ...(Array.isArray(raw.tools) ? raw.tools : []), + ...(Array.isArray(raw.messages) + ? raw.messages.flatMap(m => isRec(m) && Array.isArray(m.content) ? m.content : []) + : []), + ]; + return blocks.some(b => isRec(b) && isRec(b.cache_control) && b.cache_control.ttl === "1h") + ? "long" + : undefined; +} + export interface ClaudeInboundTranslation { body: Rec; cacheKeySource: ClaudeCacheKeySource; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 7bf4e6fa456..58d7b4163fd 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -28,7 +28,7 @@ import { planMessagesAntigravityDirect, type MessagesDirectPlan, } from "./messages-antigravity-direct"; -import { AnthropicRequestError, DesktopModelMappingUnavailableError, extractOcxEffortDirective, extractOcxRouteDirective, nativeAnthropicProjection, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; +import { AnthropicRequestError, callerCacheRetention, DesktopModelMappingUnavailableError, extractOcxEffortDirective, extractOcxRouteDirective, nativeAnthropicProjection, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { nativeReasoningTag, type NativeReasoningOwner } from "../responses/reasoning-replay-cache"; import { isKnownDesktop3pModelId, resolveDesktop3pAlias } from "../claude/desktop-3p"; import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../claude/alias"; @@ -1409,7 +1409,11 @@ async function handleClaudeMessagesWithBudget( // BEFORE translation (the translated Anthropic stream has no response.completed // frame, so tapping it records a bogus 502 with no usage/cache detail). const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish; - const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, { + const upstream = await handleResponses(internalReq, { + ...buildClaudeReplayConfig(config), + // Operator config wins; otherwise the caller's own 1h breakpoints keep 1h here. + cacheRetention: config.cacheRetention ?? callerCacheRetention(anthropicBody), + }, logCtx, { // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner. codexAuthPolicy: config, ...(logIds?.admission ? { admission: logIds.admission } : {}), diff --git a/tests/claude-integration/claude-translated-cache-ttl.test.ts b/tests/claude-integration/claude-translated-cache-ttl.test.ts new file mode 100644 index 00000000000..12a807feaa3 --- /dev/null +++ b/tests/claude-integration/claude-translated-cache-ttl.test.ts @@ -0,0 +1,94 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// A combo route declines the native Messages lane, so the anthropic adapter places its own +// breakpoints. Their TTL must follow the caller's own `ttl: "1h"` unless the operator set one. +const MODEL = "claude-opus-5-5"; +let previousHome: string | undefined; +let home = ""; +let releaseSpend: (() => void) | undefined; + +beforeAll(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-cache-ttl-")); + process.env.OPENCODEX_HOME = home; + releaseSpend = acquireOwnedSpendHome(); +}); +afterAll(() => { + releaseSpend?.(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +async function wireTtls(callerTtl: "1h" | undefined, cacheRetention?: "short" | "long"): Promise { + const wires: string[] = []; + const fetch = (async (_input: unknown, init?: RequestInit) => { + wires.push(String(init?.body)); + const frames = [ + { type: "message_start", message: { id: "msg_x", type: "message", role: "assistant", model: MODEL, content: [], stop_reason: null, usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]; + return new Response(frames.map(f => `event: ${f.type}\ndata: ${JSON.stringify(f)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }); + }) as typeof globalThis.fetch; + const config = { + port: 0, defaultProvider: "anth", + ...(cacheRetention ? { cacheRetention } : {}), + claudeCode: { nativePassthrough: false }, + providers: { + anth: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key", apiKey: "sk-ant-fixture", models: [MODEL], fetch }, + gpt: { adapter: "openai-chat", baseUrl: "https://gpt.invalid/v1", authMode: "key", apiKey: "sk-fixture", models: ["g"], fetch }, + }, + combos: { waterfall: { strategy: "failover", targets: [{ provider: "anth", model: MODEL }, { provider: "gpt", model: "g" }] } }, + } as unknown as OcxConfig; + const cc = { type: "ephemeral", ...(callerTtl ? { ttl: callerTtl } : {}) }; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "combo/waterfall", max_tokens: 64, + system: [{ type: "text", text: "You are a careful assistant.", cache_control: cc }], + tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }], + messages: [ + { role: "user", content: "First." }, + { role: "assistant", content: "Done." }, + { role: "user", content: [{ type: "text", text: "Continue.", cache_control: cc }] }, + ], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(wires).toHaveLength(1); + const ttls: unknown[] = []; + JSON.parse(wires[0]!, (key, value) => { + if (key === "cache_control") ttls.push(value.ttl); + return value; + }); + expect(ttls.length).toBeGreaterThan(0); + return ttls; +} + +describe("translated Claude lane cache TTL", () => { + test("caller ttl 1h keeps every adapter breakpoint at 1h", async () => { + expect(new Set(await wireTtls("1h"))).toEqual(new Set(["1h"])); + }); + + test("caller without ttl keeps the 5m default", async () => { + expect(new Set(await wireTtls(undefined))).toEqual(new Set([undefined])); + }); + + test("explicit operator cacheRetention wins over the caller hint", async () => { + expect(new Set(await wireTtls("1h", "short"))).toEqual(new Set([undefined])); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f5047535934..ddd3b4540fe 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -334,7 +334,7 @@ "claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-config-home-isolation.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", - "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", + "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-native-reasoning-provenance.test.ts": "claude-integration", "claude-translated-cache-ttl.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-local-ca-files.test.ts": "claude-integration", From 2fd7a34f6a5c6a35de36096bf8ba9e4296dd8708 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 15 Oct 2026 18:58:47 +0800 Subject: [PATCH 2/6] docs(config): document the caller 1h TTL fallback for cacheRetention Co-Authored-By: Claude Code --- docs-site/src/content/docs/reference/configuration/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 289a9e5ae1d..5e18d035a6b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -100,7 +100,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `maxUpstreamBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a serialized native Responses **passthrough** body. `0` or omitted disables it — no limit is inferred for any destination. When set, a built body above the ceiling is refused locally before the send: streaming turns receive a terminal `response.failed` / `context_length_exceeded` so the client compacts instead of resending, and non-streaming turns receive a `413` naming the size, the number of embedded `input_image` items, and roughly how many megabytes of image data they represent. Checked at every build and rebuild point, including OAuth-refresh replay and alternate-account retry. Translated adapter paths are not covered. There is deliberately no default: the only measured ceiling here belongs to the WebSocket transport, which already falls back to HTTP for oversized turns, so a default would refuse requests that currently succeed. Set it when your gateway has a known request-size limit and you would rather see an actionable local error than an opaque upstream failure. | | `maxInboundBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a decompressed **inbound** data-plane request body — the mirror of `maxUpstreamBodyBytes` above. `0` or omitted keeps the built-in 256 MiB default. Raise it when a large-context session can no longer compact: Codex replays the whole history to the compaction model, so on the 922k-token opt-in window the compaction request is itself the one that crosses the limit, and the session is stuck at the only operation that would have shrunk it. Clamped to 1 MiB–512 MiB. The ceiling is not negotiable: the reader materializes the body several times over (wire bytes, decoded bytes, the decoded string, and the parsed object graph), so peak memory is a multiple of whatever is admitted, and an unbounded value would be a memory exhaustion lever. Applies to `/v1/responses`, `/v1/responses/compact`, `/v1/chat/completions`, and `/v1/messages`. The listener's accept size is fixed when the proxy binds, so a change takes effect on restart. A body above the limit is refused locally with HTTP 413 and `code: "inbound_body_too_large"`, which is deliberately distinct from the `context_length_exceeded` 413 a provider size refusal produces. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache. | -| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | +| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. When unset, a `/v1/messages` request on a translated route whose own `cache_control` breakpoints ask for `ttl: "1h"` is treated as `"long"`; an explicit value always wins. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. | Across these strategies, each account's effective threshold is its `codexAccountAutoSwitchThresholds` entry when present, otherwise global `autoSwitchThreshold`. A zero override disables only proactive usage switching; startup binding, hard-lock, cooldown, entitlement checks, and failure recovery remain enforced. From 20718ab1e9d0402f023ccb06cb9152159d506ba0 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 20:12:04 +0900 Subject: [PATCH 3/6] fix(claude): keep a mixed-TTL caller on the 5m default A caller that marks only its stable prefix 1h and keeps the moving tail at 5m was mapped to cacheRetention long, which also makes the adapter's per-turn breakpoints 1h and bills those writes at the 1h rate. Only map to long when every caller breakpoint asks for 1h. --- .../docs/reference/configuration/providers.md | 2 +- src/claude/inbound.ts | 11 +++++++---- .../claude-translated-cache-ttl.test.ts | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 5e18d035a6b..52eb062d154 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -100,7 +100,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `maxUpstreamBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a serialized native Responses **passthrough** body. `0` or omitted disables it — no limit is inferred for any destination. When set, a built body above the ceiling is refused locally before the send: streaming turns receive a terminal `response.failed` / `context_length_exceeded` so the client compacts instead of resending, and non-streaming turns receive a `413` naming the size, the number of embedded `input_image` items, and roughly how many megabytes of image data they represent. Checked at every build and rebuild point, including OAuth-refresh replay and alternate-account retry. Translated adapter paths are not covered. There is deliberately no default: the only measured ceiling here belongs to the WebSocket transport, which already falls back to HTTP for oversized turns, so a default would refuse requests that currently succeed. Set it when your gateway has a known request-size limit and you would rather see an actionable local error than an opaque upstream failure. | | `maxInboundBodyBytes?` | `number` | `0` | Opt-in ceiling, in bytes, on a decompressed **inbound** data-plane request body — the mirror of `maxUpstreamBodyBytes` above. `0` or omitted keeps the built-in 256 MiB default. Raise it when a large-context session can no longer compact: Codex replays the whole history to the compaction model, so on the 922k-token opt-in window the compaction request is itself the one that crosses the limit, and the session is stuck at the only operation that would have shrunk it. Clamped to 1 MiB–512 MiB. The ceiling is not negotiable: the reader materializes the body several times over (wire bytes, decoded bytes, the decoded string, and the parsed object graph), so peak memory is a multiple of whatever is admitted, and an unbounded value would be a memory exhaustion lever. Applies to `/v1/responses`, `/v1/responses/compact`, `/v1/chat/completions`, and `/v1/messages`. The listener's accept size is fixed when the proxy binds, so a change takes effect on restart. A body above the limit is refused locally with HTTP 413 and `code: "inbound_body_too_large"`, which is deliberately distinct from the `context_length_exceeded` 413 a provider size refusal produces. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache. | -| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. When unset, a `/v1/messages` request on a translated route whose own `cache_control` breakpoints ask for `ttl: "1h"` is treated as `"long"`; an explicit value always wins. | +| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. When unset, a `/v1/messages` request on a translated route whose own `cache_control` breakpoints all ask for `ttl: "1h"` is treated as `"long"`; a request that mixes 1h and 5m breakpoints keeps the 5-minute default, and an explicit value always wins. | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy. | Across these strategies, each account's effective threshold is its `codexAccountAutoSwitchThresholds` entry when present, otherwise global `autoSwitchThreshold`. A zero override disables only proactive usage switching; startup binding, hard-lock, cooldown, entitlement checks, and failure recovery remain enforced. diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index ea513586983..773ba0e1a86 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -389,9 +389,11 @@ export type ClaudeCacheKeySource = "metadata" | "system" | null; /** * The translated lane places its own prompt-cache breakpoints, so the caller's are dropped; - * their TTL is the one thing worth keeping. Any caller breakpoint asking for `ttl: "1h"` - * (Claude Code on a subscription marks them so) maps to `cacheRetention: "long"`; otherwise - * undefined, leaving the default. Read out of body: the Responses body has no field for it. + * their TTL is the one thing worth keeping. When every caller breakpoint asks for `ttl: "1h"` + * (Claude Code on a subscription marks them so) this maps to `cacheRetention: "long"`. A mixed + * caller (1h prefix, 5m tail) stays on the default: "long" also makes the moving per-turn + * breakpoint 1h, which bills writes the caller asked to keep at the 5m rate at the 1h rate. + * Read out of body: the Responses body has no field for it. */ export function callerCacheRetention(raw: unknown): "long" | undefined { if (!isRec(raw)) return undefined; @@ -402,7 +404,8 @@ export function callerCacheRetention(raw: unknown): "long" | undefined { ? raw.messages.flatMap(m => isRec(m) && Array.isArray(m.content) ? m.content : []) : []), ]; - return blocks.some(b => isRec(b) && isRec(b.cache_control) && b.cache_control.ttl === "1h") + const ttls = blocks.flatMap(b => isRec(b) && isRec(b.cache_control) ? [b.cache_control.ttl] : []); + return ttls.length > 0 && ttls.every(ttl => ttl === "1h") ? "long" : undefined; } diff --git a/tests/claude-integration/claude-translated-cache-ttl.test.ts b/tests/claude-integration/claude-translated-cache-ttl.test.ts index 12a807feaa3..01997ed316c 100644 --- a/tests/claude-integration/claude-translated-cache-ttl.test.ts +++ b/tests/claude-integration/claude-translated-cache-ttl.test.ts @@ -27,7 +27,11 @@ afterAll(() => { removeTreeWithRetry(home); }); -async function wireTtls(callerTtl: "1h" | undefined, cacheRetention?: "short" | "long"): Promise { +async function wireTtls( + callerTtl: "1h" | undefined, + cacheRetention?: "short" | "long", + messageTtl: "1h" | "5m" | "unset" = callerTtl ?? "unset", +): Promise { const wires: string[] = []; const fetch = (async (_input: unknown, init?: RequestInit) => { wires.push(String(init?.body)); @@ -53,6 +57,7 @@ async function wireTtls(callerTtl: "1h" | undefined, cacheRetention?: "short" | combos: { waterfall: { strategy: "failover", targets: [{ provider: "anth", model: MODEL }, { provider: "gpt", model: "g" }] } }, } as unknown as OcxConfig; const cc = { type: "ephemeral", ...(callerTtl ? { ttl: callerTtl } : {}) }; + const messageCc = { type: "ephemeral", ...(messageTtl === "unset" ? {} : { ttl: messageTtl }) }; const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { method: "POST", headers: { "content-type": "application/json" }, @@ -63,7 +68,7 @@ async function wireTtls(callerTtl: "1h" | undefined, cacheRetention?: "short" | messages: [ { role: "user", content: "First." }, { role: "assistant", content: "Done." }, - { role: "user", content: [{ type: "text", text: "Continue.", cache_control: cc }] }, + { role: "user", content: [{ type: "text", text: "Continue.", cache_control: messageCc }] }, ], }), }), config, { model: "", provider: "" }); @@ -91,4 +96,9 @@ describe("translated Claude lane cache TTL", () => { test("explicit operator cacheRetention wins over the caller hint", async () => { expect(new Set(await wireTtls("1h", "short"))).toEqual(new Set([undefined])); }); + + test("a caller mixing a 1h prefix with a 5m tail is not upgraded to 1h everywhere", async () => { + expect(new Set(await wireTtls("1h", undefined, "5m"))).toEqual(new Set([undefined])); + expect(new Set(await wireTtls("1h", undefined, "unset"))).toEqual(new Set([undefined])); + }); }); From d632dab72242def56a06837e611bccf995e46624 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 20:17:56 +0900 Subject: [PATCH 4/6] fix(claude): count top-level automatic caching in the caller TTL A 1h system prefix with a 5m top-level cache_control was read as all-1h, so every adapter breakpoint, including the moving tail, was written at the 1h rate. The request-level control is a caller breakpoint too. --- src/claude/inbound.ts | 3 +++ .../claude-integration/claude-translated-cache-ttl.test.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 773ba0e1a86..f4e2d30f674 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -398,6 +398,9 @@ export type ClaudeCacheKeySource = "metadata" | "system" | null; export function callerCacheRetention(raw: unknown): "long" | undefined { if (!isRec(raw)) return undefined; const blocks: unknown[] = [ + // Top-level automatic caching is a breakpoint too; omitting it would read a 1h prefix with a + // 5m automatic tail as all-1h. + raw, ...(Array.isArray(raw.system) ? raw.system : []), ...(Array.isArray(raw.tools) ? raw.tools : []), ...(Array.isArray(raw.messages) diff --git a/tests/claude-integration/claude-translated-cache-ttl.test.ts b/tests/claude-integration/claude-translated-cache-ttl.test.ts index 01997ed316c..8ba385b67de 100644 --- a/tests/claude-integration/claude-translated-cache-ttl.test.ts +++ b/tests/claude-integration/claude-translated-cache-ttl.test.ts @@ -31,6 +31,7 @@ async function wireTtls( callerTtl: "1h" | undefined, cacheRetention?: "short" | "long", messageTtl: "1h" | "5m" | "unset" = callerTtl ?? "unset", + topLevelTtl?: "1h" | "5m" | "unset", ): Promise { const wires: string[] = []; const fetch = (async (_input: unknown, init?: RequestInit) => { @@ -63,6 +64,7 @@ async function wireTtls( headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "combo/waterfall", max_tokens: 64, + ...(topLevelTtl ? { cache_control: { type: "ephemeral", ...(topLevelTtl === "unset" ? {} : { ttl: topLevelTtl }) } } : {}), system: [{ type: "text", text: "You are a careful assistant.", cache_control: cc }], tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }], messages: [ @@ -100,5 +102,9 @@ describe("translated Claude lane cache TTL", () => { test("a caller mixing a 1h prefix with a 5m tail is not upgraded to 1h everywhere", async () => { expect(new Set(await wireTtls("1h", undefined, "5m"))).toEqual(new Set([undefined])); expect(new Set(await wireTtls("1h", undefined, "unset"))).toEqual(new Set([undefined])); + // Top-level automatic caching counts as a caller breakpoint. + expect(new Set(await wireTtls("1h", undefined, "1h", "unset"))).toEqual(new Set([undefined])); + expect(new Set(await wireTtls("1h", undefined, "1h", "5m"))).toEqual(new Set([undefined])); + expect(new Set(await wireTtls("1h", undefined, "1h", "1h"))).toEqual(new Set(["1h"])); }); }); From af6846c6bcfc7d319619559d00e84f7e8e90ca06 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 20:19:48 +0900 Subject: [PATCH 5/6] fix(claude): count breakpoints nested in tool_result content A 5m breakpoint on a tool_result's own text block was invisible to the caller TTL scan, so a 1h prefix still upgraded the whole request to 1h. --- src/claude/inbound.ts | 5 ++++- .../claude-translated-cache-ttl.test.ts | 20 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index f4e2d30f674..119475f4d56 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -404,7 +404,10 @@ export function callerCacheRetention(raw: unknown): "long" | undefined { ...(Array.isArray(raw.system) ? raw.system : []), ...(Array.isArray(raw.tools) ? raw.tools : []), ...(Array.isArray(raw.messages) - ? raw.messages.flatMap(m => isRec(m) && Array.isArray(m.content) ? m.content : []) + ? raw.messages.flatMap(m => isRec(m) && Array.isArray(m.content) + // A tool_result's own content blocks can carry breakpoints as well. + ? m.content.flatMap(b => isRec(b) && Array.isArray(b.content) ? [b, ...b.content] : [b]) + : []) : []), ]; const ttls = blocks.flatMap(b => isRec(b) && isRec(b.cache_control) ? [b.cache_control.ttl] : []); diff --git a/tests/claude-integration/claude-translated-cache-ttl.test.ts b/tests/claude-integration/claude-translated-cache-ttl.test.ts index 8ba385b67de..53828a692a8 100644 --- a/tests/claude-integration/claude-translated-cache-ttl.test.ts +++ b/tests/claude-integration/claude-translated-cache-ttl.test.ts @@ -32,6 +32,7 @@ async function wireTtls( cacheRetention?: "short" | "long", messageTtl: "1h" | "5m" | "unset" = callerTtl ?? "unset", topLevelTtl?: "1h" | "5m" | "unset", + nestedToolResultTtl?: "1h" | "5m", ): Promise { const wires: string[] = []; const fetch = (async (_input: unknown, init?: RequestInit) => { @@ -69,8 +70,20 @@ async function wireTtls( tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }], messages: [ { role: "user", content: "First." }, - { role: "assistant", content: "Done." }, - { role: "user", content: [{ type: "text", text: "Continue.", cache_control: messageCc }] }, + ...(nestedToolResultTtl + ? [ + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "Read", input: {} }] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "toolu_1", content: [ + { type: "text", text: "file body", cache_control: { type: "ephemeral", ttl: nestedToolResultTtl } }, + ] }, + { type: "text", text: "Continue.", cache_control: messageCc }, + ] }, + ] + : [ + { role: "assistant", content: "Done." }, + { role: "user", content: [{ type: "text", text: "Continue.", cache_control: messageCc }] }, + ]), ], }), }), config, { model: "", provider: "" }); @@ -106,5 +119,8 @@ describe("translated Claude lane cache TTL", () => { expect(new Set(await wireTtls("1h", undefined, "1h", "unset"))).toEqual(new Set([undefined])); expect(new Set(await wireTtls("1h", undefined, "1h", "5m"))).toEqual(new Set([undefined])); expect(new Set(await wireTtls("1h", undefined, "1h", "1h"))).toEqual(new Set(["1h"])); + // Breakpoints nested in a tool_result's content count too. + expect(new Set(await wireTtls("1h", undefined, "1h", undefined, "5m"))).toEqual(new Set([undefined])); + expect(new Set(await wireTtls("1h", undefined, "1h", undefined, "1h"))).toEqual(new Set(["1h"])); }); }); From f0086f37d5ce7a35a64ed5aabb348195df545606 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 20:21:30 +0900 Subject: [PATCH 6/6] fix(claude): walk every nested block container for caller breakpoints search_result and document sources nest content blocks below tool_result, so a one-level scan still read a 5m tail as all-1h. Walk block containers generically and skip tool inputs and schemas, which are caller data. --- src/claude/inbound.ts | 32 +++++++++++-------- .../claude-translated-cache-ttl.test.ts | 26 +++++++++++++++ 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 119475f4d56..84335049477 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -397,20 +397,24 @@ export type ClaudeCacheKeySource = "metadata" | "system" | null; */ export function callerCacheRetention(raw: unknown): "long" | undefined { if (!isRec(raw)) return undefined; - const blocks: unknown[] = [ - // Top-level automatic caching is a breakpoint too; omitting it would read a 1h prefix with a - // 5m automatic tail as all-1h. - raw, - ...(Array.isArray(raw.system) ? raw.system : []), - ...(Array.isArray(raw.tools) ? raw.tools : []), - ...(Array.isArray(raw.messages) - ? raw.messages.flatMap(m => isRec(m) && Array.isArray(m.content) - // A tool_result's own content blocks can carry breakpoints as well. - ? m.content.flatMap(b => isRec(b) && Array.isArray(b.content) ? [b, ...b.content] : [b]) - : []) - : []), - ]; - const ttls = blocks.flatMap(b => isRec(b) && isRec(b.cache_control) ? [b.cache_control.ttl] : []); + // Top-level automatic caching is a breakpoint too, and content blocks nest (tool_result, + // search_result, document sources), so every block container is walked. Tool inputs and + // schemas are caller data, not blocks: a `cache_control` key there is not a breakpoint. + const ttls: unknown[] = []; + const visit = (node: unknown, depth: number): void => { + if (depth > 16) return; + if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return; } + if (!isRec(node)) return; + if (isRec(node.cache_control)) ttls.push(node.cache_control.ttl); + for (const [key, value] of Object.entries(node)) { + if (key !== "cache_control" && key !== "input" && key !== "input_schema" + && (Array.isArray(value) || isRec(value))) visit(value, depth + 1); + } + }; + if (isRec(raw.cache_control)) ttls.push(raw.cache_control.ttl); + visit(raw.system, 0); + visit(raw.tools, 0); + visit(raw.messages, 0); return ttls.length > 0 && ttls.every(ttl => ttl === "1h") ? "long" : undefined; diff --git a/tests/claude-integration/claude-translated-cache-ttl.test.ts b/tests/claude-integration/claude-translated-cache-ttl.test.ts index 53828a692a8..8976f14eb4d 100644 --- a/tests/claude-integration/claude-translated-cache-ttl.test.ts +++ b/tests/claude-integration/claude-translated-cache-ttl.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { callerCacheRetention } from "../../src/claude/inbound"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -124,3 +125,28 @@ describe("translated Claude lane cache TTL", () => { expect(new Set(await wireTtls("1h", undefined, "1h", undefined, "1h"))).toEqual(new Set(["1h"])); }); }); + +describe("callerCacheRetention walks every block container", () => { + const h1 = { type: "ephemeral", ttl: "1h" }; + const m5 = { type: "ephemeral", ttl: "5m" }; + const body = (nested: unknown) => ({ + system: [{ type: "text", text: "s", cache_control: h1 }], + messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t", content: [nested] }] }], + }); + + test("a 5m breakpoint deep inside search_result or document content keeps the default", () => { + expect(callerCacheRetention(body({ type: "search_result", source: "s", title: "t", + content: [{ type: "text", text: "x", cache_control: m5 }] }))).toBeUndefined(); + expect(callerCacheRetention(body({ type: "document", + source: { type: "content", content: [{ type: "text", text: "x", cache_control: m5 }] } }))).toBeUndefined(); + expect(callerCacheRetention(body({ type: "search_result", source: "s", title: "t", + content: [{ type: "text", text: "x", cache_control: h1 }] }))).toBe("long"); + }); + + test("a cache_control key inside tool input or schema is caller data, not a breakpoint", () => { + expect(callerCacheRetention({ + tools: [{ name: "T", input_schema: { type: "object", properties: { cache_control: h1 } } }], + messages: [{ role: "assistant", content: [{ type: "tool_use", id: "t", name: "T", input: { cache_control: h1 } }] }], + })).toBeUndefined(); + }); +});