From 5af8d85b38aafcf647945e333c0d5874c5cba790 Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:17:45 +0800 Subject: [PATCH 1/9] fix(anthropic): preserve cache prefixes across running turns and image compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve own-thread replay identity and stable historical image bytes. Compact old complete history units at local image/request limits without executing tools, and resume using an owner-bound bounded checkpoint. Offer default-off running-turn renewal for compatible five-minute caches, using confirmed prefixes, early natural usage, bounded retries and verified main/child runtime state. Keep existing cache retention choices unchanged. Add redacted request correlation, synthetic offline entry/runtime regressions and public configuration/structure documentation. Refs #7307 Reimplements and extends the stable-image work discussed in #4549. Co-authored-by: 焦允 --- .../content/docs/reference/configuration.md | 19 + scripts/test-layout/layout.json | 4 + src/adapters/anthropic-image-guard.ts | 37 +- src/adapters/anthropic-image-normalize.ts | 24 +- src/adapters/anthropic.ts | 21 +- src/anthropic/cache-renewal-runtime.ts | 143 ++++++ src/anthropic/cache-renewal.ts | 407 +++++++++++++++ src/codex/cache-runtime-state.ts | 206 ++++++++ src/codex/request-provenance.ts | 59 +++ src/codex/rollout-cache-runtime.ts | 150 ++++++ src/codex/thread-writer-lock.ts | 27 + src/config/schema/config-schema.ts | 1 + src/lib/anthropic-cache-observation.ts | 18 + src/lib/response-body-inactivity.ts | 6 +- src/responses/compaction-summary.ts | 42 ++ src/server/request-log-conversation.ts | 16 +- src/server/request-log.ts | 26 + src/server/request-origin.ts | 90 ++++ src/server/responses/adapter-continuation.ts | 6 +- src/server/responses/adapter-delivery.ts | 15 +- src/server/responses/adapter-dispatch.ts | 15 +- .../responses/anthropic-image-context.ts | 134 +++++ src/server/responses/compact.ts | 2 + src/server/responses/core-options.ts | 1 + src/server/responses/core-pipeline.ts | 4 + src/server/responses/fetch-helpers.ts | 23 +- .../responses/image-context-recovery.ts | 78 +++ src/server/responses/request-prepare.ts | 6 +- src/server/responses/request-sidecar-auth.ts | 9 +- src/server/responses/response-effects.ts | 41 +- src/server/responses/sidecar-execution.ts | 149 +++--- src/server/responses/sidecar-tool-surface.ts | 48 ++ src/types/config.ts | 2 + src/types/request.ts | 1 + src/web-search/progress-stream.ts | 5 +- structure/INDEX.md | 11 + structure/manifest.json | 7 + structure/providers-and-adapters.md | 4 +- .../providers/anthropic-cache-lifecycle.md | 77 +++ structure/transports/responses-wire-shapes.md | 4 +- structure/transports/responses.md | 2 +- .../anthropic/anthropic-cache-renewal.test.ts | 246 +++++++++ .../anthropic/anthropic-image-guard.test.ts | 17 +- .../anthropic-image-normalize.test.ts | 38 +- .../anthropic-image-retry-e2e.test.ts | 35 +- .../anthropic/anthropic-image-retry.test.ts | 8 +- .../codex-cache-runtime-record.test.ts | 68 +++ tests/fixtures/test-layout-expected.json | 4 + tests/helpers/anthropic-renewal-clock.ts | 17 + ...es-anthropic-cache-lifecycle-entry.test.ts | 466 ++++++++++++++++++ .../responses-anthropic-cache-prefix.test.ts | 175 +++++++ tests/usage/request-log-conversation.test.ts | 11 +- 52 files changed, 2802 insertions(+), 223 deletions(-) create mode 100644 src/anthropic/cache-renewal-runtime.ts create mode 100644 src/anthropic/cache-renewal.ts create mode 100644 src/codex/cache-runtime-state.ts create mode 100644 src/codex/request-provenance.ts create mode 100644 src/codex/rollout-cache-runtime.ts create mode 100644 src/codex/thread-writer-lock.ts create mode 100644 src/lib/anthropic-cache-observation.ts create mode 100644 src/responses/compaction-summary.ts create mode 100644 src/server/request-origin.ts create mode 100644 src/server/responses/anthropic-image-context.ts create mode 100644 src/server/responses/image-context-recovery.ts create mode 100644 src/server/responses/sidecar-tool-surface.ts create mode 100644 structure/providers/anthropic-cache-lifecycle.md create mode 100644 tests/adapters/anthropic/anthropic-cache-renewal.test.ts create mode 100644 tests/codex-integration/codex-cache-runtime-record.test.ts create mode 100644 tests/helpers/anthropic-renewal-clock.ts create mode 100644 tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts create mode 100644 tests/responses/responses-anthropic-cache-prefix.test.ts diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index baba267dadf..a26df53d8cf 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -29,6 +29,25 @@ 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, with a native rollout/writer-lock fallback currently limited to Windows. +This setting has no dedicated dashboard control and does not change native Codex. + +Anthropic image history keeps stable encodings. When the aggregate 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. Summary calls are also billed normally; no tools execute during summarization. + ### 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.json b/scripts/test-layout/layout.json index d9502640531..fa93bd7ff2a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -3,6 +3,10 @@ "root": "tests", "explicitShards": ["layout-clients.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", "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", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", + "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", "owner-registry-isolation.test.ts": "config", "responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", 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..99b39aaded2 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 @@ -334,8 +330,6 @@ 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" }); + // 固定单图编码,累计限制由上下文压缩处理,不能再次降低历史图片的画质。 + await normalizeImageTargets(targets, { ...options, stablePerImage: true }); } diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 441f3e23e4c..42f6689b3f9 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"; @@ -1013,6 +1015,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"); @@ -1025,16 +1031,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; @@ -1234,11 +1233,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( @@ -1342,6 +1344,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..e8b6f698138 --- /dev/null +++ b/src/anthropic/cache-renewal-runtime.ts @@ -0,0 +1,143 @@ +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 const NATURAL_CACHE_SCOPE = Symbol("anthropic-natural-cache-scope"); +export const RENEWAL_DISPATCH = Symbol("anthropic-cache-renewal-dispatch"); + +export interface NaturalCacheScope { + 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; + +// 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" || input.config.anthropicCacheRenewal !== true) 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, 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" || scope.config.anthropicCacheRenewal !== true) { + 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" || scope.config.anthropicCacheRenewal !== true) 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..288b2f4fb56 --- /dev/null +++ b/src/codex/cache-runtime-state.ts @@ -0,0 +1,206 @@ +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.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 ddf640a8a15..6c7bd643953 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -100,6 +100,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/lib/anthropic-cache-observation.ts b/src/lib/anthropic-cache-observation.ts new file mode 100644 index 00000000000..a476d1849fb --- /dev/null +++ b/src/lib/anthropic-cache-observation.ts @@ -0,0 +1,18 @@ +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 {} +} 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/request-log-conversation.ts b/src/server/request-log-conversation.ts index 0249ca557c6..72f5c3944bf 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -140,9 +140,9 @@ function firstSanitizedConversationId( /** * Conversation namespace for reasoning replay. Unlike the persisted log id, this stays the raw - * sanitized identity so mixed headers that carry the same conversation still hit one serving - * record. Do not hash, and do not prefer session_id over a true per-conversation thread/Cursor - * identity: session_id can be synthesized from a shared prompt_cache_key. + * sanitized own-thread identity. A parent groups siblings for logging, but cannot own their + * reasoning provenance. Without a child identity, leave parent-scoped requests unbound rather + * than share a serving record. Standalone clients may still use their session id. */ export function reasoningReplayConversationIdFromResponsesRequest(input: { clientThreadId?: string; @@ -150,12 +150,10 @@ export function reasoningReplayConversationIdFromResponsesRequest(input: { cursorConversationId?: string; sessionIdHeader?: string | null; }): string | undefined { - return firstSanitizedConversationId( - input.clientThreadId, - input.threadIdHeader, - input.cursorConversationId, - input.sessionIdHeader, - ); + const own = firstSanitizedConversationId(input.threadIdHeader, input.cursorConversationId); + if (own) return own; + if (firstSanitizedConversationId(input.clientThreadId)) return undefined; + return firstSanitizedConversationId(input.sessionIdHeader); } export function conversationIdFromResponsesRequest(input: { diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 096a028bff0..93c1396eed3 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 @@ -273,12 +276,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; } @@ -293,6 +303,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; @@ -604,7 +616,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 @@ -621,8 +644,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; @@ -1489,6 +1514,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 55f06a0f8bf..3d5e4995004 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, 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 38c48500f16..37b47645c89 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -117,6 +117,7 @@ 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"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function prepareAdapterExchange( @@ -215,6 +216,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, @@ -399,8 +403,9 @@ export async function prepareAdapterExchange( }; const autoPauseEnabled = config.providers[route.providerName]?.accountAutoPause?.enabled === true; const billingPoolRequest = isAnthropicBillingKeyPool(route.provider); + const imageContextBudget = parsed._anthropicImageOwner !== undefined; let billingRecoveryActive = false; - const genericRefetchCountsSends = () => sendBudgetState.adapterSendBudget?.spendEnforced === true + const genericRefetchCountsSends = () => imageContextBudget || sendBudgetState.adapterSendBudget?.spendEnforced === true || transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null || billingPoolRequest || billingRecoveryActive || autoPauseEnabled; let handoffPermit: ResponsesSendBudget["pendingHopPermit"]; @@ -497,6 +502,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, @@ -523,11 +529,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)), @@ -790,6 +796,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)); @@ -1749,7 +1756,7 @@ export async function prepareAdapterExchange( } // 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({ + if (transportState.activeAdapter.name !== "anthropic" && shouldAttemptImageTierRetry({ status: upstreamResponse.status, adapterName: transportState.activeAdapter.name, parsed, diff --git a/src/server/responses/anthropic-image-context.ts b/src/server/responses/anthropic-image-context.ts new file mode 100644 index 00000000000..0a7e4f952e7 --- /dev/null +++ b/src/server/responses/anthropic-image-context.ts @@ -0,0 +1,134 @@ +import type { OcxContext, OcxMessage, OcxParsedRequest, OcxProviderContinuationOwner } from "../../types"; +import { createHash } from "node:crypto"; +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 = createHash("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; + summarize(context: ImageSummaryContext): Promise; +}): Promise { + const { parsed, adapter, incoming, summarize } = input; + if (adapter.name !== "anthropic") 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; + while (!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; + 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 cb93a79f280..c354ea2d525 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -192,6 +192,7 @@ import { sealRequestAttemptIdentity, usageFromResponsesPayload, type RequestLogContext, + observeRequestOrigin, } from "../request-log"; import type { AttemptRecoveryKind } from "../../usage/log"; import { @@ -665,6 +666,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 3ee37545491..f369ecbb9a8 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..9bd5392fff2 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); diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 008bbc9ec8b..dc39e697c28 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -32,6 +32,7 @@ import { import { redactSecretString } from "../../lib/redact"; import { outboundCredentials } from "../../lib/outbound-credential-registry"; import { compressUpstreamRequestBody, isCanonicalCodexResponsesUrl } from "./request-compression"; +import { NATURAL_CACHE_SCOPE, RENEWAL_DISPATCH, observeNaturalCacheSend, type NaturalCacheScope } from "../../anthropic/cache-renewal-runtime"; export { withUpstreamHttpVersion }; @@ -236,20 +237,35 @@ 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 observeNaturalCacheSend(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); } 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. * @@ -352,6 +368,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..f5057e83107 --- /dev/null +++ b/src/server/responses/image-context-recovery.ts @@ -0,0 +1,78 @@ +import { randomUUID } from "node:crypto"; +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"; + +/** 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 | (() => Promise) { + 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 () => { + if (summaryContext || transportState.adapter.name !== "anthropic") return; + try { + await prepareAnthropicImageContext({ parsed: requestState.parsed, adapter: transportState.adapter, + 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; + 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); + } + return text; + } finally { + childLog.errorCode ??= "image_compaction_summary"; + addFinalRequestLog(`ocx-${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 e7ebf8eedf8..c9a4e71c684 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -219,7 +219,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. @@ -244,6 +244,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 fc4ebb65ead..58a477a0a7f 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"; @@ -46,7 +43,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"; @@ -56,10 +52,11 @@ 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"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function executeResponsesSidecars( - requestContext: Pick, + requestContext: Pick, requestState: Pick< PreparedResponsesRequest, | "parsed" @@ -93,7 +90,7 @@ export async function executeResponsesSidecars( | "noteRoutedAttemptSend" | "bindKeyUsageFromBridge" >, - sidecarState: Pick, + sidecarState: Pick, responseEffects: Pick< ResponsesEffects, | "commitReasoningReplayServingRoute" @@ -102,6 +99,7 @@ export async function executeResponsesSidecars( | "cancelResponseCompletion" >, sendBudgetState: Pick, + prepareImageContext?: () => Promise, ) { const { config, options, logCtx } = requestContext; const antigravityPoolActivated = requestState.route.providerName === "google-antigravity" @@ -124,9 +122,16 @@ 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); + // 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, @@ -178,45 +183,48 @@ 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; - 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); + 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 @@ -513,40 +521,10 @@ 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 imgResponse = await runWithImageBridge({ parsed, adapter: transportState.adapter, @@ -574,7 +552,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; @@ -640,14 +618,13 @@ 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({ parsed, adapter: transportState.adapter, @@ -656,7 +633,7 @@ export async function executeResponsesSidecars( 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: { @@ -752,13 +729,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 e45a6c8bb06..e13073419d6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1091,6 +1091,8 @@ export interface OcxConfig { appOwnedMemoryBudgetMb?: number; /** Anthropic prompt-cache retention: "short" = 5-min ephemeral (default), "long" = 1-hour extended, "none" = disabled. */ 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 6e014690b6c..248082d9c75 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -97,6 +97,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/progress-stream.ts b/src/web-search/progress-stream.ts index e83de5e1708..0e32430b676 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 3677e16c754..ab4eb5fdd5b 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -70,6 +70,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. | @@ -167,6 +168,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/adapters/anthropic-tool-catalog.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | | `src/adapters/dsh-account.ts` | [`providers/dsh-account.md`](providers/dsh-account.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) | @@ -184,6 +186,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `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/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) | @@ -192,8 +195,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) | | `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) | @@ -214,6 +220,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) | @@ -244,6 +251,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) | | `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) | @@ -256,10 +264,13 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.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/request-transport.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/service.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/manifest.json b/structure/manifest.json index ec894791ee1..f286f007bc5 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 a7526944ee6..08ee79ad6bc 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -7,6 +7,8 @@ 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). +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. @@ -215,7 +217,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. | | `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. Encoding depends on the image itself; aggregate pressure invokes the [cache lifecycle recovery](providers/anthropic-cache-lifecycle.md#stable-images-and-overflow-recovery), without progressively degrading historical images. | | `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..ff4f3ccb765 --- /dev/null +++ b/structure/providers/anthropic-cache-lifecycle.md @@ -0,0 +1,77 @@ +# 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. + +## 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. + +`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. 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. + +## 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. 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. An upstream 413 is not retried with +historical pictures degraded. The legacy mutating helper is not used by this adapter. + +`src/server/responses/anthropic-image-context.ts` partitions history only at complete tool +call/result boundaries. 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. + +`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. 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/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index b171c2e1884..a062e6e38d6 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 d11c13a36d8..51e472dfa1d 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/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..a31e15c8b10 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)).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)).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..b7ec549025c 100644 --- a/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts +++ b/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts @@ -121,30 +121,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 +148,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 +161,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 3a82672e452..5ba3ae27f96 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,8 @@ { "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", + "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", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", 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/responses/responses-anthropic-cache-lifecycle-entry.test.ts b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts new file mode 100644 index 00000000000..ee1dfd2b2a0 --- /dev/null +++ b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts @@ -0,0 +1,466 @@ +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; +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 (deferNatural) await new Promise(resolve => { releaseNatural = resolve; }); + const result = { id: "msg_natural", type: "message", model: body.model, content: [{ type: "text", text: "Fixture summary." }], 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: "Fixture summary." } })); + 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 = false; releaseNatural = undefined; }); + +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 ["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; + 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 === "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 }; +} + +test("image-count pressure summarizes once, preserves the latest tool pair and reuses the checkpoint", async () => { + const baseline = await invoke("main", "http", false); 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) }), + config(), { 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); +}); + +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); +}); 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..ce929f49fc7 --- /dev/null +++ b/tests/responses/responses-anthropic-cache-prefix.test.ts @@ -0,0 +1,175 @@ +import { test, expect } from "bun:test"; +import { repoRoot } from "../helpers/repo-root"; +const root = repoRoot(); +const replay = await import(`${root}/src/responses/reasoning-replay-cache.ts`); +const ids = await import(`${root}/src/server/request-log-conversation.ts`); +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 retain = cache.retainCachePrefix; +const identity = (model: string) => ({ providerName: model, providerDestinationIdentity: model, + providerDestinationDurableIdentity: model, adapterName: model, modelId: model, + credentialIdentity: "offline", credentialDurableIdentity: "offline" }); +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("异模型子完成不改变主代理身份,真实主换模型仍被识别", () => { + const parentId = "offline-parent", childId = "offline-child"; + const parent: any = { clientThreadId: ids.reasoningReplayConversationIdFromResponsesRequest({ threadIdHeader: parentId }) }; + const child: any = { clientThreadId: ids.reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: parentId, threadIdHeader: childId }) }; + replay.bindReasoningReplayScope(parent, identity("anthropic")); + replay.bindReasoningReplayScope(child, identity("gemini")); + replay.commitReasoningReplayServingIdentity(parent); + replay.commitReasoningReplayServingIdentity(child); + expect(child.clientThreadId).toBe(childId); + expect(replay.reasoningReplayServingIdentityChanged(parent)).toBe(false); + replay.bindReasoningReplayScope(parent, identity("different-backend")); + expect(replay.reasoningReplayServingIdentityChanged(parent)).toBe(true); +}); +test("父ID不能在缺失自身身份时合并子代理,独立客户端保留session后备", () => { + expect(ids.reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: "parent", sessionIdHeader: "shared" })).toBeUndefined(); + expect(ids.reasoningReplayConversationIdFromResponsesRequest({ sessionIdHeader: "standalone" })).toBe("standalone"); + expect(ids.reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: "parent", cursorConversationId: "cursor" })).toBe("cursor"); +}); +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"] 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 === "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/usage/request-log-conversation.test.ts b/tests/usage/request-log-conversation.test.ts index 0a674a57b05..53af05abc0f 100644 --- a/tests/usage/request-log-conversation.test.ts +++ b/tests/usage/request-log-conversation.test.ts @@ -143,20 +143,20 @@ describe("conversationIdFromResponsesRequest", () => { describe("reasoningReplayConversationIdFromResponsesRequest", () => { test("keeps the raw identity instead of the hashed log id", () => { expect(reasoningReplayConversationIdFromResponsesRequest({ - clientThreadId: "parent-thread", - })).toBe("parent-thread"); + threadIdHeader: "own-thread", + })).toBe("own-thread"); expect(reasoningReplayConversationIdFromResponsesRequest({ sessionIdHeader: "session", })).not.toBe(digest32("session")); }); - test("prefers parent thread, then thread-id, then cursor, then session_id", () => { + test("prefers own thread, then cursor, then standalone session; parent never owns replay", () => { expect(reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: "parent-thread", threadIdHeader: "thread", cursorConversationId: "cursor", sessionIdHeader: "session", - })).toBe("parent-thread"); + })).toBe("thread"); expect(reasoningReplayConversationIdFromResponsesRequest({ threadIdHeader: "thread", cursorConversationId: "cursor", @@ -169,6 +169,9 @@ describe("reasoningReplayConversationIdFromResponsesRequest", () => { expect(reasoningReplayConversationIdFromResponsesRequest({ sessionIdHeader: "session", })).toBe("session"); + expect(reasoningReplayConversationIdFromResponsesRequest({ + clientThreadId: "parent-thread", sessionIdHeader: "shared-session", + })).toBeUndefined(); }); test("skips empty, control-bearing, and overlong fallbacks", () => { From 14c961c6b2e53e71879ef72c329af6527e7faf18 Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:38:06 +0800 Subject: [PATCH 2/9] fix(anthropic): settle optional observer and unsubscribe failures --- src/codex/cache-runtime-state.ts | 3 ++- src/lib/anthropic-cache-observation.ts | 3 ++- tests/responses/responses-anthropic-cache-prefix.test.ts | 9 +++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/codex/cache-runtime-state.ts b/src/codex/cache-runtime-state.ts index 288b2f4fb56..35266e73f9f 100644 --- a/src/codex/cache-runtime-state.ts +++ b/src/codex/cache-runtime-state.ts @@ -138,7 +138,8 @@ class ThreadRuntimeSubscription { close(): void { if (this.closed) return; - try { if (this.owner) this.following(false); } catch {} + 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")); } diff --git a/src/lib/anthropic-cache-observation.ts b/src/lib/anthropic-cache-observation.ts index a476d1849fb..75a4a28da30 100644 --- a/src/lib/anthropic-cache-observation.ts +++ b/src/lib/anthropic-cache-observation.ts @@ -14,5 +14,6 @@ export function inheritCacheObservation(from: Response, to: Response): Response } export function observeAnthropicCacheUsage(response: Response, usage: unknown): void { - try { observers.get(response)?.(usage); } catch {} + try { observers.get(response)?.(usage); } + catch { return; } // Optional observation failure must not fail the natural response. } diff --git a/tests/responses/responses-anthropic-cache-prefix.test.ts b/tests/responses/responses-anthropic-cache-prefix.test.ts index ce929f49fc7..d6a71c65f1c 100644 --- a/tests/responses/responses-anthropic-cache-prefix.test.ts +++ b/tests/responses/responses-anthropic-cache-prefix.test.ts @@ -7,7 +7,16 @@ 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 identity = (model: string) => ({ providerName: model, providerDestinationIdentity: model, providerDestinationDurableIdentity: model, adapterName: model, modelId: model, credentialIdentity: "offline", credentialDurableIdentity: "offline" }); From 7e2f8cf4bd79e4be9b3038f08e5bf2439a07d62a Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:39:27 +0800 Subject: [PATCH 3/9] test(anthropic): align cache recovery fixtures with own-thread and stable images --- tests/adapters/abort-race.test.ts | 4 ++-- .../anthropic-image-retry-e2e.test.ts | 19 +++++++++++-------- .../github-copilot-account-origin.test.ts | 7 ++++--- ...hropic-thinking-signature-recovery.test.ts | 2 +- ...search-thinking-signature-recovery.test.ts | 14 ++++++++++++-- 5 files changed, 30 insertions(+), 16 deletions(-) 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-image-retry-e2e.test.ts b/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts index b7ec549025c..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 = 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-thinking-signature-recovery.test.ts b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts index 64c31ff6a95..78dfb69e535 100644 --- a/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts @@ -67,7 +67,7 @@ function config(): OcxConfig { function request(stream: boolean, threadId = "thinking-recovery-thread", signed = true): Request { return new Request("http://localhost/v1/responses", { method: "POST", - headers: { "content-type": "application/json", "x-codex-parent-thread-id": threadId }, + headers: { "content-type": "application/json", "x-codex-parent-thread-id": threadId, "thread-id": threadId }, body: JSON.stringify({ model: "anthropic-test/claude-sonnet-4-5", stream, store: false, tools: [{ type: "function", name: "lookup", parameters: { type: "object", properties: {} } }], diff --git a/tests/responses/responses-web-search-thinking-signature-recovery.test.ts b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts index 183f0839dc1..2ff2e44def4 100644 --- a/tests/responses/responses-web-search-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts @@ -55,9 +55,10 @@ function config(): OcxConfig { } }, } as OcxConfig; } -function request(stream = true, thread = "search-thinking-thread", signed = true): Request { +function request(stream = true, thread = "search-thinking-thread", signed = true, ownThread = true): Request { return new Request("http://localhost/v1/responses", { - method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": thread }, + method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": thread, + ...(ownThread ? { "thread-id": thread } : {}) }, body: JSON.stringify({ model: "anthropic-test/claude-sonnet-4-5", stream, store: false, tools: [{ type: "web_search" }, { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }], input: [ @@ -139,6 +140,15 @@ function removeThinking(body: string): unknown { } describe("hosted web search Anthropic thinking-signature recovery", () => { + test("parent-only provenance cannot publish a stripped-history memo for a child", async () => { + const { bodies } = transport(index => index === 1 ? rejection() : success()); + await send({}, config(), request(true, "shared-parent", true, false)); + expect(bodies).toHaveLength(2); + expectStripped(bodies[1]); + await send({}, config(), request(true, "shared-parent", true, false)); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + }); for (const rawSse of [false, true]) { test(`signed progress created during search remains eligible for recovery (SSE=${rawSse})`, async () => { const cfg = config(); From 62a53c2cad173b66a16180dac18ec1c8e8618e4e Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:55:23 +0800 Subject: [PATCH 4/9] test(anthropic): verify image recovery independently of renewal --- ...responses-anthropic-cache-lifecycle-entry.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts index ee1dfd2b2a0..b33544eeb13 100644 --- a/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts +++ b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts @@ -142,8 +142,9 @@ async function invoke(role: string, entry: string, stream: boolean, cfg = config return { ...r, response, logCtx }; } -test("image-count pressure summarizes once, preserves the latest tool pair and reuses the checkpoint", async () => { - const baseline = await invoke("main", "http", false); await baseline.response.text(); +for (const enabled of [false, true]) test(`image-count pressure preserves prefixes and latest tool pair independently of renewal: enabled=${enabled}`, async () => { + 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); @@ -156,7 +157,7 @@ test("image-count pressure summarizes once, preserves the latest tool pair and r ]).flat()]; const send = async () => { const response = await handleResponses(new Request(r.req.url, { method: "POST", headers, body: JSON.stringify(raw) }), - config(), { model: "", provider: "" } as any, r.options); + 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")); @@ -173,6 +174,11 @@ test("image-count pressure summarizes once, preserves the latest tool pair and r expect(JSON.stringify(final.messages)).toContain("image_100"); const count = summaries().length; await send(); expect(summaries()).toHaveLength(count); expect(denied).toBe(0); + 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"]) { From 72fc3388f6ce20f277aa70711d07b05fa232f4d4 Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:16:21 +0800 Subject: [PATCH 5/9] fix(anthropic): guard renewal spend and continuation recovery --- .../content/docs/reference/configuration.md | 10 +++- src/anthropic/cache-renewal-runtime.ts | 12 +++-- src/server/responses/adapter-continuation.ts | 5 +- .../responses/anthropic-image-context.ts | 3 +- .../responses/image-context-recovery.ts | 7 +-- .../providers/anthropic-cache-lifecycle.md | 10 +++- .../anthropic-image-normalize.test.ts | 4 +- ...es-anthropic-cache-lifecycle-entry.test.ts | 52 ++++++++++++++++++- .../responses-anthropic-cache-prefix.test.ts | 4 +- 9 files changed, 88 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index a26df53d8cf..2d41fb1a526 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -40,13 +40,19 @@ with zero output tokens. Renewal stops when the turn ends or its running state i 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, with a native rollout/writer-lock fallback currently limited to Windows. +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. When the aggregate 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. Summary calls are also billed normally; no tools execute during summarization. +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. ### Provider and model aliases diff --git a/src/anthropic/cache-renewal-runtime.ts b/src/anthropic/cache-renewal-runtime.ts index e8b6f698138..1c98c3fb774 100644 --- a/src/anthropic/cache-renewal-runtime.ts +++ b/src/anthropic/cache-renewal-runtime.ts @@ -25,6 +25,12 @@ export interface NaturalCacheScope { 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 { @@ -47,7 +53,7 @@ export function naturalCacheScope(input: { adapterName: string; providerName: string; oauth: boolean; conversationId?: string; }): NaturalCacheScope | undefined { const { parsed, req, options } = input; - if (input.adapterName !== "anthropic" || input.config.anthropicCacheRenewal !== true) return; + 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); @@ -91,7 +97,7 @@ export async function observeNaturalCacheSend( ): 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" || scope.config.anthropicCacheRenewal !== true) { + 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); @@ -104,7 +110,7 @@ export async function observeNaturalCacheSend( 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" || scope.config.anthropicCacheRenewal !== true) return false; + 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); diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index 3d5e4995004..2bf6df2ada6 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -681,7 +681,7 @@ export function createAdapterContinuations( } } } - if (shouldAttemptImageTierRetry({ + if (transportState.activeAdapter.name !== "anthropic" && shouldAttemptImageTierRetry({ status: response.status, adapterName: transportState.activeAdapter.name, parsed: nextParsed, @@ -726,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/anthropic-image-context.ts b/src/server/responses/anthropic-image-context.ts index 0a7e4f952e7..6ae07eef9ff 100644 --- a/src/server/responses/anthropic-image-context.ts +++ b/src/server/responses/anthropic-image-context.ts @@ -1,5 +1,4 @@ import type { OcxContext, OcxMessage, OcxParsedRequest, OcxProviderContinuationOwner } from "../../types"; -import { createHash } from "node:crypto"; import type { ProviderAdapter, IncomingMeta } from "../../adapters/base"; import { AnthropicImageOverflow } from "../../adapters/anthropic-image-guard"; import { sameProviderContinuationOwner } from "../../responses/provider-continuation"; @@ -24,7 +23,7 @@ const summaryInstruction = (): OcxMessage => ({ role: "user", timestamp: 0, const messageKey = ({ timestamp: _timestamp, ...message }: OcxMessage): string => JSON.stringify(message); function prefixDigest(messages: OcxMessage[], end: number): string { - const digest = createHash("sha256"); + const digest = new Bun.CryptoHasher("sha256"); for (let i = 0; i < end; i++) digest.update(messageKey(messages[i])).update("\n"); return digest.digest("hex"); } diff --git a/src/server/responses/image-context-recovery.ts b/src/server/responses/image-context-recovery.ts index f5057e83107..f2f02340ee0 100644 --- a/src/server/responses/image-context-recovery.ts +++ b/src/server/responses/image-context-recovery.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import type { ResponsesRequestContext, ResponsesDispatchers } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesTransport } from "./request-transport"; @@ -41,6 +40,7 @@ export function createImageContextPreparation( 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, { @@ -62,10 +62,11 @@ export function createImageContextPreparation( 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 { - childLog.errorCode ??= "image_compaction_summary"; - addFinalRequestLog(`ocx-${randomUUID().replaceAll("-", "")}`, started, childLog, status); + if (!succeeded) childLog.errorCode ??= "image_compaction_summary"; + addFinalRequestLog(`ocx-${crypto.randomUUID().replaceAll("-", "")}`, started, childLog, status); } }, }); diff --git a/structure/providers/anthropic-cache-lifecycle.md b/structure/providers/anthropic-cache-lifecycle.md index ff4f3ccb765..9be1c4c831a 100644 --- a/structure/providers/anthropic-cache-lifecycle.md +++ b/structure/providers/anthropic-cache-lifecycle.md @@ -9,6 +9,9 @@ runtime state and usage logging. This does not change native Codex or native Mes 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. +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. @@ -16,7 +19,7 @@ Main and spawned turns follow the same policy; decision, memory and vision helpe `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. Known large compacted/context records do not imply completion; +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 @@ -38,6 +41,8 @@ executes a tool or appends anything to client history. Diagnostic records correl 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. +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 @@ -58,7 +63,8 @@ new unit or unverifiable owner fails explicitly; no finite API limit can admit e `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. Sidecar execution is bypassed. +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. diff --git a/tests/adapters/anthropic/anthropic-image-normalize.test.ts b/tests/adapters/anthropic/anthropic-image-normalize.test.ts index a31e15c8b10..7e910a09621 100644 --- a/tests/adapters/anthropic/anthropic-image-normalize.test.ts +++ b/tests/adapters/anthropic/anthropic-image-normalize.test.ts @@ -376,7 +376,7 @@ describe("normalizeAnthropicImages — guards and seams", () => { for (const b of content) sum += b.source?.data?.length ?? 0; expect(sum).toBeGreaterThan(TOTAL_IMAGE_BASE64_BUDGET); const before = JSON.stringify(messages); - expect(() => assertAnthropicImageBudget(messages)).toThrow(); + expect(() => assertAnthropicImageBudget(messages, Buffer.byteLength(JSON.stringify(messages), "utf8"))).toThrow(); expect(JSON.stringify(messages)).toBe(before); }); @@ -396,7 +396,7 @@ describe("normalizeAnthropicImages — guards and seams", () => { 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)).toThrow(); + 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/responses/responses-anthropic-cache-lifecycle-entry.test.ts b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts index b33544eeb13..107e9d16f5e 100644 --- a/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts +++ b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts @@ -55,6 +55,7 @@ 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, refuseContinuation = 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) => { @@ -69,6 +70,16 @@ globalThis.fetch = (async (url: any, init: any) => { 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 (emptyNatural) { + emptyNatural = false; + const empty = { id: "msg_empty", type: "message", model: body.model, content: [], stop_reason: "end_turn", usage }; + 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 result = { id: "msg_natural", type: "message", model: body.model, content: [{ type: "text", text: "Fixture summary." }], stop_reason: "end_turn", usage: { ...usage, output_tokens: 3 } }; if (!body.stream) return Response.json(result); @@ -102,7 +113,7 @@ afterAll(() => { 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 = false; releaseNatural = undefined; }); + hangRenewal = deferNatural = emptyNatural = refuseContinuation = false; releaseNatural = undefined; }); function config(retention = "short"): any { return { defaultProvider: "anthropic", cacheRetention: retention, anthropicCacheRenewal: true, multiAgentGuidanceEnabled: false, @@ -142,7 +153,43 @@ async function invoke(role: string, entry: string, stream: boolean, cfg = config return { ...r, response, logCtx }; } +test("non-stream clients parse an SSE empty-completion continuation when renewal is enabled", async () => { + emptyNatural = true; + const cfg = config(); cfg.emptyCompletionRetry = true; cfg.webSearchSidecar.enabled = false; + const result = await invoke("main", "http", false, cfg); + expect(await result.response.text()).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; @@ -174,6 +221,9 @@ for (const enabled of [false, true]) test(`image-count pressure preserves prefix 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); diff --git a/tests/responses/responses-anthropic-cache-prefix.test.ts b/tests/responses/responses-anthropic-cache-prefix.test.ts index d6a71c65f1c..9f97c8d3dd8 100644 --- a/tests/responses/responses-anthropic-cache-prefix.test.ts +++ b/tests/responses/responses-anthropic-cache-prefix.test.ts @@ -165,13 +165,13 @@ test("compaction explicitly prohibits hosted server tools while retaining their expect(parsed._anthropicServerTools).toHaveLength(1); }); -for (const kind of ["text", "tool", "empty", "abnormal"] as const) { +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 === "abnormal" ? "unexpected_stop" : "end_turn", endTurn: true }; + yield { type: "done", stopReason: kind === "eof" ? undefined : kind === "abnormal" ? "unexpected_stop" : "end_turn", endTurn: true }; } try { const output = []; From 7e09e8b3c7ade7c4df91027fe780636444ff6e81 Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:22:43 +0800 Subject: [PATCH 6/9] test(anthropic): pair continuation guards with renewal off and on --- ...es-anthropic-cache-lifecycle-entry.test.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts index 107e9d16f5e..12cb892eb37 100644 --- a/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts +++ b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts @@ -55,7 +55,7 @@ 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, refuseContinuation = false; +let emptyNatural = false, terminalNatural = false, refuseContinuation = 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) => { @@ -74,6 +74,7 @@ globalThis.fetch = (async (url: any, init: any) => { 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 })); @@ -81,13 +82,14 @@ globalThis.fetch = (async (url: any, init: any) => { } }), { headers: { "content-type": "text/event-stream" } }); } if (deferNatural) await new Promise(resolve => { releaseNatural = resolve; }); - const result = { id: "msg_natural", type: "message", model: body.model, content: [{ type: "text", text: "Fixture summary." }], stop_reason: "end_turn", usage: { ...usage, output_tokens: 3 } }; + 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: "Fixture summary." } })); + 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(); @@ -113,7 +115,7 @@ afterAll(() => { 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 = refuseContinuation = false; releaseNatural = undefined; }); + hangRenewal = deferNatural = emptyNatural = terminalNatural = refuseContinuation = false; releaseNatural = undefined; }); function config(retention = "short"): any { return { defaultProvider: "anthropic", cacheRetention: retention, anthropicCacheRenewal: true, multiAgentGuidanceEnabled: false, @@ -153,11 +155,15 @@ async function invoke(role: string, entry: string, stream: boolean, cfg = config return { ...r, response, logCtx }; } -test("non-stream clients parse an SSE empty-completion continuation when renewal is enabled", async () => { - emptyNatural = true; +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; - const result = await invoke("main", "http", false, cfg); - expect(await result.response.text()).toContain("Fixture summary."); + 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); }); From 8596ce3daf0a16d8552019569acf726d836b354a Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:40:14 +0800 Subject: [PATCH 7/9] fix(responses): retain legacy parent replay without sharing child scopes Keep explicit parent-only compatibility at Responses ingress and native compact while own thread IDs retain precedence and Anthropic renewal remains independently verified. Register all extracted image owners in source-oracle coverage and give the successful summary fixture its required end_turn marker; existing assertions and invalid-summary refusal remain intact. --- src/server/request-log-conversation.ts | 8 +++++--- src/server/responses/compact.ts | 1 + src/server/responses/request-prepare.ts | 1 + structure/providers/anthropic-cache-lifecycle.md | 3 +++ tests/helpers/responses-core-source.ts | 3 +++ tests/server/server-auth.test.ts | 1 + tests/usage/request-log-conversation.test.ts | 7 +++++++ 7 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index 72f5c3944bf..590354f62d2 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -141,18 +141,20 @@ function firstSanitizedConversationId( /** * Conversation namespace for reasoning replay. Unlike the persisted log id, this stays the raw * sanitized own-thread identity. A parent groups siblings for logging, but cannot own their - * reasoning provenance. Without a child identity, leave parent-scoped requests unbound rather - * than share a serving record. Standalone clients may still use their session id. + * reasoning provenance. Without a child identity, leave parent-scoped requests unbound by default. + * Legacy Responses callers may opt into parent-only replay; this never supplies a renewal identity. */ export function reasoningReplayConversationIdFromResponsesRequest(input: { clientThreadId?: string; threadIdHeader?: string | null; cursorConversationId?: string; sessionIdHeader?: string | null; + allowParentThreadFallback?: boolean; }): string | undefined { const own = firstSanitizedConversationId(input.threadIdHeader, input.cursorConversationId); if (own) return own; - if (firstSanitizedConversationId(input.clientThreadId)) return undefined; + const parent = firstSanitizedConversationId(input.clientThreadId); + if (parent) return input.allowParentThreadFallback ? parent : undefined; return firstSanitizedConversationId(input.sessionIdHeader); } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 2caa5bdb6e8..1ef73576024 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1000,6 +1000,7 @@ export async function handleResponsesCompact( gatewayReplayParsed._clientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; const clientThreadId = reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: gatewayReplayParsed._clientThreadId, + allowParentThreadFallback: true, threadIdHeader: req.headers.get("thread-id"), cursorConversationId: gatewayReplayParsed._cursorConversationId, sessionIdHeader: sessionIdHeaderFromRequest(req.headers), diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 20f7d962c49..17693bd4577 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -589,6 +589,7 @@ export async function prepareResponsesRequest( if (!parsed._reasoningReplayScope) { const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: parsed._clientThreadId, + allowParentThreadFallback: true, threadIdHeader: req.headers.get("thread-id"), cursorConversationId: parsed._cursorConversationId, sessionIdHeader: sessionIdHeaderFromRequest(req.headers), diff --git a/structure/providers/anthropic-cache-lifecycle.md b/structure/providers/anthropic-cache-lifecycle.md index ef065122dd2..1969d625be9 100644 --- a/structure/providers/anthropic-cache-lifecycle.md +++ b/structure/providers/anthropic-cache-lifecycle.md @@ -44,6 +44,9 @@ executes a tool or appends anything to client history. Diagnostic records correl 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. +Responses ordinary and compact gateway requests explicitly retain legacy parent-only +replay when no child id exists. Identified siblings still use their own ids. This compatibility +exception does not bind a Codex own-thread identity or enable Anthropic renewal or image recovery. 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. diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 5b6fa2c60b1..b6f55e86011 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -50,6 +50,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", @@ -71,6 +73,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/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 }, }); diff --git a/tests/usage/request-log-conversation.test.ts b/tests/usage/request-log-conversation.test.ts index 53af05abc0f..9baf53d710d 100644 --- a/tests/usage/request-log-conversation.test.ts +++ b/tests/usage/request-log-conversation.test.ts @@ -174,6 +174,13 @@ describe("reasoningReplayConversationIdFromResponsesRequest", () => { })).toBeUndefined(); }); + test("explicit legacy parent-only replay does not coalesce identified children", () => { + const legacy = { clientThreadId: "parent-thread", sessionIdHeader: "shared-session", allowParentThreadFallback: true }; + expect(reasoningReplayConversationIdFromResponsesRequest(legacy)).toBe("parent-thread"); + expect(reasoningReplayConversationIdFromResponsesRequest({ ...legacy, threadIdHeader: "child-a" })).toBe("child-a"); + expect(reasoningReplayConversationIdFromResponsesRequest({ ...legacy, threadIdHeader: "child-b" })).toBe("child-b"); + }); + test("skips empty, control-bearing, and overlong fallbacks", () => { expect(reasoningReplayConversationIdFromResponsesRequest({ threadIdHeader: " ", From be3a883960cfccff7beb935bb817786a7ad10b95 Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:33:09 +0800 Subject: [PATCH 8/9] fix(anthropic): withhold parent-only rejection memos --- src/server/responses/anthropic-thinking-recovery.ts | 4 +++- structure/providers/anthropic-cache-lifecycle.md | 1 + structure/transports/anthropic-thinking-recovery.md | 3 ++- .../responses-anthropic-thinking-recovery.test.ts | 3 ++- tests/responses/responses-context-overflow.test.ts | 4 ++-- .../server/server-agent-task-recovery-replay.test.ts | 3 ++- tests/server/server-anthropic-billing.test.ts | 12 ++++++++---- 7 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/server/responses/anthropic-thinking-recovery.ts b/src/server/responses/anthropic-thinking-recovery.ts index 4bf6005bfa4..dc1c5751404 100644 --- a/src/server/responses/anthropic-thinking-recovery.ts +++ b/src/server/responses/anthropic-thinking-recovery.ts @@ -54,7 +54,9 @@ export function createAnthropicThinkingRecovery( guard.attempted = true; armed = true; const scope = parsed._reasoningReplayScope; - rejectedScope = scope ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined; + // A legacy parent-only replay namespace cannot identify which child was refused. + const parentOnly = parsed._clientThreadId && !parsed._codexOwnThreadId && !parsed._cursorConversationId; + rejectedScope = scope && !parentOnly ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined; if (!rejectedPayloads) { prepareOpaqueBlobRecovery(parsed); if (attempt !== parsed) prepareOpaqueBlobRecovery(attempt); diff --git a/structure/providers/anthropic-cache-lifecycle.md b/structure/providers/anthropic-cache-lifecycle.md index 1969d625be9..09cbf115254 100644 --- a/structure/providers/anthropic-cache-lifecycle.md +++ b/structure/providers/anthropic-cache-lifecycle.md @@ -47,6 +47,7 @@ correlates natural request logs, keeping missing/conflicting provenance unknown. Responses ordinary and compact gateway requests explicitly retain legacy parent-only replay when no child id exists. Identified siblings still use their own ids. This compatibility exception does not bind a Codex own-thread identity or enable Anthropic renewal or image recovery. +Parent-only Anthropic refusals cannot publish a cross-request stripped-history memo for unidentified siblings. 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. diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md index c82cbf29a73..a08493160f0 100644 --- a/structure/transports/anthropic-thinking-recovery.md +++ b/structure/transports/anthropic-thinking-recovery.md @@ -29,7 +29,8 @@ the unsent repair uncharged. Recovery preserves the original thinking classifica Only a delivery-validated completed response with actual text/tool output and an observed successful terminal can memoize the rejection. Errors, incomplete/empty responses, missing -terminals, cancellation and changed serving identity cannot memoize it. The existing thread and +terminals, cancellation and changed serving identity cannot memoize it. Parent-only Codex provenance +may recover the current refusal but cannot publish a memo shared by unidentified children. The existing thread and durable-serving-identity key and five-minute TTL in `src/responses/reasoning-replay-cache.ts` are unchanged. Selective memos retain at most 256 payload digests within the existing cache byte cap; merged sets that cannot fit fall back to the existing blanket policy. `src/server/responses/core-replay.ts` diff --git a/tests/responses/responses-anthropic-thinking-recovery.test.ts b/tests/responses/responses-anthropic-thinking-recovery.test.ts index 54e2d19db5f..a983a45d393 100644 --- a/tests/responses/responses-anthropic-thinking-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-recovery.test.ts @@ -124,10 +124,11 @@ test("redacted-only history qualifies, and HTTP success alone does not memoize", expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(true); }); -for (const variant of ["empty", "no-terminal", "error", "incomplete", "cancelled", "changed-identity", "changed-thread", "failed-fold"] as const) { +for (const variant of ["empty", "no-terminal", "error", "incomplete", "cancelled", "changed-identity", "changed-thread", "failed-fold", "parent-only"] as const) { test(`does not memoize a ${variant} replacement`, async () => { const { recovery, request, abort } = fixture(); const rejectedScope = structuredClone(request._reasoningReplayScope); + if (variant === "parent-only") request._clientThreadId = "shared-parent"; const events: AdapterEvent[] = variant === "empty" ? [{ type: "done" }] : variant === "no-terminal" ? [success[0]!] : variant === "error" ? [success[0]!, rejection] 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-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index ceed8a241b0..0b0ab389298 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -175,7 +175,8 @@ test.each([true, false, undefined])("fresh recovery and cache-only reparse prese const observed = observations[index]!; expect(observed.cohort).toBe(cohort); expect(observed.thread).toBe(parentThread); - expect(observed.replay).toMatchObject({ clientThreadId: parentThread }); + expect(observed.replay).toMatchObject({ clientThreadId: "distinct-child-thread" }); + expect(observed.replay!.clientThreadId).not.toBe(parentThread); expect(observed.replay).toEqual(observations[0]!.replay); for (const body of [observed.raw, providerBodies[index]!]) { expect(body).toContain("Read the affinity assignment."); diff --git a/tests/server/server-anthropic-billing.test.ts b/tests/server/server-anthropic-billing.test.ts index cf0abb9f243..cc8de2e6cdc 100644 --- a/tests/server/server-anthropic-billing.test.ts +++ b/tests/server/server-anthropic-billing.test.ts @@ -481,7 +481,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", @@ -495,8 +495,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 }); }); From 259c87df7959d598a2d15aae1bd4843f1f5500ce Mon Sep 17 00:00:00 2001 From: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:21:23 +0800 Subject: [PATCH 9/9] fix: narrow cache lifecycle scope and compact rejected image history --- .../content/docs/reference/configuration.md | 8 ++- src/images/loop.ts | 13 ++++ src/server/request-log-conversation.ts | 18 ++--- src/server/responses/adapter-dispatch.ts | 18 ++++- .../responses/anthropic-image-context.ts | 6 +- .../responses/anthropic-thinking-recovery.ts | 4 +- src/server/responses/compact.ts | 1 - src/server/responses/core-pipeline.ts | 1 + .../responses/image-context-recovery.ts | 27 +++++-- src/server/responses/request-prepare.ts | 1 - src/server/responses/sidecar-execution.ts | 8 ++- src/web-search/loop.ts | 13 ++++ .../providers/anthropic-cache-lifecycle.md | 17 +++-- .../transports/anthropic-thinking-recovery.md | 3 +- ...es-anthropic-cache-lifecycle-entry.test.ts | 71 +++++++++++++++++++ .../responses-anthropic-cache-prefix.test.ts | 23 ------ ...ponses-anthropic-thinking-recovery.test.ts | 3 +- ...hropic-thinking-signature-recovery.test.ts | 2 +- ...search-thinking-signature-recovery.test.ts | 14 +--- .../server-agent-task-recovery-replay.test.ts | 3 +- tests/usage/request-log-conversation.test.ts | 18 ++--- 21 files changed, 187 insertions(+), 85 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 2d41fb1a526..94f00c61d56 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -47,12 +47,16 @@ detached renewal does not yet participate in workflow spend reservations. Empty 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. When the aggregate image or complete request -limit is reached, the Responses bridge summarizes old history and continues with the newest +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 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/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index 590354f62d2..0249ca557c6 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -140,22 +140,22 @@ function firstSanitizedConversationId( /** * Conversation namespace for reasoning replay. Unlike the persisted log id, this stays the raw - * sanitized own-thread identity. A parent groups siblings for logging, but cannot own their - * reasoning provenance. Without a child identity, leave parent-scoped requests unbound by default. - * Legacy Responses callers may opt into parent-only replay; this never supplies a renewal identity. + * sanitized identity so mixed headers that carry the same conversation still hit one serving + * record. Do not hash, and do not prefer session_id over a true per-conversation thread/Cursor + * identity: session_id can be synthesized from a shared prompt_cache_key. */ export function reasoningReplayConversationIdFromResponsesRequest(input: { clientThreadId?: string; threadIdHeader?: string | null; cursorConversationId?: string; sessionIdHeader?: string | null; - allowParentThreadFallback?: boolean; }): string | undefined { - const own = firstSanitizedConversationId(input.threadIdHeader, input.cursorConversationId); - if (own) return own; - const parent = firstSanitizedConversationId(input.clientThreadId); - if (parent) return input.allowParentThreadFallback ? parent : undefined; - return firstSanitizedConversationId(input.sessionIdHeader); + return firstSanitizedConversationId( + input.clientThreadId, + input.threadIdHeader, + input.cursorConversationId, + input.sessionIdHeader, + ); } export function conversationIdFromResponsesRequest(input: { diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 661f64d9892..e46cd10a7e9 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -118,6 +118,7 @@ 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 { @@ -403,7 +405,9 @@ export async function prepareAdapterExchange( }; const autoPauseEnabled = config.providers[route.providerName]?.accountAutoPause?.enabled === true; const billingPoolRequest = isAnthropicBillingKeyPool(route.provider); - const imageContextBudget = parsed._anthropicImageOwner !== undefined; + 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 = () => imageContextBudget || options.comboAttempt === true || sendBudgetState.adapterSendBudget?.spendEnforced === true || transientRetryPolicyFor(route.provider) !== null || resetReplayPolicyFor(route.provider) !== null @@ -1781,6 +1785,18 @@ export async function prepareAdapterExchange( upstreamResponse = opaqueBlobRecovery.response; continue 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; + } + } // Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds // later with 400 invalid_request_error / "Invalid upload request." Replay the // byte-identical request once after the exact gateway rejection. diff --git a/src/server/responses/anthropic-image-context.ts b/src/server/responses/anthropic-image-context.ts index 393e1159ce3..d010173ba8b 100644 --- a/src/server/responses/anthropic-image-context.ts +++ b/src/server/responses/anthropic-image-context.ts @@ -46,6 +46,7 @@ export async function prepareAnthropicImageContext(input: { parsed: OcxParsedRequest; adapter: ProviderAdapter; incoming: IncomingMeta; + upstreamRejected?: boolean; summarize(context: ImageSummaryContext): Promise; }): Promise { const { parsed, adapter, incoming, summarize } = input; @@ -97,7 +98,9 @@ export async function prepareAnthropicImageContext(input: { ]; const cuts = completeBoundaries(original); const retainFrom = cuts.at(-1) ?? 0; - while (!await fits(effective(), false)) { + 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; @@ -116,6 +119,7 @@ export async function prepareAnthropicImageContext(input: { } 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 }); diff --git a/src/server/responses/anthropic-thinking-recovery.ts b/src/server/responses/anthropic-thinking-recovery.ts index dc1c5751404..4bf6005bfa4 100644 --- a/src/server/responses/anthropic-thinking-recovery.ts +++ b/src/server/responses/anthropic-thinking-recovery.ts @@ -54,9 +54,7 @@ export function createAnthropicThinkingRecovery( guard.attempted = true; armed = true; const scope = parsed._reasoningReplayScope; - // A legacy parent-only replay namespace cannot identify which child was refused. - const parentOnly = parsed._clientThreadId && !parsed._codexOwnThreadId && !parsed._cursorConversationId; - rejectedScope = scope && !parentOnly ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined; + rejectedScope = scope ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined; if (!rejectedPayloads) { prepareOpaqueBlobRecovery(parsed); if (attempt !== parsed) prepareOpaqueBlobRecovery(attempt); diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 1ef73576024..2caa5bdb6e8 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1000,7 +1000,6 @@ export async function handleResponsesCompact( gatewayReplayParsed._clientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; const clientThreadId = reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: gatewayReplayParsed._clientThreadId, - allowParentThreadFallback: true, threadIdHeader: req.headers.get("thread-id"), cursorConversationId: gatewayReplayParsed._cursorConversationId, sessionIdHeader: sessionIdHeaderFromRequest(req.headers), diff --git a/src/server/responses/core-pipeline.ts b/src/server/responses/core-pipeline.ts index 9bd5392fff2..7317a16ba79 100644 --- a/src/server/responses/core-pipeline.ts +++ b/src/server/responses/core-pipeline.ts @@ -108,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/image-context-recovery.ts b/src/server/responses/image-context-recovery.ts index f2f02340ee0..51449021d72 100644 --- a/src/server/responses/image-context-recovery.ts +++ b/src/server/responses/image-context-recovery.ts @@ -6,6 +6,25 @@ import { sameProviderContinuationOwner } from "../../responses/provider-continua 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( @@ -13,7 +32,7 @@ export function createImageContextPreparation( requestState: PreparedResponsesRequest, transportState: ResponsesTransport, dispatchers: ResponsesDispatchers, -): Response | (() => Promise) { +): Response | ImageContextPreparation { const summaryContext = options.imageSummaryContext; if (summaryContext) { if (!requestState.parsed._providerContinuationOwner @@ -23,10 +42,10 @@ export function createImageContextPreparation( requestState.parsed.context.messages = summaryContext.messages; requestState.parsed._anthropicImageOwner = summaryContext.owner; } - return async () => { - if (summaryContext || transportState.adapter.name !== "anthropic") return; + 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: requestState.parsed, adapter: transportState.adapter, + await prepareAnthropicImageContext({ parsed: attempt, adapter, upstreamRejected, incoming: { headers: requestState.selectedForwardHeaders, providerName: requestState.route.providerName, translatorBudget: options.translatorBudget, abortSignal: options.abortSignal }, async summarize(context) { diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 17693bd4577..20f7d962c49 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -589,7 +589,6 @@ export async function prepareResponsesRequest( if (!parsed._reasoningReplayScope) { const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: parsed._clientThreadId, - allowParentThreadFallback: true, threadIdHeader: req.headers.get("thread-id"), cursorConversationId: parsed._cursorConversationId, sessionIdHeader: sessionIdHeaderFromRequest(req.headers), diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 6083358a0a9..3bcf6c77806 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -55,6 +55,7 @@ import { isAntigravityRawValidationRefusal } from "./antigravity-validation-refu 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( @@ -101,7 +102,7 @@ export async function executeResponsesSidecars( | "cancelResponseCompletion" >, sendBudgetState: Pick, - prepareImageContext?: () => Promise, + prepareImageContext?: ImageContextPreparation, ) { const { config, options, logCtx } = requestContext; const antigravityPoolActivated = requestState.route.providerName === "google-antigravity" @@ -127,6 +128,9 @@ export async function executeResponsesSidecars( 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), @@ -531,6 +535,7 @@ export async function executeResponsesSidecars( { 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, @@ -635,6 +640,7 @@ export async function executeResponsesSidecars( ...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, 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/structure/providers/anthropic-cache-lifecycle.md b/structure/providers/anthropic-cache-lifecycle.md index 09cbf115254..ae7072e5447 100644 --- a/structure/providers/anthropic-cache-lifecycle.md +++ b/structure/providers/anthropic-cache-lifecycle.md @@ -44,10 +44,8 @@ executes a tool or appends anything to client history. Diagnostic records correl 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. -Responses ordinary and compact gateway requests explicitly retain legacy parent-only -replay when no child id exists. Identified siblings still use their own ids. This compatibility -exception does not bind a Codex own-thread identity or enable Anthropic renewal or image recovery. -Parent-only Anthropic refusals cannot publish a cross-request stripped-history memo for unidentified siblings. +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. @@ -59,8 +57,9 @@ Single-image validation and decoding protections remain in place. Native Message 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. An upstream 413 is not retried with -historical pictures degraded. The legacy mutating helper is not used by this adapter. +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 @@ -68,6 +67,12 @@ request or touching continuation-owner state. On local overflow it summarizes th 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 diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md index a08493160f0..c82cbf29a73 100644 --- a/structure/transports/anthropic-thinking-recovery.md +++ b/structure/transports/anthropic-thinking-recovery.md @@ -29,8 +29,7 @@ the unsent repair uncharged. Recovery preserves the original thinking classifica Only a delivery-validated completed response with actual text/tool output and an observed successful terminal can memoize the rejection. Errors, incomplete/empty responses, missing -terminals, cancellation and changed serving identity cannot memoize it. Parent-only Codex provenance -may recover the current refusal but cannot publish a memo shared by unidentified children. The existing thread and +terminals, cancellation and changed serving identity cannot memoize it. The existing thread and durable-serving-identity key and five-minute TTL in `src/responses/reasoning-replay-cache.ts` are unchanged. Selective memos retain at most 256 payload digests within the existing cache byte cap; merged sets that cannot fit fall back to the existing blanket policy. `src/server/responses/core-replay.ts` diff --git a/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts index cf4f1bed4fb..74795f922a0 100644 --- a/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts +++ b/tests/responses/responses-anthropic-cache-lifecycle-entry.test.ts @@ -56,6 +56,8 @@ 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) => { @@ -71,6 +73,15 @@ globalThis.fetch = (async (url: any, init: any) => { 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 }; @@ -116,6 +127,7 @@ afterAll(() => { }); 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, @@ -527,3 +539,62 @@ test("来源对象不写入用量JSONL,单个请求只有一条关联记录", 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 index 9f97c8d3dd8..68f6bd5facd 100644 --- a/tests/responses/responses-anthropic-cache-prefix.test.ts +++ b/tests/responses/responses-anthropic-cache-prefix.test.ts @@ -1,8 +1,6 @@ import { test, expect } from "bun:test"; import { repoRoot } from "../helpers/repo-root"; const root = repoRoot(); -const replay = await import(`${root}/src/responses/reasoning-replay-cache.ts`); -const ids = await import(`${root}/src/server/request-log-conversation.ts`); 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`); @@ -17,9 +15,6 @@ test("an optional cache observer failure never breaks a natural response", () => expect(() => observations.observeAnthropicCacheUsage(wrapped, {})).not.toThrow(); expect(calls).toBe(1); }); -const identity = (model: string) => ({ providerName: model, providerDestinationIdentity: model, - providerDestinationDurableIdentity: model, adapterName: model, modelId: model, - credentialIdentity: "offline", credentialDurableIdentity: "offline" }); 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() {} }); @@ -39,24 +34,6 @@ const append = (base = before()) => { }; const strip = (value: any) => JSON.stringify(value, (key, item) => key === "cache_control" ? undefined : item); -test("异模型子完成不改变主代理身份,真实主换模型仍被识别", () => { - const parentId = "offline-parent", childId = "offline-child"; - const parent: any = { clientThreadId: ids.reasoningReplayConversationIdFromResponsesRequest({ threadIdHeader: parentId }) }; - const child: any = { clientThreadId: ids.reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: parentId, threadIdHeader: childId }) }; - replay.bindReasoningReplayScope(parent, identity("anthropic")); - replay.bindReasoningReplayScope(child, identity("gemini")); - replay.commitReasoningReplayServingIdentity(parent); - replay.commitReasoningReplayServingIdentity(child); - expect(child.clientThreadId).toBe(childId); - expect(replay.reasoningReplayServingIdentityChanged(parent)).toBe(false); - replay.bindReasoningReplayScope(parent, identity("different-backend")); - expect(replay.reasoningReplayServingIdentityChanged(parent)).toBe(true); -}); -test("父ID不能在缺失自身身份时合并子代理,独立客户端保留session后备", () => { - expect(ids.reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: "parent", sessionIdHeader: "shared" })).toBeUndefined(); - expect(ids.reasoningReplayConversationIdFromResponsesRequest({ sessionIdHeader: "standalone" })).toBe("standalone"); - expect(ids.reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: "parent", cursorConversationId: "cursor" })).toBe("cursor"); -}); test("追加22通知后仍保留已确认旧尾断点,工具正文和新通知不变", () => { const a = before(), b = append(a), old = wire(a), current = wire(b); const changed = JSON.parse(retain(old, current)); diff --git a/tests/responses/responses-anthropic-thinking-recovery.test.ts b/tests/responses/responses-anthropic-thinking-recovery.test.ts index a983a45d393..54e2d19db5f 100644 --- a/tests/responses/responses-anthropic-thinking-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-recovery.test.ts @@ -124,11 +124,10 @@ test("redacted-only history qualifies, and HTTP success alone does not memoize", expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(true); }); -for (const variant of ["empty", "no-terminal", "error", "incomplete", "cancelled", "changed-identity", "changed-thread", "failed-fold", "parent-only"] as const) { +for (const variant of ["empty", "no-terminal", "error", "incomplete", "cancelled", "changed-identity", "changed-thread", "failed-fold"] as const) { test(`does not memoize a ${variant} replacement`, async () => { const { recovery, request, abort } = fixture(); const rejectedScope = structuredClone(request._reasoningReplayScope); - if (variant === "parent-only") request._clientThreadId = "shared-parent"; const events: AdapterEvent[] = variant === "empty" ? [{ type: "done" }] : variant === "no-terminal" ? [success[0]!] : variant === "error" ? [success[0]!, rejection] diff --git a/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts index 74ccc11746b..df11c72c34b 100644 --- a/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts @@ -83,7 +83,7 @@ function config(): OcxConfig { function request(stream: boolean, threadId = "thinking-recovery-thread", signed = true): Request { return new Request("http://localhost/v1/responses", { method: "POST", - headers: { "content-type": "application/json", "x-codex-parent-thread-id": threadId, "thread-id": threadId }, + headers: { "content-type": "application/json", "x-codex-parent-thread-id": threadId }, body: JSON.stringify({ model: "anthropic-test/claude-sonnet-4-5", stream, store: false, tools: [{ type: "function", name: "lookup", parameters: { type: "object", properties: {} } }], diff --git a/tests/responses/responses-web-search-thinking-signature-recovery.test.ts b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts index 2ff2e44def4..183f0839dc1 100644 --- a/tests/responses/responses-web-search-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts @@ -55,10 +55,9 @@ function config(): OcxConfig { } }, } as OcxConfig; } -function request(stream = true, thread = "search-thinking-thread", signed = true, ownThread = true): Request { +function request(stream = true, thread = "search-thinking-thread", signed = true): Request { return new Request("http://localhost/v1/responses", { - method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": thread, - ...(ownThread ? { "thread-id": thread } : {}) }, + method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": thread }, body: JSON.stringify({ model: "anthropic-test/claude-sonnet-4-5", stream, store: false, tools: [{ type: "web_search" }, { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }], input: [ @@ -140,15 +139,6 @@ function removeThinking(body: string): unknown { } describe("hosted web search Anthropic thinking-signature recovery", () => { - test("parent-only provenance cannot publish a stripped-history memo for a child", async () => { - const { bodies } = transport(index => index === 1 ? rejection() : success()); - await send({}, config(), request(true, "shared-parent", true, false)); - expect(bodies).toHaveLength(2); - expectStripped(bodies[1]); - await send({}, config(), request(true, "shared-parent", true, false)); - expect(bodies).toHaveLength(3); - expect(bodies[2]).toContain(SIGNATURE); - }); for (const rawSse of [false, true]) { test(`signed progress created during search remains eligible for recovery (SSE=${rawSse})`, async () => { const cfg = config(); diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index 0b0ab389298..ceed8a241b0 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -175,8 +175,7 @@ test.each([true, false, undefined])("fresh recovery and cache-only reparse prese const observed = observations[index]!; expect(observed.cohort).toBe(cohort); expect(observed.thread).toBe(parentThread); - expect(observed.replay).toMatchObject({ clientThreadId: "distinct-child-thread" }); - expect(observed.replay!.clientThreadId).not.toBe(parentThread); + expect(observed.replay).toMatchObject({ clientThreadId: parentThread }); expect(observed.replay).toEqual(observations[0]!.replay); for (const body of [observed.raw, providerBodies[index]!]) { expect(body).toContain("Read the affinity assignment."); diff --git a/tests/usage/request-log-conversation.test.ts b/tests/usage/request-log-conversation.test.ts index 9baf53d710d..0a674a57b05 100644 --- a/tests/usage/request-log-conversation.test.ts +++ b/tests/usage/request-log-conversation.test.ts @@ -143,20 +143,20 @@ describe("conversationIdFromResponsesRequest", () => { describe("reasoningReplayConversationIdFromResponsesRequest", () => { test("keeps the raw identity instead of the hashed log id", () => { expect(reasoningReplayConversationIdFromResponsesRequest({ - threadIdHeader: "own-thread", - })).toBe("own-thread"); + clientThreadId: "parent-thread", + })).toBe("parent-thread"); expect(reasoningReplayConversationIdFromResponsesRequest({ sessionIdHeader: "session", })).not.toBe(digest32("session")); }); - test("prefers own thread, then cursor, then standalone session; parent never owns replay", () => { + test("prefers parent thread, then thread-id, then cursor, then session_id", () => { expect(reasoningReplayConversationIdFromResponsesRequest({ clientThreadId: "parent-thread", threadIdHeader: "thread", cursorConversationId: "cursor", sessionIdHeader: "session", - })).toBe("thread"); + })).toBe("parent-thread"); expect(reasoningReplayConversationIdFromResponsesRequest({ threadIdHeader: "thread", cursorConversationId: "cursor", @@ -169,16 +169,6 @@ describe("reasoningReplayConversationIdFromResponsesRequest", () => { expect(reasoningReplayConversationIdFromResponsesRequest({ sessionIdHeader: "session", })).toBe("session"); - expect(reasoningReplayConversationIdFromResponsesRequest({ - clientThreadId: "parent-thread", sessionIdHeader: "shared-session", - })).toBeUndefined(); - }); - - test("explicit legacy parent-only replay does not coalesce identified children", () => { - const legacy = { clientThreadId: "parent-thread", sessionIdHeader: "shared-session", allowParentThreadFallback: true }; - expect(reasoningReplayConversationIdFromResponsesRequest(legacy)).toBe("parent-thread"); - expect(reasoningReplayConversationIdFromResponsesRequest({ ...legacy, threadIdHeader: "child-a" })).toBe("child-a"); - expect(reasoningReplayConversationIdFromResponsesRequest({ ...legacy, threadIdHeader: "child-b" })).toBe("child-b"); }); test("skips empty, control-bearing, and overlong fallbacks", () => {