From d2b3a56aa1cde29d51bbfe7fe6abff15ced4fe77 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Wed, 14 Oct 2026 16:29:54 +0800 Subject: [PATCH 1/9] fix(responses): recover Anthropic thinking-signature binding rejection (cherry picked from commit 015014504210e6b974454ffd8f59efb8551532a5) --- .../src/content/docs/guides/providers.md | 12 + scripts/test-layout/layout.json | 2 + src/adapters/anthropic.ts | 2 +- src/server/responses/adapter-delivery.ts | 59 ++- src/server/responses/adapter-dispatch.ts | 23 +- .../responses/anthropic-thinking-recovery.ts | 116 +++++ structure/INDEX.md | 2 + structure/manifest.json | 9 + .../transports/anthropic-thinking-recovery.md | 28 ++ structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 + ...ponses-anthropic-thinking-recovery.test.ts | 140 ++++++ ...hropic-thinking-signature-recovery.test.ts | 443 ++++++++++++++++++ .../inference-client-encoder-delivery.test.ts | 6 +- 14 files changed, 836 insertions(+), 10 deletions(-) create mode 100644 src/server/responses/anthropic-thinking-recovery.ts create mode 100644 structure/transports/anthropic-thinking-recovery.md create mode 100644 tests/responses/responses-anthropic-thinking-recovery.test.ts create mode 100644 tests/responses/responses-anthropic-thinking-signature-recovery.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520161b511b..f72cb817835 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -76,6 +76,18 @@ modalities are declared; Kimi omits `capabilities` for unknown or text-only mode a supported capability field keep their existing configuration shape. After updating opencodex, regenerate or refresh the client configuration managed by opencodex to receive the updated metadata. +## Replayed Anthropic thinking signatures + +On the Responses endpoint, an Anthropic-compatible upstream can reject a replayed thinking +signature because it belongs to a different conversation prefix. When the error explicitly +identifies this signature-binding problem, opencodex can remove replayed signed/redacted thinking +and retry once within the request's send budget. This covers HTTP 400 refusals and initial stream +errors before output or tool activity. Other errors keep their existing handling. + +Only a successful, nonempty completion remembers the rejection for that conversation and serving +identity for five minutes. Plaintext history remains available; a failed or cancelled retry does +not disable thinking replay on later requests. + ## Auth modes Provider configs accept three `authMode` values (`key` is the default). The built-in registry also diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..e66d25411ae 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1452,6 +1452,8 @@ "responses-account-change-scrub.test.ts": "responses", "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", + "responses-anthropic-thinking-signature-recovery.test.ts": "responses", + "responses-anthropic-thinking-recovery.test.ts": "responses", "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 0211aef03fc..44f194edbb8 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -282,7 +282,7 @@ function normalizeTtlOrdering(body: Record): void { } } -function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string { +export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string { if (typeof signature !== "string" || signature.length < 16) return false; if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false; return /^[A-Za-z0-9+/_=-]+$/.test(signature); diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 3a8f16434b2..fef3cfdae4e 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -23,6 +23,9 @@ import { resolveStallTimeoutMs } from "../../stall-timeout"; import { clientEncoderForDelivery, deliverClientEncodedResponse } from "../inference/client-encoder-delivery"; import { noteKiroServedSuccess } from "../../providers/kiro-usage"; import { persistKiroAccountState } from "../../providers/kiro-account-state-disk"; +import { readDisplaySafeErrorText, normalizeUpstreamErrorText } from "./core-errors"; +import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; +import { isNonReplayableResponse } from "../../lib/upstream-retry"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ @@ -47,7 +50,7 @@ export async function deliverAdapterResponse( | "notifyResponseComplete" >, completionPolicy: Pick, - adapterExchange: Pick, + adapterExchange: Pick, continuationState: Pick, ): Promise { const { logCtx, options, config } = requestContext; @@ -59,7 +62,7 @@ export async function deliverAdapterResponse( rememberKiroDeliveredFinalAnswer, responseStateOptions, } = requestState; - const { upstreamResponse, upstream, cleanupUpstreamAbort, localUpstream } = adapterExchange; + const { upstreamResponse, upstream, cleanupUpstreamAbort, localUpstream, thinkingSignatureRecovery, refetchThinkingSignature } = adapterExchange; const { terminalGuardEnabled, fetchTerminalGuardContinuation, @@ -75,6 +78,43 @@ export async function deliverAdapterResponse( const { routedCompaction } = sidecarState; const bodyInactivityMs = resolveStallTimeoutMs(config.stallTimeoutSec, { localUpstream }); const upstreamRequestsStream = parsed.stream || isCanonicalOpenAiForwardProvider(route.provider); + const recoverThinkingSignature = (events: AsyncIterable): AsyncIterable => + thinkingSignatureRecovery.wrap(events, async function* () { + const result = await refetchThinkingSignature(); + let response = "failed" in result ? result.failed : result; + // A replacement can be an HTTP/auth/budget refusal; never feed its JSON body to an SSE parser. + if (!response.ok) { + // A local refusal closes the upstream signal before returning its generated JSON. + // Reading that bounded local body still honors caller cancellation. + const errorSignal = "failed" in result ? options.abortSignal ?? new AbortController().signal : upstream.signal; + const nonReplayable = isNonReplayableResponse(response); + if (nonReplayable) response = await sanitizeNonReplayableUpstreamError(response, errorSignal); + const body = await readDisplaySafeErrorText(response, errorSignal, "Upstream recovery failed"); + const error = normalizeUpstreamErrorText(body, "Upstream recovery failed"); + yield { type: "error", status: response.status, message: error.message ?? error.safeText, + errorType: error.type ?? "upstream_error", ...(error.code ? { code: error.code } : {}), + ...(nonReplayable ? { retryable: false } : {}) }; + return; + } + try { + const retryEvents = upstreamRequestsStream + ? readResponseStreamWithInactivity(response, upstream.signal, bodyInactivityMs, + guarded => transportState.activeAdapter.parseStream(guarded, translatorBudget, logCtx.activeTierMetadata)) + : (async function* () { + yield* await readResponseBodyWithInactivity(response, upstream.signal, bodyInactivityMs, + guarded => transportState.activeAdapter.parseResponse!(guarded, translatorBudget, logCtx.activeTierMetadata)); + })(); + for await (const event of retryEvents) { + options.onCompactionRecoveryAdapterEvent?.(event); + yield event; + } + } catch (error) { + if (!(error instanceof ResponseBodyInactivityError)) throw error; + yield { type: "error", message: "Upstream recovery response body stalled before completing", + status: 504, errorType: "upstream_error" }; + } + }, () => transportState.activeAdapter.name, !isNonReplayableResponse(upstreamResponse)); + if (parsed.stream) { @@ -104,15 +144,16 @@ export async function deliverAdapterResponse( throw error; } })(); + const recoveredEvents = recoverThinkingSignature(initialEventStream); const eventStream = terminalGuardEnabled ? guardTerminalEventStream({ parsed, - firstEvents: initialEventStream, + firstEvents: recoveredEvents, adapterName: transportState.activeAdapter.name, maxAutoContinuations: 1, continuation: next => fetchTerminalGuardContinuation(next, undefined, !parsed.stream), }) - : initialEventStream; + : recoveredEvents; // Optional advisor guard (registered per request by the sidecar planner; absent unless the // user enabled the advisor): holds synthetic `advisor` tool calls, consults the configured // expert model, and re-dispatches the worker. Sits inside the empty-completion guard so an @@ -139,6 +180,7 @@ export async function deliverAdapterResponse( // One completion owner for both deliveries: the bridge calls it from its terminal, the // direct client encoder from the fold of the same events. const onCompletedResponse = (response: Record, providerState?: OcxProviderContinuationState) => { + if (!options.abortSignal?.aborted) thinkingSignatureRecovery.rememberSuccess(response); const served = transportState.sentOAuthSnapshot; if (transportState.activeAdapter.name === "kiro" && response.status === "completed" && served && noteKiroServedSuccess(served.accountId, served.generation)) persistKiroAccountState(); @@ -239,18 +281,22 @@ export async function deliverAdapterResponse( )); } for (const event of initialEvents) options.onCompactionRecoveryAdapterEvent?.(event); + const recoveredEvents: AdapterEvent[] = []; + for await (const event of recoverThinkingSignature((async function* () { yield* initialEvents; })())) { + recoveredEvents.push(event); + } let guardedEvents: AdapterEvent[]; if (terminalGuardEnabled) { guardedEvents = []; for await (const event of guardTerminalEventStream({ parsed, - firstEvents: (async function* () { yield* initialEvents; })(), + firstEvents: (async function* () { yield* recoveredEvents; })(), adapterName: transportState.activeAdapter.name, maxAutoContinuations: 1, continuation: next => fetchTerminalGuardContinuation(next, undefined, !parsed.stream), })) guardedEvents.push(event); } else { - guardedEvents = initialEvents; + guardedEvents = recoveredEvents; } // Optional advisor guard — same semantics as the streaming branch above. if (parsed._advisorGuard) { @@ -312,6 +358,7 @@ export async function deliverAdapterResponse( // #1926 gap 2: same buffered-path durability bound as the primary branch. await awaitThoughtSignatureDurability(); if (adapterResponseReachedServingTerminal(events, json)) { + if (!options.abortSignal?.aborted) thinkingSignatureRecovery.rememberSuccess(json); const served = transportState.sentOAuthSnapshot; if (transportState.activeAdapter.name === "kiro" && json.status === "completed" && served && noteKiroServedSuccess(served.accountId, served.generation)) persistKiroAccountState(); diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 8c75b514d2c..0c0cac4b73a 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -87,6 +87,7 @@ import { reasoningEffortRejectionText, anthropicFastRefused, } from "./core-opaque-recovery"; +import { createAnthropicThinkingRecovery } from "./anthropic-thinking-recovery"; import { shouldAttemptImageTierRetry } from "../image-retry"; import { isTransientConsoleGoUploadRejection, @@ -591,7 +592,7 @@ export async function prepareAdapterExchange( let rateLimitRetries = 0; // Shared with the terminal-guard continuation below: an image-tier reduction that let the // main request clear a 413 must not be forgotten on the very next continuation build. - if (!upstreamResponse.ok) { + // Recovery helpers also serve initial HTTP-200 adapter errors during delivery. // Recovery loop: multi-key 429 failover + at most ONE opaque-state rebuild and ONE // anthropic 413 tightened retry // (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves @@ -600,6 +601,9 @@ export async function prepareAdapterExchange( // 413→429 rotation cannot silently undo the tightening. let imageRetryAttempted = false; const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; + const thinkingSignatureRecovery = createAnthropicThinkingRecovery( + parsed, upstream.signal, opaqueBlobRecoveryGuard, () => !sendBudgetExhausted(), + ); // Console Go answers a transient 400 "Invalid upload request." for bodies it accepts // moments later; at most one byte-identical replay is allowed per request. const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false }; @@ -863,6 +867,11 @@ export async function prepareAdapterExchange( return { failed: formatErrorResponse(502, "upstream_error", msg) }; } }; + const refetchThinkingSignature = () => { + invalidateSameTargetRequest(); + return rebuildAndRefetch("opaque-blob-rejection"); + }; + if (!upstreamResponse.ok) { const rotateAntigravityAuth = async ( failedResponse: Response, recovery: AttemptRecoveryKind, @@ -920,6 +929,16 @@ export async function prepareAdapterExchange( return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal); } finally { cleanupUpstreamAbort(); } } + if (await thinkingSignatureRecovery.prepareHttp( + upstreamResponse, transportState.activeAdapter.name, + )) { + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* closed */ } + invalidateSameTargetRequest(); + const result = await rebuildAndRefetch("opaque-blob-rejection"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue recovery; + } if ( upstreamResponse.status === 401 && isOAuth401ReplayProvider @@ -1894,6 +1913,8 @@ export async function prepareAdapterExchange( stallTimeoutMs, localUpstream, upstreamResponse, + thinkingSignatureRecovery, + refetchThinkingSignature, rateLimitPolicy, get rateLimitRetries(): typeof rateLimitRetries { return rateLimitRetries; diff --git a/src/server/responses/anthropic-thinking-recovery.ts b/src/server/responses/anthropic-thinking-recovery.ts new file mode 100644 index 00000000000..9f99cfeb873 --- /dev/null +++ b/src/server/responses/anthropic-thinking-recovery.ts @@ -0,0 +1,116 @@ +import { isLikelyRealAnthropicThinkingSignature } from "../../adapters/anthropic"; +import type { AdapterEvent, OcxParsedRequest } from "../../types"; +import { upstreamErrorMessageFromPayload } from "../../lib/errors"; +import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { isNonReplayableResponse } from "../../lib/upstream-retry"; +import { rememberReasoningReplayOpaqueBlobRejection } from "../../responses/reasoning-replay-cache"; +import { prepareOpaqueBlobRecovery, type OpaqueBlobRecoveryGuard } from "./core-opaque-recovery"; + +/** Only the explicit conversation-binding refusal authorizes removing signed history. */ +export function isAnthropicThinkingSignatureBindingRejectionMessage(message: string): boolean { + return message.includes("Invalid `signature` in `thinking` block") + && message.includes("The block is bound to a different conversation"); +} + +function carriesThinkingState(parsed: OcxParsedRequest): boolean { + return parsed.context.messages.some(message => message.role === "assistant" + && message.content.some(part => part.type === "thinking" + && (isLikelyRealAnthropicThinkingSignature(part.signature) + || part.redacted?.some(data => typeof data === "string" && data.length > 0)))); +} + +/** One request owns both the HTTP refusal and initial-event recovery, never a fresh retry guard. */ +export function createAnthropicThinkingRecovery( + parsed: OcxParsedRequest, + signal: AbortSignal, + guard: OpaqueBlobRecoveryGuard, + canRetry: () => boolean, +) { + let rejectedScope: OcxParsedRequest["_reasoningReplayScope"]; + let armed = false; + let outputSeen = false; + let completed = false; + let failed = false; + let remembered = false; + + const prepare = (message: string, adapterName: string): boolean => { + if (signal.aborted || !canRetry() || adapterName !== "anthropic" || guard.attempted + || parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(parsed) + || !isAnthropicThinkingSignatureBindingRejectionMessage(message)) return false; + guard.attempted = true; + armed = true; + const scope = parsed._reasoningReplayScope; + rejectedScope = scope ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined; + prepareOpaqueBlobRecovery(parsed); + return true; + }; + + const prepareHttp = async (response: Response, adapterName: string): Promise => { + if (signal.aborted || !canRetry() || response.status !== 400 || isNonReplayableResponse(response) + || adapterName !== "anthropic" || guard.attempted + || parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(parsed)) return false; + try { + const body = await readBoundedResponseBody(response.clone(), { signal }); + if (!body.displaySafe || body.truncated) return false; + const message = upstreamErrorMessageFromPayload(JSON.parse(body.text) as unknown); + return typeof message === "string" && prepare(message, adapterName); + } catch { + return false; + } + }; + + const observe = (event: AdapterEvent): void => { + if (!armed) return; + if (event.type === "error" || event.type === "incomplete") failed = true; + if (event.type === "done") completed = true; + if ((event.type === "text_delta" && event.text.length > 0) || event.type === "tool_call_end") outputSeen = true; + }; + + async function* wrap( + events: AsyncIterable, + retry: () => AsyncIterable, + adapterName: () => string, + replayAllowed = true, + ): AsyncGenerator { + let source = events; + let replaySafe = replayAllowed; + while (true) { + const iterator = source[Symbol.asyncIterator](); + let recovered = false; + try { + while (true) { + const next = await iterator.next(); + if (next.done) return; + const event = next.value; + if (replaySafe && event.type === "error" && prepare(event.message, adapterName())) { + // Close the rejected body before acquiring another dispatch/pacing slot. + await iterator.return?.(); + source = retry(); + recovered = true; + break; + } + // Even hidden reasoning, boundaries, and tool activity fence a replay. A heartbeat + // carrying a side-effect latch is never evidence that a turn is safe to repeat. + replaySafe &&= event.type === "heartbeat" && event.replayUnsafe !== true; + observe(event); + yield event; + } + } finally { + if (!recovered) await iterator.return?.(); + } + } + } + + const rememberSuccess = (response: Readonly>): void => { + // HTTP 200, EOF, or a guard-generated completion alone cannot prove the stripped send worked. + // The delivery owner calls this only after its bridge/fold validated a successful terminal. + if (!armed || remembered || signal.aborted || failed || !completed || !outputSeen + || response.status !== "completed" || !Array.isArray(response.output) || response.output.length === 0 + || !rejectedScope + || rejectedScope.clientThreadId !== parsed._reasoningReplayScope?.clientThreadId + || JSON.stringify(rejectedScope.current) !== JSON.stringify(parsed._reasoningReplayScope?.current)) return; + remembered = true; + rememberReasoningReplayOpaqueBlobRejection(rejectedScope); + }; + return { prepareHttp, wrap, rememberSuccess }; +} diff --git a/structure/INDEX.md b/structure/INDEX.md index 0942d50a14a..ef17d12c64e 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -42,6 +42,7 @@ The wire surfaces a client actually talks to. | --- | --- | | [`local-messaging.md`](local-messaging.md) | Command-local metadata discovery, Unix RPC queued submission, peer envelopes, receipts and owned lifecycle bounds. | | [`transports/byte-accounting.md`](transports/byte-accounting.md) | Request-copy and stream-buffer byte accounting shared by parsing, SSE rewriting, the adapters, and the translator budget, plus durable spill admission. | +| [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | One-shot exact signature-binding recovery before output, and successful-retry memo boundaries. | | [`transports/responses.md`](transports/responses.md) | The Responses HTTP/SSE endpoint, dispatch, credential and upload boundaries, and core module ownership. | | [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | Mixed-wire model defaults, xAI agent-message continuation, declared-tool membership, and passthrough SSE stream shapes. | | [`transports/responses-failover.md`](transports/responses-failover.md) | Upstream reset retry, the ambiguous-resend gate, combo failover and commit boundaries, compaction routing, and output headroom. | @@ -201,6 +202,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.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-thinking-recovery.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.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) | | `src/service/` | [`runtime.md`](runtime.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 5176735ae24..848b6b40d1a 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -215,6 +215,15 @@ "src/responses/" ] }, + { + "path": "transports/anthropic-thinking-recovery.md", + "tier": 3, + "title": "Anthropic Thinking-Signature Recovery", + "scope": "One-shot exact signature-binding recovery before output, and successful-retry memo boundaries.", + "documents": [ + "src/server/responses/anthropic-thinking-recovery.ts" + ] + }, { "path": "transports/responses.md", "tier": 3, diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md new file mode 100644 index 00000000000..f4ddce0af98 --- /dev/null +++ b/structure/transports/anthropic-thinking-recovery.md @@ -0,0 +1,28 @@ +# Anthropic Thinking-Signature Recovery + +`src/server/responses/anthropic-thinking-recovery.ts` owns one request-scoped recovery for +Anthropic history rejected as bound to another conversation. The exact backtick-bearing +signature-validation phrase and the conversation-binding phrase must both be present. +The request must contain a signature that the real Anthropic adapter would replay, or a +nonempty redacted-thinking block. The adapter's existing signature predicate is shared. + +`src/server/responses/adapter-dispatch.ts` checks a complete, bounded, display-safe HTTP 400 +error body; non-replayable responses remain terminal. `src/server/responses/adapter-delivery.ts` +checks initial HTTP-200 adapter errors before terminal/empty-completion guards. Both use the +same opaque-recovery guard and the existing request send budget, pacing and rebuild owner. +Only harmless heartbeats may precede a streamed recovery: text, thinking, boundaries, tool +activity and replay-unsafe heartbeats fence replay. The rejected iterator closes before refetch. + +Recovery uses the existing opaque-state strip/drop flags and leaves plaintext history intact. +A replacement HTTP refusal retains its status, safe error message/type and code; its body is +not parsed as SSE. Cancellation, budget exhaustion and a second rejection cannot trigger another +signature recovery. No Anthropic `runTurn` path or search/continuation recovery is introduced. + +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 +durable-serving-identity key and five-minute TTL are unchanged. + +`tests/responses/responses-anthropic-thinking-signature-recovery.test.ts` exercises the actual +Anthropic adapter with synthetic HTTP/SSE; `tests/responses/responses-anthropic-thinking-recovery.test.ts` +pins heartbeat/side-effect fences, exact classification, iterator cleanup and memo boundaries. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 0a065aec8ba..b2b466df699 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -428,7 +428,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts`, `sidecar-send-budget.ts` | Image/video versus web-search execution, shared rotation, and adapter-aware prepaid inference/producer ownership under the [spend contract](responses-spend.md#prepaid-initial-sends). | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | -| `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery and the shared continuation retry counter. Initial and rebuilt adapter budget refusals retain the HTTP 413 [byte-accounting contract](byte-accounting.md#request-copy-accounting); ordinary build errors remain 400. | +| `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery (including [Anthropic thinking-signature recovery](anthropic-thinking-recovery.md)) and the shared continuation retry counter. Initial and rebuilt adapter budget refusals retain the HTTP 413 [byte-accounting contract](byte-accounting.md#request-copy-accounting); ordinary build errors remain 400. | | `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; a streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..a8d4b544996 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -970,6 +970,8 @@ "response-log-inspection.test.ts": "server", "response-model-identity.test.ts": "server", "responses-4546-incident-regression.test.ts": "responses", "responses-account-change-scrub.test.ts": "responses", "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", + "responses-anthropic-thinking-signature-recovery.test.ts": "responses", + "responses-anthropic-thinking-recovery.test.ts": "responses", "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", "responses-chatgpt-web-replay-metadata.test.ts": "responses", "responses-code-mode-goal-helpers.test.ts": "responses", diff --git a/tests/responses/responses-anthropic-thinking-recovery.test.ts b/tests/responses/responses-anthropic-thinking-recovery.test.ts new file mode 100644 index 00000000000..b61aaa4cb1a --- /dev/null +++ b/tests/responses/responses-anthropic-thinking-recovery.test.ts @@ -0,0 +1,140 @@ +import { afterEach, expect, test } from "bun:test"; +import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; +import { createAnthropicThinkingRecovery, isAnthropicThinkingSignatureBindingRejectionMessage } from "../../src/server/responses/anthropic-thinking-recovery"; +import { clearReasoningReplayCacheForTests, reasoningReplayOpaqueBlobRejectionMemoized } from "../../src/responses/reasoning-replay-cache"; +import { markResponseNonReplayable } from "../../src/lib/upstream-retry"; + +const MESSAGE = "Invalid `signature` in `thinking` block. The block is bound to a different conversation."; +const rejection: AdapterEvent = { type: "error", status: 400, message: MESSAGE }; +const success: AdapterEvent[] = [{ type: "text_delta", text: "answer" }, { type: "done" }]; +function parsed(): OcxParsedRequest { + return { + modelId: "synthetic-model", stream: true, options: {}, + context: { messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "prior", signature: "SyntheticSignature123456==" }] }] }, + _reasoningReplayScope: { clientThreadId: "synthetic-thread", current: { + providerName: "synthetic-provider", adapterName: "anthropic", modelId: "synthetic-model", + providerDestinationIdentity: "destination", providerDestinationDurableIdentity: "durable-destination", + credentialIdentity: "credential", credentialDurableIdentity: "durable-credential", + } }, + }; +} +async function* stream(events: AdapterEvent[]): AsyncGenerator { yield* events; } +async function collect(events: AsyncIterable): Promise { + const result: AdapterEvent[] = []; + for await (const event of events) result.push(event); + return result; +} +function fixture() { + const request = parsed(); + const abort = new AbortController(); + const guard = { attempted: false }; + const recovery = createAnthropicThinkingRecovery(request, abort.signal, guard, () => true); + return { request, abort, guard, recovery }; +} +afterEach(() => clearReasoningReplayCacheForTests()); + +test("classifier requires both exact validation phrases including backticks", () => { + expect(isAnthropicThinkingSignatureBindingRejectionMessage(MESSAGE)).toBe(true); + for (const message of [MESSAGE.replaceAll("`", ""), "Invalid `signature` in `thinking` block", "The block is bound to a different conversation", "invalid signature"]) { + expect(isAnthropicThinkingSignatureBindingRejectionMessage(message)).toBe(false); + } +}); + +for (const prefix of [ + { type: "heartbeat", replayUnsafe: true }, + { type: "assistant_boundary" }, + { type: "thinking_signature", signature: "new-signature" }, + { type: "redacted_thinking", data: "new-redacted" }, + { type: "web_search_call_begin", id: "search-effect" }, +] satisfies AdapterEvent[]) { + test(`replay fence retains ${prefix.type} and the original rejection`, async () => { + const { recovery, guard } = fixture(); + let retried = false; + const events = [prefix, rejection]; + expect(await collect(recovery.wrap(stream(events), () => { retried = true; return stream(success); }, () => "anthropic"))).toEqual(events); + expect(retried).toBe(false); + expect(guard.attempted).toBe(false); + }); +} + +test("harmless ready heartbeats allow exactly one retry and close the old iterator first", async () => { + const { recovery, guard, request } = fixture(); + let closed = false; + let calls = 0; + const original = (async function* () { + try { yield { type: "heartbeat", preflightReady: true } as AdapterEvent; yield rejection; } + finally { closed = true; } + })(); + const events = await collect(recovery.wrap(original, () => { + expect(closed).toBe(true); + calls++; + return stream([rejection]); + }, () => "anthropic")); + expect(events).toEqual([{ type: "heartbeat", preflightReady: true }, rejection]); + expect(calls).toBe(1); + expect(guard.attempted).toBe(true); + expect(request._stripReasoningEncryptedContent).toBe(true); + expect(request._dropForeignReasoningItemIds).toBe(true); +}); + +test("HTTP classification keeps incomplete/non-JSON/non-replayable bodies terminal", async () => { + const marked = Response.json({ error: { message: MESSAGE } }, { status: 400 }); + markResponseNonReplayable(marked); + for (const response of [new Response(MESSAGE, { status: 400 }), Response.json({ error: { message: MESSAGE.slice(0, 30) } }, { status: 400 }), Response.json({ error: { message: MESSAGE } }, { status: 500 }), marked]) { + const { recovery, guard } = fixture(); + expect(await recovery.prepareHttp(response, "anthropic")).toBe(false); + expect(guard.attempted).toBe(false); + expect(response.bodyUsed).toBe(false); + } +}); + +test("provider, missing state, pre-stripped and spent guards cannot arm recovery", async () => { + for (const variant of ["provider", "state", "stripped", "attempted", "aborted"] as const) { + const { recovery, request, guard, abort } = fixture(); + if (variant === "state") request.context.messages = []; + if (variant === "stripped") request._stripReasoningEncryptedContent = true; + if (variant === "attempted") guard.attempted = true; + if (variant === "aborted") abort.abort(); + const events = await collect(recovery.wrap(stream([rejection]), () => { throw new Error("unexpected retry"); }, () => variant === "provider" ? "openai-chat" : "anthropic")); + expect(events).toEqual([rejection]); + } +}); + +test("redacted-only history qualifies, and HTTP success alone does not memoize", async () => { + const { recovery, request } = fixture(); + request.context.messages = [{ role: "assistant", content: [{ type: "thinking", thinking: "", redacted: ["synthetic-redacted"] }] }]; + expect(await recovery.prepareHttp(Response.json({ error: { message: MESSAGE } }, { status: 400 }), "anthropic")).toBe(true); + recovery.rememberSuccess({ status: "completed", output: [{ type: "message" }] }); + expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(false); + await collect(recovery.wrap(stream(success), () => { throw new Error("unexpected retry"); }, () => "anthropic")); + recovery.rememberSuccess({ status: "completed", output: [{ type: "message" }] }); + expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(true); +}); + +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); + const events: AdapterEvent[] = variant === "empty" ? [{ type: "done" }] + : variant === "no-terminal" ? [success[0]!] + : variant === "error" ? [success[0]!, rejection] + : variant === "incomplete" ? [success[0]!, { type: "incomplete", reason: "max_output_tokens" }] + : success; + await collect(recovery.wrap(stream([rejection]), () => stream(events), () => "anthropic")); + if (variant === "cancelled") abort.abort(); + if (variant === "changed-identity") request._reasoningReplayScope!.current = { ...request._reasoningReplayScope!.current!, credentialDurableIdentity: "another-credential" }; + if (variant === "changed-thread") request._reasoningReplayScope = { ...request._reasoningReplayScope!, clientThreadId: "another-thread" }; + recovery.rememberSuccess({ status: variant === "failed-fold" ? "failed" : "completed", output: [{ type: "message" }] }); + expect(reasoningReplayOpaqueBlobRejectionMemoized(rejectedScope)).toBe(false); + expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(false); + }); +} + +test("a stray tool-end and done cannot memoize an empty validated output", async () => { + const { recovery, request } = fixture(); + await collect(recovery.wrap(stream([rejection]), () => stream([ + { type: "tool_call_end" }, { type: "done" }, + ]), () => "anthropic")); + recovery.rememberSuccess({ status: "completed", output: [] }); + expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(false); +}); diff --git a/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts new file mode 100644 index 00000000000..3e08c033c96 --- /dev/null +++ b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts @@ -0,0 +1,443 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { httpStatusFromTerminalError } from "../../src/lib/errors"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; +import { markResponseNonReplayable, SendBudgetExhaustedError } from "../../src/lib/upstream-retry"; +import { resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import * as thoughtSignatureReplay from "../../src/responses/thought-signature-replay"; +import { handleResponses } from "../../src/server/responses/core"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const originalHome = process.env.OPENCODEX_HOME; +const SIGNATURE = "SyntheticSignature123456=="; +const REDACTED = "synthetic-redacted-thinking"; +const REJECTION = "Invalid `signature` in `thinking` block. The block is bound to a different conversation. Remove the thinking block and retry."; +let testDir = ""; +let releaseSpendHome: (() => void) | undefined; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-anthropic-thinking-recovery-")); + process.env.OPENCODEX_HOME = testDir; + releaseSpendHome = acquireOwnedSpendHome(); + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); +}); + +afterEach(() => { + try { releaseSpendHome?.(); } finally { + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(testDir); + } +}); + +function config(): OcxConfig { + return { + defaultProvider: "anthropic-test", + emptyCompletionRetry: false, + providers: { + "anthropic-test": { + adapter: "anthropic", + baseUrl: "https://anthropic.example.test", + authMode: "key", + apiKey: "synthetic-test-key", + // The mock is the entire transport; inherited SOCKS settings must not bypass it. + proxy: "direct", + models: ["claude-sonnet-4-5"], + }, + }, + } as 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 }, + body: JSON.stringify({ + model: "anthropic-test/claude-sonnet-4-5", stream, store: false, + tools: [{ type: "function", name: "lookup", parameters: { type: "object", properties: {} } }], + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier question" }] }, + ...(signed ? [{ type: "reasoning", summary: [{ type: "summary_text", text: "earlier reasoning" }], + encrypted_content: encodeReasoningEnvelope({ sig: SIGNATURE, red: [REDACTED] }) }] : []), + { type: "message", role: "assistant", content: [{ type: "output_text", text: "earlier answer" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "next question" }] }, + ], + }), + }); +} + +function frame(type: string, data: Record = {}): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; +} + +function sse(...frames: string[]): Response { + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); +} + +function rejection(status = 400, message = REJECTION): Response { + return Response.json({ type: "error", error: { type: "invalid_request_error", message } }, { status }); +} + +function streamRejection(...prefix: string[]): Response { + return sse(...prefix, frame("error", { error: { type: "invalid_request_error", message: REJECTION } })); +} + +function textFrame(text: string): string { + return frame("content_block_delta", { index: 0, delta: { type: "text_delta", text } }); +} + +function success(stream: boolean, text = "Recovered answer"): Response { + if (stream) return sse( + frame("message_start", { message: { usage: { input_tokens: 2 } } }), + ...(text ? [textFrame(text)] : []), + frame("message_delta", { delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } }), + frame("message_stop"), + ); + return Response.json({ + id: "msg-recovered", type: "message", role: "assistant", model: "claude-sonnet-4-5", + content: text ? [{ type: "text", text }] : [], stop_reason: "end_turn", + usage: { input_tokens: 2, output_tokens: 3 }, + }); +} + +function fakeUpstream(answer: (index: number) => Response | Promise): string[] { + const bodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(String(init?.body)); + return answer(bodies.length); + }) as typeof fetch; + return bodies; +} + +async function send(stream: boolean, threadId?: string, options: Parameters[3] = {}) { + const log: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(request(stream, threadId), config(), log, options); + const text = await response.text(); + return { response, text, log }; +} + +function expectStrippedRetry(bodies: string[]): void { + expect(bodies).toHaveLength(2); + expect(bodies[0]).toContain(SIGNATURE); + expect(bodies[0]).toContain(REDACTED); + expect(bodies[1]).not.toContain(SIGNATURE); + expect(bodies[1]).not.toContain(REDACTED); + expect(bodies[1]).not.toContain('"type":"thinking"'); + expect(bodies[1]).toContain("earlier answer"); + expect(bodies[1]).toContain("next question"); +} + +function streamedFailure(text: string): { + error: { type: string; code: string; message: string }; + retryable?: boolean; +} { + const event = text.split("\n").filter(line => line.startsWith("data: {") && line.includes('"type":"response.failed"')) + .map(line => JSON.parse(line.slice(6)))[0]; + expect(event?.response?.status).toBe("failed"); + return event.response; +} + +describe("Anthropic thinking-signature recovery through Responses dispatch", () => { + for (const stream of [false, true]) { + test(`HTTP 400 rebuilds once and memoizes only completed output (stream=${stream})`, async () => { + const bodies = fakeUpstream(index => index === 1 ? rejection() : success(stream)); + const result = await send(stream); + expect(result.response.status).toBe(200); + expect(result.text).toContain("Recovered answer"); + expectStrippedRetry(bodies); + expect(result.log.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + expect(result.log.activeAttempt?.sendCount).toBe(2); + await send(stream); + expect(bodies).toHaveLength(3); + expect(bodies[2]).not.toContain(SIGNATURE); + await send(stream, "another-thinking-thread"); + expect(bodies[3]).toContain(SIGNATURE); + }); + + test(`failed and empty stripped retries do not poison later replay (stream=${stream})`, async () => { + for (const retry of ["http-error", "empty", "adapter-error"] as const) { + clearReasoningReplayCacheForTests(); + const bodies = fakeUpstream(index => index === 1 ? rejection() : index === 2 + ? retry === "http-error" ? rejection(400, "Unrelated bad request") + : retry === "empty" ? success(stream, "") + : stream ? streamRejection() : Response.json({ content: [], stop_reason: "error" }) + : success(stream)); + await send(stream); + expectStrippedRetry(bodies); + await send(stream); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + } + }); + } + + test("initial HTTP 200 signature error after harmless pings replaces the stream once", async () => { + const bodies = fakeUpstream(index => index === 1 + ? streamRejection(": keepalive\n\n", frame("ping")) : success(true)); + const result = await send(true); + expect(result.text).toContain("Recovered answer"); + expect(result.text).toContain("response.completed"); + expect(result.text).not.toContain("response.failed"); + expect(result.text).not.toContain(REJECTION); + expectStrippedRetry(bodies); + await send(true); + expect(bodies[2]).not.toContain(SIGNATURE); + }); + + test("HTTP rejection and its replacement stream share a single recovery allowance", async () => { + const bodies = fakeUpstream(index => index === 1 ? rejection() : streamRejection()); + const result = await send(true); + expectStrippedRetry(bodies); + expect(result.text).toContain("response.failed"); + expect(result.text).toContain(REJECTION); + }); + + test("a second stream rejection is terminal and is not memoized", async () => { + const bodies = fakeUpstream(index => index < 3 ? streamRejection() : success(true)); + const result = await send(true); + expectStrippedRetry(bodies); + expect(result.text).toContain("response.failed"); + await send(true); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + }); + + test("a non-OK replacement is delivered as an error rather than parsed as SSE", async () => { + const bodies = fakeUpstream(index => index === 1 ? streamRejection() : index === 2 + ? rejection(400, "Synthetic replacement refusal") : success(true)); + const result = await send(true); + expectStrippedRetry(bodies); + expect(result.text).toContain("Synthetic replacement refusal"); + expect(result.text).toContain("response.failed"); + expect(result.text).not.toContain("possible truncation"); + await send(true); + expect(bodies[2]).toContain(SIGNATURE); + }); + + test("a marked non-replayable replacement withholds diagnostics and preserves its terminal identity", async () => { + const diagnostic = "synthetic-private-upstream-diagnostic"; + const bodies = fakeUpstream(index => { + if (index === 1) return streamRejection(); + if (index !== 2) return success(true); + const response = Response.json({ error: { + type: "invalid_request_error", code: "context_length_exceeded", message: diagnostic, + } }, { status: 400 }); + markResponseNonReplayable(response); + return response; + }); + const result = await send(true); + expectStrippedRetry(bodies); + expect(result.text).not.toContain(diagnostic); + const failure = streamedFailure(result.text); + expect(failure.error).toEqual({ + type: "invalid_request_error", code: "context_length_exceeded", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", + }); + expect(failure.retryable).toBe(false); + expect(httpStatusFromTerminalError(failure.error)).toBe(400); + await send(true); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + }); + + test("a local send-budget refusal retains 429 identity after refetch aborts its upstream signal", async () => { + const bodies = fakeUpstream(index => { + if (index === 1) return streamRejection(); + if (index === 2) throw new SendBudgetExhaustedError("synthetic-upstream"); + return success(true); + }); + const result = await send(true); + expectStrippedRetry(bodies); + const failure = streamedFailure(result.text); + expect(failure.error.type).toBe("rate_limit_error"); + expect(failure.error.code).toBe("request_send_budget_exhausted"); + expect(failure.error.message).toContain("request send budget exhausted before dispatch"); + expect(httpStatusFromTerminalError(failure.error)).toBe(429); + expect(result.text).not.toContain("Upstream recovery failed"); + expect(result.text).not.toContain("Provider unreachable"); + await send(true); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + }); + + test("empty, truncated, and incomplete stream retries never memoize rejection", async () => { + for (const retry of ["empty", "truncated", "incomplete"] as const) { + clearReasoningReplayCacheForTests(); + const bodies = fakeUpstream(index => index === 1 ? streamRejection() : index === 2 + ? retry === "empty" ? success(true, "") : retry === "truncated" ? sse(textFrame("Partial answer")) + : sse(textFrame("Partial answer"), frame("message_delta", { delta: { stop_reason: "max_tokens" } }), frame("message_stop")) + : success(true)); + const result = await send(true); + expectStrippedRetry(bodies); + if (retry !== "empty") expect(result.text).not.toContain("event: response.completed"); + await send(true); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + } + }); + + for (const initialStreamError of [false, true]) { + test(`the shared send budget can deny recovery (initialStreamError=${initialStreamError})`, async () => { + const bodies = fakeUpstream(index => index === 1 + ? initialStreamError ? streamRejection() : rejection() : success(initialStreamError)); + const budget = createRequestExecutionBudget({ + maxTotalModelSends: 0, baseSendAllowance: 0, finalRecoveryAllowance: 0, + maxAlternateTargetSends: 0, maxTargetTransitions: 0, + }, "thinking-recovery-budget-refused"); + await send(initialStreamError, undefined, { sendBudget: budget }); + expect(bodies).toHaveLength(1); + expect(budget.used).toBe(0); + await send(initialStreamError); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(SIGNATURE); + }); + } + + test("concurrent requests each get one recovery and retain separate conversation memos", async () => { + let releaseInitial!: () => void; + const initialBarrier = new Promise(resolve => { releaseInitial = resolve; }); + const bodies = fakeUpstream(async index => { + if (index <= 2) { + if (index === 2) releaseInitial(); + await initialBarrier; + return rejection(); + } + return success(false); + }); + const results = await Promise.all([send(false, "parallel-thinking-a"), send(false, "parallel-thinking-b")]); + expect(results.every(result => result.response.status === 200 && result.text.includes("Recovered answer"))).toBe(true); + expect(bodies).toHaveLength(4); + expect(bodies.slice(0, 2).every(body => body.includes(SIGNATURE))).toBe(true); + expect(bodies.slice(2).every(body => !body.includes(SIGNATURE))).toBe(true); + await send(false, "parallel-thinking-a"); + await send(false, "parallel-thinking-b"); + expect(bodies).toHaveLength(6); + expect(bodies.slice(4).every(body => !body.includes(SIGNATURE))).toBe(true); + }); + + for (const prefix of [ + { name: "visible text", frame: textFrame("Already visible") }, + { name: "thinking output", frame: frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }) }, + { name: "tool call", frame: frame("content_block_start", { index: 0, content_block: { type: "tool_use", id: "call-synthetic", name: "lookup" } }) }, + ]) { + test(`does not resend after ${prefix.name}`, async () => { + const bodies = fakeUpstream(() => streamRejection(prefix.frame)); + const result = await send(true); + expect(bodies).toHaveLength(1); + expect(result.text).toContain("response.failed"); + expect(result.text).toContain(REJECTION); + }); + } + + test("unrelated wording and missing replayed thinking never gain a resend", async () => { + for (const message of ["Invalid `signature` in `thinking` block", "The block is bound to a different conversation", "Invalid API key"]) { + const bodies = fakeUpstream(() => rejection(400, message)); + const result = await send(false); + expect(result.response.status).toBe(400); + expect(bodies).toHaveLength(1); + } + const bodies = fakeUpstream(() => rejection()); + const result = await handleResponses(request(false, "unsigned-thread", false), config(), { model: "", provider: "" }); + await result.text(); + expect(result.status).toBe(400); + expect(bodies).toHaveLength(1); + }); + + test("reasoning that Anthropic never serialized cannot gain an identical resend", async () => { + for (const stream of [false, true]) { + for (const encrypted of [undefined, "native-provider-opaque-blob", + encodeReasoningEnvelope({ sig: "short" }), + encodeReasoningEnvelope({ sig: "call_not_a_real_signature" })]) { + const bodies = fakeUpstream(() => stream ? streamRejection() : rejection()); + const base = request(stream); + const body = await base.json() as { input: Record[] }; + body.input[1] = { + type: "reasoning", summary: [{ type: "summary_text", text: "unsigned reasoning" }], + ...(encrypted ? { encrypted_content: encrypted } : {}), + }; + const response = await handleResponses(new Request(base.url, { + method: "POST", headers: base.headers, body: JSON.stringify(body), + }), config(), { model: "", provider: "" }); + const text = await response.text(); + expect(bodies).toHaveLength(1); + expect(bodies[0]).not.toContain('"type":"thinking"'); + expect(bodies[0]).not.toContain('"type":"redacted_thinking"'); + expect(text).toContain(REJECTION); + } + } + }); + + test("non-replayable HTTP responses stay terminal", async () => { + const bodies = fakeUpstream(() => { + const response = rejection(); + markResponseNonReplayable(response); + return response; + }); + const result = await send(false); + expect(result.response.status).toBe(400); + expect(bodies).toHaveLength(1); + }); + + test("cancellation before recovery prevents a second send and leaves replay available", async () => { + const abort = new AbortController(); + const bodies = fakeUpstream(index => { + if (index === 1) { abort.abort(); return rejection(); } + return success(false); + }); + await send(false, undefined, { abortSignal: abort.signal }); + expect(bodies).toHaveLength(1); + await send(false); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(SIGNATURE); + }); + + test("cancellation after recovery dispatch cannot memoize an unread HTTP 200", async () => { + const abort = new AbortController(); + const bodies = fakeUpstream(index => { + if (index === 1) return streamRejection(); + if (index === 2) abort.abort(); + return success(true); + }); + const response = await handleResponses(request(true), config(), { model: "", provider: "" }, { abortSignal: abort.signal }); + try { await response.text(); } catch { /* client cancellation can reject its stream reader */ } + expectStrippedRetry(bodies); + await send(true); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + }); + + test("caller cancellation during buffered durability wait cannot memoize completed recovery", async () => { + const abort = new AbortController(); + const bodies = fakeUpstream(index => index === 1 ? rejection() : success(false)); + const durability = spyOn(thoughtSignatureReplay, "awaitThoughtSignatureDurability").mockImplementation(async () => { + abort.abort(); + }); + try { + await send(false, undefined, { abortSignal: abort.signal }); + expect(durability).toHaveBeenCalledTimes(1); + expect(abort.signal.aborted).toBe(true); + expectStrippedRetry(bodies); + } finally { + durability.mockRestore(); + } + await send(false); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + }); +}); diff --git a/tests/server/inference-client-encoder-delivery.test.ts b/tests/server/inference-client-encoder-delivery.test.ts index 0b1c3fd3b7e..0c549364408 100644 --- a/tests/server/inference-client-encoder-delivery.test.ts +++ b/tests/server/inference-client-encoder-delivery.test.ts @@ -18,6 +18,7 @@ import type { RequestLogContext, RequestLogEntry } from "../../src/server/reques import { markProtocolEntry, protocolTraceForRequest } from "../../src/protocols/trace"; import { parseRequest } from "../../src/responses/parser"; import { buildToolBridgeMaps } from "../../src/server/responses"; +import { createAnthropicThinkingRecovery } from "../../src/server/responses/anthropic-thinking-recovery"; import { deliverAdapterResponse } from "../../src/server/responses/adapter-delivery"; import { peekReasoningForCall } from "../../src/responses/reasoning-replay-cache"; import type { AdapterEvent, OcxConfig, OcxReasoningReplayScopeRef, OcxUsage } from "../../src/types"; @@ -287,6 +288,7 @@ describe("deliverAdapterResponse through a client encoder", () => { { type: "done" }, ]; const completed: Record[] = []; + const upstream = new AbortController(); const response = await deliverAdapterResponse( { logCtx: { model: "m", provider: "p" }, @@ -314,7 +316,9 @@ describe("deliverAdapterResponse through a client encoder", () => { { emptyCompletionGuardEnabled: false }, { upstreamResponse: new Response(""), - upstream: new AbortController(), + upstream, + thinkingSignatureRecovery: createAnthropicThinkingRecovery(parsed, upstream.signal, { attempted: false }, () => false), + refetchThinkingSignature: async () => { throw new Error("unexpected recovery without thinking history"); }, cleanupUpstreamAbort: () => {}, localUpstream: false, } as unknown as Args[6], From c1053c1d56f6f8dcf9447621d2ada93cf3b9ca07 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Wed, 14 Oct 2026 17:03:17 +0800 Subject: [PATCH 2/9] fix(responses): recover thinking signatures within hosted search iterations (cherry picked from commit 8b801dbc764ce901c69411bc2c7499c3c5705e6a) --- .../src/content/docs/guides/providers.md | 8 +- scripts/test-layout/layout.json | 1 + .../responses/anthropic-thinking-recovery.ts | 30 +- src/server/responses/sidecar-execution.ts | 39 +- src/server/responses/sidecar-send-budget.ts | 4 +- src/web-search/loop.ts | 136 +++-- src/web-search/progress-stream.ts | 12 +- structure/runtime.md | 2 +- .../transports/anthropic-thinking-recovery.md | 26 +- tests/fixtures/test-layout-expected.json | 1 + ...search-thinking-signature-recovery.test.ts | 540 ++++++++++++++++++ 11 files changed, 744 insertions(+), 55 deletions(-) create mode 100644 tests/responses/responses-web-search-thinking-signature-recovery.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f72cb817835..e0b10b2cf28 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -82,7 +82,13 @@ On the Responses endpoint, an Anthropic-compatible upstream can reject a replaye signature because it belongs to a different conversation prefix. When the error explicitly identifies this signature-binding problem, opencodex can remove replayed signed/redacted thinking and retry once within the request's send budget. This covers HTTP 400 refusals and initial stream -errors before output or tool activity. Other errors keep their existing handling. +errors before output or tool activity. Hosted web search uses the same one-recovery limit for +all model iterations: only the rejected iteration is resent, while completed searches and tool +results are retained. The strip policy continues into later iterations. A failed replacement is +returned without another signature retry. Other errors keep their existing handling. + +Search recovery is disabled when enforced spend accounting cannot establish receipts for earlier +ordinary search sends. It never increases a caller or Combo send allowance. Only a successful, nonempty completion remembers the rejection for that conversation and serving identity for five minutes. Plaintext history remains available; a failed or cancelled retry does diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e66d25411ae..76685b1c43a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1453,6 +1453,7 @@ "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", "responses-anthropic-thinking-signature-recovery.test.ts": "responses", + "responses-web-search-thinking-signature-recovery.test.ts": "responses", "responses-anthropic-thinking-recovery.test.ts": "responses", "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", diff --git a/src/server/responses/anthropic-thinking-recovery.ts b/src/server/responses/anthropic-thinking-recovery.ts index 9f99cfeb873..571dbcf3e45 100644 --- a/src/server/responses/anthropic-thinking-recovery.ts +++ b/src/server/responses/anthropic-thinking-recovery.ts @@ -1,3 +1,5 @@ +import { normalizeUpstreamErrorText, readDisplaySafeErrorText } from "./core-errors"; +import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { isLikelyRealAnthropicThinkingSignature } from "../../adapters/anthropic"; import type { AdapterEvent, OcxParsedRequest } from "../../types"; import { upstreamErrorMessageFromPayload } from "../../lib/errors"; @@ -33,27 +35,28 @@ export function createAnthropicThinkingRecovery( let failed = false; let remembered = false; - const prepare = (message: string, adapterName: string): boolean => { + const prepare = (message: string, adapterName: string, attempt = parsed): boolean => { if (signal.aborted || !canRetry() || adapterName !== "anthropic" || guard.attempted - || parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(parsed) + || parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(attempt) || !isAnthropicThinkingSignatureBindingRejectionMessage(message)) return false; guard.attempted = true; armed = true; const scope = parsed._reasoningReplayScope; rejectedScope = scope ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined; prepareOpaqueBlobRecovery(parsed); + if (attempt !== parsed) prepareOpaqueBlobRecovery(attempt); return true; }; - const prepareHttp = async (response: Response, adapterName: string): Promise => { + const prepareHttp = async (response: Response, adapterName: string, attempt = parsed): Promise => { if (signal.aborted || !canRetry() || response.status !== 400 || isNonReplayableResponse(response) || adapterName !== "anthropic" || guard.attempted - || parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(parsed)) return false; + || parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(attempt)) return false; try { const body = await readBoundedResponseBody(response.clone(), { signal }); if (!body.displaySafe || body.truncated) return false; const message = upstreamErrorMessageFromPayload(JSON.parse(body.text) as unknown); - return typeof message === "string" && prepare(message, adapterName); + return typeof message === "string" && prepare(message, adapterName, attempt); } catch { return false; } @@ -112,5 +115,20 @@ export function createAnthropicThinkingRecovery( remembered = true; rememberReasoningReplayOpaqueBlobRejection(rejectedScope); }; - return { prepareHttp, wrap, rememberSuccess }; + // The search owner supplies only a fully validated current iteration, never search-card output. + const observeIteration = (events: readonly AdapterEvent[]): void => { + outputSeen = false; + completed = false; + for (const event of events) observe(event); + }; + const projectHttpFailure = async (response: Response, abortSignal: AbortSignal): Promise> => { + const nonReplayable = isNonReplayableResponse(response); + if (nonReplayable) response = await sanitizeNonReplayableUpstreamError(response, abortSignal); + const text = await readDisplaySafeErrorText(response, abortSignal, `Provider error ${response.status}`); + const error = normalizeUpstreamErrorText(text, `Provider error ${response.status}`); + return { type: "error", status: response.status, message: error.message ?? error.safeText, + errorType: error.type ?? "upstream_error", ...(error.code ? { code: error.code } : {}), + ...(nonReplayable ? { retryable: false } : {}) }; + }; + return { prepareHttp, prepareEvent: prepare, observeIteration, projectHttpFailure, wrap, rememberSuccess }; } diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 46c027216de..14efb4597a9 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -1,3 +1,4 @@ +import { createAnthropicThinkingRecovery } from "./anthropic-thinking-recovery"; import { rotateAnthropicAccountOnResponseForInstance } from "../../oauth/anthropic-account-refusal"; import type { ResponsesRequestContext } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; @@ -32,7 +33,7 @@ import { failoverAccountSnapshot, } from "../../oauth/generic-account-failover"; import { classifyKiroRefusal } from "../../adapters/kiro-refusal"; -import { isNonReplayableResponse } from "../../lib/upstream-retry"; +import { isNonReplayableResponse, SendBudgetExhaustedError } from "../../lib/upstream-retry"; import { unwrapUpstreamRetryEvidenceError } from "../../codex/auth-context"; import { AdmissionModelDeniedError, admissionModelDeniedResponse } from "../admission-model-scope"; import { noteKiroMonthlyRefusal, noteKiroServedSuccess } from "../../providers/kiro-usage"; @@ -98,7 +99,7 @@ export async function executeResponsesSidecars( | "notifyResponseComplete" | "cancelResponseCompletion" >, - sendBudgetState: Pick, + sendBudgetState: Pick, ) { const { config, options, logCtx } = requestContext; const antigravityPoolActivated = requestState.route.providerName === "google-antigravity" @@ -377,7 +378,7 @@ export async function executeResponsesSidecars( recoveryKind = "oauth-account-429"; if (!options.comboInitialSend && canRunWebSearch && route.providerName === "google-antigravity") sendBudgetState.pendingHopPermit = hop.permit; - else sidecarBudget.ownCredentialHop(hop.permit); + else sidecarBudget.ownCredentialHop(hop.permit, canRunWebSearch && transportState.adapter.name === "anthropic"); } else if ( // Anthropic's pool is excluded from generic failover, so without this arm a 429 inside a // web-search or image-bridge turn was terminal even with the pool fully enabled -- while @@ -418,7 +419,7 @@ export async function executeResponsesSidecars( return null; } recoveryKind = originalResponse?.status === 401 ? "oauth-401" : "anthropic-oauth-429"; - sidecarBudget.ownCredentialHop(hop.permit); + sidecarBudget.ownCredentialHop(hop.permit, canRunWebSearch && transportState.adapter.name === "anthropic"); } else { // No key pool, no generic OAuth roster, no Anthropic pool could produce a replacement // credential. The 429 is terminal for this sidecar turn. @@ -592,6 +593,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) { + // The legacy direct-search lane has no authoritative receipt for earlier sends under + // enforced spend. Fail closed for recovery there instead of granting unproven headroom. + const thinkingRecovery = createAnthropicThinkingRecovery(parsed, + options.abortSignal ?? new AbortController().signal, { attempted: false }, + () => !sendBudgetState.sendBudgetExhausted() + && !!sendBudgetState.adapterDispatchBudget + && sendBudgetState.adapterDispatchBudget.spendEnforced !== true); 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. @@ -603,10 +611,30 @@ export async function executeResponsesSidecars( })(input, init)) as typeof globalThis.fetch; const loopResponse = await runWithWebSearch({ parsed, adapter: transportState.adapter, + ...(transportState.adapter.name === "anthropic" ? { thinkingRecovery } : {}), onIterationEnd: sidecarBudget.releaseUnsentHop, - fetchForRequest: (request, iterParsed, adapter) => providerFetch(route.provider, options.codexWsRuntimeIdentity, { + fetchForRequest: (request, iterParsed, adapter, recovery) => providerFetch(route.provider, options.codexWsRuntimeIdentity, { dispatchOverride: oauthDispatch(request, iterParsed), providerName: route.providerName, modelId: route.modelId, ...sidecarBudget.fetchOptions(adapter), + ...(adapter.name === "anthropic" ? { onPhysicalDispatch: () => { + if (recovery === "opaque-blob-rejection") { + if (sendBudgetState.adapterDispatchBudget?.spendEnforced) throw new SendBudgetExhaustedError("web-search thinking recovery"); + const decision = sendBudgetState.adapterDispatchBudget?.reserveDispatch({ + sendClass: "repair", targetKey: `${route.providerName}|${request.url}|${route.modelId}`, + }); + if (!decision?.allowed) throw new SendBudgetExhaustedError("web-search thinking recovery"); + try { sendBudgetState.noteInitialDispatch(decision.permit); } + finally { decision.permit.release(); } + } else { + const existing = sidecarBudget.fetchOptions(adapter).onPhysicalDispatch; + if (existing) existing(); + else { + const prepaid = sendBudgetState.pendingHopPermit; + sendBudgetState.pendingHopPermit = undefined; + sendBudgetState.noteInitialDispatch(prepaid); + } + } + } } : {}), }), incomingMeta: { headers: requestState.selectedForwardHeaders, @@ -665,6 +693,7 @@ export async function executeResponsesSidecars( if (route.providerName === "kiro" && response.status === "completed" && served && noteKiroServedSuccess(served.accountId, served.generation)) persistKiroAccountState(); commitReasoningReplayServingRoute(); + if (!options.abortSignal?.aborted) thinkingRecovery.rememberSuccess(response); notifyResponseComplete(response); }, }); diff --git a/src/server/responses/sidecar-send-budget.ts b/src/server/responses/sidecar-send-budget.ts index 8d0402a22a1..c2bed679260 100644 --- a/src/server/responses/sidecar-send-budget.ts +++ b/src/server/responses/sidecar-send-budget.ts @@ -22,8 +22,8 @@ export function createSidecarSendBudget( }; return { /** Retain a Combo hop until dispatch; direct requests keep eager settlement. */ - ownCredentialHop(permit?: SingleUseDispatchPermit): void { - if (!initial) { permit?.use(); return; } // Preserve direct callers' reporting contract. + ownCredentialHop(permit?: SingleUseDispatchPermit, retainUntilDispatch = false): void { + if (!initial && !retainUntilDispatch) { permit?.use(); return; } // Preserve direct callers' reporting contract. releaseHop(); hopPermit = permit; budget.pendingHopPermit = permit; diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 8720e657411..40bb62383b6 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -1,3 +1,4 @@ +import type { createAnthropicThinkingRecovery } from "../server/responses/anthropic-thinking-recovery"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base"; import type { AdapterEvent, OcxConfig, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxProviderOpaqueToolCallMetadata, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types"; import { namespacedToolName, toolChoiceToolPredicate } from "../types"; @@ -15,7 +16,7 @@ import type { WebSearchBackendId } from "./index"; import { pacingHeaderDeadline } from "./header-deadline"; import { redactSecretString } from "../lib/redact"; import { readBoundedResponseBody } from "../lib/bounded-body"; -import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry"; import { rateLimitRetryDelayMs } from "../providers/key-failover"; import { isTranslatorBudgetExceededError, @@ -23,7 +24,7 @@ import { TranslatorBudgetExceededError, } from "../lib/translator-budget"; import { formatWebSearchResults } from "./format-result"; -import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "./progress-stream"; +import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "./progress-stream"; import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; const SSE_HEADERS = { @@ -250,17 +251,17 @@ function forcedAnswerRetryNudge(): OcxMessage { }; } -function jsonError(status: number, message: string): Response { - return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { +function jsonError(status: number, message: string, event?: Extract): Response { + return new Response(JSON.stringify({ error: { message, type: event?.errorType ?? "upstream_error", code: event?.code ?? null, ...(event?.retryable === false ? { retryable: false } : {}) } }), { status, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...(event?.retryable === false ? { "x-should-retry": "false" } : {}) }, }); } /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a * non-200 jsonError; later (already-streaming) iterations surface it as an in-stream error event. */ class LoopError extends Error { - constructor(readonly status: number, message: string) { + constructor(readonly status: number, message: string, readonly event?: Extract) { super(message); this.name = "LoopError"; } @@ -323,7 +324,8 @@ export interface WebSearchLoopDeps { /** Observe the exact adapter request selected for each routed-model iteration. */ onRequestBuilt?: (request: AdapterRequest) => void; /** Request-scoped executor retains the core's selection binding across loop retries. */ - fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter) => typeof globalThis.fetch; + thinkingRecovery?: ReturnType; + 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; /** @@ -414,6 +416,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise Promise; } type IterationSplit = ReturnType & { /** @@ -430,6 +433,38 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise => { + if (!prepared.response.ok) { + if (deps.thinkingRecovery && prepared.responseAdapter.name === "anthropic") { + const event = await deps.thinkingRecovery.projectHttpFailure(prepared.response, signal); + throw new LoopError(event.status ?? prepared.response.status, event.message, event); + } + let body: Awaited>; + try { + body = await readBoundedResponseBody(prepared.response, { signal }); + } catch { + // The response status is authoritative even when its untrusted error body fails while + // being read (including a synchronous getReader() failure). Never route that failure + // through the adapter formatter or the generic transport error, which could expose its + // raw message. A parent/client cancellation still owns the request lifecycle as 499. + if (signal.aborted) throw new LoopError(499, "client closed request during web-search"); + throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`); + } + let formatted = ""; + if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) { + try { + formatted = prepared.responseAdapter.formatErrorBody( + prepared.response.status, + prepared.response.headers, + body.text, + ).trim(); + } catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ } + } + const suffix = formatted ? `: ${formatted.slice(0, 400)}` : ""; + throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`); + } + }; + // Acquire one iteration's final response headers. The first call is drained eagerly so an initial // connect/header/HTTP failure stays a non-2xx JSON response. Its successful BODY is deliberately // left unread until the downstream Responses SSE bridge exists. @@ -494,7 +529,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise => { + if (signal.aborted || !error.replaySafe || isNonReplayableResponse(prepared.response) + || !deps.thinkingRecovery?.prepareEvent(error.event.message, prepared.responseAdapter.name, iterParsed)) return; + cachedRequest = undefined; + cachedAdapter = undefined; + // The successful headers ended the previous timer; the replacement owns a fresh deadline. + headerDeadline = pacingHeaderDeadline(connectTimeoutMs, signal); + try { + const replacement = await fetchOnce(prepared.responseAdapter, "opaque-blob-rejection"); + headerDeadline.clear(); + await requireSuccessfulHeaders(replacement); + return replacement; + } catch (error) { + if (signal.aborted) throw new LoopError(499, "client closed request during web-search"); + if (headerDeadline.didExpire()) throw new LoopError(504, + `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`); + if (error instanceof LoopError || error instanceof SendBudgetExhaustedError || isTranslatorBudgetExceededError(error)) throw error; + throw new LoopError(502, `Provider unreachable: ${error instanceof Error ? error.message : String(error)}`); + } finally { + headerDeadline.clear(); + deps.onIterationEnd?.(); + } + }; let prepared = await fetchOnce(adapter); // Same-target 429 wait-and-retry (opt-in `retryOn429`) BEFORE key rotation: a primary-key // rate-limit blip replays on the SAME key; rotation only runs after attempts exhaust. @@ -609,36 +667,20 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise {}); } catch { /* closed */ } + cachedRequest = undefined; + cachedAdapter = undefined; + prepared = await fetchOnce(adapter, "opaque-blob-rejection"); + } + + // Final headers have arrived. Clear only the deadline timer before ANY body read. headerDeadline.clear(); - if (!prepared.response.ok) { - let body: Awaited>; - try { - body = await readBoundedResponseBody(prepared.response, { signal }); - } catch { - // The response status is authoritative even when its untrusted error body fails while - // being read (including a synchronous getReader() failure). Never route that failure - // through the adapter formatter or the generic transport error, which could expose its - // raw message. A parent/client cancellation still owns the request lifecycle as 499. - if (signal.aborted) throw new LoopError(499, "client closed request during web-search"); - throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`); - } - let formatted = ""; - if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) { - try { - formatted = prepared.responseAdapter.formatErrorBody( - prepared.response.status, - prepared.response.headers, - body.text, - ).trim(); - } catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ } - } - const suffix = formatted ? `: ${formatted.slice(0, 400)}` : ""; - throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`); - } - return prepared; + await requireSuccessfulHeaders(prepared); + return { ...prepared, recover }; } catch (error) { - if (isTranslatorBudgetExceededError(error)) throw error; + if (isTranslatorBudgetExceededError(error) || error instanceof SendBudgetExhaustedError) throw error; if (headerDeadline.didExpire()) { throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`); } @@ -681,6 +723,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise, readonly replaySafe: boolean) { + super(event.message); + } +} + export interface ParseStreamWithProgressOptions { + preserveAdapterError?: boolean; inactivityTimeoutMs: number; translatorBudget: TranslatorBudget; signal?: AbortSignal; @@ -247,6 +255,7 @@ export async function* parseStreamWithProgress( resetInactivity(); + let replaySafe = true; const parserPump = (async (): Promise => { let heldTerminal: Extract | undefined; try { @@ -298,9 +307,10 @@ export async function* parseStreamWithProgress( if (event.type === "error") { fail(event.code === "translation_buffer_limit" ? new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_TURN_BYTES) - : new Error(event.message)); + : options.preserveAdapterError ? new WebSearchAdapterEventError(event, replaySafe) : new Error(event.message)); return; } + replaySafe &&= event.type === "heartbeat" && event.replayUnsafe !== true; if (event.type === "done" || event.type === "incomplete") { heldTerminal = event; // Response-byte inactivity ends once the adapter has produced a terminal event. diff --git a/structure/runtime.md b/structure/runtime.md index 2a31215cb52..8b5aa8d9d5b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -400,7 +400,7 @@ Automatic Codex pool selection and account status share the [plan exclusion cont Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools. `src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call, only while an iteration remains. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. `src/web-search/run-turn-loop.ts` shares this recovery and checks the ceiling before dispatch so it cannot start an unused request; below the search cap, `emptyCompletionRetry` permits one identical empty-answer retry per request with current tools and results. Errors and invalid terminals never authorize another search. Batched results from one call share one 8,000-character budget in `src/web-search/format-result.ts`, planned per query before serialization (#6621): every query keeps its label and status, shortened answers and unlisted sources carry markers with their original counts outside the untrusted boundary, structured output stays one valid JSON document, and a batch whose fixed parts alone overflow is condensed to status lines with a per-status count of any query that still did not fit. -`src/web-search/loop.ts` uses `src/web-search/header-deadline.ts` to suspend the cumulative response-header deadline while each physical send awaits local pacing admission. Ordinary fetches, adapter executors, reset retries and pool-key rotations retain the remaining budget after admission; same-target 429 backoff still clears and restarts that budget. The default remains 200,000 ms. Client cancellation remains linked during queued admission and after final headers, and the existing pacing slot wrapper retains leases through body completion or cancellation. Streaming retry heartbeats and body inactivity deadlines keep their existing ownership. `tests/web-search/web-search-pacing-deadline.test.ts` covers queued success, real header stalls, queued cancellation, retry admission and cumulative rotation time. OpenAI sidecar 429 replays run only when their backoff fits the remaining sidecar deadline; otherwise the original 429 remains the routing-health outcome rather than becoming a timeout. Reset recovery and 429 replays share one three-send budget per search, so the two layers cannot multiply physical sends. Direct vision/search helpers and the alpha/search relay retain the resolver's live [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) through physical sends and retries; a local hold prevents the send without becoming an upstream connection failure. +Anthropic hosted-search model iterations use the [request-scoped thinking-signature recovery policy](transports/anthropic-thinking-recovery.md), retaining completed searches and tool results. `src/web-search/loop.ts` uses `src/web-search/header-deadline.ts` to suspend the cumulative response-header deadline while each physical send awaits local pacing admission. Ordinary fetches, adapter executors, reset retries and pool-key rotations retain the remaining budget after admission; same-target 429 backoff still clears and restarts that budget. The default remains 200,000 ms. Client cancellation remains linked during queued admission and after final headers, and the existing pacing slot wrapper retains leases through body completion or cancellation. Streaming retry heartbeats and body inactivity deadlines keep their existing ownership. `tests/web-search/web-search-pacing-deadline.test.ts` covers queued success, real header stalls, queued cancellation, retry admission and cumulative rotation time. OpenAI sidecar 429 replays run only when their backoff fits the remaining sidecar deadline; otherwise the original 429 remains the routing-health outcome rather than becoming a timeout. Reset recovery and 429 replays share one three-send budget per search, so the two layers cannot multiply physical sends. Direct vision/search helpers and the alpha/search relay retain the resolver's live [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) through physical sends and retries; a local hold prevents the send without becoming an upstream connection failure. ## Scoped provider quota for Combo selection `src/providers/quota/report-cache.ts` publishes routing evidence only when a producer explicitly supplies its diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md index f4ddce0af98..1f6f78adbd6 100644 --- a/structure/transports/anthropic-thinking-recovery.md +++ b/structure/transports/anthropic-thinking-recovery.md @@ -16,7 +16,7 @@ activity and replay-unsafe heartbeats fence replay. The rejected iterator closes Recovery uses the existing opaque-state strip/drop flags and leaves plaintext history intact. A replacement HTTP refusal retains its status, safe error message/type and code; its body is not parsed as SSE. Cancellation, budget exhaustion and a second rejection cannot trigger another -signature recovery. No Anthropic `runTurn` path or search/continuation recovery is introduced. +signature recovery. No Anthropic `runTurn` or continuation recovery is introduced. 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 @@ -26,3 +26,27 @@ durable-serving-identity key and five-minute TTL are unchanged. `tests/responses/responses-anthropic-thinking-signature-recovery.test.ts` exercises the actual Anthropic adapter with synthetic HTTP/SSE; `tests/responses/responses-anthropic-thinking-recovery.test.ts` pins heartbeat/side-effect fences, exact classification, iterator cleanup and memo boundaries. + +## Hosted search iterations + +`src/server/responses/sidecar-execution.ts` supplies the same request-owned policy to +`src/web-search/loop.ts`. Only the current routed-model iteration is rebuilt; completed searches, +tool results, and history remain intact. Request caches are invalidated and the root strip flags +persist into later iterations. HTTP 400 classification runs after existing 429/key recovery. +`src/web-search/progress-stream.ts` has an opt-in typed adapter-error handoff before its normal +exception conversion, with a per-send semantic/replay-unsafe fence and original-body cleanup. +A streamed replacement gets a fresh header deadline; an HTTP refusal retains its current deadline. + +The Anthropic search HTTP receipt admits a repair against the shared dispatch budget and settles +it once. Ordinary direct search sends retain their existing reporting contract; Combo bookings +and credential-hop permits are settled rather than charged twice. Recovery is conservatively +unavailable with enforced spend accounting because this legacy direct search lane cannot prove +receipts for earlier ordinary sends. No explicit caller or Combo allowance is increased, and the +ordinary search iteration limit/admission behavior is unchanged. + +Only a validated current model iteration contributes completion/output evidence; search cards +cannot establish memo success. The final bridge completion still commits the memo, subject to +cancellation and unchanged serving scope. Replacement HTTP errors keep status/type/code and +non-replayable diagnostics remain withheld. A failed repair is terminal, including a replacement +429; it does not start another credential/rate-limit ladder. The actual-adapter hosted-search contract lives in +`tests/responses/responses-web-search-thinking-signature-recovery.test.ts`. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a8d4b544996..1ca46c3aa89 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -971,6 +971,7 @@ "responses-4546-incident-regression.test.ts": "responses", "responses-account-change-scrub.test.ts": "responses", "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", "responses-anthropic-thinking-signature-recovery.test.ts": "responses", + "responses-web-search-thinking-signature-recovery.test.ts": "responses", "responses-anthropic-thinking-recovery.test.ts": "responses", "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", diff --git a/tests/responses/responses-web-search-thinking-signature-recovery.test.ts b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts new file mode 100644 index 00000000000..4ac99f571b8 --- /dev/null +++ b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts @@ -0,0 +1,540 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; +import { markResponseNonReplayable, SendBudgetExhaustedError } from "../../src/lib/upstream-retry"; +import { createSpendReservationLedger, DEFAULT_SPEND_RESERVATION_POLICY, type SpendJournal } from "../../src/lib/spend-reservation-ledger"; +import { createRequestSpendTracker } from "../../src/server/responses/request-spend"; +import { resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { handleResponses } from "../../src/server/responses/core"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const originalHome = process.env.OPENCODEX_HOME; +const SIGNATURE = "SyntheticSearchSignature123456=="; +const REDACTED = "synthetic-search-redacted-thinking"; +const REJECTION = "Invalid `signature` in `thinking` block. The block is bound to a different conversation. Remove the thinking block and retry."; +let home = ""; +let release: (() => void) | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-search-thinking-recovery-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); +}); +afterEach(() => { + try { release?.(); } finally { + release = undefined; + globalThis.fetch = originalFetch; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); + } +}); + +function config(): OcxConfig { + return { + defaultProvider: "anthropic-test", emptyCompletionRetry: false, + webSearchSidecar: { backend: "exa", exaApiKey: "synthetic-search-key", maxSearchesPerTurn: 2 }, + providers: { "anthropic-test": { + adapter: "anthropic", baseUrl: "https://anthropic.example.test", authMode: "key", + apiKey: "synthetic-test-key", proxy: "direct", models: ["claude-sonnet-4-5"], + } }, + } as OcxConfig; +} +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 }, + 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: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier question" }] }, + ...(signed ? [{ type: "reasoning", summary: [{ type: "summary_text", text: "earlier reasoning" }], + encrypted_content: encodeReasoningEnvelope({ sig: SIGNATURE, red: [REDACTED] }) }] : []), + { type: "message", role: "assistant", content: [{ type: "output_text", text: "earlier answer" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "search this fixture" }] }, + ], + }), + }); +} +function frame(type: string, data: Record = {}): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; +} +function sse(...frames: string[]): Response { + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); +} +function rejection(message = REJECTION): Response { + return Response.json({ type: "error", error: { type: "invalid_request_error", message } }, { status: 400 }); +} +function streamRejection(...prefix: string[]): Response { + return sse(...prefix, frame("error", { error: { type: "invalid_request_error", message: REJECTION } })); +} +function textFrame(text: string): string { + return frame("content_block_delta", { index: 0, delta: { type: "text_delta", text } }); +} +function success(text = "Recovered search answer", stopReason = "end_turn"): Response { + return sse(frame("message_start", { message: { usage: { input_tokens: 2 } } }), + ...(text ? [textFrame(text)] : []), + frame("message_delta", { delta: { stop_reason: stopReason }, usage: { output_tokens: 3 } }), frame("message_stop")); +} +function search(id = "ws_fixture", query = "fixture"): Response { + return sse(frame("message_start", { message: { usage: { input_tokens: 2 } } }), + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "thinking_delta", thinking: "search reasoning" } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: SIGNATURE } }), + frame("content_block_stop", { index: 0 }), + frame("content_block_start", { index: 1, content_block: { type: "tool_use", id, name: "web_search", input: {} } }), + frame("content_block_delta", { index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ query }) } }), + frame("content_block_stop", { index: 1 }), + frame("message_delta", { delta: { stop_reason: "tool_use" }, usage: { output_tokens: 3 } }), frame("message_stop")); +} +function transport(answer: (index: number, init?: RequestInit) => Response | Promise) { + const bodies: string[] = []; + const queries: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://api.exa.ai/search") { + queries.push(String(init?.body)); + return Response.json({ results: [{ title: "fixture", url: "https://fixture.example.test", text: "retained search result" }] }); + } + if (!["https://anthropic.example.test/v1/messages", "https://other-anthropic.example.test/v1/messages"].includes(url)) throw new Error(`Unexpected network target blocked: ${url}`); + bodies.push(String(init?.body)); + return answer(bodies.length, init); + }) as typeof fetch; + return { bodies, queries }; +} +async function send(options: Parameters[3] = {}, cfg = config(), req = request()) { + const log: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(req, cfg, log, options); + return { response, text: await response.text(), log }; +} +function expectStripped(body: string): void { + expect(body).not.toContain(SIGNATURE); + expect(body).not.toContain(REDACTED); + expect(body).not.toContain('"type":"thinking"'); + expect(body).not.toContain('"type":"redacted_thinking"'); + expect(body).toContain("earlier answer"); + expect(body).toContain("search this fixture"); +} +function removeThinking(body: string): unknown { + const parsed = JSON.parse(body); + for (const message of parsed.messages) { + if (Array.isArray(message.content)) message.content = message.content.filter( + (part: { type: string }) => part.type !== "thinking" && part.type !== "redacted_thinking"); + } + return parsed; +} + +describe("hosted web search Anthropic thinking-signature recovery", () => { + for (const stream of [false, true]) { + test(`HTTP 400 rebuilds current iteration and memoizes final completion (stream=${stream})`, async () => { + const { bodies, queries } = transport(index => index === 1 ? rejection() : success()); + const result = await send({}, config(), request(stream)); + expect(result.response.status).toBe(200); + expect(result.text).toContain("Recovered search answer"); + expect(result.text).not.toContain("response.failed"); + expect(bodies).toHaveLength(2); + expect(bodies[0]).toContain(SIGNATURE); + expectStripped(bodies[1]); + expect(JSON.parse(bodies[1])).toEqual(removeThinking(bodies[0])); + expect(queries).toHaveLength(0); + expect(result.log.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + await send({}, config(), request(stream)); + expect(bodies).toHaveLength(3); + expectStripped(bodies[2]); + }); + } + test("initial raw SSE error after pings retries before any current-send output", async () => { + const { bodies } = transport(index => index === 1 ? streamRejection(": keepalive\n\n", frame("ping")) : success()); + const result = await send(); + expect(result.text).toContain("Recovered search answer"); + expect(result.text).toContain("response.completed"); + expect(result.text).not.toContain(REJECTION); + expect(bodies).toHaveLength(2); + expectStripped(bodies[1]); + }); + for (const rawSse of [false, true]) { + test(`rejection after search replays only current iteration and retains tool history (SSE=${rawSse})`, async () => { + const { bodies, queries } = transport(index => index === 1 ? search() : index === 2 + ? rawSse ? streamRejection(frame("ping")) : rejection() : success()); + const result = await send(); + expect(result.text).toContain("Recovered search answer"); + expect(result.text).not.toContain("response.failed"); + expect(bodies).toHaveLength(3); + expect(queries).toHaveLength(1); + expect(bodies[1]).toContain(SIGNATURE); + expectStripped(bodies[2]); + expect(bodies[2]).toContain("retained search result"); + expect(bodies[2]).toContain("ws_fixture"); + expect(JSON.parse(bodies[2])).toEqual(removeThinking(bodies[1])); + expect(result.log.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + }); + } + + test("stripping persists when a recovered iteration asks for another search", async () => { + const { bodies, queries } = transport(index => index === 1 ? rejection() + : index === 2 ? search("ws_first", "first fixture") + : index === 3 ? search("ws_second", "second fixture") : success()); + const result = await send(); + expect(result.text).toContain("Recovered search answer"); + expect(bodies).toHaveLength(4); + expect(queries).toHaveLength(2); + bodies.slice(1).forEach(expectStripped); + expect(bodies[3]).toContain("ws_first"); + expect(bodies[3]).toContain("ws_second"); + expect(result.log.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + }); + for (const firstSse of [false, true]) { + test(`HTTP and SSE refusals share one request-wide guard across rounds (first SSE=${firstSse})`, async () => { + const { bodies, queries } = transport(index => index === 1 ? firstSse ? streamRejection() : rejection() + : index === 2 ? search() : index === 3 ? firstSse ? rejection() : streamRejection() : success()); + const result = await send(); + expect(bodies).toHaveLength(3); + expect(queries).toHaveLength(1); + expectStripped(bodies[1]); + expectStripped(bodies[2]); + expect(result.text).toContain("response.failed"); + expect(result.text).toContain(REJECTION); + await send(); + expect(bodies).toHaveLength(4); + expect(bodies[3]).toContain(SIGNATURE); + }); + test(`HTTP and SSE refusals share one allowance within a send (first SSE=${firstSse})`, async () => { + const { bodies } = transport(index => index === 1 ? firstSse ? streamRejection() : rejection() + : firstSse ? rejection() : streamRejection()); + const result = await send(); + expect(bodies).toHaveLength(2); + expectStripped(bodies[1]); + expect(result.text).toContain(REJECTION); + }); + } + for (const prefix of [ + { name: "visible text", value: textFrame("Already visible") }, + { name: "thinking start", value: frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }) }, + { name: "tool call start", value: frame("content_block_start", { index: 0, content_block: { type: "tool_use", id: "lookup_fixture", name: "lookup", input: {} } }) }, + { name: "hosted search start", value: frame("content_block_start", { index: 0, content_block: { type: "tool_use", id: "ws_unsafe", name: "web_search", input: {} } }) }, + ]) for (const afterSearch of [false, true]) { + test(`current-send ${prefix.name} fences recovery (prior search=${afterSearch})`, async () => { + const { bodies, queries } = transport(index => afterSearch && index === 1 ? search() : streamRejection(prefix.value)); + const result = await send(); + expect(bodies).toHaveLength(afterSearch ? 2 : 1); + expect(queries).toHaveLength(afterSearch ? 1 : 0); + expect(result.text).toContain("response.failed"); + expect(result.text).toContain(REJECTION); + }); + } + for (const retry of ["http", "raw-error", "empty", "truncated", "incomplete", "thinking-only", "search-then-fail"] as const) { + test(`a ${retry} replacement cannot memoize stripped history`, async () => { + const { bodies } = transport(index => index === 1 ? streamRejection() : index === 2 + ? retry === "http" ? rejection("Synthetic replacement refusal") + : retry === "raw-error" ? streamRejection() + : retry === "empty" ? success("") + : retry === "truncated" ? sse(textFrame("Partial answer")) + : retry === "incomplete" ? success("Partial answer", "max_tokens") + : retry === "thinking-only" ? sse(frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "thinking_delta", thinking: "Only thinking" } }), + frame("content_block_stop", { index: 0 }), frame("message_delta", { delta: { stop_reason: "end_turn" } }), frame("message_stop")) + : search() + : index === 3 && retry === "search-then-fail" ? rejection("Synthetic final refusal") : success()); + const result = await send(); + expect(bodies).toHaveLength(retry === "search-then-fail" ? 3 : 2); + expectStripped(bodies[1]); + if (retry === "http") expect(result.text).toContain("Synthetic replacement refusal"); + if (retry === "search-then-fail") expect(result.text).toContain("Synthetic final refusal"); + await send(); + expect(bodies.at(-1)).toContain(SIGNATURE); + }); + } + test("non-replayable HTTP refusal never authorizes stripping", async () => { + const { bodies } = transport(() => { + const response = rejection(); markResponseNonReplayable(response); return response; + }); + const result = await send(); + expect(result.response.status).toBe(400); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toContain(SIGNATURE); + }); + test("unrelated refusal text and unsigned history never gain a resend", async () => { + for (const message of ["Invalid `signature` in `thinking` block", "The block is bound to a different conversation", "Invalid API key"]) { + const { bodies } = transport(() => rejection(message)); + expect((await send()).response.status).toBe(400); + expect(bodies).toHaveLength(1); + } + const { bodies } = transport(() => rejection()); + expect((await send({}, config(), request(true, "unsigned-search-thread", false))).response.status).toBe(400); + expect(bodies).toHaveLength(1); + }); + for (const combo of [false, true]) for (const rawSse of [false, true]) { + test(`shared budget denies recovery without another physical send (Combo=${combo}, SSE=${rawSse})`, async () => { + const budget = createRequestExecutionBudget({ maxTotalModelSends: combo ? 1 : 0, + baseSendAllowance: combo ? 1 : 0, finalRecoveryAllowance: 0, + maxAlternateTargetSends: 0, maxTargetTransitions: 0 }, "search-recovery-refused"); + const options: Parameters[3] = { sendBudget: budget }; + if (combo) { + const booking = budget.reserveDispatch({ sendClass: "initial", targetKey: "anthropic-test|https://anthropic.example.test/v1/messages|claude-sonnet-4-5", countedExternally: true }); + if (!booking.allowed) throw new Error("Synthetic initial booking refused"); + options.comboAttempt = true; + options.comboInitialSend = { permit: booking.permit }; + } + const { bodies } = transport(index => index === 1 ? rawSse ? streamRejection() : rejection() : success()); + await send(options); + expect(bodies).toHaveLength(1); + expect(budget.used).toBe(1); + await send(); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(SIGNATURE); + }); + } + for (const rawSse of [false, true]) { + test(`cancellation before recovery prevents dispatch and memo (SSE=${rawSse})`, async () => { + const abort = new AbortController(); + const { bodies } = transport(index => { + if (index === 1) { abort.abort(); return rawSse ? streamRejection() : rejection(); } + return success(); + }); + await send({ abortSignal: abort.signal }); + expect(bodies).toHaveLength(1); + await send(); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(SIGNATURE); + }); + } + test("cancellation after recovery dispatch does not memoize an unread success", async () => { + const abort = new AbortController(); + const { bodies } = transport(index => { + if (index === 1) return streamRejection(); + if (index === 2) abort.abort(); + return success(); + }); + try { await send({ abortSignal: abort.signal }); } catch { /* cancelled client read */ } + expect(bodies).toHaveLength(2); + expectStripped(bodies[1]); + await send(); + expect(bodies[2]).toContain(SIGNATURE); + }); + test("success memo is isolated by thread, credential, destination, and model", async () => { + const { bodies } = transport(index => index === 1 ? rejection() : success()); + await send(); + expect(bodies).toHaveLength(2); + await send(); + expectStripped(bodies[2]); + await send({}, config(), request(true, "separate-search-thread")); + expect(bodies[3]).toContain(SIGNATURE); + const credential = config(); credential.providers["anthropic-test"]!.apiKey = "separate-synthetic-key"; + await send({}, credential); + // A serving-route transition independently strips once; the next stable turn proves no rejection memo crossed identities. + await send({}, credential); expect(bodies[5]).toContain(SIGNATURE); + const destination = config(); destination.providers["anthropic-test"]!.baseUrl = "https://other-anthropic.example.test"; + await send({}, destination); + await send({}, destination); expect(bodies[7]).toContain(SIGNATURE); + const model = config(); model.providers["anthropic-test"]!.models = ["claude-opus-4-5"]; + const previous = request(); const body = await previous.json(); body.model = "anthropic-test/claude-opus-4-5"; + await send({}, model, new Request(previous.url, { method: "POST", headers: previous.headers, body: JSON.stringify(body) })); + await send({}, model, new Request(previous.url, { method: "POST", headers: previous.headers, body: JSON.stringify(body) })); + expect(bodies[9]).toContain(SIGNATURE); + }); + + test("a completed intermediate search cannot memoize an empty final answer", async () => { + const { bodies, queries } = transport(index => index === 1 ? rejection() : index === 2 ? search() + : index === 3 ? success("") : success()); + const result = await send(); + expect(result.text).toContain("response.completed"); + expect(queries).toHaveLength(1); + expect(bodies).toHaveLength(3); + await send(); + expect(bodies[3]).toContain(SIGNATURE); + }); + for (const combo of [false, true]) for (const rawSse of [false, true]) { + test(`successful recovery charges each physical send once (Combo=${combo}, SSE=${rawSse})`, async () => { + let charges = 0, refunds = 0; + const budget = createRequestExecutionBudget({ maxTotalModelSends: 2, baseSendAllowance: 2, + finalRecoveryAllowance: 0, maxAlternateTargetSends: 0, maxTargetTransitions: 0 }, "search-recovery-charged", + { charge: () => { charges++; return true; }, refund: () => { refunds++; } }); + const options: Parameters[3] = { sendBudget: budget }; + if (combo) { + const booking = budget.reserveDispatch({ sendClass: "initial", targetKey: "anthropic-test|https://anthropic.example.test/v1/messages|claude-sonnet-4-5", countedExternally: true }); + if (!booking.allowed) throw new Error("Synthetic initial booking refused"); + options.comboAttempt = true; options.comboInitialSend = { permit: booking.permit }; + } + const { bodies } = transport(index => index === 1 ? rawSse ? streamRejection() : rejection() : success()); + const result = await send(options); + expect(result.text).toContain("Recovered search answer"); + expect(bodies).toHaveLength(2); + expect(budget.used).toBe(2); + expect(charges).toBe(2); + expect(refunds).toBe(0); + }); + } + for (const rawSse of [false, true]) { + test(`enforced spend owner refuses unproven recovery even with numeric headroom (SSE=${rawSse})`, async () => { + const lines: string[] = []; + const journal: SpendJournal = { read: () => [...lines], append: line => { lines.push(line); }, + rewrite: next => { lines.splice(0, lines.length, ...next); } }; + const ledger = createSpendReservationLedger({ journal, salt: "search-recovery-enforced", policy: { + ...DEFAULT_SPEND_RESERVATION_POLICY, pool: { maxTokens: 1_000_000 }, + } }); + const tracker = createRequestSpendTracker({ provider: "anthropic-test", model: "claude-sonnet-4-5", + spendPoolId: "anthropic-test", usageLogInputTokens: 10, spendOutputCeilingTokens: 0 }, undefined, ledger); + const budget = createRequestExecutionBudget(undefined, "search-recovery-enforced", tracker); + const { bodies } = transport(index => index === 1 ? rawSse ? streamRejection() : rejection() : success()); + const result = await send({ sendBudget: budget }); + expect(budget.spendEnforced).toBe(true); + expect(bodies).toHaveLength(1); + expect(result.text).toContain(REJECTION); + expect(budget.remainingBaseSends(4)).toBeGreaterThan(0); + await send(); + expect(bodies[1]).toContain(SIGNATURE); + }); + } + test("a local retry-budget exception preserves terminal 429 identity", async () => { + const { bodies } = transport(index => { + if (index === 1) return streamRejection(); + if (index === 2) throw new SendBudgetExhaustedError("synthetic-search-recovery"); + return success(); + }); + const result = await send(); + expect(bodies).toHaveLength(2); + expectStripped(bodies[1]); + const failure = result.text.split("\n").filter(line => line.startsWith("data: {") && line.includes('"type":"response.failed"')) + .map(line => JSON.parse(line.slice(6)))[0]?.response; + expect(failure?.error.type).toBe("rate_limit_error"); + expect(failure?.error.code).toBe("request_send_budget_exhausted"); + expect(result.text).not.toContain("Provider unreachable"); + await send(); + expect(bodies[2]).toContain(SIGNATURE); + }); + test("a non-replayable replacement withholds diagnostics and retains terminal error identity", async () => { + const diagnostic = "synthetic-private-search-diagnostic"; + const { bodies } = transport(index => { + if (index === 1) return streamRejection(); + if (index !== 2) return success(); + const response = Response.json({ error: { type: "invalid_request_error", code: "context_length_exceeded", message: diagnostic } }, { status: 400 }); + markResponseNonReplayable(response); return response; + }); + const result = await send(); + expect(bodies).toHaveLength(2); + const failure = result.text.split("\n").filter(line => line.startsWith("data: {") && line.includes('"type":"response.failed"')) + .map(line => JSON.parse(line.slice(6)))[0]?.response; + expect(result.text).not.toContain(diagnostic); + expect(failure?.error.code).toBe("context_length_exceeded"); + expect(failure?.retryable).toBe(false); + await send(); + expect(bodies[2]).toContain(SIGNATURE); + }); + + test("three ordinary search rounds still reach their final answer without recovery", async () => { + const cfg = config(); delete cfg.webSearchSidecar!.maxSearchesPerTurn; + const { bodies, queries } = transport(index => index <= 3 ? search(`ws_${index}`, `fixture ${index}`) : success()); + const result = await send({}, cfg); + expect(result.text).toContain("Recovered search answer"); + expect(bodies).toHaveLength(4); + expect(queries).toHaveLength(3); + expect(bodies.every(body => body.includes(SIGNATURE))).toBe(true); + expect(result.log.activeAttempt?.recoveryKinds ?? []).not.toContain("opaque-blob-rejection"); + }); + test("an HTTP replacement budget exception retains HTTP 429 and its stable code", async () => { + const { bodies } = transport(index => { + if (index === 1) return rejection(); + throw new SendBudgetExhaustedError("synthetic-http-search-recovery"); + }); + const result = await send(); + expect(bodies).toHaveLength(2); + expect(result.response.status).toBe(429); + expect(JSON.parse(result.text).error).toMatchObject({ type: "rate_limit_error", code: "request_send_budget_exhausted" }); + expect(result.text).not.toContain("Provider unreachable"); + }); + test("an SSE recovery owns a fresh header deadline after the original headers cleared it", async () => { + const abort = new AbortController(); + const fallback = setTimeout(() => abort.abort(), 1500); + const cfg = config(); cfg.connectTimeoutMs = 60; + const { bodies } = transport((index, init) => { + if (index === 1) return streamRejection(); + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) reject(signal.reason); + else signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }); + try { + const result = await send({ abortSignal: abort.signal }, cfg); + expect(bodies).toHaveLength(2); + expect(abort.signal.aborted).toBe(false); + expect(result.text).toContain("response-header timeout"); + expect(result.text).toContain("response.failed"); + } finally { clearTimeout(fallback); abort.abort(); } + }, 3000); + test("a rejected stream whose cancel never settles cannot hold the replacement hostage", async () => { + let cancelled = false; + const abort = new AbortController(); + const fallback = setTimeout(() => abort.abort(), 1500); + const { bodies } = transport(index => index === 1 ? new Response(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode(frame("error", { + error: { type: "invalid_request_error", message: REJECTION }, + }))); }, + cancel() { cancelled = true; return new Promise(() => {}); }, + }), { headers: { "content-type": "text/event-stream" } }) : success()); + try { + const result = await send({ abortSignal: abort.signal }); + expect(abort.signal.aborted).toBe(false); + expect(cancelled).toBe(true); + expect(bodies).toHaveLength(2); + expect(result.text).toContain("Recovered search answer"); + } finally { clearTimeout(fallback); abort.abort(); } + }, 3000); + + test("same-key 429 replay can discover and recover the later signature refusal", async () => { + const cfg = config(); cfg.providers["anthropic-test"]!.retryOn429 = { attempts: 1, intervalMs: 1, respectRetryAfter: false }; + const { bodies } = transport(index => index === 1 + ? Response.json({ error: { type: "rate_limit_error", message: "Synthetic busy upstream" } }, { status: 429 }) + : index === 2 ? rejection() : success()); + const result = await send({}, cfg); + expect(result.text).toContain("Recovered search answer"); + expect(bodies).toHaveLength(3); + expect(bodies[0]).toBe(bodies[1]); + expectStripped(bodies[2]); + expect(result.log.activeAttempt?.recoveryKinds).toEqual(["rate-limit-429", "opaque-blob-rejection"]); + }); + test("an HTTP non-replayable replacement keeps its status, stable code, and no-retry header", async () => { + const { bodies } = transport(index => { + if (index === 1) return rejection(); + const response = Response.json({ error: { type: "invalid_request_error", code: "context_length_exceeded", + message: "synthetic-private-http-diagnostic" } }, { status: 400 }); + markResponseNonReplayable(response); return response; + }); + const result = await send(); + expect(bodies).toHaveLength(2); + expect(result.response.status).toBe(400); + expect(result.response.headers.get("x-should-retry")).toBe("false"); + expect(JSON.parse(result.text).error).toMatchObject({ type: "invalid_request_error", code: "context_length_exceeded" }); + expect(result.text).not.toContain("synthetic-private-http-diagnostic"); + }); + + test("a marked HTTP 200 raw SSE refusal neither replays nor exposes its diagnostic", async () => { + const diagnostic = "synthetic-private-first-stream-diagnostic"; + const { bodies } = transport(() => { + const response = sse(frame("error", { error: { type: "invalid_request_error", message: `${REJECTION} ${diagnostic}` } })); + markResponseNonReplayable(response); return response; + }); + const result = await send(); + expect(bodies).toHaveLength(1); + expect(result.text).not.toContain(diagnostic); + expect(result.text).not.toContain(REJECTION); + const failure = result.text.split("\n").filter(line => line.startsWith("data: {") && line.includes('"type":"response.failed"')) + .map(line => JSON.parse(line.slice(6)))[0]?.response; + expect(failure?.retryable).toBe(false); + expect(failure?.error.message).toContain("diagnostic withheld"); + }); +}); From a9082df09eb2c5cf76570415a9bb68c900199a4e Mon Sep 17 00:00:00 2001 From: lcxhh Date: Wed, 14 Oct 2026 17:47:16 +0800 Subject: [PATCH 3/9] fix(search): preserve Anthropic thinking-bound conversation fidelity (cherry picked from commit 98fc937d3d1116eae24255be9734c87cdfc64359) --- scripts/test-layout/layout.json | 1 + src/adapters/anthropic-replay.ts | 127 +++++++++++++ src/adapters/anthropic-tool-catalog.ts | 11 ++ src/adapters/anthropic.ts | 37 +++- src/web-search/anthropic-history.ts | 54 ++++++ src/web-search/loop.ts | 111 +++++++++--- .../transports/anthropic-thinking-recovery.md | 36 ++++ .../anthropic/anthropic-replay.test.ts | 66 +++++++ tests/fixtures/test-layout-expected.json | 3 +- ...search-thinking-signature-recovery.test.ts | 171 ++++++++++++++++++ 10 files changed, 578 insertions(+), 39 deletions(-) create mode 100644 src/adapters/anthropic-replay.ts create mode 100644 src/adapters/anthropic-tool-catalog.ts create mode 100644 src/web-search/anthropic-history.ts create mode 100644 tests/adapters/anthropic/anthropic-replay.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 76685b1c43a..236c90c2f00 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -200,6 +200,7 @@ "anthropic-stream-hardening.test.ts": "adapters/anthropic", "anthropic-tail-guard.test.ts": "adapters/anthropic", "anthropic-thinking-signature.test.ts": "adapters/anthropic", + "anthropic-replay.test.ts": "adapters/anthropic", "anthropic-opaque-strip.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", "anthropic-messages-passthrough.test.ts": "adapters/anthropic", diff --git a/src/adapters/anthropic-replay.ts b/src/adapters/anthropic-replay.ts new file mode 100644 index 00000000000..4f41dbf4724 --- /dev/null +++ b/src/adapters/anthropic-replay.ts @@ -0,0 +1,127 @@ +import { isConformingToolCallId } from "./tool-call-id"; +import type { AdapterEvent, OcxAssistantMessage } from "../types"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import { jsonUtf8Bytes } from "../lib/json-byte-size"; + +type Block = Record; +export interface AnthropicReplaySnapshot { readonly content: readonly Block[]; release(): void } +const enabled = new WeakSet(); +const terminals = new WeakMap(); +const pending = new WeakMap(); +const owners = new WeakMap(); +const issued = new WeakSet(); +const messages = new WeakMap(); +export function enableAnthropicReplayCapture(response: Response): () => void { + enabled.add(response); + return () => { enabled.delete(response); pending.get(response)?.release(); pending.delete(response); }; +} +export function takeAnthropicReplay(event: object): AnthropicReplaySnapshot | undefined { + const snapshot = terminals.get(event); terminals.delete(event); + const owner = owners.get(event); if (owner) pending.delete(owner); owners.delete(event); return snapshot; +} +export function bindAnthropicReplay(message: OcxAssistantMessage, snapshot: AnthropicReplaySnapshot): void { + if (!issued.has(snapshot)) throw new Error("Untrusted Anthropic replay snapshot"); + messages.set(message, snapshot); +} +/** Top-level copies let cache-control decoration leave the captured provider response untouched. */ +export function anthropicReplayContent(message: OcxAssistantMessage): Block[] | undefined { + const snapshot = messages.get(message); + if (snapshot && !issued.has(snapshot)) throw new Error("Expired Anthropic replay snapshot"); + return snapshot?.content.map(block => ({ ...block })); +} + +/** Capture only explicitly opted-in search streams; no client data can manufacture a snapshot. */ +export function createAnthropicReplayCapture(response: Response, budget: TranslatorBudget) { + if (!enabled.delete(response)) return undefined; + let bytes = 0, valid = true, transferred = false; + let active: number | undefined; + let started = false, stopped = false; + const toolIds = new Set(); + const blocks: Array<{ value: Block; text: string[]; thinking: string[]; args: string[] }> = []; + const release = () => { if (bytes) budget.releaseRetained(bytes, { kind: "retained_collectors" }); bytes = 0; }; + const invalidate = () => { valid = false; release(); blocks.length = 0; toolIds.clear(); active = undefined; }; + const retain = (value: unknown) => { + // Charge each frame once, before retaining its values. This conservative linear bound also + // covers array/object overhead and replacement signatures without serializing a growing block. + const size = jsonUtf8Bytes(value) * 4 + 64; + budget.chargeRetained(size, { kind: "retained_collectors" }); bytes += size; + }; + return { + invalidate, + accept(data: Record): void { + if (!valid) return; + const type = data.type; + if (type === "ping") return; + if (type === "message_start") { + const message = data.message as Block | undefined; + if (started || (message?.content !== undefined && (!Array.isArray(message.content) || message.content.length > 0))) invalidate(); + else started = true; + return; + } + if (!started || stopped) { invalidate(); return; } + if (type === "message_stop") { stopped = true; if (active !== undefined) invalidate(); return; } + if (type === "content_block_start") { + const block = data.content_block as Block | undefined; + if (active !== undefined || data.index !== blocks.length || !block + || !(block.type === "text" && typeof block.text === "string" + || block.type === "thinking" && typeof block.thinking === "string" + || block.type === "redacted_thinking" && typeof block.data === "string" + || block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string" + && !!block.input && typeof block.input === "object" && !Array.isArray(block.input))) { invalidate(); return; } + if (block.type === "tool_use") { + const id = block.id as string; + if (!isConformingToolCallId(id) || toolIds.has(id)) { invalidate(); return; } + toolIds.add(id); + } + retain(block); + active = blocks.length; + blocks.push({ value: { ...block, ...(Array.isArray(block.citations) ? { citations: [...block.citations] } : {}) }, text: typeof block.text === "string" ? [block.text] : [], + thinking: typeof block.thinking === "string" ? [block.thinking] : [], args: [] }); + } else if (type === "content_block_delta") { + const delta = data.delta as Block | undefined; + if (active === undefined || data.index !== active || !delta) { invalidate(); return; } + const block = blocks[active]!; + retain(delta); + if (delta.type === "text_delta" && block.value.type === "text" && typeof delta.text === "string") block.text.push(delta.text); + else if (delta.type === "thinking_delta" && block.value.type === "thinking" && typeof delta.thinking === "string") block.thinking.push(delta.thinking); + else if (delta.type === "signature_delta" && block.value.type === "thinking" && typeof delta.signature === "string") block.value.signature = delta.signature; + else if (delta.type === "input_json_delta" && block.value.type === "tool_use" && typeof delta.partial_json === "string") block.args.push(delta.partial_json); + else if (delta.type === "citations_delta" && block.value.type === "text" && delta.citation && typeof delta.citation === "object") { + const citations = block.value.citations; + if (citations !== undefined && !Array.isArray(citations)) { invalidate(); return; } + if (citations === undefined) block.value.citations = [delta.citation]; + else (citations as unknown[]).push(delta.citation); + } else invalidate(); + } else if (type === "content_block_stop") { + if (active === undefined || data.index !== active) { invalidate(); return; } + active = undefined; + } else if (type !== "message_start" && type !== "message_delta" && type !== "message_stop" && type !== "ping") invalidate(); + }, + finish(event: AdapterEvent): void { + if (!valid || !stopped || active !== undefined || event.type !== "done") { invalidate(); return; } + const reservation = budget.reserveTransient(bytes * 2, { kind: "retained_collectors" }); + try { + const content = blocks.map(block => { + const value = block.value; + if (value.type === "text") value.text = block.text.join(""); + if (value.type === "thinking") { + value.thinking = block.thinking.join(""); + if (typeof value.signature !== "string" || value.signature.length === 0) throw new Error("incomplete thinking signature"); + } + if (value.type === "tool_use" && block.args.length) { + value.input = JSON.parse(block.args.join("")); + if (!value.input || typeof value.input !== "object" || Array.isArray(value.input)) throw new Error("invalid tool input"); + } + return Object.freeze(value); + }); + const snapshot = { content: Object.freeze(content), release: () => { issued.delete(snapshot); release(); } }; + issued.add(snapshot); + terminals.set(event, snapshot); owners.set(event, response); pending.set(response, snapshot); + blocks.length = 0; toolIds.clear(); + transferred = true; + } catch { invalidate(); } + finally { reservation.release(); } + }, + discard(): void { if (!transferred) invalidate(); }, + }; +} diff --git a/src/adapters/anthropic-tool-catalog.ts b/src/adapters/anthropic-tool-catalog.ts new file mode 100644 index 00000000000..34bd84cd07d --- /dev/null +++ b/src/adapters/anthropic-tool-catalog.ts @@ -0,0 +1,11 @@ +import { isAllowedToolChoice, toolChoiceToolPredicate, type OcxParsedRequest } from "../types"; +import { rejectsForcedToolChoice } from "./anthropic-model-contract"; + +/** The catalog actually advertised on the wire, independently of whether calling it is enabled. */ +export function anthropicDeclaredTools(parsed: OcxParsedRequest) { + const tools = parsed.context.tools ?? []; + const choice = parsed.options.toolChoice; + return isAllowedToolChoice(choice) + || (rejectsForcedToolChoice(parsed.modelId) && typeof choice === "object" && choice !== null && "name" in choice) + ? tools.filter(toolChoiceToolPredicate(choice, tools)) : tools; +} diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 44f194edbb8..c20af06329f 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1,3 +1,5 @@ +import { anthropicDeclaredTools } from "./anthropic-tool-catalog"; +import { anthropicReplayContent, createAnthropicReplayCapture } from "./anthropic-replay"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { createAdapterTierMetadata, type AdapterTierMetadata } from "../providers/fastwire"; import { ANTHROPIC_FAST_MODE_BETA, mergeAnthropicBetaHeader } from "../providers/anthropic-fast"; @@ -16,7 +18,7 @@ import type { OcxToolResultMessage, OcxUsage, } from "../types"; -import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; +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"; @@ -738,8 +740,8 @@ function messagesToAnthropicFormat( } } const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools( - parsed.context.tools, - parsed.options.toolChoice, + anthropicDeclaredTools(parsed), + undefined, tool => toolNames.toWire(namespacedToolName(tool.namespace, tool.name)), ); const systemParts = [...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])]; @@ -769,6 +771,7 @@ function messagesToAnthropicFormat( case "assistant": { const aMsg = msg as OcxAssistantMessage; const preface: unknown[] = []; + const toolUses: unknown[] = []; const toolUseIds: string[] = []; for (const part of aMsg.content) { @@ -806,7 +809,17 @@ function messagesToAnthropicFormat( } // Anthropic treats text/thinking after tool_use as ending the tool turn, which makes // earlier tool_use ids look unpaired (#620 / common multi-step history shape). - const content = [...preface, ...toolUses]; + const captured = anthropicReplayContent(aMsg); + if (captured) { + const expected = toolUses as Array<{ id: string; name: string }>; + const actual = captured.filter(block => block.type === "tool_use"); + if (actual.length !== expected.length || actual.some((block, index) => block.id !== expected[index]?.id || block.name !== expected[index]?.name)) { + throw new Error("Anthropic search replay tool identity changed"); + } + } + const content = captured + ? captured.filter(block => parsed._stripReasoningEncryptedContent !== true || (block.type !== "thinking" && block.type !== "redacted_thinking")) + : [...preface, ...toolUses]; if (content.length === 0) break; messages.push({ role: "assistant", content }); if (toolUseIds.length > 0) { @@ -869,9 +882,7 @@ function messagesToAnthropicFormat( function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }): unknown[] | undefined { if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined; - const tools = isAllowedToolChoice(parsed.options.toolChoice) - ? parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)) - : parsed.context.tools; + const tools = anthropicDeclaredTools(parsed); if (tools.length === 0) return undefined; const converted = tools.map(t => ({ name: toolNames.toWire(namespacedToolName(t.namespace, t.name)), @@ -1196,6 +1207,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti return; } + const replayCapture = createAnthropicReplayCapture(response, budget); const budgetEncoder = new TextEncoder(); let currentBlockType = ""; let currentToolCallId = ""; @@ -1228,11 +1240,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage)); return; } - yield { + const done: AdapterEvent = { type: "done", usage: usageFromAnthropic(pendingUsage), ...(pendingStopReason ? { stopReason: pendingStopReason } : {}), }; + replayCapture?.finish(done); + yield done; }; try { @@ -1252,6 +1266,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti try { parsed = JSON.parse(payload); } catch { + replayCapture?.invalidate(); debugDroppedFrame("anthropic", payload); continue; } @@ -1259,10 +1274,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // it and the `data.type` read below crashed the stream. Drop a non-record frame the same // way an unparseable one is dropped, so the message_stop check still governs the outcome. if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + replayCapture?.invalidate(); debugDroppedFrame("anthropic", payload); continue; } const data = parsed as Record; + replayCapture?.accept({ ...data, type: record.event || data.type }); switch (record.event || data.type) { case "message_start": { @@ -1275,6 +1292,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti case "content_block_start": { const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string } | undefined; if (!block) break; + // Even an unmodeled server tool or empty block is semantic progress. + // Fence replays before a later refusal; unknown blocks must not look like pings. + yield { type: "heartbeat", replayUnsafe: true }; currentBlockType = block.type; if (block.type === "thinking") { // Preserve even a display:omitted block boundary. The bridge can then @@ -1396,6 +1416,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // one-terminal-event contract for consumers that keep draining the generator. return; } finally { + replayCapture?.discard(); if (currentToolCallId) budget.closeCall(currentToolCallId); } if (!emittedDone) { diff --git a/src/web-search/anthropic-history.ts b/src/web-search/anthropic-history.ts new file mode 100644 index 00000000000..6c08fa3ea04 --- /dev/null +++ b/src/web-search/anthropic-history.ts @@ -0,0 +1,54 @@ +import type { AdapterEvent, OcxAssistantContentPart, OcxThinkingContent, OcxToolCall } from "../types"; + +/** Rebuild one validated Anthropic model response without changing its search arguments or order. */ +export function anthropicSearchAssistantContent(events: readonly AdapterEvent[]): OcxAssistantContentPart[] { + const content: OcxAssistantContentPart[] = []; + let thinking: OcxThinkingContent | undefined; + let text: Extract | undefined; + let call: OcxToolCall | undefined; + let argumentsText = ""; + for (const event of events) { + if (event.type === "heartbeat") continue; + // A signature is a replacement snapshot until the next semantic block starts. + if (thinking?.signature !== undefined && event.type !== "thinking_signature") thinking = undefined; + if (event.type !== "text_delta") text = undefined; + switch (event.type) { + case "thinking_delta": + if (!thinking) { thinking = { type: "thinking", thinking: "" }; content.push(thinking); } + thinking.thinking += event.thinking; + break; + case "thinking_signature": + if (!thinking) { thinking = { type: "thinking", thinking: "" }; content.push(thinking); } + thinking.signature = event.signature; + break; + case "redacted_thinking": + thinking = undefined; + content.push({ type: "thinking", thinking: "", redacted: [event.data] }); + break; + case "text_delta": + thinking = undefined; + if (!text) { text = { type: "text", text: "" }; content.push(text); } + text.text += event.text; + break; + case "tool_call_start": + thinking = undefined; + call = { type: "toolCall", id: event.id, name: event.name, arguments: {}, + ...(event.providerMetadata ? { providerMetadata: event.providerMetadata } : {}) }; + content.push(call); + argumentsText = ""; + break; + case "tool_call_delta": + argumentsText += event.arguments; + break; + case "tool_call_end": { + if (!call) throw new Error("search history has an unpaired tool call"); + const args: unknown = JSON.parse(argumentsText || "{}"); + if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("search history tool input is not an object"); + call.arguments = args as Record; + call = undefined; + break; + } + } + } + return content; +} diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 40bb62383b6..e4270149e53 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -1,3 +1,6 @@ +import { anthropicDeclaredTools } from "../adapters/anthropic-tool-catalog"; +import { enableAnthropicReplayCapture, takeAnthropicReplay, bindAnthropicReplay, type AnthropicReplaySnapshot } from "../adapters/anthropic-replay"; +import { anthropicSearchAssistantContent } from "./anthropic-history"; import type { createAnthropicThinkingRecovery } from "../server/responses/anthropic-thinking-recovery"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base"; import type { AdapterEvent, OcxConfig, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxProviderOpaqueToolCallMetadata, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types"; @@ -190,6 +193,9 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] }; for (const e of events) { + // Match the bridge: consecutive signature snapshots update one block. The next + // semantic event (including an empty thinking-block start) closes that block. + if (signature !== undefined && e.type !== "thinking_signature" && e.type !== "heartbeat") flushVisible(); if (e.type === "thinking_delta") { flushRaw(); thinking += e.thinking; @@ -198,7 +204,6 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] rawReasoning += e.text; } else if (e.type === "thinking_signature") { signature = e.signature; - flushVisible(); } else if (e.type === "redacted_thinking") { flushVisible(); flushRaw(); @@ -216,8 +221,8 @@ function normalizeQuery(q: string): string { } /** - * Transient developer-role nudge appended ONLY to the forced-answer pass's request (never the - * persisted `messages`). It tells the model to ground its final answer in the web results already + * Developer nudge for the forced-answer pass. Anthropic keeps its first sent position in the + * request-local history; other providers append it only for that iteration. It tells the model to ground its final answer in the web results already * gathered this turn. Citation wording is conditional — a failed/empty search still wants an answer, * just without fabricated sources. */ @@ -234,10 +239,8 @@ function forcedAnswerNudge(): OcxMessage { } /** - * Transient developer-role nudge for the ONE recovery pass after a forced answer came back empty. - * The recovery also removes every tool, so the model has nothing to call and can only return text; - * this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge} - * it is iteration-local and never touches the persisted `messages`. + * Developer nudge for the ONE recovery pass after an empty forced answer. Tool calls are disabled; + * Anthropic retains its declared catalog and this nudge in request-local history for prefix fidelity. */ function forcedAnswerRetryNudge(): OcxMessage { return { @@ -386,8 +389,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise(); const loopT0 = Date.now(); - const allTools = parsed.context.tools ?? []; + const allTools = adapter.name === "anthropic" ? anthropicDeclaredTools(parsed) : parsed.context.tools ?? []; + let forcedNudgeAdded = false; + let emptyNudgeAdded = false; let searchesExecuted = 0; let executedSearchCount = 0; // Queries whose search already failed this turn — repeats are short-circuited so a model that keeps @@ -419,6 +425,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise Promise; } type IterationSplit = ReturnType & { + events: AdapterEvent[]; + anthropicReplay?: AnthropicReplaySnapshot; /** * How many leading passthrough events were already delivered live this iteration. They are * exactly the first N passthrough entries (live delivery stops before the first event that @@ -474,24 +482,30 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { - // On the forced-answer pass the model is asked to answer from the results already in - // `messages`. A weak model can still produce a thin answer that - // ignores what the search found, which reads to the user as "the search did nothing". Nudge it - // (iteration-locally — never mutate the shared `messages`) to actually use the gathered results. - // Only when a REAL search ran (executedSearchCount, not empty-query/limit/repeat placeholders). - let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0 - ? [...messages, forcedAnswerNudge()] - : messages; - // #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the - // model nothing to call, and the extra developer turn asks it for the text it just failed to - // produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry - // cannot repeat the same empty or tool-shaped response. const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0; - if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()]; + const preserveAnthropicPrefix = adapter.name === "anthropic"; + let iterMessages = messages; + if (preserveAnthropicPrefix) { + // Once sent, each nudge is part of subsequent signatures' bound prefix. Keep its + // original position even when a forced-answer response makes another tool call. + if (forceAnswer && executedSearchCount > 0 && !forcedNudgeAdded) { + const nudge = forcedAnswerNudge(); + if (nudge.role === "developer" && typeof nudge.content === "string") nudge.content = [{ type: "text", text: nudge.content }]; + messages.push(nudge); forcedNudgeAdded = true; + } + if (recoveringEmptyAnswer && !emptyNudgeAdded) { + const nudge = forcedAnswerRetryNudge(); + if (nudge.role === "developer" && typeof nudge.content === "string") nudge.content = [{ type: "text", text: nudge.content }]; + messages.push(nudge); emptyNudgeAdded = true; + } + } else { + if (forceAnswer && executedSearchCount > 0) iterMessages = [...messages, forcedAnswerNudge()]; + if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()]; + } const iterParsed: OcxParsedRequest = { ...parsed, stream: true, ...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}), - context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : allTools }, + context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer && !preserveAnthropicPrefix ? [] : allTools }, }; // One cumulative header deadline spans key rotations and reset sends, excluding local pacing. // clear() stops only its timer after final headers; the direct turn signal remains attached to @@ -717,14 +731,23 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise {}; try { - const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter); + const parse: ProviderAdapter["parseStream"] = (response, budget) => { + if (prepared.responseAdapter.name === "anthropic") releaseCapture = enableAnthropicReplayCapture(response); + return prepared.responseAdapter.parseStream(response, budget); + }; for await (const event of parseStreamWithProgress(prepared.response, parse, { signal, inactivityTimeoutMs: routedModelStallTimeoutMs, translatorBudget, preserveAdapterError: deps.thinkingRecovery !== undefined, })) { + if (event.type === "done") { + anthropicReplay = takeAnthropicReplay(event); + if (anthropicReplay) replaySnapshots.add(anthropicReplay); + } if (event.type === "heartbeat") yield event; // Kiro's explicit-completion protocol marks ordinary assistant text as commentary while // it performs a bounded final-answer retry. That text is safe to surface immediately and @@ -760,7 +783,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise event.type === "done" || event.type === "incomplete" || event.type === "error" ? [index] : []); @@ -775,7 +798,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { + async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = [], appendAssistant = true): AsyncGenerator { const results: { query: string; outcome: SidecarOutcome }[] = []; let beganCell = false; if (call.queries.length === 0) { @@ -861,7 +884,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise = call.queries.length > 1 ? { queries: call.queries } : { query: call.queries[0] ?? "" }; - messages.push({ + if (appendAssistant) messages.push({ role: "assistant", content: [ // Signed thinking must precede tool_use on replay (Anthropic extended thinking), and @@ -1001,7 +1024,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise block.type === "tool_use"); + const priorIds = new Set(messages.flatMap(message => message.role === "assistant" + ? message.content.filter(part => part.type === "toolCall").map(part => part.id) + : message.role === "toolResult" ? [message.toolCallId] : [])); + if (originalCalls.some(block => priorIds.has(block.id as string))) { + throw new LoopError(502, "Anthropic search replay tool identity changed"); + } + const calls = message.content.filter(part => part.type === "toolCall"); + if (originalCalls.length !== calls.length || originalCalls.some((block, index) => block.id !== calls[index]?.id)) { + throw new LoopError(502, "Anthropic search replay tool identity changed"); + } + // The raw input is authoritative, including non-empty content_block_start input. + for (const [index, call] of calls.entries()) call.arguments = originalCalls[index]!.input as Record; + for (const call of split.calls) { + const original = originalCalls.find(block => block.id === call.id); + if (!original) throw new LoopError(502, "Anthropic search replay tool identity changed"); + call.queries = parseQueries(JSON.stringify(original.input)); + } + bindAnthropicReplay(message, split.anthropicReplay); + messages.push(message); + } + const iterationThinking = preserveAnthropicHistory ? [] : extractIterationThinking(split.passthrough); for (const [callIndex, call] of split.calls.entries()) { accountRefusalOutputStarted = true; - yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []); + yield* runSearchCall(call, callIndex === 0 ? iterationThinking : [], !preserveAnthropicHistory); } } catch (e) { if (isTranslatorBudgetExceededError(e)) { @@ -1049,6 +1098,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise["capture"]) { + capture.accept({ type: "content_block_stop", index: 0 }); + capture.accept({ type: "message_stop" }); + const done = { type: "done" } as const; + capture.finish(done); + return done; +} + +test("capture cancellation releases retained bytes idempotently", () => { + const { budget, capture, cleanup } = start(); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + capture.discard(); cleanup(); capture.discard(); + expect(budget.snapshot().currentBytes).toBe(0); +}); +test("invalid index releases bytes and cannot issue a snapshot", () => { + const { budget, capture, cleanup } = start(); + capture.accept({ type: "content_block_stop", index: 9 }); + expect(takeAnthropicReplay(complete(capture))).toBeUndefined(); + cleanup(); + expect(budget.snapshot().currentBytes).toBe(0); +}); +test("response cleanup releases an unclaimed complete snapshot", () => { + const { budget, capture, cleanup } = start(); + complete(capture); capture.discard(); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + cleanup(); + expect(budget.snapshot().currentBytes).toBe(0); +}); +test("claim transfers ownership until release; external and expired snapshots are refused", () => { + const { budget, capture, cleanup } = start(); + const snapshot = takeAnthropicReplay(complete(capture))!; + const message = { role: "assistant", content: [], timestamp: 0 } as const; + const mutableMessage = { ...message, content: [] }; + bindAnthropicReplay(mutableMessage, snapshot); + expect(anthropicReplayContent(mutableMessage)).toEqual([{ type: "text", text: "initial" }]); + expect(() => bindAnthropicReplay(mutableMessage, { content: [], release() {} })).toThrow(); + cleanup(); capture.discard(); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + snapshot.release(); snapshot.release(); + expect(budget.snapshot().currentBytes).toBe(0); + expect(() => bindAnthropicReplay(mutableMessage, snapshot)).toThrow(); + expect(() => anthropicReplayContent(mutableMessage)).toThrow(); +}); +test("budget overflow releases previously retained frames on discard", () => { + const { budget, capture, cleanup } = start(1_000); + expect(() => capture.accept({ type: "content_block_delta", index: 0, + delta: { type: "text_delta", text: "x".repeat(2_000) } })).toThrow(); + capture.discard(); cleanup(); + expect(budget.snapshot().currentBytes).toBe(0); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1ca46c3aa89..79c3732e0e1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -189,7 +189,8 @@ "anthropic-reasoning.test.ts": "adapters/anthropic", "anthropic-reset-grants.test.ts": "adapters/anthropic", "anthropic-sidecar-account-failover.test.ts": "adapters/anthropic", "anthropic-stream-hardening.test.ts": "adapters/anthropic", "anthropic-tail-guard.test.ts": "adapters/anthropic", - "anthropic-thinking-signature.test.ts": "adapters/anthropic", "anthropic-opaque-strip.test.ts": "adapters/anthropic", + "anthropic-thinking-signature.test.ts": "adapters/anthropic", + "anthropic-replay.test.ts": "adapters/anthropic", "anthropic-opaque-strip.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", "anthropic-messages-passthrough.test.ts": "adapters/anthropic", "anthropic-beta-allowlist.test.ts": "adapters/anthropic", 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 4ac99f571b8..8bf283ed209 100644 --- a/tests/responses/responses-web-search-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts @@ -538,3 +538,174 @@ describe("hosted web search Anthropic thinking-signature recovery", () => { expect(failure?.error.message).toContain("diagnostic withheld"); }); }); + +// Signature deltas are replacement snapshots (Anthropic MessageStream), not new blocks. +for (const separateEmptyBlock of [false, true]) test(`search replay keeps latest signature without merging real blocks (empty next block=${separateEmptyBlock})`, async () => { + const replacementSignature = "SyntheticUpdatedSearchSignature123456=="; + const secondSignature = "SyntheticSecondSearchSignature123456=="; + const initial = (await search().text()).replaceAll('"index":1', separateEmptyBlock ? '"index":2' : '"index":1'); + const marker = frame("content_block_stop", { index: 0 }); + const extra = frame("ping") + + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: replacementSignature } }) + + marker + + (separateEmptyBlock + ? frame("content_block_start", { index: 1, content_block: { type: "thinking", thinking: "" } }) + + frame("content_block_delta", { index: 1, delta: { type: "signature_delta", signature: secondSignature } }) + + frame("content_block_stop", { index: 1 }) : ""); + const { bodies, queries } = transport(index => index === 1 ? sse(initial.replace(marker, extra)) : success()); + const result = await send(); + expect(result.text).toContain("response.completed"); + expect(bodies).toHaveLength(2); + expect(queries).toHaveLength(1); + const replayed = JSON.parse(bodies[1]).messages.find((message: { role: string; content: unknown }) => + message.role === "assistant" && Array.isArray(message.content) + && message.content.some((part: { type: string; id: string }) => part.type === "tool_use" && part.id === "ws_fixture")); + const blocks = replayed.content.filter((part: { type: string }) => part.type === "thinking"); + expect(blocks).toEqual([ + { type: "thinking", thinking: "search reasoning", signature: replacementSignature }, + ...(separateEmptyBlock ? [{ type: "thinking", thinking: "", signature: secondSignature }] : []), + ]); +}); + +function originalBlockStream(blocks: Record[], corrupt = ""): Response { + return sse(frame("message_start", { message: { usage: { input_tokens: 2 } } }), + ...blocks.flatMap((block, index) => [frame("content_block_start", { index, content_block: block }), + frame("content_block_stop", { index })]), corrupt, + frame("message_delta", { delta: { stop_reason: "tool_use" } }), frame("message_stop")); +} + +test("search continuation preserves raw text boundaries, citations, opaque blocks and parallel original inputs", async () => { + const original = [ + { type: "text", text: "first", citations: [{ type: "char_location", cited_text: "first", document_index: 0, start_char_index: 0, end_char_index: 5 }] }, + { type: "text", text: "second" }, + { type: "thinking", thinking: "", signature: SIGNATURE }, + { type: "redacted_thinking", data: REDACTED }, + { type: "tool_use", id: "raw_one", name: "web_search", input: { query: "first query", extra_context: { preserve: true } } }, + { type: "tool_use", id: "raw_two", name: "web_search", input: { queries: ["second query"], original: 2 } }, + ]; + const { bodies, queries } = transport(index => index === 1 ? originalBlockStream(original) : success()); + const result = await send(); + expect(result.text).toContain("response.completed"); + expect(queries).toHaveLength(2); + expect(bodies).toHaveLength(2); + const messages = JSON.parse(bodies[1]).messages; + const assistant = messages.findIndex((m: { content: unknown[] }) => Array.isArray(m.content) + && m.content.some((b: any) => b.id === "raw_one")); + expect(messages[assistant].content).toEqual(original); + expect(messages[assistant + 1].content.map((b: any) => b.tool_use_id)).toEqual(["raw_one", "raw_two"]); +}); + +for (const malformed of ["unknown block", "unknown delta", "missing stop", "duplicate index", "malformed JSON", "missing message stop", "unexpected initial content"]) { + test(`invalid raw search history fails before search execution: ${malformed}`, async () => { + let initial = await search().text(); + if (malformed === "unknown block") initial = initial.replace('"type":"thinking","thinking":""', '"type":"server_tool_use","id":"server_side_effect","name":"web_search","input":{}'); + if (malformed === "unknown delta") initial = initial.replace('"type":"thinking_delta"', '"type":"unknown_delta"'); + if (malformed === "missing message stop") initial = initial.replace(frame("message_stop"), ""); + if (malformed === "unexpected initial content") initial = initial.replace('"message":{"usage":{"input_tokens":2}}', '"message":{"content":[{"type":"text","text":"unobserved"}]}'); + if (malformed === "missing stop") initial = initial.replace(frame("content_block_stop", { index: 0 }), ""); + if (malformed === "duplicate index") initial = initial.replaceAll('"index":1', '"index":0'); + if (malformed === "malformed JSON") initial = initial.replace(frame("content_block_stop", { index: 0 }), 'event: content_block_delta\ndata: {broken\n\n' + frame("content_block_stop", { index: 0 })); + const { bodies, queries } = transport(() => sse(initial)); + const result = await send(); + expect(result.text).toContain("response.failed"); + expect(result.text).toContain("cannot be replayed losslessly"); + expect(bodies).toHaveLength(1); + expect(queries).toHaveLength(0); + }); +} + +test("server tool start fences thinking repair before message completion", async () => { + const { bodies, queries } = transport(index => index === 1 ? streamRejection( + frame("message_start", { message: {} }), + frame("content_block_start", { index: 0, content_block: { type: "server_tool_use", id: "opaque", name: "web_search", input: {} } }), + frame("content_block_stop", { index: 0 }), + ) : success()); + const result = await send(); + expect(result.text).toContain("response.failed"); + expect(bodies).toHaveLength(1); + expect(queries).toHaveLength(0); +}); + +for (const failedSearch of [false, true]) test(`parallel repeated queries retain original tool turns and existing execution counts (failed=${failedSearch})`, async () => { + const original = [{ type: "thinking", thinking: "parallel", signature: SIGNATURE }, + ...["repeat_one", "repeat_two"].map(id => ({ type: "tool_use", id, name: "web_search", input: { query: "identical query", extra: id } }))]; + const { bodies, queries } = transport(index => index === 1 ? originalBlockStream(original) : success()); + const installedFetch = globalThis.fetch; + let failedCount = 0; + if (failedSearch) globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "https://api.exa.ai/search") { failedCount++; return Response.json({ error: "synthetic failure" }, { status: 400 }); } + return installedFetch(input, init); + }) as typeof fetch; + const result = await send(); + expect(result.text).toContain("response.completed"); + expect(failedSearch ? failedCount : queries.length).toBe(failedSearch ? 1 : 2); + expect(bodies).toHaveLength(2); + const messages = JSON.parse(bodies[1]).messages; + const index = messages.findIndex((m: any) => m.role === "assistant" && m.content.some((b: any) => b.id === "repeat_one")); + expect(messages[index].content).toEqual(original); + expect(messages[index + 1].content.map((b: any) => b.tool_use_id)).toEqual(["repeat_one", "repeat_two"]); +}); + +test("forced-answer tool calls and empty retry retain bound catalog and nudge positions", async () => { + const { bodies, queries } = transport(index => index <= 3 ? search(`forced_${index}`, `query ${index}`) + : index === 4 ? success("") : success()); + const result = await send(); + expect(result.text).toContain("response.completed"); + expect(bodies).toHaveLength(5); + expect(queries).toHaveLength(2); + const requests = bodies.map(body => JSON.parse(body)); + for (const current of requests) { + expect(current.tools).toEqual(requests[0].tools); + expect(current.system).toEqual(requests[0].system); + } + expect(requests[4].tool_choice).toEqual({ type: "none" }); + const withoutCache = (value: unknown) => JSON.parse(JSON.stringify(value, (key, item) => key === "cache_control" ? undefined : item)); + const prefix = withoutCache(requests[2].messages); + expect(withoutCache(requests[3].messages).slice(0, prefix.length)).toEqual(prefix); + expect(withoutCache(requests[4].messages).slice(0, prefix.length)).toEqual(prefix); + for (const current of requests.slice(2)) { + expect(JSON.stringify(current.messages).match(/Answer the user's question now using/g)).toHaveLength(1); + } +}); + +for (const choice of ["auto", "none", { type: "function", name: "web_search" }, + { type: "allowed_tools", mode: "auto", tools: [{ type: "web_search" }] }]) { + test(`Opus search wire catalog remains authorized and stable: ${JSON.stringify(choice)}`, async () => { + const cfg = config(); cfg.providers["anthropic-test"].models = ["claude-opus-5-5"]; + const data = await request().json(); + data.model = "anthropic-test/claude-opus-5-5"; + data.reasoning = { effort: "high" }; + data.tool_choice = choice; + const req = new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data) }); + const { bodies, queries } = transport(index => choice === "none" ? success() : index <= 2 ? search(`choice_${index}`, `query ${index}`) + : index === 3 ? success("") : success()); + const result = await send({}, cfg, req); + expect(result.text).toContain("response.completed"); + expect(bodies).toHaveLength(choice === "none" ? 1 : 4); + expect(queries).toHaveLength(choice === "none" ? 0 : 2); + const requests = bodies.map(body => JSON.parse(body)); + const names = requests[0].tools.map((tool: any) => tool.name); + if (choice === "none") expect(names).not.toContain("web_search"); + else expect(names).toContain("web_search"); + if (typeof choice === "object") expect(names).not.toContain("lookup"); + else expect(names).toContain("lookup"); + for (const current of requests) { + expect(current.tools).toEqual(requests[0].tools); + expect(current.system).toEqual(requests[0].system); + expect(current.thinking).toEqual({ type: "adaptive", display: "summarized" }); + } + expect(requests.at(-1).tool_choice).toEqual({ type: "none" }); + }); +} + +for (const identity of ["unsafe id", "duplicate", "prior collision"]) test(`search refuses non-replayable tool IDs before execution: ${identity}`, async () => { + const id = identity === "unsafe id" ? identity : "collision"; + const { bodies, queries } = transport(index => identity === "duplicate" ? originalBlockStream([ + { type: "tool_use", id, name: "web_search", input: { query: "one" } }, + { type: "tool_use", id, name: "web_search", input: { query: "two" } }, + ]) : search(id, `query ${index}`)); + const result = await send(); + expect(result.text).toContain("response.failed"); + expect(bodies).toHaveLength(identity === "prior collision" ? 2 : 1); + expect(queries).toHaveLength(identity === "prior collision" ? 1 : 0); +}); From c7917233619bf5a1725b16e3a645d64abd6bcaf7 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:37:42 +0900 Subject: [PATCH 4/9] fix(anthropic): scope recovery memos to serialized opaque payloads Carry the bounded payload memo from #7098 without its local prefix validator. Keep legal history edits upstream-owned, and preserve newly issued thinking after a repaired send. Co-authored-by: lcxhh --- scripts/test-layout/layout.json | 1 + src/adapters/anthropic-rejection-memo.ts | 71 ++++++ src/adapters/anthropic.ts | 21 +- src/responses/reasoning-replay-cache.ts | 54 ++++- src/server/responses/adapter-delivery.ts | 2 +- src/server/responses/adapter-dispatch.ts | 3 + .../responses/anthropic-thinking-recovery.ts | 31 ++- src/server/responses/core-replay.ts | 6 +- src/web-search/loop.ts | 4 +- ...anthropic-message-stop-termination.test.ts | 14 +- .../reasoning-replay-identity.test.ts | 203 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../responses-anthropic-legal-history.test.ts | 150 +++++++++++++ ...ponses-anthropic-thinking-recovery.test.ts | 15 +- ...search-thinking-signature-recovery.test.ts | 38 +++- 15 files changed, 579 insertions(+), 35 deletions(-) create mode 100644 src/adapters/anthropic-rejection-memo.ts create mode 100644 tests/responses/responses-anthropic-legal-history.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 236c90c2f00..9f920c583ce 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1456,6 +1456,7 @@ "responses-anthropic-thinking-signature-recovery.test.ts": "responses", "responses-web-search-thinking-signature-recovery.test.ts": "responses", "responses-anthropic-thinking-recovery.test.ts": "responses", + "responses-anthropic-legal-history.test.ts": "responses", "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", diff --git a/src/adapters/anthropic-rejection-memo.ts b/src/adapters/anthropic-rejection-memo.ts new file mode 100644 index 00000000000..1d24ec9d76f --- /dev/null +++ b/src/adapters/anthropic-rejection-memo.ts @@ -0,0 +1,71 @@ +import { createHash } from "node:crypto"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import type { AdapterRequest } from "./base"; +import type { OcxParsedRequest } from "../types"; + +type Block = Record; +type Policy = { mode: "blanket" } | { mode: "selective"; fingerprints: ReadonlySet }; +const requests = new WeakMap>(); +const responses = new WeakMap>(); +const policies = new WeakMap(); +const owner = (parsed: OcxParsedRequest) => parsed._reasoningReplayScope ?? parsed; +/** Domain-separated opaque bytes, deliberately independent of plaintext and untrusted hints. */ +export function anthropicOpaqueFingerprint(block: Block): string | undefined { + const value = block.type === "thinking" ? block.signature : block.type === "redacted_thinking" ? block.data : undefined; + if (typeof value !== "string" || value.length === 0) return; + return createHash("sha256").update(String(block.type)).update("\0").update(value).digest("hex"); +} +export function captureAnthropicRejectionPayloads(request: AdapterRequest, body: Block, budget?: TranslatorBudget): void { + requests.delete(request); + if (!budget || !Array.isArray(body.messages)) return; + let retained = 0; + let complete = false; + const fingerprints = new Set(); + try { + for (const message of body.messages) { + if (!message || typeof message !== "object") return; + if (typeof message.content === "string") continue; + if (!Array.isArray(message.content)) return; + for (const block of message.content) { + if (!block || typeof block !== "object") return; + if (block.type !== "thinking" && block.type !== "redacted_thinking") continue; + const value = block.type === "thinking" ? block.signature : block.data; + if (typeof value !== "string" || value.length === 0) return; + const temporary = budget.reserveTransient(Buffer.byteLength(value) * 2, { kind: "reasoning" }); + let fingerprint: string | undefined; + try { fingerprint = anthropicOpaqueFingerprint(block); } finally { temporary.release(); } + if (!fingerprint) return; + if (!fingerprints.has(fingerprint)) { + if (fingerprints.size >= 256) return; + // Includes Set/hex storage and later defensive copies; owned until turn disposal. + budget.chargeRetained(256, { kind: "reasoning" }); retained += 256; + fingerprints.add(fingerprint); + } + } + } + if (fingerprints.size) { requests.set(request, fingerprints); complete = true; } + } catch { /* An uncollectable set keeps the legacy blanket policy. */ } + finally { if (!complete) budget.releaseRetained(retained, { kind: "reasoning" }); } +} +export function bindAnthropicRejectionResponse(request: AdapterRequest | undefined, response: Response): void { + const fingerprints = request && requests.get(request); + if (fingerprints) responses.set(response, fingerprints); else responses.delete(response); +} +export function rejectedAnthropicPayloads(response: Response | undefined): ReadonlySet | undefined { + const value = response && responses.get(response); + return value && new Set(value); +} +export function bindAnthropicRejectionPolicy(parsed: OcxParsedRequest, policy: Policy | undefined): void { + if (policy) policies.set(owner(parsed), policy); else policies.delete(owner(parsed)); +} +export function anthropicRejectionPolicy(parsed: OcxParsedRequest): Policy | undefined { return policies.get(owner(parsed)); } + +/** Filter only refused opaque bytes; newly issued thinking in this request remains replayable. */ +export function keepAnthropicReplayBlock(parsed: OcxParsedRequest, block: Block): boolean { + const fingerprint = anthropicOpaqueFingerprint(block); + if (!fingerprint) return true; + if (parsed._stripReasoningEncryptedContent === true) return false; + const policy = anthropicRejectionPolicy(parsed); + return policy ? policy.mode === "selective" && !policy.fingerprints.has(fingerprint) + : true; +} diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index c20af06329f..d9c0743a15c 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1,3 +1,4 @@ +import { captureAnthropicRejectionPayloads, keepAnthropicReplayBlock } from "./anthropic-rejection-memo"; import { anthropicDeclaredTools } from "./anthropic-tool-catalog"; import { anthropicReplayContent, createAnthropicReplayCapture } from "./anthropic-replay"; import type { IncomingMeta, ProviderAdapter } from "./base"; @@ -780,9 +781,6 @@ function messagesToAnthropicFormat( if (text) preface.push({ type: "text", text }); } else if (part.type === "thinking") { const t = part as OcxThinkingContent; - // History minted under another serving identity (or already rejected as opaque) is not - // this destination's to verify: drop its opaque blocks, as the Responses passthrough does. - if (parsed._stripReasoningEncryptedContent === true) continue; // Redacted blocks replay verbatim FIRST (they preceded the visible thinking block // in the original stream order preserved by the bridge envelope). for (const data of t.redacted ?? []) { @@ -817,9 +815,7 @@ function messagesToAnthropicFormat( throw new Error("Anthropic search replay tool identity changed"); } } - const content = captured - ? captured.filter(block => parsed._stripReasoningEncryptedContent !== true || (block.type !== "thinking" && block.type !== "redacted_thinking")) - : [...preface, ...toolUses]; + const content = (captured ?? [...preface, ...toolUses]).filter(block => keepAnthropicReplayBlock(parsed, block as Record)); if (content.length === 0) break; messages.push({ role: "assistant", content }); if (toolUseIds.length > 0) { @@ -1181,7 +1177,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti enforceCacheControlLimit(body, explicitLimit); normalizeTtlOrdering(body); - return { + const request = { url, method: "POST", headers, @@ -1195,6 +1191,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti fastSpeed?.value ?? null, ), }; + captureAnthropicRejectionPayloads(request, body, incoming?.translatorBudget); + return request; }, async *parseStream( @@ -1396,8 +1394,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti break; } case "error": { - const err = data.error as { message?: string } | undefined; - yield { type: "error", message: err?.message ?? "Anthropic error" }; + const err = data.error as { message?: unknown; type?: unknown; code?: unknown; status?: unknown } | undefined; + yield { type: "error", message: typeof err?.message === "string" ? err.message : "Anthropic error", + ...(typeof err?.type === "string" ? { errorType: err.type } : {}), + ...(typeof err?.code === "string" ? { code: err.code } : {}), + ...(typeof err?.status === "number" && Number.isInteger(err.status) && err.status >= 400 && err.status <= 599 + ? { status: err.status } : {}), + }; return; } } diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index a481ffc35f0..6fa40eaa7f1 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -46,6 +46,7 @@ export function nativeReasoningTagMatches(expected: string | undefined, presente const MAX_TOTAL_BYTES = 256 * 1024; const TTL_MS = 60 * 60 * 1000; const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000; +const MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS = 256; const replayIdentityKey = randomBytes(32); const CREDENTIAL_HEADER_NAMES = new Set([ "authorization", @@ -78,8 +79,14 @@ interface ServingIdentityEntry { interface OpaqueBlobRejectionEntry { bytes: number; at: number; + /** Undefined retains the legacy blanket rejection; only payload digests are stored here. */ + fingerprints?: ReadonlySet; } +export type ReasoningReplayOpaqueBlobRejection = + | { mode: "blanket" } + | { mode: "selective"; fingerprints: ReadonlySet }; + const entries = new Map(); const servingIdentities = new Map(); const opaqueBlobRejections = new Map(); @@ -288,18 +295,57 @@ export function reasoningReplayOpaqueBlobRejectionMemoized( return opaqueBlobRejections.has(key); } +/** Undefined means no live memo; a selective result owns a defensive copy of its digests. */ +export function getReasoningReplayOpaqueBlobRejection( + scope: OcxReasoningReplayScopeRef | undefined, +): ReasoningReplayOpaqueBlobRejection | undefined { + const key = opaqueBlobRejectionKeyFor(scope); + if (!key) return undefined; + sweepExpiredOpaqueBlobRejections(now()); + const entry = opaqueBlobRejections.get(key); + return entry === undefined ? undefined : entry.fingerprints === undefined ? { mode: "blanket" } + : { mode: "selective", fingerprints: new Set(entry.fingerprints) }; +} + +/** Incomplete, invalid or unbounded enumerations cannot safely narrow the legacy rejection. */ +function boundedRejectionFingerprints(fingerprints: ReadonlySet | undefined): Set | undefined { + if (fingerprints === undefined) return undefined; + const copy = new Set(); + try { + // The intrinsic validates a real Set without trusting an overridden iterator or size getter. + for (const value of Set.prototype.values.call(fingerprints) as IterableIterator) { + if (typeof value !== "string" || value.length !== 64 || !/^[a-f0-9]{64}$/.test(value) + || copy.size >= MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS) return undefined; + copy.add(value); + } + } catch { return undefined; } + return copy.size > 0 ? copy : undefined; +} + /** Record only after a blobless retry succeeded for this durable serving identity. */ export function rememberReasoningReplayOpaqueBlobRejection( scope: OcxReasoningReplayScopeRef | undefined, + fingerprints?: ReadonlySet, ): void { const key = opaqueBlobRejectionKeyFor(scope); if (!key) return; - const bytes = Buffer.byteLength(key, "utf8"); - if (bytes > MAX_TOTAL_BYTES) return; + const keyBytes = Buffer.byteLength(key, "utf8"); + if (keyBytes > MAX_TOTAL_BYTES) return; const at = now(); sweepExpiredOpaqueBlobRejections(at); - if (opaqueBlobRejections.has(key)) deleteOpaqueBlobRejection(key); - opaqueBlobRejections.set(key, { bytes, at }); + const previous = opaqueBlobRejections.get(key); + let merged = previous && previous.fingerprints === undefined ? undefined : boundedRejectionFingerprints(fingerprints); + if (merged && previous?.fingerprints) { + for (const fingerprint of previous.fingerprints) { + merged.add(fingerprint); + if (merged.size > MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS) { merged = undefined; break; } + } + } + // A near-limit key can still retain its existing blanket memo if the digest set will not fit. + if (merged && keyBytes + merged.size * 64 > MAX_TOTAL_BYTES) merged = undefined; + const bytes = keyBytes + (merged?.size ?? 0) * 64; + if (previous) deleteOpaqueBlobRejection(key); + opaqueBlobRejections.set(key, { bytes, at, ...(merged ? { fingerprints: merged } : {}) }); opaqueBlobRejectionTotalBytes += bytes; while ( (opaqueBlobRejectionTotalBytes > MAX_TOTAL_BYTES || opaqueBlobRejections.size > MAX_ENTRIES) diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index fef3cfdae4e..39f1f96e14c 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -113,7 +113,7 @@ export async function deliverAdapterResponse( yield { type: "error", message: "Upstream recovery response body stalled before completing", status: 504, errorType: "upstream_error" }; } - }, () => transportState.activeAdapter.name, !isNonReplayableResponse(upstreamResponse)); + }, () => transportState.activeAdapter.name, !isNonReplayableResponse(upstreamResponse), upstreamResponse); diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 0c0cac4b73a..d62b2897aa5 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1,3 +1,4 @@ +import { bindAnthropicRejectionResponse } from "../../adapters/anthropic-rejection-memo"; import { deferPhysicalSendReporter } from "./deferred-send-reporter"; import { spendLedgerStorageErrorResponse } from "./spend-storage-error"; import { classifyAnthropic429 } from "../../oauth/anthropic-rate-limit-policy"; @@ -929,6 +930,7 @@ export async function prepareAdapterExchange( return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal); } finally { cleanupUpstreamAbort(); } } + bindAnthropicRejectionResponse(transportState.sameTargetRequest, upstreamResponse); if (await thinkingSignatureRecovery.prepareHttp( upstreamResponse, transportState.activeAdapter.name, )) { @@ -1904,6 +1906,7 @@ export async function prepareAdapterExchange( } } + bindAnthropicRejectionResponse(transportState.sameTargetRequest, upstreamResponse); cancelBodyOnAbort(upstreamResponse.body, upstream.signal); return { diff --git a/src/server/responses/anthropic-thinking-recovery.ts b/src/server/responses/anthropic-thinking-recovery.ts index 571dbcf3e45..4bf6005bfa4 100644 --- a/src/server/responses/anthropic-thinking-recovery.ts +++ b/src/server/responses/anthropic-thinking-recovery.ts @@ -1,3 +1,4 @@ +import { anthropicRejectionPolicy, bindAnthropicRejectionPolicy, rejectedAnthropicPayloads } from "../../adapters/anthropic-rejection-memo"; import { normalizeUpstreamErrorText, readDisplaySafeErrorText } from "./core-errors"; import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { isLikelyRealAnthropicThinkingSignature } from "../../adapters/anthropic"; @@ -29,22 +30,38 @@ export function createAnthropicThinkingRecovery( canRetry: () => boolean, ) { let rejectedScope: OcxParsedRequest["_reasoningReplayScope"]; + let rejectedPayloads: ReadonlySet | undefined; let armed = false; let outputSeen = false; let completed = false; let failed = false; let remembered = false; - const prepare = (message: string, adapterName: string, attempt = parsed): boolean => { + const prepare = (message: string, adapterName: string, attempt = parsed, response?: Response): boolean => { if (signal.aborted || !canRetry() || adapterName !== "anthropic" || guard.attempted || parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(attempt) || !isAnthropicThinkingSignatureBindingRejectionMessage(message)) return false; + rejectedPayloads = rejectedAnthropicPayloads(response); + // A real send must prove that it serialized opaque history; otherwise rebuilding could + // repeat the same request. Direct event-policy callers have no HTTP body to identify. + if (response && !rejectedPayloads?.size) return false; + const previous = anthropicRejectionPolicy(parsed); + if (rejectedPayloads && previous?.mode === "selective") { + const merged = new Set(previous.fingerprints); + for (const payload of rejectedPayloads) merged.add(payload); + rejectedPayloads = merged.size <= 256 ? merged : undefined; + } guard.attempted = true; armed = true; const scope = parsed._reasoningReplayScope; rejectedScope = scope ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined; - prepareOpaqueBlobRecovery(parsed); - if (attempt !== parsed) prepareOpaqueBlobRecovery(attempt); + if (!rejectedPayloads) { + prepareOpaqueBlobRecovery(parsed); + if (attempt !== parsed) prepareOpaqueBlobRecovery(attempt); + } + const policy = rejectedPayloads ? { mode: "selective" as const, fingerprints: rejectedPayloads } : { mode: "blanket" as const }; + bindAnthropicRejectionPolicy(parsed, policy); + bindAnthropicRejectionPolicy(attempt, policy); return true; }; @@ -56,7 +73,7 @@ export function createAnthropicThinkingRecovery( const body = await readBoundedResponseBody(response.clone(), { signal }); if (!body.displaySafe || body.truncated) return false; const message = upstreamErrorMessageFromPayload(JSON.parse(body.text) as unknown); - return typeof message === "string" && prepare(message, adapterName, attempt); + return typeof message === "string" && prepare(message, adapterName, attempt, response); } catch { return false; } @@ -74,6 +91,8 @@ export function createAnthropicThinkingRecovery( retry: () => AsyncIterable, adapterName: () => string, replayAllowed = true, + response?: Response, + attempt = parsed, ): AsyncGenerator { let source = events; let replaySafe = replayAllowed; @@ -85,7 +104,7 @@ export function createAnthropicThinkingRecovery( const next = await iterator.next(); if (next.done) return; const event = next.value; - if (replaySafe && event.type === "error" && prepare(event.message, adapterName())) { + if (replaySafe && event.type === "error" && prepare(event.message, adapterName(), attempt, response)) { // Close the rejected body before acquiring another dispatch/pacing slot. await iterator.return?.(); source = retry(); @@ -113,7 +132,7 @@ export function createAnthropicThinkingRecovery( || rejectedScope.clientThreadId !== parsed._reasoningReplayScope?.clientThreadId || JSON.stringify(rejectedScope.current) !== JSON.stringify(parsed._reasoningReplayScope?.current)) return; remembered = true; - rememberReasoningReplayOpaqueBlobRejection(rejectedScope); + rememberReasoningReplayOpaqueBlobRejection(rejectedScope, rejectedPayloads); }; // The search owner supplies only a fully validated current iteration, never search-card output. const observeIteration = (events: readonly AdapterEvent[]): void => { diff --git a/src/server/responses/core-replay.ts b/src/server/responses/core-replay.ts index 66fdfa819ed..d510555152d 100644 --- a/src/server/responses/core-replay.ts +++ b/src/server/responses/core-replay.ts @@ -1,3 +1,5 @@ +import { bindAnthropicRejectionPolicy } from "../../adapters/anthropic-rejection-memo"; +import { getReasoningReplayOpaqueBlobRejection } from "../../responses/reasoning-replay-cache"; import type { OcxProviderContinuationOwner, OcxProviderContinuationState, @@ -164,8 +166,10 @@ export function bindRouteReasoningReplayScope(args: { parsed._dropForeignReasoningItemIds = true; } } + const rejectionMemo = getReasoningReplayOpaqueBlobRejection(parsed._reasoningReplayScope); + bindAnthropicRejectionPolicy(parsed, adapterName === "anthropic" ? rejectionMemo : undefined); if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) { - parsed._stripReasoningEncryptedContent = true; + if (adapterName !== "anthropic" || rejectionMemo?.mode !== "selective") parsed._stripReasoningEncryptedContent = true; parsed._dropForeignReasoningItemIds = true; } bindProviderContinuationForRoute(parsed, continuationOwner); diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index e4270149e53..21c072ce17d 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -1,3 +1,4 @@ +import { bindAnthropicRejectionResponse } from "../adapters/anthropic-rejection-memo"; import { anthropicDeclaredTools } from "../adapters/anthropic-tool-catalog"; import { enableAnthropicReplayCapture, takeAnthropicReplay, bindAnthropicReplay, type AnthropicReplaySnapshot } from "../adapters/anthropic-replay"; import { anthropicSearchAssistantContent } from "./anthropic-history"; @@ -586,12 +587,13 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise => { if (signal.aborted || !error.replaySafe || isNonReplayableResponse(prepared.response) - || !deps.thinkingRecovery?.prepareEvent(error.event.message, prepared.responseAdapter.name, iterParsed)) return; + || !deps.thinkingRecovery?.prepareEvent(error.event.message, prepared.responseAdapter.name, iterParsed, prepared.response)) return; cachedRequest = undefined; cachedAdapter = undefined; // The successful headers ended the previous timer; the replacement owns a fresh deadline. diff --git a/tests/adapters/anthropic/anthropic-message-stop-termination.test.ts b/tests/adapters/anthropic/anthropic-message-stop-termination.test.ts index 0c8f37058fb..b171a63e083 100644 --- a/tests/adapters/anthropic/anthropic-message-stop-termination.test.ts +++ b/tests/adapters/anthropic/anthropic-message-stop-termination.test.ts @@ -94,6 +94,7 @@ describe("Anthropic message_stop termination (#6766)", () => { try { const events = await collect(stream(new Response(turn() + tail), budget, false)); expect(events).toEqual([ + { type: "heartbeat", replayUnsafe: true }, { type: "text_delta", text: "ok" }, { type: "done", usage, stopReason: "end_turn" }, ]); @@ -105,7 +106,8 @@ describe("Anthropic message_stop termination (#6766)", () => { const budget = createTranslatorBudget(); try { const events = await collect(stream(new Response(turn() + tail), budget, true)); - // Raw-byte progress may add heartbeats before the validated terminal. + // Raw-byte progress may add harmless heartbeats, but the semantic replay fence survives. + expect(events.filter(event => event.type === "heartbeat" && event.replayUnsafe)).toEqual([{ type: "heartbeat", replayUnsafe: true }]); expect(events.filter(event => event.type !== "heartbeat")).toEqual([ { type: "text_delta", text: "ok" }, { type: "done", usage, stopReason: "end_turn" }, @@ -122,6 +124,7 @@ describe("Anthropic message_stop termination (#6766)", () => { const events = await collect(stream(new Response(wire), budget, false)); expect(events).toEqual([ { type: "heartbeat" }, { type: "heartbeat" }, { type: "heartbeat" }, + { type: "heartbeat", replayUnsafe: true }, { type: "text_delta", text: "ok" }, { type: "done", usage, stopReason: "end_turn" }, ]); @@ -141,7 +144,7 @@ describe("Anthropic message_stop termination (#6766)", () => { ? { type: "incomplete", reason: "content_filter", retryable: false, message: `upstream ended the turn with stop_reason "${stopReason}"`, usage } : { type: "done", stopReason, usage }; - expect(events).toEqual([{ type: "text_delta", text: "ok" }, expected]); + expect(events).toEqual([{ type: "heartbeat", replayUnsafe: true }, { type: "text_delta", text: "ok" }, expected]); expectReleased(budget); } finally { budget.dispose(); } }, @@ -200,7 +203,8 @@ describe("Anthropic message_stop termination (#6766)", () => { + frame("message_stop") + frame("ping"); try { const events = await collect(stream(new Response(wire), budget, false)); - expect(events.map(event => event.type)).toEqual(["tool_call_start", "tool_call_delta", "done"]); + expect(events.map(event => event.type)).toEqual(["heartbeat", "tool_call_start", "tool_call_delta", "done"]); + expect(events[0]).toEqual({ type: "heartbeat", replayUnsafe: true }); // Existing behavior does not invent tool_call_end for an unclosed block. expect(events.at(-1)).toEqual({ type: "done", usage: undefined, stopReason: "tool_use" }); expectReleased(budget); @@ -213,6 +217,7 @@ describe("Anthropic message_stop termination (#6766)", () => { content_block: { type: "tool_use", id: "call_cancel", name: "web_search" } })); const iterator = stream(upstream.response, budget, false); try { + expect((await iterator.next()).value).toEqual({ type: "heartbeat", replayUnsafe: true }); expect((await iterator.next()).value?.type).toBe("tool_call_start"); expect(budget.snapshot().activeCalls).toBe(1); await iterator.return(undefined); @@ -231,7 +236,8 @@ describe("Anthropic message_stop termination (#6766)", () => { + frame("message_stop") + frame("ping"); try { const events = await collect(stream(new Response(wire), budget, false)); - expect(events.map(event => event.type)).toEqual(["tool_call_start", "error"]); + expect(events.map(event => event.type)).toEqual(["heartbeat", "tool_call_start", "error"]); + expect(events[0]).toEqual({ type: "heartbeat", replayUnsafe: true }); expect(events.at(-1)).toMatchObject({ type: "error", status: 502, errorType: "upstream_error", code: "translation_buffer_limit" }); expectReleased(budget, 1); diff --git a/tests/adapters/reasoning-replay-identity.test.ts b/tests/adapters/reasoning-replay-identity.test.ts index 8709c6ea65d..737923bfb7b 100644 --- a/tests/adapters/reasoning-replay-identity.test.ts +++ b/tests/adapters/reasoning-replay-identity.test.ts @@ -1,3 +1,6 @@ +import { anthropicOpaqueFingerprint, captureAnthropicRejectionPayloads, bindAnthropicRejectionResponse, rejectedAnthropicPayloads } from "../../src/adapters/anthropic-rejection-memo"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { AdapterRequest } from "../../src/adapters/base"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE } from "../../src/bridge"; import { @@ -5,6 +8,7 @@ import { clearReasoningReplayCacheForTests, commitReasoningReplayServingIdentity, durableReplayCredentialIdentity, + getReasoningReplayOpaqueBlobRejection, peekReasoningForCall, reasoningReplayCodexCredentialIdentity, reasoningReplayCredentialIdentity, @@ -384,3 +388,202 @@ describe("reasoning replay provider and credential identity", () => { expect(peekReasoningForCall(CALL_ID, holder)).toBe("new reasoning"); }); }); + + +describe("selective opaque-blob rejection memos", () => { + const digest = (index: number): string => index.toString(16).padStart(64, "0"); + const fingerprints = (count: number): Set => new Set(Array.from({ length: count }, (_, index) => digest(index))); + beforeEach(() => clearReasoningReplayCacheForTests()); + afterEach(() => clearReasoningReplayCacheForTests()); + + test("distinguishes absent, selective and sticky blanket memos while preserving the boolean API", () => { + expect(getReasoningReplayOpaqueBlobRejection(scope())).toBeUndefined(); + expect(reasoningReplayOpaqueBlobRejectionMemoized(scope())).toBe(false); + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(1)])); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "selective", fingerprints: new Set([digest(1)]) }); + expect(reasoningReplayOpaqueBlobRejectionMemoized(scope())).toBe(true); + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(1), digest(2)])); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "selective", fingerprints: new Set([digest(1), digest(2)]) }); + rememberReasoningReplayOpaqueBlobRejection(scope()); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "blanket" }); + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(3)])); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "blanket" }); + }); + + test("allows 256 distinct digests but falls back to blanket when a merge or one input overflows", () => { + rememberReasoningReplayOpaqueBlobRejection(scope(), fingerprints(256)); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "selective", fingerprints: fingerprints(256) }); + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(255)])); + expect(getReasoningReplayOpaqueBlobRejection(scope())?.mode).toBe("selective"); + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(256)])); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "blanket" }); + const other = { ...scope(), clientThreadId: "overflow-input" }; + rememberReasoningReplayOpaqueBlobRejection(other, fingerprints(257)); + expect(getReasoningReplayOpaqueBlobRejection(other)).toEqual({ mode: "blanket" }); + }); + + test.each([ + new Set(), new Set(["a".repeat(63)]), new Set(["A".repeat(64)]), + new Set(["a".repeat(64) + "\n"]), new Set(["g".repeat(64)]), new Set([null]), + null, ["a".repeat(64)], { size: 1, [Symbol.iterator]() { throw new Error("untrusted iterator"); } }, + ].map(value => ({ value })))("invalid or unreliable fingerprint input falls back without throwing: %j", ({ value }) => { + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(1)])); + expect(() => rememberReasoningReplayOpaqueBlobRejection(scope(), value as ReadonlySet)).not.toThrow(); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "blanket" }); + }); + + test("caller-owned input and getter output cannot mutate retained fingerprints", () => { + const input = new Set([digest(1)]); + rememberReasoningReplayOpaqueBlobRejection(scope(), input); + input.clear(); input.add("invalid"); + const observed = getReasoningReplayOpaqueBlobRejection(scope()); + expect(observed).toEqual({ mode: "selective", fingerprints: new Set([digest(1)]) }); + if (observed?.mode !== "selective") throw new Error("selective memo missing"); + (observed.fingerprints as Set).clear(); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "selective", fingerprints: new Set([digest(1)]) }); + }); + + test("uses durable scope dimensions, ignores volatile generations and refuses incomplete scopes", () => { + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(1)])); + expect(getReasoningReplayOpaqueBlobRejection(scope({ credentialIdentity: "new-process-token" }))?.mode).toBe("selective"); + for (const changed of [ + { ...scope(), clientThreadId: "different-thread" }, scope({ providerName: "different-provider" }), + scope({ providerDestinationDurableIdentity: "different-destination" }), scope({ adapterName: "anthropic" }), + scope({ modelId: "different-model" }), scope({ credentialDurableIdentity: "different-credential" }), + ]) expect(getReasoningReplayOpaqueBlobRejection(changed)).toBeUndefined(); + for (const incomplete of [undefined, scope({ credentialDurableIdentity: undefined }), scope({ providerDestinationDurableIdentity: undefined })]) { + rememberReasoningReplayOpaqueBlobRejection(incomplete, new Set([digest(1)])); + expect(getReasoningReplayOpaqueBlobRejection(incomplete)).toBeUndefined(); + } + }); + + test("reads do not refresh five-minute expiry, expired memos do not merge, and clear resets both APIs", () => { + let clock = 1000; + clearReasoningReplayCacheForTests(() => clock); + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(1)])); + clock += 5 * 60 * 1000 - 1; + expect(getReasoningReplayOpaqueBlobRejection(scope())?.mode).toBe("selective"); + clock += 1; + expect(getReasoningReplayOpaqueBlobRejection(scope())).toBeUndefined(); + expect(reasoningReplayOpaqueBlobRejectionMemoized(scope())).toBe(false); + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(2)])); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "selective", fingerprints: new Set([digest(2)]) }); + rememberReasoningReplayOpaqueBlobRejection(scope()); + clock += 5 * 60 * 1000; + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(3)])); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "selective", fingerprints: new Set([digest(3)]) }); + clearReasoningReplayCacheForTests(); + expect(getReasoningReplayOpaqueBlobRejection(scope())).toBeUndefined(); + expect(reasoningReplayOpaqueBlobRejectionMemoized(scope())).toBe(false); + }); + + test("merging refreshes the existing expiry without losing earlier selective digests", () => { + let clock = 1000; + clearReasoningReplayCacheForTests(() => clock); + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(1)])); + clock += 200_000; + rememberReasoningReplayOpaqueBlobRejection(scope(), new Set([digest(2)])); + clock += 100_000; + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "selective", fingerprints: new Set([digest(1), digest(2)]) }); + clock += 200_000; + expect(getReasoningReplayOpaqueBlobRejection(scope())).toBeUndefined(); + }); + + test("fingerprint bytes participate in the existing 256 KiB oldest-entry eviction", () => { + let clock = 1000; + clearReasoningReplayCacheForTests(() => clock); + for (let index = 0; index < 16; index++) { + rememberReasoningReplayOpaqueBlobRejection({ ...scope(), clientThreadId: `byte-thread-${index}` }, fingerprints(256)); + clock++; + } + expect(getReasoningReplayOpaqueBlobRejection({ ...scope(), clientThreadId: "byte-thread-0" })).toBeUndefined(); + expect(getReasoningReplayOpaqueBlobRejection({ ...scope(), clientThreadId: "byte-thread-1" })?.mode).toBe("selective"); + expect(getReasoningReplayOpaqueBlobRejection({ ...scope(), clientThreadId: "byte-thread-15" })?.mode).toBe("selective"); + }); + + test("replacing a selective memo with blanket releases its retained fingerprint bytes", () => { + let clock = 1000; + clearReasoningReplayCacheForTests(() => clock); + rememberReasoningReplayOpaqueBlobRejection(scope(), fingerprints(256)); + rememberReasoningReplayOpaqueBlobRejection(scope()); + for (let index = 0; index < 15; index++) { + clock++; + rememberReasoningReplayOpaqueBlobRejection({ ...scope(), clientThreadId: `byte-thread-${index}` }, fingerprints(256)); + } + expect(getReasoningReplayOpaqueBlobRejection(scope())).toEqual({ mode: "blanket" }); + expect(getReasoningReplayOpaqueBlobRejection({ ...scope(), clientThreadId: "byte-thread-0" })?.mode).toBe("selective"); + }); + + test("falls back to the old blanket entry when a valid set cannot fit beside a near-limit key", () => { + const current = scope().current!; + const identity = JSON.stringify([current.providerName, current.providerDestinationDurableIdentity, current.adapterName, current.modelId, current.credentialDurableIdentity]); + const overhead = Buffer.byteLength(JSON.stringify(["", identity])); + const nearLimit = { ...scope(), clientThreadId: "t".repeat(256 * 1024 - overhead - 1) }; + rememberReasoningReplayOpaqueBlobRejection(nearLimit, new Set([digest(1)])); + expect(getReasoningReplayOpaqueBlobRejection(nearLimit)).toEqual({ mode: "blanket" }); + }); +}); + +describe("actual Anthropic rejection capture", () => { + const body = (count: number) => ({ messages: [{ role: "assistant", content: Array.from({ length: count }, (_, index) => ({ type: "thinking", signature: `SyntheticCapture${index}` })) }] }); + test("fingerprints exclude text and metadata and separate opaque domains", () => { + const signature = "SyntheticOpaqueBytes"; + expect(anthropicOpaqueFingerprint({ type: "thinking", signature, thinking: "before", ap: "original" })) + .toBe(anthropicOpaqueFingerprint({ type: "thinking", signature, thinking: "after", ap: "forged" })); + expect(anthropicOpaqueFingerprint({ type: "thinking", signature })) + .not.toBe(anthropicOpaqueFingerprint({ type: "redacted_thinking", data: signature })); + }); + test("complete capture is bounded, charged until disposal, and defensively copied", () => { + const budget = createTranslatorBudget(); + const request = {} as AdapterRequest; + const response = new Response(null, { status: 400 }); + try { + captureAnthropicRejectionPayloads(request, body(256), budget); + bindAnthropicRejectionResponse(request, response); + expect(budget.snapshot().currentBytes).toBe(256 * 256); + expect(rejectedAnthropicPayloads(response)?.size).toBe(256); + (rejectedAnthropicPayloads(response) as Set).clear(); + expect(rejectedAnthropicPayloads(response)?.size).toBe(256); + } finally { budget.dispose(); } + expect(budget.snapshot().currentBytes).toBe(0); + }); + test("overflow and missing budget never narrow the rejection policy", () => { + const budget = createTranslatorBudget(); + const request = {} as AdapterRequest; + const response = new Response(null, { status: 400 }); + try { + captureAnthropicRejectionPayloads(request, body(257), budget); + bindAnthropicRejectionResponse(request, response); + expect(rejectedAnthropicPayloads(response)).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(0); + captureAnthropicRejectionPayloads(request, body(1)); + bindAnthropicRejectionResponse(request, response); + expect(rejectedAnthropicPayloads(response)).toBeUndefined(); + } finally { budget.dispose(); } + }); + test("admission failure refunds only admitted digests", () => { + const budget = createTranslatorBudget({ maxTurnBytes: 600 }); + budget.chargeRetained(123, { kind: "reasoning" }); + const request = {} as AdapterRequest; + const response = new Response(null, { status: 400 }); + try { + captureAnthropicRejectionPayloads(request, body(2), budget); + bindAnthropicRejectionResponse(request, response); + expect(rejectedAnthropicPayloads(response)).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(123); + } finally { budget.dispose(); } + }); + test("failed recapture clears stale request and response ownership", () => { + const budget = createTranslatorBudget(); + const request = {} as AdapterRequest; + const response = new Response(null, { status: 400 }); + try { + captureAnthropicRejectionPayloads(request, body(1), budget); + bindAnthropicRejectionResponse(request, response); + expect(rejectedAnthropicPayloads(response)?.size).toBe(1); + captureAnthropicRejectionPayloads(request, { messages: [{ content: null }] }, budget); + bindAnthropicRejectionResponse(request, response); + expect(rejectedAnthropicPayloads(response)).toBeUndefined(); + } finally { budget.dispose(); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 79c3732e0e1..8e648d790a1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -974,6 +974,7 @@ "responses-anthropic-thinking-signature-recovery.test.ts": "responses", "responses-web-search-thinking-signature-recovery.test.ts": "responses", "responses-anthropic-thinking-recovery.test.ts": "responses", + "responses-anthropic-legal-history.test.ts": "responses", "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", "responses-chatgpt-web-replay-metadata.test.ts": "responses", "responses-code-mode-goal-helpers.test.ts": "responses", diff --git a/tests/responses/responses-anthropic-legal-history.test.ts b/tests/responses/responses-anthropic-legal-history.test.ts new file mode 100644 index 00000000000..416004cf1c3 --- /dev/null +++ b/tests/responses/responses-anthropic-legal-history.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { handleResponses } from "../../src/server/responses/core"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const originalHome = process.env.OPENCODEX_HOME; +const SIGNATURE = "SyntheticPrefixSignature123456=="; +const SECOND_SIGNATURE = "SyntheticNextPrefixSignature123456=="; +const MODEL = "claude-opus-5-5"; +const TOOLS = [{ type: "function", name: "lookup", description: "Fixture lookup", parameters: { type: "object", properties: {} } }]; +type Item = Record; +let home = ""; +let release: (() => void) | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-prefix-roundtrip-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); +}); +afterEach(() => { + try { release?.(); } finally { + release = undefined; + globalThis.fetch = originalFetch; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + resetWorkflowBudgetsForTest(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); + } +}); + +function config(): OcxConfig { + return { defaultProvider: "anthropic-test", emptyCompletionRetry: false, providers: { "anthropic-test": { + adapter: "anthropic", baseUrl: "https://anthropic.example.test", authMode: "key", + apiKey: "synthetic-test-key", proxy: "direct", models: [MODEL, "claude-sonnet-4-5"], + } } } as OcxConfig; +} +const user = (text: string): Item => ({ type: "message", role: "user", content: [{ type: "input_text", text }] }); +function request(input: Item[], stream: boolean, overrides: Item = {}): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": "prefix-fixture-thread" }, + body: JSON.stringify({ model: `anthropic-test/${MODEL}`, stream, store: false, + instructions: "Preserve the fixture context.", reasoning: { effort: "high", summary: "detailed" }, + tools: TOOLS, input, ...overrides }), + }); +} +function frame(type: string, data: Item = {}): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; +} +function signedFrames(signature = SIGNATURE, thinking = "Complete fixture reasoning"): string[] { + return [ + frame("message_start", { message: { id: "msg_fixture", role: "assistant", content: [], usage: { input_tokens: 2 } } }), + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "thinking_delta", thinking } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature } }), + frame("content_block_stop", { index: 0 }), + ]; +} +function upstream(signature = SIGNATURE, tool = false, stream = true): Response { + if (!stream) return Response.json({ id: "msg_fixture", type: "message", role: "assistant", model: MODEL, + content: [{ type: "thinking", thinking: "Complete fixture reasoning", signature }, + tool ? { type: "tool_use", id: "tool_fixture", name: "lookup", input: {} } : { type: "text", text: "Fixture answer" }], + stop_reason: tool ? "tool_use" : "end_turn", usage: { input_tokens: 2, output_tokens: 3 } }); + const frames = signedFrames(signature); + if (tool) frames.push( + frame("content_block_start", { index: 1, content_block: { type: "tool_use", id: "tool_fixture", name: "lookup", input: {} } }), + frame("content_block_delta", { index: 1, delta: { type: "input_json_delta", partial_json: "{}" } }), + frame("content_block_stop", { index: 1 }), + ); + else frames.push( + frame("content_block_start", { index: 1, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index: 1, delta: { type: "text_delta", text: "Fixture answer" } }), + frame("content_block_stop", { index: 1 }), + ); + frames.push(frame("message_delta", { delta: { stop_reason: tool ? "tool_use" : "end_turn" }, usage: { output_tokens: 3 } }), frame("message_stop")); + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); +} +function transport(answer: (index: number, stream: boolean) => Response | Promise) { + const bodies: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (!["https://anthropic.example.test/v1/messages", "https://other-anthropic.example.test/v1/messages"].includes(url)) { + throw new Error(`Unexpected network target blocked: ${url}`); + } + bodies.push(String(init?.body)); + return answer(bodies.length, JSON.parse(String(init?.body)).stream === true); + }) as typeof fetch; + return bodies; +} +async function send(input: Item[], stream = false, overrides: Item = {}, cfg = config(), options: Parameters[3] = {}) { + const log: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(request(input, stream, overrides), cfg, log, options); + const text = await response.text(); + const events = stream ? text.split("\n").filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6)) as Item) : []; + const json = stream ? undefined : JSON.parse(text); + const output = (stream ? events.filter(event => event.type === "response.output_item.done").map(event => event.item) + : json?.output ?? []) as Item[]; + return { response, text, output, log, json, events }; +} +test("removing only the oldest thinking preserves the remaining valid run", async () => { + const bodies = transport((index, stream) => upstream(index === 1 ? SIGNATURE : SECOND_SIGNATURE, false, stream)); + const first = await send([user("First question")]); + const history = [user("First question"), ...first.output, user("Second question")]; + const second = await send(history); + expect(second.response.status).toBe(200); + const trimmed = [user("First question"), ...first.output.filter(item => item.type !== "reasoning"), + user("Second question"), ...second.output, user("Third question")]; + const third = await send(trimmed); + expect(third.response.status).toBe(200); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SECOND_SIGNATURE); +}); + +test("rotating image URL with identical bytes is left to upstream validation", async () => { + const bodies = transport((_index, stream) => upstream(SIGNATURE, true, stream)); + const imageUser = (url: string): Item => ({type: "message", role: "user", content: [ + {type: "input_text", text: "Look at this fixed image"}, {type: "input_image", image_url: url} + ]}); + const first = await send([imageUser("https://images.example.test/fixed.png?signature=old")], false, + {model: "anthropic-test/claude-sonnet-4-5"}); + const next = await send([imageUser("https://images.example.test/fixed.png?signature=new"), ...first.output, + {type: "function_call_output", call_id: "tool_fixture", output: "Already executed"}], false, + {model: "anthropic-test/claude-sonnet-4-5"}); + expect(next.response.status).toBe(200); + expect(bodies).toHaveLength(2); +}); + +test("pre-binding model accepts a changed system on a manual tool continuation", async () => { + const bodies = transport((_index, stream) => upstream(SIGNATURE, true, stream)); + const first = await send([user("Run lookup")], false, {model: "anthropic-test/claude-sonnet-4-5"}); + const next = await send([user("Run lookup"), ...first.output, + {type: "function_call_output", call_id: "tool_fixture", output: "Already executed"}], false, + {model: "anthropic-test/claude-sonnet-4-5", instructions: "New system prompt for this next turn."}); + expect(next.response.status).toBe(200); + expect(bodies).toHaveLength(2); +}); diff --git a/tests/responses/responses-anthropic-thinking-recovery.test.ts b/tests/responses/responses-anthropic-thinking-recovery.test.ts index b61aaa4cb1a..54e2d19db5f 100644 --- a/tests/responses/responses-anthropic-thinking-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-recovery.test.ts @@ -1,3 +1,5 @@ +import { captureAnthropicRejectionPayloads, bindAnthropicRejectionResponse } from "../../src/adapters/anthropic-rejection-memo"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { afterEach, expect, test } from "bun:test"; import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; import { createAnthropicThinkingRecovery, isAnthropicThinkingSignatureBindingRejectionMessage } from "../../src/server/responses/anthropic-thinking-recovery"; @@ -24,6 +26,17 @@ async function collect(events: AsyncIterable): Promise { const { recovery, request } = fixture(); request.context.messages = [{ role: "assistant", content: [{ type: "thinking", thinking: "", redacted: ["synthetic-redacted"] }] }]; - expect(await recovery.prepareHttp(Response.json({ error: { message: MESSAGE } }, { status: 400 }), "anthropic")).toBe(true); + expect(await recovery.prepareHttp(httpRejection(), "anthropic")).toBe(true); recovery.rememberSuccess({ status: "completed", output: [{ type: "message" }] }); expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(false); await collect(recovery.wrap(stream(success), () => { throw new Error("unexpected retry"); }, () => "anthropic")); 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 8bf283ed209..13a0b8edcb0 100644 --- a/tests/responses/responses-web-search-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts @@ -90,11 +90,11 @@ function success(text = "Recovered search answer", stopReason = "end_turn"): Res ...(text ? [textFrame(text)] : []), frame("message_delta", { delta: { stop_reason: stopReason }, usage: { output_tokens: 3 } }), frame("message_stop")); } -function search(id = "ws_fixture", query = "fixture"): Response { +function search(id = "ws_fixture", query = "fixture", signature = SIGNATURE): Response { return sse(frame("message_start", { message: { usage: { input_tokens: 2 } } }), frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), frame("content_block_delta", { index: 0, delta: { type: "thinking_delta", thinking: "search reasoning" } }), - frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: SIGNATURE } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature } }), frame("content_block_stop", { index: 0 }), frame("content_block_start", { index: 1, content_block: { type: "tool_use", id, name: "web_search", input: {} } }), frame("content_block_delta", { index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ query }) } }), @@ -184,28 +184,37 @@ describe("hosted web search Anthropic thinking-signature recovery", () => { }); } - test("stripping persists when a recovered iteration asks for another search", async () => { + test("old stripping persists while newly issued search thinking remains replayable", async () => { + const firstFresh = "SyntheticFreshFirstSignature123456=="; + const secondFresh = "SyntheticFreshSecondSignature123456=="; const { bodies, queries } = transport(index => index === 1 ? rejection() - : index === 2 ? search("ws_first", "first fixture") - : index === 3 ? search("ws_second", "second fixture") : success()); + : index === 2 ? search("ws_first", "first fixture", firstFresh) + : index === 3 ? search("ws_second", "second fixture", secondFresh) : success()); const result = await send(); expect(result.text).toContain("Recovered search answer"); expect(bodies).toHaveLength(4); expect(queries).toHaveLength(2); - bodies.slice(1).forEach(expectStripped); + expectStripped(bodies[1]); + for (const body of bodies.slice(2)) { expect(body).not.toContain(SIGNATURE); expect(body).not.toContain(REDACTED); } + expect(bodies[2]).toContain(firstFresh); + expect(bodies[3]).toContain(firstFresh); + expect(bodies[3]).toContain(secondFresh); expect(bodies[3]).toContain("ws_first"); expect(bodies[3]).toContain("ws_second"); expect(result.log.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); }); for (const firstSse of [false, true]) { test(`HTTP and SSE refusals share one request-wide guard across rounds (first SSE=${firstSse})`, async () => { + const fresh = "SyntheticFreshGuardSignature123456=="; const { bodies, queries } = transport(index => index === 1 ? firstSse ? streamRejection() : rejection() - : index === 2 ? search() : index === 3 ? firstSse ? rejection() : streamRejection() : success()); + : index === 2 ? search("ws_fixture", "fixture", fresh) : index === 3 ? firstSse ? rejection() : streamRejection() : success()); const result = await send(); expect(bodies).toHaveLength(3); expect(queries).toHaveLength(1); expectStripped(bodies[1]); - expectStripped(bodies[2]); + expect(bodies[2]).not.toContain(SIGNATURE); + expect(bodies[2]).not.toContain(REDACTED); + expect(bodies[2]).toContain(fresh); expect(result.text).toContain("response.failed"); expect(result.text).toContain(REJECTION); await send(); @@ -709,3 +718,16 @@ for (const identity of ["unsafe id", "duplicate", "prior collision"]) test(`sear expect(bodies).toHaveLength(identity === "prior collision" ? 2 : 1); expect(queries).toHaveLength(identity === "prior collision" ? 1 : 0); }); + +// All gateway request identifiers in this fixture are synthetic. +const GENERIC_BEDROCK_SIGNATURE_ERROR = "aws_invoke_error: InvokeModelWithResponseStream: operation error Bedrock Runtime: InvokeModelWithResponseStream, https response error StatusCode: 400, RequestID: 11111111-2222-4333-8444-555555555555, ValidationException: ***.***.content.0: Invalid `signature` in `thinking` block (request id: 20260101000000000000000000syntheticABC)"; +for (const stream of [true, false]) test(`generic Bedrock HTTP signature refusal stays terminal without stripping (stream=${stream})`, async () => { + const { bodies, queries } = transport(() => rejection(GENERIC_BEDROCK_SIGNATURE_ERROR)); + const result = await send({}, config(), request(stream)); + expect(bodies).toHaveLength(1); + expect(queries).toHaveLength(0); + expect(bodies[0]).toContain(SIGNATURE); + expect(bodies[0]).toContain(REDACTED); + expect(result.text).toContain("Invalid `signature` in `thinking` block"); + expect(result.log.activeAttempt?.recoveryKinds ?? []).toEqual([]); +}); From 2b2c74b1d0f7f4429cdbd1e431ec4f98dae5759d Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:39:35 +0900 Subject: [PATCH 5/9] fix(responses): share thinking recovery across media and empty retries Use each existing dispatch, pacing and send-budget owner. Retry only the current inference, keep completed media side effects, and retain structured replacement refusals. Ordinary continuations after output stay ineligible. Co-authored-by: lcxhh --- src/images/loop.ts | 90 +++++++++--- src/server/responses/adapter-continuation.ts | 52 ++++--- src/server/responses/sidecar-execution.ts | 67 +++++---- ...anthropic-sidecar-account-failover.test.ts | 6 +- tests/providers/umans-provider.test.ts | 10 +- ...hropic-thinking-signature-recovery.test.ts | 137 ++++++++++++++++++ 6 files changed, 288 insertions(+), 74 deletions(-) diff --git a/src/images/loop.ts b/src/images/loop.ts index 516b3cbc6a1..6b1330260bd 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -1,3 +1,5 @@ +import type { createAnthropicThinkingRecovery } from "../server/responses/anthropic-thinking-recovery"; +import { bindAnthropicRejectionResponse } from "../adapters/anthropic-rejection-memo"; /** * Media bridge agentic loop — supports both image and video generation sidecars. * @@ -22,7 +24,7 @@ import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { readBoundedResponseBody } from "../lib/bounded-body"; -import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry"; import { rateLimitRetryDelayMs } from "../providers/key-failover"; import { releaseProviderRequestSlot, sendTrackingRequestSlot, type ProviderRequestSlot } from "../providers/request-pacing"; import { @@ -32,7 +34,7 @@ import { TRANSLATOR_MAX_TURN_BYTES, TranslatorBudgetExceededError, } from "../lib/translator-budget"; -import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "../web-search/progress-stream"; +import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "../web-search/progress-stream"; import { fulfillImageCall } from "./fulfill"; import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult, createVideoBudget } from "./fulfill-video"; import { submitVideoJob } from "./xai-video-client"; @@ -264,17 +266,18 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] return parts; } -function jsonError(status: number, message: string): Response { - return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { +function jsonError(status: number, message: string, event?: Extract): Response { + return new Response(JSON.stringify({ error: { message, type: event?.errorType ?? "upstream_error", code: event?.code ?? null, + ...(event?.retryable === false ? { retryable: false } : {}) } }), { status, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...(event?.retryable === false ? { "x-should-retry": "false" } : {}) }, }); } /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a * non-2xx jsonError; later (already-streaming) iterations surface it as an in-stream error event. */ class LoopError extends Error { - constructor(readonly status: number, message: string) { + constructor(readonly status: number, message: string, readonly event?: Extract) { super(message); this.name = "LoopError"; } @@ -285,6 +288,7 @@ class LoopError extends Error { * metadata, and the optional image/video bridge plans. */ export interface ImageBridgeDeps { + thinkingRecovery?: ReturnType; parsed: OcxParsedRequest; adapter: ProviderAdapter; incomingMeta: IncomingMeta; @@ -314,7 +318,7 @@ export interface ImageBridgeDeps { /** Provider-specific fetch (e.g. xAI transport wrapper). Falls back to global fetch. */ fetchImpl?: typeof globalThis.fetch; /** Bind physical dispatch to this iteration's built request; pacing remains owned by the loop. */ - fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter) => typeof globalThis.fetch; + fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => typeof globalThis.fetch; /** Reserve the routed provider's next request-start slot before each adapter dispatch. */ waitForRequestSlot?: (signal?: AbortSignal) => Promise; /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ @@ -445,6 +449,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise Promise; /** runTurn events are already bounded; consume them without replaying or charging twice. */ collectedEvents?: AdapterEvent[]; } @@ -614,7 +620,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise {}); } catch { /* closed */ } + cachedRequest = undefined; + prepared = await fetchOnce(adapter, "opaque-blob-rejection"); + } // Final headers have arrived. Clear only the deadline timer before ANY body read. headerDeadline.clear(); if (!prepared.response.ok) { + if (deps.thinkingRecovery && prepared.responseAdapter.name === "anthropic") { + const event = await deps.thinkingRecovery.projectHttpFailure(prepared.response, signal); + throw new LoopError(event.status ?? prepared.response.status, event.message, event); + } let body: Awaited>; try { body = await readBoundedResponseBody(prepared.response, { signal }); @@ -738,9 +754,19 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + cachedRequest = undefined; + headerDeadline = clearableDeadline(connectTimeoutMs, signal); + try { return await fetchOnce(adapter, "opaque-blob-rejection"); } + catch (error) { + if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); + if (headerDeadline.didExpire()) throw new LoopError(504, + `Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`); + throw error; + } finally { headerDeadline.clear(); deps.onIterationEnd?.(); } + } }; } catch (error) { - if (isTranslatorBudgetExceededError(error)) throw error; + if (isTranslatorBudgetExceededError(error) || error instanceof SendBudgetExhaustedError) throw error; if (headerDeadline.didExpire()) { throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`); } @@ -767,12 +793,30 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + if (!current.response.ok && deps.thinkingRecovery) { + yield await deps.thinkingRecovery.projectHttpFailure(current.response, signal); + return; + } + try { + yield* parseStreamWithProgress(current.response, current.responseAdapter.parseStream.bind(current.responseAdapter), { + signal, inactivityTimeoutMs: stallTimeoutMs, translatorBudget, + preserveAdapterError: deps.thinkingRecovery !== undefined, + }); + } catch (error) { + if (!(error instanceof WebSearchAdapterEventError)) throw error; + if (!error.replaySafe) yield { type: "heartbeat", replayUnsafe: true }; + yield isNonReplayableResponse(current.response) + ? { ...error.event, message: "Provider stream error: upstream diagnostic withheld for a non-replayable failure", retryable: false } + : error.event; + } + }; + const source = deps.thinkingRecovery && prepared.retryThinking + ? deps.thinkingRecovery.wrap(parse(prepared), async function* () { + yield* parse(await prepared.retryThinking!()); + }, () => prepared.responseAdapter.name, !isNonReplayableResponse(prepared.response), prepared.response, prepared.parsed) + : parse(prepared); + for await (const event of source) { if (event.type === "heartbeat") yield event; else { iterationBudget.retain(event); @@ -786,6 +830,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise, ) { const { options, logCtx, config } = requestContext; @@ -159,6 +162,10 @@ export function createAdapterContinuations( errorType: MODEL_NOT_ALLOWED_FOR_KEY, code: MODEL_NOT_ALLOWED_FOR_KEY, retryable: false, }; } + if (refusal instanceof SendBudgetExhaustedError) return { + type: "error", status: 429, errorType: "rate_limit_error", code: refusal.code, + message: refusal.message, retryable: false, + }; return { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}`, @@ -309,6 +316,14 @@ export function createAdapterContinuations( const recoveryKind = nextContinuationRecoveryKind; nextContinuationRecoveryKind = undefined; response = await fetchContinuation(recoveryKind); + bindAnthropicRejectionResponse(transportState.sameTargetRequest, response); + if (initialRecoveryKind === "empty-completion" && await adapterExchange.thinkingSignatureRecovery.prepareHttp( + response, transportState.activeAdapter.name, nextParsed, + )) { + try { void response.body?.cancel().catch(() => {}); } catch { /* closed */ } + invalidateSameTargetRequest(); + response = await fetchContinuation("opaque-blob-rejection"); + } if (kiroRefusalPendingReplay) { try { void kiroRefusalPendingReplay.body?.cancel().catch(() => {}); } catch { /* already closed */ } kiroRefusalPendingReplay = undefined; @@ -693,24 +708,25 @@ export function createAdapterContinuations( // listener that can cancel its reader even while the parser holds the body lock. // The shared signal is not aborted on timeout: this generator must still emit // the error that the already-live Responses bridge turns into a terminal. - if (nextParsed.stream) { - yield* readResponseStreamWithInactivity( - response, - upstream.signal, - bodyInactivityMs, - guarded => transportState.activeAdapter.parseStream(guarded, translatorBudget, logCtx.activeTierMetadata), - ); - } else if (transportState.activeAdapter.parseResponse) { - yield* await readResponseBodyWithInactivity( - response, - upstream.signal, - bodyInactivityMs, - guarded => transportState.activeAdapter.parseResponse!(guarded, translatorBudget, logCtx.activeTierMetadata), - ); - } else { - try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } - yield { type: "error", message: "Provider continuation does not support response parsing" }; - } + const parse = async function* (current: Response): AsyncGenerator { + if (!current.ok) { + yield await adapterExchange.thinkingSignatureRecovery.projectHttpFailure(current, upstream.signal); + } else if (nextParsed.stream) { + yield* readResponseStreamWithInactivity(current, upstream.signal, bodyInactivityMs, + guarded => transportState.activeAdapter.parseStream(guarded, translatorBudget, logCtx.activeTierMetadata)); + } else if (transportState.activeAdapter.parseResponse) { + yield* await readResponseBodyWithInactivity(current, upstream.signal, bodyInactivityMs, + guarded => transportState.activeAdapter.parseResponse!(guarded, translatorBudget, logCtx.activeTierMetadata)); + } else { + try { void current.body?.cancel().catch(() => {}); } catch { /* closed */ } + yield { type: "error", message: "Provider continuation does not support response parsing" }; + } + }; + yield* adapterExchange.thinkingSignatureRecovery.wrap(parse(response), async function* () { + invalidateSameTargetRequest(); + yield* parse(await fetchContinuation("opaque-blob-rejection")); + }, () => transportState.activeAdapter.name, + initialRecoveryKind === "empty-completion" && !isNonReplayableResponse(response), response, nextParsed); } catch (error) { // Classify the thrown error before reading signal state: cancelling the stalled // source can synchronously abort the shared signal, which would otherwise report diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 14efb4597a9..0bd83dec879 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -21,7 +21,7 @@ import { runWithImageBridge, clampImageMaxRounds, } from "../../images"; -import type { ProviderAdapter } from "../../adapters/base"; +import type { AdapterRequest, ProviderAdapter } from "../../adapters/base"; import type { OcxParsedRequest } from "../../types"; import { rotateProviderTransportOn429, rateLimitRetryPolicyFor } from "../../providers/key-failover"; import { @@ -378,7 +378,7 @@ export async function executeResponsesSidecars( recoveryKind = "oauth-account-429"; if (!options.comboInitialSend && canRunWebSearch && route.providerName === "google-antigravity") sendBudgetState.pendingHopPermit = hop.permit; - else sidecarBudget.ownCredentialHop(hop.permit, canRunWebSearch && transportState.adapter.name === "anthropic"); + else sidecarBudget.ownCredentialHop(hop.permit, (canRunWebSearch || !!imgPlan || !!vidPlan) && transportState.adapter.name === "anthropic"); } else if ( // Anthropic's pool is excluded from generic failover, so without this arm a 429 inside a // web-search or image-bridge turn was terminal even with the pool fully enabled -- while @@ -419,7 +419,7 @@ export async function executeResponsesSidecars( return null; } recoveryKind = originalResponse?.status === 401 ? "oauth-401" : "anthropic-oauth-429"; - sidecarBudget.ownCredentialHop(hop.permit, canRunWebSearch && transportState.adapter.name === "anthropic"); + sidecarBudget.ownCredentialHop(hop.permit, (canRunWebSearch || !!imgPlan || !!vidPlan) && transportState.adapter.name === "anthropic"); } else { // No key pool, no generic OAuth roster, no Anthropic pool could produce a replacement // credential. The 429 is terminal for this sidecar turn. @@ -452,6 +452,35 @@ export async function executeResponsesSidecars( }); return { adapter: rotatedAdapter, recoveryKind }; }; + // One guard follows the active sidecar across all iterations. Enforced spend has no + // authoritative receipt for earlier legacy sends, so recovery cannot claim headroom there. + const thinkingRecovery = createAnthropicThinkingRecovery(parsed, + options.abortSignal ?? new AbortController().signal, { attempted: false }, + () => !sendBudgetState.sendBudgetExhausted() + && !!sendBudgetState.adapterDispatchBudget + && sendBudgetState.adapterDispatchBudget.spendEnforced !== true); + const thinkingReceipt = (request: AdapterRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => + adapter.name !== "anthropic" ? {} : { onPhysicalDispatch: () => { + if (recovery === "opaque-blob-rejection") { + const decision = sendBudgetState.adapterDispatchBudget?.reserveDispatch({ + sendClass: "repair", targetKey: `${route.providerName}|${request.url}|${route.modelId}`, + }); + if (sendBudgetState.adapterDispatchBudget?.spendEnforced || !decision?.allowed) { + if (decision?.allowed) decision.permit.release(); + throw new SendBudgetExhaustedError("sidecar thinking recovery"); + } + try { sendBudgetState.noteInitialDispatch(decision.permit); } + finally { decision.permit.release(); } + } else { + const existing = sidecarBudget.fetchOptions(adapter).onPhysicalDispatch; + if (existing) existing(); + else { + const prepaid = sendBudgetState.pendingHopPermit; + sendBudgetState.pendingHopPermit = undefined; + sendBudgetState.noteInitialDispatch(prepaid); + } + } + } }; if ((imgPlan || vidPlan) && wsPlan) { // Web search takes priority when both are active — the media bridge cannot run // alongside runWithWebSearch. Surface a runtime signal so the user knows their @@ -509,6 +538,7 @@ export async function executeResponsesSidecars( ); const imgResponse = await runWithImageBridge({ parsed, adapter: transportState.adapter, + ...(transportState.adapter.name === "anthropic" ? { thinkingRecovery } : {}), incomingMeta: { headers: requestState.selectedForwardHeaders, providerName: route.providerName, abortSignal: options.abortSignal, translatorBudget, ...(options.comboInitialSend ? { providerFetch: imageProviderFetch } : {}), ...sidecarBudget.incomingMeta }, onProducerStart: sidecarBudget.takeProducerOwnership, onProducerEnd: sidecarBudget.release, @@ -528,10 +558,11 @@ export async function executeResponsesSidecars( stallTimeoutSec: config.stallTimeoutSec, waitForRequestSlot: imageProviderFetch.waitForPacing, fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch, - fetchForRequest: (request, iterParsed, adapter) => { + 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), + ...thinkingReceipt(request, adapter, recovery), }); return fetch.unpacedFetch ?? fetch; }, @@ -555,6 +586,7 @@ export async function executeResponsesSidecars( onFirstOutput: noteSidecarOutput, ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}), onCompletedResponse: (response, providerState) => { + if (!options.abortSignal?.aborted) thinkingRecovery.rememberSuccess(response); const served = transportState.replayOAuthCredentialSnapshot; if (route.providerName === "kiro" && response.status === "completed" && served && noteKiroServedSuccess(served.accountId, served.generation)) persistKiroAccountState(); @@ -593,13 +625,6 @@ 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) { - // The legacy direct-search lane has no authoritative receipt for earlier sends under - // enforced spend. Fail closed for recovery there instead of granting unproven headroom. - const thinkingRecovery = createAnthropicThinkingRecovery(parsed, - options.abortSignal ?? new AbortController().signal, { attempted: false }, - () => !sendBudgetState.sendBudgetExhausted() - && !!sendBudgetState.adapterDispatchBudget - && sendBudgetState.adapterDispatchBudget.spendEnforced !== true); 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. @@ -616,25 +641,7 @@ export async function executeResponsesSidecars( fetchForRequest: (request, iterParsed, adapter, recovery) => providerFetch(route.provider, options.codexWsRuntimeIdentity, { dispatchOverride: oauthDispatch(request, iterParsed), providerName: route.providerName, modelId: route.modelId, ...sidecarBudget.fetchOptions(adapter), - ...(adapter.name === "anthropic" ? { onPhysicalDispatch: () => { - if (recovery === "opaque-blob-rejection") { - if (sendBudgetState.adapterDispatchBudget?.spendEnforced) throw new SendBudgetExhaustedError("web-search thinking recovery"); - const decision = sendBudgetState.adapterDispatchBudget?.reserveDispatch({ - sendClass: "repair", targetKey: `${route.providerName}|${request.url}|${route.modelId}`, - }); - if (!decision?.allowed) throw new SendBudgetExhaustedError("web-search thinking recovery"); - try { sendBudgetState.noteInitialDispatch(decision.permit); } - finally { decision.permit.release(); } - } else { - const existing = sidecarBudget.fetchOptions(adapter).onPhysicalDispatch; - if (existing) existing(); - else { - const prepaid = sendBudgetState.pendingHopPermit; - sendBudgetState.pendingHopPermit = undefined; - sendBudgetState.noteInitialDispatch(prepaid); - } - } - } } : {}), + ...thinkingReceipt(request, adapter, recovery), }), incomingMeta: { headers: requestState.selectedForwardHeaders, diff --git a/tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts b/tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts index f0504412a53..d80477ea2f7 100644 --- a/tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts +++ b/tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts @@ -71,14 +71,14 @@ beforeAll(async () => { parsed: OcxParsedRequest; adapter: ProviderAdapter; incomingMeta: IncomingMeta; - fetchForRequest: (request: AdapterRequest, parsed: OcxParsedRequest) => typeof fetch; + fetchForRequest: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter) => typeof fetch; on429?: (retryAfter: string | null, headers: Headers, parsed: OcxParsedRequest, response: Response) => Promise< { adapter: ProviderAdapter; recoveryKind: AttemptRecoveryKind } | null >; }) => { // This is a dispatch seam test. The real loop is covered in anthropic-quota-dispatch. const first = await args.adapter.buildRequest(args.parsed, args.incomingMeta); - const refused = await args.fetchForRequest(first, args.parsed)(first.url, { + const refused = await args.fetchForRequest(first, args.parsed, args.adapter)(first.url, { method: first.method, headers: first.headers, body: first.body, }); expect(refused.status).toBe(429); @@ -91,7 +91,7 @@ beforeAll(async () => { // proven end to end rather than against a hand-written stub. expect(rotated.recoveryKind).toBe("anthropic-oauth-429"); const second = await rotated.adapter.buildRequest(args.parsed, args.incomingMeta); - return args.fetchForRequest(second, args.parsed)(second.url, { + return args.fetchForRequest(second, args.parsed, rotated.adapter)(second.url, { method: second.method, headers: second.headers, body: second.body, }); }, diff --git a/tests/providers/umans-provider.test.ts b/tests/providers/umans-provider.test.ts index 755b50570a6..69a0062369d 100644 --- a/tests/providers/umans-provider.test.ts +++ b/tests/providers/umans-provider.test.ts @@ -201,9 +201,13 @@ describe("Umans provider", () => { const events = await collect(createAnthropicAdapter(umansProvider()).parseStream(new Response(stream))); - expect(events[0]).toEqual({ type: "tool_call_start", id: "toolu_1", name: "web_search" }); - expect(events[1]).toEqual({ type: "tool_call_delta", arguments: "{\"query\":\"umans\"}" }); - expect(events[2]).toEqual({ type: "tool_call_end" }); + expect(events).toEqual([ + { type: "heartbeat", replayUnsafe: true }, + { type: "tool_call_start", id: "toolu_1", name: "web_search" }, + { type: "tool_call_delta", arguments: "{\"query\":\"umans\"}" }, + { type: "tool_call_end" }, + { type: "done", usage: undefined, stopReason: undefined }, + ]); }); test("Anthropic adapter strips Umans compatibility prefix from non-streaming tool calls", async () => { diff --git a/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts index 3e08c033c96..494a9d6763d 100644 --- a/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts @@ -441,3 +441,140 @@ describe("Anthropic thinking-signature recovery through Responses dispatch", () expect(bodies[2]).toContain(SIGNATURE); }); }); + +async function mediaRequest(options: Parameters[3] = {}) { + const source = request(true); + const body = await source.json() as Record; + body.tools = [{ type: "image_generation" }]; + const cfg = config(); + cfg.images = { bridgeEnabled: true }; + cfg.providers.xai = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "synthetic-image-key", proxy: "direct" }; + const log: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(new Request(source.url, { + method: "POST", headers: source.headers, body: JSON.stringify(body), + }), cfg, log, options); + return { response, text: await response.text(), log }; +} + +for (const rawSse of [false, true]) { + test(`media bridge repairs only the rejected inference (initial SSE=${rawSse})`, async () => { + const bodies = fakeUpstream(index => index === 1 ? rawSse ? streamRejection(frame("ping")) : rejection() : success(true)); + const result = await mediaRequest(); + expect(result.text).toContain("Recovered answer"); + expectStrippedRetry(bodies); + expect(result.log.activeAttempt?.sendCount).toBe(2); + }); + test(`empty-completion retry shares the original recovery allowance (SSE=${rawSse})`, async () => { + const bodies = fakeUpstream(index => index === 1 ? success(true, "") + : index === 2 ? rawSse ? streamRejection() : rejection() : success(true)); + const cfg = config(); cfg.emptyCompletionRetry = true; + const response = await handleResponses(request(true), cfg, { model: "", provider: "" }); + expect(await response.text()).toContain("Recovered answer"); + expect(bodies).toHaveLength(3); + expect(bodies[1]).toContain(SIGNATURE); + expect(bodies[2]).not.toContain(SIGNATURE); + }); +} + +test("media repair never repeats an already executed image tool", async () => { + const bodies: string[] = []; + let imageCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://api.x.ai/v1/images/generations") { + imageCalls++; + return Response.json({ error: { message: "Synthetic image refusal" } }, { status: 400 }); + } + if (url !== "https://anthropic.example.test/v1/messages") throw new Error("Unexpected test network target"); + bodies.push(String(init?.body)); + if (bodies.length === 1) return sse( + frame("message_start", { message: { usage: { input_tokens: 2 } } }), + frame("content_block_start", { index: 0, content_block: { type: "tool_use", id: "image_fixture", name: "image_gen", input: {} } }), + frame("content_block_delta", { index: 0, delta: { type: "input_json_delta", partial_json: '{"prompt":"fixture"}' } }), + frame("content_block_stop", { index: 0 }), + frame("message_delta", { delta: { stop_reason: "tool_use" } }), frame("message_stop")); + return bodies.length === 2 ? streamRejection() : success(true); + }) as typeof fetch; + const result = await mediaRequest(); + expect(result.text).toContain("Recovered answer"); + expect(imageCalls).toBe(1); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain("image_fixture"); + expect(bodies[2]).toContain("xAI images API returned 400"); + expect(bodies[2]).not.toContain(SIGNATURE); +}); + +for (const prefix of [textFrame("visible"), frame("content_block_start", { + index: 0, content_block: { type: "server_tool_use", id: "effect_fixture", name: "unmodeled" }, +})]) test("media semantic progress fences signature recovery", async () => { + const bodies = fakeUpstream(() => streamRejection(prefix)); + const result = await mediaRequest(); + expect(result.text).toContain("response.failed"); + expect(bodies).toHaveLength(1); +}); + +test("selective recovery retains earlier rejection memos while accepting new thinking", async () => { + const fresh = "SyntheticNewSignature123456=="; + const newest = "SyntheticNewestSignature123456=="; + const bodies = fakeUpstream(index => index === 1 || index === 3 ? rejection() : success(false)); + await send(false); + const withFresh = async (signatures: string[]) => { + const source = request(false); + const body = await source.json() as { input: unknown[] }; + body.input.splice(body.input.length - 1, 0, ...signatures.map(sig => ({ + type: "reasoning", encrypted_content: encodeReasoningEnvelope({ sig }), + }))); + return handleResponses(new Request(source.url, { method: "POST", headers: source.headers, body: JSON.stringify(body) }), + config(), { model: "", provider: "" }); + }; + const second = await withFresh([fresh]); + expect(await second.text()).toContain("Recovered answer"); + expect(bodies[2]).not.toContain(SIGNATURE); + expect(bodies[2]).toContain(fresh); + expect(bodies[3]).not.toContain(SIGNATURE); + expect(bodies[3]).not.toContain(fresh); + const third = await withFresh([fresh, newest]); + await third.text(); + expect(bodies).toHaveLength(5); + expect(bodies[4]).not.toContain(SIGNATURE); + expect(bodies[4]).not.toContain(fresh); + expect(bodies[4]).toContain(newest); +}); + +test("a memo that already removed every opaque block cannot authorize an identical resend", async () => { + const bodies = fakeUpstream(index => index === 2 ? success(false) : rejection()); + await send(false); + const result = await send(false); + expect(result.response.status).toBe(400); + expect(bodies).toHaveLength(3); + expect(bodies[2]).not.toContain(SIGNATURE); +}); + +for (const rawSse of [false, true]) test(`media replacement retains structured refusal (SSE=${rawSse})`, async () => { + const bodies = fakeUpstream(index => index === 1 ? rawSse ? streamRejection() : rejection() + : Response.json({ error: { type: "permission_error", code: "fixture_denied", message: "Synthetic refusal" } }, { status: 403 })); + const result = await mediaRequest(); + if (rawSse) expect(streamedFailure(result.text).error).toMatchObject({ type: "permission_error", code: "fixture_denied" }); + else { + expect(result.response.status).toBe(403); + expect(JSON.parse(result.text).error).toMatchObject({ type: "permission_error", code: "fixture_denied" }); + } + expect(bodies).toHaveLength(2); +}); + +for (const rawSse of [false, true]) test(`media recovery honors shared exhaustion and cancellation (SSE=${rawSse})`, async () => { + const budget = createRequestExecutionBudget({ maxTotalModelSends: 1, baseSendAllowance: 1, + finalRecoveryAllowance: 0, maxAlternateTargetSends: 0, maxTargetTransitions: 0 }, "media-recovery-budget"); + const bodies = fakeUpstream(() => rawSse ? streamRejection() : rejection()); + await mediaRequest({ sendBudget: budget }); + expect(bodies).toHaveLength(1); + expect(budget.used).toBe(1); + const abort = new AbortController(); + const cancelledBodies = fakeUpstream(() => { + abort.abort(); + return rawSse ? streamRejection() : rejection(); + }); + const result = await mediaRequest({ abortSignal: abort.signal }); + expect(result.text).not.toContain("Recovered answer"); + expect(cancelledBodies).toHaveLength(1); +}); From a62ff0570048a468442fbccc537ff9e129d5d6db Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:39:35 +0900 Subject: [PATCH 6/9] docs(anthropic): document bounded thinking signature recovery Describe serialized-payload memos, sidecar and empty-retry boundaries, and upstream-owned legal history edits. Co-authored-by: lcxhh --- .../src/content/docs/guides/providers.md | 14 ++++---- structure/INDEX.md | 4 +++ structure/data-planes/images.md | 2 ++ structure/manifest.json | 6 +++- structure/providers-and-adapters.md | 2 +- structure/runtime.md | 2 +- .../transports/anthropic-thinking-recovery.md | 32 ++++++++++++++++--- 7 files changed, 48 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e0b10b2cf28..d01ef6b0787 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -82,16 +82,18 @@ On the Responses endpoint, an Anthropic-compatible upstream can reject a replaye signature because it belongs to a different conversation prefix. When the error explicitly identifies this signature-binding problem, opencodex can remove replayed signed/redacted thinking and retry once within the request's send budget. This covers HTTP 400 refusals and initial stream -errors before output or tool activity. Hosted web search uses the same one-recovery limit for -all model iterations: only the rejected iteration is resent, while completed searches and tool -results are retained. The strip policy continues into later iterations. A failed replacement is +errors before output or tool activity. Hosted web search and image/video bridges share one recovery +limit across their model iterations: only the rejected inference is resent, while completed sidecar +calls and tool results are retained. An empty-completion retry shares its request's allowance; +ordinary continuations after output cannot use it. A failed replacement is returned without another signature retry. Other errors keep their existing handling. -Search recovery is disabled when enforced spend accounting cannot establish receipts for earlier -ordinary search sends. It never increases a caller or Combo send allowance. +Sidecar recovery is disabled when enforced spend accounting cannot establish receipts for earlier +ordinary sidecar sends. It never increases a caller or Combo send allowance. Only a successful, nonempty completion remembers the rejection for that conversation and serving -identity for five minutes. Plaintext history remains available; a failed or cancelled retry does +identity for five minutes. Only the refused opaque payloads are suppressed when they can be +identified; newly issued thinking remains replayable. Plaintext history remains available; a failed or cancelled retry does not disable thinking replay on later requests. ## Auth modes diff --git a/structure/INDEX.md b/structure/INDEX.md index ef17d12c64e..42780e3cc98 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -142,6 +142,9 @@ A source area can be described by more than one doc, because these docs are orga | `scripts/generate-ocx-skill-surface.ts` | [`cli-management.md`](cli-management.md) | | `skills/ocx/` | [`cli-management.md`](cli-management.md) | | `src/adapters/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.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/cursor.md`](providers/cursor.md)
[`providers/chat-compat.md`](providers/chat-compat.md)
[`adapters/registry.md`](adapters/registry.md) | +| `src/adapters/anthropic-rejection-memo.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | +| `src/adapters/anthropic-replay.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | +| `src/adapters/anthropic-tool-catalog.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | | `src/advisor/` | [`advisor.md`](advisor.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) | @@ -192,6 +195,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/responses-wire-shapes.md`](transports/responses-wire-shapes.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/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) | | `src/routing/history/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index b1f578757a9..712c75940c6 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -22,6 +22,8 @@ Codex Spark exception; standalone Images retain the separate relay contract belo Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses-wire-shapes.md#passthrough-sse-stream-shapes-314). +Fetch-based Anthropic model iterations use the [thinking-signature recovery contract](../transports/anthropic-thinking-recovery.md#imagevideo-model-iterations), without repeating completed media calls. + Fetch-based Anthropic image bridge account recovery follows [the revoked-token contract](../providers/anthropic-account-pool.md#revoked-oauth-access-token-recovery), including output commitment and send-budget gates. ## Hosted Responses image display diff --git a/structure/manifest.json b/structure/manifest.json index 848b6b40d1a..b27edb97efc 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -221,7 +221,11 @@ "title": "Anthropic Thinking-Signature Recovery", "scope": "One-shot exact signature-binding recovery before output, and successful-retry memo boundaries.", "documents": [ - "src/server/responses/anthropic-thinking-recovery.ts" + "src/server/responses/anthropic-thinking-recovery.ts", + "src/adapters/anthropic-rejection-memo.ts", + "src/adapters/anthropic-replay.ts", + "src/adapters/anthropic-tool-catalog.ts", + "src/responses/reasoning-replay-cache.ts" ] }, { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index c522806f375..ad1993df17a 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -55,7 +55,7 @@ Direct MCP names emitted in a verified custom code-mode catalog follow the [Responses restoration boundary](transports/responses-wire-shapes.md#direct-mcp-calls-in-code-mode). Ordinary structured functions named `exec` do not opt into this compatibility path. -RunTurn hosted search uses `src/web-search/run-turn-loop.ts`: synthetic calls remain private, progress reaches the bridge during collection, and a validated terminal precedes search execution. Complete search calls remain actionable at a truncated `done`; cancellation prevents subsequent queries and calls. OAuth preflight replay in `src/server/responses/run-turn-execution.ts` retains the synthetic tool while refreshing credential-scoped route state. In `src/server/responses/sidecar-execution.ts`, a search plan takes priority over image/video bridge execution for both transports; only fetch-capable adapters enter the fetch search loop. Its Antigravity structured-403 rotation and physical-send accounting follow [Account refusal and rotation boundaries](transports/responses-failover.md#account-refusal-and-rotation-boundaries). +RunTurn hosted search uses `src/web-search/run-turn-loop.ts`: synthetic calls remain private, progress reaches the bridge during collection, and a validated terminal precedes search execution. Complete search calls remain actionable at a truncated `done`; cancellation prevents subsequent queries and calls. OAuth preflight replay in `src/server/responses/run-turn-execution.ts` retains the synthetic tool while refreshing credential-scoped route state. In `src/server/responses/sidecar-execution.ts`, a search plan takes priority over image/video bridge execution for both transports; only fetch-capable adapters enter the fetch search loop. Anthropic replay and signature repair follow the [thinking recovery contract](transports/anthropic-thinking-recovery.md), with no local prefix validator or beta-header changes. Its Antigravity structured-403 rotation and physical-send accounting follow [Account refusal and rotation boundaries](transports/responses-failover.md#account-refusal-and-rotation-boundaries). Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools. diff --git a/structure/runtime.md b/structure/runtime.md index 8b5aa8d9d5b..135ef02fb4e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -398,7 +398,7 @@ Automatic Codex pool selection and account status share the [plan exclusion cont ### Empty forced search answers -Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools. `src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call, only while an iteration remains. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. `src/web-search/run-turn-loop.ts` shares this recovery and checks the ceiling before dispatch so it cannot start an unused request; below the search cap, `emptyCompletionRetry` permits one identical empty-answer retry per request with current tools and results. Errors and invalid terminals never authorize another search. Batched results from one call share one 8,000-character budget in `src/web-search/format-result.ts`, planned per query before serialization (#6621): every query keeps its label and status, shortened answers and unlisted sources carry markers with their original counts outside the untrusted boundary, structured output stays one valid JSON document, and a batch whose fixed parts alone overflow is condensed to status lines with a per-status count of any query that still did not fit. +Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone disables tool calls; Anthropic keeps its declared catalog stable. `src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call, only while an iteration remains. The recovery disables tool calls and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage. `src/web-search/run-turn-loop.ts` shares this recovery and checks the ceiling before dispatch so it cannot start an unused request; below the search cap, `emptyCompletionRetry` permits one identical empty-answer retry per request with current tools and results. Errors and invalid terminals never authorize another search. Batched results from one call share one 8,000-character budget in `src/web-search/format-result.ts`, planned per query before serialization (#6621): every query keeps its label and status, shortened answers and unlisted sources carry markers with their original counts outside the untrusted boundary, structured output stays one valid JSON document, and a batch whose fixed parts alone overflow is condensed to status lines with a per-status count of any query that still did not fit. Anthropic hosted-search model iterations use the [request-scoped thinking-signature recovery policy](transports/anthropic-thinking-recovery.md), retaining completed searches and tool results. `src/web-search/loop.ts` uses `src/web-search/header-deadline.ts` to suspend the cumulative response-header deadline while each physical send awaits local pacing admission. Ordinary fetches, adapter executors, reset retries and pool-key rotations retain the remaining budget after admission; same-target 429 backoff still clears and restarts that budget. The default remains 200,000 ms. Client cancellation remains linked during queued admission and after final headers, and the existing pacing slot wrapper retains leases through body completion or cancellation. Streaming retry heartbeats and body inactivity deadlines keep their existing ownership. `tests/web-search/web-search-pacing-deadline.test.ts` covers queued success, real header stalls, queued cancellation, retry admission and cumulative rotation time. OpenAI sidecar 429 replays run only when their backoff fits the remaining sidecar deadline; otherwise the original 429 remains the routing-health outcome rather than becoming a timeout. Reset recovery and 429 replays share one three-send budget per search, so the two layers cannot multiply physical sends. Direct vision/search helpers and the alpha/search relay retain the resolver's live [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) through physical sends and retries; a local hold prevents the send without becoming an upstream connection failure. ## Scoped provider quota for Combo selection diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md index 4a135cf9b88..5538d1971e1 100644 --- a/structure/transports/anthropic-thinking-recovery.md +++ b/structure/transports/anthropic-thinking-recovery.md @@ -13,15 +13,23 @@ same opaque-recovery guard and the existing request send budget, pacing and rebu Only harmless heartbeats may precede a streamed recovery: text, thinking, boundaries, tool activity and replay-unsafe heartbeats fence replay. The rejected iterator closes before refetch. -Recovery uses the existing opaque-state strip/drop flags and leaves plaintext history intact. +Recovery records only digests of the opaque blocks actually serialized by the adapter, using +`src/adapters/anthropic-rejection-memo.ts`. The request-local policy removes those blocks and +leaves plaintext history and newly issued thinking intact. If bounded collection is unavailable, recovery is withheld rather than risking a byte-identical resend. A replacement HTTP refusal retains its status, safe error message/type and code; its body is not parsed as SSE. Cancellation, budget exhaustion and a second rejection cannot trigger another -signature recovery. No Anthropic `runTurn` or continuation recovery is introduced. +signature recovery. Empty-completion retries in `src/server/responses/adapter-continuation.ts` share this guard and +rebuild through their existing pacing/send owner. Ordinary continuations after output cannot +recover signatures. Anthropic has no `runTurn` recovery. 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 -durable-serving-identity key and five-minute TTL are unchanged. +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` +binds a memo only for the current serving scope; a serving-identity change retains its independent +blanket strip. No prefix hashes or local prefix validator participate in replay admission. `tests/responses/responses-anthropic-thinking-signature-recovery.test.ts` exercises the actual Anthropic adapter with synthetic HTTP/SSE; `tests/responses/responses-anthropic-thinking-recovery.test.ts` @@ -35,8 +43,8 @@ that block, so a genuine empty thinking-block start remains a separate signed bl `src/server/responses/sidecar-execution.ts` supplies the same request-owned policy to `src/web-search/loop.ts`. Only the current routed-model iteration is rebuilt; completed searches, -tool results, and history remain intact. Request caches are invalidated and the root strip flags -persist into later iterations. HTTP 400 classification runs after existing 429/key recovery. +tool results, and history remain intact. Request caches are invalidated and the rejection policy +persists into later iterations. HTTP 400 classification runs after existing 429/key recovery. `src/web-search/progress-stream.ts` has an opt-in typed adapter-error handoff before its normal exception conversion, with a per-send semantic/replay-unsafe fence and original-body cleanup. A streamed replacement gets a fresh header deadline; an HTTP refusal retains its current deadline. @@ -86,3 +94,17 @@ existing empty-answer policy. Hosted search disabled by the incoming choice rema indices, bounded admission, forged/expired snapshot rejection and idempotent budget release. Nonconforming or repeated tool IDs fail before search execution, including IDs already present in the request-local history, rather than waiting for the next serializer to reject normalization. + +## Image/video model iterations + +`src/images/loop.ts` uses the same request-scoped recovery supplied by +`src/server/responses/sidecar-execution.ts`. Both HTTP 400 and initial stream refusals rebuild +only the current model send. Completed media calls and their tool results remain in loop history; +recovery cannot repeat a media fulfillment. The loop keeps its pacing slot and fresh replacement +header deadline. Replacement failures retain status/type/code and release unused permits. +As in search, enforced spend without authoritative earlier-send receipts withholds recovery. + +`tests/responses/responses-anthropic-thinking-signature-recovery.test.ts` covers media repair, +completed tool retention, progress fences, and empty-completion recovery. Legal oldest-thinking +removal, changed system prompts on Sonnet 4.5 tool continuations, and rotating image URLs remain +upstream decisions, covered by `tests/responses/responses-anthropic-legal-history.test.ts`. From e64730e99c07fd203efc347c55b6b9fd8635c07c Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:47:57 +0900 Subject: [PATCH 7/9] docs(anthropic): correct empty-answer tool catalog contract --- structure/providers-and-adapters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ad1993df17a..c5e12aa1660 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -57,7 +57,7 @@ Ordinary structured functions named `exec` do not opt into this compatibility pa RunTurn hosted search uses `src/web-search/run-turn-loop.ts`: synthetic calls remain private, progress reaches the bridge during collection, and a validated terminal precedes search execution. Complete search calls remain actionable at a truncated `done`; cancellation prevents subsequent queries and calls. OAuth preflight replay in `src/server/responses/run-turn-execution.ts` retains the synthetic tool while refreshing credential-scoped route state. In `src/server/responses/sidecar-execution.ts`, a search plan takes priority over image/video bridge execution for both transports; only fetch-capable adapters enter the fetch search loop. Anthropic replay and signature repair follow the [thinking recovery contract](transports/anthropic-thinking-recovery.md), with no local prefix validator or beta-header changes. Its Antigravity structured-403 rotation and physical-send accounting follow [Account refusal and rotation boundaries](transports/responses-failover.md#account-refusal-and-rotation-boundaries). -Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools. +Both search loops keep `web_search` declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most `maxSearches + 3` model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery disables tool calls; Anthropic retains its declared catalog to preserve the bound prefix. Combo preflight allows the private search tool only while a search plan is active; client tool declaration checks and replay-unsafe heartbeat protection remain enforced. From a71aa0583461e6a7862e086e50b0ffeafcfa3491 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 18:17:53 -0700 Subject: [PATCH 8/9] test(responses): register Anthropic thinking recovery module --- tests/helpers/responses-core-source.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 516521ecf48..968416117a7 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -62,6 +62,7 @@ export const RESPONSES_CORE_MODULES = [ "antigravity-validation-refusal.ts", "adapter-continuation.ts", "adapter-delivery.ts", + "anthropic-thinking-recovery.ts", "advisor-slot.ts", "advisor-plan-slot.ts", "policy-refusal.ts", From f7a60f0f2fa39418c5900a964d1b74de3f42ae05 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 10:53:43 +0900 Subject: [PATCH 9/9] fix(anthropic): contain replay capture pressure and media budget refusals --- src/adapters/anthropic-replay.ts | 22 ++++++--- src/images/loop.ts | 4 +- .../transports/anthropic-thinking-recovery.md | 5 ++ .../anthropic/anthropic-replay.test.ts | 48 ++++++++++++++++++- ...hropic-thinking-signature-recovery.test.ts | 27 +++++++++++ 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/src/adapters/anthropic-replay.ts b/src/adapters/anthropic-replay.ts index 4f41dbf4724..e91f933d654 100644 --- a/src/adapters/anthropic-replay.ts +++ b/src/adapters/anthropic-replay.ts @@ -1,6 +1,6 @@ import { isConformingToolCallId } from "./tool-call-id"; import type { AdapterEvent, OcxAssistantMessage } from "../types"; -import type { TranslatorBudget } from "../lib/translator-budget"; +import { isTranslatorBudgetExceededError, type TranslatorBudget, type TranslatorTransientReservation } from "../lib/translator-budget"; import { jsonUtf8Bytes } from "../lib/json-byte-size"; type Block = Record; @@ -43,8 +43,15 @@ export function createAnthropicReplayCapture(response: Response, budget: Transla const retain = (value: unknown) => { // Charge each frame once, before retaining its values. This conservative linear bound also // covers array/object overhead and replacement signatures without serializing a growing block. - const size = jsonUtf8Bytes(value) * 4 + 64; - budget.chargeRetained(size, { kind: "retained_collectors" }); bytes += size; + try { + const size = jsonUtf8Bytes(value) * 4 + 64; + budget.chargeRetained(size, { kind: "retained_collectors" }); bytes += size; + return true; + } catch (error) { + if (!isTranslatorBudgetExceededError(error)) throw error; + invalidate(); + return false; + } }; return { invalidate, @@ -73,7 +80,7 @@ export function createAnthropicReplayCapture(response: Response, budget: Transla if (!isConformingToolCallId(id) || toolIds.has(id)) { invalidate(); return; } toolIds.add(id); } - retain(block); + if (!retain(block)) return; active = blocks.length; blocks.push({ value: { ...block, ...(Array.isArray(block.citations) ? { citations: [...block.citations] } : {}) }, text: typeof block.text === "string" ? [block.text] : [], thinking: typeof block.thinking === "string" ? [block.thinking] : [], args: [] }); @@ -81,7 +88,7 @@ export function createAnthropicReplayCapture(response: Response, budget: Transla const delta = data.delta as Block | undefined; if (active === undefined || data.index !== active || !delta) { invalidate(); return; } const block = blocks[active]!; - retain(delta); + if (!retain(delta)) return; if (delta.type === "text_delta" && block.value.type === "text" && typeof delta.text === "string") block.text.push(delta.text); else if (delta.type === "thinking_delta" && block.value.type === "thinking" && typeof delta.thinking === "string") block.thinking.push(delta.thinking); else if (delta.type === "signature_delta" && block.value.type === "thinking" && typeof delta.signature === "string") block.value.signature = delta.signature; @@ -99,8 +106,9 @@ export function createAnthropicReplayCapture(response: Response, budget: Transla }, finish(event: AdapterEvent): void { if (!valid || !stopped || active !== undefined || event.type !== "done") { invalidate(); return; } - const reservation = budget.reserveTransient(bytes * 2, { kind: "retained_collectors" }); + let reservation: TranslatorTransientReservation | undefined; try { + reservation = budget.reserveTransient(bytes * 2, { kind: "retained_collectors" }); const content = blocks.map(block => { const value = block.value; if (value.type === "text") value.text = block.text.join(""); @@ -120,7 +128,7 @@ export function createAnthropicReplayCapture(response: Response, budget: Transla blocks.length = 0; toolIds.clear(); transferred = true; } catch { invalidate(); } - finally { reservation.release(); } + finally { reservation?.release(); } }, discard(): void { if (!transferred) invalidate(); }, }; diff --git a/src/images/loop.ts b/src/images/loop.ts index c65940640a6..77ec229f71b 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -790,7 +790,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise bindAnthropicReplay(mutableMessage, snapshot)).toThrow(); expect(() => anthropicReplayContent(mutableMessage)).toThrow(); }); -test("budget overflow releases previously retained frames on discard", () => { +test("budget overflow invalidates optional capture without throwing or refunding other owners", () => { const { budget, capture, cleanup } = start(1_000); + budget.chargeRetained(100, { kind: "retained_collectors" }); expect(() => capture.accept({ type: "content_block_delta", index: 0, - delta: { type: "text_delta", text: "x".repeat(2_000) } })).toThrow(); + delta: { type: "text_delta", text: "x".repeat(2_000) } })).not.toThrow(); + expect(takeAnthropicReplay(complete(capture))).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(100); capture.discard(); cleanup(); + expect(budget.snapshot().currentBytes).toBe(100); + budget.releaseRetained(100, { kind: "retained_collectors" }); expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); +}); + +test("finish reservation pressure invalidates capture and preserves foreign retained bytes", () => { + const { budget, capture, cleanup } = start(1_000); + budget.chargeRetained(500, { kind: "retained_collectors" }); + let done: ReturnType | undefined; + expect(() => { done = complete(capture); }).not.toThrow(); + expect(takeAnthropicReplay(done!)).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(500); + cleanup(); + budget.releaseRetained(500, { kind: "retained_collectors" }); + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); +}); + +test("optional capture pressure does not replace a healthy adapter terminal", async () => { + const budget = createTranslatorBudget({ maxTurnBytes: 6_000 }); + const adapter = createAnthropicAdapter({ adapter: "anthropic", baseUrl: "https://synthetic.invalid", apiKey: "synthetic" }); + const text = "x".repeat(1_000); + const response = new Response([ + { type: "message_start", message: { content: [] } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }, + { type: "content_block_stop", index: 0 }, + { type: "message_stop" }, + ].map(data => `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`).join("")); + const cleanup = enableAnthropicReplayCapture(response); + try { + const events = []; + for await (const event of adapter.parseStream(response, budget)) events.push(event); + expect(events.filter(event => event.type === "error")).toEqual([]); + expect(events.some(event => event.type === "text_delta" && event.text === text)).toBe(true); + expect(events.at(-1)?.type).toBe("done"); + expect(takeAnthropicReplay(events.at(-1)!)).toBeUndefined(); + expect(budget.snapshot().overflows).toBeGreaterThan(0); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { cleanup(); budget.dispose(); } }); diff --git a/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts index 494a9d6763d..64c31ff6a95 100644 --- a/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-signature-recovery.test.ts @@ -476,6 +476,33 @@ for (const rawSse of [false, true]) { }); } +test("a later media iteration preserves a structured send-budget refusal after tool execution", async () => { + let imageCalls = 0; + let inferenceCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://api.x.ai/v1/images/generations") { + imageCalls++; + return Response.json({ error: { message: "Synthetic image refusal" } }, { status: 400 }); + } + if (url !== "https://anthropic.example.test/v1/messages") throw new Error("Unexpected test network target"); + if (++inferenceCalls > 1) throw new SendBudgetExhaustedError("synthetic-later-media-send"); + return sse(frame("message_start", { message: { usage: { input_tokens: 2 } } }), + frame("content_block_start", { index: 0, content_block: { type: "tool_use", id: "image_fixture", name: "image_gen", input: {} } }), + frame("content_block_delta", { index: 0, delta: { type: "input_json_delta", partial_json: '{"prompt":"fixture"}' } }), + frame("content_block_stop", { index: 0 }), + frame("message_delta", { delta: { stop_reason: "tool_use" } }), frame("message_stop")); + }) as typeof fetch; + const result = await mediaRequest(); + const failure = streamedFailure(result.text); + expect(failure.error.type).toBe("rate_limit_error"); + expect(failure.error.code).toBe("request_send_budget_exhausted"); + expect(httpStatusFromTerminalError(failure.error)).toBe(429); + expect(failure.retryable).toBe(false); + expect(imageCalls).toBe(1); + expect(inferenceCalls).toBe(2); +}); + test("media repair never repeats an already executed image tool", async () => { const bodies: string[] = []; let imageCalls = 0;