diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index baba267dadf..94f00c61d56 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -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. diff --git a/scripts/test-layout/layout-cache-lifecycle.json b/scripts/test-layout/layout-cache-lifecycle.json new file mode 100644 index 00000000000..7c2dd62a8cf --- /dev/null +++ b/scripts/test-layout/layout-cache-lifecycle.json @@ -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" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e2ec2449dfd..c51c3925e5d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", diff --git a/src/adapters/anthropic-image-guard.ts b/src/adapters/anthropic-image-guard.ts index 30179a01f3a..ccda1171b2d 100644 --- a/src/adapters/anthropic-image-guard.ts +++ b/src/adapters/anthropic-image-guard.ts @@ -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; @@ -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]"; diff --git a/src/adapters/anthropic-image-normalize.ts b/src/adapters/anthropic-image-normalize.ts index d9506f6e90c..537ef4a02da 100644 --- a/src/adapters/anthropic-image-normalize.ts +++ b/src/adapters/anthropic-image-normalize.ts @@ -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 @@ -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; /** @@ -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; @@ -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); @@ -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 @@ -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 { +export async function normalizeAnthropicImages(messages: unknown[], options: NormalizeOptions & { stablePerImage?: boolean } = {}): Promise { const refs = collectImageRefs(messages); if (refs.length === 0) return; const targets: NormalizeTarget[] = refs.map(ref => ({ @@ -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" }); } diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 4251de21e29..eaef31569cc 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -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"; @@ -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"; @@ -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"); @@ -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; @@ -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( @@ -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; diff --git a/src/anthropic/cache-renewal-runtime.ts b/src/anthropic/cache-renewal-runtime.ts new file mode 100644 index 00000000000..0beef393b64 --- /dev/null +++ b/src/anthropic/cache-renewal-runtime.ts @@ -0,0 +1,147 @@ +import { randomUUID } from "node:crypto"; +import type { OcxConfig, OcxParsedRequest } from "../types"; +import type { HandleResponsesOptions } from "../server/responses/core-options"; +import { codexRequestProvenance, codexRequestTurn } from "../codex/request-provenance"; +import { getAccountSet } from "../oauth/store"; +import { appendUsageEntry } from "../usage/log"; +import { CodexCacheRuntime } from "../codex/cache-runtime-state"; +import { AnthropicCacheRenewal, cacheUsage, renewalBody, type CachePrefixComparison, type RenewalResult, type RenewalWire } from "./cache-renewal"; +import { attachCacheObservation } from "../lib/anthropic-cache-observation"; + +export interface NaturalCacheScope { + observeSend: typeof observeNaturalCacheSend; + observationId?: string; + threadId: string; + turnId: string; + role: "main" | "subagent"; + purpose?: "turn" | "compaction"; + provider: string; + config: OcxConfig; + oauth: boolean; + conversationId?: string; +} + +export type PinnedRenewalSend = (url: string, headers: Headers, body: string, signal: AbortSignal, dispatched: () => void) => Promise; + +function renewalConfigAllowed(config: OcxConfig): boolean { + // Detached renewals cannot reserve the original workflow's spend scopes yet. + return config.anthropicCacheRenewal === true + && ![config.spend?.root, config.spend?.identity, config.spend?.pool].some(scope => scope?.maxTokens !== undefined); +} + +// Diagnostic correlation is process-local evidence, never an input to cache or task identity. +function naturalDiagnostic(scope: { observationId?: string; threadId?: string; turnId?: string; role?: string; purpose?: string }, + phase: "scope" | "response" | "usage", reason: string, usage?: unknown, comparison?: CachePrefixComparison): void { + const id = (value?: string) => value && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value) ? value : "unavailable"; + const parsed = cacheUsage(usage); + try { + console.info("[anthropic-cache-natural]", JSON.stringify({ version: 1, observedAt: Date.now(), + observationId: id(scope.observationId), threadId: id(scope.threadId), turnId: id(scope.turnId), + role: ["main", "subagent"].includes(scope.role ?? "") ? scope.role : "unknown", + purpose: ["turn", "compaction", "helper"].includes(scope.purpose ?? "") ? scope.purpose : "unknown", phase, reason, + ...(usage && typeof usage === "object" ? { usageFieldMask: ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"] + .map(key => Number.isSafeInteger((usage as Record)[key]) && Number((usage as Record)[key]) >= 0 ? "1" : "0").join("") } : {}), + ...(parsed ? { cachedTokens: parsed.cache_read_input_tokens + parsed.cache_creation_input_tokens } : {}), + ...(comparison ? { prefixComparison: comparison } : {}) })); + } catch { /* Logging must not affect natural requests or cache confirmation. */ } +} + +export function naturalCacheScope(input: { + parsed: OcxParsedRequest; req: Request; config: OcxConfig; options: HandleResponsesOptions; + adapterName: string; providerName: string; oauth: boolean; conversationId?: string; +}): NaturalCacheScope | undefined { + const { parsed, req, options } = input; + if (input.adapterName !== "anthropic" || !renewalConfigAllowed(input.config)) return; + const diagnostic = { observationId: randomUUID(), threadId: parsed._codexOwnThreadId }; + if (!parsed._codexOwnThreadId) { naturalDiagnostic(diagnostic, "scope", "missing-own-thread"); return; } + const turn = codexRequestTurn(parsed._rawBody, req.headers); + if (!turn || turn.threadId !== parsed._codexOwnThreadId) { + naturalDiagnostic(diagnostic, "scope", "missing-or-mismatched-turn"); return; + } + const origin = codexRequestProvenance(parsed._rawBody, req.headers, { + transport: options.inboundTransport, + internalHelper: !!(options.internalDecisionCall || options.memoryModelPhase || options.shadowCallIntercepted + || options.visionDescribeTerminal || options.advisorInternal), + }); + if ((origin.role !== "main" && origin.role !== "subagent") || !["turn", "compaction"].includes(origin.purpose)) { + naturalDiagnostic({ ...diagnostic, ...turn, ...origin }, "scope", "ineligible-provenance"); return; + } + naturalDiagnostic({ ...diagnostic, ...turn, ...origin }, "scope", "eligible"); + return { ...turn, observeSend: observeNaturalCacheSend, observationId: diagnostic.observationId, role: origin.role, purpose: origin.purpose as "turn" | "compaction", provider: input.providerName, config: input.config, + oauth: input.oauth, conversationId: input.conversationId }; +} + +export function renewalDiagnosticRecord(scope: NaturalCacheScope, event: RenewalResult, requestId: string) { + const id = (value: string) => /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value) ? value : "unavailable"; + return { version: 1, requestId, threadId: id(scope.threadId), turnId: id(scope.turnId), + role: scope.role, status: event.status, reason: event.reason, sent: event.sent, + durationMs: event.duration, ...event.diagnostics }; +} + +const runtime = new CodexCacheRuntime(); +const manager = new AnthropicCacheRenewal({ + now: () => performance.now(), + later: (fn, delay) => { const timer = setTimeout(fn, delay); timer.unref(); return timer; }, + clear: timer => clearTimeout(timer as ReturnType), + watch: (thread, update, turn) => runtime.watch(thread, update, turn), +}); + +export async function observeNaturalCacheSend( + scope: NaturalCacheScope, + input: Parameters[0], + init: RequestInit, + physical: typeof fetch, + renew: PinnedRenewalSend, +): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const original = typeof init.body === "string" ? init.body : undefined; + if (!original || !renewalBody(original) || scope.config.cacheRetention === "none" || !renewalConfigAllowed(scope.config)) { + naturalDiagnostic(scope, "response", scope.config.cacheRetention === "none" ? "cache-disabled" : "ineligible-body"); + manager.forget(scope.threadId); + return physical(input, init); + } + const parsed = JSON.parse(original); + const body = JSON.stringify({ ...parsed, stream: true }); + const headers = new Headers(init.headers); + headers.delete("content-length"); + headers.set("accept", "text/event-stream"); + const credential = headers.get("authorization") ?? headers.get("x-api-key") ?? ""; + const identity = JSON.stringify([scope.provider, url, parsed.model, credential, headers.get("anthropic-organization-id")]); + const allowed = () => { + if (scope.config.cacheRetention === "none" || !renewalConfigAllowed(scope.config)) return false; + if (!scope.oauth) return true; + const token = headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + return !!token && !!getAccountSet(scope.provider)?.accounts.some(row => row.credential.access === token && !row.paused && !row.needsReauth); + }; + const wire: RenewalWire = { + identity, turnId: scope.turnId, model: String(parsed.model), body, allowed, + async send(nextBody, signal, dispatched) { + if (!await runtime.active(scope.threadId) || signal.aborted || !allowed()) throw new Error("cache-runtime-not-active"); + const renewalHeaders = new Headers(headers); + renewalHeaders.delete("content-length"); + renewalHeaders.set("accept", "application/json"); + return renew(url, renewalHeaders, nextBody, signal, dispatched); + }, + record(event) { + const usage = event.usage; + const requestId = `ocx-${randomUUID().replaceAll("-", "")}`; + console.info("[anthropic-cache-renewal]", JSON.stringify(renewalDiagnosticRecord(scope, event, requestId))); + appendUsageEntry({ requestId, timestamp: Date.now() - event.duration, + provider: scope.provider, model: wire.model, conversationId: scope.conversationId, + status: event.status, durationMs: event.duration, usageStatus: usage ? "reported" : "unreported", + cacheProvenance: usage ? "observed" : "unknown", errorCode: `cache_renewal_${event.reason}`, + ...(usage ? { usage: { inputTokens: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens, + outputTokens: usage.output_tokens, cachedInputTokens: usage.cache_read_input_tokens, + cacheReadInputTokens: usage.cache_read_input_tokens, cacheCreationInputTokens: usage.cache_creation_input_tokens } } : {}), + }); + }, + }; + wire.body = manager.prepare(scope.threadId, wire); + const acceptUsage = manager.begin(scope.threadId, wire, (reason, raw, comparison) => naturalDiagnostic(scope, "usage", reason, raw, comparison)); + const response = await physical(input, { ...init, headers, body: wire.body }); + naturalDiagnostic(scope, "response", response.ok ? "attached" : "http-error"); + if (response.ok) attachCacheObservation(response, acceptUsage); + return response; +} + +export function stopAnthropicCacheRenewal(): void { manager.close(); } diff --git a/src/anthropic/cache-renewal.ts b/src/anthropic/cache-renewal.ts new file mode 100644 index 00000000000..68f80a5b82b --- /dev/null +++ b/src/anthropic/cache-renewal.ts @@ -0,0 +1,407 @@ +import { ANTHROPIC_REQUEST_BYTES } from "../adapters/anthropic-image-guard"; + +export interface CacheRuntimeState { + running: boolean; + turn: string | null; +} + +export interface CacheUsage { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; +} + +export interface RenewalWire { + identity: string; + turnId: string; + model: string; + body: string; + allowed(): boolean; + send(body: string, signal: AbortSignal, dispatched: () => void): Promise; + record(event: RenewalResult): void; +} + +export interface CachePrefixComparison { + previousCachedTokens: number; + previousCacheAgeMs: number; + identityChanged: boolean; + toolsChanged: boolean; + systemChanged: boolean; + optionsChanged: boolean; + previousMessages: number; + currentMessages: number; + firstChangedMessage: number | null; + previousBodyBytes: number; + currentBodyBytes: number; +} + +const stableCacheValue = (value: unknown) => JSON.stringify(value, (key, item) => key === "cache_control" ? undefined : item); +const cacheMessage = (message: Record) => ({ ...message, + content: typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content }); + +// Compare in memory; emit only fixed booleans and counts, never prompt text or account identity. +export function compareCachePrefix(previous: RenewalWire, current: RenewalWire, + previousCachedTokens: number, previousCacheAgeMs: number): CachePrefixComparison | undefined { + try { + const before = JSON.parse(previous.body), after = JSON.parse(current.body); + const stable = stableCacheValue; + const options = ({ messages, tools, system, max_tokens, stream, ...rest }: Record) => rest; + const left = Array.isArray(before.messages) ? before.messages : []; + const right = Array.isArray(after.messages) ? after.messages : []; + let firstChangedMessage: number | null = null; + for (let index = 0; index < left.length; index++) { + if (index >= right.length || stable(cacheMessage(left[index])) !== stable(cacheMessage(right[index]))) { firstChangedMessage = index; break; } + } + return { previousCachedTokens, previousCacheAgeMs: Math.max(0, previousCacheAgeMs), + identityChanged: previous.identity !== current.identity, + toolsChanged: stable(before.tools) !== stable(after.tools), + systemChanged: stable(before.system) !== stable(after.system), + optionsChanged: stable(options(before)) !== stable(options(after)), + previousMessages: left.length, currentMessages: right.length, firstChangedMessage, + previousBodyBytes: Buffer.byteLength(previous.body, "utf8"), currentBodyBytes: Buffer.byteLength(current.body, "utf8") }; + } catch { return; } +} + +/** Keep the confirmed tail reachable when an append moves automatic caching beyond its lookback. */ +export function retainCachePrefix(previous: RenewalWire, current: RenewalWire): string { + const comparison = compareCachePrefix(previous, current, 0, 0); + if (!comparison || comparison.identityChanged || comparison.toolsChanged || comparison.systemChanged + || comparison.optionsChanged || comparison.firstChangedMessage !== null + || comparison.currentMessages <= comparison.previousMessages) return current.body; + try { + const before = JSON.parse(previous.body), after = JSON.parse(current.body); + if (before.cache_control?.type !== "ephemeral" || after.cache_control?.type !== "ephemeral" + || before.cache_control.ttl === "1h" || after.cache_control.ttl === "1h") return current.body; + const index = before.messages.length - 1; + if (index < 0) return current.body; + const message = cacheMessage(after.messages[index]); + const blocks = message.content as Array>; + const tail = blocks?.[blocks.length - 1]; + if (!tail || ["thinking", "redacted_thinking"].includes(String(tail.type))) return current.body; + // Only replace the adapter's penultimate-user point; caller-supplied locations stay intact. + const users = after.messages.flatMap((item: any, i: number) => item.role === "user" ? [i] : []); + const pointMessage = users[users.length - 2]; + const content = after.messages[pointMessage]?.content; + const textPoint = Array.isArray(content) ? content.findLastIndex((item: any) => item.type === "text") : -1; + const pointBlock = textPoint >= 0 ? textPoint : Array.isArray(content) ? content.length - 1 : -1; + for (const [i, item] of after.messages.entries()) { + if (!Array.isArray(item.content)) continue; + for (const [j, block] of item.content.entries()) { + if (block.cache_control && (i !== pointMessage || j !== pointBlock + || stableCacheValue(block.cache_control) !== stableCacheValue(after.cache_control))) return current.body; + } + } + for (const item of after.messages) { + if (Array.isArray(item.content)) for (const block of item.content) delete block.cache_control; + } + blocks[blocks.length - 1] = { ...tail, cache_control: after.cache_control }; + after.messages[index] = { ...message, content: blocks }; + let points = 0; + const body = JSON.stringify(after, (key, value) => { if (key === "cache_control" && value) points++; return value; }); + return points <= 4 && Buffer.byteLength(body, "utf8") <= ANTHROPIC_REQUEST_BYTES ? body : current.body; + } catch { return current.body; } +} + +export interface RenewalResult { + status: number; + reason: string; + sent: boolean; + started: number; + duration: number; + usage?: CacheUsage; + diagnostics?: RenewalDiagnostics; +} + +type RenewalCancelReason = "natural-confirmed" | "identity-changed" | "turn-replaced" + | "runtime-not-running" | "scope-not-eligible" | "expired" | "renewal-failed" | "shutdown"; + +export interface RenewalDiagnostics { + attempt: number; + cacheAgeMs: number; + expectedCachedTokens: number; + phase: "send" | "response-body" | "response"; + outcome: "hit" | "cancelled" | "timeout" | "network-error" | "transport-error" + | "invalid-response" | "http-error" | "unconfirmed"; + cancelReason?: RenewalCancelReason; + networkCode?: string; +} + +const NETWORK_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", + "EPIPE", "ETIMEDOUT", "ESOCKETTIMEDOUT", "ENETUNREACH", "EHOSTUNREACH", + "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET", + "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE"]); + +function failureDiagnostic(error: unknown, attempt: AbortSignal, parent: AbortSignal) { + const row = error as { code?: unknown; cause?: { code?: unknown }; message?: unknown } | undefined; + const code = row?.code ?? row?.cause?.code; + const networkCode = typeof code === "string" && NETWORK_CODES.has(code) ? code : undefined; + if (parent.aborted) return { outcome: "cancelled" as const, cancelReason: parent.reason as RenewalCancelReason }; + if (attempt.aborted) return { outcome: "timeout" as const }; + if (row?.message === "cache-runtime-not-active") return { outcome: "cancelled" as const, cancelReason: "runtime-not-running" as const }; + return { outcome: networkCode ? "network-error" as const : "transport-error" as const, + ...(networkCode ? { networkCode } : {}) }; +} + +interface Snapshot { + ordinal: number; + wire: RenewalWire; + cachedTokens: number; + confirmedAt: number; + turn: string; +} + +interface Entry { + runId: string; + confirm?: () => void; + runtime: CacheRuntimeState; + latest: number; + snapshot?: Snapshot; + timer?: unknown; + controller?: AbortController; + unsubscribe?: () => void; +} + +export interface RenewalDependencies { + now(): number; + later(fn: () => void, delay: number): unknown; + clear(timer: unknown): void; + watch(thread: string, update: (state: CacheRuntimeState) => void, turn?: string): () => void; +} + +function abortable(pending: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(new Error("cache-renewal-aborted")); + signal.addEventListener("abort", abort, { once: true }); + pending.then(value => { signal.removeEventListener("abort", abort); resolve(value); }, + error => { signal.removeEventListener("abort", abort); reject(error); }); + if (signal.aborted) abort(); + }); +} + +export function cacheUsage(value: unknown): CacheUsage | undefined { + if (!value || typeof value !== "object") return; + const row = value as Record; + const names = ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"] as const; + if (names.some(name => !Number.isSafeInteger(row[name]) || Number(row[name]) < 0)) return; + return Object.fromEntries(names.map(name => [name, row[name]])) as unknown as CacheUsage; +} + +export function renewalBody(serialized: string): string | undefined { + try { + const body = JSON.parse(serialized); + if (!body || typeof body !== "object" || Array.isArray(body) || body.max_tokens === 0 + || body.thinking?.type === "enabled" || body.output_config?.format + || ["any", "tool"].includes(body.tool_choice?.type)) return; + let cached = false; + let long = false; + const visit = (item: unknown): void => { + if (Array.isArray(item)) { item.forEach(visit); return; } + if (!item || typeof item !== "object") return; + for (const [key, value] of Object.entries(item)) { + if (key === "cache_control" && value && typeof value === "object") { + const control = value as Record; + cached ||= control.type === "ephemeral"; + long ||= control.ttl === "1h"; + } else visit(value); + } + }; + visit(body); + if (!cached || long) return; + return JSON.stringify({ ...body, max_tokens: 0, stream: false }); + } catch { return; } +} + +export class AnthropicCacheRenewal { + private entries = new Map(); + private ordinal = 0; + constructor(private readonly deps: RenewalDependencies) {} + + prepare(thread: string, wire: RenewalWire): string { + const entry = this.entries.get(thread), previous = entry?.snapshot; + if (!previous || entry?.runId !== wire.turnId || !entry.runtime.running + || this.deps.now() - previous.confirmedAt >= 300_000 || !wire.allowed()) return wire.body; + return retainCachePrefix(previous.wire, wire); + } + + begin(thread: string, wire: RenewalWire, observe?: (reason: string, usage: unknown, comparison?: CachePrefixComparison) => void): (usage: unknown) => string { + const report = (reason: string, usage: unknown, comparison?: CachePrefixComparison): string => { + try { observe?.(reason, usage, comparison); } catch { /* Diagnostic sinks cannot affect cache state. */ } + return reason; + }; + if (!wire.turnId || !renewalBody(wire.body) || !wire.allowed()) { this.forget(thread); return raw => report("scope-not-eligible", raw); } + const prior = this.entries.get(thread); + if (prior && prior.runId !== wire.turnId) this.drop(thread, prior, "turn-replaced"); + let entry = this.entries.get(thread); + if (!entry) { + entry = { runtime: { running: false, turn: null }, latest: 0, runId: wire.turnId }; + this.entries.set(thread, entry); + entry.unsubscribe = this.deps.watch(thread, state => this.runtime(thread, state), wire.turnId); + if (this.entries.get(thread) !== entry) { entry.unsubscribe(); return raw => report("runtime-not-running", raw); } + } + const current = entry; + const ordinal = ++this.ordinal; + const started = this.deps.now(); + const turn = wire.turnId; + const previous = entry.snapshot; + entry.latest = ordinal; + if (entry.snapshot && entry.snapshot.wire.identity !== wire.identity) this.pause(entry, "identity-changed"); + if (!entry.snapshot) { + if (entry.timer) this.deps.clear(entry.timer); + entry.timer = this.deps.later(() => this.drop(thread, current, "expired"), 300_000); + } + let accepted = false; + const acceptUsage = (raw: unknown): string => { + const usage = cacheUsage(raw); + const live = this.entries.get(thread); + if (accepted) return report("already-accepted", raw); + if (!usage) return report("invalid-usage", raw); + if (live !== current) return report("entry-replaced", raw); + if (live.latest !== ordinal) return report("request-superseded", raw); + if (live.runId !== turn) return report("turn-replaced", raw); + if (!wire.allowed()) return report("identity-disabled", raw); + const cachedTokens = usage.cache_read_input_tokens + usage.cache_creation_input_tokens; + if (!cachedTokens) return report("no-cache", raw); + if (this.deps.now() >= started + 300_000) return report("expired", raw); + if (!live.runtime.running) { live.confirm = () => { acceptUsage(usage); }; return report("runtime-pending", raw); } + const comparison = observe && previous && usage.cache_creation_input_tokens > 0 + && usage.cache_read_input_tokens < previous.cachedTokens / 2 + ? compareCachePrefix(previous.wire, wire, previous.cachedTokens, started - previous.confirmedAt) : undefined; + accepted = true; + this.pause(live, "natural-confirmed"); + live.snapshot = { ordinal, wire, cachedTokens, confirmedAt: started, turn }; + this.schedule(thread, live); + return report("accepted", raw, comparison); + }; + return acceptUsage; + } + + private runtime(thread: string, state: CacheRuntimeState): void { + const entry = this.entries.get(thread); + if (!entry) return; + entry.runtime = state; + if (!state.running) this.drop(thread, entry, "runtime-not-running"); + else entry.confirm?.(); + } + + private pause(entry: Entry, reason: RenewalCancelReason): void { + entry.confirm = undefined; + if (entry.timer) this.deps.clear(entry.timer); + entry.timer = undefined; + entry.controller?.abort(reason); + entry.controller = undefined; + entry.snapshot = undefined; + } + + private drop(thread: string, entry: Entry, reason: RenewalCancelReason): void { + if (this.entries.get(thread) !== entry) return; + this.pause(entry, reason); + this.entries.delete(thread); + entry.unsubscribe?.(); + } + + private schedule(thread: string, entry: Entry): void { + const snapshot = entry.snapshot; + if (!snapshot) return; + entry.timer = this.deps.later(() => { void this.renew(thread, entry, snapshot).catch(() => this.drop(thread, entry, "renewal-failed")); }, + Math.max(0, snapshot.confirmedAt + 270_000 - this.deps.now())); + } + + private async renew(thread: string, entry: Entry, snapshot: Snapshot): Promise { + entry.timer = undefined; + const live = () => this.entries.get(thread) === entry && entry.snapshot === snapshot + && entry.runtime.running && entry.runId === snapshot.turn && snapshot.wire.allowed(); + if (!live() || this.deps.now() >= snapshot.confirmedAt + 300_000) { this.drop(thread, entry, "scope-not-eligible"); return; } + const body = renewalBody(snapshot.wire.body); + if (!body) { this.drop(thread, entry, "scope-not-eligible"); return; } + const controller = new AbortController(); + entry.controller = controller; + const deadline = snapshot.confirmedAt + 300_000; + let renewedAt: number | undefined; + try { + for (let attempt = 0; attempt < 3 && live() && this.deps.now() < deadline && !controller.signal.aborted; attempt++) { + const attemptController = new AbortController(); + const cancel = () => attemptController.abort(controller.signal.reason); + controller.signal.addEventListener("abort", cancel, { once: true }); + // Status checks, pacing and the response share the remaining cache lifetime. + const timeout = this.deps.later(() => attemptController.abort("attempt-timeout"), deadline - this.deps.now()); + let sent = false; + let started = this.deps.now(); + let delay: number | undefined; + const record = (event: Omit, detail: Pick) => + snapshot.wire.record({ ...event, diagnostics: { attempt: attempt + 1, + cacheAgeMs: Math.max(0, started - snapshot.confirmedAt), expectedCachedTokens: snapshot.cachedTokens, ...detail } }); + try { + const response = await abortable(snapshot.wire.send(body, attemptController.signal, () => { + if (!live() || this.deps.now() >= deadline || attemptController.signal.aborted) throw new Error("cache-renewal-cancelled"); + sent = true; + started = this.deps.now(); + }).then(response => { + if (attemptController.signal.aborted) { + void response.body?.cancel().catch(() => {}); + throw new Error("cache-renewal-aborted"); + } + return response; + }), attemptController.signal); + let raw: any; + let bodyFailure: ReturnType | undefined; + try { raw = await abortable(response.json(), attemptController.signal); } + catch (error) { raw = undefined; bodyFailure = failureDiagnostic(error, attemptController.signal, controller.signal); } + const usage = cacheUsage(raw?.usage); + const hit = response.ok && sent && Array.isArray(raw?.content) && raw.content.length === 0 && usage && usage.output_tokens === 0 + && usage.cache_creation_input_tokens === 0 && usage.cache_read_input_tokens >= snapshot.cachedTokens; + const reason = hit ? "hit" : usage?.cache_creation_input_tokens ? "unexpected-write" : response.ok ? "unconfirmed" : "http-error"; + record({ status: response.status, reason, sent, started, duration: this.deps.now() - started, usage }, + bodyFailure ? { phase: "response-body", ...bodyFailure, + outcome: bodyFailure.outcome === "transport-error" ? "invalid-response" : bodyFailure.outcome } + : { phase: "response", outcome: hit ? "hit" : response.ok ? "unconfirmed" : "http-error" }); + if (hit) { renewedAt = started; break; } + const type = raw?.error?.type; + if ((response.status === 429 && type === "rate_limit_error") || ([503, 529].includes(response.status) && type === "overloaded_error")) { + const retryAfter = response.headers.get("retry-after"); + const seconds = retryAfter === null ? NaN : Number(retryAfter); + delay = retryAfter === null ? 250 * (attempt + 1) : Number.isFinite(seconds) ? seconds * 1000 : Date.parse(retryAfter) - Date.now(); + } + if (usage && (usage.cache_creation_input_tokens || usage.cache_read_input_tokens || usage.output_tokens)) delay = undefined; + } catch (error) { + record({ status: 0, reason: sent ? "unknown" : "not-sent", sent, started, duration: this.deps.now() - started }, + { phase: "send", ...failureDiagnostic(error, attemptController.signal, controller.signal) }); + const code = (error as { code?: string })?.code; + if (["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"].includes(code ?? "")) delay = 250 * (attempt + 1); + } finally { + this.deps.clear(timeout); + controller.signal.removeEventListener("abort", cancel); + } + if (attempt === 2 || delay === undefined || delay < 0 || this.deps.now() + delay >= deadline || !live() || controller.signal.aborted) break; + await new Promise(resolve => { + const timer = this.deps.later(finish, delay!); + function finish() { controller.signal.removeEventListener("abort", abort); resolve(); } + const abort = () => { this.deps.clear(timer); finish(); }; + controller.signal.addEventListener("abort", abort, { once: true }); + if (controller.signal.aborted) abort(); + }); + } + } finally { + if (live()) { + entry.controller = undefined; + if (renewedAt !== undefined) { + snapshot.confirmedAt = renewedAt; + this.schedule(thread, entry); + } else if (entry.latest !== snapshot.ordinal) { + // Stop the failed renewal, but let an in-flight newer natural request confirm its own cache. + this.pause(entry, "renewal-failed"); + entry.timer = this.deps.later(() => this.drop(thread, entry, "expired"), 300_000); + } else this.drop(thread, entry, "renewal-failed"); + } + } + } + + forget(thread: string): void { + const entry = this.entries.get(thread); + if (entry) this.drop(thread, entry, "scope-not-eligible"); + } + + close(): void { + for (const [thread, entry] of this.entries) this.drop(thread, entry, "shutdown"); + } +} diff --git a/src/codex/cache-runtime-state.ts b/src/codex/cache-runtime-state.ts new file mode 100644 index 00000000000..35266e73f9f --- /dev/null +++ b/src/codex/cache-runtime-state.ts @@ -0,0 +1,207 @@ +import { connect, type Socket } from "node:net"; +import { randomUUID } from "node:crypto"; +import type { CacheRuntimeState } from "../anthropic/cache-renewal"; +import { RolloutCacheRuntime, type RolloutRuntimeDependencies } from "./rollout-cache-runtime"; + +const UNKNOWN: CacheRuntimeState = { running: false, turn: null }; +const ROOTS = new Set(["id", "hostId", "threadRuntimeStatus", "latestTurnStartMessageId"]); +const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i; + +export function projectCacheRuntime(state: Record): CacheRuntimeState { + const turn = state.latestTurnStartMessageId; + return { running: state.threadRuntimeStatus?.type === "active", + turn: typeof turn === "string" && turn.length > 0 ? turn : null }; +} + +export function applyRuntimePatches(state: Record, patches: unknown): Record { + if (!Array.isArray(patches)) throw new Error("invalid-runtime-patches"); + const next = structuredClone(state); + for (const patch of patches) { + if (!patch || !Array.isArray(patch.path) || !patch.path.length) throw new Error("invalid-runtime-path"); + if (!ROOTS.has(patch.path[0])) continue; + if (!["add", "replace", "remove"].includes(patch.op) + || patch.path.some((part: unknown) => typeof part !== "string" && typeof part !== "number" + || ["__proto__", "prototype", "constructor"].includes(String(part)))) throw new Error("invalid-runtime-patch"); + let target: any = next; + for (const part of patch.path.slice(0, -1)) { + if (!target[part] || typeof target[part] !== "object") throw new Error("missing-runtime-path"); + target = target[part]; + } + const key = patch.path.at(-1); + if (patch.op === "remove") delete target[key]; + else target[key] = structuredClone(patch.value); + } + return next; +} + +class ThreadRuntimeSubscription { + private socket?: Socket; + private buffer = Buffer.alloc(0); + private client?: string; + private owner?: string; + private state: Record = {}; + private revision?: number; + private closed = false; + private pending = new Map }>(); + private snapshots = new Set<(value: CacheRuntimeState) => void>(); + current = UNKNOWN; + available = false; + + constructor(readonly thread: string, private update: (state: CacheRuntimeState) => void, + private readonly pipe: string = "\\\\.\\pipe\\codex-ipc") { + this.socket = connect(pipe); + this.socket.unref(); + this.socket.on("data", chunk => this.receive(Buffer.from(chunk))); + this.socket.on("error", () => this.close()); + this.socket.on("close", () => this.close()); + void this.initialize().catch(() => this.close()); + } + + private send(message: object): void { + if (this.closed || !this.socket) throw new Error("runtime-ipc-unavailable"); + const bytes = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); header.writeUInt32LE(bytes.length); + this.socket.write(Buffer.concat([header, bytes])); + } + + private request(method: "initialize" | "thread-owner-discovery", params: object, version: number): Promise { + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.pending.delete(requestId); reject(new Error("runtime-ipc-timeout")); }, 3000); + timer.unref(); + this.pending.set(requestId, { resolve, reject, timer }); + try { this.send({ type: "request", requestId, sourceClientId: this.client ?? "initializing-client", version, method, params, timeoutMs: 3000 }); } + catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); } + }); + } + + private following(value: boolean): void { + this.send({ type: "broadcast", sourceClientId: this.client, version: 1, method: "thread-stream-following-changed", + targetClientIds: [this.owner], params: { conversationId: this.thread, hostId: "local", following: value } }); + } + + private async initialize(): Promise { + const initialized = await this.request("initialize", { clientType: "opencodex-cache-renewal" }, 0); + this.client = initialized.result?.clientId; + if (!this.client) throw new Error("runtime-ipc-initialize-failed"); + const owner = await this.request("thread-owner-discovery", { hostId: "local", conversationId: this.thread }, 1); + if (owner.resultType !== "success" || !owner.handledByClientId) throw new Error("runtime-ipc-owner-missing"); + this.owner = owner.handledByClientId; + this.following(true); + } + + private receive(chunk: Buffer): void { + try { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (this.buffer.length >= 4) { + const size = this.buffer.readUInt32LE(0); + if (size > 64 * 1024 * 1024) throw new Error("runtime-ipc-frame-limit"); + if (this.buffer.length < size + 4) break; + const message = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(this.buffer.subarray(4, 4 + size))); + this.buffer = this.buffer.subarray(4 + size); + const pending = this.pending.get(message.requestId); + if (pending) { this.pending.delete(message.requestId); clearTimeout(pending.timer); pending.resolve(message); continue; } + if (message.method === "ipc-connection-reset" || message.method === "client-status-changed" + && message.params?.clientId === this.owner && message.params?.status !== "connected") { this.close(); return; } + if (message.method !== "thread-stream-state-changed" || message.sourceClientId !== this.owner) continue; + const params = message.params; + if (params?.conversationId !== this.thread) continue; + if (params.hostId !== "local" || message.version !== 11) throw new Error("runtime-ipc-protocol-changed"); + const change = params.change; + if (!Number.isSafeInteger(change?.revision)) throw new Error("runtime-ipc-revision-missing"); + if (change.type === "snapshot") { + if (change.conversationState?.id !== this.thread) throw new Error("runtime-ipc-thread-mismatch"); + this.state = Object.fromEntries([...ROOTS].map(key => [key, change.conversationState[key]])); + } else { + if (change.baseRevision !== this.revision) throw new Error("runtime-ipc-revision-gap"); + this.state = applyRuntimePatches(this.state, change.patches); + } + this.revision = change.revision; + this.available = true; + this.current = projectCacheRuntime(this.state); + this.update(this.current); + for (const listener of this.snapshots) listener(this.current); + this.snapshots.clear(); + } + } catch { this.close(); } + } + + refresh(): Promise { + if (this.closed || !this.owner) return Promise.resolve(UNKNOWN); + return new Promise(resolve => { + const done = (state: CacheRuntimeState) => { clearTimeout(timer); this.snapshots.delete(done); resolve(state); }; + const timer = setTimeout(() => { this.close(); done(UNKNOWN); }, 3000); timer.unref(); + this.snapshots.add(done); + try { this.following(true); } catch { this.close(); done(UNKNOWN); } + }); + } + + close(): void { + if (this.closed) return; + try { if (this.owner) this.following(false); } + catch { this.socket?.destroy(); } // A failed unsubscribe still closes the local subscription. + this.closed = true; + this.available = false; + for (const row of this.pending.values()) { clearTimeout(row.timer); row.reject(new Error("runtime-ipc-closed")); } + this.pending.clear(); + this.current = UNKNOWN; + this.update(UNKNOWN); + for (const listener of this.snapshots) listener(UNKNOWN); + this.snapshots.clear(); + this.socket?.destroy(); + this.buffer = Buffer.alloc(0); + } +} + +export class CodexCacheRuntime { + private subscriptions = new Map; close(): void }>(); + constructor(private readonly pipe?: string, private readonly rolloutDeps?: RolloutRuntimeDependencies) {} + watch(thread: string, update: (state: CacheRuntimeState) => void, expectedTurn?: string): () => void { + let closed = false; + let fallback: RolloutCacheRuntime | undefined; + let last: string | undefined; + const publish = (state: CacheRuntimeState, source: string, reason: string) => { + if (closed) return; + const key = JSON.stringify([source, state.running, state.turn, reason]); + if (last !== key && UUID.test(thread) && expectedTurn && UUID.test(expectedTurn)) { + last = key; + console.info("[codex-cache-runtime]", JSON.stringify({ threadId: thread, + turnId: expectedTurn, source, running: state.running, reason })); + } + update(state); + }; + const secondary = () => { + if (!expectedTurn) { publish(UNKNOWN, "unavailable", "own-turn-missing"); return; } + return fallback ??= new RolloutCacheRuntime(thread, expectedTurn, + (state, reason) => publish(state, "rollout-writer", reason), this.rolloutDeps); + }; + const primary = new ThreadRuntimeSubscription(thread, state => { + if (closed) return; + if (primary.available) { + fallback?.close(); fallback = undefined; + publish(state, "ipc", "ipc-state"); + } else secondary(); + }, this.pipe); + const subscription = { + async refresh() { + if (closed) return UNKNOWN; + if (primary.available) { + const state = await primary.refresh(); + if (primary.available) return state; + } + return await secondary()?.refresh() ?? UNKNOWN; + }, + close() { closed = true; primary.close(); fallback?.close(); fallback = undefined; }, + }; + this.subscriptions.set(thread, subscription); + return () => { + if (this.subscriptions.get(thread) === subscription) this.subscriptions.delete(thread); + subscription.close(); + }; + } + async active(thread: string): Promise { + const current = this.subscriptions.get(thread); + const state = await current?.refresh(); + return state?.running === true; + } +} diff --git a/src/codex/request-provenance.ts b/src/codex/request-provenance.ts new file mode 100644 index 00000000000..c79e5986028 --- /dev/null +++ b/src/codex/request-provenance.ts @@ -0,0 +1,59 @@ +export type CodexRequestProvenance = { + role: "main" | "subagent" | "unknown"; + purpose: "turn" | "compaction" | "helper" | "unknown"; +}; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : undefined; +} + +export function codexRequestTurn(body: unknown, headers: Headers): { threadId: string; turnId: string } | undefined { + const raw = record(body); + const client = record(raw?.client_metadata); + const threadId = headers.get("thread-id")?.trim(); + const turnId = client?.turn_id; + if (raw?.generate === false || !threadId || client?.thread_id !== threadId + || typeof turnId !== "string" || !turnId.trim()) return; + return { threadId, turnId }; +} + +export function codexRequestProvenance( + body: unknown, + headers: Headers, + options: { transport?: "websocket"; internalHelper?: boolean } = {}, +): CodexRequestProvenance { + const unknown: CodexRequestProvenance = { role: "unknown", purpose: "unknown" }; + if (options.internalHelper) return { role: "unknown", purpose: "helper" }; + const raw = record(body); + const client = record(raw?.client_metadata); + if (raw && Object.hasOwn(raw, "client_metadata") && !client) return unknown; + const copies: unknown[] = []; + const header = headers.get("x-codex-turn-metadata"); + if (options.transport !== "websocket" && header !== null) copies.push(header); + if (client && Object.hasOwn(client, "x-codex-turn-metadata")) copies.push(client["x-codex-turn-metadata"]); + let verdict: CodexRequestProvenance | undefined; + for (const copy of copies) { + if (typeof copy !== "string") return unknown; + let metadata: Record | undefined; + try { metadata = record(JSON.parse(copy)); } catch { return unknown; } + if (!metadata) return unknown; + const purpose = metadata.request_kind === "turn" ? "turn" + : metadata.request_kind === "compaction" ? "compaction" : "helper"; + const role = metadata.subagent_kind === "thread_spawn" ? "subagent" + : metadata.thread_source === "user" ? "main" : "unknown"; + const next: CodexRequestProvenance = { role, purpose }; + if (verdict && (verdict.role !== role || verdict.purpose !== purpose)) return unknown; + verdict = next; + } + const subagent = headers.get("x-openai-subagent"); + const own = headers.get("thread-id")?.trim(); + const parent = headers.get("x-codex-parent-thread-id")?.trim(); + if (subagent === "collab_spawn" || (own && parent && own !== parent)) { + return { role: "subagent", purpose: verdict?.purpose ?? "unknown" }; + } + if (subagent && subagent !== "compact" && !(options.transport === "websocket" && verdict)) { + return { role: "unknown", purpose: "helper" }; + } + return verdict ?? unknown; +} diff --git a/src/codex/rollout-cache-runtime.ts b/src/codex/rollout-cache-runtime.ts new file mode 100644 index 00000000000..bd7fc3d3847 --- /dev/null +++ b/src/codex/rollout-cache-runtime.ts @@ -0,0 +1,150 @@ +import { open, stat } from "node:fs/promises"; +import { join } from "node:path"; +import type { CacheRuntimeState } from "../anthropic/cache-renewal"; +import { getCodexHome, resolveCodexStateDbPath } from "./paths"; +import { openCodexStateForPreflight } from "./history-state-open"; +import { nativeThreadWriterHeld } from "./thread-writer-lock"; + +const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i; +const UNKNOWN: CacheRuntimeState = { running: false, turn: null }; +const MAX_LINE = 1024 * 1024; +export interface RolloutRuntimeDependencies { + home(): string; + statePath(): string; + writerHeld(path: string): boolean; +} +const defaults: RolloutRuntimeDependencies = { + home: getCodexHome, statePath: resolveCodexStateDbPath, writerHeld: nativeThreadWriterHeld, +}; + +/** 隐藏子代理没有前端owner:自身生命周期决定运行,原生锁排除进程退出后的旧日志。 */ +export class RolloutCacheRuntime { + private path?: string; + private lock?: string; + private identity?: string; + private offset = 0; + private partial = Buffer.alloc(0); + private oversized = false; + private metadata = false; + private lifecycle = UNKNOWN; + private lifecycleReason = "unconfirmed-lifecycle"; + private closed = false; + private pending?: Promise; + private timer: ReturnType; + private last?: string; + current = UNKNOWN; + + constructor(private thread: string, private turn: string, + private update: (state: CacheRuntimeState, reason: string) => void, + private deps: RolloutRuntimeDependencies = defaults) { + this.timer = setInterval(() => { void this.refresh(); }, 1000); + this.timer.unref(); + void Promise.resolve().then(() => this.refresh()); + } + + refresh(): Promise { + if (this.closed) return Promise.resolve(UNKNOWN); + if (this.pending) return this.pending; + const pending = this.inspect(); + this.pending = pending; + void pending.finally(() => { if (this.pending === pending) this.pending = undefined; }); + return pending; + } + + private publish(state: CacheRuntimeState, reason: string): CacheRuntimeState { + if (this.closed) return UNKNOWN; + this.current = state; + const key = JSON.stringify([state.running, state.turn, reason]); + if (this.last !== key) { this.last = key; this.update(state, reason); } + return state; + } + + private line(bytes: Buffer, oversized: boolean): void { + if (!bytes.length) return; + if (oversized) { + // 原生记录头先于正文;摘要和轮次上下文不改变任务生命周期,大小不应阻断后续终态。 + const header = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, 256), { stream: true }); + if (/^\{"timestamp":"[^"\n]+",(?:"ordinal":\d+,)?"type":"(?:response_item|token_usage_record|compacted|turn_context)",/.test(header)) return; + if (/^\{"timestamp":"[^"\n]+",(?:"ordinal":\d+,)?"type":"event_msg","payload":\{"type":"item_completed",/.test(header)) return; + throw new Error("unsupported-large-runtime-record"); + } + const row = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + if (!this.metadata) { + if (row.type !== "session_meta" || row.payload?.id !== this.thread) throw new Error("runtime-thread-mismatch"); + this.metadata = true; + return; + } + if (row.type === "session_meta") { + if (row.payload?.id !== this.thread) throw new Error("runtime-thread-mismatch"); + return; + } + if (row.type !== "event_msg") return; + const event = row.payload; + if (event?.type === "task_started") { + this.lifecycle = UUID.test(event.turn_id ?? "") ? { running: true, turn: event.turn_id } : UNKNOWN; + this.lifecycleReason = this.lifecycle.running ? "task-started" : "unsupported-lifecycle"; + } else if (event?.type === "task_complete" || event?.type === "turn_aborted") { + if (!event.turn_id || event.turn_id === this.lifecycle.turn) { + this.lifecycle = UNKNOWN; + this.lifecycleReason = event.type; + } + } + } + + private consume(chunk: Buffer): void { + let start = 0; + for (;;) { + const end = chunk.indexOf(10, start); + const part = chunk.subarray(start, end < 0 ? chunk.length : end); + if (!this.oversized && this.partial.length + part.length <= MAX_LINE) this.partial = Buffer.concat([this.partial, part]); + else { this.oversized = true; if (this.partial.length < 256) this.partial = Buffer.concat([this.partial, part.subarray(0, 256 - this.partial.length)]); } + if (end < 0) return; + this.line(this.partial, this.oversized); + this.partial = Buffer.alloc(0); this.oversized = false; start = end + 1; + if (start === chunk.length) return; + } + } + + private async inspect(): Promise { + try { + if (!UUID.test(this.thread) || !UUID.test(this.turn)) return this.publish(UNKNOWN, "unsupported-identity"); + if (!this.path) { + const db = openCodexStateForPreflight(this.deps.statePath()); + try { + const row = db.query<{ rollout_path: string }, [string]>("SELECT rollout_path FROM threads WHERE id = ?").get(this.thread); + if (!row?.rollout_path?.endsWith(".jsonl")) return this.publish(UNKNOWN, "rollout-unavailable"); + this.path = row.rollout_path; + this.lock = join(this.deps.home(), "thread-writer-locks", this.thread + ".lock"); + } finally { db.close(); } + } + if (!this.deps.writerHeld(this.lock!)) return this.publish(UNKNOWN, "writer-unavailable"); + const info = await stat(this.path); + const identity = `${info.dev}:${info.ino}:${info.birthtimeMs}`; + if (this.identity && (identity !== this.identity || info.size < this.offset)) return this.publish(UNKNOWN, "rollout-replaced"); + this.identity = identity; + const handle = await open(this.path, "r"); + try { + const opened = await handle.stat(); + if (`${opened.dev}:${opened.ino}:${opened.birthtimeMs}` !== identity) throw new Error("runtime-file-changed"); + const bytes = Buffer.alloc(256 * 1024); + while (!this.closed && this.offset < info.size) { + const { bytesRead } = await handle.read(bytes, 0, Math.min(bytes.length, info.size - this.offset), this.offset); + if (!bytesRead) throw new Error("runtime-short-read"); + this.consume(bytes.subarray(0, bytesRead)); this.offset += bytesRead; + } + } finally { await handle.close(); } + if (!this.deps.writerHeld(this.lock!)) return this.publish(UNKNOWN, "writer-unavailable"); + const state = this.metadata && this.lifecycle.running && this.lifecycle.turn === this.turn ? this.lifecycle : UNKNOWN; + return this.publish(state, state.running ? "own-turn-active" + : this.lifecycle.running ? "turn-replaced" : this.lifecycleReason); + } catch (error) { + const reason = error instanceof Error && ["unsupported-large-runtime-record", "runtime-thread-mismatch", "runtime-short-read", "runtime-file-changed"].includes(error.message) + ? error.message : "runtime-unreadable"; + return this.publish(UNKNOWN, reason); + } + } + + close(): void { + this.closed = true; clearInterval(this.timer); this.partial = Buffer.alloc(0); this.current = UNKNOWN; + } +} diff --git a/src/codex/thread-writer-lock.ts b/src/codex/thread-writer-lock.ts new file mode 100644 index 00000000000..b42a9451530 --- /dev/null +++ b/src/codex/thread-writer-lock.ts @@ -0,0 +1,27 @@ +import { dlopen, ptr } from "bun:ffi"; + +let kernel: ReturnType | undefined; +function openKernel() { + return dlopen("kernel32.dll", { + CreateFileW: { args: ["ptr", "u32", "u32", "ptr", "u32", "u32", "ptr"], returns: "i64" }, + ReadFile: { args: ["i64", "ptr", "u32", "ptr", "ptr"], returns: "i32" }, + GetLastError: { args: [], returns: "u32" }, + CloseHandle: { args: ["i64"], returns: "i32" }, + }); +} + +/** 只读观察原生写入锁;不创建文件,也不获取或释放原生进程的锁。 */ +export function nativeThreadWriterHeld(path: string): boolean { + if (process.platform !== "win32") return false; + try { + const api = (kernel ??= openKernel()).symbols; + const name = Buffer.from(path + "\0", "utf16le"); + const handle = api.CreateFileW(ptr(name), 0x80000000, 7, null, 3, 0, null); + if (Number(handle) === -1) return false; + try { + const byte = Buffer.alloc(1), read = Buffer.alloc(4); + const ok = api.ReadFile(handle, ptr(byte), 1, ptr(read), null); + return !ok && api.GetLastError() === 33; + } finally { api.CloseHandle(handle); } + } catch { return false; } +} diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index d4a561836f3..ca03ff31463 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -101,6 +101,7 @@ export const configSchema = z.object({ chatgptDesktop: chatgptDesktopSchema.optional().catch(undefined), codexNativeSteering: z.boolean().optional().catch(false), codexNativeInjection: z.boolean().optional().catch(false), + anthropicCacheRenewal: z.boolean().optional().catch(false), port: z.number().int().min(0).max(65535).default(10100), // A malformed hand edit must disable only remote-role behavior, not discard // providers or data-plane keys. Live writes are rejected explicitly below. diff --git a/src/images/loop.ts b/src/images/loop.ts index 7095e3669b0..7821b364ac2 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -305,6 +305,7 @@ function loopFailure(e: LoopError): SidecarLoopFailure { */ export interface ImageBridgeDeps { thinkingRecovery?: ReturnType; + recoverImageContext?: (response: Response, parsed: OcxParsedRequest, adapter: ProviderAdapter, signal: AbortSignal) => Promise<{ response: Response; changed: boolean }>; parsed: OcxParsedRequest; clientToolCatalogRestriction?: ClientToolCatalogRestriction; adapter: ProviderAdapter; @@ -448,6 +449,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise(); @@ -760,6 +762,17 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise {}); } catch { /* closed */ } cachedRequest = undefined; diff --git a/src/lib/anthropic-cache-observation.ts b/src/lib/anthropic-cache-observation.ts new file mode 100644 index 00000000000..75a4a28da30 --- /dev/null +++ b/src/lib/anthropic-cache-observation.ts @@ -0,0 +1,19 @@ +import { registerProviderResponseProvenance } from "../providers/request-pacing"; + +const observers = new WeakMap void>(); +registerProviderResponseProvenance(observers); + +export function attachCacheObservation(response: Response, observe: (usage: unknown) => void): void { + observers.set(response, observe); +} + +export function inheritCacheObservation(from: Response, to: Response): Response { + const observer = observers.get(from); + if (observer) observers.set(to, observer); + return to; +} + +export function observeAnthropicCacheUsage(response: Response, usage: unknown): void { + try { observers.get(response)?.(usage); } + catch { return; } // Optional observation failure must not fail the natural response. +} diff --git a/src/lib/response-body-inactivity.ts b/src/lib/response-body-inactivity.ts index 7c9a64836c7..1bbfd85cf0b 100644 --- a/src/lib/response-body-inactivity.ts +++ b/src/lib/response-body-inactivity.ts @@ -1,3 +1,5 @@ +import { inheritCacheObservation } from "./anthropic-cache-observation"; + /** A response-body guard owns one reader, never the request-wide abort controller. */ export interface ResponseBodyInactivityGuard { response: Response; @@ -171,7 +173,7 @@ export async function readResponseBodyWithInactivity( ): Promise { const guarded = guardResponseBodyInactivity(response, signal, timeoutMs); try { - return await parse(guarded.response); + return await parse(inheritCacheObservation(response, guarded.response)); } finally { guarded.dispose(); } @@ -186,7 +188,7 @@ export async function* readResponseStreamWithInactivity( ): AsyncGenerator { const guarded = guardResponseBodyInactivity(response, signal, timeoutMs); try { - yield* parse(guarded.response); + yield* parse(inheritCacheObservation(response, guarded.response)); } finally { guarded.dispose(); } diff --git a/src/responses/compaction-summary.ts b/src/responses/compaction-summary.ts new file mode 100644 index 00000000000..073b794fa03 --- /dev/null +++ b/src/responses/compaction-summary.ts @@ -0,0 +1,42 @@ +import type { AdapterEvent } from "../types"; +import { releaseTranslatedEvent, type TranslatorBudget } from "../lib/translator-budget"; +import { isTruncatedStopReason } from "./truncated-stop-reason"; + +export async function* guardCompactionSummary( + events: AsyncIterable, + budget: TranslatorBudget, +): AsyncGenerator { + let hasText = false; + let attemptedTool = false; + for await (const event of events) { + if (event.type === "text_delta") { + hasText ||= event.text.trim().length > 0; + yield event; + continue; + } + if (event.type === "error" || event.type === "incomplete") { + yield event; + return; + } + if (event.type === "done") { + if (!isTruncatedStopReason(event.stopReason) + && (attemptedTool || !hasText || event.endTurn === false + || (event.stopReason !== "end_turn" && event.stopReason !== "stop_sequence"))) { + releaseTranslatedEvent(event, budget); + yield { + type: "error", status: 502, errorType: "upstream_error", + code: "compaction_invalid_summary", retryable: false, + message: "Compaction requires a non-empty text summary without tool calls and a normal completion.", + usage: event.usage, + }; + } else { + yield event; + } + return; + } + if (event.type === "tool_call_start" || event.type === "tool_call_delta" || event.type === "tool_call_end" + || event.type === "web_search_call_begin" || event.type === "web_search_call_end") attemptedTool = true; + releaseTranslatedEvent(event, budget); + yield { type: "heartbeat" }; + } +} diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 2b39f1ee4b3..92e7ff8ed79 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -345,7 +345,7 @@ async function anthropicNativePassthrough( // count_tokens too — counts must match what the real send will contain, and the 32MB // body cap applies to it equally. Non-message bodies pass through untouched. if (Array.isArray(body.messages)) { - await normalizeAnthropicImages(body.messages, { abortSignal: req.signal }); + await normalizeAnthropicImages(body.messages, { abortSignal: req.signal, stablePerImage: false }); enforceAnthropicImageLimits(body.messages); sanitizePassthroughToolCallIds(body.messages); } diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 1c4b9fd5ac0..f75c82ba62c 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -308,7 +308,7 @@ async function prepareNativeBody(body: Rec, signal: AbortSignal): Promise if (!Array.isArray(body.messages)) return; // The adapter normally owns these; this lane bypasses it, so it runs the same steps as the // caller-forward passthrough: tier-normalize and guard images, then repair tool-call ids. - await normalizeAnthropicImages(body.messages, { abortSignal: signal }); + await normalizeAnthropicImages(body.messages, { abortSignal: signal, stablePerImage: false }); enforceAnthropicImageLimits(body.messages); sanitizePassthroughToolCallIds(body.messages); } diff --git a/src/server/request-log.ts b/src/server/request-log.ts index cf8feddc5b9..df8cd918e2f 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -88,6 +88,7 @@ import { } from "../usage/jev-stats"; import { generationWindowFields, recordGenerationEvent } from "./request-log-generation-window"; import type { RequestMetricsRecorder } from "./request-metrics"; +import { captureRequestOrigin, requestOriginLogRecord, type RequestOrigin, type RequestOriginOptions } from "./request-origin"; import type { CacheDiagnosticDraft, CacheDiagnosticFinalFacts, @@ -125,6 +126,8 @@ export interface RequestLogContext extends UpstreamErrorDiagnostics { logicalRequestId?: string; /** Process-local privacy-bounded cache diagnostic; never persisted with request logs. */ cacheDiagnosticDraft?: CacheDiagnosticDraft; + /** Content-free ingress identity, correlated with the final request id. */ + requestOrigin?: RequestOrigin; /** * Internal live reference to this request's execution budget; omitted from RequestLogEntry and * JSONL. Read at final-log time so the row reports the budget's FINAL state rather than a @@ -275,12 +278,19 @@ export interface RequestLogContext extends UpstreamErrorDiagnostics { claudeCompatibility?: PersistedClaudeCompatibilityLog; } +export function observeRequestOrigin(logCtx: RequestLogContext, body: unknown, headers: Headers, options?: RequestOriginOptions): void { + // A retry/compact replay must not relabel the caller that owns this logical request. + logCtx.requestOrigin ??= captureRequestOrigin(body, headers, options); +} + export function observeCacheDiagnosticInbound( logCtx: RequestLogContext, body: unknown, headers: Headers, source: PromptCacheKeySource, + originOptions?: RequestOriginOptions, ): void { + observeRequestOrigin(logCtx, body, headers, originOptions); const draft = cacheDiagnosticHooks()?.observeInbound(body, headers, source); if (draft) logCtx.cacheDiagnosticDraft = draft; } @@ -295,6 +305,8 @@ export function rebindCacheDiagnosticBody( export interface RequestLogEntry extends UpstreamErrorDiagnostics { requestId: string; + /** Transient diagnostic handoff; removed before the ring and disk projections. */ + requestOriginDiagnostic?: { origin?: RequestOrigin }; /** The logical request this row belongs to (#4546); absent on rows written without a budget. */ logicalRequestId?: string; timestamp: number; @@ -606,7 +618,18 @@ export function rehydrateRequestLogsAfterLedgerReplacement( return hydrateRequestLogsFromDisk(reader); } +function hasAnthropicAdapter(log: { provider: string; providerAdapter?: string; attempts?: readonly { adapter?: string }[] }): boolean { + return log.providerAdapter === "anthropic" || log.provider === "anthropic" + || log.attempts?.some(attempt => attempt.adapter === "anthropic") === true; +} + export function addRequestLog(entry: RequestLogEntry) { + if (entry.requestOriginDiagnostic || hasAnthropicAdapter(entry)) { + try { + console.info("[opencodex-request-origin]", JSON.stringify(requestOriginLogRecord( + entry.requestId, entry.timestamp, entry.status, entry.requestOriginDiagnostic?.origin))); + } catch { /* Diagnostics must never change request completion. */ } + } // Sanitize ONCE, at the ingress, and use that one value for both destinations. // // `addFinalRequestLog` is not the only way in: `addRequestLog` is exported and callable @@ -623,8 +646,10 @@ export function addRequestLog(entry: RequestLogEntry) { && servedModel === entry.servedModel && entry.claudeCompatibility === undefined && entry.jevDecision === undefined + && entry.requestOriginDiagnostic === undefined ? entry : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) }; + if (retained !== entry) delete retained.requestOriginDiagnostic; if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom; if (!servedModel && retained !== entry) { delete retained.servedModel; @@ -1494,6 +1519,7 @@ export function addFinalRequestLog( const protocolTrace = protocolTraceForRequest(logCtx, logCtx.attempts); addLog({ requestId, + ...(hasAnthropicAdapter(logCtx) ? { requestOriginDiagnostic: { origin: logCtx.requestOrigin } } : {}), ...(isLogicalRequestId(logicalRequestId) ? { logicalRequestId } : {}), timestamp: start, model: isCombo ? logCtx.requestedModel! : logCtx.model, diff --git a/src/server/request-origin.ts b/src/server/request-origin.ts new file mode 100644 index 00000000000..3b1582c3e2e --- /dev/null +++ b/src/server/request-origin.ts @@ -0,0 +1,90 @@ +import { codexRequestProvenance } from "../codex/request-provenance"; + +export interface RequestOriginOptions { + endpoint?: "responses" | "responses-compact" | "messages" | "chat-completions"; + transport?: "websocket"; + internalHelper?: boolean; +} + +export interface RequestOrigin { + readonly endpoint: string; + readonly transport: string; + readonly threadId?: string; + readonly turnId?: string; + readonly headerThreadId?: string; + readonly metadataThreadId?: string; + readonly parentThreadId?: string; + readonly identity: string; + readonly role: string; + readonly purpose: string; + readonly stream?: boolean; + readonly store?: boolean; + readonly generate?: boolean; + readonly inputItems: number; + readonly tools: number; +} + +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : undefined; +} + +function uuid(value: unknown): string | undefined { + if (typeof value !== "string") return; + const id = value.trim(); + return /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(id) ? id : undefined; +} + +/** Capture only request-owned identity and shape, before routing mutates the body. */ +export function captureRequestOrigin(body: unknown, headers: Headers, options: RequestOriginOptions = {}): RequestOrigin { + const raw = object(body), client = object(raw?.client_metadata); + const headerValue = headers.get("thread-id"), metadataValue = client?.thread_id; + const own = uuid(headerValue), metadata = uuid(metadataValue), turn = uuid(client?.turn_id); + const invalid = (headerValue !== null && !own) + || (metadataValue !== undefined && !metadata) + || (client?.turn_id !== undefined && !turn) + || (raw !== undefined && Object.hasOwn(raw, "client_metadata") && !client); + const identity = invalid ? "invalid" : own && metadata && own !== metadata ? "conflict" + : own && metadata && turn ? "matched" : own ? "header-only" : "missing"; + const provenance = codexRequestProvenance(body, headers, options); + const bound = identity === "matched" || identity === "header-only"; + return Object.freeze({ + endpoint: options.endpoint ?? "responses", transport: options.transport ?? "http", + threadId: bound ? own : undefined, turnId: identity === "matched" ? turn : undefined, + headerThreadId: own, metadataThreadId: metadata, + parentThreadId: uuid(headers.get("x-codex-parent-thread-id")), identity, + role: bound ? provenance.role : "unknown", purpose: provenance.purpose, + stream: typeof raw?.stream === "boolean" ? raw.stream : undefined, + store: typeof raw?.store === "boolean" ? raw.store : undefined, + generate: typeof raw?.generate === "boolean" ? raw.generate : undefined, + inputItems: Array.isArray(raw?.input) ? raw.input.length : typeof raw?.input === "string" ? 1 : 0, + tools: Array.isArray(raw?.tools) ? raw.tools.length : 0, + }); +} + +const known = (value: unknown, values: readonly string[], fallback = "unknown") => + typeof value === "string" && values.includes(value) ? value : fallback; +const count = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 1_000_000 + ? Number(value) : undefined; +const flag = (value: unknown) => typeof value === "boolean" ? value : undefined; + +/** Whitelist again at the final boundary; no body, credentials, URLs or free text can escape. */ +export function requestOriginLogRecord(requestId: string, startedAt: number, status: number, origin?: RequestOrigin) { + return { + version: 1, pid: process.pid, + requestId: /^ocx-[a-f0-9]{32}$/i.test(requestId) ? requestId : "unavailable", + startedAt: Number.isFinite(startedAt) && startedAt >= 0 ? startedAt : undefined, + status: Number.isInteger(status) && status >= 0 && status <= 599 ? status : undefined, + captured: origin !== undefined, + endpoint: known(origin?.endpoint, ["responses", "responses-compact", "messages", "chat-completions"]), + transport: known(origin?.transport, ["http", "websocket"]), + threadId: uuid(origin?.threadId), turnId: uuid(origin?.turnId), + headerThreadId: uuid(origin?.headerThreadId), metadataThreadId: uuid(origin?.metadataThreadId), + parentThreadId: uuid(origin?.parentThreadId), + identity: known(origin?.identity, ["matched", "header-only", "conflict", "missing", "invalid"], "missing"), + role: known(origin?.role, ["main", "subagent", "unknown"]), + purpose: known(origin?.purpose, ["turn", "compaction", "helper", "unknown"]), + stream: flag(origin?.stream), store: flag(origin?.store), generate: flag(origin?.generate), + inputItems: count(origin?.inputItems), tools: count(origin?.tools), + }; +} diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index 1239ac374c7..470a4f83fbb 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -64,10 +64,11 @@ import { } from "../../lib/response-body-inactivity"; import { resolveStallTimeoutMs } from "../../stall-timeout"; import { guardTerminalEventStream } from "./terminal-guard"; +import { naturalCacheScope } from "../../anthropic/cache-renewal-runtime"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export function createAdapterContinuations( - requestContext: Pick, + requestContext: Pick, requestState: Pick< PreparedResponsesRequest, | "route" @@ -287,6 +288,9 @@ export function createAdapterContinuations( connectMs, nextParsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + anthropicCacheRenewal: naturalCacheScope({ ...requestContext, parsed: nextParsed, + adapterName: transportState.activeAdapter.name, providerName: route.providerName, + oauth: route.provider.authMode === "oauth", conversationId: logCtx.conversationId }), dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed), providerName: route.providerName, modelId: nextParsed.modelId, @@ -677,7 +681,7 @@ export function createAdapterContinuations( } } } - if (shouldAttemptImageTierRetry({ + if (transportState.activeAdapter.name !== "anthropic" && shouldAttemptImageTierRetry({ status: response.status, adapterName: transportState.activeAdapter.name, parsed: nextParsed, @@ -722,7 +726,8 @@ export function createAdapterContinuations( const parse = async function* (current: Response): AsyncGenerator { if (!current.ok) { yield await adapterExchange.thinkingSignatureRecovery.projectHttpFailure(current, upstream.signal); - } else if (nextParsed.stream) { + } else if (nextParsed.stream || (transportState.activeAdapter.name === "anthropic" + && current.headers.get("content-type")?.includes("text/event-stream") === true)) { yield* readResponseStreamWithInactivity(current, upstream.signal, bodyInactivityMs, guarded => transportState.activeAdapter.parseStream(guarded, translatorBudget, logCtx.activeTierMetadata)); } else if (transportState.activeAdapter.parseResponse) { diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 01a1af78cdc..d79c38c054a 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -28,6 +28,7 @@ import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog"; +import { guardCompactionSummary } from "../../responses/compaction-summary"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function deliverAdapterResponse( @@ -79,7 +80,8 @@ export async function deliverAdapterResponse( } = responseEffects; const { routedCompaction } = sidecarState; const bodyInactivityMs = resolveStallTimeoutMs(config.stallTimeoutSec, { localUpstream }); - const upstreamRequestsStream = parsed.stream || isCanonicalOpenAiForwardProvider(route.provider); + const upstreamRequestsStream = parsed.stream || isCanonicalOpenAiForwardProvider(route.provider) + || transportState.activeAdapter.name === "anthropic" && upstreamResponse.headers.get("content-type")?.includes("text/event-stream") === true; const recoverThinkingSignature = (events: AsyncIterable): AsyncIterable => thinkingSignatureRecovery.wrap(events, async function* () { const result = await refetchThinkingSignature(); @@ -183,7 +185,9 @@ export async function deliverAdapterResponse( continuation: fetchGuardedEmptyCompletionRetry, }) : advisorStream; - const guardedEventStream = guardEmptyToolCatalogStream(completionGuardedEventStream, clientToolCatalog, translatorBudget); + const catalogGuardedEventStream = guardEmptyToolCatalogStream(completionGuardedEventStream, clientToolCatalog, translatorBudget); + const guardedEventStream = routedCompaction && transportState.activeAdapter.name === "anthropic" + ? guardCompactionSummary(catalogGuardedEventStream, translatorBudget) : catalogGuardedEventStream; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = deliveryToolBridgeMaps; // One completion owner for both deliveries: the bridge calls it from its terminal, the // direct client encoder from the fold of the same events. @@ -329,6 +333,13 @@ export async function deliverAdapterResponse( } else { events = guardedEvents; } + if (routedCompaction && transportState.activeAdapter.name === "anthropic") { + const summaryEvents: AdapterEvent[] = []; + for await (const event of guardCompactionSummary((async function* () { yield* events; })(), translatorBudget)) { + summaryEvents.push(event); + } + events = summaryEvents; + } } catch (error) { if (error instanceof ResponseBodyInactivityError) { return formatErrorResponse(504, "upstream_error", "Upstream response body stalled before completing"); diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index ffeefdbf05d..e46cd10a7e9 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -92,7 +92,6 @@ import { anthropicFastRefused, } from "./core-opaque-recovery"; import { createAnthropicThinkingRecovery } from "./anthropic-thinking-recovery"; -import { shouldAttemptImageTierRetry } from "../image-retry"; import { isTransientConsoleGoUploadRejection, enrichOpenCodeZenUpstreamMessage, @@ -118,6 +117,8 @@ import { chargeWorkflowSends } from "../../lib/workflow-budget"; import { isAntigravityValidationRefusal } from "./antigravity-validation-refusal"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { AdmissionModelDeniedError, admissionModelDeniedResponse } from "../admission-model-scope"; +import { naturalCacheScope } from "../../anthropic/cache-renewal-runtime"; +import { recoverImageContextResponse, type ImageContextPreparation } from "./image-context-recovery"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function prepareAdapterExchange( @@ -190,6 +191,7 @@ export async function prepareAdapterExchange( | "initialSendAllowance" | "noteInitialDispatch" >, + prepareImageContext?: ImageContextPreparation, ) { const { options, config, logCtx, req } = requestContext; const { @@ -216,6 +218,9 @@ export async function prepareAdapterExchange( subagentQuotaFailureModel, } = requestState; const { cancelResponseCompletion, notifyResponseComplete, refreshRequestToolAliases } = responseEffects; + const cacheRenewalScope = () => naturalCacheScope({ ...requestContext, parsed, + adapterName: transportState.activeAdapter.name, providerName: route.providerName, + oauth: route.provider.authMode === "oauth", conversationId: logCtx.conversationId }); const { adapterSendBudget, adapterDispatchBudget, @@ -400,8 +405,11 @@ export async function prepareAdapterExchange( }; const autoPauseEnabled = config.providers[route.providerName]?.accountAutoPause?.enabled === true; const billingPoolRequest = isAnthropicBillingKeyPool(route.provider); + const imageContextBudget = parsed._anthropicImageOwner !== undefined || transportState.activeAdapter.name === "anthropic" + && !!prepareImageContext && parsed.context.messages.some(m => Array.isArray(m.content) && m.content.some(p => p.type === "image")); + let imageRecoveryAttempted = false; let billingRecoveryActive = false; - const genericRefetchCountsSends = () => options.comboAttempt === true || sendBudgetState.adapterSendBudget?.spendEnforced === true + const genericRefetchCountsSends = () => imageContextBudget || options.comboAttempt === true || sendBudgetState.adapterSendBudget?.spendEnforced === true || transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null || billingPoolRequest || billingRecoveryActive || autoPauseEnabled; let handoffPermit: ResponsesSendBudget["pendingHopPermit"]; @@ -498,6 +506,7 @@ export async function prepareAdapterExchange( body: builtInitialRequest.body, }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + anthropicCacheRenewal: cacheRenewalScope(), dispatchOverride: oauthDispatch(builtInitialRequest), providerName: route.providerName, modelId: route.modelId, @@ -524,11 +533,11 @@ export async function prepareAdapterExchange( abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url), claimAmbiguousResend: claimPreHeaderResend, - ...(sendBudgetState.adapterSendBudget?.spendEnforced || options.comboDispatchPermit || transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt + ...(imageContextBudget || sendBudgetState.adapterSendBudget?.spendEnforced || options.comboDispatchPermit || transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt || billingPoolRequest || autoPauseEnabled ? { // A pending compaction permit already booked this leg's first physical send. - ...(transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt || autoPauseEnabled + ...(imageContextBudget || transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt || autoPauseEnabled ? { attempts: Math.min(initialSendCap, sendBudgetState.initialSendAllowance(initialSendCap) + (compactPrepaid ? 1 : 0)), @@ -596,16 +605,8 @@ export async function prepareAdapterExchange( // `attempts` same-key replays in total (bounded per request). const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider); let rateLimitRetries = 0; - // Shared with the terminal-guard continuation below: an image-tier reduction that let the - // main request clear a 413 must not be forgotten on the very next continuation build. // Recovery helpers also serve initial HTTP-200 adapter errors during delivery. - // Recovery loop: multi-key 429 failover + at most ONE opaque-state rebuild and ONE - // anthropic 413 tightened retry - // (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves - // both paths so a 429→413 sequence never rebuilds against a stale pre-rotation - // adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a - // 413→429 rotation cannot silently undo the tightening. - let imageRetryAttempted = false; + // Recover credentials and opaque state without changing historical image encodings. const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; const thinkingSignatureRecovery = createAnthropicThinkingRecovery( parsed, upstream.signal, opaqueBlobRecoveryGuard, () => !sendBudgetExhausted(), @@ -793,6 +794,7 @@ export async function prepareAdapterExchange( method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, }, recoveryKind), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + anthropicCacheRenewal: cacheRenewalScope(), dispatchOverride: oauthDispatch(retryRequest), onPhysicalDispatch: deferredRefetch ? () => { if (!deferredRefetch.beforeDispatch()) throw new SendBudgetExhaustedError(safeHostLabel(retryRequest.url)); @@ -1783,26 +1785,17 @@ export async function prepareAdapterExchange( upstreamResponse = opaqueBlobRecovery.response; continue recovery; } - // Anthropic 413 request_too_large: rebuild once with every image one tier lower - // (spiral guard: single attempt). The biased response re-enters the 429 check above. - if (shouldAttemptImageTierRetry({ - status: upstreamResponse.status, - adapterName: transportState.activeAdapter.name, - parsed, - alreadyAttempted: imageRetryAttempted, - })) { - imageRetryAttempted = true; - transportState.imageTierBias = 1; - invalidateSameTargetRequest(); - const failedResponse = await retainFailureResponse(); - const result = await rebuildAndRefetch("image-413", undefined, failedResponse); - if ("failed" in result) { - if (result.failed !== failedResponse) return result.failed; - break recovery; + if (upstreamResponse.status === 413 && transportState.activeAdapter.name === "anthropic" + && !imageRecoveryAttempted && prepareImageContext && !sendBudgetExhausted()) { + imageRecoveryAttempted = true; + const recovered = await recoverImageContextResponse(upstreamResponse, parsed, transportState.activeAdapter, prepareImageContext, upstream.signal); + upstreamResponse = recovered.response; + if (recovered.changed) { + invalidateSameTargetRequest(); + const result = await rebuildAndRefetch("image-413", undefined, upstreamResponse); + if ("failed" in result) return result.failed; + upstreamResponse = result; continue recovery; } - try { void failedResponse.body?.cancel().catch(() => {}); } catch { /* closed */ } - upstreamResponse = result; - continue recovery; } // Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds // later with 400 invalid_request_error / "Invalid upload request." Replay the diff --git a/src/server/responses/anthropic-image-context.ts b/src/server/responses/anthropic-image-context.ts new file mode 100644 index 00000000000..d010173ba8b --- /dev/null +++ b/src/server/responses/anthropic-image-context.ts @@ -0,0 +1,139 @@ +import type { OcxContext, OcxMessage, OcxParsedRequest, OcxProviderContinuationOwner } from "../../types"; +import type { ProviderAdapter, IncomingMeta } from "../../adapters/base"; +import { AnthropicImageOverflow } from "../../adapters/anthropic-image-guard"; +import { sameProviderContinuationOwner } from "../../responses/provider-continuation"; +import { previousResponseProviderState, rememberResponseState } from "../../responses/state"; +import { COMPACT_PROMPT, SUMMARY_PREFIX } from "../../responses/compaction"; + +export interface ImageSummaryContext { + messages: OcxMessage[]; + tools: OcxContext["tools"]; + systemPrompt: OcxContext["systemPrompt"]; + owner: OcxProviderContinuationOwner; +} + +export class ImageContextError extends Error { + constructor(message: string, readonly status = 413) { super(message); } +} + +const pending = new Map>(); +const summaryMessage = (summary: string): OcxMessage => ({ role: "user", timestamp: 0, content: `${SUMMARY_PREFIX}\n\n${summary}` }); +const summaryInstruction = (): OcxMessage => ({ role: "user", timestamp: 0, + content: `${COMPACT_PROMPT}\n\nReturn only the handoff summary. Do not call tools or continue the task.` }); +const messageKey = ({ timestamp: _timestamp, ...message }: OcxMessage): string => JSON.stringify(message); + +function prefixDigest(messages: OcxMessage[], end: number): string { + const digest = new Bun.CryptoHasher("sha256"); + for (let i = 0; i < end; i++) digest.update(messageKey(messages[i])).update("\n"); + return digest.digest("hex"); +} + +function completeBoundaries(messages: OcxMessage[]): number[] { + const cuts = [0]; + const calls = new Set(); + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (!calls.size && i > 0 && message.role !== "toolResult") cuts.push(i); + if (message.role === "assistant") { + for (const part of message.content) if (part.type === "toolCall") calls.add(part.id); + } else if (message.role === "toolResult") calls.delete(message.toolCallId); + } + return cuts; +} + +/** 检查点复用已有有界续接存储;仅在触顶时压缩完整的旧历史单元。 */ +export async function prepareAnthropicImageContext(input: { + parsed: OcxParsedRequest; + adapter: ProviderAdapter; + incoming: IncomingMeta; + upstreamRejected?: boolean; + summarize(context: ImageSummaryContext): Promise; +}): Promise { + const { parsed, adapter, incoming, summarize } = input; + if (adapter.name !== "anthropic") return; + if (!parsed.context.messages.some(message => Array.isArray(message.content) + && message.content.some(part => part.type === "image"))) return; + const signal = incoming.abortSignal; + const checkAbort = () => { if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError"); }; + const fits = async (messages: OcxMessage[], summary: boolean): Promise => { + checkAbort(); + const trial = { ...parsed, options: { ...parsed.options }, context: { ...parsed.context, + messages: summary ? [...messages, summaryInstruction()] : messages } }; + if (summary) delete trial.options.textFormat; + try { + const built = await adapter.buildRequest(trial, incoming); + built.releaseBodyObservation?.(); + return true; + } catch (error) { + if (error instanceof AnthropicImageOverflow) return false; + throw error; + } + }; + const owner = parsed._providerContinuationOwner; + const thread = parsed._codexOwnThreadId; + const principal = parsed._reasoningReplayScope?.clientPrincipalId; + const key = thread && owner ? `ocx-image:${principal ?? "local"}:${thread}` : undefined; + const before = key ? pending.get(key) : undefined; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const queued = (before ?? Promise.resolve()).then(() => held); + if (key) pending.set(key, queued); + try { + if (before) await before; + checkAbort(); + const original = parsed.context.messages; + const stored = key ? previousResponseProviderState(key) : undefined; + const saved = stored?.anthropicImages; + let covered = 0; + let summary: string | undefined; + if (owner && stored?.__ocxOwner && sameProviderContinuationOwner(owner, stored.__ocxOwner) && saved + && Number.isSafeInteger(saved.covered) && Number(saved.covered) > 0 && Number(saved.covered) <= original.length + && saved.prefixDigest === prefixDigest(original, Number(saved.covered)) + && typeof saved.summary === "string" && saved.summary.trim()) { + covered = Number(saved.covered); + summary = saved.summary; + } + const effective = (end = original.length) => [ + ...(summary ? [summaryMessage(summary)] : []), ...original.slice(covered, end), + ]; + const cuts = completeBoundaries(original); + const retainFrom = cuts.at(-1) ?? 0; + let rejected = input.upstreamRejected === true && original.slice(covered, retainFrom).some(message => + Array.isArray(message.content) && message.content.some(part => part.type === "image")); + while (rejected || !await fits(effective(), false)) { + if (!owner || !key) throw new ImageContextError("Image context cannot be compacted without a verified conversation and serving identity."); + const candidates = cuts.filter(end => end > covered && end <= retainFrom); + let lo = 0, hi = candidates.length - 1, end = covered; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if (await fits(effective(candidates[mid]), true)) { end = candidates[mid]; lo = mid + 1; } + else hi = mid - 1; + } + if (end === covered) throw new ImageContextError("The newest image or indivisible input exceeds the request limit; old-context compaction cannot make it fit."); + const next = await summarize({ messages: effective(end), tools: parsed.context.tools, + systemPrompt: parsed.context.systemPrompt, owner }); + checkAbort(); + if (!next.trim() || !parsed._providerContinuationOwner + || !sameProviderContinuationOwner(owner, parsed._providerContinuationOwner)) { + throw new ImageContextError("Image compaction did not produce a valid summary for the current serving account.", 502); + } + summary = next; + covered = end; + rejected = false; + rememberResponseState({ input: [], store: false }, { id: key, output: [], status: "completed" }, { + __ocxOwner: owner, anthropicImages: { covered, prefixDigest: prefixDigest(original, covered), summary }, + }, { force: true, clientThreadId: thread }); + const retained = previousResponseProviderState(key)?.anthropicImages; + if (!retained || retained.summary !== summary || retained.covered !== covered) { + throw new ImageContextError("Image compaction checkpoint could not be retained; refusing repeated summary charges.", 503); + } + } + if (summary && owner) { + parsed.context.messages = effective(); + parsed._anthropicImageOwner = { ...owner }; + } + } finally { + release(); + if (key && pending.get(key) === queued) pending.delete(key); + } +} diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 28cb3fbc718..2fac3e38547 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -194,6 +194,7 @@ import { sealRequestAttemptIdentity, usageFromResponsesPayload, type RequestLogContext, + observeRequestOrigin, } from "../request-log"; import type { AttemptRecoveryKind } from "../../usage/log"; import { @@ -667,6 +668,7 @@ export async function handleResponsesCompact( return formatErrorResponse(400, "invalid_request_error", "Invalid compaction request body"); } // Compaction forwards client history upstream too; keep local artifact paths out of it. + observeRequestOrigin(logCtx, body, req.headers, { endpoint: "responses-compact" }); redactHostedImageDisplayPaths(body); if (!options.compactionRoutingOverride) { options = { ...options, compactionRoutingOverride: applyCompactionRoutingOverride(body, req.headers, config, { endpoint: "compact" }) }; diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index b8f237828c5..6cdaf9eb4e3 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -93,6 +93,7 @@ export interface DirectRequestOption { } export interface HandleResponsesOptions { + imageSummaryContext?: import("./anthropic-image-context").ImageSummaryContext; /** Internal request-owned policy authorization; never read from client headers or body. */ policyRequestScope?: PolicyRequestScope; /** Internal concrete selector chosen from the original policy evaluation. */ diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts index 3e5e584d8e1..7317a16ba79 100644 --- a/src/server/responses/core-pipeline.ts +++ b/src/server/responses/core-pipeline.ts @@ -24,6 +24,7 @@ import { releaseUpstreamHostAdmission } from "../../codex/upstream-host-health"; import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context"; import { formatErrorResponse } from "../../bridge"; import { InvalidRetainedCompactionSummary, prepareCompactionRetention } from "./compaction-retention"; +import { createImageContextPreparation } from "./image-context-recovery"; /** Compose request phases while retaining the original admission-finally ownership. */ export async function handleResponsesInner( @@ -55,6 +56,8 @@ export async function handleResponsesInner( const execute = async (): Promise => { const sidecarState = await prepareResponsesSidecarAuth(requestContext, requestState, transportState); if (sidecarState instanceof Response) return sidecarState; + const prepareImageContext = createImageContextPreparation(requestContext, requestState, transportState, requestDispatchers); + if (prepareImageContext instanceof Response) return prepareImageContext; const responseEffects = createResponsesEffects( requestContext, admissionState, @@ -84,6 +87,7 @@ export async function handleResponsesInner( sidecarState, responseEffects, sendBudgetState, + prepareImageContext, ); if (sidecarPlans instanceof Response) return sidecarPlans; const completionPolicy = createResponsesCompletionPolicy(requestContext, sidecarState); @@ -104,6 +108,7 @@ export async function handleResponsesInner( transportState, responseEffects, sendBudgetState, + prepareImageContext, ); if (adapterExchange instanceof Response) return adapterExchange; const continuationState = createAdapterContinuations( diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 7776242db4b..6ec0cc74cdc 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -33,6 +33,7 @@ import { import { redactSecretString } from "../../lib/redact"; import { outboundCredentials } from "../../lib/outbound-credential-registry"; import { compressUpstreamRequestBody, isCanonicalCodexResponsesUrl } from "./request-compression"; +import type { NaturalCacheScope } from "../../anthropic/cache-renewal-runtime"; export { withUpstreamHttpVersion }; @@ -53,6 +54,9 @@ const PHYSICAL_DISPATCH = Symbol.for("opencodex.physical-dispatch"); // Final destination and headers after plugin rewrites and queued rebuilds, for observers that // must attribute a response to the credential that actually reached the wire. const PHYSICAL_SEND_OBSERVER = Symbol.for("opencodex.physical-send-observer"); +// The request owner supplies observation; transport must not load its runtime or watchers. +const NATURAL_CACHE_SCOPE = Symbol("anthropic-natural-cache-scope"); +const RENEWAL_DISPATCH = Symbol("anthropic-cache-renewal-dispatch"); export type AccountAutoPauseObserver = ((response: Response) => Promise) & { physical?: (sameDestination: boolean, headers: Headers) => void; }; @@ -234,16 +238,30 @@ export function sendWithConnectionPolicy( // Final headers, after plugin rewrites: an upstream can only echo what it received here, so the // request log refuses any diagnostic that repeats one of these values (see request-log-terminal-status). outboundCredentials().remember(headers); - return physicalFetch(input, { + const physicalInit = { ...init, ...(compressed ? { body: compressed.body } : largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}), headers, - redirect: "manual", + redirect: "manual" as const, ...(fresh ? { keepalive: false } : {}), ...egressInit, ...(decide || redirectedToLoopback ? { [EGRESS_DECIDED]: true } : {}), ...{ [UPSTREAM_REWRITTEN]: true }, - }); + }; + const renewalDispatch = (init as Record void) | undefined> | undefined)?.[RENEWAL_DISPATCH]; + renewalDispatch?.(); + delete (physicalInit as Record)[RENEWAL_DISPATCH]; + const scope = (init as Record | undefined)?.[NATURAL_CACHE_SCOPE]; + delete (physicalInit as Record)[NATURAL_CACHE_SCOPE]; + if (scope && egress && "adapter" in egress.provider && typeof egress.provider.adapter === "string" + && "baseUrl" in egress.provider && typeof egress.provider.baseUrl === "string") { + const pinned = { ...egress.provider } as OcxProviderConfig; + return scope.observeSend(scope, input, physicalInit, physicalFetch, (url, renewalHeaders, renewalBody, signal, dispatched) => + providerFetch(pinned, undefined, { httpOnly: true, providerName: egress.providerName, + modelId: JSON.parse(renewalBody).model })(url, { method: "POST", headers: renewalHeaders, body: renewalBody, signal, + [RENEWAL_DISPATCH]: dispatched } as RequestInit)); + } + return physicalFetch(input, physicalInit); } interface CodexWsExecutor { provider: OcxProviderConfig; http: typeof globalThis.fetch } @@ -262,6 +280,7 @@ export const codexWsExecutorBinding = (execute: unknown): CodexWsExecutor | unde export interface ProviderFetchOptions { accountAutoPause?: (name: string, provider: OcxProviderConfig, headers: Headers) => AccountAutoPauseObserver | undefined; + anthropicCacheRenewal?: NaturalCacheScope; /** * Keep this send on HTTP even where the WebSocket upstream would normally be selected. * @@ -366,6 +385,7 @@ export function providerFetch( const sharedReceipt = (init as Record void) | undefined> | undefined)?.[WS_FALLBACK_RECEIPT]; let notified = false; const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0, + ...(options.anthropicCacheRenewal ? { [NATURAL_CACHE_SCOPE]: options.anthropicCacheRenewal } : {}), ...(options.onPhysicalDispatch ? { [PHYSICAL_DISPATCH]: () => { if (sharedReceipt) sharedReceipt(); else if (!notified) { options.onPhysicalDispatch!(); notified = true; } diff --git a/src/server/responses/image-context-recovery.ts b/src/server/responses/image-context-recovery.ts new file mode 100644 index 00000000000..51449021d72 --- /dev/null +++ b/src/server/responses/image-context-recovery.ts @@ -0,0 +1,98 @@ +import type { ResponsesRequestContext, ResponsesDispatchers } from "./core-options"; +import type { PreparedResponsesRequest } from "./request-prepare"; +import type { ResponsesTransport } from "./request-transport"; +import { prepareAnthropicImageContext, ImageContextError } from "./anthropic-image-context"; +import { sameProviderContinuationOwner } from "../../responses/provider-continuation"; +import { decodeCompactionSummary } from "../../responses/compaction"; +import { addFinalRequestLog, type RequestLogContext } from "../request-log"; +import { formatErrorResponse } from "../../bridge"; +import type { OcxParsedRequest } from "../../types"; +import type { ProviderAdapter } from "../../adapters/base"; +import { readDisplaySafeErrorText } from "./core-errors"; +import { isNonReplayableResponse } from "../../lib/upstream-retry"; + +export type ImageContextPreparation = (upstreamRejected?: boolean, attempt?: OcxParsedRequest, adapter?: ProviderAdapter) => Promise; + +/** Release the refused send before admitting its summary; retain the original terminal failure. */ +export async function recoverImageContextResponse(response: Response, parsed: OcxParsedRequest, adapter: ProviderAdapter, + prepare: ImageContextPreparation, signal: AbortSignal): Promise<{ response: Response; changed: boolean }> { + if (response.status !== 413 || adapter.name !== "anthropic" || isNonReplayableResponse(response) + || !parsed.context.messages.some(m => Array.isArray(m.content) && m.content.some(p => p.type === "image"))) return { response, changed: false }; + const text = await readDisplaySafeErrorText(response, signal, ""); + const headers = new Headers(response.headers); headers.delete("content-length"); headers.delete("content-encoding"); + const retained = new Response(text, { status: response.status, headers }); + const before = parsed.context.messages; + const refusal = await prepare(true, parsed, adapter); + return { response: refusal ?? retained, changed: !refusal && parsed.context.messages !== before }; +} + +/** Keep image recovery inside the owning request's admission, cancellation and send budget. */ +export function createImageContextPreparation( + { req, config, logCtx, options }: ResponsesRequestContext, + requestState: PreparedResponsesRequest, + transportState: ResponsesTransport, + dispatchers: ResponsesDispatchers, +): Response | ImageContextPreparation { + const summaryContext = options.imageSummaryContext; + if (summaryContext) { + if (!requestState.parsed._providerContinuationOwner + || !sameProviderContinuationOwner(summaryContext.owner, requestState.parsed._providerContinuationOwner)) { + return formatErrorResponse(409, "image_compaction_owner_changed", "Serving account changed before image compaction; retry the request."); + } + requestState.parsed.context.messages = summaryContext.messages; + requestState.parsed._anthropicImageOwner = summaryContext.owner; + } + return async (upstreamRejected = false, attempt = requestState.parsed, adapter = transportState.activeAdapter ?? transportState.adapter) => { + if (summaryContext || adapter.name !== "anthropic" || upstreamRejected && attempt._compactionRequest) return; + try { + await prepareAnthropicImageContext({ parsed: attempt, adapter, upstreamRejected, + incoming: { headers: requestState.selectedForwardHeaders, providerName: requestState.route.providerName, + translatorBudget: options.translatorBudget, abortSignal: options.abortSignal }, + async summarize(context) { + const headers = new Headers(req.headers); + headers.delete("content-length"); headers.delete("content-encoding"); + const raw = requestState.parsed._rawBody as Record; + const body: Record = { ...raw, model: `${requestState.route.providerName}/${requestState.route.modelId}`, + stream: false, store: false, input: [{ type: "compaction_trigger" }] }; + delete body.previous_response_id; + const started = Date.now(); + const childLog: RequestLogContext = { model: requestState.route.modelId, provider: requestState.route.providerName, + conversationId: logCtx.conversationId }; + let status = 502; + let succeeded = false; + try { + const child = new Request(req.url, { method: "POST", headers, body: JSON.stringify(body), signal: options.abortSignal }); + const response = await dispatchers.handleResponses(child, config, childLog, { + translatorBudget: options.translatorBudget, sendBudget: options.sendBudget, + abortSignal: options.abortSignal, turnAdmissionLease: options.turnAdmissionLease, + admission: options.admission, openAiSidecarAuth: options.openAiSidecarAuth, + nativeCallerAuth: options.nativeCallerAuth, callerDirectAuth: options.callerDirectAuth, + internalDecisionCall: true, imageSummaryContext: context, recordTerminalOutcomes: false, + compactionRecoveryAttempted: true, inboundTransport: options.inboundTransport, + }); + status = response.status; + const result = await response.json() as { status?: unknown; output?: Array<{ type?: string; encrypted_content?: unknown }> }; + const items = Array.isArray(result.output) ? result.output : []; + const compactions = items.filter(item => item.type === "compaction"); + const text = compactions.length === 1 && typeof compactions[0]!.encrypted_content === "string" + ? decodeCompactionSummary(compactions[0]!.encrypted_content) : null; + if (!response.ok || result.status !== "completed" || !text?.trim() + || items.some(item => !["compaction", "reasoning"].includes(item.type ?? ""))) { + status = response.ok ? 502 : response.status; + throw new ImageContextError("Image compaction failed to return a completed text-only summary.", status); + } + succeeded = true; + return text; + } finally { + if (!succeeded) childLog.errorCode ??= "image_compaction_summary"; + addFinalRequestLog(`ocx-${crypto.randomUUID().replaceAll("-", "")}`, started, childLog, status); + } + }, + }); + } catch (error) { + if (options.abortSignal?.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled image compaction."); + if (error instanceof ImageContextError) return formatErrorResponse(error.status, "image_compaction_failed", error.message); + throw error; + } + }; +} diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 9830fef7e09..ff74ffcdeff 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -224,7 +224,7 @@ export async function prepareResponsesRequest( ) { const { options, config, req, logCtx } = requestContext; const policyScope = options.policyRequestScope ?? { triedDestinations: new Set() }; - const concreteSelection = options.comboAttempt || options.policyFallbackCandidate !== undefined; + const concreteSelection = options.comboAttempt || options.policyFallbackCandidate !== undefined || options.imageSummaryContext !== undefined; // The Chat and Anthropic surfaces replay through here with a Responses-shaped body, // so an omitted value means a genuine Responses inbound. @@ -249,6 +249,10 @@ export async function prepareResponsesRequest( options.promptCacheKeyIsSharedCohort === true ? "system-derived" : options.promptCacheKeyIsSharedCohort === false ? "metadata-derived" : "caller", + { endpoint: inboundWire === "anthropic" ? "messages" : inboundWire === "chat" ? "chat-completions" : "responses", + transport: options.inboundTransport, + internalHelper: !!(options.internalDecisionCall || options.memoryModelPhase || options.shadowCallIntercepted + || options.visionDescribeTerminal || options.advisorInternal) }, ); if (!concreteSelection && !options.compactionRoutingOverride && inboundWire === "responses") { options.compactionRoutingOverride = applyCompactionRoutingOverride(body, req.headers, config, { diff --git a/src/server/responses/request-sidecar-auth.ts b/src/server/responses/request-sidecar-auth.ts index 63a00d9655e..358fed809a8 100644 --- a/src/server/responses/request-sidecar-auth.ts +++ b/src/server/responses/request-sidecar-auth.ts @@ -52,13 +52,17 @@ export async function prepareResponsesSidecarAuth( const visionDescribeTerminal = options.visionDescribeTerminal === true; const routedCompaction = parsed._compactionRequest === true && (!supportsNativeResponsesCompactionTrigger(route.provider) || parsed._portableCompaction === true); - if (routedCompaction) parsed.context.messages = omitEarlierCompactionImages(parsed.context.messages); + const preserveCompactionPrefix = routedCompaction && transportState.adapter.name === "anthropic"; + if (options.imageSummaryContext) return { openAiSidecar, routedCompaction, preserveCompactionPrefix }; + if (routedCompaction && !preserveCompactionPrefix) { + parsed.context.messages = omitEarlierCompactionImages(parsed.context.messages); + } const needsOpenAiVision = !visionDescribeTerminal && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName); // LOCAL PATCH (runturn-websearch): runTurn adapters can run the web-search // sidecar through src/web-search/run-turn-loop.ts, so resolve the helper // credential for them too. The passthrough bridge stays fetch-only. - const needsOpenAiSearch = !routedCompaction + const needsOpenAiSearch = (!routedCompaction || preserveCompactionPrefix) && (transportState.adapter.runTurn ? shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough) : (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough) @@ -158,6 +162,7 @@ export async function prepareResponsesSidecarAuth( return { openAiSidecar, routedCompaction, + preserveCompactionPrefix, }; } diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index e901594e401..aa53e2556b8 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -10,7 +10,7 @@ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { contextRelayActivated } from "../../codex/context-compat"; import { recordContextSessionOwner } from "../../codex/context-owner"; import { resolveContextPrincipal } from "../auth-cors"; -import { COMPACT_PROMPT } from "../../responses/compaction"; +import { COMPACT_PROMPT, CHECKPOINT_CONTINUATION_NOTE, decodeCompactionSummary, isCompactionItemType } from "../../responses/compaction"; import type { RoutedNamespaceToolAliases } from "../../responses/namespace-tool-compat"; import type { MuseToolNameAliases } from "../../responses/muse-tool-name-alias"; import type { AdapterRequest } from "../../adapters/base"; @@ -26,11 +26,11 @@ export function createResponsesEffects( | "route" | "substituteMainCredential" >, - sidecarState: Pick, + sidecarState: Pick, ) { const { options, req, config } = requestContext; const { parsed, poolAffinityKey, route, substituteMainCredential } = requestState; - const { routedCompaction } = sidecarState; + const { routedCompaction, preserveCompactionPrefix: preservePrefix } = sidecarState; const recordTerminalOutcomes = options.recordTerminalOutcomes !== false; @@ -67,8 +67,8 @@ export function createResponsesEffects( // Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly // one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it - // natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search - // sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts). + // natively upstream; here the routed model writes a summary, with no sidecar execution. + // The bridge appends the synthetic compaction item (src/responses/compaction.ts). // A Responses-shaped wire does not imply support for Codex's private // `compaction_trigger` item — only the canonical ChatGPT backend speaks that // contract. An API-key gateway would receive the trigger, answer with an ordinary @@ -87,12 +87,27 @@ export function createResponsesEffects( } }; if (routedCompaction) { - delete parsed.context.tools; - delete parsed._webSearch; - delete parsed._anthropicServerTools; - delete parsed._anthropicServerReplay; - delete parsed.options.toolChoice; - delete parsed.options.parallelToolCalls; + if (!preservePrefix) { + delete parsed.context.tools; + delete parsed.options.toolChoice; + delete parsed.options.parallelToolCalls; + delete parsed._webSearch; + delete parsed._anthropicServerTools; + delete parsed._anthropicServerReplay; + } + // Client tools are blocked by the summary guard without changing the cache options. + // Hosted tools execute upstream, so these declarations require an explicit prohibition. + if (preservePrefix && parsed._anthropicServerTools?.length) parsed.options.toolChoice = "none"; + const input = (parsed._rawBody as { input?: unknown } | undefined)?.input; + if (preservePrefix && Array.isArray(input) + && input.some((item: unknown) => { + if (!item || typeof item !== "object") return false; + const checkpoint = item as { type?: unknown; encrypted_content?: unknown }; + return isCompactionItemType(checkpoint.type) && typeof checkpoint.encrypted_content === "string" + && decodeCompactionSummary(checkpoint.encrypted_content) !== null; + })) { + (parsed.context.systemPrompt ??= []).push(CHECKPOINT_CONTINUATION_NOTE); + } // The compaction turn is a plain prose summary; a surviving structured-output format // would force schema-constrained JSON into the synthetic compaction item. The flag and // the raw `text` control go too: the key-mode openai-responses adapter builds from @@ -105,7 +120,9 @@ export function createResponsesEffects( } parsed.context.messages = [ ...parsed.context.messages, - { role: "user", content: COMPACT_PROMPT, timestamp: Date.now() }, + { role: "user", content: preservePrefix + ? `${COMPACT_PROMPT}\n\nReturn only the handoff summary. Do not call tools or continue the task.` + : COMPACT_PROMPT, timestamp: Date.now() }, ]; } diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index c9fbea9a167..1aa8f9674bb 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -7,17 +7,14 @@ import type { ResponsesSidecarAuth } from "./request-sidecar-auth"; import type { ResponsesEffects } from "./response-effects"; import type { ResponsesSendBudget } from "./request-send-budget"; import { formatErrorResponse } from "../../bridge"; -import { planWebSearch, buildWebSearchTool, runWithWebSearch } from "../../web-search"; +import { planWebSearch, runWithWebSearch } from "../../web-search"; +import { applySidecarToolSurface, type SidecarToolSurface } from "./sidecar-tool-surface"; import { createRegisteredAdvisorPlan } from "./advisor-plan-slot"; import { ADVISOR_TOOL_NAME } from "./advisor-slot"; import { buildToolBridgeMaps } from "./collaboration"; import { planImageBridge, planVideoBridge, - IMAGE_GEN_TOOL_NAME, - buildImageTool, - VIDEO_GEN_TOOL_NAME, - buildVideoTool, runWithImageBridge, clampImageMaxRounds, } from "../../images"; @@ -47,7 +44,6 @@ import { } from "../../oauth/anthropic-routing"; import { resolveWireProtocolOverride } from "../adapter-resolve"; import { bindRouteReasoningReplayScope, adapterNeedsForcedContinuation, adapterNeedsToolCallContinuation } from "./core-replay"; -import { namespacedToolName } from "../../types"; import { providerFetch } from "./fetch-helpers"; import type { AttemptRecoveryKind } from "../../usage/log"; import { recordAdapterReasoning, recordAdapterTier } from "../request-log"; @@ -57,11 +53,13 @@ import { rememberResponseState } from "../../responses/state"; import { trackStreamLifetime } from "../lifecycle"; import { isAntigravityRawValidationRefusal } from "./antigravity-validation-refusal"; import { createSidecarSendBudget } from "./sidecar-send-budget"; +import { naturalCacheScope } from "../../anthropic/cache-renewal-runtime"; import { stripReplayedSearchPairs } from "../../web-search/sidecar-search-replay"; +import { recoverImageContextResponse, type ImageContextPreparation } from "./image-context-recovery"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function executeResponsesSidecars( - requestContext: Pick, + requestContext: Pick, requestState: Pick< PreparedResponsesRequest, | "parsed" @@ -95,7 +93,7 @@ export async function executeResponsesSidecars( | "noteRoutedAttemptSend" | "bindKeyUsageFromBridge" >, - sidecarState: Pick, + sidecarState: Pick, responseEffects: Pick< ResponsesEffects, | "commitReasoningReplayServingRoute" @@ -104,6 +102,7 @@ export async function executeResponsesSidecars( | "cancelResponseCompletion" >, sendBudgetState: Pick, + prepareImageContext?: ImageContextPreparation, ) { const { config, options, logCtx } = requestContext; const antigravityPoolActivated = requestState.route.providerName === "google-antigravity" @@ -126,9 +125,19 @@ export async function executeResponsesSidecars( rememberKiroDeliveredFinalAnswer, responseStateOptions, } = requestState; - const { routedCompaction, openAiSidecar } = sidecarState; + const { routedCompaction, preserveCompactionPrefix, openAiSidecar } = sidecarState; const { reserveCredentialHop } = sendBudgetState; const sidecarBudget = createSidecarSendBudget(options, sendBudgetState, () => logCtx.usageLogInputTokens); + const recoverImageContext = prepareImageContext ? (response: Response, attempt: OcxParsedRequest, adapter: ProviderAdapter, signal: AbortSignal) => + sendBudgetState.sendBudgetExhausted() ? Promise.resolve({ response, changed: false }) + : recoverImageContextResponse(response, attempt, adapter, prepareImageContext, signal) : undefined; + // These executors send the routed primary model; search/media helper sends own separate policy. + const primaryFetchOptions = (adapter: ProviderAdapter, requestParsed = parsed) => ({ + ...sidecarBudget.fetchOptions(adapter), + anthropicCacheRenewal: naturalCacheScope({ ...requestContext, parsed: requestParsed, + adapterName: adapter.name, providerName: route.providerName, + oauth: route.provider.authMode === "oauth", conversationId: logCtx.conversationId }), + }); const { commitReasoningReplayServingRoute, continuationStateForResponse, @@ -180,47 +189,50 @@ export async function executeResponsesSidecars( } } - // Advisor sidecar plan (optional subsystem; null when disabled, unconfigured, or when this - // request IS an advisor loopback consultation — the recursion fence). The plan carries - // request-scoped state only; preflight keeps fixed policy separate from user-role advice - // before the worker is dispatched. Registration seam: the core path sees only - // `parsed._advisorGuard`; only an activated factory can load the optional implementation. - const advisorPlan = options.advisorInternal === true - ? null - : await createRegisteredAdvisorPlan({ - config, - workerIdentity: `${route.modelId} (provider ${route.providerName})`, - workerModelId: route.modelId, - abortSignal: options.abortSignal, - }); - // Preflight only on real worker turns: a routed-compaction turn is a summarization request, - // and injecting advice into it would pollute the summary Codex replaces its history with. - if (advisorPlan && !routedCompaction) { - await advisorPlan.preflightInject(parsed); + if (options.imageSummaryContext) { + parsed.context.tools = options.imageSummaryContext.tools; + parsed.context.systemPrompt = options.imageSummaryContext.systemPrompt; + return undefined; } - - // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. - // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but - // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses - // completion instead of the synthetic compaction item Codex expects (#424). - // - // Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending - // Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So: - // - non-runTurn: web-search wins over image when both eligible (documented priority) - // - runTurn: a search plan takes priority over media bridges and is executed - // by executeResponsesRunTurn; the fetch loop below remains fetch-only. - // LOCAL PATCH (runturn-websearch): runTurn adapters run their own web-search - // loop inside executeResponsesRunTurn (src/web-search/run-turn-loop.ts); the - // fetch-path loop below stays non-runTurn-only. - const wsPlan = !routedCompaction - ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, { - admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, providerName: route.providerName, - }) - : undefined; - const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; - // Replayed search pairs call the synthetic web_search tool only the sidecar declares (#7269). - if (!wsPlan) stripReplayedSearchPairs(parsed); - const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; + let advisorPlan: Awaited>; + let wsPlan: ReturnType; + let imgPlan: Awaited>; + let vidPlan: Awaited>; + try { + advisorPlan = options.advisorInternal === true + ? null + : await createRegisteredAdvisorPlan({ + config, + workerIdentity: `${route.modelId} (provider ${route.providerName})`, + workerModelId: route.modelId, + abortSignal: options.abortSignal, + }); + if (advisorPlan && !routedCompaction) await advisorPlan.preflightInject(parsed); + if (!routedCompaction || preserveCompactionPrefix) { + wsPlan = planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, { + admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, providerName: route.providerName, + }); + imgPlan = await planImageBridge(config, parsed, route.provider); + // Replayed search pairs require the synthetic tool declared by the search sidecar. + if (!wsPlan) stripReplayedSearchPairs(parsed); + vidPlan = await planVideoBridge(config, parsed, route.provider); + const surface: SidecarToolSurface = wsPlan + ? transportState.adapter.runTurn ? { kind: "none" } : { kind: "web-search" } + : (imgPlan || vidPlan) && (parsed.stream || preserveCompactionPrefix) + ? { kind: "media", image: imgPlan, video: vidPlan } + : advisorPlan && !transportState.adapter.runTurn && !imgPlan && !vidPlan + ? { kind: "advisor", tool: advisorPlan.tool } + : { kind: "none" }; + applySidecarToolSurface(parsed, surface); + } + } finally { + if (routedCompaction) releaseSearchProbeLease(); + } + try { + const refusal = await prepareImageContext?.(); + if (refusal) { releaseSearchProbeLease(); return refusal; } + } catch (error) { releaseSearchProbeLease(); throw error; } + if (routedCompaction) return undefined; const canRunWebSearch = !!wsPlan && !transportState.adapter.runTurn; let sidecarOutputStarted = false; const sidecarHasCommittedOutput = () => sidecarOutputStarted @@ -517,42 +529,13 @@ export async function executeResponsesSidecars( } // Video-only: skip bridge for non-streaming requests } else { - // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names. - const priorTools = parsed.context.tools ?? []; - const bridgeTools = [...priorTools.filter(t => { - if (t.imageGeneration) return false; - if (t.videoGeneration) return false; - if (imgPlan && imgPlan.toolNames.has(t.name)) return false; - if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; - // Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone. - if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false; - return true; - })]; - const existingNames = new Set(bridgeTools.map(t => t.name)); - if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool()); - if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool()); - parsed.context.tools = bridgeTools; - // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name. - // Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting - // image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject. - const tc = parsed.options.toolChoice; - if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) { - const mapped = tc.allowedTools.map(name => - name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false) - ? IMAGE_GEN_TOOL_NAME - : name, - ); - parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] }; - } else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string" - && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) { - parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME }; - } const imageProviderFetch = providerFetch( route.provider, options.codexWsRuntimeIdentity, - { providerName: route.providerName, modelId: route.modelId, ...sidecarBudget.fetchOptions(transportState.adapter) }, + { providerName: route.providerName, modelId: route.modelId, ...primaryFetchOptions(transportState.adapter) }, ); const imgLoopResponse = await runWithImageBridge({ + recoverImageContext, parsed, adapter: transportState.adapter, ...(transportState.adapter.name === "anthropic" ? { thinkingRecovery } : {}), clientToolCatalogRestriction: route.provider.authMode === "forward" ? undefined : requestState.clientToolCatalogRestriction, @@ -578,7 +561,7 @@ export async function executeResponsesSidecars( fetchForRequest: (request, iterParsed, adapter, recovery) => { const fetch = providerFetch(route.provider, options.codexWsRuntimeIdentity, { dispatchOverride: oauthDispatch(request, iterParsed), - providerName: route.providerName, modelId: route.modelId, ...sidecarBudget.fetchOptions(adapter), + providerName: route.providerName, modelId: route.modelId, ...primaryFetchOptions(adapter, iterParsed), ...thinkingReceipt(request, adapter, recovery), }); return fetch.unpacedFetch ?? fetch; @@ -646,23 +629,23 @@ export async function executeResponsesSidecars( // Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch // through web-search instead of being swallowed. runTurn adapters never enter this branch. if (canRunWebSearch && wsPlan) { - parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()]; // Resolve the mutable route at send time: a 429 rotation replaces route.provider, so retaining // one pre-rotation providerFetch would keep the old credential and transport pin. const routedProviderFetch = ((input: Parameters[0], init?: RequestInit) => providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, - ...sidecarBudget.fetchOptions(transportState.adapter), + ...primaryFetchOptions(transportState.adapter), })(input, init)) as typeof globalThis.fetch; const loopResponse = await runWithWebSearch({ + recoverImageContext, parsed, adapter: transportState.adapter, ...(transportState.adapter.name === "anthropic" ? { thinkingRecovery } : {}), clientToolCatalogRestriction: route.provider.authMode === "forward" ? undefined : requestState.clientToolCatalogRestriction, onIterationEnd: sidecarBudget.releaseUnsentHop, fetchForRequest: (request, iterParsed, adapter, recovery) => providerFetch(route.provider, options.codexWsRuntimeIdentity, { dispatchOverride: oauthDispatch(request, iterParsed), - providerName: route.providerName, modelId: route.modelId, ...sidecarBudget.fetchOptions(adapter), + providerName: route.providerName, modelId: route.modelId, ...primaryFetchOptions(adapter, iterParsed), ...thinkingReceipt(request, adapter, recovery), }), incomingMeta: { @@ -763,13 +746,7 @@ export async function executeResponsesSidecars( // run-turn adapter, not compaction). The web-search/image loops own their own event handling; // run-turn adapters execute their own loop; both would leak the synthetic tool call they // cannot intercept. Preflight (above) still applies to those paths — only the tool does not. - if (advisorPlan && !routedCompaction && !transportState.adapter.runTurn && !wsPlan && !imgPlan && !vidPlan) { - // A client-declared tool named "advisor" would collide with the synthetic injection (one - // wire name, two schemas); the synthetic runtime owns the name for this turn. - parsed.context.tools = [ - ...(parsed.context.tools ?? []).filter(t => !t.advisor && t.name !== ADVISOR_TOOL_NAME), - advisorPlan.tool, - ]; + if (advisorPlan && !transportState.adapter.runTurn && !wsPlan && !imgPlan && !vidPlan) { // The advisor tool joined AFTER prepare computed the bridge maps; recompute so the tool is // declared (undeclared-tool guard, tool_choice mapping, schema repair) on this turn. // diff --git a/src/server/responses/sidecar-tool-surface.ts b/src/server/responses/sidecar-tool-surface.ts new file mode 100644 index 00000000000..abc9b0ddab1 --- /dev/null +++ b/src/server/responses/sidecar-tool-surface.ts @@ -0,0 +1,48 @@ +import type { OcxParsedRequest, OcxTool } from "../../types"; +import { namespacedToolName } from "../../types"; +import { buildWebSearchTool } from "../../web-search/synthetic-tool"; +import { buildImageTool, buildVideoTool, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images/synthetic-tool"; + +type MediaToolPlan = { toolNames: ReadonlySet }; + +export type SidecarToolSurface = + | { kind: "none" } + | { kind: "web-search" } + | { kind: "media"; image?: MediaToolPlan; video?: MediaToolPlan } + | { kind: "advisor"; tool: OcxTool }; + +export function applySidecarToolSurface(parsed: OcxParsedRequest, surface: SidecarToolSurface): void { + if (surface.kind === "none") return; + if (surface.kind === "web-search") { + parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()]; + return; + } + if (surface.kind === "advisor") { + parsed.context.tools = [ + ...(parsed.context.tools ?? []).filter(tool => !tool.advisor && tool.name !== surface.tool.name), + surface.tool, + ]; + return; + } + const { image, video } = surface; + const tools = (parsed.context.tools ?? []).filter(tool => { + if (tool.imageGeneration || tool.videoGeneration) return false; + if (image?.toolNames.has(tool.name)) return false; + if (image && tool.namespace && image.toolNames.has(namespacedToolName(tool.namespace, tool.name))) return false; + if (video && !tool.namespace && video.toolNames.has(tool.name)) return false; + return true; + }); + const names = new Set(tools.map(tool => tool.name)); + if (image && !names.has(IMAGE_GEN_TOOL_NAME)) tools.push(buildImageTool()); + if (video && !names.has(VIDEO_GEN_TOOL_NAME)) tools.push(buildVideoTool()); + parsed.context.tools = tools; + const choice = parsed.options.toolChoice; + if (image && choice && typeof choice === "object" && "allowedTools" in choice && Array.isArray(choice.allowedTools)) { + const mapped = choice.allowedTools.map(name => + name === "image_generation" || name === "image_gen" || image.toolNames.has(name) ? IMAGE_GEN_TOOL_NAME : name); + parsed.options.toolChoice = { ...choice, allowedTools: [...new Set(mapped)] }; + } else if (image && choice && typeof choice === "object" && "name" in choice && typeof choice.name === "string" + && (choice.name === "image_generation" || image.toolNames.has(choice.name))) { + parsed.options.toolChoice = { ...choice, name: IMAGE_GEN_TOOL_NAME }; + } +} diff --git a/src/types/config.ts b/src/types/config.ts index 479895aa2c4..e5e16770c42 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1097,6 +1097,8 @@ export interface OcxConfig { * Anthropic-compatible endpoints (adapters/anthropic/cache-retention.ts). */ cacheRetention?: "none" | "short" | "long"; + /** Opt-in zero-output Anthropic cache reads while a verified Codex turn is running. Default false. */ + anthropicCacheRenewal?: boolean; /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */ webSearchSidecar?: OcxWebSearchSidecarConfig; /** diff --git a/src/types/request.ts b/src/types/request.ts index ef1577f3b27..3725c1102cb 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -105,6 +105,7 @@ export interface OcxParsedRequest { * parent. Only a surface that must distinguish siblings should read it. */ _codexOwnThreadId?: string; + _anthropicImageOwner?: OcxProviderContinuationOwner; /** True when promptCacheKey identifies a shared cache cohort rather than one conversation. */ _promptCacheKeyIsSharedCohort?: boolean; /** Cursor-only thread owner; may be an opaque process-local Desktop session/thread identity. */ diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 43baa4c57db..0b74ab8d2da 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -369,6 +369,7 @@ export interface WebSearchLoopDeps { onRequestBuilt?: (request: AdapterRequest) => void; /** Request-scoped executor retains the core's selection binding across loop retries. */ thinkingRecovery?: ReturnType; + recoverImageContext?: (response: Response, parsed: OcxParsedRequest, adapter: ProviderAdapter, signal: AbortSignal) => Promise<{ response: Response; changed: boolean }>; fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => typeof globalThis.fetch; /** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */ onAttemptSend?: (recovery?: AttemptRecoveryKind) => void; @@ -432,6 +433,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise(); const loopT0 = Date.now(); const allTools = adapter.name === "anthropic" ? anthropicDeclaredTools(parsed) : parsed.context.tools ?? []; @@ -736,6 +738,17 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise {}); } catch { /* closed */ } cachedRequest = undefined; diff --git a/src/web-search/progress-stream.ts b/src/web-search/progress-stream.ts index 58d4cfdceca..8472a2e96e1 100644 --- a/src/web-search/progress-stream.ts +++ b/src/web-search/progress-stream.ts @@ -1,5 +1,6 @@ import type { ProviderAdapter } from "../adapters/base"; import type { AdapterEvent, OcxUsage } from "../types"; +import { inheritCacheObservation } from "../lib/anthropic-cache-observation"; import { isTranslatorBudgetExceededError, TRANSLATOR_MAX_TURN_BYTES, @@ -249,11 +250,11 @@ export async function* parseStreamWithProgress( }, }, { highWaterMark: 0 }); - const tappedResponse = new Response(tappedBody, { + const tappedResponse = inheritCacheObservation(response, new Response(tappedBody, { status: response.status, statusText: response.statusText, headers: response.headers, - }); + })); resetInactivity(); diff --git a/structure/INDEX.md b/structure/INDEX.md index e4982e65274..23fca28f655 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -73,6 +73,7 @@ Per-vendor contracts and the adapter authority that constructs them. | Doc | Scope | | --- | --- | +| [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | Opt-in running-turn cache renewal, stable historical images and bounded overflow compaction. | | [`providers/generic-account-pool.md`](providers/generic-account-pool.md) | Opt-in conversation ownership, request-local selection receipts and bounded release for generic OAuth pools. | | [`providers/anthropic-progress.md`](providers/anthropic-progress.md) | Opus 5.5 progress display, visible commentary, and exact signed-block replay. | | [`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md) | Account-owned usage thresholds, inheritance, routing boundaries and durable policy changes. | @@ -182,6 +183,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/adapters/workbuddy-schema.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) | | `src/adapters/workbuddy.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) | | `src/advisor/` | [`advisor.md`](advisor.md) | +| `src/anthropic/` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) | | `src/bridge/` | [`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md) | | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | @@ -206,6 +208,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/app-server-processes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/auth-api/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/codex/auth-api/pool-mode-gate.ts` | [`providers/pool-credit-renewal.md`](providers/pool-credit-renewal.md) | +| `src/codex/cache-runtime-state.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/codex/catalog/effort.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md)
[`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/catalog/model-hints.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/ownership.ts` | [`catalog-routed-ownership.md`](catalog-routed-ownership.md) | @@ -214,8 +217,11 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/catalog/vendor-context.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/cli-update-plan.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/provider-routing.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | +| `src/codex/request-provenance.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | +| `src/codex/rollout-cache-runtime.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/codex/runtime.ts` | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/signed-in-identity.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | +| `src/codex/thread-writer-lock.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | @@ -236,6 +242,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-spend.md`](transports/responses-spend.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/lib/account-auto-pause-response.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/lib/advisor-activation.ts` | [`advisor.md`](advisor.md) | +| `src/lib/anthropic-cache-observation.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/lib/decision-quota-sink.ts` | [`providers/jev-decision.md`](providers/jev-decision.md) | | `src/lib/gui-pair-intent.ts` | [`remote-link.md`](remote-link.md) | | `src/lib/windows-owner-acl.ts` | [`remote-link.md`](remote-link.md) | @@ -276,6 +283,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/remote-control/` | [`remote-workspace.md`](remote-workspace.md) | | `src/remote/` | [`runtime.md`](runtime.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/responses/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/kiro.md`](providers/kiro.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | +| `src/responses/compaction-summary.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/responses/reasoning-replay-cache.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | | `src/router.ts` | [`runtime.md`](runtime.md) | | `src/routing/` | [`catalog.md`](catalog.md)
[`fallback-policy.md`](fallback-policy.md) | @@ -291,10 +299,13 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/link-routes.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | | `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) | +| `src/server/request-origin.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/server/responses/account-auto-pause-recovery.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/server/responses/advisor-plan-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/advisor-slot.ts` | [`advisor.md`](advisor.md) | +| `src/server/responses/anthropic-image-context.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/server/responses/anthropic-thinking-recovery.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | +| `src/server/responses/image-context-recovery.ts` | [`providers/anthropic-cache-lifecycle.md`](providers/anthropic-cache-lifecycle.md) | | `src/server/responses/opengateway-completion-guard.ts` | [`providers/opengateway.md`](providers/opengateway.md) | | `src/server/responses/passthrough-completion.ts` | [`providers/opengateway.md`](providers/opengateway.md) | | `src/server/responses/passthrough-dispatch.ts` | [`providers/opengateway.md`](providers/opengateway.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 3f6079e5566..7d6279d3b99 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -47,6 +47,13 @@ } ], "docs": [ + { + "path": "providers/anthropic-cache-lifecycle.md", + "tier": 4, + "title": "Anthropic Cache Lifecycle", + "scope": "Opt-in running-turn cache renewal, stable historical images and bounded overflow compaction.", + "documents": ["src/anthropic/", "src/codex/cache-runtime-state.ts", "src/codex/rollout-cache-runtime.ts", "src/codex/thread-writer-lock.ts", "src/codex/request-provenance.ts", "src/server/responses/anthropic-image-context.ts", "src/server/responses/image-context-recovery.ts", "src/responses/compaction-summary.ts", "src/server/request-origin.ts", "src/lib/anthropic-cache-observation.ts"] + }, { "path": "providers/generic-account-pool.md", "tier": 4, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a81d0790042..b53960bc161 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -7,7 +7,7 @@ Claude CLI replay, client tool capture and usage admission follow [Claude CLI re API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint. -Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). +Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). Stable historical image encoding, overflow compaction and opt-in running-turn renewal follow the [Anthropic cache lifecycle](providers/anthropic-cache-lifecycle.md). A shared-quota Anthropic 429 or classified pre-output account 403 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling for 429 is skipped only after the matching account cooldown is present. Exact revoked-token 401s persist generation-fenced reauthentication under [the Anthropic recovery contract](providers/anthropic-account-pool.md#revoked-oauth-access-token-recovery). The Anthropic helper sends share the same routing authority: `getAnthropicSidecarAccessToken` resolves the vision-describe and web-search sidecars' helper model through the same first-match route decision, so a routed send authenticates as the route's own account rather than whatever pool account happens to be active. A strict route with no eligible account fails the helper locally instead of silently falling back to the active outsider, matching the primary-traffic contract; callers without a pool config keep the plain stored-credential path. @@ -213,7 +213,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | -| `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | +| `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. Responses uses encoding based on the image itself and [cache lifecycle recovery](providers/anthropic-cache-lifecycle.md#stable-images-and-overflow-recovery), without progressively degrading historical images. Native Messages explicitly retains its legacy normalization policy. | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | `src/adapters/devin/trajectory.ts` owns up to 256 process-local conversation trajectories, keyed by a digest of resolved credential identity, tenant host and named conversation. Own-thread identity takes precedence over session aliases. When a nonempty parent header accompanies an own-thread identity, the tagged parent/own pair identifies the conversation; standalone identities have a distinct tag, so a JSON-like caller string cannot alias a child tuple. Session-only keys retain their existing behavior, and a parent-only request does not borrow its parent's trajectory. Sequential turns reuse an inactive entry across request-scoped adapters; overlapping turns and a store full of active entries receive fresh unretained IDs. Only inactive entries are evicted, and a claim's idempotent release runs in the adapter's `finally` on success, error and cancellation. The optional trajectory travels through `src/adapters/devin/cloud-direct/chat.ts` to protobuf field #15.1; unnamed calls retain per-request allocation. A named claim remains held through the turn's existing reset/signature retries. This is identifier continuity, not proof of upstream cache hits. diff --git a/structure/providers/anthropic-cache-lifecycle.md b/structure/providers/anthropic-cache-lifecycle.md new file mode 100644 index 00000000000..ae7072e5447 --- /dev/null +++ b/structure/providers/anthropic-cache-lifecycle.md @@ -0,0 +1,97 @@ +# Anthropic Cache Lifecycle + +`src/anthropic/cache-renewal.ts` owns confirmed five-minute prompt prefixes and their renewal +clock. `src/anthropic/cache-renewal-runtime.ts` binds that owner to Responses transport, Codex +runtime state and usage logging. This does not change native Codex or native Messages passthrough. +The request owner supplies its observation callback to `src/server/responses/fetch-helpers.ts`; +the transport invokes it at the final send without importing the renewal runtime or starting watchers. + +## Running-turn renewal + +The optional top-level `anthropicCacheRenewal` boolean defaults off. Only explicit `true` +enables background sends; existing `cacheRetention` choices remain authoritative. One-hour, +disabled, externally controlled or incompatible cache requests do not opt into renewal. +First-party Anthropic now defaults omitted retention to one hour; select `cacheRetention: "short"` explicitly to use five-minute renewal. +Configured root, identity or pool spend ceilings disable renewal, including a ceiling added after +confirmation. Detached renewals do not yet reserve their original workflow scopes. Empty spend +scopes and retention-only accounting stay observational; normal sends retain existing admission. + +`src/codex/request-provenance.ts` derives the request's own thread and turn from HTTP or +per-frame WebSocket metadata. Parent conversation identity is not an own-thread fallback. +Main and spawned turns follow the same policy; decision, memory and vision helpers do not. +`src/codex/cache-runtime-state.ts` reads the existing local IPC state. When IPC cannot identify +a headless child, `src/codex/rollout-cache-runtime.ts` reads that child's native lifecycle and +`src/codex/thread-writer-lock.ts` verifies the Windows writer lock. Other platforms without +usable IPC fail closed; the default endpoint currently supports Windows only. Known large compacted/context records do not imply completion; +unknown records, missing locks, changed turns and explicit terminal events stop renewal. + +The clock starts at physical send, after complete valid upstream cache usage confirms a +prefix. At 270 seconds, the manager checks the same active turn and sends the same full +prefix with zero output tokens. It changes no tool definitions, historical messages or +credential. The request pins destination, model and credential; OAuth ownership is checked +again before dispatch. This is a billable cache-read request, not a free heartbeat. + +Natural usage observations replace the confirmed prefix and reset its clock. During a long +generation the previously confirmed prefix remains eligible until the new request's early +usage arrives. `src/lib/anthropic-cache-observation.ts` carries the observer through response +wrappers, including `src/web-search/progress-stream.ts`; it does not consume a second stream. +Incompatible requests clear old eligibility. Completion, cancellation, unknown runtime state, +expired confirmation and disabled config stop further sends. + +Retries are bounded by attempts and remaining confirmed lifetime. Ambiguous post-send failure +is not a confirmed renewal or zero cost. Renewal never rotates accounts, publishes an answer, +executes a tool or appends anything to client history. Diagnostic records correlate request +ids with allowlisted thread/turn/role, phase, counters and fixed network reasons; they contain +no prompt, image, credential or raw exception. `src/server/request-origin.ts` separately +correlates natural request logs, keeping missing/conflicting provenance unknown. +Generic reasoning replay retains its existing identity and rejection-memo contracts. Renewal +and image checkpoints use their independent own-thread identity; they do not change generic replay. +Terminal continuations select Anthropic SSE parsing from the actual response content type even +for a non-stream client. Both initial and continuation 413 paths refuse unchanged image resends. + +## Stable images and overflow recovery + +`src/adapters/anthropic-image-normalize.ts` chooses encoding from the image itself, not its age, +aggregate pressure or retry number. A repeated image is deterministic even after cache eviction. +Single-image validation and decoding protections remain in place. Native Messages lanes explicitly +retain their legacy age tiers; they do not own Responses compaction. The actual Anthropic adapter +uses the non-mutating assertion in `src/adapters/anthropic-image-guard.ts`: 26 MiB aggregate +base64, the existing image-count/dimension limits, and 32,000,000 bytes for the complete +serialized request, including system, tools and text. The 26 MiB allowance also applies to the +legacy native Messages guard; its age tiers and mutating handling otherwise stay unchanged. +An upstream 413 is never retried with historical pictures degraded. + +`src/server/responses/anthropic-image-context.ts` partitions history only at complete tool +call/result boundaries. Image-free requests skip this preflight rather than building a second +request or touching continuation-owner state. On local overflow it summarizes the largest fitting old prefix and +retains the newest indivisible unit, including the latest image. Recovery repeats only while +the same admission, cancellation and finite send budget allow it. An individually oversized +new unit or unverifiable owner fails explicitly; no finite API limit can admit every image. +An upstream image 413 can compact an old image-bearing complete prefix once before retrying +the current send. The refused body releases its pacing lease before the summary is admitted. +Ordinary dispatch, search and media loops share this request-owned recovery; loop histories +adopt the summary without replaying completed tools. Repeated 413, image-free input or a newest +indivisible unit without compactable old images does not start another summary. Original send +budgets, cancellation and serving-owner checks remain authoritative. + +`src/server/responses/image-context-recovery.ts` dispatches the summary inside the original +Responses owner. `src/server/responses/response-effects.ts` preserves Anthropic tools, system +and normal tool choice. `src/responses/compaction-summary.ts` suppresses client tool events +and rejects invalid summaries before checkpoint publication, including EOF without a normal stop +reason. Successful summaries have no failure error code. Sidecar execution is bypassed. +Hosted server tools are the exception: `tool_choice: none` prevents upstream execution, at +the documented cost of invalidating the Messages cache when choice changes. + +Successful summaries use the existing bounded response-state store, bound to principal, own +thread and serving owner. A digest identifies the exact timestamp-free historical prefix +without retaining another full image history. Replay reuses a matching checkpoint; a failed +store write refuses further summary charges. This is a proxy projection, not a rewrite of the +native Codex transcript. After necessary compaction, a new message prefix is expected. + +## Verification boundaries + +The focused cache lifecycle entry tests use generated images, fake upstreams, blocked external +connections and a deterministic clock. They exercise HTTP/WS, compact v1/v2, image overflow, +checkpoint reuse, default-off behavior and usage correlation. Runtime record tests use synthetic +large native records. These checks establish the wire and lifecycle contracts, not live provider +acceptance or a guarantee that every future request hits a cache. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index c934f0fd504..815b806a324 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -38,7 +38,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Meta Muse Responses tool names | `src/responses/muse-tool-name-alias.ts`, `src/adapters/openai-responses.ts` | `api.meta.ai` only: function names over 64 characters or containing characters outside `[a-zA-Z0-9_-]` become collision-safe wire aliases and are restored before the client sees them. | | Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/adapters/google-wire-shape.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. Its transient ladder uses the shared `TRANSIENT_RETRY_MAX_ATTEMPTS` constant, also used to fund each eligible generic OAuth account in the [request send budget](responses-spend.md#credential-hop-reservations). Antigravity 403 normalization reads at most 4096 bytes for at most two seconds and emits a validation marker only for a complete structured `VALIDATION_REQUIRED` reason; an unsafe or incomplete body falls back to status-only text. The Google-family wrapper reuses shared abort/deadline helpers, upstream error normalization, and policy-aware wire-body repair: strict initial schema loss sends nothing, while strict repair withholding returns the original 400 without a changed send. The final compiler produces the [content-free tool-schema loss contract](../providers/google.md#google-tool-schema-loss-reporting). `google-wire-shape.ts` remains diagnostic-only. | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | -| Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. An image's ladder position is pinned to its own fixed-size digest of content and canonical media type rather than recomputed from recency each request (#4532); appending a newer image therefore cannot demote and re-encode older images and bust Anthropic's prompt prefix cache. Position bytes count toward the shared retained-memory budget but are pinned — the shared evictor clears normalization cache slots first and never drops a position mid-request; positions shrink only through the store's own entry-count cap at request end. Unseen images still take the age-tier pyramid's first position, the total byte budget still binds, and a 413 `tierBias` retry still applies. Recorded positions only move down the ladder, so the store is monotonic. | +| Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | The Anthropic adapter uses fixed per-image encoding and non-mutating overflow assertions under the [cache lifecycle contract](../providers/anthropic-cache-lifecycle.md#stable-images-and-overflow-recovery). Responses compacts old complete history units instead of degrading historical images or repeating an unchanged 413. Native Messages explicitly retains legacy age tiers, pinned positions, aggregate demotion and its mutating guard; it does not enter Responses compaction. Shared decoding and cancellation protections remain in place. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | | Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 1578413634c..e2ee52964a8 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -11,14 +11,14 @@ Unknown reasons remain verbatim, including `adapter_eof`, `max_messages`, and `s ## Compaction image input -For translated routed compaction, `src/responses/compaction-images.ts` replaces earlier user and +For non-Anthropic translated routed compaction, `src/responses/compaction-images.ts` replaces earlier user and tool-result images with a short reopening note only when a later nonempty `final_answer` message exists. This structural boundary does not prove the image was analyzed: text and source references remain, and the note asks the next model to reopen unresolved visual evidence. Pending images and commentary-only or unphased histories stay intact. Sidecar preparation projects parsed messages before vision planning. The projection does not rewrite `_rawBody` or stored history; later vision preprocessing may rewrite the request-local raw body for transport safety. Normal generation and native compaction -stay unchanged. Both routed v1 and v2 pass through this boundary; raw Responses gateways retain their existing text-only compaction conversion. +stay unchanged. Both routed v1 and v2 pass through this boundary; raw Responses gateways retain their existing text-only compaction conversion. Anthropic retains image bytes and the tool/system prefix under the [cache lifecycle contract](../providers/anthropic-cache-lifecycle.md#stable-images-and-overflow-recovery). The [decoded checkpoint continuation contract](responses.md#decoded-checkpoint-continuation) governs the conditional system note and its authorization limits. ## Direct MCP calls in code mode diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 02f6eb0a8b6..6228172fc8e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -296,7 +296,7 @@ Anthropic Fast eligibility and downgrade recovery use the [Responses failover co `POST /v1/responses/compact` handles remote compaction v1 before the generic `/v1/responses` branch and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through -to GUI static serving. Translated compaction uses the [historical image projection](responses-wire-shapes.md#compaction-image-input). +to GUI static serving. Translated compaction uses the [historical image projection](responses-wire-shapes.md#compaction-image-input), with Anthropic's prefix-preserving exception. Running-turn renewal and local image-overflow recovery follow the [Anthropic cache lifecycle](../providers/anthropic-cache-lifecycle.md). Both entry points apply the Reserve opt-in refusal in [providers/openai-tiers.md](../providers/openai-tiers.md#public-provider-contract) before auth, host-circuit diff --git a/tests/adapters/abort-race.test.ts b/tests/adapters/abort-race.test.ts index 00a7d6c56c4..ce1228123e7 100644 --- a/tests/adapters/abort-race.test.ts +++ b/tests/adapters/abort-race.test.ts @@ -79,7 +79,7 @@ describe("Responses abort guards", () => { }); for (const budgetError of [true, false]) { - test(`an image-retry build ${budgetError ? "budget refusal returns 413" : "ordinary error retains 400"} without a retry send`, async () => { + test(`an image preflight build ${budgetError ? "budget refusal returns 413" : "ordinary error retains 400"} without any send`, async () => { let builds = 0; let sends = 0; let adapterSignal: AbortSignal | undefined; @@ -112,7 +112,7 @@ describe("Responses abort guards", () => { message: "request translation buffer exceeded the safe limit", } : { type: "invalid_request_error", code: "invalid_request_error", message: "fixture rebuild failure" }); expect(builds).toBe(2); - expect(sends).toBe(1); + expect(sends).toBe(0); expect(adapterSignal?.aborted).toBe(true); }); } diff --git a/tests/adapters/anthropic/anthropic-cache-renewal.test.ts b/tests/adapters/anthropic/anthropic-cache-renewal.test.ts new file mode 100644 index 00000000000..3485110c15e --- /dev/null +++ b/tests/adapters/anthropic/anthropic-cache-renewal.test.ts @@ -0,0 +1,246 @@ +import { test, expect } from "bun:test"; +import { Clock } from "../../helpers/anthropic-renewal-clock"; +import { AnthropicCacheRenewal, cacheUsage, renewalBody, type RenewalWire, type CacheRuntimeState } from "../../../src/anthropic/cache-renewal"; +import { applyRuntimePatches, projectCacheRuntime } from "../../../src/codex/cache-runtime-state"; + +const usage = { input_tokens: 4, output_tokens: 0, cache_read_input_tokens: 1000, cache_creation_input_tokens: 0 }; +const naturalBody = JSON.stringify({ model: "claude-opus-5-5", stream: true, max_tokens: 8192, + cache_control: { type: "ephemeral" }, thinking: { type: "adaptive" }, output_config: { effort: "max" }, + tools: [{ name: "fixture_tool", input_schema: { type: "object" } }], + system: [{ type: "text", text: "Fixed system." }], messages: [{ role: "user", content: [ + { type: "text", text: "Continue the original task." }, { type: "image", source: { type: "base64", media_type: "image/png", data: "offline-image-exact-bytes" } }, + ] }] }); + +function fixture() { + const clock = new Clock(); + const watches = new Map void>(); + let disposed = 0; + const manager = new AnthropicCacheRenewal({ now: () => clock.now, later: clock.later, clear: clock.clear, + watch: (thread, update) => { watches.set(thread, update); update({ running: true, turn: "turn-a" }); + return () => { disposed++; watches.delete(thread); }; } }); + const sent: any[] = [], results: any[] = []; + let allowed = true; + let send: RenewalWire["send"] = async (body, signal, dispatched) => { + dispatched(); sent.push({ body: JSON.parse(body), at: clock.now, signal }); + return Response.json({ content: [], usage }); + }; + const wire = (identity = "account-a", body = naturalBody): RenewalWire => ({ identity, body, turnId: "request-turn-a", model: "claude-opus-5-5", + allowed: () => allowed, send: (...args) => send(...args), record: event => results.push(event) }); + return { clock, watches, manager, sent, results, wire, setAllowed: (value: boolean) => { allowed = value; }, + setSend: (value: RenewalWire["send"]) => { send = value; }, disposed: () => disposed }; +} + +test("仅修改零输出字段,保持完整图片、工具和推理前缀", () => { + const body = JSON.parse(renewalBody(naturalBody)!); + expect(body).toEqual({ ...JSON.parse(naturalBody), stream: false, max_tokens: 0 }); +}); +for (const change of [ + { thinking: { type: "enabled", budget_tokens: 1024 } }, { output_config: { format: { type: "json_schema" } } }, + { tool_choice: { type: "any" } }, { tool_choice: { type: "tool", name: "fixture_tool" } }, { max_tokens: 0 }, + { cache_control: { type: "ephemeral", ttl: "1h" } }, { cache_control: undefined }, +]) test(`不兼容请求不通过改写前缀强行续期 ${JSON.stringify(change)}`, () => { + expect(renewalBody(JSON.stringify({ ...JSON.parse(naturalBody), ...change }))).toBeUndefined(); +}); + +test("无用量、非法用量和零缓存不是命中证据", () => { + for (const raw of [undefined, {}, { ...usage, cache_read_input_tokens: null }, { ...usage, output_tokens: -1 }]) expect(cacheUsage(raw)).toBeUndefined(); +}); + +test("四分三十秒才续,成功后按发送时间重新计时", async () => { + const f = fixture(); f.manager.begin("main", f.wire())(usage); + await f.clock.advance(269999); expect(f.sent).toHaveLength(0); + await f.clock.advance(270000); expect(f.sent).toHaveLength(1); + await f.clock.advance(540000); expect(f.sent).toHaveLength(2); + expect(f.sent.map(x => x.at)).toEqual([270000, 540000]); f.manager.close(); +}); + +test("不兼容的新自然请求停止旧前缀续期", async () => { + const f = fixture(); f.manager.begin("main", f.wire())(usage); + f.manager.begin("main", f.wire("account-a", JSON.stringify({ ...JSON.parse(naturalBody), max_tokens: 0 })))(usage); + await f.clock.advance(900000); expect(f.sent).toHaveLength(0); f.manager.close(); +}); + +test("畸形空内容响应不能延长续期链", async () => { + const f = fixture(); f.manager.begin("main", f.wire())(usage); + f.setSend(async (_body, _signal, dispatched) => { dispatched(); return Response.json({ content: "", usage }); }); + await f.clock.advance(900000); expect(f.results).toHaveLength(1); expect(f.results[0].reason).toBe("unconfirmed"); f.manager.close(); +}); + +test("首次写入也能建立缓存时钟", async () => { + const f = fixture(); f.manager.begin("main", f.wire())({ ...usage, cache_read_input_tokens: 0, cache_creation_input_tokens: 1000 }); + await f.clock.advance(270000); expect(f.sent).toHaveLength(1); f.manager.close(); +}); + +test("回答未结束也续期,不把确认到达时刻当起点", async () => { + const f = fixture(); const confirm = f.manager.begin("main", f.wire()); + await f.clock.advance(120000); confirm(usage); + await f.clock.advance(270000); expect(f.sent).toHaveLength(1); f.manager.close(); +}); + +test("新自然请求尚未确认时仍维护旧前缀,确认后换新计时", async () => { + const f = fixture(); f.manager.begin("main", f.wire())(usage); + await f.clock.advance(260000); const confirm = f.manager.begin("main", f.wire()); + await f.clock.advance(270000); expect(f.sent).toHaveLength(1); + await f.clock.advance(280000); confirm(usage); + await f.clock.advance(529999); expect(f.sent).toHaveLength(1); + await f.clock.advance(530000); expect(f.sent).toHaveLength(2); f.manager.close(); +}); + +test("自然请求命中后取消旧计时器", async () => { + const f = fixture(); f.manager.begin("main", f.wire())(usage); + await f.clock.advance(200000); f.manager.begin("main", f.wire())(usage); + await f.clock.advance(270000); expect(f.sent).toHaveLength(0); + await f.clock.advance(470000); expect(f.sent).toHaveLength(1); f.manager.close(); +}); + +test("超时后才出现的缓存确认不能触发冷写", async () => { + const f = fixture(); const confirm = f.manager.begin("main", f.wire()); + await f.clock.advance(310000); confirm(usage); + await f.clock.advance(900000); expect(f.sent).toHaveLength(0); f.manager.close(); +}); + +for (const raw of [{ ...usage, cache_read_input_tokens: 0 }, {}]) test(`自然请求未缓存不启动续期 ${JSON.stringify(raw)}`, async () => { + const f = fixture(); f.manager.begin("main", f.wire())(raw); + await f.clock.advance(900000); expect(f.sent).toHaveLength(0); f.manager.close(); +}); + +for (const state of [{ running: false, turn: "turn-a" }, { running: false, turn: null }]) + test(`任务结束、失联或新 turn 不继承旧计时 ${JSON.stringify(state)}`, async () => { + const f = fixture(); f.manager.begin("main", f.wire())(usage); f.watches.get("main")!(state); + await f.clock.advance(900000); expect(f.sent).toHaveLength(0); expect(f.disposed()).toBe(1); f.manager.close(); + }); + +test("真实active不依赖可选前端消息ID,请求新轮次清除旧快照及回调", async () => { + const f = fixture(); const old = f.manager.begin("main", f.wire()); + f.watches.get("main")!({ running: true, turn: null }); old(usage); + await f.clock.advance(270000); expect(f.sent).toHaveLength(1); + const fresh = f.manager.begin("main", { ...f.wire(), turnId: "request-turn-b" }); + old(usage); await f.clock.advance(540000); expect(f.sent).toHaveLength(1); + fresh(usage); f.manager.close(); + expect(projectCacheRuntime({ threadRuntimeStatus: { type: "active" } }).running).toBe(true); +}); + +test("自然用量早于首个IPC快照时,确认active后仍从原发送时间计时", async () => { + const clock = new Clock(); let update!: (state: CacheRuntimeState) => void; let sends = 0; + const manager = new AnthropicCacheRenewal({ now: () => clock.now, later: clock.later, clear: clock.clear, + watch: (_thread, emit) => { update = emit; return () => {}; } }); + manager.begin("main", { turnId: "actual-turn", identity: "account", body: naturalBody, model: "claude-opus-5-5", + allowed: () => true, record: () => {}, send: async (_body, _signal, dispatched) => { + sends++; dispatched(); return Response.json({ content: [], usage }); + } })(usage); + await clock.advance(1000); update({ running: true, turn: null }); + await clock.advance(269999); expect(sends).toBe(0); + await clock.advance(270000); expect(sends).toBe(1); manager.close(); +}); + +test("主与两个子代理按自身 ID 分开续期", async () => { + const f = fixture(); for (const thread of ["main", "child-a", "child-b"]) f.manager.begin(thread, f.wire())(usage); + f.watches.get("child-a")!({ running: false, turn: "turn-a" }); + await f.clock.advance(270000); expect(f.sent).toHaveLength(2); f.manager.close(); +}); + +test("旧账号、旧自然请求的迟到确认不覆盖新快照", async () => { + const f = fixture(); const old = f.manager.begin("main", f.wire()); + await f.clock.advance(100000); f.manager.begin("main", f.wire("account-b"))(usage); old(usage); + await f.clock.advance(270000); expect(f.sent).toHaveLength(0); + await f.clock.advance(370000); expect(f.sent).toHaveLength(1); f.manager.close(); +}); + +test("缓存禁用或凭据失效后不再发送", async () => { + const f = fixture(); f.manager.begin("main", f.wire())(usage); f.setAllowed(false); + await f.clock.advance(900000); expect(f.sent).toHaveLength(0); f.manager.close(); +}); + +for (const answer of [ + { content: [], usage: { ...usage, cache_creation_input_tokens: 1000 } }, + { content: [], usage: { ...usage, cache_read_input_tokens: 100 } }, + { content: [], usage: { ...usage, output_tokens: 1 } }, { content: [] }, + { content: [{ type: "tool_use", name: "fixture_tool", input: {} }], usage }, +]) test(`部分命中、冷写或异常输出暂停收费循环 ${JSON.stringify(answer)}`, async () => { + const f = fixture(); let calls = 0; + f.setSend(async (_body, _signal, dispatched) => { calls++; dispatched(); return Response.json(answer); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(1800000); + expect(calls).toBe(1); expect(f.results).toHaveLength(1); f.manager.close(); +}); + +test("明确 DNS 失败最多三次,只有一个重试预算", async () => { + const f = fixture(); let calls = 0; + f.setSend(async () => { calls++; throw Object.assign(new Error("offline-dns"), { code: "ENOTFOUND" }); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(900000); + expect(calls).toBe(3); expect(f.results.every(x => x.sent === false)).toBe(true); f.manager.close(); +}); + +for (const status of [400, 401, 403, 402, 500, 502, 504]) test(`错误不通过换账号或普通恢复链扩散 ${status}`, async () => { + const f = fixture(); let calls = 0; + f.setSend(async (_body, _signal, dispatched) => { calls++; dispatched(); return Response.json({ error: { type: "fixture_error" } }, { status }); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(900000); expect(calls).toBe(1); f.manager.close(); +}); + +test("限流 Retry-After 在期限内可重试成功", async () => { + const f = fixture(); let calls = 0; + f.setSend(async (_body, _signal, dispatched) => { calls++; dispatched(); return calls === 1 + ? Response.json({ error: { type: "rate_limit_error" } }, { status: 429, headers: { "retry-after": "1" } }) + : Response.json({ content: [], usage }); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(271000); + expect(calls).toBe(2); expect(f.results.at(-1).reason).toBe("hit"); f.manager.close(); +}); + +for (const retry of ["40", "garbage"]) test(`Retry-After 不能突破原缓存期限 ${retry}`, async () => { + const f = fixture(); let calls = 0; + f.setSend(async (_body, _signal, dispatched) => { calls++; dispatched(); return Response.json({ error: { type: "rate_limit_error" } }, { status: 429, headers: { "retry-after": retry } }); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(900000); expect(calls).toBe(1); f.manager.close(); +}); + +test("额度耗尽的 429 不重试", async () => { + const f = fixture(); let calls = 0; + f.setSend(async (_body, _signal, dispatched) => { calls++; dispatched(); return Response.json({ error: { type: "insufficient_quota" } }, { status: 429 }); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(900000); expect(calls).toBe(1); f.manager.close(); +}); + +test("发送后断线结果未知,不盲目再收费", async () => { + const f = fixture(); let calls = 0; + f.setSend(async (_body, _signal, dispatched) => { calls++; dispatched(); throw Object.assign(new Error("offline-reset"), { code: "ECONNRESET" }); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(900000); + expect(calls).toBe(1); expect(f.results[0].reason).toBe("unknown"); f.manager.close(); +}); + +test("等待超时结束本轮,不变成反复冷写", async () => { + const f = fixture(); let calls = 0; + f.setSend(async (_body, signal, dispatched) => { calls++; dispatched(); return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("offline-timeout")), { once: true })); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(900000); + expect(calls).toBe(1); expect(f.results[0].reason).toBe("unknown"); f.manager.close(); +}); + +test("停止在途续期不恢复旧任务计时", async () => { + const f = fixture(); let finish!: (value: Response) => void; + f.setSend(async (_body, _signal, dispatched) => { dispatched(); return new Promise(resolve => { finish = resolve; }); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(270000); + f.watches.get("main")!({ running: false, turn: "turn-a" }); + finish(Response.json({ content: [], usage })); await f.clock.advance(900000); + expect(f.results).toHaveLength(1); expect(f.clock.jobs.size).toBe(0); f.manager.close(); +}); + +test("IPC 只投影运行状态,不持有完整对话", () => { + const state = { id: "main", threadRuntimeStatus: { type: "active", activeFlags: [] }, latestTurnStartMessageId: "turn-a" }; + expect(projectCacheRuntime(state)).toEqual({ running: true, turn: "turn-a" }); + const patched = applyRuntimePatches(state, [{ op: "replace", path: ["threadRuntimeStatus", "type"], value: "idle" }, + { op: "add", path: ["turns", 100], value: { text: "must-not-be-retained" } }]); + expect(projectCacheRuntime(patched).running).toBe(false); expect(patched.turns).toBeUndefined(); + expect(() => applyRuntimePatches(state, [{ op: "add", path: ["threadRuntimeStatus", "__proto__"], value: {} }])).toThrow(); +}); + +test("IPC 尚未确认缓存就失联,下一次自然请求可重新登记", async () => { + const f = fixture(); const old = f.manager.begin("main", f.wire()); + f.watches.get("main")!({ running: false, turn: null }); old(usage); + await f.clock.advance(100000); f.manager.begin("main", f.wire())(usage); + await f.clock.advance(370000); expect(f.sent).toHaveLength(1); f.manager.close(); +}); + +test("不响应取消的网络实现也不能挂住重试窗口", async () => { + const f = fixture(); let finish!: (response: Response) => void; + f.setSend(async (_body, _signal, dispatched) => { dispatched(); return new Promise(resolve => { finish = resolve; }); }); + f.manager.begin("main", f.wire())(usage); await f.clock.advance(300000); + expect(f.results).toHaveLength(1); expect(f.results[0].reason).toBe("unknown"); + finish(Response.json({ content: [], usage })); await f.clock.advance(900000); + expect(f.results).toHaveLength(1); expect(f.clock.jobs.size).toBe(0); f.manager.close(); +}); diff --git a/tests/adapters/anthropic/anthropic-image-guard.test.ts b/tests/adapters/anthropic/anthropic-image-guard.test.ts index 07f6e4d6422..41fc7a98872 100644 --- a/tests/adapters/anthropic/anthropic-image-guard.test.ts +++ b/tests/adapters/anthropic/anthropic-image-guard.test.ts @@ -222,16 +222,17 @@ describe("enforceAnthropicImageLimits — byte limits", () => { } test("B1: over-budget total evicts oldest base64 images until under budget, newest survive", () => { - // 8 × 4MiB base64 = 32MiB > 20MiB budget → 3 oldest evicted (28 → 24 → 20MiB). + // Legacy mutating helper: 8 × 4MiB base64 exceeds the current image share. const messages = [userMsg(Array.from({ length: 8 }, () => midPng()))]; enforceAnthropicImageLimits(messages); const content = (messages[0] as { content: Array<{ type: string }> }).content; - for (let i = 0; i < 3; i++) { + const evictions = 8 - Math.floor(TOTAL_IMAGE_BASE64_BUDGET / (4 * 1024 * 1024)); + for (let i = 0; i < evictions; i++) { expect(content[i].type).toBe("text"); expect(textOf(content[i])).toContain("older screenshots were dropped"); expect(textOf(content[i])).toContain("request limit"); } - for (let i = 3; i < 8; i++) expect(content[i].type).toBe("image"); + for (let i = evictions; i < 8; i++) expect(content[i].type).toBe("image"); expect(base64SumOf(messages)).toBeLessThanOrEqual(TOTAL_IMAGE_BASE64_BUDGET); }); @@ -266,7 +267,7 @@ describe("enforceAnthropicImageLimits — byte limits", () => { test("B5: >8000px image textified by the dimension rule does not count toward the byte budget", () => { // 18MiB decoded (24MiB base64) at 9000px wide → dimension rule kills it first; the - // remaining 2 × 4MiB base64 = 8MiB ≤ 20MiB, so no byte-budget eviction fires. + // remaining 2 × 4MiB base64 fit, so no byte-budget eviction fires. const messages = [userMsg([imageBlock(bigPngBase64(9000, 500, 18 * 1024 * 1024)), midPng(), midPng()])]; enforceAnthropicImageLimits(messages); const content = (messages[0] as { content: Array<{ type: string }> }).content; @@ -277,17 +278,17 @@ describe("enforceAnthropicImageLimits — byte limits", () => { }); test("B6: URL images are never evicted by the byte budget; oldest base64 goes instead", () => { - // Oldest ref is a URL image, then 6 × 4MiB base64 = 24MiB > 20MiB → exactly one - // base64 eviction (the oldest base64, index 1), never the URL. + // Exactly one base64 image exceeds the available share; never evict the URL. const urlImg = { type: "image", source: { type: "url", url: "https://example.com/a.png" } }; - const messages = [userMsg([{ ...urlImg }, midPng(), midPng(), midPng(), midPng(), midPng(), midPng()])]; + const count = Math.floor(TOTAL_IMAGE_BASE64_BUDGET / (4 * 1024 * 1024)) + 1; + const messages = [userMsg([{ ...urlImg }, ...Array.from({ length: count }, midPng)])]; enforceAnthropicImageLimits(messages); const content = (messages[0] as { content: Array<{ type: string; source?: { type?: string } }> }).content; expect(content[0].type).toBe("image"); expect(content[0].source?.type).toBe("url"); expect(content[1].type).toBe("text"); expect(textOf(content[1])).toContain("older screenshots were dropped"); - for (let i = 2; i < 7; i++) expect(content[i].type).toBe("image"); + for (let i = 2; i <= count; i++) expect(content[i].type).toBe("image"); expect(base64SumOf(messages)).toBeLessThanOrEqual(TOTAL_IMAGE_BASE64_BUDGET); }); }); diff --git a/tests/adapters/anthropic/anthropic-image-normalize.test.ts b/tests/adapters/anthropic/anthropic-image-normalize.test.ts index 7b22fb26276..7e910a09621 100644 --- a/tests/adapters/anthropic/anthropic-image-normalize.test.ts +++ b/tests/adapters/anthropic/anthropic-image-normalize.test.ts @@ -14,6 +14,7 @@ import { } from "../../../src/adapters/anthropic-image-normalize"; import { enforceAnthropicImageLimits, + assertAnthropicImageBudget, sniffImageDimensions, TOTAL_IMAGE_BASE64_BUDGET, } from "../../../src/adapters/anthropic-image-guard"; @@ -238,26 +239,22 @@ describe("normalizeAnthropicImages — real Bun.Image path", () => { expect((block.source?.data ?? "").length).toBeLessThanOrEqual(TIER_SPECS[0].hardCap); }); - test("N2: 30 images land on age tiers — newest pass through, older shrink to their tier edges", async () => { + test("N2: appending images preserves the encoding of every historical image", async () => { const src = await realPngBase64(1500, 1000); const messages = [userMsg(Array.from({ length: 30 }, () => imageBlock(src)))]; await normalizeAnthropicImages(messages); const content = contentOf(messages); expect(content.every(b => b.type === "image")).toBe(true); - // Wire order is oldest first: indices 0-9 are tier 2 (<=700px), 10-23 tier 1 (<=1024px), 24-29 tier 0 (pass-through PNG). - for (let i = 0; i < 10; i++) { - const d = sniffImageDimensions(content[i].source?.data ?? ""); - expect(Math.max(d!.width, d!.height)).toBeLessThanOrEqual(700); - expect(content[i].source?.media_type).toBe("image/jpeg"); - } - for (let i = 10; i < 24; i++) { - const d = sniffImageDimensions(content[i].source?.data ?? ""); - expect(Math.max(d!.width, d!.height)).toBeLessThanOrEqual(1024); - } - for (let i = 24; i < 30; i++) { + for (let i = 0; i < 30; i++) { expect(content[i].source?.media_type).toBe("image/png"); expect(content[i].source?.data).toBe(src); } + const appended = [userMsg(Array.from({ length: 31 }, () => imageBlock(src)))]; + await normalizeAnthropicImages(appended); + expect(contentOf(appended).slice(0, 30)).toEqual(content); + resetNormalizeStateForTests(); + await normalizeAnthropicImages(appended); + expect(contentOf(appended).slice(0, 30)).toEqual(content); }); test("N3: cache hit — same input re-normalized with ZERO additional encoder invocations and identical bytes", async () => { @@ -366,22 +363,24 @@ describe("normalizeAnthropicImages — guards and seams", () => { expect(sum).toBeLessThanOrEqual(TOTAL_IMAGE_BASE64_BUDGET); }); - test("representative 100-image session: demotion keeps every image, zero textify, sum within budget", async () => { + test("representative 100-image session retains images and signals aggregate pressure without mutation", async () => { const capFitting = sizedEncoder(edge => { const spec = TIER_SPECS.find(s => s.maxEdge === edge)!; return Number.isFinite(spec.hardCap) ? spec.hardCap : 100 * 1024; }); const messages = [userMsg(Array.from({ length: 100 }, () => imageBlock(fakePngBase64(3000, 2000))))]; await normalizeAnthropicImages(messages, { encode: capFitting }); - enforceAnthropicImageLimits(messages); const content = contentOf(messages); expect(content.every(b => b.type === "image")).toBe(true); let sum = 0; for (const b of content) sum += b.source?.data?.length ?? 0; - expect(sum).toBeLessThanOrEqual(TOTAL_IMAGE_BASE64_BUDGET); + expect(sum).toBeGreaterThan(TOTAL_IMAGE_BASE64_BUDGET); + const before = JSON.stringify(messages); + expect(() => assertAnthropicImageBudget(messages, Buffer.byteLength(JSON.stringify(messages), "utf8"))).toThrow(); + expect(JSON.stringify(messages)).toBe(before); }); - test("aggregate demotion: over-budget totals demote OLDEST images further down the ladder until the sum fits", async () => { + test("aggregate pressure never demotes historical images", async () => { // Encoder returns exactly the hard cap at each position (terminal: 100KiB). const capFitting = sizedEncoder(edge => { const spec = TIER_SPECS.find(s => s.maxEdge === edge)!; @@ -394,9 +393,10 @@ describe("normalizeAnthropicImages — guards and seams", () => { expect(content.every(b => b.type === "image")).toBe(true); let sum = 0; for (const b of content) sum += b.source?.data?.length ?? 0; - expect(sum).toBeLessThanOrEqual(TOTAL_IMAGE_BASE64_BUDGET); - // Oldest images were demoted below tier 2 (192KiB) to the 100KiB floor. - expect(content[0].source?.data?.length).toBe(100 * 1024); + expect(sum).toBeGreaterThan(TOTAL_IMAGE_BASE64_BUDGET); + expect(content[0].source?.data?.length).toBe(TIER_SPECS[0].hardCap); + expect(content.every(block => block.source?.data === content[0].source?.data)).toBe(true); + expect(() => assertAnthropicImageBudget(messages, Buffer.byteLength(JSON.stringify(messages), "utf8"))).toThrow(); }); test("activation both directions: with normalization the guard keeps every image; without it Rule 4 drops", async () => { diff --git a/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts b/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts index da69051e673..aea5470f6f8 100644 --- a/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts +++ b/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts @@ -9,7 +9,7 @@ import { resetNormalizeStateForTests } from "../../../src/adapters/anthropic-ima import { sniffImageDimensions } from "../../../src/adapters/anthropic-image-guard"; import type { OcxConfig } from "../../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../../helpers/isolated-codex-home"; -import { removeTreeWithRetry } from "../../helpers/remove-tree"; +import { removeTreeAfterBackgroundWork } from "../../helpers/remove-tree"; let testDir = ""; let previousHome: string | undefined; @@ -25,15 +25,18 @@ beforeEach(() => { resetNormalizeStateForTests(); }); -afterEach(() => { +afterEach(async () => { upstream?.stop(true); upstream = null; - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - isolatedCodexHome?.restore(); - isolatedCodexHome = null; - if (testDir) removeTreeWithRetry(testDir); - clearKeyCooldowns(); + try { + if (testDir) await removeTreeAfterBackgroundWork(testDir); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + clearKeyCooldowns(); + } }); const ONE_PX_PNG = @@ -121,30 +124,26 @@ async function postImageRequest(serverUrl: string, dataUrl: string): Promise { - test("R1: upstream 413 then 200 — exactly one biased rebuild, images re-encoded a tier lower", async () => { +describe("anthropic image encoding and upstream rejection (end-to-end)", () => { + test("R1: upstream 413 surfaces once without rewriting images", async () => { const seen: SeenRequest[] = []; upstream = scriptedUpstream([413, 200], seen); saveConfig(anthropicConfig(upstream.url.toString().replace(/\/$/, ""))); const server = startServer(0); try { const res = await postImageRequest(String(server.url), await realPngDataUrl(1500, 1000)); - expect(res.status).toBe(200); - expect(seen).toHaveLength(2); + expect(res.status).toBe(413); + expect(seen).toHaveLength(1); // First attempt: 1500px PNG rides tier-0 pass-through. const first = firstImageSource(seen[0].body); expect(first?.media_type).toBe("image/png"); - // Biased retry: tier 1 — re-encoded JPEG within 1024px. - const second = firstImageSource(seen[1].body); - expect(second?.media_type).toBe("image/jpeg"); - const dims = sniffImageDimensions(second?.data ?? ""); - expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(1024); + expect(sniffImageDimensions(first?.data ?? "")).toEqual({ width: 1500, height: 1000 }); } finally { await server.stop(true); } }); - test("R2: upstream 413 twice — exactly two calls, honest 413 surfaces, no spiral", async () => { + test("R2: repeated upstream 413 cannot start a lossy retry spiral", async () => { const seen: SeenRequest[] = []; upstream = scriptedUpstream([413, 413], seen); saveConfig(anthropicConfig(upstream.url.toString().replace(/\/$/, ""))); @@ -152,7 +151,7 @@ describe("anthropic 413 tightened-retry (end-to-end)", () => { try { const res = await postImageRequest(String(server.url), await realPngDataUrl(1500, 1000)); expect(res.status).toBe(413); - expect(seen).toHaveLength(2); + expect(seen).toHaveLength(1); expect(res.headers.get("content-type")).toContain("application/json"); const errorBody = await res.json(); expect(errorBody.error).toEqual({ @@ -165,22 +164,19 @@ describe("anthropic 413 tightened-retry (end-to-end)", () => { } }); - test("R4: 413 → biased retry → 429 → key rotation keeps the tightened tiers", async () => { + test("R4: 429 key rotation preserves the exact image encoding", async () => { const seen: SeenRequest[] = []; - upstream = scriptedUpstream([413, 429, 200], seen); + upstream = scriptedUpstream([429, 200], seen); saveConfig(anthropicConfig(upstream.url.toString().replace(/\/$/, ""), true)); const server = startServer(0); try { const res = await postImageRequest(String(server.url), await realPngDataUrl(1500, 1000)); expect(res.status).toBe(200); - expect(seen).toHaveLength(3); - // Rotation happened after the biased 413 retry hit 429. - expect(seen[2].apiKey).not.toBe(seen[1].apiKey); - // And the rotated rebuild STILL carries the tightened tier (jpeg <= 1024px). - const third = firstImageSource(seen[2].body); - expect(third?.media_type).toBe("image/jpeg"); - const dims = sniffImageDimensions(third?.data ?? ""); - expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(1024); + expect(seen).toHaveLength(2); + expect(seen[1].apiKey).not.toBe(seen[0].apiKey); + expect(firstImageSource(seen[1].body)).toEqual(firstImageSource(seen[0].body)); + const dims = sniffImageDimensions(firstImageSource(seen[1].body)?.data ?? ""); + expect(Math.max(dims!.width, dims!.height)).toBe(1500); } finally { await server.stop(true); } diff --git a/tests/adapters/anthropic/anthropic-image-retry.test.ts b/tests/adapters/anthropic/anthropic-image-retry.test.ts index 3bbe06facf1..2fe5c98104e 100644 --- a/tests/adapters/anthropic/anthropic-image-retry.test.ts +++ b/tests/adapters/anthropic/anthropic-image-retry.test.ts @@ -68,8 +68,8 @@ describe("image-retry gate (030 R3)", () => { }); }); -describe("imageTierBias plumbing (030 R1 — bias activation through the real adapter)", () => { - test("bias 0 passes a tier-0-sized image through; bias 1 re-encodes it at the tier-1 edge", async () => { +describe("stable image encoding through the real adapter", () => { + test("retry bias cannot change a previously valid image encoding", async () => { const dataUrl = await realPngDataUrl(1500, 1000); const adapter = createAnthropicAdapter(provider); @@ -81,8 +81,8 @@ describe("imageTierBias plumbing (030 R1 — bias activation through the real ad const biased = await adapter.buildRequest(parsedWithImages([dataUrl]), { headers: new Headers(), imageTierBias: 1 }); const biasedBody = JSON.parse(biased.body as string) as { messages: Array<{ content: Array<{ type: string; source?: { media_type?: string; data?: string } }> }> }; const biasedImg = biasedBody.messages[0].content.find(b => b.type === "image"); - expect(biasedImg?.source?.media_type).toBe("image/jpeg"); // tier 1: 1500px > 1024 — re-encoded + expect(biasedImg?.source).toEqual(unbiasedImg?.source); const dims = sniffImageDimensions(biasedImg?.source?.data ?? ""); - expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(1024); + expect(Math.max(dims!.width, dims!.height)).toBe(1500); }); }); diff --git a/tests/codex-integration/codex-cache-runtime-record.test.ts b/tests/codex-integration/codex-cache-runtime-record.test.ts new file mode 100644 index 00000000000..2fd80cdaf13 --- /dev/null +++ b/tests/codex-integration/codex-cache-runtime-record.test.ts @@ -0,0 +1,68 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { Database } from "bun:sqlite"; +import { repoRoot } from "../helpers/repo-root"; +import { RolloutCacheRuntime } from "../../src/codex/rollout-cache-runtime"; + +const thread = "11111111-1111-4111-8111-111111111111"; +const turn = "22222222-2222-4222-8222-222222222222"; +const event = (type: string, ownTurn = turn) => JSON.stringify({ type: "event_msg", payload: { type, turn_id: ownTurn } }) + "\n"; +const large = (type: string) => JSON.stringify({ timestamp: "2026-01-01T00:00:00Z", type, + payload: { text: "synthetic-history".repeat(110000) } }) + "\n"; + +function fixture() { + const scratch = join(repoRoot(), ".tmp"); mkdirSync(scratch, { recursive: true }); + const home = mkdtempSync(join(scratch, "cache-runtime-")); + const rollout = join(home, "rollout.jsonl"), state = join(home, "state.sqlite"); + writeFileSync(rollout, JSON.stringify({ type: "session_meta", payload: { id: thread } }) + "\n" + event("task_started")); + const db = new Database(state); + db.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL)"); + db.query("INSERT INTO threads VALUES (?, ?)").run(thread, rollout); db.close(); + let held = true; + const reader = new RolloutCacheRuntime(thread, turn, () => {}, { + home: () => home, statePath: () => state, writerHeld: () => held, + }); + return { rollout, reader, loseWriter: () => { held = false; }, + close() { reader.close(); rmSync(home, { recursive: true, force: true }); } }; +} + +for (const type of ["compacted", "turn_context", "response_item", "token_usage_record"]) { + test(`oversized ${type} preserves the running turn and the following completion is observed`, async () => { + const f = fixture(); + try { + appendFileSync(f.rollout, large(type)); + expect((await f.reader.refresh()).running).toBe(true); + appendFileSync(f.rollout, event("task_complete")); + expect((await f.reader.refresh()).running).toBe(false); + appendFileSync(f.rollout, large(type)); + expect((await f.reader.refresh()).running).toBe(false); + } finally { f.close(); } + }); +} + +for (const terminal of ["task_complete", "turn_aborted"]) { + test(`partial ${terminal} waits for a complete line and never revives the ended turn`, async () => { + const f = fixture(); + try { + expect((await f.reader.refresh()).running).toBe(true); + appendFileSync(f.rollout, event(terminal).trimEnd()); + expect((await f.reader.refresh()).running).toBe(true); + appendFileSync(f.rollout, "\n" + large("compacted")); + expect((await f.reader.refresh()).running).toBe(false); + } finally { f.close(); } + }); +} + +test("a different turn, missing writer or unknown large format cannot authorize renewal", async () => { + for (const reason of ["turn", "writer", "format"]) { + const f = fixture(); + try { + expect((await f.reader.refresh()).running).toBe(true); + if (reason === "turn") appendFileSync(f.rollout, event("task_started", "33333333-3333-4333-8333-333333333333")); + if (reason === "writer") f.loseWriter(); + if (reason === "format") appendFileSync(f.rollout, large("future_unknown")); + expect((await f.reader.refresh()).running).toBe(false); + } finally { f.close(); } + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fed4bb7ebc7..cd336891ede 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,8 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-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", "responses-phase-inference.test.ts": "responses", + "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", "service-proxy-env.test.ts": "service", "bridge-terminal-refusal.test.ts": "adapters", "responses-replay-metadata-principal.test.ts": "responses", diff --git a/tests/helpers/anthropic-renewal-clock.ts b/tests/helpers/anthropic-renewal-clock.ts new file mode 100644 index 00000000000..6eef3235ab6 --- /dev/null +++ b/tests/helpers/anthropic-renewal-clock.ts @@ -0,0 +1,17 @@ +export class Clock { + now = 0; + next = 1; + jobs = new Map(); + later = (fn: () => void, delay: number) => { const id = this.next++; this.jobs.set(id, { due: this.now + delay, fn }); return id; }; + clear = (id: unknown) => { this.jobs.delete(Number(id)); }; + async flush() { for (let n = 0; n < 30; n++) await Promise.resolve(); } + async advance(to: number) { + for (let n = 0; n < 10000; n++) { + await this.flush(); + const job = [...this.jobs].filter(([, v]) => v.due <= to).sort((a, b) => a[1].due - b[1].due)[0]; + if (!job) { this.now = to; await this.flush(); return; } + this.now = job[1].due; this.jobs.delete(job[0]); job[1].fn(); + } + throw new Error("offline-timer-loop"); + } +} diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 2bc9173afe3..45280852828 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -54,6 +54,8 @@ export const RESPONSES_CORE_MODULES = [ // Reached from request-transport.ts: opt-in DeepSeek upload-backed image replay. "deepseek-image-files.ts", "request-sidecar-auth.ts", + "anthropic-image-context.ts", + "image-context-recovery.ts", "response-effects.ts", "response-tool-ownership.ts", "request-send-budget.ts", @@ -75,6 +77,7 @@ export const RESPONSES_CORE_MODULES = [ "terminal-error-redaction.ts", "non-replayable-error.ts", "sidecar-execution.ts", + "sidecar-tool-surface.ts", "sidecar-send-budget.ts", "completion-policy.ts", "run-turn-execution.ts", diff --git a/tests/providers/github-copilot/github-copilot-account-origin.test.ts b/tests/providers/github-copilot/github-copilot-account-origin.test.ts index 845d3e29cb5..90cc393b8fc 100644 --- a/tests/providers/github-copilot/github-copilot-account-origin.test.ts +++ b/tests/providers/github-copilot/github-copilot-account-origin.test.ts @@ -242,7 +242,7 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => { expect(sent).toEqual(["Bearer synthetic-b"]); }); - test("a pacing switch to B keeps B when Anthropic rebuilds an image after 413", async () => { + test("a pacing switch to B keeps B on an image rejection without a lossy 413 retry", async () => { for (const id of ["a", "b"]) await saveCredential("anthropic", { access: `synthetic-anthropic-${id}`, refresh: `synthetic-refresh-${id}`, expires: Date.now() + 3_600_000, accountId: id, @@ -267,8 +267,9 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => { { type: "input_image", image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" }, ] }], }), cfg, { model: "", provider: "" }); - expect(await response.text()).toContain("ok"); - expect(sent).toEqual(["Bearer synthetic-anthropic-b", "Bearer synthetic-anthropic-b"]); + expect(response.status).toBe(413); + expect(await response.text()).toContain("context_length_exceeded"); + expect(sent).toEqual(["Bearer synthetic-anthropic-b"]); }); test("a pacing switch to B keeps B for an empty-completion continuation", async () => { diff --git a/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts new file mode 100644 index 00000000000..74795f922a0 --- /dev/null +++ b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts @@ -0,0 +1,600 @@ +import { test, expect, mock, afterAll, beforeEach } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import net from "node:net"; +import tls from "node:tls"; +import http from "node:http"; +import https from "node:https"; +import http2 from "node:http2"; +import { Clock } from "../helpers/anthropic-renewal-clock"; +import { repoRoot } from "../helpers/repo-root"; +import { randomUUID } from "node:crypto"; + +const savedEnv = { ...process.env }; +const scratch = join(repoRoot(), ".tmp"); +mkdirSync(scratch, { recursive: true }); +const root = mkdtempSync(join(scratch, "anthropic-cache-entry-")); +for (const name of ["home", "codex", "temp"]) mkdirSync(join(root, name), { recursive: true }); +process.env.OPENCODEX_HOME = join(root, "home"); process.env.CODEX_HOME = join(root, "codex"); +process.env.TEMP = process.env.TMP = join(root, "temp"); +writeFileSync(join(root, "home/auth.json"), JSON.stringify({ anthropic: { access: "offline-only-not-a-key", refresh: "offline-only-not-a-key", expires: Date.now() + 86400000 } })); +const source = pathToFileURL(repoRoot()).href; +const diagnosticRows: any[] = []; +const originRows: any[] = []; +const originalInfo = console.info; +console.info = (...args: any[]) => { + if (args[0] === "[opencodex-request-origin]") { originRows.push(JSON.parse(args[1])); return; } + if (args[0] === "[anthropic-cache-renewal]") { diagnosticRows.push(JSON.parse(args[1])); return; } + originalInfo(...args); +}; +afterAll(() => { console.info = originalInfo; }); +beforeEach(() => { diagnosticRows.length = 0; originRows.length = 0; }); +const clock = new Clock(); +let denied = 0; +function deny(): never { denied++; throw new Error("离线验证禁止真实连接"); } +const savedNetwork = [net.connect, net.createConnection, tls.connect, http.request, http.get, https.request, https.get, http2.connect, Bun.connect, globalThis.WebSocket, globalThis.fetch] as const; +net.connect = net.createConnection = tls.connect = http.request = http.get = https.request = https.get = http2.connect = Bun.connect = deny as any; +globalThis.WebSocket = class { constructor() { deny(); } } as any; +const listeners = new Map void>(); +const originalRuntime = { ...await import(`${source}/src/codex/cache-runtime-state.ts`) }; +mock.module(`${source}/src/codex/cache-runtime-state.ts`, () => ({ ...originalRuntime, CodexCacheRuntime: class { + watch(thread: string, update: (state: any) => void, expectedTurn: string) { + expect(expectedTurn).toBe("22222222-2222-4222-8222-222222222222"); + listeners.set(thread, update); update({ running: true, turn: null }); return () => { listeners.delete(thread); }; + } + async active(thread: string) { return listeners.has(thread); } +} })); +const original = { ...await import(`${source}/src/anthropic/cache-renewal.ts`) }; +mock.module(`${source}/src/anthropic/cache-renewal.ts`, () => ({ ...original, + AnthropicCacheRenewal: class extends original.AnthropicCacheRenewal { + constructor(deps: any) { super({ ...deps, now: () => clock.now, later: clock.later, clear: clock.clear }); } + }, +})); +const sent: any[] = []; +const cache = new Map(); +let hold = false, finishStream: (() => void) | undefined, coldWarm = false; +let hangRenewal = false, deferNatural = false, releaseNatural: (() => void) | undefined; +let emptyNatural = false, terminalNatural = false, refuseContinuation = false; +let refuseImages: "once" | "always" | undefined; +let cancelImageRefusal: AbortController | undefined, invalidImageSummary = false; +const encoder = new TextEncoder(); +function frame(event: string, data: any) { return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } +globalThis.fetch = (async (url: any, init: any) => { + if (String(url) !== "https://api.anthropic.com/v1/messages") deny(); + const body = JSON.parse(String(init.body)); const key = JSON.stringify({ ...body, max_tokens: undefined, stream: undefined }); + const hit = (cache.get(key) ?? -1) > clock.now && !(body.max_tokens === 0 && coldWarm); + const usage = { input_tokens: 4, cache_read_input_tokens: hit ? 5000 : 0, cache_creation_input_tokens: hit ? 0 : 5000, output_tokens: 0 }; + sent.push({ url: String(url), headers: Object.fromEntries(new Headers(init.headers)), body, at: clock.now }); + cache.set(key, clock.now + 300000); + if (body.max_tokens === 0) { + if (hangRenewal) return new Promise(() => {}); + expect(body.stream).toBe(false); + return Response.json({ id: "msg_warm", type: "message", model: body.model, content: [], stop_reason: "max_tokens", usage }); + } + if (refuseContinuation && !emptyNatural) return new Response("request too large", { status: 413 }); + if (invalidImageSummary && JSON.stringify(body.messages.at(-1)).includes("Return only the handoff summary")) { + return Response.json({ content: [], stop_reason: "end_turn", usage }); + } + if (refuseImages && JSON.stringify(body.messages).includes('"type":"image"') + && !JSON.stringify(body.messages.at(-1)).includes("Return only the handoff summary")) { + if (refuseImages === "once") refuseImages = undefined; + cancelImageRefusal?.abort(); + return Response.json({ error: { type: "request_too_large", message: "image fixture exceeds request limit" } }, { status: 413 }); + } + if (emptyNatural) { + emptyNatural = false; + const empty = { id: "msg_empty", type: "message", model: body.model, content: [], stop_reason: "end_turn", usage }; + if (!body.stream) return Response.json(empty); + return new Response(new ReadableStream({ start(controller) { + controller.enqueue(frame("message_start", { type: "message_start", message: empty })); + controller.enqueue(frame("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage })); + controller.enqueue(frame("message_stop", { type: "message_stop" })); controller.close(); + } }), { headers: { "content-type": "text/event-stream" } }); + } + if (deferNatural) await new Promise(resolve => { releaseNatural = resolve; }); + const text = terminalNatural ? "I will inspect the file now." : "Fixture summary."; terminalNatural = false; + const result = { id: "msg_natural", type: "message", model: body.model, content: [{ type: "text", text }], stop_reason: "end_turn", usage: { ...usage, output_tokens: 3 } }; + if (!body.stream) return Response.json(result); + return new Response(new ReadableStream({ start(controller) { + controller.enqueue(frame("message_start", { type: "message_start", message: { ...result, content: [], usage } })); + const finish = () => { + controller.enqueue(frame("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })); + controller.enqueue(frame("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } })); + controller.enqueue(frame("content_block_stop", { type: "content_block_stop", index: 0 })); + controller.enqueue(frame("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: result.usage })); + controller.enqueue(frame("message_stop", { type: "message_stop" })); controller.close(); + }; + if (hold) finishStream = finish; else finish(); + } }), { headers: { "content-type": "text/event-stream" } }); +}) as typeof fetch; + +const { handleResponses } = await import(`${source}/src/server/responses/core.ts`); +const { handleResponsesCompact } = await import(`${source}/src/server/responses/compact.ts`); +const { observeCacheDiagnosticInbound, addFinalRequestLog, addRequestLog, getRequestLogEntries } = await import(`${source}/src/server/request-log.ts`); +const { stopAnthropicCacheRenewal, naturalCacheScope } = await import(`${source}/src/anthropic/cache-renewal-runtime.ts`); +const { acquireSpendLedgerServerLifecycle } = await import(`${source}/src/server/index/spend-ledger-lifecycle.ts`); +const ledger = acquireSpendLedgerServerLifecycle(process.env.OPENCODEX_HOME!); ledger.configure(undefined); +afterAll(() => { + stopAnthropicCacheRenewal(); ledger.release(); + mock.module(`${source}/src/codex/cache-runtime-state.ts`, () => originalRuntime); + mock.module(`${source}/src/anthropic/cache-renewal.ts`, () => original); + [net.connect, net.createConnection, tls.connect, http.request, http.get, https.request, https.get, http2.connect, Bun.connect, globalThis.WebSocket, globalThis.fetch] = savedNetwork as any; + for (const key of ["OPENCODEX_HOME", "CODEX_HOME", "TEMP", "TMP"]) { + if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; + } + rmSync(root, { recursive: true, force: true }); +}); +beforeEach(() => { stopAnthropicCacheRenewal(); clock.jobs.clear(); clock.now = 0; sent.length = 0; cache.clear(); hold = false; finishStream = undefined; coldWarm = false; + hangRenewal = deferNatural = emptyNatural = terminalNatural = refuseContinuation = false; releaseNatural = undefined; }); +beforeEach(() => { refuseImages = undefined; cancelImageRefusal = undefined; invalidImageSummary = false; }); + +function config(retention = "short"): any { return { defaultProvider: "anthropic", cacheRetention: retention, + anthropicCacheRenewal: true, multiAgentGuidanceEnabled: false, + webSearchSidecar: { enabled: true, backend: "exa", exaApiKey: "offline-only-not-a-key" }, + providers: { anthropic: { baseUrl: "https://api.anthropic.com", apiKey: "offline-only-not-a-key", adapter: "anthropic", authMode: "oauth", models: ["claude-opus-5-5"] } } }; } + +for (const enabled of [undefined, false, true]) for (const retention of [undefined, "short", "long", "none"]) { + if (enabled === true && retention === "short") continue; + test(`renewal preserves the opt-in and configured TTL: enabled=${enabled}, retention=${retention}`, async () => { + const cfg = config(retention); cfg.anthropicCacheRenewal = enabled; + if (retention === undefined) delete cfg.cacheRetention; + const { response } = await invoke("main", "http", false, cfg); await response.text(); + await clock.advance(900000); + expect(sent).toHaveLength(1); expect(listeners.size).toBe(0); + expect(sent[0].body.cache_control?.ttl).toBe(retention === undefined || retention === "long" ? "1h" : undefined); + }); +} +function request(role = "main", entry = "http", stream = false) { + const child = role === "child", ws = entry.endsWith("ws"), compact = entry.startsWith("compact"); + const own = child ? "11111111-1111-4111-8111-111111111112" : "11111111-1111-4111-8111-111111111111"; + const metadata = { request_kind: compact ? "compaction" : "turn", thread_source: child ? "subagent" : role === "helper" ? "memory_consolidation" : "user", ...(child ? { subagent_kind: "thread_spawn" } : {}) }; + const headers: any = { "content-type": "application/json", "thread-id": own, "session-id": "parent", + ...(child ? { "x-codex-parent-thread-id": "parent", "x-openai-subagent": "collab_spawn" } : {}), + ...(!ws ? { "x-codex-turn-metadata": JSON.stringify(metadata) } : {}) }; + const body: any = { model: "anthropic/claude-opus-5-5", stream, input: [{ role: "user", content: [{ type: "input_text", text: "Fixture request." }] }], + tools: [{ type: "function", name: "fixture_tool", parameters: { type: "object", properties: {} } }, { type: "web_search" }], + reasoning: { effort: "max" }, client_metadata: { thread_id: own, turn_id: "22222222-2222-4222-8222-222222222222", ...(ws ? { "x-codex-turn-metadata": JSON.stringify(metadata) } : {}) } }; + if (compact && entry !== "compact-v1") body.input.push({ type: "compaction_trigger" }); + return { req: new Request(`http://localhost/v1/responses${entry === "compact-v1" ? "/compact" : ""}`, { method: "POST", headers, body: JSON.stringify(body) }), + options: { recordTerminalOutcomes: false, ...(ws ? { inboundTransport: "websocket" } : {}) }, own }; +} +async function invoke(role: string, entry: string, stream: boolean, cfg = config()) { + const r = request(role, entry, stream); + const logCtx: any = { model: "", provider: "" }; + const response = await (entry === "compact-v1" ? handleResponsesCompact : handleResponses)(r.req, cfg, logCtx, entry === "compact-v1" ? undefined : r.options, + ...(entry === "compact-v1" ? [undefined, r.options] : [])); + if (!response.ok) throw new Error(await response.text()); + return { ...r, response, logCtx }; +} + +for (const enabled of [false, true]) for (const trigger of ["empty", "terminal"]) test(`non-stream ${trigger} continuation follows the actual wire: renewal=${enabled}`, async () => { + emptyNatural = trigger === "empty"; terminalNatural = trigger === "terminal"; + const cfg = config(); cfg.emptyCompletionRetry = true; cfg.webSearchSidecar.enabled = false; + cfg.anthropicCacheRenewal = enabled; + const r = request("main", "http", false), raw = await r.req.json() as any; + raw.input[0].content[0].text = "Inspect the file with fixture_tool and continue."; + const response = await handleResponses(new Request(r.req.url, { method: "POST", headers: r.req.headers, body: JSON.stringify(raw) }), cfg, { model: "", provider: "" } as any, r.options); + const result = await response.json(); + expect(result.status).toBe("completed"); expect(JSON.stringify(result.output)).toContain("Fixture summary."); + expect(sent).toHaveLength(2); +}); + +test("an Anthropic 413 continuation is not resent with identical historical images", async () => { + emptyNatural = refuseContinuation = true; + const r = request("main", "http", false); + const raw = await r.req.json() as any; + raw.input[0].content.push({ type: "input_image", image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aHe8AAAAASUVORK5CYII=" }); + const cfg = config(); cfg.emptyCompletionRetry = true; cfg.webSearchSidecar.enabled = false; + const response = await handleResponses(new Request(r.req.url, { method: "POST", headers: r.req.headers, body: JSON.stringify(raw) }), cfg, { model: "", provider: "" } as any, r.options); + const text = await response.text(); + expect(text).toContain("empty_completion_retry_failed"); + expect(sent).toHaveLength(2); +}); + +for (const scope of ["root", "identity", "pool"]) test(`renewal cannot bypass a configured ${scope} spend ceiling`, async () => { + const cfg = config(); cfg.spend = { [scope]: { maxTokens: 1000000 } }; + const { response } = await invoke("main", "http", false, cfg); await response.text(); + await clock.advance(270000); + expect(sent).toHaveLength(1); expect(listeners.size).toBe(0); +}); + +test("a spend ceiling added after confirmation stops subsequent renewal", async () => { + const cfg = config(); + const { response } = await invoke("main", "http", false, cfg); await response.text(); + cfg.spend = { root: { maxTokens: 1000000 } }; + await clock.advance(270000); + expect(sent).toHaveLength(1); +}); + +for (const enabled of [false, true]) test(`image-count pressure preserves prefixes and latest tool pair independently of renewal: enabled=${enabled}`, async () => { + const beforeLogs = new Set(getRequestLogEntries().map(row => row.requestId)); + const cfg = config(); cfg.anthropicCacheRenewal = enabled; + const baseline = await invoke("main", "http", false, cfg); await baseline.response.text(); + const prefix = sent[0].body; + const r = request("main", "http", false), raw = await r.req.json() as any; + const own = randomUUID(), headers = new Headers(r.req.headers); headers.set("thread-id", own); + raw.client_metadata.thread_id = own; + const image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aHe8AAAAASUVORK5CYII="; + raw.input = [{ role: "user", content: "Inspect the images and continue the task." }, + ...Array.from({ length: 101 }, (_, i) => [ + { type: "function_call", call_id: `image_${i}`, name: "fixture_tool", arguments: "{}" }, + { type: "function_call_output", call_id: `image_${i}`, output: [{ type: "input_image", image_url: image }] }, + ]).flat()]; + const send = async () => { + const response = await handleResponses(new Request(r.req.url, { method: "POST", headers, body: JSON.stringify(raw) }), + cfg, { model: "", provider: "" } as any, r.options); + const text = await response.text(); expect(response.ok).toBe(true); expect(text).toContain("response.completed"); + }; + const summaries = () => sent.filter(item => JSON.stringify(item.body.messages.at(-1)).includes("CONTEXT CHECKPOINT COMPACTION")); + await send(); + expect(summaries().length).toBeGreaterThan(0); + for (const item of summaries()) { + expect(item.body.tool_choice).toEqual(prefix.tool_choice); + expect(item.body.tools).toEqual(prefix.tools); + expect(item.body.system).toEqual(prefix.system); + } + const final = sent.at(-1).body; + const { collectImageRefs } = await import(`${source}/src/adapters/anthropic-image-guard.ts`); + expect(collectImageRefs(final.messages).length).toBeLessThanOrEqual(100); + expect(JSON.stringify(final.messages)).toContain("image_100"); + const count = summaries().length; + await send(); expect(summaries()).toHaveLength(count); expect(denied).toBe(0); + const summaryLogs = getRequestLogEntries().filter(row => !beforeLogs.has(row.requestId)); + expect(summaryLogs.length).toBeGreaterThan(0); + expect(summaryLogs.every(row => row.status === 200 && !row.errorCode)).toBe(true); + if (!enabled) { + await clock.advance(270000); + expect(sent.some(item => item.body.max_tokens === 0)).toBe(false); + expect(listeners.size).toBe(0); + } +}); + +for (const role of ["main", "child"]) for (const entry of ["http", "ws"]) { + test(`续期超时后在途自然请求仍能接替 ${role} ${entry}`, async () => { + const first = await invoke(role, entry, false); await first.response.text(); + await clock.advance(249000); hangRenewal = deferNatural = true; + const pending = invoke(role, entry, false); + for (let n = 0; n < 200 && !releaseNatural; n++) await Bun.sleep(5); + expect(releaseNatural).toBeDefined(); + await clock.advance(300000); expect(diagnosticRows[0].outcome).toBe("timeout"); + await clock.advance(313000); deferNatural = false; releaseNatural!(); + const second = await pending; await second.response.text(); + expect(listeners.has(second.own)).toBe(true); + hangRenewal = false; + await clock.advance(518999); expect(sent.filter(x => x.body.max_tokens === 0)).toHaveLength(1); + await clock.advance(519000); expect(sent.filter(x => x.body.max_tokens === 0)).toHaveLength(2); + expect(diagnosticRows[1].outcome).toBe("hit"); + expect(denied).toBe(0); + }); +} + +for (const role of ["main", "child"]) for (const entry of ["http", "ws", "compact-v1", "compact-v2-http", "compact-v2-ws"]) { + test(`${entry} ${role} 经实际入口续同一前缀`, async () => { + const { response, own } = await invoke(role, entry, false); + const text = await response.text(); expect(response.ok).toBe(true); expect(text.length).toBeGreaterThan(0); + expect(response.headers.get("content-type")).toContain(entry.startsWith("compact") ? "application/json" : "text/event-stream"); + if (entry.startsWith("compact")) expect(() => JSON.parse(text)).not.toThrow(); + expect(sent[0].body.tools.some((tool: any) => tool.name === "web_search")).toBe(true); + expect(sent).toHaveLength(1); expect(sent[0].body.stream).toBe(true); expect(listeners.has(own)).toBe(true); + await clock.advance(270000); + expect(sent).toHaveLength(2); expect(sent[1].body).toEqual({ ...sent[0].body, max_tokens: 0, stream: false }); + expect(sent[1].headers["x-api-key"]).toBe(sent[0].headers["x-api-key"]); + expect(sent[1].body.cache_control.ttl ?? "5m").toBe("5m"); + expect(diagnosticRows).toHaveLength(1); + expect(diagnosticRows[0]).toMatchObject({ threadId: own, turnId: "22222222-2222-4222-8222-222222222222", + role: role === "child" ? "subagent" : "main", sent: true, outcome: "hit", attempt: 1, cacheAgeMs: 270000 }); + const persisted = readFileSync(join(root, "home", "usage.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + expect(persisted.some(row => row.requestId === diagnosticRows[0].requestId && row.errorCode === "cache_renewal_hit")).toBe(true); + expect(JSON.stringify(diagnosticRows[0])).not.toContain("offline-only-not-a-key"); + }); +} + +for (const role of ["main", "child"]) for (const entry of ["http", "ws"]) { + test(`实际发送保留已确认断点,续期接替修改后的完整请求 ${role} ${entry}`, async () => { + const r = request(role, entry, false), body = await r.req.json() as any; + body.input = [{ role: "user", content: "old user" }, { role: "assistant", content: "old assistant" }, + { role: "user", content: "confirmed tail" }]; + const send = async () => { + const response = await handleResponses(new Request(r.req.url, { method: "POST", headers: r.req.headers, body: JSON.stringify(body) }), + config(), { model: "", provider: "" } as any, r.options); + expect(response.ok).toBe(true); await response.text(); + }; + await send(); + const oldCount = sent[0].body.messages.length; + for (let i = 0; i < 22; i++) body.input.push({ role: "user", content: `notification ${i}` }); + await send(); + expect(sent).toHaveLength(2); + expect(sent[1].body.messages[oldCount - 1].content.at(-1).cache_control).toEqual({ type: "ephemeral" }); + expect(sent[1].body.messages.length - oldCount).toBe(22); + await clock.advance(270000); + expect(sent).toHaveLength(3); + expect(sent[2].body).toEqual({ ...sent[1].body, max_tokens: 0, stream: false }); + expect(diagnosticRows.at(-1).outcome).toBe("hit"); + expect(denied).toBe(0); + }); +} + +for (const clientStream of [true, false]) test(`长生成未结束时真实流解析已触发续期,客户端 stream=${clientStream}`, async () => { + hold = true; + let response: Response | undefined; + const pending = invoke("main", "http", clientStream).then(value => { response = value.response; return value.response.text(); }); + for (let n = 0; n < 100 && !finishStream; n++) await Bun.sleep(10); + expect(finishStream).toBeDefined(); await Bun.sleep(20); + await clock.advance(540000); expect(sent.filter(x => x.body.max_tokens === 0)).toHaveLength(2); + finishStream!(); expect(await pending).toContain(clientStream ? "response.completed" : "Fixture summary."); + expect(response!.ok).toBe(true); +}); + +test("真正发生冷写后不再循环,续期费用独立记入用量", async () => { + const { response } = await invoke("main", "http", false); await response.text(); coldWarm = true; + await clock.advance(900000); expect(sent).toHaveLength(2); + const logs = readFileSync(join(root, "home/usage.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + const warm = logs.filter(row => row.errorCode?.startsWith("cache_renewal_")); + expect(warm.some(row => row.errorCode === "cache_renewal_unexpected-write" && row.usage.cacheCreationInputTokens === 5000)).toBe(true); +}); + +test("助手与全局关闭缓存不会发送后台请求", async () => { + for (const [role, retention] of [["helper", "short"], ["main", "none"]]) { + const { response } = await invoke(role, "http", false, config(retention)); await response.text(); + } + await clock.advance(900000); expect(sent).toHaveLength(2); expect(listeners.size).toBe(0); +}); + +test("运行状态结束时停止,恢复网络或读取状态不自动重建缓存", async () => { + const { response, own } = await invoke("child", "http", false); await response.text(); + listeners.get(own)!({ running: false, turn: `turn-${own}` }); + await clock.advance(900000); expect(sent).toHaveLength(1); +}); + +test("缓存策略仅统一明确主子代理,其他适配器没有续期上下文", () => { + const r = request(); + expect(naturalCacheScope({ parsed: { _codexOwnThreadId: r.own } as any, req: r.req, config: config(), options: {}, adapterName: "openai-responses", providerName: "openai", oauth: false })).toBeUndefined(); + expect(denied).toBe(0); +}); + +test("自身轮次只读当前正文,拒绝错配对话和预热,WS忽略旧握手元数据", async () => { + const { codexRequestTurn } = await import(`${source}/src/codex/request-provenance.ts`); + const r = request("child", "ws"); + const body = await r.req.json(); + expect(codexRequestTurn(body, r.req.headers)).toEqual({ threadId: r.own, turnId: "22222222-2222-4222-8222-222222222222" }); + expect(codexRequestTurn({ ...body, generate: false }, r.req.headers)).toBeUndefined(); + expect(codexRequestTurn({ ...body, client_metadata: { ...body.client_metadata, turn_id: "" } }, r.req.headers)).toBeUndefined(); + expect(codexRequestTurn({ ...body, client_metadata: { ...body.client_metadata, thread_id: "parent" } }, r.req.headers)).toBeUndefined(); + const headers = new Headers(r.req.headers); headers.set("x-codex-turn-metadata", JSON.stringify({ request_kind: "helper" })); + const scope = naturalCacheScope({ parsed: { _rawBody: body, _codexOwnThreadId: r.own } as any, req: new Request(r.req.url, { headers }), + options: { inboundTransport: "websocket" }, config: config(), adapterName: "anthropic", providerName: "anthropic", oauth: true }); + expect(scope?.turnId).toBe("22222222-2222-4222-8222-222222222222"); +}); + +for (const cap of [1, 2]) test(`长生成遵守账号并发限制 maxConcurrentRequests=${cap}`, async () => { + hold = true; + const cfg = config(); cfg.providers.anthropic.requestPacing = { enabled: true, maxConcurrentRequests: cap }; + const pending = invoke("main", "http", false, cfg).then(value => value.response.text()); + for (let n = 0; n < 100 && !finishStream; n++) await Bun.sleep(10); + expect(finishStream).toBeDefined(); await Bun.sleep(20); + await clock.advance(285000); + expect(sent.filter(x => x.body.max_tokens === 0)).toHaveLength(cap === 2 ? 1 : 0); + finishStream!(); expect(await pending).toContain("Fixture summary."); +}); + + +test("联网路径自然请求接替旧前缀和计时,不再续第一笔请求", async () => { + const initial = request("main", "http", true); const initialBody = await initial.req.json(); + initialBody.tools = initialBody.tools.filter((tool: any) => tool.type !== "web_search"); + const initialReq = new Request(initial.req.url, { method: "POST", headers: initial.req.headers, body: JSON.stringify(initialBody) }); + const first = await handleResponses(initialReq, config(), { model: "", provider: "" } as any, initial.options); + await first.text(); + expect(sent).toHaveLength(1); + await clock.advance(200000); + const next = request("main", "ws"); const raw = await next.req.json(); + raw.input.push({ role: "user", content: [{ type: "input_text", text: "Latest natural prefix." }] }); + const req = new Request(next.req.url, { method: "POST", headers: next.req.headers, body: JSON.stringify(raw) }); + const response = await handleResponses(req, config(), { model: "", provider: "" } as any, next.options); + expect(response.ok).toBe(true); await response.text(); + expect(sent).toHaveLength(2); + await clock.advance(270000); expect(sent).toHaveLength(2); + await clock.advance(470000); expect(sent).toHaveLength(3); + expect(sent[2].body).toEqual({ ...sent[1].body, max_tokens: 0, stream: false }); + expect(JSON.stringify(sent[2].body)).toContain("Latest natural prefix."); + expect(denied).toBe(0); +}); + +const ownA = "11111111-1111-4111-8111-111111111111"; +const ownB = "11111111-1111-4111-8111-111111111112"; +const turnA = "22222222-2222-4222-8222-222222222222"; +const logId = "ocx-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +function finish(logCtx: any, id = logId) { + const emitted: any[] = []; + addFinalRequestLog(id, 1792304678673, logCtx, 200, undefined, (row: any) => { emitted.push(row); addRequestLog(row); }); + expect(emitted).toHaveLength(1); + return originRows.at(-1); +} + +for (const role of ["main", "child"]) for (const entry of ["http", "ws", "compact-v1"]) { + test(`来源关联经实际入口到最终原请求编号 ${role} ${entry}`, async () => { + const r = await invoke(role, entry, false); await r.response.text(); + const result = finish(r.logCtx); + expect(result).toMatchObject({ requestId: logId, captured: true, identity: "matched", threadId: r.own, turnId: turnA, + role: role === "child" ? "subagent" : "main", purpose: entry === "compact-v1" ? "compaction" : "turn", + endpoint: entry === "compact-v1" ? "responses-compact" : "responses", transport: entry === "ws" ? "websocket" : "http" }); + expect(result.pid).toBe(process.pid); + expect(result.tools).toBe(2); + expect(denied).toBe(0); + }); +} + +test("两笔同父子任务与不同轮次的日志不串绑", async () => { + const ctxA: any = { model: "claude-opus-5-5", provider: "anthropic" }, ctxB: any = { ...ctxA }; + const parent = "33333333-3333-4333-8333-333333333333"; + for (const [ctx, own, turn] of [[ctxA, ownA, turnA], [ctxB, ownB, parent]]) { + observeCacheDiagnosticInbound(ctx, { client_metadata: { thread_id: own, turn_id: turn }, input: [] }, + new Headers({ "thread-id": own, "x-codex-parent-thread-id": parent, "x-openai-subagent": "collab_spawn" }), "caller"); + finish(ctx); + } + expect(originRows.map(x => x.threadId)).toEqual([ownA, ownB]); + expect(originRows.map(x => x.turnId)).toEqual([turnA, parent]); + expect(originRows.every(x => x.parentThreadId === parent && x.role === "subagent")).toBe(true); +}); + +test("WS切换只读取本帧用途,不借握手中的旧主代理元数据", async () => { + const r = request("child", "ws"), raw = await r.req.json(); + r.req.headers.set("x-codex-turn-metadata", JSON.stringify({ request_kind: "turn", thread_source: "user" })); + const logCtx: any = { model: "", provider: "" }; + const response = await handleResponses(new Request(r.req.url, { method: "POST", headers: r.req.headers, body: JSON.stringify(raw) }), config(), logCtx, r.options); + await response.text(); expect(finish(logCtx)).toMatchObject({ role: "subagent", transport: "websocket", threadId: ownB }); +}); + +test("缺少本帧元数据时,WS握手不能提供用途", () => { + const ctx: any = { model: "claude-opus-5-5", provider: "anthropic" }; + observeCacheDiagnosticInbound(ctx, { client_metadata: { thread_id: ownA, turn_id: turnA } }, + new Headers({ "thread-id": ownA, "x-codex-turn-metadata": JSON.stringify({ request_kind: "turn", thread_source: "user" }) }), "caller", { transport: "websocket" }); + expect(finish(ctx)).toMatchObject({ purpose: "unknown", role: "unknown", identity: "matched" }); +}); + +test("自身身份冲突明确标记,不能绑定到任意一方", () => { + const ctx: any = { model: "claude-opus-5-5", provider: "anthropic" }; + observeCacheDiagnosticInbound(ctx, { client_metadata: { thread_id: ownB, turn_id: turnA } }, new Headers({ "thread-id": ownA }), "caller"); + const result = finish(ctx); expect(result).toMatchObject({ identity: "conflict", headerThreadId: ownA, metadataThreadId: ownB, role: "unknown" }); + expect(result.threadId).toBeUndefined(); expect(result.turnId).toBeUndefined(); +}); + +test("缺少自身身份不能使用父ID或session ID冒充", () => { + const ctx: any = { model: "claude-opus-5-5", provider: "anthropic" }; + observeCacheDiagnosticInbound(ctx, { input: [] }, new Headers({ "x-codex-parent-thread-id": ownA, "session-id": ownA }), "caller"); + const result = finish(ctx); expect(result).toMatchObject({ identity: "missing", parentThreadId: ownA, captured: true }); + expect(result.threadId).toBeUndefined(); expect(result.turnId).toBeUndefined(); +}); + +test("只提供自身header时保留对话线索,明确轮次未确认", () => { + const ctx: any = { model: "claude-opus-5-5", provider: "anthropic" }; + observeCacheDiagnosticInbound(ctx, {}, new Headers({ "thread-id": ownA }), "caller"); + expect(finish(ctx)).toMatchObject({ identity: "header-only", threadId: ownA }); expect(originRows.at(-1).turnId).toBeUndefined(); +}); + +test("内部辅助请求带用途及store/generate形状,不继承主代理角色", () => { + const ctx: any = { model: "claude-opus-5-5", provider: "anthropic" }; + observeCacheDiagnosticInbound(ctx, { store: false, generate: false, stream: false, client_metadata: { thread_id: ownA, turn_id: turnA } }, + new Headers({ "thread-id": ownA }), "caller", { internalHelper: true }); + expect(finish(ctx)).toMatchObject({ purpose: "helper", role: "unknown", store: false, generate: false, stream: false }); +}); + +test("缺少调用用途时不根据提示词猜后台类型", () => { + const ctx: any = { model: "claude-opus-5-5", provider: "anthropic" }; + observeCacheDiagnosticInbound(ctx, { input: "Generate a title or extract memories", client_metadata: { thread_id: ownA, turn_id: turnA } }, new Headers({ "thread-id": ownA }), "caller"); + expect(finish(ctx)).toMatchObject({ role: "unknown", purpose: "unknown", inputItems: 1 }); +}); + +test("重试及压缩重放保留原入口快照,正文对象后改不改变日志", () => { + const ctx: any = { model: "claude-opus-5-5", provider: "anthropic" }; + const body: any = { input: [{ role: "user", content: "private" }], tools: [{}], stream: true, client_metadata: { thread_id: ownA, turn_id: turnA } }; + observeCacheDiagnosticInbound(ctx, body, new Headers({ "thread-id": ownA }), "caller"); body.input.push({}); body.tools.push({}); body.client_metadata.thread_id = ownB; + observeCacheDiagnosticInbound(ctx, body, new Headers({ "thread-id": ownB }), "caller", { internalHelper: true }); + expect(finish(ctx)).toMatchObject({ threadId: ownA, inputItems: 1, tools: 1, stream: true, identity: "matched" }); +}); + +test("正文、图片、工具参数、凭据和非法身份都不进入关联日志", () => { + const secret = "PRIVATE_CANARY_DO_NOT_LOG", ctx: any = { model: "claude-opus-5-5", provider: "anthropic" }; + observeCacheDiagnosticInbound(ctx, { input: [{ content: [{ type: "input_text", text: secret }, { type: "input_image", image_url: secret }] }], tools: [{ parameters: { secret } }], client_metadata: { thread_id: secret, turn_id: secret } }, + new Headers({ authorization: secret, "thread-id": secret, "x-codex-parent-thread-id": secret, "session-id": secret }), "caller"); + const result = finish(ctx); expect(result.identity).toBe("invalid"); expect(JSON.stringify(result)).not.toContain(secret); + expect(Object.keys(result).sort()).toEqual(["version", "pid", "requestId", "startedAt", "status", "captured", "endpoint", "transport", "identity", "role", "purpose", "inputItems", "tools"].sort()); +}); + +test("最终边界再次白名单过滤被污染的上下文", () => { + const secret = "PRIVATE_CANARY_DO_NOT_LOG", ctx: any = { model: "claude-opus-5-5", provider: "anthropic", requestOrigin: { + threadId: secret, turnId: secret, endpoint: secret, role: secret, purpose: secret, transport: secret, identity: secret, extra: secret, stream: secret, inputItems: secret, + } }; + const result = finish(ctx); expect(JSON.stringify(result)).not.toContain(secret); expect(result.role).toBe("unknown"); expect(result.endpoint).toBe("unknown"); +}); + +test("旧零命中缺入口记录时保留原requestId并明确未知,不补造历史身份", () => { + const ctx: any = { model: "claude-opus-5-5", provider: "anthropic", usage: { inputTokens: 164333, cacheReadInputTokens: 0, cacheCreationInputTokens: 164329, outputTokens: 62 } }; + expect(finish(ctx)).toMatchObject({ requestId: logId, captured: false, identity: "missing", role: "unknown", purpose: "unknown" }); +}); + +test("自定义Anthropic provider按适配器识别;其他provider不新增日志", () => { + const ctx: any = { model: "model", provider: "custom", providerAdapter: "anthropic" }; + finish(ctx); expect(originRows).toHaveLength(1); finish({ model: "gpt", provider: "other" }); expect(originRows).toHaveLength(1); +}); + +test("关联日志写入失败不改变正常请求完成", () => { + const saved = console.info; console.info = () => { throw new Error("offline-log-failure"); }; + try { expect(() => finish({ model: "model", provider: "anthropic" })).not.toThrow(); } finally { console.info = saved; } +}); + +test("直接用量写入路径同样输出原编号且不伪造身份", () => { + addRequestLog({ requestId: logId, timestamp: 1792304678673, model: "claude-opus-5-5", provider: "anthropic", status: 200, durationMs: 10013, + usage: { inputTokens: 164333, cacheReadInputTokens: 0, cacheCreationInputTokens: 164329, outputTokens: 62 }, totalTokens: 164395 }); + expect(originRows).toHaveLength(1); + expect(originRows[0]).toMatchObject({ requestId: logId, captured: false, role: "unknown", purpose: "unknown" }); +}); + +test("来源对象不写入用量JSONL,单个请求只有一条关联记录", () => { + const ctx: any = { provider: "anthropic", model: "claude-opus-5-5" }; + observeCacheDiagnosticInbound(ctx, { input: [] }, new Headers({ "thread-id": ownA }), "caller"); finish(ctx); + expect(originRows).toHaveLength(1); + const disk = readFileSync(join(root, "home/usage.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + expect(disk.at(-1).requestOriginDiagnostic).toBeUndefined(); + expect(getRequestLogEntries().at(-1).requestOriginDiagnostic).toBeUndefined(); + expect(disk.at(-1).requestId).toBe(logId); +}); + +async function imageRecoveryRequest(stream = false) { + const r = request("main", "http", stream), raw = await r.req.json(); + const own = randomUUID(); r.req.headers.set("thread-id", own); raw.client_metadata.thread_id = own; + const image = { type: "input_image", image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2G0AAAAAASUVORK5CYII=" }; + raw.input = [{ role: "user", content: [{ type: "input_text", text: "Old image." }, image] }, + { role: "assistant", content: "Old image inspected." }, { role: "user", content: [{ type: "input_text", text: "Inspect newest image." }, image] }]; + return { r, raw }; +} + +for (const enabled of [false, true]) for (const stream of [false, true]) for (const search of [false, true, "media"] as const) { + if (search === "media" && !stream) continue; + test(`上游图片413在同轮压缩后继续,renewal=${enabled}, stream=${stream}, search=${search}`, async () => { + refuseImages = "once"; + const cfg = config(); cfg.anthropicCacheRenewal = enabled; cfg.webSearchSidecar.enabled = search === true; + cfg.providers.anthropic.requestPacing = { enabled: true, maxConcurrentRequests: 1 }; + const { r, raw } = await imageRecoveryRequest(stream); + if (search !== true) raw.tools = raw.tools.filter((tool: any) => tool.type !== "web_search"); + if (search === "media") { + raw.tools.push({ type: "image_generation" }); cfg.images = { bridgeEnabled: true }; + cfg.providers.xai = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "offline-only-not-a-key", proxy: "direct" }; + } + const response = await handleResponses(new Request(r.req.url, { method: "POST", headers: r.req.headers, body: JSON.stringify(raw) }), cfg, + { model: "", provider: "" } as any, { ...r.options, abortSignal: AbortSignal.timeout(5000) }); + const text = await response.text(); + expect(response.ok).toBe(true); expect(text).toContain("Fixture summary."); expect(text).not.toContain("response.failed"); + expect(sent).toHaveLength(3); + expect(sent[2].body.tools).toEqual(sent[0].body.tools); expect(sent[2].body.system).toEqual(sent[0].body.system); + expect(JSON.stringify(sent[2].body.messages)).toContain("Inspect newest image."); + expect(sent[2].body.messages.flatMap((m: any) => Array.isArray(m.content) ? m.content : []).filter((p: any) => p.type === "image")).toHaveLength(1); + expect(denied).toBe(0); + }); +} + +for (const scenario of ["second-413", "text-only", "budget", "cancelled", "invalid-summary", "checkpoint-replay"] as const) { + test(`上游图片413恢复边界 ${scenario}`, async () => { + refuseImages = scenario === "second-413" ? "always" : "once"; + const cfg = config(); cfg.anthropicCacheRenewal = false; cfg.webSearchSidecar.enabled = false; cfg.emptyCompletionRetry = false; + const { r, raw } = await imageRecoveryRequest(); raw.tools = raw.tools.filter((tool: any) => tool.type !== "web_search"); + const options: any = { ...r.options, abortSignal: AbortSignal.timeout(5000) }; + if (scenario === "text-only") { refuseContinuation = true; raw.input = [{ role: "user", content: "Only text." }]; } + if (scenario === "budget") { + const { createRequestExecutionBudget, CODEX_TEXT_GUARDED_BUDGET_POLICY } = await import(`${source}/src/lib/request-execution-budget.ts`); + options.sendBudget = createRequestExecutionBudget({ ...CODEX_TEXT_GUARDED_BUDGET_POLICY, maxTotalModelSends: 1, baseSendAllowance: 1, finalRecoveryAllowance: 0 }); + } + if (scenario === "cancelled") { cancelImageRefusal = new AbortController(); options.abortSignal = cancelImageRefusal.signal; } + if (scenario === "invalid-summary") invalidImageSummary = true; + const send = () => handleResponses(new Request(r.req.url, { method: "POST", headers: r.req.headers, body: JSON.stringify(raw) }), cfg, + { model: "", provider: "" } as any, options); + const response = await send(); await response.text(); + expect(response.status).toBe(scenario === "cancelled" ? 499 : scenario === "invalid-summary" ? 502 : scenario === "checkpoint-replay" ? 200 : 413); + expect(sent).toHaveLength(scenario === "second-413" || scenario === "checkpoint-replay" ? 3 : scenario === "invalid-summary" ? 2 : 1); + if (scenario === "checkpoint-replay") { + const previous = sent.at(-1).body; const next = await send(); await next.text(); + expect(next.status).toBe(200); expect(sent).toHaveLength(4); expect(sent.at(-1).body).toEqual(previous); + } + expect(denied).toBe(0); + }); +} diff --git a/tests/responses/responses-anthropic-cache-prefix.test.ts b/tests/responses/responses-anthropic-cache-prefix.test.ts new file mode 100644 index 00000000000..68f6bd5facd --- /dev/null +++ b/tests/responses/responses-anthropic-cache-prefix.test.ts @@ -0,0 +1,161 @@ +import { test, expect } from "bun:test"; +import { repoRoot } from "../helpers/repo-root"; +const root = repoRoot(); +const cache = await import(`${root}/src/anthropic/cache-renewal.ts`); +const { createResponsesEffects } = await import(`${root}/src/server/responses/response-effects.ts`); +const { guardCompactionSummary } = await import(`${root}/src/responses/compaction-summary.ts`); +const { createTranslatorBudget } = await import(`${root}/src/lib/translator-budget.ts`); +const observations = await import(`${root}/src/lib/anthropic-cache-observation.ts`); +const retain = cache.retainCachePrefix; +test("an optional cache observer failure never breaks a natural response", () => { + const response = new Response("natural answer"); + let calls = 0; + observations.attachCacheObservation(response, () => { calls++; throw new Error("synthetic observer failure"); }); + const wrapped = observations.inheritCacheObservation(response, new Response("natural answer")); + expect(() => observations.observeAnthropicCacheUsage(wrapped, {})).not.toThrow(); + expect(calls).toBe(1); +}); +const cc = { type: "ephemeral" }; +const wire = (body: any, identity = "one", turnId = "turn") => ({ identity, turnId, model: "claude", + body: JSON.stringify(body), allowed: () => true, send: async () => { throw new Error("must-not-send"); }, record() {} }); +const before = () => ({ model: "claude", max_tokens: 100, stream: true, cache_control: cc, + tools: [{ name: "read", input_schema: { type: "object" }, cache_control: cc }], + system: [{ type: "text", text: "stable system", cache_control: cc }], + messages: [{ role: "user", content: [{ type: "text", text: "old user", cache_control: cc }] }, + { role: "assistant", content: [{ type: "text", text: "old assistant" }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call-1", content: "old result" }] }] }); +const append = (base = before()) => { + const next = structuredClone(base) as any; + for (const message of next.messages) if (Array.isArray(message.content)) for (const block of message.content) delete block.cache_control; + for (let i = 0; i < 22; i++) next.messages.push({ role: "user", content: `notification ${i}` }); + const point = next.messages.length - 2; + next.messages[point].content = [{ type: "text", text: next.messages[point].content, cache_control: cc }]; + return next; +}; +const strip = (value: any) => JSON.stringify(value, (key, item) => key === "cache_control" ? undefined : item); + +test("追加22通知后仍保留已确认旧尾断点,工具正文和新通知不变", () => { + const a = before(), b = append(a), old = wire(a), current = wire(b); + const changed = JSON.parse(retain(old, current)); + expect(changed.messages[a.messages.length - 1].content.at(-1).cache_control).toEqual(cc); + expect(strip(changed)).toEqual(strip(b)); + const points: any[] = []; + JSON.stringify(changed, (key, item) => { if (key === "cache_control") points.push(item); return item; }); + expect(points.length).toBe(4); + expect(changed.messages.slice(a.messages.length).length).toBe(22); + expect(changed.messages[changed.messages.length - 2].content[0].cache_control).toBeUndefined(); +}); +test("字符串和单text块的缓存标记移动不误报正文改变,真实改变仍识别", () => { + const a = before() as any, b = append(a); + b.messages[0].content = "old user"; + expect(cache.compareCachePrefix(wire(a), wire(b), 100, 20).firstChangedMessage).toBeNull(); + b.messages[0].content = "actually changed"; + expect(cache.compareCachePrefix(wire(a), wire(b), 100, 20).firstChangedMessage).toBe(0); + expect(retain(wire(a), wire(b))).toBe(wire(b).body); +}); +test("历史图片与签名思考逐字保留,追加新图只移动缓存标记", () => { + const a = before() as any; + a.messages[1].content.unshift({ type: "thinking", thinking: "old thought", signature: "old signed bytes" }); + a.messages[2].content = [{ type: "image", source: { type: "base64", media_type: "image/png", data: "old-image-exact-bytes" } }]; + const b = append(a); + b.messages.push({ role: "user", content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: "new-image-exact-bytes" } }] }); + // 重建标准倒数第二用户断点。 + for (const message of b.messages) if (Array.isArray(message.content)) for (const block of message.content) delete block.cache_control; + b.messages[b.messages.length - 2].content = [{ type: "text", text: "notification 21", cache_control: cc }]; + const fixed = JSON.parse(retain(wire(a), wire(b))); + expect(strip(fixed)).toBe(strip(b)); + expect(fixed.messages[2].content[0].source.data).toBe("old-image-exact-bytes"); + expect(fixed.messages.at(-1).content[0].source.data).toBe("new-image-exact-bytes"); + expect(fixed.messages[1].content[0].signature).toBe("old signed bytes"); +}); +test("不在thinking块添加标记,不把已有合法请求扩大到32MB以上", () => { + const a = before() as any; + a.messages.at(-1).content = [{ type: "thinking", thinking: "old", signature: "signature" }]; + const b = wire(append(a)); + expect(retain(wire(a), b)).toBe(b.body); + a.messages.at(-1).content = "old string"; + const large = append(a); + large.messages.at(-1).content += "x".repeat(32000000 - Buffer.byteLength(JSON.stringify(large)) - 1); + const current = wire(large); + expect(Buffer.byteLength(current.body)).toBe(31999999); + expect(retain(wire(a), current)).toBe(current.body); +}); +for (const kind of ["credential", "system", "tools", "model-option", "compacted", "long-ttl", "no-automatic", "external-point", "full-breakpoints"]) { + test(`不覆盖不兼容或外部断点:${kind}`, () => { + const a = before() as any, b = append(a); + let newIdentity = "one"; + if (kind === "credential") newIdentity = "another-account"; + if (kind === "system") b.system[0].text = "changed"; + if (kind === "tools") b.tools[0].name = "changed"; + if (kind === "model-option") b.thinking = { type: "adaptive" }; + if (kind === "compacted") b.messages = [{ role: "user", content: "summary" }]; + if (kind === "long-ttl") { a.cache_control = { ...cc, ttl: "1h" }; b.cache_control = a.cache_control; } + if (kind === "no-automatic") { delete a.cache_control; delete b.cache_control; } + if (kind === "external-point") b.messages[1].content[0].cache_control = cc; + if (kind === "full-breakpoints") { a.tools.push({ ...a.tools[0], name: "extra" }); b.tools.push({ ...b.tools[0], name: "extra" }); } + const current = wire(b, newIdentity); + expect(retain(wire(a), current)).toBe(current.body); + }); +} +test("只消费同自身线程、同轮次、未过期的已确认快照;结束后释放", () => { + let now = 0, notify: any; + const manager = new cache.AnthropicCacheRenewal({ now: () => now, later: () => 1, clear() {}, + watch(_thread: string, update: any, turn: string) { notify = update; update({ running: true, turn }); return () => {}; } }); + const a = wire(before()), b = wire(append()); + const prepare = (thread: string, current = b) => manager.prepare ? manager.prepare(thread, current) : current.body; + expect(prepare("main")).toBe(b.body); + manager.begin("main", a)({ input_tokens: 0, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 10000 }); + expect(prepare("main")).not.toBe(b.body); + expect(prepare("child")).toBe(b.body); + expect(prepare("main", { ...b, allowed: () => false })).toBe(b.body); + expect(prepare("main", { ...b, turnId: "other-turn" })).toBe(b.body); + now = 300000; + expect(prepare("main")).toBe(b.body); + now = 100; + notify({ running: false, turn: "turn" }); + expect(prepare("main")).toBe(b.body); + manager.close(); +}); + +test("compaction preserves client tool and system cache prefixes without changing tool choice", () => { + const parsed: any = { options: { toolChoice: "auto" }, context: { + tools: [{ name: "read", description: "Read a file", parameters: { type: "object" } }], + systemPrompt: ["stable system"], messages: [], + }, _rawBody: { input: [] } }; + const original = structuredClone(parsed); + createResponsesEffects({ options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as any, + {} as any, { parsed, route: { provider: { adapter: "anthropic" } } } as any, + { routedCompaction: true, preserveCompactionPrefix: true }); + expect(parsed.context.tools).toEqual(original.context.tools); + expect(parsed.context.systemPrompt).toEqual(original.context.systemPrompt); + expect(parsed.options.toolChoice).toBe("auto"); + expect(parsed.context.messages.at(-1).content).toContain("Do not call tools"); +}); + +test("compaction explicitly prohibits hosted server tools while retaining their declarations", () => { + const parsed: any = { options: { toolChoice: "auto" }, context: { tools: [], messages: [] }, + _anthropicServerTools: [{ type: "web_search_20250305", name: "web_search" }] }; + createResponsesEffects({ options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as any, + {} as any, { parsed, route: { provider: { adapter: "anthropic" } } } as any, + { routedCompaction: true, preserveCompactionPrefix: true }); + expect(parsed.options.toolChoice).toBe("none"); + expect(parsed._anthropicServerTools).toHaveLength(1); +}); + +for (const kind of ["text", "tool", "empty", "abnormal", "eof"] as const) { + test(`summary guard admits only a normally completed tool-free summary: ${kind}`, async () => { + const budget = createTranslatorBudget(); + async function* events(): AsyncGenerator { + if (kind === "tool") yield { type: "tool_call_start", id: "synthetic-call", name: "read" }; + if (kind !== "empty") yield { type: "text_delta", text: "summary" }; + yield { type: "done", stopReason: kind === "eof" ? undefined : kind === "abnormal" ? "unexpected_stop" : "end_turn", endTurn: true }; + } + try { + const output = []; + for await (const event of guardCompactionSummary(events(), budget)) output.push(event); + expect(output.some(event => event.type === "tool_call_start")).toBe(false); + if (kind === "text") expect(output.at(-1)?.type).toBe("done"); + else expect(output.at(-1)).toMatchObject({ type: "error", code: "compaction_invalid_summary" }); + } finally { budget.dispose(); } + }); +} diff --git a/tests/responses/responses-context-overflow.test.ts b/tests/responses/responses-context-overflow.test.ts index e7bf31c1278..fc5f29df213 100644 --- a/tests/responses/responses-context-overflow.test.ts +++ b/tests/responses/responses-context-overflow.test.ts @@ -221,7 +221,7 @@ describe("Responses provider input overflow", () => { } }); - test("the bounded Anthropic image retry runs once before the terminal failure", async () => { + test("an upstream Anthropic image 413 is terminal without degrading and resending history", async () => { let hits = 0; const upstream = upstream413(() => { hits += 1; }); saveConfig(config({ target: provider("anthropic", upstream) })); @@ -244,7 +244,7 @@ describe("Responses provider input overflow", () => { }], )); expect((failed.error as { code?: string }).code).toBe("context_length_exceeded"); - expect(hits).toBe(2); + expect(hits).toBe(1); } finally { await server.stop(true); } diff --git a/tests/server/server-anthropic-billing.test.ts b/tests/server/server-anthropic-billing.test.ts index 23d9ec82ec2..2820b265a20 100644 --- a/tests/server/server-anthropic-billing.test.ts +++ b/tests/server/server-anthropic-billing.test.ts @@ -493,7 +493,7 @@ for (const lane of ["native", "translated"] as const) describe(`${lane} Anthropi }); }); -for (const statusCode of [401, 429, 413]) test(`translated ${statusCode} recovery releases the refused response concurrency lease`, async () => { +for (const statusCode of [401, 429, 413]) test(`translated ${statusCode} refusal releases the response concurrency lease`, async () => { setProviderRequestPacingLimitsForTest({ maxQueueAgeMs: 200 }); const config = fixture((index, stream) => index === 0 ? refusal(statusCode, statusCode === 429 ? "fixture rate refusal" + " ".repeat(5_000) : "fixture credential/image refusal", @@ -507,8 +507,12 @@ for (const statusCode of [401, 429, 413]) test(`translated ${statusCode} recover image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2G0AAAAAASUVORK5CYII=" }] }] : "hello" }) }); const response = await handleResponses(request, config, { model: "", provider: "" }, { abortSignal: AbortSignal.timeout(2_000) }); - expect(response.status).toBe(200); - expect(await response.text()).toContain("billing recovered"); - expect(seen.map(entry => entry.key)).toEqual(statusCode === 413 ? ["fixture-key-0", "fixture-key-0"] : ["fixture-key-0", "fixture-key-1"]); + expect(response.status).toBe(statusCode === 413 ? 413 : 200); + const text = await response.text(); + if (statusCode === 413) { + expect(text).toContain("context_length_exceeded"); + expect(JSON.stringify(seen[0]!.body)).toContain("iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"); + } else expect(text).toContain("billing recovered"); + expect(seen.map(entry => entry.key)).toEqual(statusCode === 413 ? ["fixture-key-0"] : ["fixture-key-0", "fixture-key-1"]); expect(providerRequestPacingStatus("anth", config.providers.anth!)).toMatchObject({ inFlight: 0, queued: 0 }); }); diff --git a/tests/server/server-auth.test.ts b/tests/server/server-auth.test.ts index f24cc0b0206..3fc57a00811 100644 --- a/tests/server/server-auth.test.ts +++ b/tests/server/server-auth.test.ts @@ -1359,6 +1359,7 @@ describe("server local API auth", () => { fetch() { // Anthropic non-stream response carrying the summarizer's text. return Response.json({ + stop_reason: "end_turn", content: [{ type: "text", text: "compact summary body" }], usage: { input_tokens: 10, output_tokens: 5 }, });