From 015014504210e6b974454ffd8f59efb8551532a5 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Wed, 14 Oct 2026 16:29:54 +0800 Subject: [PATCH 1/7] fix(responses): recover Anthropic thinking-signature binding rejection --- .../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 3696b56564c..418bd24301f 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 d662008849b..14d715f2c5d 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 8b801dbc764ce901c69411bc2c7499c3c5705e6a Mon Sep 17 00:00:00 2001 From: lcxhh Date: Wed, 14 Oct 2026 17:03:17 +0800 Subject: [PATCH 2/7] fix(responses): recover thinking signatures within hosted search iterations --- .../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 418bd24301f..49745690dac 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 bdea85e470e..ae3edd95d47 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 14d715f2c5d..26229c2b679 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 98fc937d3d1116eae24255be9734c87cdfc64359 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Wed, 14 Oct 2026 17:47:16 +0800 Subject: [PATCH 3/7] fix(search): preserve Anthropic thinking-bound conversation fidelity --- 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 49745690dac..568c94b2a28 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 26229c2b679..484be4d57bf 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 fffff66c4730d76163af24535f7e4b5c3cc6ed38 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Wed, 14 Oct 2026 19:06:58 +0800 Subject: [PATCH 4/7] fix(anthropic): validate replay prefixes and retain selective rejection memos --- .../src/content/docs/guides/providers.md | 21 +- .../content/docs/reference/architecture.md | 44 ++ scripts/test-layout/layout.json | 5 +- src/adapters/anthropic-prefix-hints.ts | 11 + src/adapters/anthropic-prefix-observer.ts | 83 +++ src/adapters/anthropic-prefix.ts | 143 ++++ src/adapters/anthropic-rejection-memo.ts | 61 ++ src/adapters/anthropic-replay.ts | 5 +- src/adapters/anthropic.ts | 58 +- src/bridge/response-json.ts | 6 + src/bridge/sse.ts | 8 +- src/responses/parser.ts | 4 +- src/responses/reasoning-envelope.ts | 15 + src/responses/reasoning-replay-cache.ts | 54 +- src/server/responses/adapter-delivery.ts | 13 +- src/server/responses/adapter-dispatch.ts | 9 +- .../responses/anthropic-thinking-recovery.ts | 18 +- src/server/responses/core-replay.ts | 6 +- src/types.ts | 1 + src/types/request.ts | 13 + src/web-search/loop.ts | 5 +- structure/INDEX.md | 6 +- structure/manifest.json | 8 +- .../transports/anthropic-thinking-recovery.md | 99 +++ ...anthropic-message-stop-termination.test.ts | 14 +- ...anthropic-sidecar-account-failover.test.ts | 6 +- .../adapters/bridge-anthropic-prefix.test.ts | 93 +++ .../reasoning-replay-identity.test.ts | 203 +++++ tests/fixtures/test-layout-expected.json | 5 +- tests/helpers/responses-core-source.ts | 1 + .../reasoning-envelope-prefix.test.ts | 79 ++ ...onses-anthropic-prefix-consistency.test.ts | 700 ++++++++++++++++++ ...search-thinking-signature-recovery.test.ts | 25 +- 33 files changed, 1768 insertions(+), 54 deletions(-) create mode 100644 src/adapters/anthropic-prefix-hints.ts create mode 100644 src/adapters/anthropic-prefix-observer.ts create mode 100644 src/adapters/anthropic-prefix.ts create mode 100644 src/adapters/anthropic-rejection-memo.ts create mode 100644 tests/adapters/bridge-anthropic-prefix.test.ts create mode 100644 tests/responses/reasoning-envelope-prefix.test.ts create mode 100644 tests/responses/responses-anthropic-prefix-consistency.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e0b10b2cf28..7c766b0b8ef 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -84,7 +84,8 @@ identifies this signature-binding problem, opencodex can remove replayed signed/ 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 +results are retained. Older rejected thinking stays excluded in later iterations; newly observed +complete thinking blocks from the repaired request can be retained. 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 @@ -94,6 +95,24 @@ Only a successful, nonempty completion remembers the rejection for that conversa identity for five minutes. Plaintext history remains available; a failed or cancelled retry does not disable thinking replay on later requests. +When the rejected request's opaque payloads were fully captured, this memo can remember only those +payload fingerprints. Compatible new thinking from the successful repair can then survive later +requests after its full prefix checks. Already rejected payloads remain excluded even if their +visible text or metadata changes. Legacy/unrecognized blocks retain conservative stripping while +the memo is active. Old blanket memos or incomplete/oversized capture keep blanket behavior; the +fingerprint set is capped at 256, stays within the existing cache limits, and does not grant another +signature retry or persist across restarts. + +New reasoning envelopes also carry optional prefix-consistency observations. When comparable, +these let the adapter remove only thinking whose recorded context no longer matches, while +preserving compatible newer thinking, plaintext and tool results. They do not change the reasoning +mode or increase the retry allowance. A manual-thinking tool continuation that would lose required +thinking fails with an incompatible-history error before sending. Legacy or unrecognized metadata +keeps existing compatibility behavior. No client update or new persistent store is required; a +repair that produces no new signed/redacted thinking has no durable omission marker, and legacy +rejections can recur after restart. See the [architecture notes](/reference/architecture/#anthropic-thinking-prefix-continuity) +for the complete-block, scope and replay-shape limits. + ## Auth modes Provider configs accept three `authMode` values (`key` is the default). The built-in registry also diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index d9a323ae3c5..347d72fbdf5 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -126,6 +126,50 @@ set, and the tool-search set captured by the parser β€” so MCP namespaces, `appl tools, and client-executed `tool_search` all round-trip. A `buildResponseJSON()` variant produces a single non-streaming response object from the same events. +### Anthropic thinking-prefix continuity + +The existing `ocxr1` reasoning envelope may include versioned `anthropic-prefix-v1` observations. +Both streaming and buffered Responses paths preserve these alongside the original signature or +redacted payload; no client update is needed. The envelope is base64 JSON, not encryption, and the +observation is a consistency check rather than cryptographic validation of Anthropic's signature. + +The adapter compares the final normalized request's effective system instructions, tool definitions +and ordered non-thinking history, plus a separate parent/block chain for signed and redacted +thinking. Its scope includes the provider destination, credential identity and model. Protocol-level +cache annotations are excluded; similarly named fields inside tool arguments or schemas still count. +Known mismatches can remove an older incompatible segment while retaining compatible thinking, +including new thinking produced after a repair. Plaintext and completed tool results remain intact. +A manual-thinking tool continuation that cannot preserve the required history fails before sending +instead of turning thinking off or running tools again. + +Hints describe complete blocks: streaming issuance waits for the matching block stop, including +redacted blocks. An unfinished redacted block keeps its payload without a hint where error/EOF +processing can return it. A completed block can retain its observation even if the response later +fails; this never proves response success, grants another retry, or records a successful repair. +Because Responses cannot round-trip the original assistant-message shape for thinking after text +or tool content, those client-facing blocks keep legacy behavior. Lossless in-memory hosted-search +snapshots can preserve observations for those shapes. + +After a validated successful repair, the existing five-minute rejection memo can retain up to 256 +fingerprints of the opaque payloads actually present in the rejected final request. The capture is +privately bound to that exact request/response pair. Type-separated signature/redacted-data hashes +exclude plaintext and prefix metadata, so editing those fields cannot bypass an existing rejection. +Selective memos merge rejected fingerprints within the existing 64-entry/256 KiB cache bounds. +Incomplete, invalid or oversized capture, and an existing blanket memo, retain blanket stripping. + +With a selective memo active, rejected payloads and legacy/unrecognized blocks remain excluded. +A different payload carrying a recognized same-scope observation can survive only after the normal +block, prefix and parent-chain checks; a new hint alone does not make it valid. This preserves +compatible thinking generated by a successful repair on later requests without giving an active +memo another signature retry. The memo still contains no raw opaque content and adds no persistence. + +Legacy envelopes, malformed/unknown versions and unknown scopes do not by themselves authorize +removing provider payloads. Recognized observations travel with reasoning items without a new key, +database or persistent store. There is no durable omission marker if a repair returns no new +signed/redacted thinking, so that case and legacy histories can still encounter the old rejection +after restart. The existing short-lived memo and one-shot recovery rules remain separate; see +[replayed Anthropic thinking signatures](/guides/providers/#replayed-anthropic-thinking-signatures). + ## Management API, OAuth, and usage `server/management-api.ts` backs the dashboard and dispatches focused route groups to diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 568c94b2a28..6cb039c4c0f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -258,7 +258,7 @@ "bridge-live-delivery.test.ts": "server", "bridge-nonstreaming-terminal.test.ts": "adapters", "bridge-raw-reasoning-hidden.test.ts": "adapters", - "bridge-reasoning-replay-batch.test.ts": "adapters", + "bridge-reasoning-replay-batch.test.ts": "adapters", "bridge-anthropic-prefix.test.ts": "adapters", "bridge-stall.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", @@ -1400,7 +1400,7 @@ "rate-limit-retry.test.ts": "providers", "reasoning-effort-summary-default.test.ts": "responses", "reasoning-effort.test.ts": "codex-integration", - "reasoning-envelope.test.ts": "responses", + "reasoning-envelope.test.ts": "responses", "reasoning-envelope-prefix.test.ts": "responses", "reasoning-metadata.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", @@ -1454,6 +1454,7 @@ "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", "responses-anthropic-thinking-signature-recovery.test.ts": "responses", + "responses-anthropic-prefix-consistency.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", diff --git a/src/adapters/anthropic-prefix-hints.ts b/src/adapters/anthropic-prefix-hints.ts new file mode 100644 index 00000000000..3da8df50b4b --- /dev/null +++ b/src/adapters/anthropic-prefix-hints.ts @@ -0,0 +1,11 @@ +import type { AdapterEvent, AnthropicPrefixHint } from "../types"; + +// Neither events nor provider blocks expose these observations in public JSON/log surfaces. +const observations = new WeakMap(); +const locallyIssued = new WeakMap(); +export function getAnthropicPrefixHint(value: object): AnthropicPrefixHint | undefined { return observations.get(value); } +export function setAnthropicPrefixHint(value: object, hint: AnthropicPrefixHint): void { observations.set(value, hint); } +export function issueAnthropicPrefixHint(event: AdapterEvent, hint: AnthropicPrefixHint, generation: object = hint): void { + locallyIssued.set(hint, generation); observations.set(event, hint); +} +export function isLocallyIssuedAnthropicPrefixHint(hint: AnthropicPrefixHint, generation?: object): boolean { return generation ? locallyIssued.get(hint) === generation : locallyIssued.has(hint); } diff --git a/src/adapters/anthropic-prefix-observer.ts b/src/adapters/anthropic-prefix-observer.ts new file mode 100644 index 00000000000..375e456e063 --- /dev/null +++ b/src/adapters/anthropic-prefix-observer.ts @@ -0,0 +1,83 @@ +import type { AdapterEvent, AnthropicPrefixHint } from "../types"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import { jsonUtf8Bytes } from "../lib/json-byte-size"; +import { appendAnthropicPrefix, hintForAnthropicBlock, nextAnthropicParent, takeAnthropicPrefixSeed } from "./anthropic-prefix"; +import { issueAnthropicPrefixHint } from "./anthropic-prefix-hints"; + +type Block = Record; +/** Incremental observation of a complete block, never an attestation of response success. */ +export function createAnthropicPrefixObserver(response: Response, budget: TranslatorBudget) { + const seed = takeAnthropicPrefixSeed(response); + if (!seed) return; + let valid = true, started = false, index = 0, bytes = 0; + let clientRepresentable = true; + let block: Block | undefined; + let text: string[] = [], args: string[] = []; + const release = () => { budget.releaseRetained(bytes, { kind: "reasoning" }); bytes = 0; block = undefined; text = []; args = []; }; + const invalidate = () => { valid = false; release(); }; + const retain = (value: unknown) => { const size = jsonUtf8Bytes(value) * 4 + 64; budget.chargeRetained(size, { kind: "reasoning" }); bytes += size; }; + return { + invalidate, + accept(data: Block): { event: AdapterEvent; hint: AnthropicPrefixHint; index: number } | undefined { + if (!valid) return; + const type = data.type; + if (type === "ping" || type === "message_delta") return; + if (type === "message_start") { + const message = data.message as Block | undefined; + if (started || (Array.isArray(message?.content) && message.content.length > 0)) invalidate(); + else started = true; + return; + } + if (!started) { invalidate(); return; } + if (type === "content_block_start") { + const incoming = data.content_block as Block | undefined; + if (block || data.index !== index || !incoming || !["text", "thinking", "redacted_thinking", "tool_use"].includes(String(incoming.type))) { invalidate(); return; } + if (incoming.type === "thinking" && Object.keys(incoming).some(key => !["type", "thinking", "signature"].includes(key))) clientRepresentable = false; + if (incoming.type === "redacted_thinking" && Object.keys(incoming).some(key => !["type", "data"].includes(key))) clientRepresentable = false; + retain(incoming); block = { ...incoming, ...(Array.isArray(incoming.citations) ? { citations: [...incoming.citations] } : {}) }; + if (block.type === "text" || block.type === "thinking") { + const initial = block.type === "text" ? block.text : block.thinking; + if (typeof initial !== "string") { invalidate(); return; } + text = [initial]; + } + } else if (type === "content_block_delta") { + const delta = data.delta as Block | undefined; + if (!block || data.index !== index || !delta) { invalidate(); return; } + retain(delta); + if (delta.type === "text_delta" && block.type === "text" && typeof delta.text === "string") text.push(delta.text); + else if (delta.type === "thinking_delta" && block.type === "thinking" && typeof delta.thinking === "string") text.push(delta.thinking); + else if (delta.type === "signature_delta" && block.type === "thinking" && typeof delta.signature === "string") block.signature = delta.signature; + else if (delta.type === "input_json_delta" && block.type === "tool_use" && typeof delta.partial_json === "string") args.push(delta.partial_json); + else if (delta.type === "citations_delta" && block.type === "text" && delta.citation && typeof delta.citation === "object") { + if (block.citations !== undefined && !Array.isArray(block.citations)) { invalidate(); return; } + (block.citations ??= [] as unknown[] as unknown); + (block.citations as unknown[]).push(delta.citation); + } else invalidate(); + } else if (type === "content_block_stop") { + if (!block || data.index !== index) { invalidate(); return; } + const reservation = budget.reserveTransient(bytes * 2, { kind: "reasoning" }); + try { + if (block.type === "text") block.text = text.join(""); + if (block.type === "thinking") block.thinking = text.join(""); + if (block.type === "tool_use" && args.length) block.input = JSON.parse(args.join("")); + let event: AdapterEvent | undefined; + if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) event = { type: "thinking_signature", signature: block.signature }; + else if (block.type === "redacted_thinking" && typeof block.data === "string") event = { type: "redacted_thinking", data: block.data }; + else if (block.type === "thinking" || block.type === "redacted_thinking") { invalidate(); return; } + if (event) { + const hint = hintForAnthropicBlock(seed, block); + // Responses currently splits text-before-thinking into separate assistant items. + // Preserve the payload as legacy rather than issuing a hint it cannot round-trip. + issueAnthropicPrefixHint(clientRepresentable ? event : { type: "heartbeat" }, hint, seed.generation); + seed.parent = nextAnthropicParent(seed.parent, block); + return { event, hint, index: index++ }; + } + appendAnthropicPrefix(seed.hash, block); clientRepresentable = false; index++; + } catch { invalidate(); } + finally { reservation.release(); release(); } + } else if (type === "message_stop") { if (block) invalidate(); } + else invalidate(); + }, + dispose: release, + }; +} diff --git a/src/adapters/anthropic-prefix.ts b/src/adapters/anthropic-prefix.ts new file mode 100644 index 00000000000..caa4120fef0 --- /dev/null +++ b/src/adapters/anthropic-prefix.ts @@ -0,0 +1,143 @@ +import { anthropicOpaqueFingerprint, anthropicRejectionPolicy, bindAnthropicRejectionResponse } from "./anthropic-rejection-memo"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import { jsonUtf8Bytes } from "../lib/json-byte-size"; +import { createHash, type Hash } from "node:crypto"; +import type { AdapterRequest } from "./base"; +import type { AnthropicPrefixHint, OcxParsedRequest } from "../types"; +import { getAnthropicPrefixHint, isLocallyIssuedAnthropicPrefixHint } from "./anthropic-prefix-hints"; + +type Block = Record; +export interface AnthropicPrefixSeed { hash: Hash; parent: string; scope: string; generation: object } +const generations = new WeakMap(); +function generationFor(parsed: OcxParsedRequest): object { + const owner = parsed._reasoningReplayScope ?? parsed; + let generation = generations.get(owner); if (!generation) { generation = {}; generations.set(owner, generation); } + return generation; +} +export function invalidateAnthropicPrefixGeneration(parsed: OcxParsedRequest): void { generations.set(parsed._reasoningReplayScope ?? parsed, {}); } +const requests = new WeakMap(); +const responses = new WeakMap(); +export const ANTHROPIC_PREFIX_ROOT = createHash("sha256").update("anthropic-thinking-chain-v1").digest("hex"); +/** Stable object ordering; array/block/message order is significant. Only cache annotations vary. */ +class UnsupportedPrefixShape extends Error {} +export function canonicalAnthropicPrefix(value: unknown, depth = 0): string { + if (depth > 128) throw new UnsupportedPrefixShape("Anthropic prefix nesting is unsupported"); + if (Array.isArray(value)) return `[${value.map(item => canonicalAnthropicPrefix(item, depth + 1)).join(",")}]`; + if (value !== null && typeof value === "object") return `{${Object.keys(value).filter(key => (value as Block)[key] !== undefined).sort() + .map(key => `${JSON.stringify(key)}:${canonicalAnthropicPrefix((value as Block)[key], depth + 1)}`).join(",")}}`; + return JSON.stringify(value) ?? "null"; +} +export function anthropicPrefixDigest(value: unknown): string { return createHash("sha256").update(canonicalAnthropicPrefix(value)).digest("hex"); } +export function appendAnthropicPrefix(hash: Hash, value: unknown): void { + const encoded = canonicalAnthropicPrefix(value); hash.update(`${Buffer.byteLength(encoded)}:`).update(encoded); +} +function protocolBlock(block: Block): Block { const { cache_control: _cache, ...content } = block; return content; } +function protocolBlocks(value: unknown): unknown { return Array.isArray(value) ? value.map(block => block && typeof block === "object" ? protocolBlock(block as Block) : block) : value; } +export function nextAnthropicParent(parent: string, block: Block): string { return anthropicPrefixDigest([parent, protocolBlock(block)]); } +export function beginAnthropicAssistant(seed: AnthropicPrefixSeed): AnthropicPrefixSeed { + const copy = { ...seed, hash: seed.hash.copy() }; appendAnthropicPrefix(copy.hash, { role: "assistant" }); return copy; +} +export function hintForAnthropicBlock(seed: AnthropicPrefixSeed, block: Block): AnthropicPrefixHint { + return { v: 1, scheme: "anthropic-prefix-v1", scope: seed.scope, prefix: seed.hash.copy().digest("hex"), + parent: seed.parent, block: anthropicPrefixDigest(protocolBlock(block)) }; +} +export class AnthropicHistoryIncompatibleError extends Error { + constructor() { super("Anthropic thinking history is incompatible with the current request prefix; resend a consistent history. Reasoning mode and executed tool results were not changed."); } +} +function scopeFor(parsed: OcxParsedRequest): string | undefined { + const current = parsed._reasoningReplayScope?.current; + if (!current?.providerDestinationDurableIdentity || !current.credentialDurableIdentity) return; + return anthropicPrefixDigest(["anthropic", current.providerDestinationDurableIdentity, current.credentialDurableIdentity, parsed.modelId]); +} +export function isCurrentAnthropicPrefixHint(hint: AnthropicPrefixHint, parsed: OcxParsedRequest): boolean { + return hint.scope === scopeFor(parsed) && isLocallyIssuedAnthropicPrefixHint(hint, generationFor(parsed)); +} +function initialSeed(body: Block, scope: string, generation: object): AnthropicPrefixSeed { + const hash = createHash("sha256"); appendAnthropicPrefix(hash, { scheme: "anthropic-prefix-v1", system: typeof body.system === "string" ? [{ type: "text", text: body.system }] : protocolBlocks(body.system ?? null), tools: protocolBlocks(body.tools ?? []) }); + return { hash, parent: ANTHROPIC_PREFIX_ROOT, scope, generation }; +} +const opaque = (block: Block) => block.type === "thinking" || block.type === "redacted_thinking"; +/** The final wire, not the client's input JSON, defines the observed prefix. */ +function prepareAnthropicPrefixUnchecked(body: Block, parsed: OcxParsedRequest): AnthropicPrefixSeed | undefined { + const scope = scopeFor(parsed); + if (!scope || !Array.isArray(body.messages)) return; + let seed = initialSeed(body, scope, generationFor(parsed)); + const removed = new Set(); + const kept: Block[] = []; + let incompleteChain = false; + for (const entry of body.messages as Block[]) { + const before = seed.hash.copy(); + const { content, ...header } = entry; appendAnthropicPrefix(seed.hash, header); + const blocks = typeof content === "string" ? [{ type: "text", text: content }] : content as Block[]; + if (!Array.isArray(blocks)) return; + for (const block of blocks) { + if (!opaque(block)) { appendAnthropicPrefix(seed.hash, protocolBlock(block)); continue; } + const hint = getAnthropicPrefixHint(block); + const comparable = hint?.scope === scope; + const memo = anthropicRejectionPolicy(parsed); + const memoStrip = !!memo && (memo.mode === "blanket" || !comparable || memo.fingerprints.has(anthropicOpaqueFingerprint(block) ?? "")); + const forceStrip = memoStrip || parsed._stripReasoningEncryptedContent === true && !(hint && isCurrentAnthropicPrefixHint(hint, parsed)); + const mismatch = comparable && (hint.block !== anthropicPrefixDigest(protocolBlock(block)) || hint.prefix !== seed.hash.copy().digest("hex")); + if (forceStrip || mismatch) { removed.add(block); incompleteChain = true; continue; } + if (comparable) { + if (hint.parent !== seed.parent) { + // A complete, explicitly empty predecessor chain starts a new generation after omission. + if (hint.parent !== ANTHROPIC_PREFIX_ROOT) throw new AnthropicHistoryIncompatibleError(); + for (const prior of kept) removed.add(prior); + kept.length = 0; seed.parent = ANTHROPIC_PREFIX_ROOT; + } + incompleteChain = false; + } else if (incompleteChain) throw new AnthropicHistoryIncompatibleError(); + kept.push(block); seed.parent = nextAnthropicParent(seed.parent, block); + } + if (blocks.length > 0 && blocks.every(opaque)) seed.hash = before; + else appendAnthropicPrefix(seed.hash, { endMessage: true }); + } + if (removed.size || parsed._stripReasoningEncryptedContent === true) { + const filtered = (body.messages as Block[]).map(message => Array.isArray(message.content) + ? { ...message, content: (message.content as Block[]).filter(block => !removed.has(block)) } : message) + .filter(message => !Array.isArray(message.content) || message.content.length > 0); + const last = filtered.at(-1); + const assistant = filtered.findLast(message => message.role === "assistant"); + if ((body.thinking as Block | undefined)?.type === "enabled" && last?.role === "user" + && Array.isArray(last.content) && last.content.some(block => (block as Block).type === "tool_result") + && Array.isArray(assistant?.content) && !assistant.content.some(block => opaque(block as Block))) throw new AnthropicHistoryIncompatibleError(); + body.messages = filtered; + } + // Recompute after omission, including any removed empty assistant container. + seed = initialSeed(body, scope, generationFor(parsed)); + for (const entry of body.messages as Block[]) { + const before = seed.hash.copy(); + const { content, ...header } = entry; appendAnthropicPrefix(seed.hash, header); + const blocks = typeof content === "string" ? [{ type: "text", text: content }] : content as Block[]; + for (const block of blocks) { + if (opaque(block)) seed.parent = nextAnthropicParent(seed.parent, block); + else appendAnthropicPrefix(seed.hash, protocolBlock(block)); + } + if (blocks.length > 0 && blocks.every(opaque)) seed.hash = before; + else appendAnthropicPrefix(seed.hash, { endMessage: true }); + } + return seed; +} +/** Account for temporary canonical strings before hashing the already-built wire body. */ +export function prepareAnthropicPrefix(body: Block, parsed: OcxParsedRequest, budget?: TranslatorBudget): AnthropicPrefixSeed | undefined { + if (!scopeFor(parsed)) return undefined; + const reservation = budget?.reserveTransient(jsonUtf8Bytes(body) * 4, { kind: "reasoning" }); + try { return prepareAnthropicPrefixUnchecked(body, parsed); } + catch (error) { + if (!(error instanceof UnsupportedPrefixShape)) throw error; + const hasHints = Array.isArray(body.messages) && body.messages.some(message => Array.isArray(message.content) + && message.content.some((block: object) => getAnthropicPrefixHint(block))); + if (hasHints) throw new AnthropicHistoryIncompatibleError(); + return undefined; + } finally { reservation?.release(); } +} +export function rememberAnthropicPrefixRequest(request: AdapterRequest, seed: AnthropicPrefixSeed | undefined): void { if (seed) requests.set(request, seed); } +export function bindAnthropicPrefixResponse(request: AdapterRequest | undefined, response: Response): void { + bindAnthropicRejectionResponse(request, response); + const seed = request && requests.get(request); if (seed && response.ok) responses.set(response, seed); +} +export function aliasAnthropicPrefixResponse(source: Response, target: Response): void { const seed = responses.get(source); if (seed) responses.set(target, seed); } +export function takeAnthropicPrefixSeed(response: Response): AnthropicPrefixSeed | undefined { + const seed = responses.get(response); responses.delete(response); return seed && beginAnthropicAssistant(seed); +} diff --git a/src/adapters/anthropic-rejection-memo.ts b/src/adapters/anthropic-rejection-memo.ts new file mode 100644 index 00000000000..696a871a538 --- /dev/null +++ b/src/adapters/anthropic-rejection-memo.ts @@ -0,0 +1,61 @@ +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)); } diff --git a/src/adapters/anthropic-replay.ts b/src/adapters/anthropic-replay.ts index 4f41dbf4724..46193a5dd81 100644 --- a/src/adapters/anthropic-replay.ts +++ b/src/adapters/anthropic-replay.ts @@ -1,3 +1,5 @@ +import { getAnthropicPrefixHint, setAnthropicPrefixHint } from "./anthropic-prefix-hints"; +import type { AnthropicPrefixHint } from "../types"; import { isConformingToolCallId } from "./tool-call-id"; import type { AdapterEvent, OcxAssistantMessage } from "../types"; import type { TranslatorBudget } from "../lib/translator-budget"; @@ -27,7 +29,7 @@ export function bindAnthropicReplay(message: OcxAssistantMessage, snapshot: Anth 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 })); + return snapshot?.content.map(block => { const copy = { ...block }; const hint = getAnthropicPrefixHint(block); if (hint) setAnthropicPrefixHint(copy, hint); return copy; }); } /** Capture only explicitly opted-in search streams; no client data can manufacture a snapshot. */ @@ -48,6 +50,7 @@ export function createAnthropicReplayCapture(response: Response, budget: Transla }; return { invalidate, + annotate(index: number, hint: AnthropicPrefixHint): void { const block = blocks[index]; if (block) setAnthropicPrefixHint(block.value, hint); }, accept(data: Record): void { if (!valid) return; const type = data.type; diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index c20af06329f..125478cb81f 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1,3 +1,7 @@ +import { captureAnthropicRejectionPayloads } from "./anthropic-rejection-memo"; +import { createAnthropicPrefixObserver } from "./anthropic-prefix-observer"; +import { prepareAnthropicPrefix, rememberAnthropicPrefixRequest, isCurrentAnthropicPrefixHint } from "./anthropic-prefix"; +import { setAnthropicPrefixHint, getAnthropicPrefixHint } from "./anthropic-prefix-hints"; import { anthropicDeclaredTools } from "./anthropic-tool-catalog"; import { anthropicReplayContent, createAnthropicReplayCapture } from "./anthropic-replay"; import type { IncomingMeta, ProviderAdapter } from "./base"; @@ -786,10 +790,14 @@ function messagesToAnthropicFormat( // 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 ?? []) { - preface.push({ type: "redacted_thinking", data }); + const block = { type: "redacted_thinking", data }; + if (t.anthropicPrefix && (t.redacted?.length ?? 0) === 1 && !isLikelyRealAnthropicThinkingSignature(t.signature)) setAnthropicPrefixHint(block, t.anthropicPrefix); + preface.push(block); } if (isLikelyRealAnthropicThinkingSignature(t.signature)) { - preface.push({ type: "thinking", thinking: t.thinking, signature: t.signature }); + const block = { type: "thinking", thinking: t.thinking, signature: t.signature }; + if (t.anthropicPrefix && !t.redacted?.length) setAnthropicPrefixHint(block, t.anthropicPrefix); + preface.push(block); } } else if (part.type === "toolCall") { const tc = part as OcxToolCall; @@ -818,7 +826,7 @@ function messagesToAnthropicFormat( } } const content = captured - ? captured.filter(block => parsed._stripReasoningEncryptedContent !== true || (block.type !== "thinking" && block.type !== "redacted_thinking")) + ? captured.filter(block => parsed._stripReasoningEncryptedContent !== true || (block.type !== "thinking" && block.type !== "redacted_thinking") || (getAnthropicPrefixHint(block) && isCurrentAnthropicPrefixHint(getAnthropicPrefixHint(block)!, parsed))) : [...preface, ...toolUses]; if (content.length === 0) break; messages.push({ role: "assistant", content }); @@ -1181,7 +1189,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti enforceCacheControlLimit(body, explicitLimit); normalizeTtlOrdering(body); - return { + const prefixSeed = prepareAnthropicPrefix(body, parsed, incoming?.translatorBudget); + const request = { url, method: "POST", headers, @@ -1195,6 +1204,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti fastSpeed?.value ?? null, ), }; + rememberAnthropicPrefixRequest(request, prefixSeed); + captureAnthropicRejectionPayloads(request, body, incoming?.translatorBudget); + return request; }, async *parseStream( @@ -1208,6 +1220,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } const replayCapture = createAnthropicReplayCapture(response, budget); + const prefixObserver = createAnthropicPrefixObserver(response, budget); + let pendingRedactedPrefix: { data: string } | undefined; + let pendingRedactedBytes = 0; const budgetEncoder = new TextEncoder(); let currentBlockType = ""; let currentToolCallId = ""; @@ -1267,6 +1282,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti parsed = JSON.parse(payload); } catch { replayCapture?.invalidate(); + prefixObserver?.invalidate(); debugDroppedFrame("anthropic", payload); continue; } @@ -1275,11 +1291,23 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // 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(); + prefixObserver?.invalidate(); debugDroppedFrame("anthropic", payload); continue; } const data = parsed as Record; replayCapture?.accept({ ...data, type: record.event || data.type }); + const observedBlock = prefixObserver?.accept({ ...data, type: record.event || data.type }); + if (observedBlock) replayCapture?.annotate(observedBlock.index, observedBlock.hint); + const frameType = record.event || data.type; + if (pendingRedactedPrefix && (frameType === "content_block_stop" || frameType === "content_block_start" + || frameType === "error" || frameType === "message_stop")) { + yield observedBlock?.event.type === "redacted_thinking" ? observedBlock.event + : { type: "redacted_thinking", data: pendingRedactedPrefix.data }; + pendingRedactedPrefix = undefined; + budget.releaseRetained(pendingRedactedBytes, { kind: "reasoning" }); pendingRedactedBytes = 0; + } + if (observedBlock?.event.type === "thinking_signature") yield observedBlock.event; switch (record.event || data.type) { case "message_start": { @@ -1310,7 +1338,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } if (block.type === "redacted_thinking" && typeof block.data === "string") { // Opaque redacted block: replay verbatim later or tool-use turns 400. - yield { type: "redacted_thinking", data: block.data }; + if (prefixObserver) { + const retainedBytes = Buffer.byteLength(block.data); + budget.chargeRetained(retainedBytes, { kind: "reasoning" }); + pendingRedactedBytes = retainedBytes; + pendingRedactedPrefix = { data: block.data }; + } + else yield { type: "redacted_thinking", data: block.data }; } break; } @@ -1402,6 +1436,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } } } + if (pendingRedactedPrefix) yield { type: "redacted_thinking", data: pendingRedactedPrefix.data }; } catch (error) { if (!isTranslatorBudgetExceededError(error)) throw error; yield { @@ -1417,6 +1452,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti return; } finally { replayCapture?.discard(); + prefixObserver?.dispose(); + budget.releaseRetained(pendingRedactedBytes, { kind: "reasoning" }); pendingRedactedBytes = 0; if (currentToolCallId) budget.closeCall(currentToolCallId); } if (!emittedDone) { @@ -1496,6 +1533,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti }]; } const json = parsed; + const prefixObserver = createAnthropicPrefixObserver(response, budget); + prefixObserver?.accept({ type: "message_start" }); const responseBytes = Buffer.byteLength(JSON.stringify(json), "utf8"); budget.chargeRetained(responseBytes, { kind: "retained_collectors" }); try { @@ -1532,18 +1571,20 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } const content = rawContent as { type: string; text?: string; id?: string; name?: unknown; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined; if (content) { - for (const block of content) { + for (const [index, block] of content.entries()) { + prefixObserver?.accept({ type: "content_block_start", index, content_block: block }); + const observed = prefixObserver?.accept({ type: "content_block_stop", index }); if (block.type === "text" && block.text) { events.push({ type: "text_delta", text: block.text }); } else if (block.type === "thinking" && typeof block.thinking === "string") { events.push({ type: "thinking_delta", thinking: block.thinking }); if (typeof block.signature === "string" && block.signature) { - events.push({ type: "thinking_signature", signature: block.signature }); + events.push(observed?.event ?? { type: "thinking_signature", signature: block.signature }); } } else if (block.type === "reasoning" && typeof block.reasoning === "string") { events.push({ type: "thinking_delta", thinking: block.reasoning }); } else if (block.type === "redacted_thinking" && typeof block.data === "string") { - events.push({ type: "redacted_thinking", data: block.data }); + events.push(observed?.event ?? { type: "redacted_thinking", data: block.data }); } else if (block.type === "tool_use") { const id = usableToolUseId(block.id); events.push({ type: "tool_call_start", id, name: toolNames.fromWire(typeof block.name === "string" ? block.name : "") }); @@ -1587,6 +1628,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti retainTranslatedEventBatch(events, budget); return events; } finally { + prefixObserver?.dispose(); budget.releaseRetained(responseBytes, { kind: "retained_collectors" }); } }, diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index f74ebccf77b..b32e1a82972 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -44,6 +44,7 @@ import { import { adapterFailureFromEvent, emptyChunks, joinChunks, responsesUsage, toolCallArgumentsUsable, uuid, webSearchAction } from "./internal"; import type { OutputItem, StringChunks } from "./internal"; import { bridgeToResponsesSSE } from "./sse"; +import { getAnthropicPrefixHint } from "../adapters/anthropic-prefix-hints"; /** Build a buffered Responses result within a caller-owned or temporary translator budget. */ export function buildResponseJSON( @@ -211,6 +212,7 @@ function buildResponseJSONWithBudget( let batchSignatureBytes = 0; let batchRedacted: string[] = []; let batchRedactedBytes = 0; + let batchAnthropicPrefix: ReasoningEnvelope["ap"]; // Kiro reasoning blob, held until after the trailing flushes so it lands AFTER the assistant // message (see the streaming path). Retained because it outlives releaseTranslatedEvent. let batchKiroRedacted: string | undefined; @@ -268,6 +270,7 @@ function buildResponseJSONWithBudget( const envelope: ReasoningEnvelope = {}; if (batchSignature) envelope.sig = batchSignature; if (batchRedacted.length > 0) envelope.red = batchRedacted; + if (batchAnthropicPrefix && Number(!!batchSignature) + batchRedacted.length === 1) envelope.ap = batchAnthropicPrefix; const hidden = options?.hideThinkingSummary === true; if (hidden && summaryText && (envelope.sig || envelope.red)) envelope.txt = summaryText; const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope, budget) : undefined; @@ -276,6 +279,7 @@ function buildResponseJSONWithBudget( batchSignatureBytes = 0; batchRedacted = []; batchRedactedBytes = 0; + batchAnthropicPrefix = undefined; if (hidden && !encrypted) { budget?.releaseRetained(sourceBytes, { kind: "reasoning" }); currentSummaryReasoning = emptyChunks(); @@ -425,6 +429,7 @@ function buildResponseJSONWithBudget( // event. Flushing every update would manufacture signature-only siblings. batchSignatureBytes = replaceBatchRetainedString(batchSignatureBytes, e.signature, "reasoning"); batchSignature = e.signature; + batchAnthropicPrefix = getAnthropicPrefixHint(e); break; case "redacted_thinking": flushText("commentary"); @@ -437,6 +442,7 @@ function buildResponseJSONWithBudget( batchRedactedBytes += dataBytes; } batchRedacted.push(e.data); + batchAnthropicPrefix = getAnthropicPrefixHint(e); flushSummaryReasoning(); break; case "kiro_redacted_reasoning": diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index e34c3bebdf6..3b4021a527b 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -27,6 +27,7 @@ import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../respon import { mayBecomeCodeModeShellInput } from "../responses/code-mode-shell-input"; import { isTruncatedStopReason, truncationReasonFor } from "../responses/truncated-stop-reason"; import { encodeReasoningEnvelope, type ReasoningEnvelope } from "../responses/reasoning-envelope"; +import { getAnthropicPrefixHint } from "../adapters/anthropic-prefix-hints"; import { rememberReasoningForCall } from "../responses/reasoning-replay-cache"; import { rememberAndSerializeExtraContent, @@ -372,12 +373,14 @@ export function bridgeToResponsesSSE( let pendingSignature: string | undefined; let pendingSignatureBytes = 0; let pendingRedacted: string[] = []; + let pendingAnthropicPrefix: ReasoningEnvelope["ap"]; let hiddenThinking = emptyChunks(); const takeReasoningEnvelope = (hiddenText?: string): string | undefined => { if (!pendingSignature && pendingRedacted.length === 0) return undefined; const envelope: ReasoningEnvelope = {}; if (pendingSignature) envelope.sig = pendingSignature; if (pendingRedacted.length > 0) envelope.red = pendingRedacted; + if (pendingAnthropicPrefix && Number(!!pendingSignature) + pendingRedacted.length === 1) envelope.ap = pendingAnthropicPrefix; if (hiddenText) envelope.txt = hiddenText; const previousBytes = pendingSignatureBytes + pendingRedacted.reduce((sum, value) => sum + bytesOf(value), 0) @@ -387,6 +390,7 @@ export function bridgeToResponsesSSE( pendingSignature = undefined; pendingSignatureBytes = 0; pendingRedacted = []; + pendingAnthropicPrefix = undefined; reservation?.commitRetained(); budget?.releaseRetained(previousBytes, { kind: "reasoning" }); return encoded; @@ -963,6 +967,7 @@ export function bridgeToResponsesSSE( case "thinking_signature": { pendingSignatureBytes = replaceRetainedString(pendingSignatureBytes, event.signature, "reasoning"); pendingSignature = event.signature; + pendingAnthropicPrefix = getAnthropicPrefixHint(event); // Delay closing until the next semantic event so a signature update cannot // create another block or become attached to the following thinking text. break; @@ -975,7 +980,8 @@ export function bridgeToResponsesSSE( if (currentToolCall) closeCurrentToolCall(); budget?.chargeRetained(bytesOf(event.data), { kind: "reasoning" }); pendingRedacted.push(event.data); - // A redacted block is complete at content_block_start. Emit it here, + pendingAnthropicPrefix = getAnthropicPrefixHint(event); + // A redacted event carries one complete block. Emit it here, // not with a later thinking block or after a tool call at turn end. flushHiddenReasoningEnvelope(); break; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 0c372164de0..15446984650 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -405,12 +405,14 @@ export function parseRequest( thinking: thinkingText, signature: envelope?.sig ?? JSON.stringify(reasoning), ...(envelope?.red ? { redacted: envelope.red } : {}), + ...(envelope?.ap ? { anthropicPrefix: envelope.ap } : {}), ...(reasoning.id ? { itemId: reasoning.id } : {}), }; const envelopeSigned = typeof envelope?.sig === "string"; const previous = pendingReasoning[pendingReasoning.length - 1]; - if (!envelopeSigned && !part.redacted && previous && !previous.envelopeSigned && !previous.part.redacted) { + if (!envelopeSigned && !part.redacted && !part.anthropicPrefix + && previous && !previous.envelopeSigned && !previous.part.redacted && !previous.part.anthropicPrefix) { previous.part = { ...part, thinking: `${previous.part.thinking}\n${part.thinking}`, diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index 2e6b9d229e9..b09371b1edc 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -14,6 +14,7 @@ import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; import { jsonUtf8Bytes } from "../lib/json-byte-size"; +import type { AnthropicPrefixHint } from "../types"; export const OCX_REASONING_PREFIX = "ocxr1:"; @@ -27,6 +28,8 @@ export interface ReasoningEnvelope { * so replay needs it even though the visible summary was suppressed. */ txt?: string; + /** Optional exact-prefix observation for one completed Anthropic thinking block. */ + ap?: AnthropicPrefixHint; /** * Kiro's reasoning blob from `reasoningContentEvent`: a KMS-encrypted value that is opaque to the * proxy (the GPT-5.6 family sends it as `signature`, other models as the base64 @@ -64,6 +67,15 @@ function decodeNativeReasoning(value: unknown): NativeReasoning | undefined { return typeof id === "string" && id.length > 0 ? { enc, model, tag, id } : { enc, model, tag }; } +function decodeAnthropicPrefixHint(value: unknown): AnthropicPrefixHint | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const { v, scheme, scope, prefix, parent, block } = value as Record; + const digest = (part: unknown): part is string => typeof part === "string" && part.length === 64 && /^[0-9a-f]{64}$/.test(part); + if (v !== 1 || scheme !== "anthropic-prefix-v1" + || !digest(scope) || !digest(prefix) || !digest(parent) || !digest(block)) return undefined; + return { v, scheme, scope, prefix, parent, block }; +} + export function encodeReasoningEnvelope(envelope: ReasoningEnvelope, budget?: TranslatorBudget): string { const activeBudget = budget ?? createTranslatorBudget(); try { @@ -144,6 +156,9 @@ export function decodeReasoningEnvelope(encryptedContent: string, budget?: Trans if (typeof krc === "string" && krc.length > 0) envelope.krc = krc; const nat = decodeNativeReasoning((parsed as { nat?: unknown }).nat); if (nat) envelope.nat = nat; + // Future/malformed hints never invalidate the original provider payload. + const ap = decodeAnthropicPrefixHint((parsed as { ap?: unknown }).ap); + if (ap) envelope.ap = ap; return envelope.sig || envelope.red || hasTxt || envelope.krc || envelope.nat ? envelope : null; } catch { return null; 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..0bb2fbc25a4 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -1,3 +1,4 @@ +import { aliasAnthropicPrefixResponse } from "../../adapters/anthropic-prefix"; import type { ResponsesRequestContext } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesTransport } from "./request-transport"; @@ -99,10 +100,10 @@ export async function deliverAdapterResponse( try { const retryEvents = upstreamRequestsStream ? readResponseStreamWithInactivity(response, upstream.signal, bodyInactivityMs, - guarded => transportState.activeAdapter.parseStream(guarded, translatorBudget, logCtx.activeTierMetadata)) + guarded => { aliasAnthropicPrefixResponse(response, guarded); return transportState.activeAdapter.parseStream(guarded, translatorBudget, logCtx.activeTierMetadata); }) : (async function* () { yield* await readResponseBodyWithInactivity(response, upstream.signal, bodyInactivityMs, - guarded => transportState.activeAdapter.parseResponse!(guarded, translatorBudget, logCtx.activeTierMetadata)); + guarded => { aliasAnthropicPrefixResponse(response, guarded); return transportState.activeAdapter.parseResponse!(guarded, translatorBudget, logCtx.activeTierMetadata); }); })(); for await (const event of retryEvents) { options.onCompactionRecoveryAdapterEvent?.(event); @@ -113,7 +114,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); @@ -126,7 +127,7 @@ export async function deliverAdapterResponse( upstreamResponse, upstream.signal, bodyInactivityMs, - response => transportState.activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata), + response => { aliasAnthropicPrefixResponse(upstreamResponse, response); return transportState.activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata); }, )) { options.onCompactionRecoveryAdapterEvent?.(event); yield event; @@ -270,14 +271,14 @@ export async function deliverAdapterResponse( upstreamResponse, upstream.signal, bodyInactivityMs, - response => transportState.activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata), + response => { aliasAnthropicPrefixResponse(upstreamResponse, response); return transportState.activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata); }, )) initialEvents.push(event); } else { initialEvents.push(...await readResponseBodyWithInactivity( upstreamResponse, upstream.signal, bodyInactivityMs, - response => transportState.activeAdapter.parseResponse!(response, translatorBudget, logCtx.activeTierMetadata), + response => { aliasAnthropicPrefixResponse(upstreamResponse, response); return transportState.activeAdapter.parseResponse!(response, translatorBudget, logCtx.activeTierMetadata); }, )); } for (const event of initialEvents) options.onCompactionRecoveryAdapterEvent?.(event); diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 0c0cac4b73a..0578128f796 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1,3 +1,4 @@ +import { bindAnthropicPrefixResponse } from "../../adapters/anthropic-prefix"; import { deferPhysicalSendReporter } from "./deferred-send-reporter"; import { spendLedgerStorageErrorResponse } from "./spend-storage-error"; import { classifyAnthropic429 } from "../../oauth/anthropic-rate-limit-policy"; @@ -867,9 +868,11 @@ export async function prepareAdapterExchange( return { failed: formatErrorResponse(502, "upstream_error", msg) }; } }; - const refetchThinkingSignature = () => { + const refetchThinkingSignature = async () => { invalidateSameTargetRequest(); - return rebuildAndRefetch("opaque-blob-rejection"); + const result = await rebuildAndRefetch("opaque-blob-rejection"); + if (!("failed" in result)) bindAnthropicPrefixResponse(transportState.sameTargetRequest, result); + return result; }; if (!upstreamResponse.ok) { const rotateAntigravityAuth = async ( @@ -929,6 +932,7 @@ export async function prepareAdapterExchange( return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal); } finally { cleanupUpstreamAbort(); } } + bindAnthropicPrefixResponse(transportState.sameTargetRequest, upstreamResponse); if (await thinkingSignatureRecovery.prepareHttp( upstreamResponse, transportState.activeAdapter.name, )) { @@ -1904,6 +1908,7 @@ export async function prepareAdapterExchange( } } + bindAnthropicPrefixResponse(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..0bc5c62c74c 100644 --- a/src/server/responses/anthropic-thinking-recovery.ts +++ b/src/server/responses/anthropic-thinking-recovery.ts @@ -1,3 +1,5 @@ +import { anthropicRejectionPolicy, rejectedAnthropicPayloads } from "../../adapters/anthropic-rejection-memo"; +import { invalidateAnthropicPrefixGeneration } from "../../adapters/anthropic-prefix"; import { normalizeUpstreamErrorText, readDisplaySafeErrorText } from "./core-errors"; import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { isLikelyRealAnthropicThinkingSignature } from "../../adapters/anthropic"; @@ -29,20 +31,23 @@ 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) + || parsed._stripReasoningEncryptedContent === true || anthropicRejectionPolicy(parsed) || !carriesThinkingState(attempt) || !isAnthropicThinkingSignatureBindingRejectionMessage(message)) return false; + rejectedPayloads = rejectedAnthropicPayloads(response); guard.attempted = true; armed = true; const scope = parsed._reasoningReplayScope; rejectedScope = scope ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined; + invalidateAnthropicPrefixGeneration(parsed); prepareOpaqueBlobRecovery(parsed); if (attempt !== parsed) prepareOpaqueBlobRecovery(attempt); return true; @@ -51,12 +56,12 @@ export function createAnthropicThinkingRecovery( 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(attempt)) return false; + || parsed._stripReasoningEncryptedContent === true || anthropicRejectionPolicy(parsed) || !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, attempt); + return typeof message === "string" && prepare(message, adapterName, attempt, response); } catch { return false; } @@ -74,6 +79,7 @@ export function createAnthropicThinkingRecovery( retry: () => AsyncIterable, adapterName: () => string, replayAllowed = true, + response?: Response, ): AsyncGenerator { let source = events; let replaySafe = replayAllowed; @@ -85,7 +91,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(), parsed, response)) { // Close the rejected body before acquiring another dispatch/pacing slot. await iterator.return?.(); source = retry(); @@ -113,7 +119,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/types.ts b/src/types.ts index 63425b6c6ba..d8c8ab2d848 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,7 @@ export type { OcxDocumentContent, OcxContentPart, OcxThinkingContent, + AnthropicPrefixHint, OcxToolCall, OcxProviderOpaqueToolCallMetadata, OcxAssistantContentPart, diff --git a/src/types/request.ts b/src/types/request.ts index 804199c7919..6681c733346 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -291,6 +291,17 @@ export interface OcxDocumentContent { /** A user/developer message content part: text, native media, or an attached document. */ export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent | OcxDocumentContent; +/** Versioned observation of the exact Anthropic prefix at one completed thinking block. */ +export interface AnthropicPrefixHint { + v: 1; + scheme: "anthropic-prefix-v1"; + /** Digests use exactly 64 lowercase hexadecimal characters. */ + scope: string; + prefix: string; + parent: string; + block: string; +} + export interface OcxThinkingContent { type: "thinking"; thinking: string; @@ -298,6 +309,8 @@ export interface OcxThinkingContent { itemId?: string; /** Raw Anthropic redacted_thinking block payloads to replay verbatim (order preserved). */ redacted?: string[]; + /** Internal replay hint; not evidence of final response success or a provider attestation. */ + anthropicPrefix?: AnthropicPrefixHint; } export interface OcxToolCall { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index e4270149e53..c1ab6b1ea0b 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -1,3 +1,4 @@ +import { bindAnthropicPrefixResponse, aliasAnthropicPrefixResponse } from "../adapters/anthropic-prefix"; 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. @@ -735,6 +737,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise {}; try { const parse: ProviderAdapter["parseStream"] = (response, budget) => { + aliasAnthropicPrefixResponse(prepared.response, response); if (prepared.responseAdapter.name === "anthropic") releaseCapture = enableAnthropicReplayCapture(response); return prepared.responseAdapter.parseStream(response, budget); }; diff --git a/structure/INDEX.md b/structure/INDEX.md index ef17d12c64e..d2071930a97 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -42,7 +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/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | Anthropic prefix-consistency observations, one-shot signature-binding recovery 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. | @@ -142,6 +142,10 @@ 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-prefix-hints.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | +| `src/adapters/anthropic-prefix-observer.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | +| `src/adapters/anthropic-prefix.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | +| `src/adapters/anthropic-rejection-memo.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) | diff --git a/structure/manifest.json b/structure/manifest.json index 848b6b40d1a..48d0f3a227d 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -219,9 +219,13 @@ "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.", + "scope": "Anthropic prefix-consistency observations, one-shot signature-binding recovery and successful-retry memo boundaries.", "documents": [ - "src/server/responses/anthropic-thinking-recovery.ts" + "src/server/responses/anthropic-thinking-recovery.ts", + "src/adapters/anthropic-prefix.ts", + "src/adapters/anthropic-prefix-observer.ts", + "src/adapters/anthropic-prefix-hints.ts", + "src/adapters/anthropic-rejection-memo.ts" ] }, { diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md index 4a135cf9b88..fbca3668704 100644 --- a/structure/transports/anthropic-thinking-recovery.md +++ b/structure/transports/anthropic-thinking-recovery.md @@ -14,6 +14,8 @@ Only harmless heartbeats may precede a streamed recovery: text, thinking, bounda 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. +It invalidates the request-local observation generation: older opaque blocks are stripped, while +complete blocks observed on the repaired request can survive later search iterations. 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. @@ -27,6 +29,99 @@ durable-serving-identity key and five-minute TTL are unchanged. 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. +## Selective rejection memos + +`src/adapters/anthropic-rejection-memo.ts` captures the complete opaque-payload fingerprint set +from the final Anthropic request body after normalization and prefix filtering. Private WeakMaps +bind that set to the actual adapter request and its exact upstream `Response`; recovery does not +infer what was sent from a later rebuilt request or a caller-supplied hint. Each fingerprint is a +64-character lowercase SHA-256 digest of the block type, a separator, and the signature bytes for +`thinking` or data bytes for `redacted_thinking`. The domains are separate even when their opaque +bytes are identical. Plaintext and `ap` metadata do not participate, so editing either cannot make +an already rejected opaque payload appear new. Only a complete, nonempty, reliable set of at most +256 distinct fingerprints permits selective memoization; absent/incomplete capture falls back to +the existing blanket policy. + +`src/responses/reasoning-replay-cache.ts` retains these fingerprints in the existing rejection +memo after the same validated successful-repair gate. Repeated selective entries union their sets; +invalid, empty or overflowing input becomes blanket, and an existing blanket entry cannot be +narrowed before expiry. Each retained fingerprint adds 64 bytes to the existing 256 KiB rejection +memo budget; the 64-entry oldest-first bound, durable scope, five-minute TTL and clear behavior are +unchanged. Getter results contain defensive set copies. No raw opaque payload is stored by this +memo, and no new cache, persistent key or database is introduced. + +An active selective memo removes the captured rejected payloads, even if their visible text or +hint changed. Legacy blocks and blocks without a comparable recognized prefix observation retain +the conservative strip behavior under an active memo. A different opaque payload with a recognized +same-scope observation is only a candidate for retention: its block, non-thinking prefix and parent +chain must still pass the normal prefix-consistency checks. Older/blanket memos and unreliable or +oversized captures keep the existing blanket behavior; other adapters are unchanged. An active +memo never opens another signature-recovery attempt or increases the request send budget. The +memo is still process-local and does not change the legacy/no-new-thinking restart limitations. + +`tests/adapters/reasoning-replay-identity.test.ts` covers selective union, blanket fallback, +defensive copies, byte/count eviction, expiry and scope isolation. + +## Portable prefix-consistency observations + +`src/adapters/anthropic-prefix.ts` compares replay observations against the final normalized +Anthropic body, after effective system guidance, advertised tools, message/tool normalization and +cache annotations have been applied. It hashes the effective system and tool definitions, message +boundaries and ordered non-thinking content. Signed/redacted blocks have a separate ordered +parent/block digest chain, so removing an incompatible opaque segment does not itself change the +non-thinking prefix. Assistant containers containing only opaque blocks do not contribute to it. +Object-key order is canonicalized; array, message and content-block order remains significant. +Only `cache_control` annotations at protocol system-block, tool-definition and message-content-block +positions are excluded. A field with that name inside a tool input, schema or other nested content +remains significant. + +The existing `ocxr1` reasoning envelope can carry optional `ap` metadata with `v: 1`, +`scheme: "anthropic-prefix-v1"`, and four 64-character lowercase SHA-256 hex digests: `scope`, +`prefix`, `parent`, and `block`. Scope binds the durable provider destination, physical credential +identity and model. The envelope remains transparent base64 JSON, not encryption; the hashes are +consistency observations, not authentication, local verification of Anthropic's signature, or a +claim that Anthropic accepts every field in this comparison as its own signing contract. + +`src/adapters/anthropic-prefix-observer.ts` observes ordered complete blocks from the response +associated with that request. Streaming hints are issued only after the matching `content_block_stop`; +JSON blocks are observed through equivalent start/stop processing. The final signature event, or +one complete redacted event, receives private metadata through `src/adapters/anthropic-prefix-hints.ts`. +SSE and JSON bridges copy it into that block's envelope without adding public adapter-event fields. +An unfinished or invalidated redacted observation retains its original payload without a hint when +normal error/EOF processing can emit it; cancellation releases pending buffers without minting one. +A complete-block hint remains an observation even if the response later fails or is incomplete. +It cannot authorize a retry, establish final success, or commit the rejection memo. + +Recognized same-scope prefix/block mismatches remove only the incompatible opaque blocks. A retained +block must also match the surviving opaque parent chain; a root parent can establish a new empty +chain and retire older opaque predecessors after an omission. An unresolved parent-chain mismatch +fails before sending rather than inventing a predecessor. Fresh observations from the current +post-recovery generation remain eligible during later search iterations despite the older strip +flag. Plaintext, tool calls, executed tool results, advertised tools and reasoning mode are preserved. +If omission would leave a manual `thinking: enabled` tool-result continuation without its required +assistant thinking, the adapter reports incompatible history before sending; it does not disable +thinking or re-execute the tool. + +Missing/malformed metadata, unknown versions and unknown scopes are not themselves evidence of +invalidity; the original payload remains governed by legacy policies, including an active rejection +memo's conservative stripping. If an +already-pruned chain cannot be reconciled with a later unrecognized block, the guard fails closed. +No comparable durable scope means no new observation. The client-facing bridge cannot preserve the +original assistant-message shape for thinking after text/tool content, so those blocks remain +legacy, without a client hint. Opted-in raw search snapshots preserve that shape and may retain the +private observation. Unsupported block shapes likewise do not acquire an invented client hint. + +No client change, new signing key, database or persistent store is introduced. Comparable hints +travel with existing reasoning items and work independently of process caches. This does not add a +durable omission marker to responses with no newly observed signed/redacted thinking: such repairs, +and legacy histories without comparable hints, can still depend on the existing five-minute +in-memory rejection memo and encounter the original refusal again after restart. + +`tests/responses/responses-anthropic-prefix-consistency.test.ts` covers replay across fresh cache +state, prefix edits, selective omission, repaired generations, manual-tool refusal, unknown metadata, +complete/incomplete blocks and later failures. `tests/responses/reasoning-envelope-prefix.test.ts` +and `tests/adapters/bridge-anthropic-prefix.test.ts` cover bounded decoding and private propagation. + ## Hosted search iterations Search history uses the bridge's signature snapshot semantics: consecutive signature updates, @@ -86,3 +181,7 @@ 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. + +Request-local fingerprint capture charges 256 retained translation-budget bytes per unique digest, +including conservative Set/copy overhead, until the existing turn budget is disposed. Partial +capture is refunded on invalid input, admission denial or overflow and cannot narrow the memo. 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/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/adapters/bridge-anthropic-prefix.test.ts b/tests/adapters/bridge-anthropic-prefix.test.ts new file mode 100644 index 00000000000..71dea90b234 --- /dev/null +++ b/tests/adapters/bridge-anthropic-prefix.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import { getAnthropicPrefixHint, setAnthropicPrefixHint } from "../../src/adapters/anthropic-prefix-hints"; +import { decodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import type { AdapterEvent, AnthropicPrefixHint } from "../../src/types"; + +function hint(block: string): AnthropicPrefixHint { + return { v: 1, scheme: "anthropic-prefix-v1", scope: "a".repeat(64), prefix: "b".repeat(64), parent: "c".repeat(64), block: block.repeat(64) }; +} +function tagged(event: AdapterEvent, value: AnthropicPrefixHint): AdapterEvent { + setAnthropicPrefixHint(event, value); + return event; +} + +async function output(events: AdapterEvent[], stream: boolean, hidden: boolean): Promise[]> { + if (!stream) return buildResponseJSON(events, "claude-x", { hideThinkingSummary: hidden }).output as Record[]; + async function* replay() { yield* events; } + const wire = await new Response(bridgeToResponsesSSE(replay(), "claude-x", undefined, undefined, undefined, undefined, undefined, { hideThinkingSummary: hidden })).text(); + return wire.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))) + .filter(event => event.type === "response.output_item.done").map(event => event.item); +} + +const cases = [ + { stream: true, hidden: false }, { stream: true, hidden: true }, + { stream: false, hidden: false }, { stream: false, hidden: true }, +]; + +describe("private Anthropic prefix bridge propagation", () => { + test("event hints never become public enumerable metadata", () => { + const event = tagged({ type: "thinking_signature", signature: "signature" }, hint("d")); + expect(Object.keys(event)).toEqual(["type", "signature"]); + expect(JSON.stringify(event)).toBe('{"type":"thinking_signature","signature":"signature"}'); + expect(getAnthropicPrefixHint(event)).toEqual(hint("d")); + expect(getAnthropicPrefixHint({ ...event })).toBeUndefined(); + }); + + test.each(cases)("keeps hints on their own signed or redacted blocks: %j", async ({ stream, hidden }) => { + const items = await output([ + { type: "thinking_delta", thinking: "first" }, + { type: "thinking_signature", signature: "signature-update" }, + { type: "heartbeat" }, + tagged({ type: "thinking_signature", signature: "signature-final" }, hint("d")), + tagged({ type: "redacted_thinking", data: "red-one" }, hint("e")), + tagged({ type: "redacted_thinking", data: "red-two" }, hint("f")), + { type: "thinking_delta", thinking: "second" }, + { type: "thinking_signature", signature: "legacy-signature" }, + { type: "text_delta", text: "answer" }, + { type: "done" }, + ], stream, hidden); + const reasoning = items.filter(item => item.type === "reasoning"); + expect(reasoning.map(item => decodeReasoningEnvelope(item.encrypted_content as string))).toEqual([ + { sig: "signature-final", ap: hint("d"), ...(hidden ? { txt: "first" } : {}) }, + { red: ["red-one"], ap: hint("e") }, { red: ["red-two"], ap: hint("f") }, + { sig: "legacy-signature", ...(hidden ? { txt: "second" } : {}) }, + ]); + for (const item of items) { + expect(item).not.toHaveProperty("ap"); + expect(item).not.toHaveProperty("anthropicPrefix"); + } + }); + + test.each(cases)("completed block observation survives a later response failure: %j", async ({ stream, hidden }) => { + const items = await output([ + { type: "thinking_delta", thinking: "complete block" }, + tagged({ type: "thinking_signature", signature: "signature-final" }, hint("d")), + { type: "error", message: "later upstream failure" }, + ], stream, hidden); + const reasoning = items.find(item => item.type === "reasoning")!; + expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)).toEqual({ + sig: "signature-final", ap: hint("d"), ...(hidden ? { txt: "complete block" } : {}), + }); + }); + + test.each(cases)("keeps empty signed blocks separate at their existing thinking boundaries: %j", async ({ stream, hidden }) => { + const items = await output([ + { type: "thinking_delta", thinking: "" }, + tagged({ type: "thinking_signature", signature: "signature-one" }, hint("d")), + { type: "thinking_delta", thinking: "" }, + tagged({ type: "thinking_signature", signature: "signature-two" }, hint("e")), + { type: "done" }, + ], stream, hidden); + expect(items.filter(item => item.type === "reasoning").map(item => decodeReasoningEnvelope(item.encrypted_content as string))).toEqual([ + { sig: "signature-one", ap: hint("d") }, { sig: "signature-two", ap: hint("e") }, + ]); + }); + + test.each(cases)("ignores client-shaped enumerable metadata without the private observation: %j", async ({ stream, hidden }) => { + const fake = Object.assign({ type: "thinking_signature", signature: "signature" } as AdapterEvent, { ap: hint("d"), anthropicPrefix: hint("d") }); + const items = await output([fake, { type: "done" }], stream, hidden); + const reasoning = items.find(item => item.type === "reasoning")!; + expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)).toEqual({ sig: "signature" }); + }); +}); 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 484be4d57bf..52fec671a6d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -223,7 +223,7 @@ "bounded-body.test.ts": "server", "brand-favicons.test.ts": "ci-workflows", "bridge-legacy-shell-normalization.test.ts": "adapters", "bridge-lifecycle.test.ts": "adapters", "bridge-live-delivery.test.ts": "server", "bridge-nonstreaming-terminal.test.ts": "adapters", - "bridge-raw-reasoning-hidden.test.ts": "adapters", "bridge-reasoning-replay-batch.test.ts": "adapters", + "bridge-raw-reasoning-hidden.test.ts": "adapters", "bridge-reasoning-replay-batch.test.ts": "adapters", "bridge-anthropic-prefix.test.ts": "adapters", "bridge-stall.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "ci-gui-typecheck-gate.test.ts": "ci-workflows", @@ -931,7 +931,7 @@ "qwen38-preserve-reasoning.test.ts": "providers", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", "raycast-client.test.ts": "clients", "raycast-detect.test.ts": "clients", "reasoning-effort-summary-default.test.ts": "responses", - "reasoning-effort.test.ts": "codex-integration", "reasoning-envelope.test.ts": "responses", + "reasoning-effort.test.ts": "codex-integration", "reasoning-envelope.test.ts": "responses", "reasoning-envelope-prefix.test.ts": "responses", "reasoning-metadata.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", "relay-eager.test.ts": "server", @@ -972,6 +972,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-anthropic-prefix-consistency.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/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 516521ecf48..5cd730c5519 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -23,6 +23,7 @@ export const RESPONSES_CORE_MODULES = [ "core-replay.ts", "core-errors.ts", "core-opaque-recovery.ts", + "anthropic-thinking-recovery.ts", "core-codex-account.ts", "core-combo-failure.ts", "core-auth.ts", diff --git a/tests/responses/reasoning-envelope-prefix.test.ts b/tests/responses/reasoning-envelope-prefix.test.ts new file mode 100644 index 00000000000..f6660a30c4e --- /dev/null +++ b/tests/responses/reasoning-envelope-prefix.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import type { AnthropicPrefixHint, OcxThinkingContent } from "../../src/types"; +import { parseRequest } from "../../src/responses/parser"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX, type ReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { createTranslatorBudget, TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; + +const hint: AnthropicPrefixHint = { + v: 1, scheme: "anthropic-prefix-v1", + scope: "a".repeat(64), prefix: "b".repeat(64), parent: "c".repeat(64), block: "d".repeat(64), +}; +const legacy = { sig: "original-signature", red: ["red-one", "red-two"], txt: "exact\ntext\u0000" }; +const wire = (value: unknown): string => OCX_REASONING_PREFIX + Buffer.from(JSON.stringify(value)).toString("base64"); + +function parsedThinking(envelopes: ReasoningEnvelope[]): OcxThinkingContent[] { + const parsed = parseRequest({ + model: "anthropic/claude-x", + input: [ + ...envelopes.map((envelope, index) => ({ type: "reasoning", id: `rs_${index}`, summary: [], encrypted_content: encodeReasoningEnvelope(envelope) })), + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + }); + return parsed.context.messages.flatMap(message => message.role === "assistant" + ? message.content.filter((part): part is OcxThinkingContent => part.type === "thinking") : []); +} + +describe("optional Anthropic prefix envelope metadata", () => { + test("keeps old envelope bytes and payload semantics unchanged", () => { + expect(encodeReasoningEnvelope(legacy)).toBe(wire(legacy)); + expect(decodeReasoningEnvelope(wire(legacy))).toEqual(legacy); + expect(decodeReasoningEnvelope("native-provider-ciphertext")).toBeNull(); + }); + + test("recognizes the complete v1 hint and copies only known fields", () => { + const encoded = wire({ ...legacy, ap: { ...hint, future: "ignored" } }); + expect(decodeReasoningEnvelope(encoded)).toEqual({ ...legacy, ap: hint }); + expect(decodeReasoningEnvelope(encodeReasoningEnvelope({ sig: "s", ap: hint }))).toEqual({ sig: "s", ap: hint }); + expect(decodeReasoningEnvelope(wire({ ap: hint }))).toBeNull(); + }); + + test.each([ + null, [], "unrecognized", {}, { ...hint, v: 2 }, { ...hint, v: "1" }, + { ...hint, scheme: "anthropic-prefix-v2" }, { ...hint, scope: "A".repeat(64) }, + { ...hint, prefix: "b".repeat(63) }, { ...hint, parent: "c".repeat(65) }, + { ...hint, block: "d".repeat(64) + "\n" }, { ...hint, scope: 0 }, { ...hint, parent: undefined }, + ].map(ap => ({ ap })))("omits malformed or unknown metadata without losing old payload: %j", ({ ap }) => { + expect(decodeReasoningEnvelope(wire({ ...legacy, ap }))).toEqual(legacy); + }); + + test("decoding metadata stays behind the existing pre-allocation budget", () => { + for (const ap of [hint, { ...hint, v: 2, extra: "x".repeat(4096) }]) { + const encoded = wire({ ...legacy, ap }); + const rejected = createTranslatorBudget({ maxTurnBytes: encoded.length * 8 - 1 }); + const from = spyOn(Buffer, "from"); + try { + expect(() => decodeReasoningEnvelope(encoded, rejected)).toThrow(TranslatorBudgetExceededError); + expect(from).not.toHaveBeenCalled(); + expect(rejected.snapshot().currentBytes).toBe(0); + } finally { from.mockRestore(); rejected.dispose(); } + const admitted = createTranslatorBudget({ maxTurnBytes: encoded.length * 8 }); + try { + expect(decodeReasoningEnvelope(encoded, admitted)).toEqual(ap.v === 1 ? { ...legacy, ap: hint } : legacy); + expect(admitted.snapshot().currentBytes).toBe(0); + } finally { admitted.dispose(); } + } + }); + + test("parser carries recognized hints separately from signature and redacted bytes", () => { + expect(parsedThinking([{ ...legacy, ap: hint }])).toEqual([{ + type: "thinking", thinking: legacy.txt, signature: legacy.sig, + redacted: legacy.red, anthropicPrefix: hint, itemId: "rs_0", + }]); + }); + + test("parser never transfers a single-block hint to merged unsigned reasoning", () => { + const parts = parsedThinking([{ txt: "before" }, { txt: "tagged", ap: hint }, { txt: "after" }]); + expect(parts.map(part => part.thinking)).toEqual(["before", "tagged", "after"]); + expect(parts.map(part => part.anthropicPrefix)).toEqual([undefined, hint, undefined]); + }); +}); diff --git a/tests/responses/responses-anthropic-prefix-consistency.test.ts b/tests/responses/responses-anthropic-prefix-consistency.test.ts new file mode 100644 index 00000000000..5ad047f0aee --- /dev/null +++ b/tests/responses/responses-anthropic-prefix-consistency.test.ts @@ -0,0 +1,700 @@ +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 { encodeCompactionSummary } from "../../src/responses/compaction"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { anthropicPrefixDigest, bindAnthropicPrefixResponse } from "../../src/adapters/anthropic-prefix"; +import { createTranslatorBudget, type TranslatorBudget } from "../../src/lib/translator-budget"; +import { resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope, type ReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { parseRequest } from "../../src/responses/parser"; +import { handleResponses } from "../../src/server/responses/core"; +import { bindRouteReasoningReplayScope } from "../../src/server/responses/core-replay"; +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-sonnet-5"; +const REJECTION = "Invalid `signature` in `thinking` block. The block is bound to a different conversation. Remove the thinking block and retry."; +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 upstreamBlocks(content: Item[], stream: boolean): Response { + const usage = { input_tokens: 2, output_tokens: 3 }; + if (!stream) return Response.json({ id: "msg_blocks", type: "message", role: "assistant", content, stop_reason: "end_turn", usage }); + const frames = [frame("message_start", { message: { content: [], usage } })]; + content.forEach((block, index) => { + const contentBlock = block.type === "thinking" ? { type: "thinking", thinking: "" } + : block.type === "text" ? { type: "text", text: "" } : block; + frames.push(frame("content_block_start", { index, content_block: contentBlock })); + if (block.type === "thinking") frames.push( + frame("content_block_delta", { index, delta: { type: "thinking_delta", thinking: block.thinking } }), + frame("content_block_delta", { index, delta: { type: "signature_delta", signature: block.signature } }), + ); + if (block.type === "text") frames.push(frame("content_block_delta", { index, delta: { type: "text_delta", text: block.text } })); + frames.push(frame("content_block_stop", { index })); + }); + frames.push(frame("message_delta", { delta: { stop_reason: "end_turn" }, usage }), frame("message_stop")); + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); +} +function failureAfterBlock(stream: boolean): Response { + if (!stream) return Response.json({ content: [{ type: "thinking", thinking: "Complete fixture reasoning", signature: SECOND_SIGNATURE }], + stop_reason: "error", usage: { input_tokens: 2, output_tokens: 3 } }); + return new Response([...signedFrames(SECOND_SIGNATURE), frame("error", { error: { type: "invalid_request_error", message: REJECTION } })].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 }; +} +function envelope(output: Item[]): ReasoningEnvelope { + const reasoning = output.find(item => item.type === "reasoning"); + expect(reasoning).toBeDefined(); + const decoded = decodeReasoningEnvelope(String(reasoning!.encrypted_content)); + expect(decoded).not.toBeNull(); + return decoded!; +} +function expectHint(output: Item[], signature = SIGNATURE): void { + const decoded = envelope(output); + expect(decoded.sig).toBe(signature); + expect(decoded.ap).toEqual({ v: 1, scheme: "anthropic-prefix-v1", scope: expect.stringMatching(/^[0-9a-f]{64}$/), + prefix: expect.stringMatching(/^[0-9a-f]{64}$/), parent: expect.stringMatching(/^[0-9a-f]{64}$/), block: expect.stringMatching(/^[0-9a-f]{64}$/) }); + output.forEach(item => { expect(item).not.toHaveProperty("ap"); expect(item).not.toHaveProperty("anthropicPrefix"); }); +} +function expectAdaptive(body: string): void { expect(JSON.parse(body).thinking.type).toBe("adaptive"); } + +type OpaqueKind = "thinking" | "redacted"; +function memoBlocks(kind: OpaqueKind, payload: string): Item[] { + return [kind === "thinking" ? { type: "thinking", thinking: "Complete fixture reasoning", signature: payload } + : { type: "redacted_thinking", data: payload }, { type: "text", text: "Fixture answer" }]; +} +function bindingRejection(): Response { + return Response.json({ type: "error", error: { type: "invalid_request_error", message: REJECTION } }, { status: 400 }); +} +async function recoveredMemoScenario(stream: boolean, kind: OpaqueKind, nextRefusal?: "http" | "sse") { + const oldPayload = kind === "thinking" ? SIGNATURE : "synthetic-rejected-redacted-payload"; + const newPayload = kind === "thinking" ? SECOND_SIGNATURE : "synthetic-fresh-redacted-payload"; + const bodies = transport((index, upstreamStream) => { + if (index === 2) return bindingRejection(); + if (index === 4 && nextRefusal) return nextRefusal === "http" ? bindingRejection() + : new Response(frame("error", { error: { type: "invalid_request_error", message: REJECTION } }), + { headers: { "content-type": "text/event-stream" } }); + return upstreamBlocks(memoBlocks(kind, index === 1 ? oldPayload : newPayload), upstreamStream); + }); + const first = await send([user("First question")], stream); + expect(envelope(first.output).ap).toBeDefined(); + const history = [user("First question"), ...first.output, user("Second question")]; + const recovered = await send(history, stream); + expect(envelope(recovered.output).ap).toBeDefined(); + expect(recovered.log.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + expect(bodies).toHaveLength(3); + expect(bodies[1]).toContain(oldPayload); + expect(bodies[2]).not.toContain(oldPayload); + return { bodies, oldPayload, newPayload, history, recovered }; +} + +describe("Anthropic prefix hints through actual Responses requests", () => { + for (const stream of [false, true]) for (const hidden of [false, true]) { + test(`minted envelope survives unchanged and append-only replay without caches (stream=${stream}, hidden=${hidden})`, async () => { + const bodies = transport((_index, upstreamStream) => upstream(SIGNATURE, false, upstreamStream)); + const overrides = { reasoning: { effort: "high", summary: hidden ? "none" : "detailed" } }; + const first = await send([user("First question")], stream, overrides); + expect(first.response.status).toBe(200); + expectHint(first.output); + expect(JSON.stringify(first.log)).not.toContain(envelope(first.output).ap!.scope); + expect(JSON.stringify(first.log)).not.toContain(SIGNATURE); + if (hidden) expect(envelope(first.output).txt).toBe("Complete fixture reasoning"); + // The replay crosses a JSON boundary, with fresh configuration and no serving/rejection cache. + const history = JSON.parse(JSON.stringify([user("First question"), ...first.output])) as Item[]; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + await send(history, stream, overrides); + await send([...history, user("Append-only next question")], stream, overrides); + expect(bodies).toHaveLength(3); + for (const body of bodies.slice(1)) { + expect(body).toContain(SIGNATURE); + expect(body).toContain("Complete fixture reasoning"); + expectAdaptive(body); + expect(body).not.toContain("anthropic-prefix-v1"); + expect(body).not.toContain('"anthropicPrefix"'); + } + }); + } + + for (const stream of [false, true]) for (const change of ["earlier-input", "system", "tools", "compaction"] as const) { + test(`known ${change} mismatch removes incompatible thinking without changing mode (stream=${stream})`, async () => { + const bodies = transport((_index, upstreamStream) => upstream(SIGNATURE, false, upstreamStream)); + const first = await send([user("First question")], stream); + expectHint(first.output); + const history = [user("First question"), ...first.output, user("Next question")]; + const overrides: Item = {}; + if (change === "earlier-input") history[0] = user("Edited first question"); + if (change === "system") overrides.instructions = "Changed system instructions."; + if (change === "tools") overrides.tools = [{ ...TOOLS[0], description: "Changed lookup contract" }]; + if (change === "compaction") history.splice(0, 1, { type: "compaction", + encrypted_content: encodeCompactionSummary("A compacted summary replaces the original question.") }); + const result = await send(history, stream, overrides); + expect(result.response.status).toBe(200); + expect(bodies).toHaveLength(2); + expect(bodies[1]).not.toContain(SIGNATURE); + expect(bodies[1]).not.toContain('"type":"thinking"'); + expect(bodies[1]).toContain("Fixture answer"); + expect(bodies[1]).toContain("Next question"); + expectAdaptive(bodies[1]!); + expect(result.log.activeAttempt?.sendCount).toBe(1); + expect(result.log.activeAttempt?.recoveryKinds ?? []).toEqual([]); + }); + } + + for (const stream of [false, true]) { + test(`edits between generations remove only the invalid segment (stream=${stream})`, async () => { + const bodies = transport((index, upstreamStream) => upstream(index === 1 ? SIGNATURE : SECOND_SIGNATURE, false, upstreamStream)); + const first = await send([user("First question")], stream); + const secondInput = [user("First question"), ...first.output, user("Second question")]; + const second = await send(secondInput, stream); + expectHint(second.output, SECOND_SIGNATURE); + const changed = [...secondInput.slice(0, -1), user("Edited second question"), ...second.output, user("Third question")]; + const result = await send(changed, stream); + expect(result.response.status).toBe(200); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + expect(bodies[2]).not.toContain(SECOND_SIGNATURE); + expect(bodies[2]).toContain("Edited second question"); + expect(bodies[2]).toContain("Third question"); + }); + + test(`newly minted thinking after omission survives the next request (stream=${stream})`, async () => { + const bodies = transport((index, upstreamStream) => upstream(index === 1 ? SIGNATURE : SECOND_SIGNATURE, false, upstreamStream)); + const first = await send([user("First question")], stream); + const changed = [user("Edited first question"), ...first.output, user("Second question")]; + const second = await send(changed, stream); + expectHint(second.output, SECOND_SIGNATURE); + const third = await send([...changed, ...second.output, user("Third question")], stream); + expect(third.response.status).toBe(200); + expect(bodies).toHaveLength(3); + expect(bodies[1]).not.toContain(SIGNATURE); + expect(bodies[2]).not.toContain(SIGNATURE); + expect(bodies[2]).toContain(SECOND_SIGNATURE); + expectAdaptive(bodies[2]!); + }); + + test(`successful recovery memo preserves new matched thinking across requests (stream=${stream})`, async () => { + const bodies = transport((index, upstreamStream) => index === 2 + ? Response.json({ type: "error", error: { type: "invalid_request_error", message: REJECTION } }, { status: 400 }) + : upstream(index >= 3 ? SECOND_SIGNATURE : SIGNATURE, false, upstreamStream)); + const first = await send([user("First question")], stream); + const history = [user("First question"), ...first.output, user("Second question")]; + const recovered = await send(history, stream); + expectHint(recovered.output, SECOND_SIGNATURE); + expect(recovered.log.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + expect(bodies).toHaveLength(3); + expect(bodies[2]).not.toContain(SIGNATURE); + const next = await send([...history, ...recovered.output, user("Third question")], stream); + expect(next.response.status).toBe(200); + expect(bodies).toHaveLength(4); + expect(bodies[3]).not.toContain(SIGNATURE); + expect(bodies[3]).toContain(SECOND_SIGNATURE); + }); + + test(`completed block hints do not convert a failed turn to success or authorize a retry (stream=${stream})`, async () => { + const bodies = transport((index, upstreamStream) => index === 2 ? failureAfterBlock(upstreamStream) : upstream(SIGNATURE, false, upstreamStream)); + const first = await send([user("First question")], stream); + const history = [user("First question"), ...first.output, user("Second question")]; + const failed = await send(history, stream); + expect(failed.text).toContain(stream ? REJECTION : 'upstream ended the turn'); + if (stream) expect(failed.text).toContain("response.failed"); + else expect(failed.json.status).toBe("failed"); + expectHint(failed.output, SECOND_SIGNATURE); + expect(bodies).toHaveLength(2); + await send(history, stream); + expect(bodies).toHaveLength(3); + expect(bodies[2]).toContain(SIGNATURE); + }); + + test(`failed recovered turn keeps its completed observation without memoizing omission (stream=${stream})`, async () => { + const bodies = transport((index, upstreamStream) => index === 2 + ? Response.json({ type: "error", error: { type: "invalid_request_error", message: REJECTION } }, { status: 400 }) + : index === 3 ? failureAfterBlock(upstreamStream) : upstream(SIGNATURE, false, upstreamStream)); + const first = await send([user("First question")], stream); + const history = [user("First question"), ...first.output, user("Second question")]; + const failed = await send(history, stream); + if (stream) expect(failed.text).toContain("response.failed"); + else expect(failed.json.status).toBe("failed"); + expectHint(failed.output, SECOND_SIGNATURE); + expect(bodies).toHaveLength(3); + expect(bodies[2]).not.toContain(SIGNATURE); + expect(failed.log.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + await send(history, stream); + expect(bodies).toHaveLength(4); + expect(bodies[3]).toContain(SIGNATURE); + }); + + test(`manual tool continuation reports incompatible history without a downgraded send (stream=${stream})`, async () => { + const bodies = transport((_index, upstreamStream) => upstream(SIGNATURE, true, upstreamStream)); + const overrides = { model: "anthropic-test/claude-sonnet-4-5" }; + const first = await send([user("Run the fixture tool")], stream, overrides); + expectHint(first.output); + expect(JSON.parse(bodies[0]!).thinking.type).toBe("enabled"); + const next = await send([user("Edited request for the fixture tool"), ...first.output, + { type: "function_call_output", call_id: "tool_fixture", output: "Already executed fixture result" }], stream, overrides); + expect(next.text).toContain("incompatible"); + expect(next.text).toContain("consistent history"); + expect(bodies).toHaveLength(1); + }); + } + + for (const stream of [false, true]) { + test(`redacted and consecutive signed blocks preserve their own hints and order (stream=${stream})`, async () => { + const blocks = [ + { type: "thinking", thinking: "First thinking", signature: SIGNATURE }, + { type: "redacted_thinking", data: "synthetic-redacted-prefix-one" }, + { type: "redacted_thinking", data: "synthetic-redacted-prefix-two" }, + { type: "thinking", thinking: "Second thinking", signature: SECOND_SIGNATURE }, + { type: "text", text: "Fixture answer" }, + ]; + const bodies = transport((_index, upstreamStream) => upstreamBlocks(blocks, upstreamStream)); + const first = await send([user("First question")], stream); + const reasoning = first.output.filter(item => item.type === "reasoning").map(item => decodeReasoningEnvelope(String(item.encrypted_content))!); + expect(reasoning).toHaveLength(4); + expect(reasoning.map(item => item.sig ?? item.red?.[0])).toEqual([ + SIGNATURE, "synthetic-redacted-prefix-one", "synthetic-redacted-prefix-two", SECOND_SIGNATURE, + ]); + reasoning.forEach(item => expect(item.ap?.scheme).toBe("anthropic-prefix-v1")); + expect(new Set(reasoning.map(item => item.ap?.parent)).size).toBe(4); + await send([user("First question"), ...first.output, user("Next question")], stream); + const replayed = JSON.parse(bodies[1]!).messages.find((item: Item) => item.role === "assistant").content; + expect(replayed.map(({ cache_control: _cache, ...block }: Item) => block)).toEqual(blocks); + await send([user("Edited first question"), ...first.output, user("Next question")], stream); + expect(bodies).toHaveLength(3); + expect(bodies[2]).not.toContain(SIGNATURE); + expect(bodies[2]).not.toContain(SECOND_SIGNATURE); + expect(bodies[2]).not.toContain("synthetic-redacted-prefix"); + }); + } + + for (const stream of [false, true]) for (const thinking of ["", "Consecutive thinking"]) { + test(`consecutive signed blocks keep separate envelopes and exact replay text (stream=${stream}, empty=${!thinking})`, async () => { + const blocks = [{ type: "thinking", thinking, signature: SIGNATURE }, + { type: "thinking", thinking, signature: SECOND_SIGNATURE }, { type: "text", text: "Fixture answer" }]; + const bodies = transport((_index, upstreamStream) => upstreamBlocks(blocks, upstreamStream)); + const first = await send([user("First question")], stream); + const reasoning = first.output.filter(item => item.type === "reasoning").map(item => decodeReasoningEnvelope(String(item.encrypted_content))!); + expect(reasoning.map(item => item.sig)).toEqual([SIGNATURE, SECOND_SIGNATURE]); + expect(reasoning.map(item => item.ap?.scheme)).toEqual(["anthropic-prefix-v1", "anthropic-prefix-v1"]); + await send([user("First question"), ...first.output, user("Next question")], stream); + const replayed = JSON.parse(bodies[1]!).messages.find((item: Item) => item.role === "assistant").content; + expect(replayed.map(({ cache_control: _cache, ...block }: Item) => block)).toEqual(blocks); + }); + } + + for (const stream of [false, true]) { + test(`signed-only assistant output replays before a user continuation (stream=${stream})`, async () => { + const blocks = [{ type: "thinking", thinking: "Signed-only reasoning", signature: SIGNATURE }]; + const bodies = transport((_index, upstreamStream) => upstreamBlocks(blocks, upstreamStream)); + const first = await send([user("First question")], stream); + expectHint(first.output); + await send([user("First question"), ...first.output, user("Next question")], stream); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(SIGNATURE); + expect(bodies[1]).toContain("Signed-only reasoning"); + }); + + test(`thinking following assistant text stays legacy when its wire prefix cannot round-trip (stream=${stream})`, async () => { + const blocks = [{ type: "thinking", thinking: "First thinking", signature: SIGNATURE }, + { type: "text", text: "Intermediate assistant text" }, + { type: "thinking", thinking: "Second thinking", signature: SECOND_SIGNATURE }, + { type: "text", text: "Fixture answer" }]; + const bodies = transport((_index, upstreamStream) => upstreamBlocks(blocks, upstreamStream)); + const first = await send([user("First question")], stream); + const reasoning = first.output.filter(item => item.type === "reasoning").map(item => decodeReasoningEnvelope(String(item.encrypted_content))!); + expect(reasoning.map(item => item.ap?.scheme)).toEqual(["anthropic-prefix-v1", undefined]); + await send([user("First question"), ...first.output, user("Next question")], stream); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(SIGNATURE); + expect(bodies[1]).toContain(SECOND_SIGNATURE); + }); + } + + for (const ending of ["eof", "error", "message-stop", "invalidated"] as const) { + test(`unfinished redacted block preserves its payload without a hint at ${ending}`, async () => { + const redacted = "synthetic-partial-redacted-prefix"; + const frames = [frame("message_start", { message: { content: [], usage: { input_tokens: 2 } } }), + frame("content_block_start", { index: 0, content_block: { type: "redacted_thinking", data: redacted } })]; + if (ending === "error") frames.push(frame("error", { error: { type: "invalid_request_error", message: REJECTION } })); + if (ending === "message-stop") frames.push(frame("message_stop")); + if (ending === "invalidated") frames.push("event: unknown\ndata: not-json\n\n", frame("content_block_stop", { index: 0 }), frame("message_stop")); + const bodies = transport((index, stream) => index === 1 + ? new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }) : upstream(SIGNATURE, false, stream)); + const first = await send([user("First question")], true); + const observed = envelope(first.output); + expect(observed.red).toEqual([redacted]); + expect(observed.ap).toBeUndefined(); + expect(bodies).toHaveLength(1); + if (ending === "eof" || ending === "error") expect(first.text).toContain("response.failed"); + await send([user("First question"), ...first.output, user("Next question")], true); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(redacted); + }); + } + + test("canceling while a redacted block is pending releases retained bytes without issuing a hint", async () => { + const abort = new AbortController(); + const budget = createTranslatorBudget(); + const charged = spyOn(budget, "chargeRetained"); + const released = spyOn(budget, "releaseRetained"); + let abortScheduled = false; + let canceled = false; + let retainedBeforeAbort = 0; + const bodies = transport(() => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode([ + frame("message_start", { message: { content: [], usage: { input_tokens: 2 } } }), + frame("content_block_start", { index: 0, content_block: { type: "redacted_thinking", data: "synthetic-pending-redacted" } }), + ].join(""))); + }, + pull() { + if (abortScheduled) return; + abortScheduled = true; + setTimeout(() => { retainedBeforeAbort = budget.snapshot().currentBytes; abort.abort(); }, 0); + }, + cancel() { canceled = true; }, + }), { headers: { "content-type": "text/event-stream" } })); + let wire = ""; + try { + const response = await handleResponses(request([user("First question")], true), config(), { model: "", provider: "" }, + { abortSignal: abort.signal, translatorBudget: budget }); + try { wire = await response.text(); } catch { /* The client cancellation can reject its stream reader. */ } + expect(abort.signal.aborted).toBe(true); + expect(retainedBeforeAbort).toBeGreaterThan(0); + expect(bodies).toHaveLength(1); + expect(canceled).toBe(true); + expect(wire).not.toContain("anthropic-prefix-v1"); + const completed = wire.split("\n").filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6))).filter(event => event.type === "response.output_item.done"); + completed.forEach(event => expect(decodeReasoningEnvelope(String(event.item.encrypted_content))?.ap).toBeUndefined()); + const reasoningBytes = (calls: Array<[number, { kind: string }]>) => calls + .filter(([, scope]) => scope.kind === "reasoning").reduce((sum, [bytes]) => sum + bytes, 0); + expect(reasoningBytes(charged.mock.calls)).toBeGreaterThan(0); + expect(reasoningBytes(released.mock.calls)).toBe(reasoningBytes(charged.mock.calls)); + expect(budget.snapshot().activeCalls).toBe(0); + } finally { charged.mockRestore(); released.mockRestore(); budget.dispose(); } + }); + + test("failed admission of pending redacted bytes never releases unrelated retained ownership", async () => { + const payload = "SyntheticAdmissionDeniedRedacted"; + const budget = createTranslatorBudget(); + budget.chargeRetained(123, { kind: "reasoning" }); + const wrapped = new Proxy(budget, { + get(target, property) { + if (property === "chargeRetained") return (bytes: number, scope: Parameters[1]) => { + if (bytes === Buffer.byteLength(payload)) throw new Error("synthetic pending admission denial"); + target.chargeRetained(bytes, scope); + }; + const value = Reflect.get(target, property); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const provider = config().providers["anthropic-test"]!; + const parsed = parseRequest({ model: MODEL, input: [user("First question")] }); + parsed._reasoningReplayScope = { clientThreadId: "admission-denial-fixture" }; + bindRouteReasoningReplayScope({ parsed, provider, providerName: "anthropic-test", adapterName: "anthropic" }); + const adapter = createAnthropicAdapter(provider); + const bodies = transport(() => { throw new Error("Unexpected dispatch after local admission denial"); }); + try { + const built = await adapter.buildRequest(parsed, { headers: new Headers(), translatorBudget: budget }); + const response = new Response([ + frame("message_start", { message: { content: [] } }), + frame("content_block_start", { index: 0, content_block: { type: "redacted_thinking", data: payload } }), + ].join(""), { headers: { "content-type": "text/event-stream" } }); + bindAnthropicPrefixResponse(built, response); + const consume = async () => { for await (const event of adapter.parseStream(response, wrapped)) void event; }; + await expect(consume()).rejects.toThrow("synthetic pending admission denial"); + expect(budget.snapshot().currentBytes).toBe(123); + expect(bodies).toHaveLength(0); + budget.releaseRetained(123, { kind: "reasoning" }); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } + }); + + test("canceling a recovered turn after a completed observed block does not memoize omission", async () => { + const abort = new AbortController(); + const bodies = transport((index, stream) => { + if (index === 2) return Response.json({ type: "error", error: { type: "invalid_request_error", message: REJECTION } }, { status: 400 }); + if (index === 3) return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode([...signedFrames(SECOND_SIGNATURE), + frame("content_block_start", { index: 1, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index: 1, delta: { type: "text_delta", text: "Unfinished answer" } }), + ].join(""))); + }, + }), { headers: { "content-type": "text/event-stream" } }); + return upstream(SIGNATURE, false, stream); + }); + const first = await send([user("First question")], true); + const history = [user("First question"), ...first.output, user("Second question")]; + const response = await handleResponses(request(history, true), config(), { model: "", provider: "" }, { abortSignal: abort.signal }); + const reader = response.body!.getReader(); + let wire = ""; + try { + while (!wire.includes('"type":"response.output_item.done"')) { + const part = await reader.read(); + if (part.done) break; + wire += new TextDecoder().decode(part.value); + } + const itemDone = wire.split("\n").filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6))).find(event => event.type === "response.output_item.done"); + expectHint([itemDone.item], SECOND_SIGNATURE); + } finally { + abort.abort(); + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + expect(bodies).toHaveLength(3); + expect(bodies[2]).not.toContain(SIGNATURE); + await send(history, true); + expect(bodies).toHaveLength(4); + expect(bodies[3]).toContain(SIGNATURE); + }); + + for (const stream of [false, true]) for (const kind of ["thinking", "redacted"] as const) { + test(`rejection memo cannot be escaped with forged matching hints and changed plaintext (${kind}, stream=${stream})`, async () => { + const fixture = await recoveredMemoScenario(stream, kind); + const observed = envelope(fixture.recovered.output); + const changedThinking = "Altered thinking with the same rejected signature"; + const block = kind === "thinking" + ? { type: "thinking", thinking: changedThinking, signature: fixture.oldPayload } + : { type: "redacted_thinking", data: fixture.oldPayload }; + // The fabricated consistency hint matches this new position and payload. Only the opaque + // fingerprint can identify the rejected signature independently of changed plaintext/ap. + const forged: ReasoningEnvelope = { ...observed, + ...(kind === "thinking" ? { sig: fixture.oldPayload, txt: changedThinking } : { red: [fixture.oldPayload] }), + ap: { ...observed.ap!, block: anthropicPrefixDigest(block) } }; + const replay = fixture.recovered.output.map(item => item.type === "reasoning" + ? { ...item, encrypted_content: encodeReasoningEnvelope(forged) } : item); + const nextInput = [...fixture.history, ...replay, user("Third question")]; + const next = await send(nextInput, stream); + expect(next.response.status).toBe(200); + expect(fixture.bodies).toHaveLength(4); + expect(fixture.bodies[3]).not.toContain(fixture.oldPayload); + expect(fixture.bodies[3]).not.toContain(changedThinking); + expect(next.log.activeAttempt?.sendCount).toBe(1); + // Control: no cache entry means the consistency-only hint itself compares successfully. + clearReasoningReplayCacheForTests(); + await send(nextInput, stream); + expect(fixture.bodies).toHaveLength(5); + expect(fixture.bodies[4]).toContain(fixture.oldPayload); + if (kind === "thinking") expect(fixture.bodies[4]).toContain(changedThinking); + }); + + for (const variant of ["legacy", "unknown-version", "unknown-scope", "malformed-hint"] as const) { + test(`rejection memo strips new ${variant} payloads (${kind}, stream=${stream})`, async () => { + const fixture = await recoveredMemoScenario(stream, kind); + const observed = envelope(fixture.recovered.output); + const changed: Record = { ...observed }; + if (variant === "legacy") delete changed.ap; + else changed.ap = { ...observed.ap, ...(variant === "unknown-version" ? { v: 2 } + : variant === "unknown-scope" ? { scope: "0".repeat(64) } : { block: "malformed" }) }; + const replay = fixture.recovered.output.map(item => item.type === "reasoning" + ? { ...item, encrypted_content: encodeReasoningEnvelope(changed as ReasoningEnvelope) } : item); + const next = await send([...fixture.history, ...replay, user("Third question")], stream); + expect(next.response.status).toBe(200); + expect(fixture.bodies).toHaveLength(4); + expect(fixture.bodies[3]).not.toContain(fixture.oldPayload); + expect(fixture.bodies[3]).not.toContain(fixture.newPayload); + expect(next.log.activeAttempt?.recoveryKinds ?? []).toEqual([]); + }); + } + + for (const refusal of stream ? ["http", "sse"] as const : ["http"] as const) { + test(`memo-hit ${refusal} refusal is terminal for a new valid payload (${kind}, stream=${stream})`, async () => { + const fixture = await recoveredMemoScenario(stream, kind, refusal); + const nextInput = [...fixture.history, ...fixture.recovered.output, user("Third question")]; + const refused = await send(nextInput, stream); + expect(refused.text).toContain(REJECTION); + if (refusal === "sse") expect(refused.text).toContain("response.failed"); + else expect(refused.response.status).toBe(400); + expect(fixture.bodies).toHaveLength(4); + expect(fixture.bodies[3]).not.toContain(fixture.oldPayload); + expect(fixture.bodies[3]).toContain(fixture.newPayload); + expect(refused.log.activeAttempt?.sendCount).toBe(1); + expect(refused.log.activeAttempt?.recoveryKinds ?? []).toEqual([]); + await send(nextInput, stream); + expect(fixture.bodies).toHaveLength(5); + expect(fixture.bodies[4]).toContain(fixture.newPayload); + }); + } + } + + for (const stream of [false, true]) { + test(`successful recovery memo keeps a new valid redacted payload and rejects the old one (stream=${stream})`, async () => { + const fixture = await recoveredMemoScenario(stream, "redacted"); + const next = await send([...fixture.history, ...fixture.recovered.output, user("Third question")], stream); + expect(next.response.status).toBe(200); + expect(fixture.bodies).toHaveLength(4); + expect(fixture.bodies[3]).not.toContain(fixture.oldPayload); + expect(fixture.bodies[3]).toContain(fixture.newPayload); + expectAdaptive(fixture.bodies[3]!); + }); + + test(`failed redacted recovery observation does not install a selective memo (stream=${stream})`, async () => { + const oldPayload = "synthetic-failed-recovery-old-redacted"; + const newPayload = "synthetic-failed-recovery-new-redacted"; + const bodies = transport((index, upstreamStream) => { + if (index === 2) return bindingRejection(); + if (index === 3 && !upstreamStream) return Response.json({ content: [{ type: "redacted_thinking", data: newPayload }], stop_reason: "error" }); + if (index === 3) return new Response([ + frame("message_start", { message: { content: [] } }), + frame("content_block_start", { index: 0, content_block: { type: "redacted_thinking", data: newPayload } }), + frame("content_block_stop", { index: 0 }), + frame("error", { error: { type: "invalid_request_error", message: REJECTION } }), + ].join(""), { headers: { "content-type": "text/event-stream" } }); + return upstreamBlocks(memoBlocks("redacted", oldPayload), upstreamStream); + }); + const first = await send([user("First question")], stream); + const history = [user("First question"), ...first.output, user("Second question")]; + const failed = await send(history, stream); + expect(envelope(failed.output).red).toEqual([newPayload]); + expect(envelope(failed.output).ap).toBeDefined(); + if (stream) expect(failed.text).toContain("response.failed"); + else expect(failed.json.status).toBe("failed"); + expect(bodies).toHaveLength(3); + expect(bodies[2]).not.toContain(oldPayload); + await send(history, stream); + expect(bodies).toHaveLength(4); + expect(bodies[3]).toContain(oldPayload); + }); + } + + for (const variant of ["legacy", "unknown-version", "malformed-hint"] as const) { + test(`${variant} envelopes retain provider signatures for compatibility`, async () => { + const bodies = transport((_index, upstreamStream) => upstream(SIGNATURE, false, upstreamStream)); + const first = await send([user("First question")]); + const observed = envelope(first.output); + expect(observed.ap).toBeDefined(); + const raw: Record = { ...observed }; + if (variant === "legacy") delete raw.ap; + else raw.ap = { ...observed.ap, ...(variant === "unknown-version" ? { v: 2 } : { prefix: "invalid" }) }; + const altered = first.output.map(item => item.type === "reasoning" + ? { ...item, encrypted_content: encodeReasoningEnvelope(raw as ReasoningEnvelope) } : item); + await send([user("Changed prefix with unknown history"), ...altered, user("Next question")]); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(SIGNATURE); + }); + } + + for (const dimension of ["credential", "destination", "model"] as const) { + test(`unknown ${dimension} scope is not proof of invalid thinking`, async () => { + const bodies = transport((_index, upstreamStream) => upstream(SIGNATURE, false, upstreamStream)); + const first = await send([user("First question")]); + expectHint(first.output); + // A restarted process has no separate serving-provenance record to authorize removal. + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + const cfg = config(); + const overrides: Item = {}; + if (dimension === "credential") cfg.providers["anthropic-test"]!.apiKey = "synthetic-other-test-key"; + if (dimension === "destination") cfg.providers["anthropic-test"]!.baseUrl = "https://other-anthropic.example.test"; + if (dimension === "model") overrides.model = "anthropic-test/claude-sonnet-4-5"; + const result = await send([user("Different prefix under unknown scope"), ...first.output, user("Next question")], false, overrides, cfg); + expect(result.response.status).toBe(200); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain(SIGNATURE); + }); + } +}); 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..c5ae2abe9c9 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(); From f4dddcff0ddf1a7785720bf8d144f645d211b66e Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 11:16:25 +0800 Subject: [PATCH 5/7] test(anthropic): verify generic signature errors remain terminal --- tests/providers/umans-provider.test.ts | 10 +++++++--- .../responses-anthropic-thinking-recovery.test.ts | 7 +++++++ ...s-web-search-thinking-signature-recovery.test.ts | 13 +++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) 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-recovery.test.ts b/tests/responses/responses-anthropic-thinking-recovery.test.ts index b61aaa4cb1a..c01afabefb2 100644 --- a/tests/responses/responses-anthropic-thinking-recovery.test.ts +++ b/tests/responses/responses-anthropic-thinking-recovery.test.ts @@ -138,3 +138,10 @@ test("a stray tool-end and done cannot memoize an empty validated output", async recovery.rememberSuccess({ status: "completed", output: [] }); expect(reasoningReplayOpaqueBlobRejectionMemoized(request._reasoningReplayScope)).toBe(false); }); + +// Synthetic identifiers preserve the gateway wrapper without retaining user diagnostics. +test("generic Bedrock signature wrapper still requires the conversation-binding phrase", () => { + const message = "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)"; + expect(isAnthropicThinkingSignatureBindingRejectionMessage(message)).toBe(false); + expect(isAnthropicThinkingSignatureBindingRejectionMessage(`${message} The block is bound to a different conversation.`)).toBe(true); +}); 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 c5ae2abe9c9..13a0b8edcb0 100644 --- a/tests/responses/responses-web-search-thinking-signature-recovery.test.ts +++ b/tests/responses/responses-web-search-thinking-signature-recovery.test.ts @@ -718,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 098ab7db69fff91be9ae3bf2bad4e8064c9abb1e Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 12:05:51 +0800 Subject: [PATCH 6/7] fix(anthropic): bound prefix hashing and live observer retention --- src/adapters/anthropic-prefix-observer.ts | 170 +++++++++------ src/adapters/anthropic-prefix.ts | 128 +++++++++--- .../transports/anthropic-thinking-recovery.md | 16 ++ .../adapters/bridge-anthropic-prefix.test.ts | 197 +++++++++++++++++- .../reasoning-envelope-prefix.test.ts | 50 +++++ ...onses-anthropic-prefix-consistency.test.ts | 19 +- 6 files changed, 492 insertions(+), 88 deletions(-) diff --git a/src/adapters/anthropic-prefix-observer.ts b/src/adapters/anthropic-prefix-observer.ts index 375e456e063..bf2202a5e1e 100644 --- a/src/adapters/anthropic-prefix-observer.ts +++ b/src/adapters/anthropic-prefix-observer.ts @@ -1,83 +1,135 @@ import type { AdapterEvent, AnthropicPrefixHint } 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"; import { appendAnthropicPrefix, hintForAnthropicBlock, nextAnthropicParent, takeAnthropicPrefixSeed } from "./anthropic-prefix"; import { issueAnthropicPrefixHint } from "./anthropic-prefix-hints"; type Block = Record; +/** Bound parsed JSON storage before allocation, without copying or validating its tokens. */ +function parsedJsonBytes(source: string): number { + let bytes = source.length * 2 + 64; + let quoted = false, escaped = false, scalar = false; + for (let index = 0; index < source.length; index++) { + const code = source.charCodeAt(index); + if (quoted) { + if (escaped) escaped = false; + else if (code === 92) escaped = true; + else if (code === 34) quoted = false; + continue; + } + if (code === 34) { bytes += 64; quoted = true; scalar = false; } + else if (code === 123 || code === 91) { bytes += 64; scalar = false; } + else if (code === 44 || code === 58) { bytes += code === 44 ? 16 : 32; scalar = false; } + else if (code === 125 || code === 93 || code === 32 || code === 9 || code === 10 || code === 13) scalar = false; + else if (!scalar) { bytes += 64; scalar = true; } + } + return bytes; +} /** Incremental observation of a complete block, never an attestation of response success. */ export function createAnthropicPrefixObserver(response: Response, budget: TranslatorBudget) { const seed = takeAnthropicPrefixSeed(response); if (!seed) return; + seed.budget = budget; let valid = true, started = false, index = 0, bytes = 0; let clientRepresentable = true; let block: Block | undefined; let text: string[] = [], args: string[] = []; - const release = () => { budget.releaseRetained(bytes, { kind: "reasoning" }); bytes = 0; block = undefined; text = []; args = []; }; + let textLength = 0, argsLength = 0, signatureBytes = 0, hasArgs = false; + const release = () => { + budget.releaseRetained(bytes, { kind: "reasoning" }); bytes = 0; block = undefined; text = []; args = []; + textLength = 0; argsLength = 0; signatureBytes = 0; hasArgs = false; + }; const invalidate = () => { valid = false; release(); }; - const retain = (value: unknown) => { const size = jsonUtf8Bytes(value) * 4 + 64; budget.chargeRetained(size, { kind: "reasoning" }); bytes += size; }; + const retain = (size: number) => { budget.chargeRetained(size, { kind: "reasoning" }); bytes += size; }; + const append = (parts: string[], value: string) => { + // Only the payload survives a delta frame. Bound each nonempty string and array slot; + // empty deltas need neither a retained value nor another slot. + if (value.length === 0) return; + retain(value.length * 2 + 48); parts.push(value); + }; return { invalidate, accept(data: Block): { event: AdapterEvent; hint: AnthropicPrefixHint; index: number } | undefined { if (!valid) return; - const type = data.type; - if (type === "ping" || type === "message_delta") return; - if (type === "message_start") { - const message = data.message as Block | undefined; - if (started || (Array.isArray(message?.content) && message.content.length > 0)) invalidate(); - else started = true; - return; - } - if (!started) { invalidate(); return; } - if (type === "content_block_start") { - const incoming = data.content_block as Block | undefined; - if (block || data.index !== index || !incoming || !["text", "thinking", "redacted_thinking", "tool_use"].includes(String(incoming.type))) { invalidate(); return; } - if (incoming.type === "thinking" && Object.keys(incoming).some(key => !["type", "thinking", "signature"].includes(key))) clientRepresentable = false; - if (incoming.type === "redacted_thinking" && Object.keys(incoming).some(key => !["type", "data"].includes(key))) clientRepresentable = false; - retain(incoming); block = { ...incoming, ...(Array.isArray(incoming.citations) ? { citations: [...incoming.citations] } : {}) }; - if (block.type === "text" || block.type === "thinking") { - const initial = block.type === "text" ? block.text : block.thinking; - if (typeof initial !== "string") { invalidate(); return; } - text = [initial]; + try { + const type = data.type; + if (type === "ping" || type === "message_delta") return; + if (type === "message_start") { + const message = data.message as Block | undefined; + if (started || (Array.isArray(message?.content) && message.content.length > 0)) invalidate(); + else started = true; + return; } - } else if (type === "content_block_delta") { - const delta = data.delta as Block | undefined; - if (!block || data.index !== index || !delta) { invalidate(); return; } - retain(delta); - if (delta.type === "text_delta" && block.type === "text" && typeof delta.text === "string") text.push(delta.text); - else if (delta.type === "thinking_delta" && block.type === "thinking" && typeof delta.thinking === "string") text.push(delta.thinking); - else if (delta.type === "signature_delta" && block.type === "thinking" && typeof delta.signature === "string") block.signature = delta.signature; - else if (delta.type === "input_json_delta" && block.type === "tool_use" && typeof delta.partial_json === "string") args.push(delta.partial_json); - else if (delta.type === "citations_delta" && block.type === "text" && delta.citation && typeof delta.citation === "object") { - if (block.citations !== undefined && !Array.isArray(block.citations)) { invalidate(); return; } - (block.citations ??= [] as unknown[] as unknown); - (block.citations as unknown[]).push(delta.citation); - } else invalidate(); - } else if (type === "content_block_stop") { - if (!block || data.index !== index) { invalidate(); return; } - const reservation = budget.reserveTransient(bytes * 2, { kind: "reasoning" }); - try { - if (block.type === "text") block.text = text.join(""); - if (block.type === "thinking") block.thinking = text.join(""); - if (block.type === "tool_use" && args.length) block.input = JSON.parse(args.join("")); - let event: AdapterEvent | undefined; - if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) event = { type: "thinking_signature", signature: block.signature }; - else if (block.type === "redacted_thinking" && typeof block.data === "string") event = { type: "redacted_thinking", data: block.data }; - else if (block.type === "thinking" || block.type === "redacted_thinking") { invalidate(); return; } - if (event) { - const hint = hintForAnthropicBlock(seed, block); - // Responses currently splits text-before-thinking into separate assistant items. - // Preserve the payload as legacy rather than issuing a hint it cannot round-trip. - issueAnthropicPrefixHint(clientRepresentable ? event : { type: "heartbeat" }, hint, seed.generation); - seed.parent = nextAnthropicParent(seed.parent, block); - return { event, hint, index: index++ }; + if (!started) { invalidate(); return; } + if (type === "content_block_start") { + const incoming = data.content_block as Block | undefined; + if (block || data.index !== index || !incoming || !["text", "thinking", "redacted_thinking", "tool_use"].includes(String(incoming.type))) { invalidate(); return; } + if (incoming.type === "thinking" && Object.keys(incoming).some(key => !["type", "thinking", "signature"].includes(key))) clientRepresentable = false; + if (incoming.type === "redacted_thinking" && Object.keys(incoming).some(key => !["type", "data"].includes(key))) clientRepresentable = false; + retain(jsonUtf8Bytes(incoming) * 4 + 64); + // This portion of the admitted initial block belongs to its replaceable signature. + signatureBytes = incoming.type === "thinking" && incoming.signature !== undefined ? jsonUtf8Bytes(incoming.signature) * 4 : 0; + block = { ...incoming, ...(Array.isArray(incoming.citations) ? { citations: [...incoming.citations] } : {}) }; + if (block.type === "text" || block.type === "thinking") { + const initial = block.type === "text" ? block.text : block.thinking; + if (typeof initial !== "string") { invalidate(); return; } + if (initial.length) { retain(16); text = [initial]; } + textLength = initial.length; } - appendAnthropicPrefix(seed.hash, block); clientRepresentable = false; index++; - } catch { invalidate(); } - finally { reservation.release(); release(); } - } else if (type === "message_stop") { if (block) invalidate(); } - else invalidate(); + } else if (type === "content_block_delta") { + const delta = data.delta as Block | undefined; + if (!block || data.index !== index || !delta) { invalidate(); return; } + if (delta.type === "text_delta" && block.type === "text" && typeof delta.text === "string") { append(text, delta.text); textLength += delta.text.length; } + else if (delta.type === "thinking_delta" && block.type === "thinking" && typeof delta.thinking === "string") { append(text, delta.thinking); textLength += delta.thinking.length; } + else if (delta.type === "signature_delta" && block.type === "thinking" && typeof delta.signature === "string") { + const nextBytes = jsonUtf8Bytes(delta.signature) * 4; + // Admit growth before changing ownership. A refusal must never refund a value + // that was not admitted, or release another reasoning owner's charge. + if (nextBytes > signatureBytes) retain(nextBytes - signatureBytes); + block.signature = delta.signature; + if (nextBytes < signatureBytes) { budget.releaseRetained(signatureBytes - nextBytes, { kind: "reasoning" }); bytes -= signatureBytes - nextBytes; } + signatureBytes = nextBytes; + } else if (delta.type === "input_json_delta" && block.type === "tool_use" && typeof delta.partial_json === "string") { + append(args, delta.partial_json); argsLength += delta.partial_json.length; hasArgs = true; + } else if (delta.type === "citations_delta" && block.type === "text" && delta.citation && typeof delta.citation === "object") { + if (block.citations !== undefined && !Array.isArray(block.citations)) { invalidate(); return; } + retain(jsonUtf8Bytes(delta.citation) * 4 + 64); + (block.citations ??= [] as unknown[] as unknown); + (block.citations as unknown[]).push(delta.citation); + } else invalidate(); + } else if (type === "content_block_stop") { + if (!block || data.index !== index) { invalidate(); return; } + let reservation: TranslatorTransientReservation | undefined, parsedReservation: TranslatorTransientReservation | undefined; + try { + // Fragment headers/signature snapshots are not copied. Admit joined strings, then + // parsed token/container storage; hashing owns its bounded canonical scratch. + reservation = budget.reserveTransient((textLength + argsLength) * 2 + 64, { kind: "reasoning" }); + if (block.type === "text") block.text = text.join(""); + if (block.type === "thinking") block.thinking = text.join(""); + if (block.type === "tool_use" && hasArgs) { + const joined = args.join(""); + parsedReservation = budget.reserveTransient(parsedJsonBytes(joined), { kind: "reasoning" }); + block.input = JSON.parse(joined); + } + let event: AdapterEvent | undefined; + if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) event = { type: "thinking_signature", signature: block.signature }; + else if (block.type === "redacted_thinking" && typeof block.data === "string") event = { type: "redacted_thinking", data: block.data }; + else if (block.type === "thinking" || block.type === "redacted_thinking") { invalidate(); return; } + if (event) { + const hint = hintForAnthropicBlock(seed, block); + // Responses currently splits text-before-thinking into separate assistant items. + // Preserve the payload as legacy rather than issuing a hint it cannot round-trip. + issueAnthropicPrefixHint(clientRepresentable ? event : { type: "heartbeat" }, hint, seed.generation); + seed.parent = nextAnthropicParent(seed.parent, block, budget); + return { event, hint, index: index++ }; + } + appendAnthropicPrefix(seed.hash, block, budget); clientRepresentable = false; index++; + } catch (error) { invalidate(); if (isTranslatorBudgetExceededError(error)) throw error; } + finally { parsedReservation?.release(); reservation?.release(); release(); } + } else if (type === "message_stop") { if (block) invalidate(); } + else invalidate(); + } catch (error) { invalidate(); throw error; } }, - dispose: release, + dispose: invalidate, }; } diff --git a/src/adapters/anthropic-prefix.ts b/src/adapters/anthropic-prefix.ts index caa4120fef0..15074533468 100644 --- a/src/adapters/anthropic-prefix.ts +++ b/src/adapters/anthropic-prefix.ts @@ -1,13 +1,12 @@ import { anthropicOpaqueFingerprint, anthropicRejectionPolicy, bindAnthropicRejectionResponse } from "./anthropic-rejection-memo"; import type { TranslatorBudget } from "../lib/translator-budget"; -import { jsonUtf8Bytes } from "../lib/json-byte-size"; import { createHash, type Hash } from "node:crypto"; import type { AdapterRequest } from "./base"; import type { AnthropicPrefixHint, OcxParsedRequest } from "../types"; import { getAnthropicPrefixHint, isLocallyIssuedAnthropicPrefixHint } from "./anthropic-prefix-hints"; type Block = Record; -export interface AnthropicPrefixSeed { hash: Hash; parent: string; scope: string; generation: object } +export interface AnthropicPrefixSeed { hash: Hash; parent: string; scope: string; generation: object; budget?: TranslatorBudget } const generations = new WeakMap(); function generationFor(parsed: OcxParsedRequest): object { const owner = parsed._reasoningReplayScope ?? parsed; @@ -18,7 +17,7 @@ export function invalidateAnthropicPrefixGeneration(parsed: OcxParsedRequest): v const requests = new WeakMap(); const responses = new WeakMap(); export const ANTHROPIC_PREFIX_ROOT = createHash("sha256").update("anthropic-thinking-chain-v1").digest("hex"); -/** Stable object ordering; array/block/message order is significant. Only cache annotations vary. */ +/** Stable v1 reference serialization. Runtime hashing below streams these same bytes. */ class UnsupportedPrefixShape extends Error {} export function canonicalAnthropicPrefix(value: unknown, depth = 0): string { if (depth > 128) throw new UnsupportedPrefixShape("Anthropic prefix nesting is unsupported"); @@ -27,19 +26,76 @@ export function canonicalAnthropicPrefix(value: unknown, depth = 0): string { .map(key => `${JSON.stringify(key)}:${canonicalAnthropicPrefix((value as Block)[key], depth + 1)}`).join(",")}}`; return JSON.stringify(value) ?? "null"; } -export function anthropicPrefixDigest(value: unknown): string { return createHash("sha256").update(canonicalAnthropicPrefix(value)).digest("hex"); } -export function appendAnthropicPrefix(hash: Hash, value: unknown): void { - const encoded = canonicalAnthropicPrefix(value); hash.update(`${Buffer.byteLength(encoded)}:`).update(encoded); +/** Emit the existing v1 canonical bytes without materializing a whole canonical string. + * Chunk boundaries never split a surrogate pair; protocol hashes remain byte-for-byte stable. + */ +function visitCanonical(value: unknown, emit: (part: string) => void, budget?: TranslatorBudget, depth = 0): void { + if (depth > 128) throw new UnsupportedPrefixShape("Anthropic prefix nesting is unsupported"); + if (typeof value === "string") { + emit('"'); + for (let start = 0; start < value.length;) { + let end = Math.min(start + 1024, value.length); + const last = value.charCodeAt(end - 1), next = value.charCodeAt(end); + if (end < value.length && last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--; + // Worst-case substring, escaped UTF-16 serialization/slice and UTF-8 encoding. + const reservation = budget?.reserveTransient((end - start) * 34 + 128, { kind: "reasoning" }); + try { emit(JSON.stringify(value.slice(start, end)).slice(1, -1)); } + finally { reservation?.release(); } + start = end; + } + emit('"'); return; + } + if (Array.isArray(value)) { + const reservation = budget?.reserveTransient(64, { kind: "reasoning" }); + try { + emit("["); + for (let index = 0; index < value.length; index++) { + if (index) emit(","); + if (index in value) visitCanonical(value[index], emit, budget, depth + 1); + } + emit("]"); + } finally { reservation?.release(); } + return; + } + if (value !== null && typeof value === "object") { + let count = 0; + for (const key in value) if (Object.hasOwn(value, key)) count++; + // References for keys/filter/sort and recursion; no copies of property values. + const reservation = budget?.reserveTransient(count * 64 + 64, { kind: "reasoning" }); + try { + const keys = Object.keys(value).filter(key => (value as Block)[key] !== undefined).sort(); + emit("{"); + for (let index = 0; index < keys.length; index++) { + if (index) emit(","); + visitCanonical(keys[index], emit, budget, depth + 1); emit(":"); + visitCanonical((value as Block)[keys[index]], emit, budget, depth + 1); + } + emit("}"); + } finally { reservation?.release(); } + return; + } + emit(JSON.stringify(value) ?? "null"); +} +export function anthropicPrefixDigest(value: unknown, budget?: TranslatorBudget): string { + const hash = createHash("sha256"); visitCanonical(value, part => { hash.update(part); }, budget); return hash.digest("hex"); +} +export function appendAnthropicPrefix(hash: Hash, value: unknown, budget?: TranslatorBudget): void { + let bytes = 0; visitCanonical(value, part => { bytes += Buffer.byteLength(part); }, budget); + hash.update(`${bytes}:`); + visitCanonical(value, part => { hash.update(part); }, budget); } function protocolBlock(block: Block): Block { const { cache_control: _cache, ...content } = block; return content; } function protocolBlocks(value: unknown): unknown { return Array.isArray(value) ? value.map(block => block && typeof block === "object" ? protocolBlock(block as Block) : block) : value; } -export function nextAnthropicParent(parent: string, block: Block): string { return anthropicPrefixDigest([parent, protocolBlock(block)]); } +export function nextAnthropicParent(parent: string, block: Block, budget?: TranslatorBudget): string { return anthropicPrefixDigest([parent, protocolBlock(block)], budget); } +// A fixed protocol frame needs no request-sized scratch or new admission at stream setup. +const assistantHeader = canonicalAnthropicPrefix({ role: "assistant" }); +const assistantFrame = `${Buffer.byteLength(assistantHeader)}:${assistantHeader}`; export function beginAnthropicAssistant(seed: AnthropicPrefixSeed): AnthropicPrefixSeed { - const copy = { ...seed, hash: seed.hash.copy() }; appendAnthropicPrefix(copy.hash, { role: "assistant" }); return copy; + return { ...seed, hash: seed.hash.copy().update(assistantFrame) }; } export function hintForAnthropicBlock(seed: AnthropicPrefixSeed, block: Block): AnthropicPrefixHint { return { v: 1, scheme: "anthropic-prefix-v1", scope: seed.scope, prefix: seed.hash.copy().digest("hex"), - parent: seed.parent, block: anthropicPrefixDigest(protocolBlock(block)) }; + parent: seed.parent, block: anthropicPrefixDigest(protocolBlock(block), seed.budget) }; } export class AnthropicHistoryIncompatibleError extends Error { constructor() { super("Anthropic thinking history is incompatible with the current request prefix; resend a consistent history. Reasoning mode and executed tool results were not changed."); } @@ -52,32 +108,32 @@ function scopeFor(parsed: OcxParsedRequest): string | undefined { export function isCurrentAnthropicPrefixHint(hint: AnthropicPrefixHint, parsed: OcxParsedRequest): boolean { return hint.scope === scopeFor(parsed) && isLocallyIssuedAnthropicPrefixHint(hint, generationFor(parsed)); } -function initialSeed(body: Block, scope: string, generation: object): AnthropicPrefixSeed { - const hash = createHash("sha256"); appendAnthropicPrefix(hash, { scheme: "anthropic-prefix-v1", system: typeof body.system === "string" ? [{ type: "text", text: body.system }] : protocolBlocks(body.system ?? null), tools: protocolBlocks(body.tools ?? []) }); - return { hash, parent: ANTHROPIC_PREFIX_ROOT, scope, generation }; +function initialSeed(body: Block, scope: string, generation: object, budget?: TranslatorBudget): AnthropicPrefixSeed { + const hash = createHash("sha256"); appendAnthropicPrefix(hash, { scheme: "anthropic-prefix-v1", system: typeof body.system === "string" ? [{ type: "text", text: body.system }] : protocolBlocks(body.system ?? null), tools: protocolBlocks(body.tools ?? []) }, budget); + return { hash, parent: ANTHROPIC_PREFIX_ROOT, scope, generation, budget }; } const opaque = (block: Block) => block.type === "thinking" || block.type === "redacted_thinking"; /** The final wire, not the client's input JSON, defines the observed prefix. */ -function prepareAnthropicPrefixUnchecked(body: Block, parsed: OcxParsedRequest): AnthropicPrefixSeed | undefined { +function prepareAnthropicPrefixUnchecked(body: Block, parsed: OcxParsedRequest, budget?: TranslatorBudget): AnthropicPrefixSeed | undefined { const scope = scopeFor(parsed); if (!scope || !Array.isArray(body.messages)) return; - let seed = initialSeed(body, scope, generationFor(parsed)); + let seed = initialSeed(body, scope, generationFor(parsed), budget); const removed = new Set(); const kept: Block[] = []; let incompleteChain = false; for (const entry of body.messages as Block[]) { const before = seed.hash.copy(); - const { content, ...header } = entry; appendAnthropicPrefix(seed.hash, header); + const { content, ...header } = entry; appendAnthropicPrefix(seed.hash, header, budget); const blocks = typeof content === "string" ? [{ type: "text", text: content }] : content as Block[]; if (!Array.isArray(blocks)) return; for (const block of blocks) { - if (!opaque(block)) { appendAnthropicPrefix(seed.hash, protocolBlock(block)); continue; } + if (!opaque(block)) { appendAnthropicPrefix(seed.hash, protocolBlock(block), budget); continue; } const hint = getAnthropicPrefixHint(block); const comparable = hint?.scope === scope; const memo = anthropicRejectionPolicy(parsed); const memoStrip = !!memo && (memo.mode === "blanket" || !comparable || memo.fingerprints.has(anthropicOpaqueFingerprint(block) ?? "")); const forceStrip = memoStrip || parsed._stripReasoningEncryptedContent === true && !(hint && isCurrentAnthropicPrefixHint(hint, parsed)); - const mismatch = comparable && (hint.block !== anthropicPrefixDigest(protocolBlock(block)) || hint.prefix !== seed.hash.copy().digest("hex")); + const mismatch = comparable && (hint.block !== anthropicPrefixDigest(protocolBlock(block), budget) || hint.prefix !== seed.hash.copy().digest("hex")); if (forceStrip || mismatch) { removed.add(block); incompleteChain = true; continue; } if (comparable) { if (hint.parent !== seed.parent) { @@ -88,10 +144,10 @@ function prepareAnthropicPrefixUnchecked(body: Block, parsed: OcxParsedRequest): } incompleteChain = false; } else if (incompleteChain) throw new AnthropicHistoryIncompatibleError(); - kept.push(block); seed.parent = nextAnthropicParent(seed.parent, block); + kept.push(block); seed.parent = nextAnthropicParent(seed.parent, block, budget); } if (blocks.length > 0 && blocks.every(opaque)) seed.hash = before; - else appendAnthropicPrefix(seed.hash, { endMessage: true }); + else appendAnthropicPrefix(seed.hash, { endMessage: true }, budget); } if (removed.size || parsed._stripReasoningEncryptedContent === true) { const filtered = (body.messages as Block[]).map(message => Array.isArray(message.content) @@ -105,25 +161,43 @@ function prepareAnthropicPrefixUnchecked(body: Block, parsed: OcxParsedRequest): body.messages = filtered; } // Recompute after omission, including any removed empty assistant container. - seed = initialSeed(body, scope, generationFor(parsed)); + seed = initialSeed(body, scope, generationFor(parsed), budget); for (const entry of body.messages as Block[]) { const before = seed.hash.copy(); - const { content, ...header } = entry; appendAnthropicPrefix(seed.hash, header); + const { content, ...header } = entry; appendAnthropicPrefix(seed.hash, header, budget); const blocks = typeof content === "string" ? [{ type: "text", text: content }] : content as Block[]; for (const block of blocks) { - if (opaque(block)) seed.parent = nextAnthropicParent(seed.parent, block); - else appendAnthropicPrefix(seed.hash, protocolBlock(block)); + if (opaque(block)) seed.parent = nextAnthropicParent(seed.parent, block, budget); + else appendAnthropicPrefix(seed.hash, protocolBlock(block), budget); } if (blocks.length > 0 && blocks.every(opaque)) seed.hash = before; - else appendAnthropicPrefix(seed.hash, { endMessage: true }); + else appendAnthropicPrefix(seed.hash, { endMessage: true }, budget); } return seed; } -/** Account for temporary canonical strings before hashing the already-built wire body. */ +/** Hash the already-owned wire in bounded chunks, admitting only live canonical scratch. */ export function prepareAnthropicPrefix(body: Block, parsed: OcxParsedRequest, budget?: TranslatorBudget): AnthropicPrefixSeed | undefined { if (!scopeFor(parsed)) return undefined; - const reservation = budget?.reserveTransient(jsonUtf8Bytes(body) * 4, { kind: "reasoning" }); - try { return prepareAnthropicPrefixUnchecked(body, parsed); } + const thinking = body.thinking as Block | undefined; + const hasOpaque = Array.isArray(body.messages) && body.messages.some(message => Array.isArray(message.content) + && message.content.some((block: Block) => opaque(block))); + // Repairs still observe newly generated opaque blocks even if a compatible upstream + // emits them without an explicit thinking option; later search rounds must retain them. + if ((!thinking || thinking.type === "disabled") && !hasOpaque + && parsed._stripReasoningEncryptedContent !== true && !anthropicRejectionPolicy(parsed)) return undefined; + // Account for shallow protocol projections, filtering sets and message/block references. + // Payload strings remain owned by the existing wire; they are hashed in bounded chunks. + let structuralBytes = 256; + const account = (value: unknown) => { + structuralBytes += 128; + if (value && typeof value === "object") for (const key in value) if (Object.hasOwn(value, key)) structuralBytes += 64; + }; + for (const values of [body.system, body.tools, body.messages]) if (Array.isArray(values)) for (const value of values) { + account(value); + if (value && typeof value === "object" && Array.isArray(value.content)) for (const block of value.content) account(block); + } + const reservation = budget?.reserveTransient(structuralBytes, { kind: "reasoning" }); + try { return prepareAnthropicPrefixUnchecked(body, parsed, budget); } catch (error) { if (!(error instanceof UnsupportedPrefixShape)) throw error; const hasHints = Array.isArray(body.messages) && body.messages.some(message => Array.isArray(message.content) diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md index fbca3668704..d2a5b19f2dc 100644 --- a/structure/transports/anthropic-thinking-recovery.md +++ b/structure/transports/anthropic-thinking-recovery.md @@ -185,3 +185,19 @@ in the request-local history, rather than waiting for the next serializer to rej Request-local fingerprint capture charges 256 retained translation-budget bytes per unique digest, including conservative Set/copy overhead, until the existing turn budget is disposed. Partial capture is refunded on invalid input, admission denial or overflow and cannot narrow the memo. + +Prefix hashing traverses the existing v1 canonical representation in bounded string chunks; +it does not reserve or materialize four copies of the whole wire request. Protocol ordering, +length framing and hashes remain compatible with previously issued hints, including image and +document data. Scratch for canonical escaping, UTF-8 encoding, sorted keys and shallow history +projections is admitted before allocation and released on success or failure. Requests with +neither enabled thinking nor opaque history skip prefix observation unless recovery/active memo +state must observe newly generated blocks for later continuations. Depth limits and +budget denial remain fail-closed for histories requiring validation. + +The stream observer accounts for live block payloads and nonempty fragment slots rather than +cumulative SSE frame envelopes. Empty fragments add no retained entries; replacement signature +snapshots refund the replaced value only after growth admission succeeds. Finalization charges +joins/parsing separately from chunked hashing. Invalidation, cancellation, admission failure and +disposal release only that observer's admitted ownership; genuinely excessive retained fragments +still fail the shared turn budget. diff --git a/tests/adapters/bridge-anthropic-prefix.test.ts b/tests/adapters/bridge-anthropic-prefix.test.ts index 71dea90b234..16882bc4125 100644 --- a/tests/adapters/bridge-anthropic-prefix.test.ts +++ b/tests/adapters/bridge-anthropic-prefix.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import type { AdapterRequest } from "../../src/adapters/base"; +import { createAnthropicPrefixObserver } from "../../src/adapters/anthropic-prefix-observer"; +import { ANTHROPIC_PREFIX_ROOT, anthropicPrefixDigest, appendAnthropicPrefix, beginAnthropicAssistant, bindAnthropicPrefixResponse, rememberAnthropicPrefixRequest } from "../../src/adapters/anthropic-prefix"; import { getAnthropicPrefixHint, setAnthropicPrefixHint } from "../../src/adapters/anthropic-prefix-hints"; +import { createTranslatorBudget, TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; import { decodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; import type { AdapterEvent, AnthropicPrefixHint } from "../../src/types"; @@ -91,3 +96,193 @@ describe("private Anthropic prefix bridge propagation", () => { expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)).toEqual({ sig: "signature" }); }); }); + +function observing(block: Record, maxTurnBytes?: number) { + const request = {} as AdapterRequest; + const seed = { hash: createHash("sha256").update("observer fixture"), + scope: "a".repeat(64), parent: ANTHROPIC_PREFIX_ROOT, generation: {} }; + rememberAnthropicPrefixRequest(request, seed); + const response = new Response(); + bindAnthropicPrefixResponse(request, response); + const budget = createTranslatorBudget({ maxTurnBytes }); + const observer = createAnthropicPrefixObserver(response, budget)!; + observer.accept({ type: "message_start" }); + observer.accept({ type: "content_block_start", index: 0, content_block: block }); + const delta = (value: Record) => observer.accept({ type: "content_block_delta", index: 0, delta: value }); + const stop = () => observer.accept({ type: "content_block_stop", index: 0 }); + return { observer, budget, delta, stop, seed }; +} + +describe("Anthropic prefix observer live-buffer accounting", () => { + test("repeated signature snapshots retain only the current value", () => { + const { observer, budget, delta, stop } = observing({ type: "thinking", thinking: "", signature: "s".repeat(1_000) }, 16_384); + try { + const initial = budget.snapshot().currentBytes; + delta({ type: "signature_delta", signature: "final" }); + const replaced = budget.snapshot().currentBytes; + expect(replaced).toBeLessThan(initial); + for (let index = 0; index < 20_000; index++) delta({ type: "signature_delta", signature: "final" }); + expect(budget.snapshot().currentBytes).toBe(replaced); + const result = stop()!; + expect(result.event).toEqual({ type: "thinking_signature", signature: "final" }); + expect(result.hint.block).toBe(anthropicPrefixDigest({ type: "thinking", thinking: "", signature: "final" })); + expect(getAnthropicPrefixHint(result.event)).toEqual(result.hint); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); + + test("tiny thinking frames fit the default budget and preserve the completed digest", () => { + const { observer, budget, delta, stop } = observing({ type: "thinking", thinking: "", signature: "final" }); + try { + for (let index = 0; index < 100_000; index++) delta({ type: "thinking_delta", thinking: "x" }); + const result = stop()!; + expect(result.hint.block).toBe(anthropicPrefixDigest({ type: "thinking", thinking: "x".repeat(100_000), signature: "final" })); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); + + test("empty fragments do not grow retained arrays", () => { + const { observer, budget, delta, stop } = observing({ type: "thinking", thinking: "", signature: "final" }, 4_096); + try { + const initial = budget.snapshot().currentBytes; + for (let index = 0; index < 20_000; index++) delta({ type: "thinking_delta", thinking: "" }); + expect(budget.snapshot().currentBytes).toBe(initial); + expect(stop()?.event).toEqual({ type: "thinking_signature", signature: "final" }); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); + + test("200k two-character tool fragments fit the default budget", () => { + const initial = { type: "tool_use", id: "tool_fixture", name: "lookup", input: {} }; + const { observer, budget, delta, stop, seed } = observing(initial); + try { + delta({ type: "input_json_delta", partial_json: '{"value":"' }); + for (let index = 0; index < 200_000; index++) delta({ type: "input_json_delta", partial_json: "xx" }); + delta({ type: "input_json_delta", partial_json: '"}' }); + expect(stop()).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(0); + const expected = beginAnthropicAssistant(seed); + appendAnthropicPrefix(expected.hash, { ...initial, input: { value: "xx".repeat(200_000) } }); + observer.accept({ type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "", signature: "final" } }); + expect(observer.accept({ type: "content_block_stop", index: 1 })!.hint.prefix).toBe(expected.hash.digest("hex")); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); + + test("near-2MiB single-fragment tool strings remain within the default budget", () => { + const initial = { type: "tool_use", id: "tool_fixture", name: "lookup", input: {} }; + const { observer, budget, delta, stop, seed } = observing(initial); + const value = "x".repeat(2 * 1024 * 1024 - 128); + try { + delta({ type: "input_json_delta", partial_json: JSON.stringify({ value }) }); + expect(stop()).toBeUndefined(); + const expected = beginAnthropicAssistant(seed); + appendAnthropicPrefix(expected.hash, { ...initial, input: { value } }); + observer.accept({ type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "", signature: "final" } }); + expect(observer.accept({ type: "content_block_stop", index: 1 })!.hint.prefix).toBe(expected.hash.digest("hex")); + expect(budget.snapshot().highWaterBytes).toBeLessThan(16 * 1024 * 1024); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); + + test("short-key object-heavy tool JSON is admitted before parsing its allocations", () => { + const { observer, budget, delta, stop } = observing({ type: "tool_use", id: "tool_fixture", name: "lookup", input: {} }, 4 * 1024 * 1024); + const parse = spyOn(JSON, "parse"); + try { + delta({ type: "input_json_delta", partial_json: `{"items":[${'{"a":0},'.repeat(20_000)}{"a":0}]}` }); + budget.chargeRetained(137, { kind: "reasoning" }); + expect(stop).toThrow(TranslatorBudgetExceededError); + expect(parse).not.toHaveBeenCalled(); + expect(budget.snapshot().currentBytes).toBe(137); + expect(stop()).toBeUndefined(); + } finally { parse.mockRestore(); observer.dispose(); budget.dispose(); } + }); + + test("denied replacement releases only admitted observer memory", () => { + const { observer, budget, delta, stop } = observing({ type: "thinking", thinking: "", signature: "initial" }, 4_096); + try { + budget.chargeRetained(137, { kind: "reasoning" }); + expect(() => delta({ type: "signature_delta", signature: "x".repeat(4_096) })).toThrow(TranslatorBudgetExceededError); + expect(budget.snapshot().currentBytes).toBe(137); + expect(stop()).toBeUndefined(); + observer.dispose(); observer.invalidate(); observer.dispose(); + expect(budget.snapshot().currentBytes).toBe(137); + } finally { observer.dispose(); budget.dispose(); } + }); + + test("nonempty fragment slots remain bounded by the shared budget", () => { + const { observer, budget, delta } = observing({ type: "thinking", thinking: "", signature: "final" }, 4_096); + try { + expect(() => { + for (let index = 0; index < 4_096; index++) delta({ type: "thinking_delta", thinking: "x" }); + }).toThrow(TranslatorBudgetExceededError); + observer.dispose(); observer.dispose(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); + + test("split Unicode and escaped thinking preserve the completed canonical block", () => { + const parts = ["\ud83d", "\ude80", "\"", "\\", "\u0000", "\ud800"]; + const { observer, budget, delta, stop } = observing({ type: "thinking", thinking: "initial", signature: "final" }); + try { + for (const thinking of parts) delta({ type: "thinking_delta", thinking }); + expect(stop()!.hint.block).toBe(anthropicPrefixDigest({ type: "thinking", thinking: `initial${parts.join("")}`, signature: "final" })); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); + + test.each([ + { initial: { type: "text", text: "start", citations: [{ source: "first" }] }, + deltas: [{ type: "text_delta", text: "\ud83d" }, { type: "text_delta", text: "\ude80" }, + { type: "citations_delta", citation: { source: "second", nested: ["quoted\"", "\u0000"] } }], + completed: { type: "text", text: "startπŸš€", citations: [{ source: "first" }, { source: "second", nested: ["quoted\"", "\u0000"] }] } }, + { initial: { type: "tool_use", id: "tool_fixture", name: "lookup", input: { replaced: true } }, + deltas: [{ type: "input_json_delta", partial_json: '{"value":"\ud83d' }, { type: "input_json_delta", partial_json: '\ude80","nested":{"cache_control":"significant"}}' }], + completed: { type: "tool_use", id: "tool_fixture", name: "lookup", input: { value: "πŸš€", nested: { cache_control: "significant" } } } }, + ])("text/citations and tool JSON retain their canonical prefix: $initial.type", ({ initial, deltas, completed }) => { + const { observer, budget, delta, stop, seed } = observing(initial); + try { + for (const value of deltas) delta(value); + expect(stop()).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(0); + const expected = beginAnthropicAssistant(seed); + appendAnthropicPrefix(expected.hash, completed); + observer.accept({ type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "", signature: "next" } }); + const result = observer.accept({ type: "content_block_stop", index: 1 })!; + expect(result.hint.prefix).toBe(expected.hash.digest("hex")); + expect(result.hint.parent).toBe(ANTHROPIC_PREFIX_ROOT); + expect(getAnthropicPrefixHint(result.event)).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); + + test("denied stop-time reservation refunds the observer and preserves other owners", () => { + const limit = 4_096; + const { observer, budget, stop } = observing({ type: "thinking", thinking: "joined", signature: "final" }, limit); + try { + const otherBytes = limit - budget.snapshot().currentBytes; + budget.chargeRetained(otherBytes, { kind: "reasoning" }); + expect(stop).toThrow(TranslatorBudgetExceededError); + expect(budget.snapshot().currentBytes).toBe(otherBytes); + expect(stop()).toBeUndefined(); + observer.dispose(); observer.dispose(); + expect(budget.snapshot().currentBytes).toBe(otherBytes); + } finally { observer.dispose(); budget.dispose(); } + }); + + test.each(["invalidate", "dispose"] as const)("%s releases pending fragments and prevents later hints", method => { + const { observer, budget, delta, stop } = observing({ type: "thinking", thinking: "", signature: "initial" }); + try { + delta({ type: "thinking_delta", thinking: "unfinished" }); + observer[method](); observer[method](); + expect(budget.snapshot().currentBytes).toBe(0); + delta({ type: "signature_delta", signature: "late" }); + observer.accept({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "", signature: "late" } }); + expect(stop()).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { observer.dispose(); budget.dispose(); } + }); +}); diff --git a/tests/responses/reasoning-envelope-prefix.test.ts b/tests/responses/reasoning-envelope-prefix.test.ts index f6660a30c4e..53e46ac6f7b 100644 --- a/tests/responses/reasoning-envelope-prefix.test.ts +++ b/tests/responses/reasoning-envelope-prefix.test.ts @@ -77,3 +77,53 @@ describe("optional Anthropic prefix envelope metadata", () => { expect(parts.map(part => part.anthropicPrefix)).toEqual([undefined, hint, undefined]); }); }); + +// Prefix preparation must not charge the size of already-owned media as a new copy. +describe("bounded Anthropic prefix preparation", () => { + const scoped = () => ({ modelId: "claude-fixture", _reasoningReplayScope: { current: { + providerDestinationDurableIdentity: "fixture-destination", credentialDurableIdentity: "fixture-credential", + } } }) as unknown as import("../../src/types").OcxParsedRequest; + test("large media without thinking does not allocate a prefix observation", async () => { + const { prepareAnthropicPrefix } = await import("../../src/adapters/anthropic-prefix"); + const { createTranslatorBudget } = await import("../../src/lib/translator-budget"); + const budget = createTranslatorBudget(); + try { + const body = { messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", data: "A".repeat(9 * 1024 * 1024) } }] }] }; + expect(prepareAnthropicPrefix(body, scoped(), budget)).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().highWaterBytes).toBe(0); + } finally { budget.dispose(); } + }); + test.each(["image", "document"])("large %s with thinking hashes without a whole-wire temporary copy", async type => { + const { prepareAnthropicPrefix } = await import("../../src/adapters/anthropic-prefix"); + const { createTranslatorBudget } = await import("../../src/lib/translator-budget"); + const budget = createTranslatorBudget(); + try { + const body = { thinking: { type: "adaptive" }, messages: [{ role: "user", content: [{ type, source: { type: "base64", data: "A".repeat(9 * 1024 * 1024) } }] }] }; + const seed = prepareAnthropicPrefix(body, scoped(), budget); + expect(seed).toBeDefined(); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().highWaterBytes).toBeLessThan(256 * 1024); + } finally { budget.dispose(); } + }); +}); + +test("incremental canonical hashes preserve exact v1 bytes across escaped and surrogate chunk boundaries", async () => { + const { createHash } = await import("node:crypto"); + const { anthropicPrefixDigest, appendAnthropicPrefix, canonicalAnthropicPrefix } = await import("../../src/adapters/anthropic-prefix"); + const { createTranslatorBudget } = await import("../../src/lib/translator-budget"); + const values = [null, undefined, [1, undefined, null], { b: true, a: "x", skipped: undefined }, + { nested: ["a".repeat(1023) + "πŸ˜€" + "\\\"\n\u0000\ud800\udc01", { semantic: { cache_control: "keep" } }] }, + { large: "a".repeat(100_000) }, { escaped: "\u0001".repeat(2049) }]; + const budget = createTranslatorBudget(); + try { + for (const value of values) { + const canonical = canonicalAnthropicPrefix(value); + expect(anthropicPrefixDigest(value, budget)).toBe(createHash("sha256").update(canonical).digest("hex")); + const hash = createHash("sha256"); appendAnthropicPrefix(hash, value, budget); + expect(hash.digest("hex")).toBe(createHash("sha256").update(`${Buffer.byteLength(canonical)}:`).update(canonical).digest("hex")); + } + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().highWaterBytes).toBeLessThan(64 * 1024); + } finally { budget.dispose(); } +}); diff --git a/tests/responses/responses-anthropic-prefix-consistency.test.ts b/tests/responses/responses-anthropic-prefix-consistency.test.ts index 5ad047f0aee..5ff07a1a995 100644 --- a/tests/responses/responses-anthropic-prefix-consistency.test.ts +++ b/tests/responses/responses-anthropic-prefix-consistency.test.ts @@ -491,7 +491,7 @@ describe("Anthropic prefix hints through actual Responses requests", () => { }, }); const provider = config().providers["anthropic-test"]!; - const parsed = parseRequest({ model: MODEL, input: [user("First question")] }); + const parsed = parseRequest({ model: MODEL, reasoning: { effort: "high" }, input: [user("First question")] }); parsed._reasoningReplayScope = { clientThreadId: "admission-denial-fixture" }; bindRouteReasoningReplayScope({ parsed, provider, providerName: "anthropic-test", adapterName: "anthropic" }); const adapter = createAnthropicAdapter(provider); @@ -698,3 +698,20 @@ describe("Anthropic prefix hints through actual Responses requests", () => { }); } }); + +test.each(["none", "high"])("actual scoped adapter preserves a large accepted text request with reasoning %s", async effort => { + const provider = config().providers["anthropic-test"]!; + const text = "Synthetic large input. ".repeat(450_000); + const parsed = parseRequest({ model: MODEL, reasoning: { effort }, input: [user(text)] }); + parsed._reasoningReplayScope = { clientThreadId: "large-prefix-budget-fixture" }; + bindRouteReasoningReplayScope({ parsed, provider, providerName: "anthropic-test", adapterName: "anthropic" }); + const budget = createTranslatorBudget(); + try { + const built = await createAnthropicAdapter(provider).buildRequest(parsed, { headers: new Headers(), translatorBudget: budget }); + const body = typeof built.body === "string" ? JSON.parse(built.body) : built.body; + expect(JSON.stringify(body.messages)).toContain(text); + expect(budget.snapshot().overflows).toBe(0); + built.releaseBodyObservation?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } +}); From 1c052dad40f8f80b9d7128b3bb0d3ec2b2198a95 Mon Sep 17 00:00:00 2001 From: lcxhh Date: Thu, 15 Oct 2026 12:18:02 +0800 Subject: [PATCH 7/7] fix(anthropic): reuse unchanged prefix observations --- src/adapters/anthropic-prefix.ts | 5 ++- .../transports/anthropic-thinking-recovery.md | 3 +- .../reasoning-envelope-prefix.test.ts | 32 +++++++++++++++++++ ...onses-anthropic-prefix-consistency.test.ts | 3 ++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/adapters/anthropic-prefix.ts b/src/adapters/anthropic-prefix.ts index 15074533468..31d41bee2fb 100644 --- a/src/adapters/anthropic-prefix.ts +++ b/src/adapters/anthropic-prefix.ts @@ -149,7 +149,8 @@ function prepareAnthropicPrefixUnchecked(body: Block, parsed: OcxParsedRequest, if (blocks.length > 0 && blocks.every(opaque)) seed.hash = before; else appendAnthropicPrefix(seed.hash, { endMessage: true }, budget); } - if (removed.size || parsed._stripReasoningEncryptedContent === true) { + const rewritten = removed.size > 0 || parsed._stripReasoningEncryptedContent === true; + if (rewritten) { const filtered = (body.messages as Block[]).map(message => Array.isArray(message.content) ? { ...message, content: (message.content as Block[]).filter(block => !removed.has(block)) } : message) .filter(message => !Array.isArray(message.content) || message.content.length > 0); @@ -160,6 +161,8 @@ function prepareAnthropicPrefixUnchecked(body: Block, parsed: OcxParsedRequest, && Array.isArray(assistant?.content) && !assistant.content.some(block => opaque(block as Block))) throw new AnthropicHistoryIncompatibleError(); body.messages = filtered; } + // The first pass already observed exactly this wire when no filtering occurred. + if (!rewritten) return seed; // Recompute after omission, including any removed empty assistant container. seed = initialSeed(body, scope, generationFor(parsed), budget); for (const entry of body.messages as Block[]) { diff --git a/structure/transports/anthropic-thinking-recovery.md b/structure/transports/anthropic-thinking-recovery.md index d2a5b19f2dc..e3c7e509669 100644 --- a/structure/transports/anthropic-thinking-recovery.md +++ b/structure/transports/anthropic-thinking-recovery.md @@ -188,7 +188,8 @@ capture is refunded on invalid input, admission denial or overflow and cannot na Prefix hashing traverses the existing v1 canonical representation in bounded string chunks; it does not reserve or materialize four copies of the whole wire request. Protocol ordering, -length framing and hashes remain compatible with previously issued hints, including image and +length framing and hashes remain compatible with previously issued hints. Unchanged histories reuse +the first-pass seed; only filtered histories require recomputation. This includes image and document data. Scratch for canonical escaping, UTF-8 encoding, sorted keys and shallow history projections is admitted before allocation and released on success or failure. Requests with neither enabled thinking nor opaque history skip prefix observation unless recovery/active memo diff --git a/tests/responses/reasoning-envelope-prefix.test.ts b/tests/responses/reasoning-envelope-prefix.test.ts index 53e46ac6f7b..6f0d8dd29f2 100644 --- a/tests/responses/reasoning-envelope-prefix.test.ts +++ b/tests/responses/reasoning-envelope-prefix.test.ts @@ -127,3 +127,35 @@ test("incremental canonical hashes preserve exact v1 bytes across escaped and su expect(budget.snapshot().highWaterBytes).toBeLessThan(64 * 1024); } finally { budget.dispose(); } }); + +test("unchanged hinted history returns the first-pass hash and parent without traversing it twice", async () => { + const { prepareAnthropicPrefix, beginAnthropicAssistant, hintForAnthropicBlock, nextAnthropicParent, appendAnthropicPrefix } = await import("../../src/adapters/anthropic-prefix"); + const { setAnthropicPrefixHint } = await import("../../src/adapters/anthropic-prefix-hints"); + const { createTranslatorBudget } = await import("../../src/lib/translator-budget"); + const parsed = { modelId: "claude-fixture", _reasoningReplayScope: { current: { + providerDestinationDurableIdentity: "fixture-destination", credentialDurableIdentity: "fixture-credential", + } } } as unknown as import("../../src/types").OcxParsedRequest; + const budget = createTranslatorBudget(); + try { + const thinking = { type: "adaptive" }; + const initial = prepareAnthropicPrefix({ thinking, messages: [{ role: "user", content: [{ type: "text", text: "question" }] }] }, parsed, budget)!; + const expected = beginAnthropicAssistant(initial); + const block = { type: "thinking", thinking: "complete reasoning", signature: "SyntheticUnchangedPrefixSignature==" }; + setAnthropicPrefixHint(block, hintForAnthropicBlock(expected, block)); + const answer = { type: "text", text: "answer" }; + appendAnthropicPrefix(expected.hash, answer, budget); + appendAnthropicPrefix(expected.hash, { endMessage: true }, budget); + const expectedParent = nextAnthropicParent(expected.parent, block, budget); + let textReads = 0; + const observedText = { type: "text", get text() { textReads++; return "question"; } }; + const messages = [{ role: "user", content: [observedText] }, { role: "assistant", content: [block, answer] }]; + const body = { thinking, messages }; + const actual = prepareAnthropicPrefix(body, parsed, budget)!; + expect(actual.hash.digest("hex")).toBe(expected.hash.digest("hex")); + expect(actual.parent).toBe(expectedParent); + expect(body.messages).toBe(messages); + expect(body.messages[1].content[0]).toBe(block); + expect(textReads).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } +}); diff --git a/tests/responses/responses-anthropic-prefix-consistency.test.ts b/tests/responses/responses-anthropic-prefix-consistency.test.ts index 5ff07a1a995..13a6dc28819 100644 --- a/tests/responses/responses-anthropic-prefix-consistency.test.ts +++ b/tests/responses/responses-anthropic-prefix-consistency.test.ts @@ -705,11 +705,14 @@ test.each(["none", "high"])("actual scoped adapter preserves a large accepted te const parsed = parseRequest({ model: MODEL, reasoning: { effort }, input: [user(text)] }); parsed._reasoningReplayScope = { clientThreadId: "large-prefix-budget-fixture" }; bindRouteReasoningReplayScope({ parsed, provider, providerName: "anthropic-test", adapterName: "anthropic" }); + expect(parsed._reasoningReplayScope.current?.providerDestinationDurableIdentity).toBeTruthy(); + expect(parsed._reasoningReplayScope.current?.credentialDurableIdentity).toBeTruthy(); const budget = createTranslatorBudget(); try { const built = await createAnthropicAdapter(provider).buildRequest(parsed, { headers: new Headers(), translatorBudget: budget }); const body = typeof built.body === "string" ? JSON.parse(built.body) : built.body; expect(JSON.stringify(body.messages)).toContain(text); + if (effort === "high") expect(budget.snapshot().highWaterBytes).toBeGreaterThan(0); expect(budget.snapshot().overflows).toBe(0); built.releaseBodyObservation?.(); expect(budget.snapshot().currentBytes).toBe(0);