Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5af8d85
fix(anthropic): preserve cache prefixes across running turns and imag…
S0RYUASUKA Oct 18, 2026
14c961c
fix(anthropic): settle optional observer and unsubscribe failures
S0RYUASUKA Oct 18, 2026
7e2f8cf
test(anthropic): align cache recovery fixtures with own-thread and st…
S0RYUASUKA Oct 18, 2026
62a53c2
test(anthropic): verify image recovery independently of renewal
S0RYUASUKA Oct 18, 2026
72fc338
fix(anthropic): guard renewal spend and continuation recovery
S0RYUASUKA Oct 18, 2026
7e09e8b
test(anthropic): pair continuation guards with renewal off and on
S0RYUASUKA Oct 18, 2026
ecb6af1
Merge dev and preserve cache lifecycle across updated send budgets
S0RYUASUKA Oct 19, 2026
9217475
merge(dev): retain transport import boundary for cache observation
S0RYUASUKA Oct 19, 2026
b0c3404
merge(dev): retain cache lifecycle and new adapter test registrations
S0RYUASUKA Oct 19, 2026
22980f7
merge(dev): retain lifecycle test registrations under layout size limit
S0RYUASUKA Oct 19, 2026
444f639
merge(dev): retain lifecycle ownership alongside provider recovery
S0RYUASUKA Oct 19, 2026
3306ca4
merge(dev): reconcile lifecycle CI and preserve native Messages policy
S0RYUASUKA Oct 22, 2026
8596ce3
fix(responses): retain legacy parent replay without sharing child scopes
S0RYUASUKA Oct 22, 2026
be3a883
fix(anthropic): withhold parent-only rejection memos
S0RYUASUKA Oct 22, 2026
259c87d
fix: narrow cache lifecycle scope and compact rejected image history
S0RYUASUKA Oct 22, 2026
e1c3c00
Merge commit '1025acd4b1719df2694f0449ed37bb23dac186aa' into fix/anth…
S0RYUASUKA Oct 22, 2026
6db0e67
Merge dev and preserve both test-layout registrations
S0RYUASUKA Oct 22, 2026
db134ee
Merge dev and retain both test-layout shards
S0RYUASUKA Oct 22, 2026
2163d2a
merge: reconcile cache lifecycle with requested dev baseline
S0RYUASUKA Oct 24, 2026
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
29 changes: 29 additions & 0 deletions docs-site/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,35 @@ uses the fresh-install default: one `openai` forward provider.

## Precedence and defaults

### Anthropic running-turn cache renewal

`anthropicCacheRenewal` is an optional top-level boolean, off by default. Set it to `true`
in `config.json` to renew compatible five-minute Anthropic caches while a verified Codex
main or child turn is running. Existing `cacheRetention` choices are preserved; one-hour
and disabled caches are not renewed. Natural cache usage resets the timer. At four minutes
and thirty seconds without a newer confirmation, OpenCodex sends the same cached prefix
with zero output tokens. Renewal stops when the turn ends or its running state is unknown.

Renewal is billable at the provider's cache-read rates. Failed or cancelled sends can still
incur charges; renewal cannot guarantee a hit during outages or after expiry. Reliable local
IPC is required. Renewal currently supports Windows only: the default IPC endpoint and native
rollout/writer-lock fallback do not resolve macOS or Linux runtime ownership. A configured
`spend.root.maxTokens`, `spend.identity.maxTokens` or `spend.pool.maxTokens` disables renewal;
detached renewal does not yet participate in workflow spend reservations. Empty spend scopes
and retention-only accounting do not disable it. Natural requests retain normal spend admission.
This setting has no dedicated dashboard control and does not change native Codex.

Anthropic image history keeps stable encodings. The aggregate image allowance is **26 MiB**.
When the local image or complete request limit is reached, the Responses bridge summarizes old history and continues with the newest
image and its paired tool results. An individually oversized latest input still needs to be
reduced. Requests without a verified conversation and serving owner fail explicitly on overflow
rather than silently deleting old images. Summary calls are also billed normally; no tools execute
during summarization. An EOF without a normal completion reason cannot publish a summary checkpoint.
If an upstream returns HTTP 413 for an image-bearing history with compactable old images,
the same request can summarize that old prefix and retry once. Another refusal stays terminal.
Image-free requests do not enter this recovery. Native Messages retains its original processing
policy with the explicitly increased 26 MiB allowance.

### Provider and model aliases

Aliases are optional short request names. They never change the native model id sent upstream, and omitting every alias field preserves existing routing exactly.
Expand Down
6 changes: 6 additions & 0 deletions scripts/test-layout/layout-cache-lifecycle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"anthropic-cache-renewal.test.ts": "adapters/anthropic",
"responses-anthropic-cache-prefix.test.ts": "responses",
"responses-anthropic-cache-lifecycle-entry.test.ts": "responses",
"codex-cache-runtime-record.test.ts": "codex-integration"
}
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": 1,
"root": "tests",
"explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json"],
"explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-cache-lifecycle.json"],
"explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows",
"service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai",
"owner-registry-isolation.test.ts": "config",
Expand Down
37 changes: 25 additions & 12 deletions src/adapters/anthropic-image-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@
*
* Codex threads accumulate screenshots in history, so long sessions cross 20 images
* easily and any single retina capture (>2000px wide) kills every later turn. The
* PRIMARY layer is now anthropic-image-normalize.ts (Bun.Image resize/re-encode with an
* age-tier pyramid — devlog/260714_image_normalization_pipeline/020), which runs before
* this guard; these rules remain the deterministic BACKSTOP for whatever normalization
* could not shrink (undecodable passthroughs, all-terminal overflow). When this guard
* must drop, it textifies the OLDEST image blocks — newest screenshots are the ones the
* model needs.
* current pipeline fixes each image's encoding, then checks the final wire budget.
* Aggregate pressure enters context compaction. The older mutating guard remains
* available for compatibility but is not used by the Anthropic adapter.
*/

export const MANY_IMAGE_THRESHOLD = 20;
Expand All @@ -36,13 +33,29 @@ export const MAX_IMAGE_FILE_BYTES = MAX_IMAGE_BASE64_LENGTH;
/**
* Anthropic rejects raw HTTP bodies over ~32MB with 413 request_too_large, and base64
* image data dominates image-heavy histories (base64 is single-byte ASCII, so base64
* chars ≈ serialized body bytes for the image share). The guard runs inside buildRequest
* BEFORE system/tools attach, so it cannot measure the final body; instead we bound the
* image share to 20MiB, leaving ≥11MB headroom even against a decimal 32,000,000-byte
* cap — realistic non-image share (context-capped text history + tool schemas) stays
* well under that. Residual: a request dominated by non-image content can still 413.
* chars ≈ serialized body bytes for the image share). Bound the image share to 26MiB,
* then check the actual serialized request, including history, system and tools,
* against the decimal 32,000,000-byte cap before sending. Either limit requests
* context compaction; neither limit changes an older image's encoding.
*/
export const TOTAL_IMAGE_BASE64_BUDGET = 20 * 1024 * 1024;
export const TOTAL_IMAGE_BASE64_BUDGET = 26 * 1024 * 1024;

export const ANTHROPIC_REQUEST_BYTES = 32_000_000;

export class AnthropicImageOverflow extends Error {
constructor() { super("Anthropic request requires context compaction before it can fit the image or body limit."); }
}

export function assertAnthropicImageBudget(messages: unknown[], bodyBytes: number): void {
const refs = collectImageRefs(messages);
const riskyMany = refs.length > MANY_IMAGE_THRESHOLD && refs.some(ref => {
const size = ref.base64 ? sniffImageDimensions(ref.base64) : null;
return !size || size.width > MANY_IMAGE_MAX_DIMENSION || size.height > MANY_IMAGE_MAX_DIMENSION;
});
if (refs.length > MAX_IMAGES_PER_REQUEST || riskyMany
|| refs.reduce((sum, ref) => sum + (ref.base64?.length ?? 0), 0) > TOTAL_IMAGE_BASE64_BUDGET
|| bodyBytes > ANTHROPIC_REQUEST_BYTES) throw new AnthropicImageOverflow();
}

const OMITTED_TEXT = "[image omitted: Anthropic request exceeded the 20-image limit for large images; older screenshots were dropped]";
const OVERSIZED_TEXT = "[image omitted: exceeds Anthropic's 8000px per-side limit]";
Expand Down
27 changes: 11 additions & 16 deletions src/adapters/anthropic-image-normalize.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
/**
* Anthropic image normalization: resize/re-encode images to fit Anthropic's request
* limits instead of dropping them (devlog/260714_image_normalization_pipeline/020).
*
* Age-tier pyramid: newest images keep near-full fidelity, older images become
* progressively smaller JPEG thumbnails, so a whole session's screenshots stay visible
* under the request byte budget. An aggregate demotion loop re-encodes the OLDEST
* not-yet-terminal image one ladder position at a time until the total fits; only when
* every image is terminal-floored does the guard's Rule 4 (textify) fire as backstop.
* Anthropic uses a fixed per-image encoding. Aggregate pressure is handled by context
* compaction, so appending an image cannot change already-emitted historical bytes.
* Other adapters retain the shared core's age tiers and aggregate demotion policy.
*
* Runs inside the anthropic adapter's buildRequest BEFORE enforceAnthropicImageLimits,
* on freshly-built wire messages (in-place mutation is safe: messagesToAnthropicFormat
Expand Down Expand Up @@ -112,6 +107,7 @@ export interface NormalizeTarget {
}

export interface NormalizeTargetsOptions extends NormalizeOptions {
stablePerImage?: boolean;
/** Total base64 budget across all targets. Default: TOTAL_IMAGE_BASE64_BUDGET. */
budget?: number;
/**
Expand All @@ -133,7 +129,7 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options:
const encode = options.encode ?? bunImageEncode;
const validate = options.validate ?? bunImageValidate;
const bias = options.tierBias ?? 0;
const budget = options.budget ?? TOTAL_IMAGE_BASE64_BUDGET;
const budget = options.stablePerImage ? Infinity : options.budget ?? TOTAL_IMAGE_BASE64_BUDGET;
const overflowAction = options.overflowAction ?? "none";
const processLimit = options.processLimit ?? Number.POSITIVE_INFINITY;
const abortSignal = options.abortSignal;
Expand Down Expand Up @@ -224,7 +220,7 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options:
// fixed snapshot gives each occurrence its own age tier on a cold store,
// which is the pre-#4532 behaviour.
const recorded = recordedPositions[i];
const pos = Math.min((recorded ?? initialPosition(newestFirstIndex, 0)) + Math.max(0, bias), TERMINAL_POS);
const pos = options.stablePerImage ? 0 : Math.min((recorded ?? initialPosition(newestFirstIndex, 0)) + Math.max(0, bias), TERMINAL_POS);
const result = await process(b64, pos, sourceMedia);
if (result.kind === "failed") {
target.drop(UNDECODABLE_TEXT);
Expand Down Expand Up @@ -300,7 +296,7 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options:
// position, so a repeated image converges on the most-demoted tier it was ever
// emitted at and never moves back up.
for (const entry of entries) {
if (entry) recordEmittedPosition(entry.sourceB64, entry.sourceMedia, entry.pos);
if (entry && !options.stablePerImage) recordEmittedPosition(entry.sourceB64, entry.sourceMedia, entry.pos);
}

// Terminal overflow (050 audit round 1, blocker 3): with no downstream guard, drop
Expand All @@ -325,7 +321,7 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options:
* Normalize every base64 image in already-built Anthropic wire messages (mutates in
* place). URL-source images pass through untouched. See module header for the contract.
*/
export async function normalizeAnthropicImages(messages: unknown[], options: NormalizeOptions = {}): Promise<void> {
export async function normalizeAnthropicImages(messages: unknown[], options: NormalizeOptions & { stablePerImage?: boolean } = {}): Promise<void> {
const refs = collectImageRefs(messages);
if (refs.length === 0) return;
const targets: NormalizeTarget[] = refs.map(ref => ({
Expand All @@ -334,8 +330,7 @@ export async function normalizeAnthropicImages(messages: unknown[], options: Nor
replace: (data: string, mediaType: string) => replaceImage(ref, data, mediaType),
drop: (note: string) => textify(ref, note),
}));
// Anthropic hard-caps 100 images/request and its guard textifies the surplus, so
// processing beyond the newest 100 is pure waste; terminal overflow stays with the
// guard's Rule 4 backstop (overflowAction "none").
await normalizeImageTargets(targets, { ...options, processLimit: 100, overflowAction: "none" });
// Native Messages keeps its legacy tiers; Responses owns stable bytes and compaction.
const stablePerImage = options.stablePerImage !== false;
await normalizeImageTargets(targets, { ...options, stablePerImage, processLimit: stablePerImage ? Infinity : 100, overflowAction: "none" });
}
21 changes: 12 additions & 9 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { anthropicReplayContent, createAnthropicReplayCapture } from "./anthropi
import { withAnthropicProgressUpdates } from "./anthropic-progress";
import type { IncomingMeta, ProviderAdapter } from "./base";
import { createAdapterTierMetadata, type AdapterTierMetadata } from "../providers/fastwire";
import { observeAnthropicCacheUsage } from "../lib/anthropic-cache-observation";
import { ANTHROPIC_FAST_MODE_BETA, mergeAnthropicBetaHeader } from "../providers/anthropic-fast";
import { createToolCallIdAllocator, type ToolCallIdAllocator } from "./tool-call-id";
import { debugDroppedFrame } from "../lib/debug";
Expand All @@ -23,7 +24,8 @@ import type {
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName } from "../types";
import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPrefix, stripClaudeToolPrefix } from "../oauth/anthropic";
import { parseDataUrl } from "./image";
import { enforceAnthropicImageLimits } from "./anthropic-image-guard";
import { assertAnthropicImageBudget } from "./anthropic-image-guard";
import { sameProviderContinuationOwner } from "../responses/provider-continuation";
import { normalizeAnthropicImages } from "./anthropic-image-normalize";
import { normalizeAnthropicOutputSchema } from "./anthropic-output-schema";
import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
Expand Down Expand Up @@ -1017,6 +1019,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
formatErrorBody: formatAnthropicErrorBody,

async buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) {
if (parsed._anthropicImageOwner && (!parsed._providerContinuationOwner
|| !sameProviderContinuationOwner(parsed._anthropicImageOwner, parsed._providerContinuationOwner))) {
throw new Error("Anthropic compaction serving account changed; retry with the current account.");
}
if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
if (isOAuth) {
throw new Error("anthropic oauth token missing — run ocx login anthropic");
Expand All @@ -1029,16 +1035,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
usesNativeAnthropicEndpoint(provider) ? parsed._anthropicServerReplay : undefined);
// Before image normalization, so the framing block is present for every downstream pass.
if (isAgentRouterEndpoint(provider.baseUrl)) applyAgentRouterLanguageFraming(messages);
// Primary image layer: resize/re-encode to fit Anthropic limits without dropping
// (anthropic-image-normalize.ts); the guard below remains the deterministic backstop.
// imageTierBias > 0 = upstream-413 tightened retry (030): start every image one tier lower.
await normalizeAnthropicImages(messages, {
tierBias: incoming?.imageTierBias ?? 0,
abortSignal: incoming?.abortSignal,
});
// Anthropic rejects many-image requests (>20 images) carrying any image over
// 2000px per side; see anthropic-image-guard.ts for the full limit policy.
enforceAnthropicImageLimits(messages);
const converted = toolsToAnthropicFormat(parsed, toolNames);
// A third-party Anthropic-compatible endpoint may not implement Anthropic's server tools.
const tools = usesNativeAnthropicEndpoint(provider) ? withAnthropicServerTools(converted, parsed) : converted;
Expand Down Expand Up @@ -1238,11 +1237,14 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
enforceCacheControlLimit(body, explicitLimit);
normalizeTtlOrdering(body);

const serialized = JSON.stringify(body);
assertAnthropicImageBudget(messages, Buffer.byteLength(serialized, "utf8"));

const request = {
url,
method: "POST",
headers,
body: JSON.stringify(body),
body: serialized,
// Adapter-owned: the registry wrapper would otherwise report the exact absence of a
// tier field and mislabel every fast turn as downgraded.
tierLog: createAdapterTierMetadata(
Expand Down Expand Up @@ -1346,6 +1348,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
case "message_start": {
const message = data.message as { usage?: unknown } | undefined;
pendingUsage = mergeAnthropicUsage(pendingUsage, message?.usage);
observeAnthropicCacheUsage(response, message?.usage);
// Fast mode reports the speed actually served here (`usage.speed`).
observeAnthropicSpeed(message?.usage, tierMetadata);
break;
Expand Down
Loading
Loading