Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,39 @@ 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. 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;
// 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;
}

export interface ClaudeInboundTranslation {
body: Rec;
cacheKeySource: ClaudeCacheKeySource;
Expand Down
8 changes: 6 additions & 2 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } : {}),
Expand Down
152 changes: 152 additions & 0 deletions tests/claude-integration/claude-translated-cache-ttl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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 { callerCacheRetention } from "../../src/claude/inbound";
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",
messageTtl: "1h" | "5m" | "unset" = callerTtl ?? "unset",
topLevelTtl?: "1h" | "5m" | "unset",
nestedToolResultTtl?: "1h" | "5m",
): Promise<unknown[]> {
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 messageCc = { type: "ephemeral", ...(messageTtl === "unset" ? {} : { ttl: messageTtl }) };
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,
...(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: [
{ role: "user", content: "First." },
...(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: "" });
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]));
});

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"]));
// 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"]));
});
});

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();
});
});
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading