From c3d62b0ce2dbcefbaf76ad5a5306c9f8692ff375 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 19:05:51 -0700 Subject: [PATCH 1/2] fix(responses): drop reasoning blobs and output-only status across a route switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching models mid-conversation broke the next turn. Reproduced end to end through the proxy: mint a reasoning item on xai/grok-4.6, replay to openai/gpt-5.6-sol. replay grok -> grok : OK replay grok -> SOL : Unknown parameter: 'input[1].status' ... status removed: replay grok -> SOL : The encrypted content ZvQ+...fBJg could not be verified. ... status and encrypted_content removed: replay grok -> SOL : OK Two independent problems. Grok emits an output-only `status` on reasoning items that OpenAI rejects on input, and a reasoning blob is decodable only by the backend that minted it, so after a switch the client replays blobs the new destination cannot read. This extends the mechanism the repo already uses for opaque provider state rather than adding a retry: `reasoning-replay-cache` already keeps a bounded, thread-scoped store and already computes the provider/destination/adapter/model/ credential identity. It now also records which identity served a thread last, and a request whose identity differs from that record drops `encrypted_content` from replayed reasoning items before they go out. No record — fresh process, evicted, expired, no client thread — keeps the blobs rather than discarding valid cached reasoning on a guess; that leaves a switch spanning a proxy restart uncovered, which the comment states rather than implies. `status` is stripped only from items that are not forwarding a blob. An OpenAI-operated backend binds the blob to the item's exact shape, so removing any field from an item we still expect it to decode can invalidate it — the same failure an unconditional `content` strip already produced once on this codebase. Content blanking predates that invariant and is unchanged; an item carrying both a native blob and raw content is a known unresolved conflict, noted in place. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 40 ++++-- src/responses/reasoning-replay-cache.ts | 93 ++++++++++++- src/server/responses/core.ts | 6 + src/types/request.ts | 2 + structure/04_transports-and-sidecars.md | 20 +++ tests/openai-responses-passthrough.test.ts | 148 +++++++++++++++++++++ tests/reasoning-replay-identity.test.ts | 50 +++++++ 7 files changed, 339 insertions(+), 20 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index b85252b142..f5516dc1b3 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -41,7 +41,10 @@ export const FORWARD_HEADERS = [ export function sanitizeReasoningInputContent( body: unknown, - opts?: { preserveRawReasoningContent?: boolean }, + opts?: { + preserveRawReasoningContent?: boolean; + stripEncryptedContent?: boolean; + }, ): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const raw = body as Record; @@ -56,14 +59,23 @@ export function sanitizeReasoningInputContent( // ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native // backend cannot decrypt them and would reject the request. Strip regardless of content shape. const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX); - if (!hasRawContent && !hasOcxEnvelope) return item; - if (hasOcxEnvelope) { - changed = true; - const next: Record = { ...rec }; - delete next.encrypted_content; - if (!opts?.preserveRawReasoningContent) next.content = []; - return next; - } + const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status"); + const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content"); + const stripEncryptedContent = hasOcxEnvelope + || (opts?.stripEncryptedContent === true && hasEncryptedContent); + const retainsEncryptedContent = hasEncryptedContent && !stripEncryptedContent; + // Invariant for fields newly stripped by this cross-backend layer: an item whose + // encrypted_content is forwarded keeps status because OpenAI-operated backends bind opaque + // reasoning blobs to the item shape. Content blanking predates this invariant and remains + // required by ChatGPT's input contract; a native blob plus raw content is a known unresolved + // shape conflict, not an oversight to resolve by preserving content here. + const stripOutputStatus = hasOutputStatus && !retainsEncryptedContent; + const blankContent = !opts?.preserveRawReasoningContent && (hasRawContent || hasOcxEnvelope); + if (!blankContent && !stripOutputStatus && !stripEncryptedContent) return item; + changed = true; + const next: Record = { ...rec }; + if (stripOutputStatus) delete next.status; + if (stripEncryptedContent) delete next.encrypted_content; // Routed models can produce raw `reasoning_text` output items. Codex echoes those in later // native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty // `content`; keep summaries/ids and drop the raw content so native passthrough does not 400. @@ -71,9 +83,8 @@ export function sanitizeReasoningInputContent( // guide merges reasoning items into the adjacent assistant message), so providers flagged // `preserveResponsesReasoningContent` keep it — deleting valid replay content there breaks // continuations after tool calls (issue #875 family). - if (opts?.preserveRawReasoningContent) return item; - changed = true; - return { ...rec, content: [] }; + if (blankContent) next.content = []; + return next; }); return changed ? { ...raw, input } : body; @@ -1572,7 +1583,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; } - const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); + const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { + preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, + stripEncryptedContent: parsed._stripReasoningEncryptedContent === true, + }))))))); const finalBody = stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), provider, diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index 7f1d67c18c..18fd8261ef 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -52,8 +52,16 @@ interface CacheEntry { at: number; } +interface ServingIdentityEntry { + identity: string; + bytes: number; + at: number; +} + const entries = new Map(); +const servingIdentities = new Map(); let totalBytes = 0; +let servingIdentityTotalBytes = 0; let clockForTests: (() => number) | null = null; const now = (): number => clockForTests?.() ?? Date.now(); @@ -62,28 +70,97 @@ function nonEmpty(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } -function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined { - const identity = scope?.current; +type ReasoningReplayIdentityTuple = readonly [string, string, string, string, string]; + +function tupleForIdentity( + identity: Readonly | undefined, +): ReasoningReplayIdentityTuple | undefined { if ( - !nonEmpty(callId) - || !nonEmpty(scope?.clientThreadId) - || !nonEmpty(identity?.providerName) + !nonEmpty(identity?.providerName) || !nonEmpty(identity?.providerDestinationIdentity) || !nonEmpty(identity?.adapterName) || !nonEmpty(identity?.modelId) || !nonEmpty(identity?.credentialIdentity) ) return undefined; - return JSON.stringify([ - scope.clientThreadId, + return [ identity.providerName, identity.providerDestinationIdentity, identity.adapterName, identity.modelId, identity.credentialIdentity, + ]; +} + +function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined { + const identity = tupleForIdentity(scope?.current); + if (!nonEmpty(callId) || !nonEmpty(scope?.clientThreadId) || !identity) return undefined; + return JSON.stringify([ + scope.clientThreadId, + ...identity, callId, ]); } +function deleteServingIdentity(threadId: string): void { + const entry = servingIdentities.get(threadId); + if (!entry) return; + servingIdentities.delete(threadId); + servingIdentityTotalBytes -= entry.bytes; +} + +function sweepExpiredServingIdentities(at: number): void { + for (const [threadId, entry] of servingIdentities) { + if (at - entry.at >= TTL_MS) deleteServingIdentity(threadId); + } +} + +/** + * Compare this request's route with the last route recorded for its client thread, then + * record the current route. A live mismatch means replayed opaque reasoning was minted by + * another backend and must not be forwarded to this one. + * + * Missing, expired, or evicted state is deliberately unknown rather than a mismatch. This + * store is process-local, so a backend switch spanning a proxy restart is not detected. + */ +export function updateReasoningReplayServingIdentity( + scope: OcxReasoningReplayScopeRef | undefined, +): boolean { + const threadId = scope?.clientThreadId; + const identityTuple = tupleForIdentity(scope?.current); + if (!nonEmpty(threadId) || !identityTuple) return false; + const identity = JSON.stringify(identityTuple); + + const at = now(); + sweepExpiredServingIdentities(at); + const previous = servingIdentities.get(threadId); + const changed = previous !== undefined && previous.identity !== identity; + const bytes = Buffer.byteLength(JSON.stringify([threadId, identity]), "utf8"); + if (bytes > MAX_TOTAL_BYTES) { + deleteServingIdentity(threadId); + return false; + } + + if (previous) deleteServingIdentity(threadId); + servingIdentities.set(threadId, { identity, bytes, at }); + servingIdentityTotalBytes += bytes; + while ( + (servingIdentityTotalBytes > MAX_TOTAL_BYTES || servingIdentities.size > MAX_ENTRIES) + && servingIdentities.size > 1 + ) { + let oldestThreadId: string | undefined; + let oldestAt = Infinity; + for (const [candidateThreadId, entry] of servingIdentities) { + if (entry.at < oldestAt) { + oldestAt = entry.at; + oldestThreadId = candidateThreadId; + } + } + if (oldestThreadId === undefined) break; + deleteServingIdentity(oldestThreadId); + } + return changed; +} + function processLocalIdentity(domain: string, material: string): string { return createHmac("sha256", replayIdentityKey) .update(domain) @@ -303,6 +380,8 @@ export function peekReasoningForCall( /** Test-only: reset the cache and optionally pin the clock. */ export function clearReasoningReplayCacheForTests(clock?: (() => number) | null): void { entries.clear(); + servingIdentities.clear(); totalBytes = 0; + servingIdentityTotalBytes = 0; clockForTests = clock ?? null; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3c7773e4e4..50552ac949 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -21,6 +21,7 @@ import { durableReplayCredentialIdentity, reasoningReplayKeyCredentialIdentity, reasoningReplayOAuthCredentialIdentity, + updateReasoningReplayServingIdentity, } from "../../responses/reasoning-replay-cache"; import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; @@ -407,6 +408,11 @@ function bindRouteReasoningReplayScope(args: { } : undefined, ); + // Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal + // after the first mismatch, but it cannot make history minted by the prior route decodable. + if (updateReasoningReplayServingIdentity(parsed._reasoningReplayScope)) { + parsed._stripReasoningEncryptedContent = true; + } } function nonEmptyProviderApiKey(provider: OcxProviderConfig): string | undefined { diff --git a/src/types/request.ts b/src/types/request.ts index c01d6d3614..b6a5d5e6fe 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -65,6 +65,8 @@ export interface OcxParsedRequest { _clientThreadId?: string; /** Provider/account/model-bound namespace for process-local raw-reasoning replay. */ _reasoningReplayScope?: OcxReasoningReplayScopeRef; + /** A known in-process route switch requires opaque Responses reasoning blobs to be dropped. */ + _stripReasoningEncryptedContent?: boolean; /** * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation. * When absent (single-operator local proxy), derivation stays local-scoped. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index efd1c87dbb..bd1515e17b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -479,6 +479,26 @@ replays are explicit and receive the same repair. These compatibility guards are covered by focused tests and should stay close to the adapters that need them. +Responses passthrough keeps output-only `status` on any `reasoning` input item that retains opaque +`encrypted_content` because OpenAI-operated backends may bind the blob to that field. The established +raw-`content` rule remains separate: ChatGPT accepts reasoning input only with empty `content`, so a +native blob plus raw content keeps the blob and `status` but still blanks `content`. That shape is a +known unresolved contract conflict, not evidence that either existing rule is safe to broaden. The +blob is kept unless the in-process thread record proves that the current provider, destination, +adapter, model, or credential differs from the route recorded for the prior request on that client +thread. On a proven change the blob and `status` are removed while the reasoning item and its summary +survive; `status` is also removed from blobless reasoning items. Missing, expired, or evicted identity +state is unknown. The record is deliberately process-local, so a backend switch spanning a proxy +restart is not detected and may still be rejected upstream. + +[Decision Log] +- 목적과 의도: Keep same-backend opaque reasoning replay while preventing backend-private blobs and output-only fields from breaking the first turn after a route change. +- 기존 구현 및 제약 조건: Reasoning-input sanitation already handled raw content and `ocxr1:` envelopes; the replay cache already supplied a bounded, thread-scoped physical-route identity, but no record connected that identity to native `encrypted_content` provenance. +- 검토한 주요 대안: Strip every opaque blob, persist provenance across restarts, retry after an upstream 4xx, or compare and strip before the first outbound request only when an in-process record proves a route change. +- 선택한 방식: Preserve `status` whenever its blob is forwarded without changing the pre-existing raw-`content` blanking rule; otherwise remove output-only `status`, compare and update a 64-entry/256 KiB/one-hour in-process serving-identity record at request time, and pass the proven-change decision into the Responses adapter to remove foreign `encrypted_content`. +- 다른 대안 대신 이 방식을 선택한 이유: Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and a deterministic pre-flight decision avoids a second paid or stateful upstream attempt. +- 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning, known cross-route replay keeps the reasoning item without its undecodable blob, and switches spanning a proxy restart remain an explicit coverage gap. + DeepSeek's stateless Responses compatibility pass normalizes only unambiguous tool-call batches. Calls emitted before the first matched output stay together as one assistant batch, followed by their outputs in call order; hook-injected messages that split the batch move after it without being diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 52ed4cf3d1..ecf17d1737 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -774,6 +774,154 @@ describe("OpenAI Responses passthrough sanitization", () => { }); }); + test("keeps a blob-bearing reasoning item byte-identical when the route is unchanged", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const reasoningItem = { + type: "reasoning", + id: "rs_same_backend", + status: "completed", + summary: [{ type: "summary_text", text: "summary" }], + encrypted_content: "backend-minted-blob", + content: [], + }; + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + store: true, + input: [reasoningItem], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(JSON.stringify(body.input[0])).toBe(JSON.stringify(reasoningItem)); + }); + + test("keeps a native blob while blanking its raw reasoning content", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [{ + type: "reasoning", + status: "completed", + summary: [], + encrypted_content: "native-backend-blob", + content: [{ type: "reasoning_text", text: "raw routed reasoning" }], + }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input[0]).toEqual({ + type: "reasoning", + status: "completed", + summary: [], + encrypted_content: "native-backend-blob", + content: [], + }); + }); + + test("keeps encrypted reasoning content without a proven route switch", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [{ + type: "reasoning", + summary: [], + encrypted_content: "same-backend-blob", + }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input[0]).toEqual({ + type: "reasoning", + summary: [], + encrypted_content: "same-backend-blob", + }); + }); + + test("strips encrypted reasoning content after a known route switch but keeps the item", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _stripReasoningEncryptedContent: true, + _rawBody: { + model: "gpt-5.6-sol", + input: [ + { + type: "reasoning", + id: "rs_foreign_backend", + status: "completed", + summary: [{ type: "summary_text", text: "still useful" }], + encrypted_content: "foreign-backend-blob", + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input).toEqual([ + { + type: "reasoning", + id: "rs_foreign_backend", + summary: [{ type: "summary_text", text: "still useful" }], + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ]); + }); + + test("strips status from a reasoning item that has no encrypted content", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [{ + type: "reasoning", + id: "rs_without_blob", + status: "completed", + summary: [{ type: "summary_text", text: "summary" }], + }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input[0]).toEqual({ + type: "reasoning", + id: "rs_without_blob", + summary: [{ type: "summary_text", text: "summary" }], + }); + }); + test("strips image_generation hosted tool for codex-spark passthrough", () => { const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ diff --git a/tests/reasoning-replay-identity.test.ts b/tests/reasoning-replay-identity.test.ts index 80cdd49aaa..24465bfbc3 100644 --- a/tests/reasoning-replay-identity.test.ts +++ b/tests/reasoning-replay-identity.test.ts @@ -10,6 +10,7 @@ import { reasoningReplayKeyCredentialIdentity, reasoningReplayOAuthCredentialIdentity, rememberReasoningForCall, + updateReasoningReplayServingIdentity, } from "../src/responses/reasoning-replay-cache"; import type { AdapterEvent, OcxReasoningReplayScopeRef } from "../src/types"; @@ -69,6 +70,55 @@ describe("reasoning replay provider and credential identity", () => { } }); + test("serving identity comparison reports only known model or destination changes", () => { + expect(updateReasoningReplayServingIdentity(scope())).toBe(false); + expect(updateReasoningReplayServingIdentity(scope())).toBe(false); + + const changedModel = scope({ modelId: "deepseek-v4" }); + expect(updateReasoningReplayServingIdentity(changedModel)).toBe(true); + expect(updateReasoningReplayServingIdentity(changedModel)).toBe(false); + + const changedDestination = scope({ + modelId: "deepseek-v4", + providerDestinationIdentity: "destination:provider-b", + }); + expect(updateReasoningReplayServingIdentity(changedDestination)).toBe(true); + expect(updateReasoningReplayServingIdentity(changedDestination)).toBe(false); + + expect(updateReasoningReplayServingIdentity(undefined)).toBe(false); + expect(updateReasoningReplayServingIdentity({ clientThreadId: "thread-unknown" })).toBe(false); + }); + + test("expired serving identity is unknown rather than a backend change", () => { + let clock = 1_000; + clearReasoningReplayCacheForTests(() => clock); + expect(updateReasoningReplayServingIdentity(scope())).toBe(false); + + clock += 60 * 60 * 1000 + 1; + expect(updateReasoningReplayServingIdentity(scope({ modelId: "deepseek-v4" }))).toBe(false); + }); + + test("repeated identity changes do not grow the thread store beyond 64 entries", () => { + const servingScope = ( + threadId: string, + modelId: string, + ): OcxReasoningReplayScopeRef => ({ + ...scope({ modelId }), + clientThreadId: threadId, + }); + + for (let i = 0; i < 64; i++) { + expect(updateReasoningReplayServingIdentity(servingScope(`thread-${i}`, "model-a"))).toBe(false); + } + for (let i = 0; i < 70; i++) { + expect(updateReasoningReplayServingIdentity(servingScope("thread-63", `model-change-${i}`))).toBe(true); + } + + expect(updateReasoningReplayServingIdentity(servingScope("thread-64", "model-a"))).toBe(false); + expect(updateReasoningReplayServingIdentity(servingScope("thread-1", "model-b"))).toBe(true); + expect(updateReasoningReplayServingIdentity(servingScope("thread-0", "model-b"))).toBe(false); + }); + test("incomplete, unscoped, and legacy thread-only namespaces fail closed", () => { const incomplete: OcxReasoningReplayScopeRef[] = [ { clientThreadId: THREAD }, From 4f9f7288419b3fdcbf1613dfb7a6779bac6b69a8 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 19:28:42 -0700 Subject: [PATCH 2/2] fix(responses): compare the serving identity on rotation-safe dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The serving-identity record compared `credentialIdentity`, which for OAuth is `accountId + generation` and therefore changes on every token refresh. Six of the eight `bindRouteReasoningReplayScope` call sites are key-rotation or OAuth-refresh rebinds, so an ordinary refresh registered as "the backend changed" and the next turn on that thread dropped a valid blob. Key-pool providers would have paid that repeatedly, and silently — nothing errors, the model just loses cached reasoning. The module already distinguishes the durable dimensions for exactly this reason (#1926: the rotating generation deliberately does not participate). The serving record now compares `providerDestinationDurableIdentity` and `credentialDurableIdentity`, and refuses to record at all when those are missing rather than falling back to the volatile pair: a missed strip costs one degraded turn, a spurious strip is a permanent quality regression. The proxy-owned replay cache keeps its stricter key, which is deliberate. Also documents two behaviours that would otherwise read as bugs: a combo that rotates targets between turns legitimately drops blobs while the SSE model-name rewrite hides the switch from the client, and the image/web-search loops consume the replay scope without rebinding, which is what stops an internal small-model call from poisoning the record for the main conversation. Co-Authored-By: Claude Fable 5 --- src/responses/reasoning-replay-cache.ts | 27 +++++++++++-- structure/04_transports-and-sidecars.md | 19 +++++++++- tests/reasoning-replay-identity.test.ts | 50 +++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index 18fd8261ef..c438f380c6 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -91,6 +91,25 @@ function tupleForIdentity( ]; } +function tupleForServingIdentity( + identity: Readonly | undefined, +): ReasoningReplayIdentityTuple | undefined { + if ( + !nonEmpty(identity?.providerName) + || !nonEmpty(identity?.providerDestinationDurableIdentity) + || !nonEmpty(identity?.adapterName) + || !nonEmpty(identity?.modelId) + || !nonEmpty(identity?.credentialDurableIdentity) + ) return undefined; + return [ + identity.providerName, + identity.providerDestinationDurableIdentity, + identity.adapterName, + identity.modelId, + identity.credentialDurableIdentity, + ]; +} + function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined { const identity = tupleForIdentity(scope?.current); if (!nonEmpty(callId) || !nonEmpty(scope?.clientThreadId) || !identity) return undefined; @@ -119,14 +138,16 @@ function sweepExpiredServingIdentities(at: number): void { * record the current route. A live mismatch means replayed opaque reasoning was minted by * another backend and must not be forwarded to this one. * - * Missing, expired, or evicted state is deliberately unknown rather than a mismatch. This - * store is process-local, so a backend switch spanning a proxy restart is not detected. + * Serving provenance uses restart-stable destination and credential dimensions so token + * generations and other volatile credential material cannot create false route changes. Missing + * durable identity, expired, or evicted state is deliberately unknown rather than a mismatch. + * This store is process-local, so a backend switch spanning a proxy restart is not detected. */ export function updateReasoningReplayServingIdentity( scope: OcxReasoningReplayScopeRef | undefined, ): boolean { const threadId = scope?.clientThreadId; - const identityTuple = tupleForIdentity(scope?.current); + const identityTuple = tupleForServingIdentity(scope?.current); if (!nonEmpty(threadId) || !identityTuple) return false; const identity = JSON.stringify(identityTuple); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index bd1515e17b..912fe1038e 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -488,14 +488,29 @@ blob is kept unless the in-process thread record proves that the current provide adapter, model, or credential differs from the route recorded for the prior request on that client thread. On a proven change the blob and `status` are removed while the reasoning item and its summary survive; `status` is also removed from blobless reasoning items. Missing, expired, or evicted identity -state is unknown. The record is deliberately process-local, so a backend switch spanning a proxy +state is unknown. The comparison uses the durable destination and credential identities with the +provider, adapter, and model, so OAuth token-generation refreshes do not look like backend changes; +when either durable dimension is unavailable it refuses to record rather than falling back to a +volatile identity. The record is deliberately process-local, so a backend switch spanning a proxy restart is not detected and may still be rejected upstream. +A combo target rotation between turns legitimately changes that serving identity, so the following +turn drops blobs minted by the prior target. This is correct because the new target cannot decode +them, but it is intentionally unobvious to the client: `pickComboTarget` keys selection state only by +combo id, without a conversation dimension, and the SSE model-name rewrite preserves the requested +combo name instead of exposing the concrete target switch. A user can therefore observe a reasoning +cache drop with no visible model change. + +The image and web-search auxiliary loops consume `_reasoningReplayScope` for bridge-level replay but +never call `bindRouteReasoningReplayScope`, so their internal small-model requests do not update the +serving-identity record. That omission is intentional: binding those routes would poison the main +conversation's last-serving identity and cause a later main-model turn to strip valid blobs. + [Decision Log] - 목적과 의도: Keep same-backend opaque reasoning replay while preventing backend-private blobs and output-only fields from breaking the first turn after a route change. - 기존 구현 및 제약 조건: Reasoning-input sanitation already handled raw content and `ocxr1:` envelopes; the replay cache already supplied a bounded, thread-scoped physical-route identity, but no record connected that identity to native `encrypted_content` provenance. - 검토한 주요 대안: Strip every opaque blob, persist provenance across restarts, retry after an upstream 4xx, or compare and strip before the first outbound request only when an in-process record proves a route change. -- 선택한 방식: Preserve `status` whenever its blob is forwarded without changing the pre-existing raw-`content` blanking rule; otherwise remove output-only `status`, compare and update a 64-entry/256 KiB/one-hour in-process serving-identity record at request time, and pass the proven-change decision into the Responses adapter to remove foreign `encrypted_content`. +- 선택한 방식: Preserve `status` whenever its blob is forwarded without changing the pre-existing raw-`content` blanking rule; otherwise remove output-only `status`, compare and update a 64-entry/256 KiB/one-hour in-process serving-identity record using durable destination and credential dimensions at request time, and pass the proven-change decision into the Responses adapter to remove foreign `encrypted_content`. - 다른 대안 대신 이 방식을 선택한 이유: Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and a deterministic pre-flight decision avoids a second paid or stateful upstream attempt. - 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning, known cross-route replay keeps the reasoning item without its undecodable blob, and switches spanning a proxy restart remain an explicit coverage gap. diff --git a/tests/reasoning-replay-identity.test.ts b/tests/reasoning-replay-identity.test.ts index 24465bfbc3..8f204b8141 100644 --- a/tests/reasoning-replay-identity.test.ts +++ b/tests/reasoning-replay-identity.test.ts @@ -26,9 +26,11 @@ function scope( current: { providerName: "provider-a", providerDestinationIdentity: "destination:provider-a", + providerDestinationDurableIdentity: "destination:durable-provider-a", adapterName: "openai-chat", modelId: "deepseek-v4-flash", credentialIdentity: "key:physical-a", + credentialDurableIdentity: "credential:durable-slot-a", ...overrides, }, }; @@ -70,17 +72,35 @@ describe("reasoning replay provider and credential identity", () => { } }); - test("serving identity comparison reports only known model or destination changes", () => { - expect(updateReasoningReplayServingIdentity(scope())).toBe(false); - expect(updateReasoningReplayServingIdentity(scope())).toBe(false); + test("serving identity ignores credential generation but reports durable route changes", () => { + expect(updateReasoningReplayServingIdentity(scope({ + credentialIdentity: "oauth:slot-a-generation-a", + }))).toBe(false); + expect(updateReasoningReplayServingIdentity(scope({ + credentialIdentity: "oauth:slot-a-generation-b", + }))).toBe(false); - const changedModel = scope({ modelId: "deepseek-v4" }); + const changedModel = scope({ + modelId: "deepseek-v4", + credentialIdentity: "oauth:slot-a-generation-b", + }); expect(updateReasoningReplayServingIdentity(changedModel)).toBe(true); expect(updateReasoningReplayServingIdentity(changedModel)).toBe(false); + const changedCredential = scope({ + modelId: "deepseek-v4", + credentialIdentity: "oauth:slot-b-generation-a", + credentialDurableIdentity: "credential:durable-slot-b", + }); + expect(updateReasoningReplayServingIdentity(changedCredential)).toBe(true); + expect(updateReasoningReplayServingIdentity(changedCredential)).toBe(false); + const changedDestination = scope({ modelId: "deepseek-v4", providerDestinationIdentity: "destination:provider-b", + providerDestinationDurableIdentity: "destination:durable-provider-b", + credentialIdentity: "oauth:slot-b-generation-a", + credentialDurableIdentity: "credential:durable-slot-b", }); expect(updateReasoningReplayServingIdentity(changedDestination)).toBe(true); expect(updateReasoningReplayServingIdentity(changedDestination)).toBe(false); @@ -89,6 +109,28 @@ describe("reasoning replay provider and credential identity", () => { expect(updateReasoningReplayServingIdentity({ clientThreadId: "thread-unknown" })).toBe(false); }); + test("serving identity refuses to record when durable dimensions are unavailable", () => { + const clientThreadId = "thread-without-durable-identity"; + expect(updateReasoningReplayServingIdentity({ + ...scope({ credentialDurableIdentity: undefined }), + clientThreadId, + })).toBe(false); + expect(updateReasoningReplayServingIdentity({ + ...scope({ modelId: "different-model" }), + clientThreadId, + })).toBe(false); + + const destinationThreadId = "thread-without-durable-destination"; + expect(updateReasoningReplayServingIdentity({ + ...scope({ providerDestinationDurableIdentity: undefined }), + clientThreadId: destinationThreadId, + })).toBe(false); + expect(updateReasoningReplayServingIdentity({ + ...scope({ modelId: "different-model" }), + clientThreadId: destinationThreadId, + })).toBe(false); + }); + test("expired serving identity is unknown rather than a backend change", () => { let clock = 1_000; clearReasoningReplayCacheForTests(() => clock);