From 093a0d06dfb6f5a89c1a27660a87c973fb65ae6d Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 15:05:48 -0700 Subject: [PATCH 01/17] fix(responses): keep compaction blobs on the backend that minted them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and Codex replays it on every later turn. Two paths modified or misrouted it, and because the item outlives the failure in the client transcript, both wedged the session until its history was cleared — the routed compaction turn the proxy itself drives replays the same item. Relay: `scrubOcxCompactionItems` treated every non-`ocx1:` blob as OpenAI's and forwarded it verbatim, with no check that the destination was the issuer. A session that compacted on a canonical route and then switched to a routed provider sent that blob to an upstream that could only answer "Could not decode the compaction blob". Native blobs now travel only to destinations that mint them — forward-auth routes, which relay the caller's own OpenAI credentials to the ChatGPT backend or a relay in front of it, and the official OpenAI API under key auth — and degrade elsewhere to the same opaque note the bridged parser uses. Backfill: the response-side exemption list named `compaction` alone, so `compaction_summary` and `context_compaction` received synthesized ids that the client stored and replayed as "modified from the compact response". That divergence was possible because the compact wire family was enumerated in three places; it is now one predicate in `src/responses/compaction.ts`. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 34 +++++--- src/providers/openai-tiers.ts | 19 ++++ src/responses/compaction.ts | 18 ++++ src/responses/parser.ts | 4 +- .../responses/responses-field-backfill.ts | 18 ++-- structure/04_transports-and-sidecars.md | 30 +++++++ tests/openai-responses-passthrough.test.ts | 87 +++++++++++++++++++ tests/responses-field-backfill.test.ts | 36 ++++---- 8 files changed, 204 insertions(+), 42 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 8156080c7f..71ac9df264 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2,11 +2,11 @@ import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; import { catalogModelSupportsReasoningSummaries } from "../codex/catalog"; -import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction"; +import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../responses/compaction"; import { collectResponsesToolGroups } from "../responses/tool-groups"; import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; import { decodeServerSentEvents } from "../lib/sse-decoder"; -import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, destinationDecodesNativeCompactionBlob, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; @@ -143,25 +143,33 @@ function stripItemIdsWhenUnstored(body: unknown): unknown { } /** - * Replace proxy-minted compaction items (`encrypted_content` starting with `ocx1:`) with plain - * user messages before forwarding to the ChatGPT backend. Our envelope is transparent base64, not - * OpenAI encryption — the native backend cannot decrypt it and would reject the request. Real - * OpenAI-encrypted compaction items are forwarded untouched. + * Normalize replayed compaction items for the destination backend. + * + * A compaction item carries an `encrypted_content` blob the client replays verbatim on every later + * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are + * transparent base64 rather than encryption, so no upstream can read them and they always become + * plain user messages. A foreign blob was minted by an OpenAI-operated backend: forwarding it to a + * different destination makes that upstream reject the turn ("Could not decode the compaction + * blob"), and because the item lives in the client transcript the rejection repeats on every later + * turn — including the compaction turn the proxy itself drives — leaving the session unable to + * recover. Off those destinations it degrades to the same note the bridged parser uses. + * + * A bare `context_compaction` marker carries no blob and is forwarded untouched. */ -function scrubOcxCompactionItems(body: unknown): unknown { +function scrubOcxCompactionItems(body: unknown, destinationDecodesNativeBlob: boolean): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { - if (!isPlainObject(item)) return item; - if (item.type !== "compaction" && item.type !== "compaction_summary" && item.type !== "context_compaction") return item; - const decoded = typeof item.encrypted_content === "string" ? decodeCompactionSummary(item.encrypted_content) : null; - if (decoded === null) return item; + if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item; + const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined; + if (encrypted === undefined) return item; + if (decodeCompactionSummary(encrypted) === null && destinationDecodesNativeBlob) return item; changed = true; return { type: "message", role: "user", - content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${decoded}` }], + content: [{ type: "input_text", text: compactionItemToText(encrypted) }], }; }); @@ -1602,7 +1610,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripOpenAiOnlyWebSearchFields(outBody); } } - 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, destinationDecodesNativeCompactionBlob(provider)), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); const finalBody = stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), provider, diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index f1156cb447..6e3c045fb8 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -54,6 +54,25 @@ export function supportsNativeResponsesCompactEndpoint( && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; } +/** + * Whether this destination can decode a native (non-`ocx1:`) compaction blob. + * + * Only the backend that minted a blob can decode it, and only OpenAI-operated Responses surfaces + * mint them. Forward auth relays the caller's own OpenAI credentials, so a forward destination is + * either the ChatGPT backend or a relay standing in front of one — both can read what they issued, + * and a self-hosted relay must not lose its users' compacted history to a guess about its URL. The + * remaining positive case is the official OpenAI API under key auth. + * + * Everything else is a routed provider carrying its own credentials to a non-OpenAI backend, which + * can only reject a blob it never issued. Keyed by destination rather than provider id: a blob's + * issuer is the URL that produced it, not the local config key a replay travels under. + */ +export function destinationDecodesNativeCompactionBlob(provider: OcxProviderConfig): boolean { + if (provider.authMode === "forward") return true; + return provider.adapter === "openai-responses" + && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; +} + export interface OpenAiTierMigrationProjection { config: OcxConfig; changed: boolean; diff --git a/src/responses/compaction.ts b/src/responses/compaction.ts index df31069557..f3fba7a033 100644 --- a/src/responses/compaction.ts +++ b/src/responses/compaction.ts @@ -33,6 +33,24 @@ export const SUMMARY_PREFIX = "Another language model started to solve this prob export const OPAQUE_COMPACTION_NOTE = "[earlier conversation was compacted; the summary is stored in a format this model cannot read]"; +/** + * Item types in the compact wire family. Each carries an `encrypted_content` blob the client + * replays verbatim on every later turn, and the minting backend verifies it is unmodified. + * + * Keep this the only enumeration: a copy that listed just `compaction` let the response-side + * field backfill synthesize ids into the other two, which the client then replayed as "modified + * from the compact response". + */ +const COMPACTION_ITEM_TYPES: ReadonlySet = new Set([ + "compaction", + "compaction_summary", + "context_compaction", +]); + +export function isCompactionItemType(type: unknown): boolean { + return typeof type === "string" && COMPACTION_ITEM_TYPES.has(type); +} + export function encodeCompactionSummary(summary: string): string { return OCX_COMPACTION_PREFIX + Buffer.from(summary, "utf-8").toString("base64"); } diff --git a/src/responses/parser.ts b/src/responses/parser.ts index ed7e83588c..de07832a40 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -15,7 +15,7 @@ import { namespacedToolName, toolChoiceCandidates } from "../types"; import { responsesRequestSchema } from "./schema"; import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata"; import { lookupReplayThoughtSignature } from "./thought-signature-replay"; -import { compactionItemToText } from "./compaction"; +import { compactionItemToText, isCompactionItemType } from "./compaction"; import { previousResponseReplayPrefixLength } from "./state"; import { decodeReasoningEnvelope } from "./reasoning-envelope"; import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool"; @@ -434,7 +434,7 @@ export function parseRequest( continue; } - if (effectiveType === "compaction" || effectiveType === "compaction_summary" || effectiveType === "context_compaction") { + if (isCompactionItemType(effectiveType)) { // A stored summary from a previous compaction. Decode our ocx1 envelope into plain text so // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note. // `context_compaction` (encrypted_content optional) is codex-rs's local-compaction marker; diff --git a/src/server/responses/responses-field-backfill.ts b/src/server/responses/responses-field-backfill.ts index 32cf727891..48019670f2 100644 --- a/src/server/responses/responses-field-backfill.ts +++ b/src/server/responses/responses-field-backfill.ts @@ -24,6 +24,7 @@ import { sseDataPayload, type SseBlockRewrite, } from "../sse-payload-rewrite"; +import { isCompactionItemType } from "../../responses/compaction"; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -119,16 +120,6 @@ function backfillContentArray(content: unknown): unknown { return changed ? repaired : content; } -/** - * Item types that are NOT Responses output items and must be returned byte-for-byte. - * - * `compaction` is the `/v1/responses/compact` wire format, not a Responses output item. It has - * no `id` in that contract, so synthesizing one changes a response body the client compares - * exactly. The backfill exists to satisfy strict Responses decoders; a shape those decoders - * never see is outside its remit. - */ -const NON_RESPONSES_ITEM_TYPES: ReadonlySet = new Set(["compaction"]); - /** * Walk an output item and backfill output_text parts in its content. * Also backfills a missing required id on the item itself. @@ -136,7 +127,12 @@ const NON_RESPONSES_ITEM_TYPES: ReadonlySet = new Set(["compaction"]); */ function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown { if (!isPlainObject(item)) return item; - if (typeof item.type === "string" && NON_RESPONSES_ITEM_TYPES.has(item.type)) return item; + // The compact wire family is the `/v1/responses/compact` format, not a Responses output item. + // Those items have no `id` in that contract, so synthesizing one changes a response body the + // client compares exactly — and the client replays the item on every later turn, where the + // minting backend rejects it as modified. The backfill exists to satisfy strict Responses + // decoders; a shape those decoders never see is outside its remit. + if (isCompactionItemType(item.type)) return item; const content = item.content; const repaired = backfillContentArray(content); const withId = backfillItemId(item, slot); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index d63e594b05..0dc7c26514 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -70,6 +70,36 @@ alone never opt a gateway in. and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. +A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, +and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent +base64, so they always lower to plain user messages. A native blob is relayed only to destinations +that mint them — forward-auth routes, which relay the caller's own OpenAI credentials to the ChatGPT +backend or a relay in front of it, and the official OpenAI API under key auth. On any other routed +destination it degrades to the same opaque note the bridged parser uses, because forwarding it there +fails the turn and the item outlives the failure in the client transcript, repeating on every later +turn including the compaction turn the proxy itself drives. Compact-wire items are also exempt from +the `store: false` item-id strip and from response-side field backfill: their id is not a stored-item +reference, and editing the item is what the minting backend rejects as modified. + +[Decision Log] +- 목적과 의도: Keep a session usable after its history crosses backends, instead of wedging it on a + compaction blob the current upstream cannot decode. +- 기존 구현 및 제약 조건: Compaction handling was binary — `ocx1:` envelopes were ours, everything + else was assumed to be OpenAI's and forwarded verbatim, with no record of which upstream minted a + blob. Response-side field backfill exempted only `compaction`, so its two sibling types received + synthesized ids the client then replayed. +- 검토한 주요 대안: Tag every compaction item with its minting provider/credential/model identity; + drop compaction items on any route change; gate relay on the destination that would decode them. +- 선택한 방식: Relay a native blob only to destinations that mint them and degrade it elsewhere, and + treat the compact wire family as one enumeration so id-bearing passes cannot diverge per type. +- 다른 대안 대신 이 방식을 선택한 이유: Full provenance tagging needs per-conversation state this + boundary does not have, while dropping on any change would discard compacted context that still + round-trips correctly; the destination test is decidable from the request alone. +- 장점, 단점 및 영향: A cross-backend session degrades one compaction summary to a note instead of + failing every later turn. A self-hosted forward relay keeps its blobs. The cost is that a routed + gateway that did mint its own native blob would also see a note — no such gateway exists today, + since routed compaction always produces an `ocx1:` envelope. + ### Mixed-wire provider defaults Registry `modelWireDefaults` select an evidence-backed upstream protocol for an exact model without diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 52ed4cf3d1..5f75ba4c88 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -4,6 +4,11 @@ import { openaiResponsesUrl } from "../src/adapters/openai-responses-url"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; import { sanitizeEncryptedContentInPlace } from "../src/server/responses"; +import { + encodeCompactionSummary, + OPAQUE_COMPACTION_NOTE, + SUMMARY_PREFIX, +} from "../src/responses/compaction"; import { createTranslatorBudget } from "../src/lib/translator-budget"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -735,6 +740,7 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(storedBody.input.map(item => item.id)).toEqual(["msg_abc", "fc_xyz", "rs_123"]); }); + test("drops raw reasoning input content before native GPT passthrough", () => { const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ @@ -2135,6 +2141,87 @@ describe("OpenAI Responses forward-mode unsupported param stripping", () => { }); }); +describe("replayed compaction blobs", () => { + type PassthroughProvider = Parameters[0]; + + // Shaped like a blob minted by an OpenAI-operated backend: opaque, no `ocx1:` envelope. + const NATIVE_BLOB = "gAAAAAB-openai-minted-compaction-blob"; + const routedProvider: PassthroughProvider = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "xai-test", + }; + const openaiKeyedProvider: PassthroughProvider = { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "sk-test", + }; + // Forward auth relays the caller's own OpenAI credentials, so a self-hosted relay is standing in + // front of an OpenAI backend and must keep its users' compacted history. + const forwardRelayProvider: PassthroughProvider = { + adapter: "openai-responses", + baseUrl: "https://relay.example/backend-api/codex", + authMode: "forward", + }; + + function forwardedInput(target: PassthroughProvider, input: unknown[]): Record[] { + const request = createResponsesPassthroughAdapter(target).buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "grok-4.6", store: false, input }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + return (JSON.parse(request.body) as { input: Record[] }).input; + } + + // Forwarding a blob to a backend that did not mint it fails the turn, and because the item lives + // in the client transcript the failure repeats on every later turn — including the compaction turn + // — so the session cannot recover until its history is cleared. + test("degrades a foreign blob to a note on a destination that cannot decode it", () => { + for (const type of ["compaction", "compaction_summary", "context_compaction"]) { + const forwarded = forwardedInput(routedProvider, [ + { type, encrypted_content: NATIVE_BLOB }, + ]); + expect(forwarded[0]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }); + expect(JSON.stringify(forwarded)).not.toContain(NATIVE_BLOB); + } + }); + + test("forwards a foreign blob untouched to the backends that mint them", () => { + const item = { type: "compaction", encrypted_content: NATIVE_BLOB }; + for (const target of [provider, openaiKeyedProvider, forwardRelayProvider]) { + expect(forwardedInput(target, [item])[0]).toEqual(item); + } + }); + + // The proxy's own envelope is transparent base64, so no upstream can read it anywhere. + test("lowers proxy-minted ocx1 envelopes on every destination", () => { + const item = { type: "compaction", encrypted_content: encodeCompactionSummary("prior work") }; + for (const target of [provider, openaiKeyedProvider, routedProvider]) { + expect(forwardedInput(target, [item])[0]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\nprior work` }], + }); + } + }); + + // A bare marker carries no blob, so there is nothing to mis-route. + test("leaves a bare context_compaction marker alone", () => { + const item = { type: "context_compaction" }; + for (const target of [provider, routedProvider]) { + expect(forwardedInput(target, [item])[0]).toEqual(item); + } + }); +}); + describe("openaiResponsesUrl", () => { test("does not strip mid-path /v1 or a non-endpoint responses suffix", () => { expect(openaiResponsesUrl("https://proxy.example.com/v1/relay")).toBe( diff --git a/tests/responses-field-backfill.test.ts b/tests/responses-field-backfill.test.ts index f4ac338b69..c5ec73e690 100644 --- a/tests/responses-field-backfill.test.ts +++ b/tests/responses-field-backfill.test.ts @@ -433,21 +433,25 @@ describe("responses-field-backfill", () => { expect(new Set(ids).size).toBe(2); }); - // `compaction` is the /v1/responses/compact wire format, not a Responses output item: it - // carries no id in that contract, and clients compare the body exactly. Synthesizing an id - // here changed a response that had nothing to do with strict Responses decoding — a defect - // that only appeared once this backfill and the compact endpoint were on the same tree. - test("a compaction item is returned byte-for-byte", () => { - const response = { - id: "resp_1", - object: "response", - status: "completed", - output: [{ type: "compaction", encrypted_content: "gAAAAAB-test-opaque" }], - }; - const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as { - output: Record[]; - }; - expect(result.output[0]).toEqual({ type: "compaction", encrypted_content: "gAAAAAB-test-opaque" }); - expect(result.output[0]).not.toHaveProperty("id"); + // The compact wire family is the /v1/responses/compact format, not Responses output items: they + // carry no id in that contract, clients compare the body exactly, and the client replays the item + // on every later turn where the minting backend rejects a modified one. Synthesizing an id here + // changed a response that had nothing to do with strict Responses decoding — a defect that only + // appeared once this backfill and the compact endpoint were on the same tree. It originally + // covered `compaction` alone, so the sibling types kept receiving synthesized ids. + test("every compact wire item type is returned byte-for-byte", () => { + for (const type of ["compaction", "compaction_summary", "context_compaction"]) { + const item = { type, encrypted_content: "gAAAAAB-test-opaque" }; + const response = { id: "resp_1", object: "response", status: "completed", output: [item] }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as { + output: Record[]; + }; + expect(result.output[0]).toEqual(item); + expect(result.output[0]).not.toHaveProperty("id"); + + const streamed = parseData(apply(sseBlock({ type: "response.output_item.done", output_index: 0, item }))); + expect(streamed[0].item).toEqual(item); + expect(streamed[0].item).not.toHaveProperty("id"); + } }); }); From 64cdb1150e5e22e2d2c3f1c4a7e40780c43d3d74 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 15:09:05 -0700 Subject: [PATCH 02/17] fix(responses): stop reshaping reasoning items that carry encrypted_content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex replays the reasoning item it received in the next request's input, and a backend that issued `encrypted_content` verifies what comes back. The content-to-summary channel rewrite deletes `content` and substitutes a synthesized `summary`, so the client stored and replayed an item the issuer had never sent, and every later turn failed with "Could not decrypt the provided encrypted_content. Ensure the value is the unmodified encrypted_content from a previous response." No route change is needed to reach this: it fires on the second turn of a fresh session. The rewrite's replay round trip was verified against DeepSeek, which is `statelessResponses` and issues no blob — its reasoning replay goes through the proxy-side cache instead. Providers that do issue a blob joined the same route later through `preserveReasoningContentModels`, a flag whose own purpose is Chat-wire prompt-cache replay, and the verified premise did not follow them. Only the stored item is exempt. The `reasoning_text` delta events carry no blob and still route to the summary channel, so the expandable trace Codex renders for the live turn is unchanged. Co-Authored-By: Claude Fable 5 --- .../responses-reasoning-summary-rewrite.ts | 8 ++++ structure/04_transports-and-sidecars.md | 9 ++++ ...esponses-reasoning-summary-rewrite.test.ts | 46 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts index 21a8b6a5bf..0230879313 100644 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -34,6 +34,14 @@ function reasoningTextOf(item: Record): string { /** Move a reasoning item's content channel into the summary channel. */ function reasoningItemToSummaryShape(item: Record): Record { if (item.type !== "reasoning") return item; + // An item carrying `encrypted_content` is replayed verbatim by the client, and the backend that + // issued the blob verifies the item it gets back ("Could not decrypt the provided + // encrypted_content. Ensure the value is the unmodified encrypted_content from a previous + // response."). Reshaping it here is a modification the client then replays on every later turn. + // The delta rewrite still gives Codex the expandable trace for the live turn; only the stored + // shape has to stay exactly as the upstream sent it. DeepSeek — the provider this rewrite was + // verified against — is `statelessResponses` and issues no blob, so it is unaffected. + if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) return item; const text = reasoningTextOf(item); // Items that already use the summary channel (or carry no content text at // all) are left untouched: rewriting them could clear a valid summary. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index d63e594b05..25bf0c2139 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -797,6 +797,15 @@ Codex app, so tool cells group like native models — while the text still round `content[reasoning_text]` shape. Diagnosis and codex-rs grouping evidence: `devlog/_fin/260709_native_response_pattern/`. +The content-to-summary channel rewrite skips any reasoning item that carries a native +`encrypted_content` blob. Codex replays the reasoning item it received, and a backend that issued +that blob verifies what comes back, so reshaping the stored item makes every later turn fail with +`Could not decrypt the provided encrypted_content`. The rewrite's round trip was verified against +DeepSeek, which is `statelessResponses` and issues no blob; providers that do issue one joined the +same route later through `preserveReasoningContentModels`. Only the stored item is exempt — the +`reasoning_text` delta events carry no blob and still route to the summary channel, so the live +expandable trace is unchanged. + The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential identity. API-key material is represented only by a process-keyed HMAC; OAuth replay is bound to the diff --git a/tests/responses-reasoning-summary-rewrite.test.ts b/tests/responses-reasoning-summary-rewrite.test.ts index 09b8e1bad7..868cc77d49 100644 --- a/tests/responses-reasoning-summary-rewrite.test.ts +++ b/tests/responses-reasoning-summary-rewrite.test.ts @@ -209,6 +209,52 @@ describe("responses reasoning summary channel rewrite", () => { expect(rewrite("not json")).toBe("not json"); expect(rewrite("[1,2]")).toBe("[1,2]"); }); + + // The client replays the reasoning item it received, and a backend that issued + // `encrypted_content` rejects a reshaped item on that replay. This rewrite's round-trip was + // verified against DeepSeek, which is stateless and issues no blob; providers that do issue one + // joined later through `preserveReasoningContentModels`. + describe("items carrying encrypted_content", () => { + const blobItem = { + type: "reasoning", + id: "rs_1", + status: "completed", + encrypted_content: "gAAAAAB-upstream-issued-blob", + content: [{ type: "reasoning_text", text: "thinking" }], + summary: [], + }; + + test("are returned byte-for-byte on output_item.done", () => { + const payload = { type: "response.output_item.done", output_index: 0, item: blobItem }; + expect(apply(payload)).toEqual(payload); + }); + + test("are returned byte-for-byte inside response.completed output", () => { + const payload = { + type: "response.completed", + response: { id: "resp_1", output: [blobItem] }, + }; + expect(apply(payload)).toEqual(payload); + }); + + test("are returned byte-for-byte through the non-streaming document rewrite", () => { + const doc = { id: "resp_1", object: "response", output: [blobItem] }; + expect(rewriteReasoningSummaryInJson(doc)).toBe(doc); + const json = JSON.stringify(doc); + expect(rewriteReasoningSummaryInJsonString(json)).toBe(json); + }); + + // Only the stored item is protected: the live trace Codex renders comes from the delta events, + // which carry no blob and are still routed to the summary channel. + test("do not disable the delta rewrite that renders the live trace", () => { + expect(apply({ + type: "response.reasoning_text.delta", + delta: "think", + item_id: "rs_1", + output_index: 0, + })).toMatchObject({ type: "response.reasoning_summary_text.delta", delta: "think" }); + }); + }); }); describe("routeUsesContentChannelReasoning", () => { From 1ec7343b219087f703dd4b3dba8302c6691e6c3c Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:03:50 -0700 Subject: [PATCH 03/17] fix(responses): drop a null reasoning content channel before routed passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex serializes an absent reasoning content channel as `"content": null`, and the sanitizer only acted on a non-empty array, so the null went to the wire verbatim. xAI rejects the item and blames the sibling field: {"code":"invalid-argument", "error":"Could not decode the compaction blob. Ensure it is unmodified from the compact response."} The blob is not the problem. Captured from a live failing request and bisected against it: replaying the body verbatim reproduces the 400, deleting only the `content` key returns 200, and setting it to `[]` also returns 200 — while removing `encrypted_content` instead fails schema validation, so the blob is both required and intact. The proxy was verified not to alter the blob: the value grok streamed to the client and the value replayed upstream matched in length, prefix and suffix, under identical `x-grok-conv-id`, `x-grok-session-id` and account. This bites the second turn of every Grok conversation — the first request that replays a reasoning item — which is why a fresh session fails just as reliably as a resumed one, and why the error looked like stale compaction state. The field is optional and null carries nothing, so the key is dropped rather than rewritten; an array content channel still follows the existing rules. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 12 ++++++ tests/openai-responses-passthrough.test.ts | 46 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 8156080c7f..cab27cdb12 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -56,6 +56,18 @@ 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); + // Codex serializes an absent reasoning content channel as `"content": null`. The field is + // optional and null carries nothing, but a strict gateway rejects the item on its declared type + // — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content` + // rather than the field it actually refused, which is why this reads as a blob failure. Drop the + // key so the item matches the shape the upstream issued. + if ("content" in rec && !Array.isArray(rec.content)) { + changed = true; + const next: Record = { ...rec }; + delete next.content; + if (hasOcxEnvelope) delete next.encrypted_content; + return next; + } if (!hasRawContent && !hasOcxEnvelope) return item; if (hasOcxEnvelope) { changed = true; diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 52ed4cf3d1..987af9350b 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -2145,3 +2145,49 @@ describe("openaiResponsesUrl", () => { ); }); }); + +describe("reasoning input content channel", () => { + const routed = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }; + + function forwarded(item: Record): Record { + const request = createResponsesPassthroughAdapter(routed).buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "grok-4.6", store: false, input: [item] }, + }, { headers: new Headers() }); + return (JSON.parse(request.body) as { input: Record[] }).input[0]; + } + + // Codex serializes an absent reasoning content channel as `"content": null`. xAI rejects the item + // and blames the sibling blob (`Could not decode the compaction blob`), so this reads as an + // encrypted_content failure; dropping the null key is what actually fixes it. Verified against a + // captured failing request: removing only this key turned the 400 into a 200. + test("drops a null content channel while keeping the replayable blob", () => { + const out = forwarded({ + type: "reasoning", + content: null, + summary: [{ type: "summary_text", text: "thinking" }], + encrypted_content: "upstream-issued-blob", + }); + expect(out).not.toHaveProperty("content"); + expect(out.encrypted_content).toBe("upstream-issued-blob"); + expect(out.summary).toEqual([{ type: "summary_text", text: "thinking" }]); + }); + + test("leaves an array content channel to the existing sanitizer", () => { + const out = forwarded({ + type: "reasoning", + content: [{ type: "reasoning_text", text: "raw" }], + encrypted_content: "upstream-issued-blob", + }); + expect(out.content).toEqual([]); + expect(out.encrypted_content).toBe("upstream-issued-blob"); + }); +}); From 9a55a7934d343521bf93884e60dccb68865f7e2b Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:36:52 -0700 Subject: [PATCH 04/17] fix(responses): decide native-blob relay by destination, not by forward auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the discriminator unsound, and it was. `authMode === "forward"` describes local credential handling, not which backend answers: the adapter forwards caller credentials only to the canonical ChatGPT Codex surface, so a noncanonical forward provider receives none and may point anywhere. That produced both errors at once. A self-hosted or xAI-backed forward gateway was classified as able to decode a foreign blob, was sent it unchanged, and stayed wedged — the exact failure this branch exists to fix. Meanwhile a key-auth relay genuinely fronting OpenAI was classified as unable to decode and needlessly lost its compacted context. Relay is now positive only for the canonical surface, the exact official OpenAI API, or a destination whose operator opts in with the new `decodesNativeCompactionBlobs` provider flag. Verified that the flag survives config derivation and reaches the predicate, since the unit tests construct provider literals and would not have caught it being dropped there. Also corrects a stale line in the transport notes: compact-wire items are not exempt from the `store: false` item-id strip. That exemption was deliberately reverted to match codex-rs (`core/src/client.rs:918-925`). Co-Authored-By: Claude Fable 5 --- src/config.ts | 1 + src/providers/openai-tiers.ts | 21 ++++++------ src/types/provider.ts | 5 +++ structure/04_transports-and-sidecars.md | 21 ++++++------ tests/openai-responses-passthrough.test.ts | 37 ++++++++++++++-------- tests/responses-compaction.test.ts | 22 +++++++++++-- 6 files changed, 69 insertions(+), 38 deletions(-) diff --git a/src/config.ts b/src/config.ts index 60178f3d4f..dcf34313a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -715,6 +715,7 @@ const providerConfigSchema = z.object({ supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), + decodesNativeCompactionBlobs: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), // The management API accepts `null` as "clear this", so a config written before the POST // canonicalization below can hold one on disk. Rejecting it here would send the operator diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 6e3c045fb8..59e01c417f 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -57,20 +57,19 @@ export function supportsNativeResponsesCompactEndpoint( /** * Whether this destination can decode a native (non-`ocx1:`) compaction blob. * - * Only the backend that minted a blob can decode it, and only OpenAI-operated Responses surfaces - * mint them. Forward auth relays the caller's own OpenAI credentials, so a forward destination is - * either the ChatGPT backend or a relay standing in front of one — both can read what they issued, - * and a self-hosted relay must not lose its users' compacted history to a guess about its URL. The - * remaining positive case is the official OpenAI API under key auth. + * Only the backend that minted a blob can decode it. `authMode: "forward"` alone is not a signal: + * the adapter forwards caller credentials only to the canonical ChatGPT Codex surface, while a + * noncanonical forward provider receives no caller credentials and may point at any backend. * - * Everything else is a routed provider carrying its own credentials to a non-OpenAI backend, which - * can only reject a blob it never issued. Keyed by destination rather than provider id: a blob's - * issuer is the URL that produced it, not the local config key a replay travels under. + * Relay only to that canonical surface, the official OpenAI API, or a destination whose operator + * explicitly opts in. Keyed by destination rather than provider id: a blob's issuer is the URL that + * produced it, not the local config key a replay travels under. */ export function destinationDecodesNativeCompactionBlob(provider: OcxProviderConfig): boolean { - if (provider.authMode === "forward") return true; - return provider.adapter === "openai-responses" - && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; + return isCanonicalOpenAiForwardProvider(provider) + || (provider.adapter === "openai-responses" + && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL) + || provider.decodesNativeCompactionBlobs === true; } export interface OpenAiTierMigrationProjection { diff --git a/src/types/provider.ts b/src/types/provider.ts index 574fabd8be..d76ce27443 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -204,6 +204,11 @@ export interface OcxProviderConfig { * `ocxr1` envelopes are still stripped because no upstream can decrypt them. */ preserveResponsesReasoningContent?: boolean; + /** + * Explicit opt-in for a relay that genuinely fronts OpenAI and can decode native + * compaction blobs. Absent or false degrades foreign blobs to an opaque note. + */ + decodesNativeCompactionBlobs?: boolean; /** * Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918, * link-local, or unique-local upstreams. Metadata endpoints remain blocked. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 0dc7c26514..747a10a6b4 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -73,13 +73,15 @@ to GUI static serving. A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent base64, so they always lower to plain user messages. A native blob is relayed only to destinations -that mint them — forward-auth routes, which relay the caller's own OpenAI credentials to the ChatGPT -backend or a relay in front of it, and the official OpenAI API under key auth. On any other routed -destination it degrades to the same opaque note the bridged parser uses, because forwarding it there -fails the turn and the item outlives the failure in the client transcript, repeating on every later -turn including the compaction turn the proxy itself drives. Compact-wire items are also exempt from -the `store: false` item-id strip and from response-side field backfill: their id is not a stored-item -reference, and editing the item is what the minting backend rejects as modified. +known to decode them — the canonical ChatGPT forward surface, the official OpenAI API, or a provider +with the explicit `decodesNativeCompactionBlobs` capability. Forward auth alone is not evidence: +noncanonical forward providers receive no caller credentials and may point at any backend. On any +other routed destination the blob degrades to the same opaque note the bridged parser uses, because +forwarding it there fails the turn and the item outlives the failure in the client transcript, +repeating on every later turn including the compaction turn the proxy itself drives. With +`store: false`, request sanitization strips ids from every input item, including compact-wire items, +matching codex-rs (`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side +field backfill. [Decision Log] - 목적과 의도: Keep a session usable after its history crosses backends, instead of wedging it on a @@ -96,9 +98,8 @@ reference, and editing the item is what the minting backend rejects as modified. boundary does not have, while dropping on any change would discard compacted context that still round-trips correctly; the destination test is decidable from the request alone. - 장점, 단점 및 영향: A cross-backend session degrades one compaction summary to a note instead of - failing every later turn. A self-hosted forward relay keeps its blobs. The cost is that a routed - gateway that did mint its own native blob would also see a note — no such gateway exists today, - since routed compaction always produces an `ocx1:` envelope. + failing every later turn. A self-hosted OpenAI relay keeps its blobs only when explicitly opted in; + other routed gateways see a note because routed compaction produces an `ocx1:` envelope. ### Mixed-wire provider defaults diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 5f75ba4c88..b6d4224a73 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -2158,13 +2158,20 @@ describe("replayed compaction blobs", () => { authMode: "key", apiKey: "sk-test", }; - // Forward auth relays the caller's own OpenAI credentials, so a self-hosted relay is standing in - // front of an OpenAI backend and must keep its users' compacted history. + // Forward auth alone says nothing about the backend. Noncanonical providers receive no caller + // credentials, so this relay cannot be assumed to understand OpenAI's native blob. const forwardRelayProvider: PassthroughProvider = { adapter: "openai-responses", baseUrl: "https://relay.example/backend-api/codex", authMode: "forward", }; + const optedInRelayProvider: PassthroughProvider = { + adapter: "openai-responses", + baseUrl: "https://openai-relay.example/v1", + authMode: "key", + apiKey: "relay-test", + decodesNativeCompactionBlobs: true, + }; function forwardedInput(target: PassthroughProvider, input: unknown[]): Record[] { const request = createResponsesPassthroughAdapter(target).buildRequest({ @@ -2181,22 +2188,24 @@ describe("replayed compaction blobs", () => { // in the client transcript the failure repeats on every later turn — including the compaction turn // — so the session cannot recover until its history is cleared. test("degrades a foreign blob to a note on a destination that cannot decode it", () => { - for (const type of ["compaction", "compaction_summary", "context_compaction"]) { - const forwarded = forwardedInput(routedProvider, [ - { type, encrypted_content: NATIVE_BLOB }, - ]); - expect(forwarded[0]).toEqual({ - type: "message", - role: "user", - content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], - }); - expect(JSON.stringify(forwarded)).not.toContain(NATIVE_BLOB); + for (const target of [routedProvider, forwardRelayProvider]) { + for (const type of ["compaction", "compaction_summary", "context_compaction"]) { + const forwarded = forwardedInput(target, [ + { type, encrypted_content: NATIVE_BLOB }, + ]); + expect(forwarded[0]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }); + expect(JSON.stringify(forwarded)).not.toContain(NATIVE_BLOB); + } } }); - test("forwards a foreign blob untouched to the backends that mint them", () => { + test("forwards a foreign blob untouched to destinations known to decode it", () => { const item = { type: "compaction", encrypted_content: NATIVE_BLOB }; - for (const target of [provider, openaiKeyedProvider, forwardRelayProvider]) { + for (const target of [provider, openaiKeyedProvider, optedInRelayProvider]) { expect(forwardedInput(target, [item])[0]).toEqual(item); } }); diff --git a/tests/responses-compaction.test.ts b/tests/responses-compaction.test.ts index 23a7668e9c..457cb1832f 100644 --- a/tests/responses-compaction.test.ts +++ b/tests/responses-compaction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; +import { CODEX_FORWARD_BASE_URL } from "../src/providers/openai-tiers"; import { parseRequest } from "../src/responses/parser"; import { COMPACT_PROMPT, @@ -178,8 +179,11 @@ describe("forward-path ocx1 compaction scrub", () => { authMode: "forward" as const, }; - function forwardedBody(rawBody: Record): { input: Array> } { - const adapter = createResponsesPassthroughAdapter(provider as never); + function forwardedBody( + rawBody: Record, + target = provider, + ): { input: Array> } { + const adapter = createResponsesPassthroughAdapter(target as never); const request = adapter.buildRequest({ modelId: "gpt-5.5", context: { messages: [] }, stream: true, options: {}, _rawBody: rawBody, }, { headers: new Headers() }); @@ -218,10 +222,22 @@ describe("forward-path ocx1 compaction scrub", () => { const body = forwardedBody({ model: "gpt-5.5", input: [{ type: "compaction", encrypted_content: "gAAAAA-real-openai-blob" }], - }); + }, { ...provider, baseUrl: CODEX_FORWARD_BASE_URL }); expect(body.input[0].type).toBe("compaction"); expect(body.input[0].encrypted_content).toBe("gAAAAA-real-openai-blob"); }); + + test("noncanonical forward providers degrade OpenAI-encrypted compaction items", () => { + const body = forwardedBody({ + model: "gpt-5.5", + input: [{ type: "compaction", encrypted_content: "gAAAAA-real-openai-blob" }], + }, provider); + expect(body.input[0]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }); + }); }); describe("remote compaction v1 helpers (260707 Design-B sweep)", () => { From 02464b30d4680dbf96baddd8b9a369d8e9fb73c2 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:50:12 -0700 Subject: [PATCH 05/17] docs(responses): stop asserting a disproven cause for the blob-preservation guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard is sound, but its comments claimed it fixed Grok's `Could not decrypt the provided encrypted_content` failure. Live bisection disproved that: Grok emits summary-channel reasoning natively, so `reasoningItemToSummaryShape` returns early and this rewrite never fires on that route. The real cause was `"content": null` on the replayed reasoning item, fixed separately. A false causal claim in a comment is worse than none — the next reader trusts it. The rule is restated on its own terms: an item carrying opaque provider state should not have its stored shape changed unless that backend has an explicit replay contract, which is why DeepSeek was safe and why the Kimi/GLM/NeuralWatt routes now on `preserveReasoningContentModels` are the ones this actually guards. Comments and prose only; no behaviour change. Co-Authored-By: Claude Fable 5 --- src/server/responses-reasoning-summary-rewrite.ts | 13 ++++++------- structure/04_transports-and-sidecars.md | 15 ++++++++------- tests/responses-reasoning-summary-rewrite.test.ts | 8 ++++---- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts index 0230879313..55c6d8ae7b 100644 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -34,13 +34,12 @@ function reasoningTextOf(item: Record): string { /** Move a reasoning item's content channel into the summary channel. */ function reasoningItemToSummaryShape(item: Record): Record { if (item.type !== "reasoning") return item; - // An item carrying `encrypted_content` is replayed verbatim by the client, and the backend that - // issued the blob verifies the item it gets back ("Could not decrypt the provided - // encrypted_content. Ensure the value is the unmodified encrypted_content from a previous - // response."). Reshaping it here is a modification the client then replays on every later turn. - // The delta rewrite still gives Codex the expandable trace for the live turn; only the stored - // shape has to stay exactly as the upstream sent it. DeepSeek — the provider this rewrite was - // verified against — is `statelessResponses` and issues no blob, so it is unaffected. + // `encrypted_content` is opaque, state-bearing provider data, so the entire item must retain its + // upstream shape unless that backend has an explicit replay contract permitting a rewrite. This + // defensively protects content-channel backends that do issue blobs when the client replays the + // stored item. The delta rewrite can still provide the expandable trace for the live turn. + // DeepSeek — the provider this rewrite was verified against — is `statelessResponses` and issues + // no blob, so it is unaffected. if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) return item; const text = reasoningTextOf(item); // Items that already use the summary channel (or carry no content text at diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 25bf0c2139..4a55a879de 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -798,13 +798,14 @@ Codex app, so tool cells group like native models — while the text still round `devlog/_fin/260709_native_response_pattern/`. The content-to-summary channel rewrite skips any reasoning item that carries a native -`encrypted_content` blob. Codex replays the reasoning item it received, and a backend that issued -that blob verifies what comes back, so reshaping the stored item makes every later turn fail with -`Could not decrypt the provided encrypted_content`. The rewrite's round trip was verified against -DeepSeek, which is `statelessResponses` and issues no blob; providers that do issue one joined the -same route later through `preserveReasoningContentModels`. Only the stored item is exempt — the -`reasoning_text` delta events carry no blob and still route to the summary channel, so the live -expandable trace is unchanged. +`encrypted_content` blob. The blob is opaque, state-bearing provider data, so the item must +round-trip unchanged unless that backend has an explicit replay contract permitting a rewrite. +This defensively protects providers that issue blobs and later join the route through +`preserveReasoningContentModels`. The rewrite's round trip was verified against DeepSeek, which is +`statelessResponses` and issues no blob. Grok is unaffected in practice because it natively emits +summary-channel reasoning and no `reasoning_text` events, so this content-to-summary item rewrite +does not engage on its route. Only the stored item is exempt — `reasoning_text` delta events carry +no blob and still route to the summary channel, so the live expandable trace is unchanged. The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential diff --git a/tests/responses-reasoning-summary-rewrite.test.ts b/tests/responses-reasoning-summary-rewrite.test.ts index 868cc77d49..4ba42acf91 100644 --- a/tests/responses-reasoning-summary-rewrite.test.ts +++ b/tests/responses-reasoning-summary-rewrite.test.ts @@ -210,10 +210,10 @@ describe("responses reasoning summary channel rewrite", () => { expect(rewrite("[1,2]")).toBe("[1,2]"); }); - // The client replays the reasoning item it received, and a backend that issued - // `encrypted_content` rejects a reshaped item on that replay. This rewrite's round-trip was - // verified against DeepSeek, which is stateless and issues no blob; providers that do issue one - // joined later through `preserveReasoningContentModels`. + // `encrypted_content` is opaque, state-bearing provider data, so preserve the complete item + // shape defensively when the client replays it. This rewrite's round-trip was verified against + // DeepSeek, which is stateless and issues no blob; providers that do issue one joined later + // through `preserveReasoningContentModels`. describe("items carrying encrypted_content", () => { const blobItem = { type: "reasoning", From 7e982836d5de2a613dab5d87ee32ba00318e0639 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:24:07 -0700 Subject: [PATCH 06/17] fix(responses): scope the null-content strip to routed destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version stripped `"content": null` from every reasoning item, which broke OpenAI. Caught in live traffic minutes after deploying it locally: 400 invalid_request_error The encrypted content k7pQ...Px7D could not be verified. Reason: Encrypted content could not be decrypted or parsed. An OpenAI-operated backend binds the blob to the item's exact shape, so removing a field invalidates it. The two requirements are exactly opposed: xAI refuses the null key, OpenAI needs it kept — so the strip has to follow the destination. The predicate is deliberately not `authMode === "forward"`. A noncanonical forward provider never receives the caller's credentials, so forward auth says nothing about which backend answers; only the canonical ChatGPT surface and the official OpenAI API are treated as OpenAI-operated, and a self-hosted relay is routed like any other gateway. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 15 +++++-- src/providers/openai-tiers.ts | 14 +++++++ tests/openai-responses-passthrough.test.ts | 46 ++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index cab27cdb12..0ade6c7630 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -6,7 +6,7 @@ import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../resp import { collectResponsesToolGroups } from "../responses/tool-groups"; import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; import { decodeServerSentEvents } from "../lib/sse-decoder"; -import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, isOpenAiOperatedResponsesDestination } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; @@ -41,7 +41,7 @@ export const FORWARD_HEADERS = [ export function sanitizeReasoningInputContent( body: unknown, - opts?: { preserveRawReasoningContent?: boolean }, + opts?: { preserveRawReasoningContent?: boolean; dropNullContentChannel?: boolean }, ): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const raw = body as Record; @@ -61,7 +61,11 @@ export function sanitizeReasoningInputContent( // — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content` // rather than the field it actually refused, which is why this reads as a blob failure. Drop the // key so the item matches the shape the upstream issued. - if ("content" in rec && !Array.isArray(rec.content)) { + // + // Gated to routed destinations. An OpenAI-operated backend binds the blob to the item's exact + // shape, so deleting a field there invalidates it (`The encrypted content ... could not be + // verified`); the two requirements are exactly opposed, and a live regression proved it. + if (opts?.dropNullContentChannel === true && "content" in rec && !Array.isArray(rec.content)) { changed = true; const next: Record = { ...rec }; delete next.content; @@ -1614,7 +1618,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripOpenAiOnlyWebSearchFields(outBody); } } - 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, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), + }))))))); const finalBody = stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), provider, diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index f1156cb447..1b4e14b89f 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -54,6 +54,20 @@ export function supportsNativeResponsesCompactEndpoint( && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; } +/** + * Whether this destination is an OpenAI-operated Responses backend — the canonical ChatGPT Codex + * surface or the official OpenAI API. + * + * Deliberately not keyed on `authMode === "forward"`: a noncanonical forward provider does not + * receive the caller's credentials (see the forward-header gate in the Responses adapter), so + * forward auth says nothing about which backend is on the other end. + */ +export function isOpenAiOperatedResponsesDestination(provider: OcxProviderConfig): boolean { + if (isCanonicalOpenAiForwardProvider(provider)) return true; + return provider.adapter === "openai-responses" + && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; +} + export interface OpenAiTierMigrationProjection { config: OcxConfig; changed: boolean; diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 987af9350b..51c17845b2 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -2181,6 +2181,52 @@ describe("reasoning input content channel", () => { expect(out.summary).toEqual([{ type: "summary_text", text: "thinking" }]); }); + // An OpenAI-operated backend binds the blob to the item's exact shape, so deleting a field there + // invalidates it: `The encrypted content ... could not be verified`. Caught in live traffic after + // an ungated first version of this fix shipped locally — the two backends want opposite things. + test("keeps a null content channel on OpenAI-operated destinations", () => { + const item = { + type: "reasoning", + content: null, + summary: [], + encrypted_content: "openai-issued-blob", + }; + for (const target of [ + { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key" as const, apiKey: "sk-t" }, + ]) { + const request = createResponsesPassthroughAdapter(target).buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "gpt-5.6-sol", store: false, input: [item] }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const out = (JSON.parse(request.body) as { input: Record[] }).input[0]; + expect(out).toHaveProperty("content"); + expect(out.content).toBeNull(); + expect(out.encrypted_content).toBe("openai-issued-blob"); + } + }); + + // A noncanonical forward gateway does not receive the caller's credentials, so forward auth says + // nothing about which backend answers; it is routed and must get the strip. + test("strips a null content channel on a noncanonical forward relay", () => { + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://relay.example/backend-api/codex", + authMode: "forward", + }).buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "grok-4.6", store: false, input: [{ type: "reasoning", content: null, encrypted_content: "b" }] }, + }, { headers: new Headers() }); + const out = (JSON.parse(request.body) as { input: Record[] }).input[0]; + expect(out).not.toHaveProperty("content"); + }); + test("leaves an array content channel to the existing sanitizer", () => { const out = forwarded({ type: "reasoning", From 8d360face1e777fe1765f3a329e24c600460577a Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 11:14:11 -0700 Subject: [PATCH 07/17] fix(xai): restore Grok Responses tool compatibility --- src/adapters/base.ts | 2 + src/adapters/openai-responses.ts | 60 ++++ src/responses/namespace-tool-compat.ts | 277 +++++++++++++++++++ src/server/responses/core.ts | 16 +- structure/04_transports-and-sidecars.md | 21 +- tests/namespace-tool-compat.test.ts | 150 ++++++++++ tests/openai-responses-passthrough.test.ts | 86 ++++-- tests/server-xai-responses-streaming.test.ts | 122 ++++++++ 8 files changed, 714 insertions(+), 20 deletions(-) create mode 100644 src/responses/namespace-tool-compat.ts create mode 100644 tests/namespace-tool-compat.test.ts diff --git a/src/adapters/base.ts b/src/adapters/base.ts index faeea0e959..45660e478c 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -72,6 +72,8 @@ export interface AdapterRequest { convertedRoutedCustomToolNames?: ReadonlySet; /** Client tool-search names actually lowered to upstream function calls for this request. */ convertedRoutedToolSearchNames?: ReadonlySet; + /** Upstream-only aliases for namespace tools flattened in this request. */ + convertedRoutedNamespaceToolAliases?: ReadonlyMap; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 8156080c7f..9c9ff740cd 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -12,6 +12,7 @@ import { modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; +import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; import { createAdapterTierMetadata, @@ -120,6 +121,52 @@ function stripInvalidItemIds(body: unknown): unknown { return changed ? { ...body, input } : body; } +/** + * Codex attaches ChatGPT's private `external_web_access` policy bit to the public + * `web_search` tool. Third-party Responses APIs enable browsing by the presence of the tool and + * commonly reject the extra argument (xAI returns `Argument not supported: + * external_web_access`). Keep the hosted tool and every public option, but remove only that + * canonical-only hint before a routed request reaches a non-OpenAI gateway. + */ +function stripCanonicalWebSearchAccessHint(body: unknown): unknown { + if (!isPlainObject(body)) return body; + + const rewriteTools = (tools: unknown[]): unknown[] => { + let changed = false; + const rewritten = tools.map(tool => { + if ( + !isPlainObject(tool) + || tool.type !== "web_search" + || !Object.hasOwn(tool, "external_web_access") + ) { + return tool; + } + changed = true; + const { external_web_access: _externalWebAccess, ...rest } = tool; + return rest; + }); + return changed ? rewritten : tools; + }; + + let rewrittenBody = body; + if (Array.isArray(body.tools)) { + const tools = rewriteTools(body.tools); + if (tools !== body.tools) rewrittenBody = { ...rewrittenBody, tools }; + } + if (!Array.isArray(body.input)) return rewrittenBody; + + let input: unknown[] | undefined; + for (let index = 0; index < body.input.length; index += 1) { + const item = body.input[index]; + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue; + const tools = rewriteTools(item.tools); + if (tools === item.tools) continue; + input ??= [...body.input]; + input[index] = { ...item, tools }; + } + return input ? { ...rewrittenBody, input } : rewrittenBody; +} + /** * When `store` is false, the upstream API does not persist response items. Any item ID * forwarded in `input` is then interpreted as a reference to a stored item that does not @@ -1530,6 +1577,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; + let convertedRoutedNamespaceToolAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -1602,6 +1650,17 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripOpenAiOnlyWebSearchFields(outBody); } } + if (!isCanonicalOpenAiForwardProvider(provider)) { + // Codex 0.147 emits private namespace tool groups, while public/third-party Responses + // gateways accept only flat tool variants. Run after custom/tool-search lowering so + // namespace children already carry their final public kind before they are promoted. + const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); + outBody = rewritten.body; + convertedRoutedNamespaceToolAliases = rewritten.aliases; + } + if (!isCanonicalOpenAiForwardProvider(provider)) { + outBody = stripCanonicalWebSearchAccessHint(outBody); + } const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); const finalBody = stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), @@ -1630,6 +1689,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): releaseBodyObservation, ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), + ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), ...(tierLog ? { tierLog } : {}), }; }, diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts new file mode 100644 index 0000000000..3ab954cbb7 --- /dev/null +++ b/src/responses/namespace-tool-compat.ts @@ -0,0 +1,277 @@ +import { namespacedToolName } from "../types"; +import { collectResponsesToolGroups } from "./tool-groups"; + +export interface RoutedNamespaceToolIdentity { + namespace: string; + name: string; +} + +export type RoutedNamespaceToolAliases = ReadonlyMap; + +const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function namespaceIdentity(namespace: string, name: string): string { + return `${namespace}\u0000${name}`; +} + +function namespaceToolIdentity(tool: unknown): RoutedNamespaceToolIdentity | undefined { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || typeof tool.name !== "string" + || tool.name.length === 0 + || !Array.isArray(tool.tools) + || tool.tools.length === 0 + ) return undefined; + return { namespace: tool.name, name: "" }; +} + +function namespaceChildren(tool: unknown): Record[] | undefined { + const identity = namespaceToolIdentity(tool); + if (!identity || !isPlainObject(tool) || !Array.isArray(tool.tools)) return undefined; + const children: Record[] = []; + for (const child of tool.tools) { + if ( + !isPlainObject(child) + || child.type === "namespace" + || typeof child.name !== "string" + || child.name.length === 0 + ) return undefined; + children.push(child); + } + return children; +} + +function addSelector( + selectors: Map, + selector: string, + wireName: string, +): void { + const current = selectors.get(selector); + if (current === undefined) selectors.set(selector, wireName); + else if (current !== wireName) selectors.set(selector, null); +} + +type NamespaceRewritePlan = { + aliases: Map; + identities: Map; + selectors: Map; +}; + +function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan { + const aliases = new Map(); + const identities = new Map(); + const selectors = new Map(); + const directNames = new Set(); + + for (const group of groups) { + for (const tool of group) { + if ( + isPlainObject(tool) + && tool.type !== "namespace" + && typeof tool.name === "string" + && tool.name.length > 0 + ) directNames.add(tool.name); + } + } + + const wireOwners = new Map(); + for (const name of directNames) wireOwners.set(name, `direct:${name}`); + + for (const group of groups) { + for (const tool of group) { + const parent = namespaceToolIdentity(tool); + const children = namespaceChildren(tool); + if (!parent || !children) continue; + for (const child of children) { + const childName = child.name as string; + const identity = namespaceIdentity(parent.namespace, childName); + const wireName = parent.namespace === BUILTIN_FUNCTIONS_NAMESPACE + ? childName + : namespacedToolName(parent.namespace, childName); + const owner = wireOwners.get(wireName); + if (owner !== undefined && owner !== identity) { + throw new Error( + `namespace tool wire-name collision for "${wireName}"; rename one of the colliding tools`, + ); + } + wireOwners.set(wireName, identity); + identities.set(identity, wireName); + addSelector(selectors, wireName, wireName); + addSelector(selectors, `${parent.namespace}.${childName}`, wireName); + addSelector(selectors, childName, wireName); + if (parent.namespace !== BUILTIN_FUNCTIONS_NAMESPACE) { + aliases.set(wireName, { namespace: parent.namespace, name: childName }); + } + } + } + } + + return { aliases, identities, selectors }; +} + +function rewriteToolList( + tools: unknown[], + plan: NamespaceRewritePlan, +): { tools: unknown[]; changed: boolean } { + let changed = false; + const rewritten: unknown[] = []; + for (const tool of tools) { + const parent = namespaceToolIdentity(tool); + const children = namespaceChildren(tool); + if (!parent || !children) { + rewritten.push(tool); + continue; + } + changed = true; + for (const child of children) { + const identity = namespaceIdentity(parent.namespace, child.name as string); + const wireName = plan.identities.get(identity); + rewritten.push(wireName && wireName !== child.name ? { ...child, name: wireName } : child); + } + } + return changed ? { tools: rewritten, changed: true } : { tools, changed: false }; +} + +function rewriteNamedSelector(value: unknown, plan: NamespaceRewritePlan): unknown { + if (!isPlainObject(value) || typeof value.name !== "string") return value; + const explicitNamespace = typeof value.namespace === "string" ? value.namespace : undefined; + const wireName = explicitNamespace + ? plan.identities.get(namespaceIdentity(explicitNamespace, value.name)) + : plan.selectors.get(value.name) ?? undefined; + if (!wireName) return value; + const { namespace: _namespace, ...rest } = value; + return wireName === value.name && explicitNamespace === undefined + ? value + : { ...rest, name: wireName }; +} + +function rewriteToolChoice(value: unknown, plan: NamespaceRewritePlan): unknown { + if (!isPlainObject(value)) return value; + if ((value.type === "function" || value.type === "custom") && typeof value.name === "string") { + return rewriteNamedSelector(value, plan); + } + if (value.type !== "allowed_tools" || !Array.isArray(value.tools)) return value; + let changed = false; + const tools = value.tools.map(tool => { + if ( + !isPlainObject(tool) + || (tool.type !== "function" && tool.type !== "custom") + || typeof tool.name !== "string" + ) return tool; + const rewritten = rewriteNamedSelector(tool, plan); + changed ||= rewritten !== tool; + return rewritten; + }); + return changed ? { ...value, tools } : value; +} + +function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan): unknown { + if (!isPlainObject(item)) return item; + if (item.type === "additional_tools" && Array.isArray(item.tools)) { + const rewritten = rewriteToolList(item.tools, plan); + return rewritten.changed ? { ...item, tools: rewritten.tools } : item; + } + if ( + (item.type === "function_call" || item.type === "custom_tool_call") + && typeof item.name === "string" + ) return rewriteNamedSelector(item, plan); + return item; +} + +/** + * Lower Codex's private Responses namespace declarations for public/third-party gateways. + * + * Codex 0.147 groups ordinary tools under the reserved `functions` namespace; those children + * become bare top-level declarations. Other namespaces use the same collision-checked + * `__` wire identity as the chat adapters. The returned request-local aliases + * are the only names response restoration is allowed to expand. + */ +export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { + body: unknown; + aliases: Map; +} { + if (!isPlainObject(body)) return { body, aliases: new Map() }; + const groups = collectResponsesToolGroups(body); + const plan = buildRewritePlan(groups); + if (plan.identities.size === 0) return { body, aliases: plan.aliases }; + + let tools = body.tools; + if (Array.isArray(body.tools)) tools = rewriteToolList(body.tools, plan).tools; + + let input = body.input; + if (Array.isArray(body.input)) input = body.input.map(item => rewriteInputItem(item, plan)); + + const toolChoice = rewriteToolChoice(body.tool_choice, plan); + return { + body: { + ...body, + ...(tools !== body.tools ? { tools } : {}), + ...(input !== body.input ? { input } : {}), + ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), + }, + aliases: plan.aliases, + }; +} + +export function restoreRoutedNamespaceCalls( + value: unknown, + aliases: RoutedNamespaceToolAliases, +): { value: unknown; changed: boolean } { + if (Array.isArray(value)) { + let changed = false; + const restored = value.map(entry => { + const result = restoreRoutedNamespaceCalls(entry, aliases); + changed ||= result.changed; + return result.value; + }); + return changed ? { value: restored, changed: true } : { value, changed: false }; + } + if (!isPlainObject(value)) return { value, changed: false }; + + let changed = false; + const restored: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const result = restoreRoutedNamespaceCalls(entry, aliases); + restored[key] = result.value; + changed ||= result.changed; + } + + if ( + (value.type === "function_call" || value.type === "custom_tool_call") + && typeof value.name === "string" + ) { + const identity = aliases.get(value.name); + if (identity) { + restored.name = identity.name; + restored.namespace = identity.namespace; + changed = true; + } + } + return changed ? { value: restored, changed: true } : { value, changed: false }; +} + +export function restoreRoutedNamespaceCallsInJson( + text: string, + aliases: RoutedNamespaceToolAliases, +): string { + if (aliases.size === 0) return text; + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return text; + } + const restored = restoreRoutedNamespaceCalls(payload, aliases); + return restored.changed ? JSON.stringify(restored.value) : text; +} + +export function createRoutedNamespaceCallRestoreRewrite( + aliases: RoutedNamespaceToolAliases, +): (payload: string) => string { + return payload => restoreRoutedNamespaceCallsInJson(payload, aliases); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 939da2226a..fb124d52d4 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -295,6 +295,11 @@ import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-comp import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; +import { + createRoutedNamespaceCallRestoreRewrite, + restoreRoutedNamespaceCallsInJson, + type RoutedNamespaceToolAliases, +} from "../../responses/namespace-tool-compat"; import { collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, @@ -2628,6 +2633,7 @@ async function handleResponsesInner( : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); const routedCustomToolNames = new Set(); const routedToolSearchNames = new Set(); + let routedNamespaceToolAliases: RoutedNamespaceToolAliases = new Map(); // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY @@ -2667,6 +2673,7 @@ async function handleResponsesInner( // would incorrectly disable restoration for the exact ambiguous-name case the alias fixes. routedToolSearchNames.add(name); } + routedNamespaceToolAliases = request.convertedRoutedNamespaceToolAliases ?? routedNamespaceToolAliases; // #1700: the bridged paths refuse a call to a tool the request never declared // (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed // provider's top-level `apply_patch` — which under Codex code mode exists only as a nested @@ -3227,6 +3234,9 @@ async function handleResponsesInner( // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). const payloadRewrites = [ createImageGenCallRestoreRewrite(imageGenCallAliases), + routedNamespaceToolAliases.size > 0 + ? createRoutedNamespaceCallRestoreRewrite(routedNamespaceToolAliases) + : undefined, hasResponsesItemIdRepair(repairConfig) ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, @@ -3443,8 +3453,12 @@ async function handleResponsesInner( const text = bounded.text; inspectResponseLogJson(logCtx, text); const clientJson = (() => { - const restored = restoreRoutedCustomCallsInJson( + const restoredNamespace = restoreRoutedNamespaceCallsInJson( restoreImageGenCallsInJson(text, imageGenCallAliases), + routedNamespaceToolAliases, + ); + const restored = restoreRoutedCustomCallsInJson( + restoredNamespace, routedCustomToolNames, ); const restoredToolSearch = restoreRoutedToolSearchCallsInJson( diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index d63e594b05..617749e62b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -39,6 +39,20 @@ Responses-compatible streaming output. - 다른 대안 대신 이 방식을 선택한 이유: Provider-specific workarounds fragment the contract, while unconditional restoration could turn an untrusted ordinary function call into a privileged client discovery action. - 장점, 단점 및 영향: Strict third-party Responses gateways can start and continue deferred discovery without changing native ChatGPT behavior; ordinary same-named functions remain distinct, and the proxy performs a capped SSE lifecycle rewrite only when the request actually required compatibility translation. +[Decision Log] +- 목적과 의도: Keep Codex 0.147 namespace tool catalogs usable after a routed provider adopts native Responses but implements only the public flat tool variants. +- 기존 구현 및 제약 조건: Chat translation already flattened namespace children, while native Responses passthrough forwarded the private `namespace` variant unchanged. xAI therefore rejected Grok requests before inference after its OAuth Grok 4.5/4.6 route moved to Responses. +- 검토한 주요 대안: Move Grok back to Chat; special-case only xAI or the reserved `functions` group; flatten every complete namespace on noncanonical Responses and restore request-authorized aliases on return. +- 선택한 방식: Noncanonical Responses lowers `functions` children to their bare top-level names and every other complete namespace to collision-checked `__` aliases after custom/tool-search conversion. It rewrites matching replay calls and tool selectors, records the aliases on the built request, and restores only those aliases in JSON/SSE call items before custom/tool-search lifecycle repair. Canonical OpenAI forward preserves native namespace shapes. +- 다른 대안 대신 이 방식을 선택한 이유: A transport regression should not discard Responses streaming or create a provider-specific fork, and restoration without request-local authorization could reinterpret an unrelated upstream function as a client namespace call. +- 장점, 단점 및 영향: Grok and other public-schema Responses gateways accept current Codex catalogs while Codex still receives explicit namespace routing. Ambiguous wire names fail closed; empty, malformed, and future nested namespace shapes remain untouched rather than losing tools silently. + +The same noncanonical boundary strips ChatGPT's private `external_web_access` bit from routed +`web_search` declarations. The public tool remains enabled and all other options remain intact; +canonical OpenAI forwarding preserves the bit. xAI's public Responses schema enables browsing by +the presence of `web_search` and rejects the private argument, so forwarding it made the first +post-namespace request fail with HTTP 400. + The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only the allowed Codex/OpenAI auth/session headers from the current request and short-circuits pool @@ -222,9 +236,10 @@ items restore `{ namespace: "image_gen", name: "" }` so Codex can di extension. When item-id repair is also enabled, both transforms compose in one SSE parse/stringify pass (`src/server/sse-payload-rewrite.ts`) rather than chaining separate JS pull wrappers. Inspection and continuation-cache branches keep the raw upstream alias, allowing stored -replays to return upstream without leaking a client-only namespace shape. Malformed, empty, and -unrelated namespaces remain untouched. ChatGPT forward mode preserves the private namespace and -hosted tool because that backend understands their native semantics. +replays to return upstream without leaking a client-only namespace shape. Malformed and empty +image-gen namespaces remain untouched; complete unrelated namespaces follow the general +noncanonical namespace compatibility rule above. ChatGPT forward mode preserves the private +namespace and hosted tool because that backend understands their native semantics. Per-model `modelReasoningSummaryDelivery` is a narrow compatibility layer for `openai-responses` gateways whose summary capability is real but whose accepted delivery enum diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts new file mode 100644 index 0000000000..59e535f1c7 --- /dev/null +++ b/tests/namespace-tool-compat.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { + createRoutedNamespaceCallRestoreRewrite, + restoreRoutedNamespaceCalls, + restoreRoutedNamespaceCallsInJson, + rewriteRoutedNamespaceToolsForUpstream, +} from "../src/responses/namespace-tool-compat"; + +describe("Responses namespace tool compatibility", () => { + test("flattens builtin and routed namespaces across declarations, selectors, and replay", () => { + const rewritten = rewriteRoutedNamespaceToolsForUpstream({ + model: "routed-model", + tools: [ + { + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "exec", description: "run" }], + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", parameters: {} }], + }, + ], + input: [ + { + type: "function_call", + namespace: "collaboration", + name: "spawn_agent", + call_id: "call_spawn", + arguments: "{}", + }, + { + type: "custom_tool_call", + namespace: "functions", + name: "exec", + call_id: "call_exec", + input: "text(true)", + }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", namespace: "collaboration", name: "spawn_agent" }, + { type: "custom", namespace: "functions", name: "exec" }, + ], + }, + }); + const body = rewritten.body as { + tools: Array<{ type: string; name: string }>; + input: Array<{ namespace?: string; name: string }>; + tool_choice: { tools: Array<{ namespace?: string; name: string }> }; + }; + + expect(body.tools).toEqual([ + { type: "custom", name: "exec", description: "run" }, + { type: "function", name: "collaboration__spawn_agent", parameters: {} }, + ]); + expect(body.input[0]).toMatchObject({ name: "collaboration__spawn_agent", call_id: "call_spawn" }); + expect(body.input[0]).not.toHaveProperty("namespace"); + expect(body.input[1]).toMatchObject({ name: "exec", call_id: "call_exec" }); + expect(body.input[1]).not.toHaveProperty("namespace"); + expect(body.tool_choice.tools).toEqual([ + { type: "function", name: "collaboration__spawn_agent" }, + { type: "custom", name: "exec" }, + ]); + expect([...rewritten.aliases]).toEqual([ + ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }], + ]); + }); + + test("rewrites a unique bare selector but leaves an ambiguous one unchanged", () => { + const unique = rewriteRoutedNamespaceToolsForUpstream({ + tools: [{ + type: "namespace", + name: "one", + tools: [{ type: "function", name: "read" }], + }], + tool_choice: { type: "function", name: "read" }, + }).body as { tool_choice: { name: string } }; + expect(unique.tool_choice.name).toBe("one__read"); + + const ambiguous = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "namespace", name: "one", tools: [{ type: "function", name: "read" }] }, + { type: "namespace", name: "two", tools: [{ type: "function", name: "read" }] }, + ], + tool_choice: { type: "function", name: "read" }, + }).body as { tool_choice: { name: string } }; + expect(ambiguous.tool_choice.name).toBe("read"); + }); + + test("fails closed when flattening would collide with a declared wire name", () => { + expect(() => rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "function", name: "workspace__read" }, + { type: "namespace", name: "workspace", tools: [{ type: "function", name: "read" }] }, + ], + })).toThrow('namespace tool wire-name collision for "workspace__read"'); + }); + + test("preserves empty and malformed namespaces instead of dropping capabilities", () => { + const body = { + tools: [ + { type: "namespace", name: "empty", tools: [] }, + { type: "namespace", name: "nested", tools: [{ type: "namespace", name: "child", tools: [] }] }, + ], + }; + expect(rewriteRoutedNamespaceToolsForUpstream(body).body).toEqual(body); + }); + + test("restores only aliases authorized by this request in JSON and SSE payloads", () => { + const aliases = new Map([ + ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }], + ]); + const payload = { + type: "response.completed", + response: { + output: [ + { type: "function_call", name: "collaboration__spawn_agent", call_id: "call_1" }, + { type: "function_call", name: "untrusted__tool", call_id: "call_2" }, + ], + }, + }; + + expect(restoreRoutedNamespaceCalls(payload, aliases).value).toMatchObject({ + response: { + output: [ + { type: "function_call", namespace: "collaboration", name: "spawn_agent" }, + { type: "function_call", name: "untrusted__tool" }, + ], + }, + }); + const text = JSON.stringify(payload); + expect(JSON.parse(restoreRoutedNamespaceCallsInJson(text, aliases))).toMatchObject({ + response: { output: [ + { namespace: "collaboration", name: "spawn_agent" }, + { name: "untrusted__tool" }, + ] }, + }); + expect(JSON.parse(createRoutedNamespaceCallRestoreRewrite(aliases)(text))).toMatchObject({ + response: { output: [ + { namespace: "collaboration", name: "spawn_agent" }, + { name: "untrusted__tool" }, + ] }, + }); + expect(restoreRoutedNamespaceCallsInJson("not-json", aliases)).toBe("not-json"); + }); +}); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 52ed4cf3d1..39ba0faec5 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -336,15 +336,15 @@ describe("OpenAI Responses passthrough sanitization", () => { }>; }; - const namespace = body.tools.find(tool => tool.type === "namespace" && tool.name === "workspace"); - expect(namespace?.tools?.map(tool => tool.name)).toEqual([ - "upfront_read", - "declared_deferred_read", - "deferred_read", + expect(body.tools.some(tool => tool.type === "namespace")).toBe(false); + expect(body.tools.filter(tool => tool.name?.startsWith("workspace__")).map(tool => tool.name)).toEqual([ + "workspace__upfront_read", + "workspace__declared_deferred_read", + "workspace__deferred_read", ]); - expect(namespace?.tools?.find(tool => tool.name === "declared_deferred_read")) + expect(body.tools.find(tool => tool.name === "workspace__declared_deferred_read")) .not.toHaveProperty("defer_loading"); - expect(namespace?.tools?.find(tool => tool.name === "deferred_read")) + expect(body.tools.find(tool => tool.name === "workspace__deferred_read")) .not.toHaveProperty("defer_loading"); expect(body.tools.find(tool => tool.name === "tool_search")).toMatchObject({ type: "function", @@ -419,11 +419,11 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools).toBeUndefined(); const additionalTools = body.input.find(item => item.type === "additional_tools")?.tools; - const namespace = additionalTools?.find(tool => tool.type === "namespace" && tool.name === "workspace"); - expect(namespace?.tools?.map(tool => tool.name)).toEqual([ - "upfront_read", - "declared_deferred_read", - "deferred_read", + expect(additionalTools?.some(tool => tool.type === "namespace")).toBe(false); + expect(additionalTools?.filter(tool => tool.name?.startsWith("workspace__")).map(tool => tool.name)).toEqual([ + "workspace__upfront_read", + "workspace__declared_deferred_read", + "workspace__deferred_read", ]); expect(additionalTools?.find(tool => tool.name === "tool_search")) .toMatchObject({ type: "function", name: "tool_search" }); @@ -816,6 +816,60 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toMatchObject({ type: "image_generation" }); }); + test("drops ChatGPT's external_web_access hint but keeps routed web search", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }); + const request = adapter.buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "grok-4.6", + input: [{ + type: "additional_tools", + tools: [{ type: "web_search", external_web_access: true, search_context_size: "medium" }], + }], + tools: [{ type: "web_search", external_web_access: false, filters: { allowed_domains: ["example.com"] } }], + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Record[]; + input: Array<{ type: string; tools: Record[] }>; + }; + + expect(body.tools).toEqual([{ + type: "web_search", + filters: { allowed_domains: ["example.com"] }, + }]); + expect(body.input[0]?.tools).toEqual([{ + type: "web_search", + search_context_size: "medium", + }]); + }); + + test("preserves external_web_access on the canonical OpenAI forward route", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.5", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.5", + input: [], + tools: [{ type: "web_search", external_web_access: true }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { tools: Record[] }; + + expect(body.tools).toEqual([{ type: "web_search", external_web_access: true }]); + }); + test("preserves prompt_cache_key in the raw Responses passthrough body", () => { const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ @@ -1488,7 +1542,7 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { expect(JSON.parse(secondRequest.body)).toEqual(firstBody); }); - test("keyed platform preserves unrelated and malformed namespaces", () => { + test("keyed platform flattens complete namespaces and preserves malformed ones", () => { const adapter = createResponsesPassthroughAdapter(keyedProvider); const request = adapter.buildRequest({ modelId: "gpt-5.6-sol", @@ -1518,9 +1572,9 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { expect(body.tools).toEqual([ { type: "namespace", name: "image_gen", tools: [] }, { - type: "namespace", - name: "web", - tools: [{ type: "function", name: "run", parameters: {} }], + type: "function", + name: "web__run", + parameters: { type: "object" }, }, { type: "image_generation" }, ]); diff --git a/tests/server-xai-responses-streaming.test.ts b/tests/server-xai-responses-streaming.test.ts index 5051814c90..d753f6cadc 100644 --- a/tests/server-xai-responses-streaming.test.ts +++ b/tests/server-xai-responses-streaming.test.ts @@ -222,4 +222,126 @@ describe("xAI OAuth Responses streaming opt-in", () => { await server.stop(true); } }, 10_000); + + test("lowers Codex namespaces for xAI and restores routed calls on the client stream", async () => { + let outboundBody: Record | undefined; + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== RESPONSES_ENDPOINT) return originalFetch(input, init); + outboundBody = JSON.parse(String(init?.body)) as Record; + const call = { + id: "fc_spawn", + type: "function_call", + status: "completed", + name: "collaboration__spawn_agent", + call_id: "call_spawn", + arguments: "{}", + }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(sse({ + type: "response.created", + sequence_number: 0, + response: { id: "resp_namespace", object: "response", status: "in_progress", model: "grok-4.6", output: [] }, + })); + controller.enqueue(sse({ + type: "response.output_item.added", + sequence_number: 1, + output_index: 0, + item: call, + })); + controller.enqueue(sse({ + type: "response.output_item.done", + sequence_number: 2, + output_index: 0, + item: call, + })); + controller.enqueue(sse({ + type: "response.completed", + sequence_number: 3, + response: { + id: "resp_namespace", + object: "response", + status: "completed", + model: "grok-4.6", + output: [call], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + })); + controller.close(); + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + saveConfig(config()); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + stream: true, + store: false, + tools: [{ type: "web_search", external_web_access: true }], + input: [ + { + type: "additional_tools", + role: "developer", + tools: [ + { + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "exec", description: "run code", format: { type: "text" } }], + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", description: "spawn", parameters: {} }], + }, + ], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "delegate" }] }, + ], + }), + }); + expect(response.status).toBe(200); + const clientText = await response.text(); + + const outboundInput = outboundBody?.input as Array<{ + type: string; + tools?: Array<{ type: string; name?: string }>; + }> | undefined; + const outboundTools = outboundInput?.find(item => item.type === "additional_tools")?.tools; + expect(outboundTools?.some(tool => tool.type === "namespace")).toBe(false); + expect(outboundTools?.find(tool => tool.name === "exec")?.type).toBe("function"); + expect(outboundTools?.find(tool => tool.name === "collaboration__spawn_agent")?.type).toBe("function"); + expect(outboundBody?.tools).toEqual([{ type: "web_search" }]); + + const payloads = clientText + .split(/\r?\n/) + .filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6)) as Record); + const added = payloads.find(payload => payload.type === "response.output_item.added") as { + item?: Record; + } | undefined; + expect(added?.item).toMatchObject({ + type: "function_call", + namespace: "collaboration", + name: "spawn_agent", + call_id: "call_spawn", + }); + const completed = payloads.find(payload => payload.type === "response.completed") as { + response?: { output?: Array> }; + } | undefined; + expect(completed?.response?.output?.[0]).toMatchObject({ + namespace: "collaboration", + name: "spawn_agent", + }); + } finally { + await server.stop(true); + } + }, 10_000); }); From b7e8546bb67789a68605c26e05e49ac6ba482ac6 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 13:04:45 -0700 Subject: [PATCH 08/17] fix(responses): address namespace review findings --- src/responses/namespace-tool-compat.ts | 5 +- tests/namespace-tool-compat.test.ts | 13 ++++ tests/server-xai-responses-streaming.test.ts | 62 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 3ab954cbb7..98d9f6c54e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -75,7 +75,10 @@ function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan { && tool.type !== "namespace" && typeof tool.name === "string" && tool.name.length > 0 - ) directNames.add(tool.name); + ) { + directNames.add(tool.name); + addSelector(selectors, tool.name, tool.name); + } } } diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts index 59e535f1c7..1801fb3071 100644 --- a/tests/namespace-tool-compat.test.ts +++ b/tests/namespace-tool-compat.test.ts @@ -89,6 +89,19 @@ describe("Responses namespace tool compatibility", () => { tool_choice: { type: "function", name: "read" }, }).body as { tool_choice: { name: string } }; expect(ambiguous.tool_choice.name).toBe("read"); + + const directCollision = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "function", name: "read" }, + { type: "namespace", name: "workspace", tools: [{ type: "function", name: "read" }] }, + ], + tool_choice: { type: "function", name: "read" }, + }).body as { + tools: Array<{ name: string }>; + tool_choice: { name: string }; + }; + expect(directCollision.tools.map(tool => tool.name)).toEqual(["read", "workspace__read"]); + expect(directCollision.tool_choice.name).toBe("read"); }); test("fails closed when flattening would collide with a declared wire name", () => { diff --git a/tests/server-xai-responses-streaming.test.ts b/tests/server-xai-responses-streaming.test.ts index d753f6cadc..4f6bd36cb4 100644 --- a/tests/server-xai-responses-streaming.test.ts +++ b/tests/server-xai-responses-streaming.test.ts @@ -344,4 +344,66 @@ describe("xAI OAuth Responses streaming opt-in", () => { await server.stop(true); } }, 10_000); + + test("restores routed namespace calls in a non-streaming xAI JSON response", async () => { + let outboundBody: Record | undefined; + const call = { + id: "fc_spawn_json", + type: "function_call", + status: "completed", + name: "collaboration__spawn_agent", + call_id: "call_spawn_json", + arguments: "{}", + }; + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== RESPONSES_ENDPOINT) return originalFetch(input, init); + outboundBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ + id: "resp_namespace_json", + object: "response", + status: "completed", + model: "grok-4.6", + output: [call], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + saveConfig(config()); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + stream: false, + store: false, + tools: [{ + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", description: "spawn", parameters: {} }], + }], + input: "delegate", + }), + }); + expect(response.status).toBe(200); + + const outboundTools = outboundBody?.tools as Array<{ type: string; name?: string }> | undefined; + expect(outboundTools).toEqual([expect.objectContaining({ + type: "function", + name: "collaboration__spawn_agent", + })]); + const clientBody = await response.json() as { output?: Array> }; + expect(clientBody.output?.[0]).toMatchObject({ + type: "function_call", + namespace: "collaboration", + name: "spawn_agent", + call_id: "call_spawn_json", + }); + } finally { + await server.stop(true); + } + }, 10_000); }); From d3a13f67312e505bb939bc014f91233589797d66 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 15:46:50 -0700 Subject: [PATCH 09/17] fix(responses): close the remaining private-shape leaks on the routed boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The namespace boundary lowered complete groups but still let several Codex-private shapes reach a strict gateway, each reproducing the pre-inference rejection the boundary exists to prevent. No `type: "namespace"` value survives now. A group the layer cannot express — empty, nested, or with an unusable child name — is dropped along with the children it cannot represent. Relaying the private shape costs the whole request rather than one tool, so "preserve rather than lose a tool" was losing strictly more. Replayed call items are lowered whether or not this turn declares the group they name. The routed compaction turn strips the entire tool surface before the boundary runs, so every compaction after a namespaced tool call shipped the private `namespace` key this layer's own restoration had stamped on the item. Only tool_choice resolves a bare name through the catalog: a history item records which tool actually ran, so re-pointing it at a same-named namespace child would rewrite that record on a coincidence rather than translate it. Codex-private tool fields now come from one table instead of one bespoke pass each, and it gains `defer_loading` — `activateDeferredTool` clears that only for tools a `tool_search_output` already loaded, so the first turn of a deferred catalog carried it to the wire — and the `web_search_preview` variant. A bare declaration and a `functions` child of the same name are one logical tool: `buildTools` flattens the reserved group without a namespace, the parser tolerates the duplicate, and `promoteClientLoadedTools` produces it. That shape raised a wire-name collision that escaped every catch up to the Bun handler, so an ordinary catalog became an unstructured 500 with no request log — while the rotation-rebuild path answered 400 for the identical throw. It is now deduped, and a genuine collision is a typed error the passthrough maps to 400. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 47 +++-- src/responses/namespace-tool-compat.ts | 215 +++++++++++++-------- src/server/responses/core.ts | 8 + structure/04_transports-and-sidecars.md | 28 ++- tests/namespace-tool-compat.test.ts | 92 ++++++++- tests/openai-responses-passthrough.test.ts | 59 +++++- 6 files changed, 335 insertions(+), 114 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 9c9ff740cd..6a3419213f 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -122,28 +122,42 @@ function stripInvalidItemIds(body: unknown): unknown { } /** - * Codex attaches ChatGPT's private `external_web_access` policy bit to the public - * `web_search` tool. Third-party Responses APIs enable browsing by the presence of the tool and - * commonly reject the extra argument (xAI returns `Argument not supported: - * external_web_access`). Keep the hosted tool and every public option, but remove only that - * canonical-only hint before a routed request reaches a non-OpenAI gateway. + * Codex-private tool fields that only the ChatGPT backend understands. + * + * A third-party Responses gateway validates its schema and rejects the whole request before + * inference — xAI answers `Argument not supported: external_web_access` — so these are removed at + * the noncanonical boundary while the tool and every public option stay. + * + * Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip + * with its own traversal, and the traversals disagreed about which containers they covered; a new + * one should be a row here instead. `toolTypes` omitted means the field is private on any tool. */ -function stripCanonicalWebSearchAccessHint(body: unknown): unknown { +const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet }[] = [ + // ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone. + { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]) }, + // Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output` + // already loaded, so a still-deferred declaration — including one promoted out of a namespace + // group — otherwise reaches the wire carrying it. + { field: "defer_loading" }, +]; + +function stripCanonicalOnlyToolFields(body: unknown): unknown { if (!isPlainObject(body)) return body; const rewriteTools = (tools: unknown[]): unknown[] => { let changed = false; const rewritten = tools.map(tool => { - if ( - !isPlainObject(tool) - || tool.type !== "web_search" - || !Object.hasOwn(tool, "external_web_access") - ) { - return tool; + if (!isPlainObject(tool)) return tool; + let next = tool; + for (const { field, toolTypes } of CANONICAL_ONLY_TOOL_FIELDS) { + if (!Object.hasOwn(next, field)) continue; + if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue; + const { [field]: _private, ...rest } = next; + next = rest; } + if (next === tool) return tool; changed = true; - const { external_web_access: _externalWebAccess, ...rest } = tool; - return rest; + return next; }); return changed ? rewritten : tools; }; @@ -1657,9 +1671,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; - } - if (!isCanonicalOpenAiForwardProvider(provider)) { - outBody = stripCanonicalWebSearchAccessHint(outBody); + // Last, so promoted namespace children are also cleared of Codex-private fields. + outBody = stripCanonicalOnlyToolFields(outBody); } const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); const finalBody = stripDisabledReasoningSummaries( diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 98d9f6c54e..8a52251da5 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -18,32 +18,64 @@ function namespaceIdentity(namespace: string, name: string): string { return `${namespace}\u0000${name}`; } -function namespaceToolIdentity(tool: unknown): RoutedNamespaceToolIdentity | undefined { +/** + * A name that can become a wire tool name. Control characters are rejected because the identity + * key below joins namespace and name with NUL: a name carrying one could otherwise forge another + * tool's identity and silently take over its wire name. + */ +function isRepresentableName(name: unknown): name is string { + if (typeof name !== "string" || name.length === 0) return false; + for (let index = 0; index < name.length; index += 1) { + const code = name.charCodeAt(index); + // C0 controls and DEL, written as code points so this source never carries one itself. + if (code <= 0x1f || code === 0x7f) return false; + } + return true; +} + +type NamespaceGroup = { + namespace: string; + /** Children that can be lowered to a flat declaration; unrepresentable ones are omitted. */ + children: Record[]; +}; + +/** + * Read a private namespace group, or return undefined when the value is not one. + * + * Children that cannot be expressed as a flat declaration — a nested group, a missing name, a + * control character in the name — are dropped, and a group left with no children is dropped whole + * by the rewrite. Preserving the private `namespace` shape instead would lose every tool in the + * request rather than one: the strict gateways this layer exists for reject that tool type before + * inference, which is the failure the layer was written to prevent. + */ +function parseNamespaceGroup(tool: unknown): NamespaceGroup | undefined { if ( !isPlainObject(tool) || tool.type !== "namespace" - || typeof tool.name !== "string" - || tool.name.length === 0 + || !isRepresentableName(tool.name) || !Array.isArray(tool.tools) - || tool.tools.length === 0 ) return undefined; - return { namespace: tool.name, name: "" }; -} - -function namespaceChildren(tool: unknown): Record[] | undefined { - const identity = namespaceToolIdentity(tool); - if (!identity || !isPlainObject(tool) || !Array.isArray(tool.tools)) return undefined; const children: Record[] = []; for (const child of tool.tools) { - if ( - !isPlainObject(child) - || child.type === "namespace" - || typeof child.name !== "string" - || child.name.length === 0 - ) return undefined; + if (!isPlainObject(child) || child.type === "namespace" || !isRepresentableName(child.name)) continue; children.push(child); } - return children; + return { namespace: tool.name, children }; +} + +/** + * Wire identity of a lowered tool. A `functions` child and an identical top-level declaration share + * one identity because they denote the same logical tool: `buildTools` flattens the reserved group + * without a namespace, so the parser already treats them as one and tolerates the duplicate. + */ +function loweredIdentity(namespace: string, name: string): string { + return namespace === BUILTIN_FUNCTIONS_NAMESPACE + ? namespaceIdentity(BUILTIN_FUNCTIONS_NAMESPACE, name) + : namespaceIdentity(namespace, name); +} + +function loweredWireName(namespace: string, name: string): string { + return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name); } function addSelector( @@ -62,53 +94,48 @@ type NamespaceRewritePlan = { selectors: Map; }; +/** Two distinct logical tools would occupy one wire name; the caller maps this to a 400. */ +export class NamespaceToolCollisionError extends Error {} + function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan { const aliases = new Map(); const identities = new Map(); const selectors = new Map(); - const directNames = new Set(); + const wireOwners = new Map(); for (const group of groups) { for (const tool of group) { - if ( - isPlainObject(tool) - && tool.type !== "namespace" - && typeof tool.name === "string" - && tool.name.length > 0 - ) { - directNames.add(tool.name); + if (isPlainObject(tool) && tool.type !== "namespace" && isRepresentableName(tool.name)) { + // A bare declaration is the reserved group's flattened form, so it claims that identity: + // declaring the same tool both ways is the duplicate the parser already tolerates, not a + // collision, and `promoteClientLoadedTools` produces exactly that shape. + wireOwners.set(tool.name, loweredIdentity(BUILTIN_FUNCTIONS_NAMESPACE, tool.name)); addSelector(selectors, tool.name, tool.name); } } } - const wireOwners = new Map(); - for (const name of directNames) wireOwners.set(name, `direct:${name}`); - for (const group of groups) { for (const tool of group) { - const parent = namespaceToolIdentity(tool); - const children = namespaceChildren(tool); - if (!parent || !children) continue; - for (const child of children) { + const parsed = parseNamespaceGroup(tool); + if (!parsed) continue; + for (const child of parsed.children) { const childName = child.name as string; - const identity = namespaceIdentity(parent.namespace, childName); - const wireName = parent.namespace === BUILTIN_FUNCTIONS_NAMESPACE - ? childName - : namespacedToolName(parent.namespace, childName); + const identity = loweredIdentity(parsed.namespace, childName); + const wireName = loweredWireName(parsed.namespace, childName); const owner = wireOwners.get(wireName); if (owner !== undefined && owner !== identity) { - throw new Error( + throw new NamespaceToolCollisionError( `namespace tool wire-name collision for "${wireName}"; rename one of the colliding tools`, ); } wireOwners.set(wireName, identity); identities.set(identity, wireName); addSelector(selectors, wireName, wireName); - addSelector(selectors, `${parent.namespace}.${childName}`, wireName); + addSelector(selectors, `${parsed.namespace}.${childName}`, wireName); addSelector(selectors, childName, wireName); - if (parent.namespace !== BUILTIN_FUNCTIONS_NAMESPACE) { - aliases.set(wireName, { namespace: parent.namespace, name: childName }); + if (parsed.namespace !== BUILTIN_FUNCTIONS_NAMESPACE) { + aliases.set(wireName, { namespace: parsed.namespace, name: childName }); } } } @@ -117,72 +144,96 @@ function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan { return { aliases, identities, selectors }; } +/** + * Lower every namespace group in one tool container. `emitted` is shared across the whole body so + * a tool declared both bare and under `functions` is written once rather than twice. + * + * No `type: "namespace"` value survives this pass, including a group this layer cannot read: + * relaying the private shape is what the strict gateway rejects. + */ function rewriteToolList( tools: unknown[], plan: NamespaceRewritePlan, -): { tools: unknown[]; changed: boolean } { + emitted: Set, +): unknown[] { let changed = false; const rewritten: unknown[] = []; for (const tool of tools) { - const parent = namespaceToolIdentity(tool); - const children = namespaceChildren(tool); - if (!parent || !children) { - rewritten.push(tool); + if (isPlainObject(tool) && tool.type === "namespace") { + changed = true; + const parsed = parseNamespaceGroup(tool); + if (!parsed) continue; + for (const child of parsed.children) { + const wireName = plan.identities.get(loweredIdentity(parsed.namespace, child.name as string)); + if (wireName === undefined || emitted.has(wireName)) continue; + emitted.add(wireName); + rewritten.push(wireName === child.name ? child : { ...child, name: wireName }); + } continue; } - changed = true; - for (const child of children) { - const identity = namespaceIdentity(parent.namespace, child.name as string); - const wireName = plan.identities.get(identity); - rewritten.push(wireName && wireName !== child.name ? { ...child, name: wireName } : child); - } + if (isPlainObject(tool) && isRepresentableName(tool.name)) emitted.add(tool.name); + rewritten.push(tool); } - return changed ? { tools: rewritten, changed: true } : { tools, changed: false }; + return changed ? rewritten : tools; } -function rewriteNamedSelector(value: unknown, plan: NamespaceRewritePlan): unknown { +/** + * Resolve one `{namespace?, name}` reference to its wire name and drop the private `namespace` key. + * + * `bareFallback` is for tool_choice, where a bare name is a selector the caller expects resolved + * against the catalog. Replayed call items pass `false`: a history item records which tool actually + * ran, so resolving a bare name through a same-named namespace child would rewrite history on a + * coincidence rather than translate it. + * + * An explicit namespace is always lowered, even when this turn's catalog no longer declares that + * group — a compaction turn drops the whole catalog, and a catalog can change mid-session. Leaving + * the key in place ships a Codex-private field to a gateway that rejects unknown fields, which is + * the failure this layer exists to prevent, and this layer's own response restoration is what put + * the key on the item. + */ +function rewriteNamedSelector( + value: unknown, + plan: NamespaceRewritePlan, + bareFallback: boolean, +): unknown { if (!isPlainObject(value) || typeof value.name !== "string") return value; - const explicitNamespace = typeof value.namespace === "string" ? value.namespace : undefined; - const wireName = explicitNamespace - ? plan.identities.get(namespaceIdentity(explicitNamespace, value.name)) - : plan.selectors.get(value.name) ?? undefined; - if (!wireName) return value; - const { namespace: _namespace, ...rest } = value; - return wireName === value.name && explicitNamespace === undefined - ? value - : { ...rest, name: wireName }; + if (typeof value.namespace !== "string") { + if (!bareFallback) return value; + const wireName = plan.selectors.get(value.name) ?? undefined; + return wireName === undefined || wireName === value.name ? value : { ...value, name: wireName }; + } + const { namespace, ...rest } = value; + const wireName = plan.identities.get(loweredIdentity(namespace, value.name)) + ?? loweredWireName(namespace, value.name); + return { ...rest, name: wireName }; } function rewriteToolChoice(value: unknown, plan: NamespaceRewritePlan): unknown { if (!isPlainObject(value)) return value; if ((value.type === "function" || value.type === "custom") && typeof value.name === "string") { - return rewriteNamedSelector(value, plan); + return rewriteNamedSelector(value, plan, true); } if (value.type !== "allowed_tools" || !Array.isArray(value.tools)) return value; let changed = false; const tools = value.tools.map(tool => { - if ( - !isPlainObject(tool) - || (tool.type !== "function" && tool.type !== "custom") - || typeof tool.name !== "string" - ) return tool; - const rewritten = rewriteNamedSelector(tool, plan); + if (!isPlainObject(tool) || typeof tool.name !== "string") return tool; + const rewritten = rewriteNamedSelector(tool, plan, true); changed ||= rewritten !== tool; return rewritten; }); return changed ? { ...value, tools } : value; } -function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan): unknown { +function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Set): unknown { if (!isPlainObject(item)) return item; if (item.type === "additional_tools" && Array.isArray(item.tools)) { - const rewritten = rewriteToolList(item.tools, plan); - return rewritten.changed ? { ...item, tools: rewritten.tools } : item; + const tools = rewriteToolList(item.tools, plan, emitted); + return tools === item.tools ? item : { ...item, tools }; } if ( (item.type === "function_call" || item.type === "custom_tool_call") && typeof item.name === "string" - ) return rewriteNamedSelector(item, plan); + ) return rewriteNamedSelector(item, plan, false); return item; } @@ -201,13 +252,23 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { if (!isPlainObject(body)) return { body, aliases: new Map() }; const groups = collectResponsesToolGroups(body); const plan = buildRewritePlan(groups); - if (plan.identities.size === 0) return { body, aliases: plan.aliases }; - let tools = body.tools; - if (Array.isArray(body.tools)) tools = rewriteToolList(body.tools, plan).tools; + // Deliberately not gated on the plan being non-empty: a turn whose catalog is gone still replays + // call items carrying a private `namespace`, and the routed compaction turn strips the whole tool + // surface before this runs. + const emitted = new Set(); + const tools = Array.isArray(body.tools) ? rewriteToolList(body.tools, plan, emitted) : body.tools; let input = body.input; - if (Array.isArray(body.input)) input = body.input.map(item => rewriteInputItem(item, plan)); + if (Array.isArray(body.input)) { + let inputChanged = false; + const rewrittenInput = body.input.map(item => { + const next = rewriteInputItem(item, plan, emitted); + if (next !== item) inputChanged = true; + return next; + }); + if (inputChanged) input = rewrittenInput; + } const toolChoice = rewriteToolChoice(body.tool_choice, plan); return { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fb124d52d4..65de2e60fa 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -297,6 +297,7 @@ import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search- import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; import { createRoutedNamespaceCallRestoreRewrite, + NamespaceToolCollisionError, restoreRoutedNamespaceCallsInJson, type RoutedNamespaceToolAliases, } from "../../responses/namespace-tool-compat"; @@ -2660,6 +2661,13 @@ async function handleResponsesInner( request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); } catch (error) { releaseCodexAuthContextProbeLease(authCtx); + // A tool catalog this proxy cannot lower onto one wire namespace is a client input error, and + // the rotation-rebuild and bridged paths already answer 400 for the identical throw. Rethrowing + // it here escaped every catch up to the Bun handler, so the same request produced an + // unstructured 500 — and no request log — depending only on whether a rotation ran first. + if (error instanceof NamespaceToolCollisionError) { + return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message)); + } throw error; } if (route.provider.authMode !== "forward") { diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 617749e62b..9681562e54 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -45,7 +45,24 @@ Responses-compatible streaming output. - 검토한 주요 대안: Move Grok back to Chat; special-case only xAI or the reserved `functions` group; flatten every complete namespace on noncanonical Responses and restore request-authorized aliases on return. - 선택한 방식: Noncanonical Responses lowers `functions` children to their bare top-level names and every other complete namespace to collision-checked `__` aliases after custom/tool-search conversion. It rewrites matching replay calls and tool selectors, records the aliases on the built request, and restores only those aliases in JSON/SSE call items before custom/tool-search lifecycle repair. Canonical OpenAI forward preserves native namespace shapes. - 다른 대안 대신 이 방식을 선택한 이유: A transport regression should not discard Responses streaming or create a provider-specific fork, and restoration without request-local authorization could reinterpret an unrelated upstream function as a client namespace call. -- 장점, 단점 및 영향: Grok and other public-schema Responses gateways accept current Codex catalogs while Codex still receives explicit namespace routing. Ambiguous wire names fail closed; empty, malformed, and future nested namespace shapes remain untouched rather than losing tools silently. +- 장점, 단점 및 영향: Grok and other public-schema Responses gateways accept current Codex catalogs while Codex still receives explicit namespace routing. No `type: "namespace"` value survives the boundary: a group the layer cannot express — empty, nested, or with an unusable child name — is dropped along with the children it cannot represent, because relaying the private shape costs the whole request rather than one tool. Genuinely ambiguous wire names still fail closed, now as a 400 rather than an unstructured 500. + +Two coordinates that lower to the same wire name are treated as one tool when they denote one: +`buildTools` flattens the reserved `functions` group without a namespace, so a bare declaration and +a `functions` child of the same name are the duplicate the parser already tolerates — and the one +`promoteClientLoadedTools` produces. The declaration is emitted once instead of failing the request. + +Replayed call items are lowered whether or not this turn declares the group they name. A routed +compaction turn strips the whole tool surface before the boundary runs, and a catalog can change +mid-session, but the client is still replaying items this layer's own response restoration stamped +with a private `namespace`. Only `tool_choice` resolves a bare name through the catalog: a history +item records which tool actually ran, so re-pointing it at a same-named namespace child would +rewrite that record on a coincidence rather than translate it. + +Codex-private tool fields are removed at the same boundary from one table +(`CANONICAL_ONLY_TOOL_FIELDS`) rather than one bespoke pass each: `external_web_access` on either +web-search variant, and `defer_loading` on any declaration, which `activateDeferredTool` clears only +for tools a `tool_search_output` already loaded. A new private bit is a row there. The same noncanonical boundary strips ChatGPT's private `external_web_access` bit from routed `web_search` declarations. The public tool remains enabled and all other options remain intact; @@ -236,10 +253,11 @@ items restore `{ namespace: "image_gen", name: "" }` so Codex can di extension. When item-id repair is also enabled, both transforms compose in one SSE parse/stringify pass (`src/server/sse-payload-rewrite.ts`) rather than chaining separate JS pull wrappers. Inspection and continuation-cache branches keep the raw upstream alias, allowing stored -replays to return upstream without leaking a client-only namespace shape. Malformed and empty -image-gen namespaces remain untouched; complete unrelated namespaces follow the general -noncanonical namespace compatibility rule above. ChatGPT forward mode preserves the private -namespace and hosted tool because that backend understands their native semantics. +replays to return upstream without leaking a client-only namespace shape. The image-gen layer itself +leaves malformed and empty image-gen namespaces untouched, but on a noncanonical route the general +namespace boundary above runs after it and lowers whatever remains, so no private group reaches the +wire. ChatGPT forward mode preserves the private namespace and hosted tool because that backend +understands their native semantics. Per-model `modelReasoningSummaryDelivery` is a narrow compatibility layer for `openai-responses` gateways whose summary capability is real but whose accepted delivery enum diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts index 1801fb3071..81ffbef5ff 100644 --- a/tests/namespace-tool-compat.test.ts +++ b/tests/namespace-tool-compat.test.ts @@ -113,14 +113,96 @@ describe("Responses namespace tool compatibility", () => { })).toThrow('namespace tool wire-name collision for "workspace__read"'); }); - test("preserves empty and malformed namespaces instead of dropping capabilities", () => { - const body = { + // Relaying `type: "namespace"` is what the strict gateway rejects, and it rejects the request + // rather than the tool — so a group this layer cannot represent costs every tool in the turn. + // Dropping what cannot be expressed costs only that. + test("lowers every namespace group rather than relaying the private shape", () => { + const body = rewriteRoutedNamespaceToolsForUpstream({ tools: [ { type: "namespace", name: "empty", tools: [] }, - { type: "namespace", name: "nested", tools: [{ type: "namespace", name: "child", tools: [] }] }, + { + type: "namespace", + name: "partial", + tools: [ + { type: "namespace", name: "nested", tools: [] }, + { type: "function", name: "", parameters: {} }, + { type: "function", name: "ok", parameters: {} }, + ], + }, ], - }; - expect(rewriteRoutedNamespaceToolsForUpstream(body).body).toEqual(body); + }).body as { tools: Array> }; + + expect(body.tools).toEqual([{ type: "function", name: "partial__ok", parameters: {} }]); + expect(body.tools.some(tool => tool.type === "namespace")).toBe(false); + }); + + // The identity key joins namespace and name with NUL, so a name carrying one could otherwise + // forge another tool's identity and silently take over its wire name. + test("drops children whose names cannot become a wire name", () => { + const NUL = String.fromCharCode(0); + const body = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "namespace", name: "a", tools: [{ type: "function", name: `b${NUL}c` }] }, + { type: "namespace", name: `a${NUL}b`, tools: [{ type: "function", name: "c" }] }, + { type: "namespace", name: "ok", tools: [{ type: "function", name: "run" }] }, + ], + }).body as { tools: Array> }; + + expect(body.tools).toEqual([{ type: "function", name: "ok__run" }]); + }); + + // `buildTools` flattens the reserved group without a namespace, so the parser treats these as one + // logical tool and tolerates the duplicate; `promoteClientLoadedTools` produces exactly this shape. + test("treats a bare declaration and a functions child of the same name as one tool", () => { + const rewritten = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "function", name: "exec", parameters: {} }, + { type: "namespace", name: "functions", tools: [{ type: "function", name: "exec", parameters: {} }] }, + ], + }); + const body = rewritten.body as { tools: Array> }; + + expect(body.tools).toEqual([{ type: "function", name: "exec", parameters: {} }]); + expect([...rewritten.aliases]).toEqual([]); + }); + + // The routed compaction turn strips the whole tool surface before this runs, and a catalog can + // change mid-session — but the client is still replaying items this layer's own restoration + // stamped with a private `namespace`. + test("lowers replayed calls even when this turn declares no namespace", () => { + const body = rewriteRoutedNamespaceToolsForUpstream({ + input: [ + { type: "function_call", namespace: "collaboration", name: "spawn_agent", call_id: "c1", arguments: "{}" }, + { type: "custom_tool_call", namespace: "functions", name: "exec", call_id: "c2", input: "run" }, + ], + }).body as { input: Array> }; + + expect(body.input[0]).toEqual({ + type: "function_call", + name: "collaboration__spawn_agent", + call_id: "c1", + arguments: "{}", + }); + expect(body.input[1]).toEqual({ + type: "custom_tool_call", + name: "exec", + call_id: "c2", + input: "run", + }); + expect(JSON.stringify(body)).not.toContain("namespace"); + }); + + // A history item records which tool actually ran. Resolving its bare name through a same-named + // namespace child would rewrite that record on a coincidence rather than translate it. + test("does not re-point a replayed bare-named call at a namespace child", () => { + const body = rewriteRoutedNamespaceToolsForUpstream({ + tools: [{ type: "namespace", name: "workspace", tools: [{ type: "function", name: "read" }] }], + input: [{ type: "function_call", name: "read", call_id: "c1", arguments: "{}" }], + tool_choice: { type: "function", name: "read" }, + }).body as { input: Array>; tool_choice: { name: string } }; + + expect(body.input[0].name).toBe("read"); + expect(body.tool_choice.name).toBe("workspace__read"); }); test("restores only aliases authorized by this request in JSON and SSE payloads", () => { diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 39ba0faec5..afbc692017 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -870,6 +870,48 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools).toEqual([{ type: "web_search", external_web_access: true }]); }); + // `activateDeferredTool` clears `defer_loading` only for tools a `tool_search_output` already + // loaded, so the first turn of a deferred catalog — and any child promoted out of a namespace + // group — otherwise carries the private field to a gateway that rejects unknown arguments. + test("drops Codex-private tool fields from routed declarations", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }); + const request = adapter.buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "grok-4.6", + tools: [ + { type: "web_search_preview", external_web_access: true }, + { + type: "namespace", + name: "workspace", + tools: [{ type: "function", name: "read", defer_loading: true, parameters: {} }], + }, + ], + input: [{ + type: "additional_tools", + tools: [{ type: "function", name: "loose", defer_loading: true, parameters: {} }], + }], + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Record[]; + input: Array<{ tools: Record[] }>; + }; + + expect(body.tools[0]).toEqual({ type: "web_search_preview" }); + expect(body.tools[1]).toMatchObject({ type: "function", name: "workspace__read" }); + expect(body.tools[1]).not.toHaveProperty("defer_loading"); + expect(body.input[0].tools[0]).not.toHaveProperty("defer_loading"); + }); + test("preserves prompt_cache_key in the raw Responses passthrough body", () => { const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ @@ -1542,7 +1584,7 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { expect(JSON.parse(secondRequest.body)).toEqual(firstBody); }); - test("keyed platform flattens complete namespaces and preserves malformed ones", () => { + test("keyed platform flattens complete namespaces and drops ones it cannot express", () => { const adapter = createResponsesPassthroughAdapter(keyedProvider); const request = adapter.buildRequest({ modelId: "gpt-5.6-sol", @@ -1569,8 +1611,9 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { tool_choice: { type: string; name: string }; }; + // The empty `image_gen` group declares nothing, and relaying `type: "namespace"` is the shape a + // strict gateway rejects for the whole request. expect(body.tools).toEqual([ - { type: "namespace", name: "image_gen", tools: [] }, { type: "function", name: "web__run", @@ -1948,10 +1991,8 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }, meta); const body = JSON.parse(request.body) as { tools: Array> }; - expect(body.tools).toEqual([ - { type: "namespace", name: "image_gen", tools: [] }, - { type: "image_generation" }, - ]); + // The empty namespace group is lowered away; only the hosted tool reaches the wire. + expect(body.tools).toEqual([{ type: "image_generation" }]); }); test("hosted-tool preference uses the exact model id", () => { @@ -1975,10 +2016,8 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }, meta); const body = JSON.parse(request.body) as { tools: Array> }; - expect(body.tools).toEqual([ - { type: "namespace", name: "image_gen", tools: [] }, - { type: "image_generation" }, - ]); + // The empty namespace group is lowered away; only the hosted tool reaches the wire. + expect(body.tools).toEqual([{ type: "image_generation" }]); }); test("hosted-tool preference honors an OpenAI virtual model's selected id", () => { From 9a2f3ef1c9e61726cfc5806ef4bdf7a5cdf37f74 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 19:05:51 -0700 Subject: [PATCH 10/17] 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 8156080c7f..59476e060f 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; @@ -1602,7 +1613,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripOpenAiOnlyWebSearchFields(outBody); } } - 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 939da2226a..c256742774 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -22,6 +22,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"; @@ -501,6 +502,11 @@ function bindRouteReasoningReplayScope(args: { parsed._reasoningReplayScope, replayIdentity, ); + // 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; + } bindProviderContinuationForRoute(parsed, continuationOwner); } diff --git a/src/types/request.ts b/src/types/request.ts index 6fa4815add..7d7ce480dd 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 d63e594b05..877c99d4af 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 2dbc6c3a66001657c0b6aa7b535810233ade2eaa Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:49:15 -0700 Subject: [PATCH 11/17] fix(responses): make namespace dedup order-independent and restore custom calls by wire identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two defects in the flattening layer; both are fixed here. Deduplication depended on declaration order. A bare declaration and a `functions` child of the same name are one logical tool, but which one owned the wire name — and therefore which one was emitted — followed whichever container the rewrite reached first. The plan now records the bare wire names from the complete catalog and the bare declaration always wins, so the same catalog flattens identically whichever container declares it. Custom-call restoration used the wrong coordinate. A custom tool inside a non-`functions` namespace is lowered twice on the way out (custom to function, then renamed to `__`), while on the way back namespace restore runs first and replaces the wire name with the bare one. Custom restore then matched that bare name and could convert an unrelated same-named function call, sending Codex a `custom_tool_call` with the wrong payload shape. Converted custom tools are now tracked by their final upstream wire name, and restoration reconstructs that identity from the `{namespace, name}` an earlier rewrite restored. A namespaced custom and a namespaced function sharing a child name now round-trip to their own item types, on both the JSON and SSE paths. Co-Authored-By: Claude Fable 5 --- src/adapters/base.ts | 2 +- src/responses/custom-tool-compat.ts | 75 +++++++- src/responses/namespace-tool-compat.ts | 21 ++- src/server/responses-custom-tool-repair.ts | 4 +- src/server/responses/core.ts | 5 +- tests/namespace-tool-compat.test.ts | 32 ++++ tests/openai-responses-passthrough.test.ts | 193 ++++++++++++++++++++- 7 files changed, 320 insertions(+), 12 deletions(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 45660e478c..3f3f3d06b6 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -68,7 +68,7 @@ export interface AdapterRequest { method: string; headers: Record; body: string; - /** Custom-tool names actually lowered to upstream function calls while building this request. */ + /** Final upstream wire names of custom tools lowered to functions while building this request. */ convertedRoutedCustomToolNames?: ReadonlySet; /** Client tool-search names actually lowered to upstream function calls for this request. */ convertedRoutedToolSearchNames?: ReadonlySet; diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 83ddc1be19..e7db3c32a6 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -1,4 +1,8 @@ +import { namespacedToolName } from "../types"; +import { collectResponsesToolGroups } from "./tool-groups"; + const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); +const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -13,6 +17,65 @@ function customToolInput(argumentsText: unknown): string { return argumentsText; } +function customToolWireName(namespace: string | undefined, name: string): string { + return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name); +} + +/** Final upstream identity of a call, including a namespace restored by an earlier rewrite. */ +export function routedCustomToolWireName(value: unknown): string | undefined { + if (!isPlainObject(value) || typeof value.name !== "string") return undefined; + return customToolWireName( + typeof value.namespace === "string" ? value.namespace : undefined, + value.name, + ); +} + +/** + * Names of converted custom declarations after namespace lowering. Restoration uses these exact + * wire identities so same-named function and custom children in different namespaces stay distinct. + */ +function collectRoutedCustomToolWireNames(body: unknown): Set { + const names = new Set(); + const groups = collectResponsesToolGroups(body); + const bareWireNames = new Set(); + for (const group of groups) { + for (const tool of group) { + if ( + isPlainObject(tool) + && tool.type !== "namespace" + && typeof tool.name === "string" + ) bareWireNames.add(tool.name); + } + } + + for (const group of groups) { + for (const tool of group) { + if (!isPlainObject(tool)) continue; + if ( + tool.type === "custom" + && typeof tool.name === "string" + && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(tool.name) + ) { + names.add(tool.name); + continue; + } + if (tool.type !== "namespace" || typeof tool.name !== "string" || !Array.isArray(tool.tools)) { + continue; + } + for (const child of tool.tools) { + if ( + isPlainObject(child) + && child.type === "custom" + && typeof child.name === "string" + && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(child.name) + && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) + ) names.add(customToolWireName(tool.name, child.name)); + } + } + } + return names; +} + export function customToolItemId(id: unknown): unknown { if (typeof id !== "string") return id; return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; @@ -125,11 +188,12 @@ export function rewriteRoutedCustomToolsForUpstream(body: unknown): { body: unknown; names: Set; } { - const names = collectRoutedCustomToolNames(body); - if (names.size === 0) return { body, names }; + const conversionNames = collectRoutedCustomToolNames(body); + const names = collectRoutedCustomToolWireNames(body); + if (conversionNames.size === 0) return { body, names }; const callIds = new Set(); - collectConvertedCallIds(body, names, callIds); - return { body: rewriteForUpstream(body, names, callIds), names }; + collectConvertedCallIds(body, conversionNames, callIds); + return { body: rewriteForUpstream(body, conversionNames, callIds), names }; } export function restoreRoutedCustomCalls( @@ -155,7 +219,8 @@ export function restoreRoutedCustomCalls( changed ||= result.changed; } - if (value.type === "function_call" && typeof value.name === "string" && names.has(value.name)) { + const wireName = routedCustomToolWireName(value); + if (value.type === "function_call" && wireName !== undefined && names.has(wireName)) { restored.type = "custom_tool_call"; restored.id = customToolItemId(value.id); restored.input = customToolInput(value.arguments); diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 8a52251da5..3f6cd42ea2 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -90,6 +90,7 @@ function addSelector( type NamespaceRewritePlan = { aliases: Map; + bareWireNames: Set; identities: Map; selectors: Map; }; @@ -99,6 +100,7 @@ export class NamespaceToolCollisionError extends Error {} function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan { const aliases = new Map(); + const bareWireNames = new Set(); const identities = new Map(); const selectors = new Map(); const wireOwners = new Map(); @@ -110,6 +112,7 @@ function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan { // declaring the same tool both ways is the duplicate the parser already tolerates, not a // collision, and `promoteClientLoadedTools` produces exactly that shape. wireOwners.set(tool.name, loweredIdentity(BUILTIN_FUNCTIONS_NAMESPACE, tool.name)); + bareWireNames.add(tool.name); addSelector(selectors, tool.name, tool.name); } } @@ -141,7 +144,7 @@ function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan { } } - return { aliases, identities, selectors }; + return { aliases, bareWireNames, identities, selectors }; } /** @@ -165,13 +168,25 @@ function rewriteToolList( if (!parsed) continue; for (const child of parsed.children) { const wireName = plan.identities.get(loweredIdentity(parsed.namespace, child.name as string)); - if (wireName === undefined || emitted.has(wireName)) continue; + // A bare declaration is the canonical representation of a `functions` child. Decide that + // from the complete catalog rather than whichever container happens to be rewritten first. + if ( + wireName === undefined + || (parsed.namespace === BUILTIN_FUNCTIONS_NAMESPACE && plan.bareWireNames.has(wireName)) + || emitted.has(wireName) + ) continue; emitted.add(wireName); rewritten.push(wireName === child.name ? child : { ...child, name: wireName }); } continue; } - if (isPlainObject(tool) && isRepresentableName(tool.name)) emitted.add(tool.name); + if (isPlainObject(tool) && isRepresentableName(tool.name)) { + if (emitted.has(tool.name)) { + changed = true; + continue; + } + emitted.add(tool.name); + } rewritten.push(tool); } return changed ? rewritten : tools; diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index c3c40b134d..1aaa16c73e 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -2,6 +2,7 @@ import type { TranslatorBudget } from "../lib/translator-budget"; import { customToolItemId, restoreRoutedCustomCalls, + routedCustomToolWireName, unwrapRoutedCustomToolArguments, } from "../responses/custom-tool-compat"; import { @@ -181,7 +182,8 @@ export function createRoutedCustomToolRestoreBlockRewrite( && typeof parsed.item.name === "string" ) { const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; - const routed = names.has(parsed.item.name); + const wireName = routedCustomToolWireName(parsed.item); + const routed = wireName !== undefined && names.has(wireName); if (upstreamItemId) { if (routed) { itemNames.set(upstreamItemId, parsed.item.name); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 65de2e60fa..d72b670316 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2672,7 +2672,10 @@ async function handleResponsesInner( } if (route.provider.authMode !== "forward") { for (const name of request.convertedRoutedCustomToolNames ?? []) { - if (toolBridgeMaps.freeformToolNames.has(name)) routedCustomToolNames.add(name); + if ( + toolBridgeMaps.freeformToolNames.has(name) + || toolBridgeMaps.toolNsMap.get(name)?.freeform === true + ) routedCustomToolNames.add(name); } } for (const name of request.convertedRoutedToolSearchNames ?? []) { diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts index 81ffbef5ff..45a4157808 100644 --- a/tests/namespace-tool-compat.test.ts +++ b/tests/namespace-tool-compat.test.ts @@ -166,6 +166,38 @@ describe("Responses namespace tool compatibility", () => { expect([...rewritten.aliases]).toEqual([]); }); + test("chooses the bare declaration regardless of which tool container comes first", () => { + const bare = { + type: "function", + name: "exec", + description: "canonical bare declaration", + parameters: { type: "object", properties: { input: { type: "string" } } }, + }; + const functionsGroup = { + type: "namespace", + name: "functions", + tools: [{ + type: "function", + name: "exec", + description: "namespace duplicate", + parameters: { type: "object", properties: {} }, + }], + }; + const flatten = (bodyTools: unknown[], additionalTools: unknown[]) => { + const rewritten = rewriteRoutedNamespaceToolsForUpstream({ + tools: bodyTools, + input: [{ type: "additional_tools", role: "developer", tools: additionalTools }], + }).body as { + tools: Array>; + input: Array<{ tools: Array> }>; + }; + return [...rewritten.tools, ...rewritten.input[0]!.tools]; + }; + + expect(flatten([bare], [functionsGroup])).toEqual([bare]); + expect(flatten([functionsGroup], [bare])).toEqual([bare]); + }); + // The routed compaction turn strips the whole tool surface before this runs, and a catalog can // change mid-session — but the client is still replaying items this layer's own restoration // stamped with a private `namespace`. diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index afbc692017..a44d7353a1 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -3,8 +3,9 @@ import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterP import { openaiResponsesUrl } from "../src/adapters/openai-responses-url"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; -import { sanitizeEncryptedContentInPlace } from "../src/server/responses"; +import { handleResponses, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { createTranslatorBudget } from "../src/lib/translator-budget"; +import type { OcxConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; const createResponsesPassthroughAdapter = (...args: Parameters) => @@ -2162,6 +2163,196 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }); }); +describe("routed namespace and custom-tool identity", () => { + const customNamespace = "custom_catalog"; + const functionNamespace = "function_catalog"; + const rawTools = [ + { + type: "namespace", + name: customNamespace, + tools: [{ + type: "custom", + name: "read", + description: "Read freeform input", + format: { type: "text" }, + }], + }, + { + type: "namespace", + name: functionNamespace, + tools: [{ + type: "function", + name: "read", + description: "Read structured input", + parameters: { type: "object", properties: {} }, + }], + }, + ]; + const customUpstreamItem = { + type: "function_call", + id: "fc_custom_read", + call_id: "call_custom_read", + name: `${customNamespace}__read`, + arguments: JSON.stringify({ input: "freeform payload" }), + status: "completed", + }; + const functionUpstreamItem = { + type: "function_call", + id: "fc_function_read", + call_id: "call_function_read", + name: `${functionNamespace}__read`, + arguments: "{}", + status: "completed", + }; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + const frame = (event: string, payload: Record): string => + `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; + + test("round-trips same-named namespaced custom and function calls through JSON and SSE", async () => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const built = adapter.buildRequest({ + modelId: "routed-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "routed-model", input: "read", tools: rawTools }, + }, { headers: new Headers() }); + const builtBody = JSON.parse(built.body) as { tools: Array> }; + + expect(builtBody.tools.map(tool => ({ type: tool.type, name: tool.name }))).toEqual([ + { type: "function", name: `${customNamespace}__read` }, + { type: "function", name: `${functionNamespace}__read` }, + ]); + expect([...(built.convertedRoutedCustomToolNames ?? [])]).toEqual([ + `${customNamespace}__read`, + ]); + + const savedFetch = globalThis.fetch; + const outboundBodies: Array> = []; + globalThis.fetch = (async (_input, init) => { + const outbound = JSON.parse(String(init?.body)) as Record; + outboundBodies.push(outbound); + if (outbound.stream === true) { + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...customUpstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: customUpstreamItem.id, + arguments: customUpstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: customUpstreamItem }), + frame("response.output_item.added", { + output_index: 1, + item: { ...functionUpstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 1, + item_id: functionUpstreamItem.id, + arguments: functionUpstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 1, item: functionUpstreamItem }), + frame("response.completed", { + response: { + id: "resp_stream", + status: "completed", + output: [customUpstreamItem, functionUpstreamItem], + }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + } + return new Response(JSON.stringify({ + id: "resp_json", + status: "completed", + output: [customUpstreamItem, functionUpstreamItem], + }), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const requestBody = (stream: boolean) => ({ + model: "fixture/routed-model", + stream, + input: [{ role: "user", content: [{ type: "input_text", text: "read both" }] }], + tools: rawTools, + }); + + try { + const jsonResponse = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody(false)), + }), config, { model: "", provider: "" }); + const json = await jsonResponse.json() as { output: Array> }; + expect(json.output[0]).toMatchObject({ + type: "custom_tool_call", + namespace: customNamespace, + name: "read", + input: "freeform payload", + }); + expect(json.output[0]).not.toHaveProperty("arguments"); + expect(json.output[1]).toMatchObject({ + type: "function_call", + namespace: functionNamespace, + name: "read", + arguments: "{}", + }); + + const sseResponse = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody(true)), + }), config, { model: "", provider: "" }); + const clientSse = await sseResponse.text(); + const payloads = clientSse + .split(/\r?\n/) + .filter(line => line.startsWith("data:") && line.slice(5).trim() !== "[DONE]") + .map(line => JSON.parse(line.slice(5).trim()) as Record); + const completed = payloads.find(payload => payload.type === "response.completed") as { + response: { output: Array> }; + } | undefined; + expect(completed?.response.output[0]).toMatchObject({ + type: "custom_tool_call", + namespace: customNamespace, + name: "read", + input: "freeform payload", + }); + expect(completed?.response.output[1]).toMatchObject({ + type: "function_call", + namespace: functionNamespace, + name: "read", + arguments: "{}", + }); + expect(payloads.some(payload => payload.type === "response.custom_tool_call_input.done")).toBe(true); + expect(payloads.some(payload => payload.type === "response.function_call_arguments.done")).toBe(true); + + for (const outbound of outboundBodies) { + const tools = outbound.tools as Array>; + expect(tools.map(tool => ({ type: tool.type, name: tool.name }))).toEqual([ + { type: "function", name: `${customNamespace}__read` }, + { type: "function", name: `${functionNamespace}__read` }, + ]); + } + } finally { + globalThis.fetch = savedFetch; + } + }); +}); + describe("OpenAI Responses forward-mode unsupported param stripping", () => { const meta = { headers: new Headers({ authorization: "Bearer token" }) }; const rawBody = { From e4cbac88da4203a2c8290a7f7473d09ca62ea3b8 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 19:28:42 -0700 Subject: [PATCH 12/17] 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 877c99d4af..74e95c7a33 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); From 4b3b39b97243690e61aa25217e19d483a580d593 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 20:14:00 -0700 Subject: [PATCH 13/17] fix(responses): recover when an upstream rejects foreign opaque state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread-scoped serving-identity record strips replayed blobs deterministically, but it is in-process and bounded, and it deliberately keeps blobs when it has no record — stripping on "unknown" would discard valid reasoning after every restart. That leaves a failure users hit routinely. From the live usage log, one conversation: 19:33:31 xai grok-4.6 200 <- last grok turn 19:38 proxy restarted (records wiped) 19:48:11 openai gpt-5.6-sol 400 "The encrypted content Py6J...kwW9 could not be verified. Reason: Encrypted content could not be decrypted or parsed." The proxy never served the turn that minted those blobs, so it cannot know they are foreign. TTL expiry, LRU eviction and any transcript older than the process open the same hole. Register a recovery kind rather than invent a retry path: `image-413` already reacts to an upstream rejection by rebuilding the body once and refetching inside the recovery loop, with a single-attempt guard. This adds `opaque-blob-rejection` on the same shape, triggered only by a decoder's own 4xx identity — OpenAI's nested `invalid_encrypted_content`, or xAI's two concrete decoder messages — and only when the exact outbound body still carried a blob, so an unrelated `invalid-argument` never gains a hidden resend and a blobless body never triggers an identical resend. The deterministic pre-flight stays primary: when a record exists the first request is already correct and this never runs. Cost when it does run is one extra round trip and one turn of degraded reasoning. Co-Authored-By: Claude Fable 5 --- src/server/responses/core.ts | 283 ++++++++++++++++++- src/usage/log.ts | 2 + structure/04_transports-and-sidecars.md | 21 +- tests/responses-opaque-blob-recovery.test.ts | 248 ++++++++++++++++ tests/usage-log.test.ts | 25 ++ 5 files changed, 563 insertions(+), 16 deletions(-) create mode 100644 tests/responses-opaque-blob-recovery.test.ts diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c5e1822c6c..e72fbb5214 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -25,7 +25,7 @@ import { updateReasoningReplayServingIdentity, } from "../../responses/reasoning-replay-cache"; import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; -import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; +import { buildCompactV1Output, COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, extractCompactUserMessages, isCompactionItemType } from "../../responses/compaction"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { copyPreviousResponseReplayProvenance, @@ -516,6 +516,124 @@ function bindRouteReasoningReplayScope(args: { bindProviderContinuationForRoute(parsed, continuationOwner); } +const OPAQUE_RESPONSES_INPUT_TYPES = new Set([ + "reasoning", + "compaction", + "compaction_summary", + "context_compaction", +]); + +function outboundResponsesBodyCarriesOpaqueBlob(bodyText: string | undefined): boolean { + if (!bodyText) return false; + try { + const body = JSON.parse(bodyText) as unknown; + if (!body || typeof body !== "object" || Array.isArray(body)) return false; + const input = (body as { input?: unknown }).input; + if (!Array.isArray(input)) return false; + return input.some(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + const candidate = item as { type?: unknown; encrypted_content?: unknown }; + return typeof candidate.type === "string" + && OPAQUE_RESPONSES_INPUT_TYPES.has(candidate.type) + && typeof candidate.encrypted_content === "string" + && candidate.encrypted_content.length > 0; + }); + } catch { + return false; + } +} + +function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { + try { + const payload = JSON.parse(bodyText) as unknown; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const record = payload as { code?: unknown; error?: unknown }; + + if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) { + const error = record.error as { type?: unknown; code?: unknown }; + if (error.type === "invalid_request_error" && error.code === "invalid_encrypted_content") { + return true; + } + } + + if (record.code !== "invalid-argument" || typeof record.error !== "string") return false; + return record.error.startsWith("Could not decode the compaction blob") + || record.error.startsWith("Could not decrypt the provided encrypted_content"); + } catch { + return false; + } +} + +/** + * Whether an upstream Responses 4xx authoritatively rejected opaque replay state. + * + * The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or + * compaction blob that the adapter already lowered, in which case a replay would be byte-identical. + * OpenAI exposes a dedicated nested code. xAI's code is generic, so its two concrete decoder error + * identities are also required; unrelated invalid-argument prose must never gain a hidden resend. + */ +export function shouldAttemptOpaqueBlobRecovery(args: { + status: number; + adapterName: string; + outboundBody?: string; + errorBody: string; + alreadyAttempted: boolean; +}): boolean { + return args.status >= 400 + && args.status < 500 + && args.adapterName === "openai-responses" + && !args.alreadyAttempted + && outboundResponsesBodyCarriesOpaqueBlob(args.outboundBody) + && isSelfIdentifiedOpaqueBlobRejection(args.errorBody); +} + +async function opaqueBlobRejectionBodyForRecovery( + response: Response, + outboundBody: string | undefined, + adapterName: string, + alreadyAttempted: boolean, + signal: AbortSignal, +): Promise { + if ( + response.status < 400 + || response.status >= 500 + || adapterName !== "openai-responses" + || alreadyAttempted + || !outboundResponsesBodyCarriesOpaqueBlob(outboundBody) + ) return undefined; + try { + const body = await readBoundedResponseBody(response.clone(), { signal }); + return body.displaySafe && !body.truncated ? body.text : undefined; + } catch { + return undefined; + } +} + +function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void { + parsed._stripReasoningEncryptedContent = true; + const body = parsed._rawBody; + if (!body || typeof body !== "object" || Array.isArray(body)) return; + const input = (body as { input?: unknown }).input; + if (!Array.isArray(input)) return; + let changed = false; + const recoveredInput = input.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const candidate = item as { type?: unknown; encrypted_content?: unknown }; + if (!isCompactionItemType(candidate.type) || typeof candidate.encrypted_content !== "string") { + return item; + } + changed = true; + return { + type: "message", + role: "user", + content: [{ type: "input_text", text: compactionItemToText(candidate.encrypted_content) }], + }; + }); + // Mutate only the raw input carrier so the adapter rebuild sees the same degradation as its + // ordinary compaction scrub. Keeping the body object preserves non-enumerable persistence marks. + if (changed) (body as { input: unknown[] }).input = recoveredInput; +} + function nonEmptyProviderApiKey(provider: OcxProviderConfig): string | undefined { return typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0 ? provider.apiKey @@ -2705,14 +2823,17 @@ async function handleResponsesInner( // with no tools and Copilot answers with a `custom_tool_call` for `apply_patch`. Policing an // empty catalog truncates that turn. An unreadable body lands here too, since it yields no // names either. - const outboundRequestBody = (() => { + const parseOutboundRequestBody = (bodyText: string): Record | undefined => { try { - const body = JSON.parse(request.body) as unknown; - return body && typeof body === "object" && !Array.isArray(body) ? body : undefined; + const body = JSON.parse(bodyText) as unknown; + return body && typeof body === "object" && !Array.isArray(body) + ? body as Record + : undefined; } catch { return undefined; } - })(); + }; + let outboundRequestBody = parseOutboundRequestBody(request.body); const declaredWireToolNames = collectDeclaredWireToolNames(outboundRequestBody); // Union with the caller's own catalog, because the outbound body is not always a complete // record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a @@ -2794,7 +2915,7 @@ async function handleResponsesInner( } hostAdmissionLease = null; }; - const passthroughEstimate = typeof request.usageLog?.inputTokens === "number" + let passthroughEstimate = typeof request.usageLog?.inputTokens === "number" ? request.usageLog.inputTokens : undefined; if (passthroughEstimate !== undefined) { @@ -2881,11 +3002,87 @@ async function handleResponsesInner( request.releaseBodyObservation?.(); } + let opaqueBlobRecoveryAttempted = false; + let oauth401ReplayAttempted = false; + const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider); + let rateLimitRetries = 0; + const rebuildAndRefetch = async ( + recovery: AttemptRecoveryKind, + ): Promise => { + const retryAdapter = resolveAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + if (!("passthrough" in retryAdapter) || !retryAdapter.passthrough) { + upstream.abort(); + return { failed: formatErrorResponse(502, "upstream_error", "Recovery changed the provider wire unexpectedly") }; + } + try { + request = await retryAdapter.buildRequest(parsed, { + headers: selectedForwardHeaders, + translatorBudget, + }); + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + } catch (err) { + upstream.abort(); + if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() }; + const msg = err instanceof Error ? err.message : String(err); + return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) }; + } + passthroughEstimate = typeof request.usageLog?.inputTokens === "number" + ? request.usageLog.inputTokens + : undefined; + if (passthroughEstimate !== undefined) logCtx.usageLogInputTokens = passthroughEstimate; + outboundRequestBody = parseOutboundRequestBody(request.body); + logCtx.providerAdapter = retryAdapter.name; + sealRequestAttemptIdentity( + logCtx.activeAttempt, + logCtx.provider, + retryAdapter.name, + logCtx.accountLogLabel, + ); + try { + return await fetchWithTransientRetry( + innerRecovery => { + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, innerRecovery ?? recovery); + return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, innerRecovery), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + }), + route.provider.authMode === "forward") + .then(response => { + settleObservedHostResponse(); + return response; + }); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + ); + } catch (err) { + return { failed: transportFailureResponse(err) }; + } finally { + request.releaseBodyObservation?.(); + } + }; + + passthroughRecovery: for (;;) { + // Native Responses providers return before the generic adapter recovery loop below. Keep // their OAuth contract identical: one pre-stream 401 forces a credential refresh and one // rebuilt replay. xAI's current subscription models use this branch now that their official // Grok CLI catalog declares the Responses backend. - if (upstreamResponse.status === 401 && isOAuth401ReplayProvider && sentOAuthSnapshot) { + if ( + upstreamResponse.status === 401 + && isOAuth401ReplayProvider + && sentOAuthSnapshot + && !oauth401ReplayAttempted + ) { + oauth401ReplayAttempted = true; try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } let refreshed: OAuthAccessSnapshot; try { @@ -2979,8 +3176,6 @@ async function handleResponsesInner( // immediately with no same-key replay. Pre-stream only — nothing has been relayed yet, so // the replay is lossless (same invariant as the recovery loop). Forward/OAuth providers // keep their pool logic below (rateLimitRetryPolicyFor returns null for them). - const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider); - let rateLimitRetries = 0; while ( upstreamResponse.status === 429 && rateLimitPolicy !== null @@ -3104,6 +3299,39 @@ async function handleResponsesInner( } } } + // The deterministic route record cannot classify history it never observed (restart, expiry, + // eviction, or an older transcript). Inspect only a bounded clone of a 4xx whose exact outbound + // Responses body still carries opaque state, then rebuild once through the ordinary adapter + // sanitation path. A second rejection falls through unchanged because the guard stays armed. + const opaqueBlobErrorBody = await opaqueBlobRejectionBodyForRecovery( + upstreamResponse, + request.body, + adapter.name, + opaqueBlobRecoveryAttempted, + upstream.signal, + ); + if (opaqueBlobErrorBody !== undefined && shouldAttemptOpaqueBlobRecovery({ + status: upstreamResponse.status, + adapterName: adapter.name, + outboundBody: request.body, + errorBody: opaqueBlobErrorBody, + alreadyAttempted: opaqueBlobRecoveryAttempted, + })) { + opaqueBlobRecoveryAttempted = true; + prepareOpaqueBlobRecovery(parsed); + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuildAndRefetch("opaque-blob-rejection"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue passthroughRecovery; + } + break; + } + // Binding normally records before the first send. Repeat it only after a successful recovery + // so an eviction during the extra round trip cannot leave the next cross-route turn cold. + if (opaqueBlobRecoveryAttempted && upstreamResponse.ok) { + updateReasoningReplayServingIdentity(parsed._reasoningReplayScope); + } const headers = sanitizePassthroughHeaders(upstreamResponse.headers); const resolvedModel = headers.get("openai-model")?.trim(); if (resolvedModel) logCtx.resolvedModel = resolvedModel; @@ -4166,12 +4394,14 @@ async function handleResponsesInner( // main request clear a 413 must not be forgotten on the very next continuation build. let imageTierBias = 0; if (!upstreamResponse.ok) { - // Recovery loop: multi-key 429 failover + at most ONE anthropic 413 tightened retry + // Recovery loop: multi-key 429 failover + at most ONE opaque-state rebuild and ONE + // anthropic 413 tightened retry // (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves // both paths so a 429→413 sequence never rebuilds against a stale pre-rotation // adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a // 413→429 rotation cannot silently undo the tightening. let imageRetryAttempted = false; + let opaqueBlobRecoveryAttempted = false; let oauth401ReplayAttempted = false; /** * Rebuild the request from the current parsed input (and any image-tier bias) and refetch @@ -4404,6 +4634,34 @@ async function handleResponsesInner( break; } } + // Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry, + // or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is + // the missing authoritative signal. Rebuild once through the same sanitation path used by a + // known route switch; invalidating is mandatory because `parsed` mutates in place and the + // same-target cache would otherwise replay the rejected bytes verbatim. + const opaqueBlobErrorBody = await opaqueBlobRejectionBodyForRecovery( + upstreamResponse, + sameTargetRequest?.body, + activeAdapter.name, + opaqueBlobRecoveryAttempted, + upstream.signal, + ); + if (opaqueBlobErrorBody !== undefined && shouldAttemptOpaqueBlobRecovery({ + status: upstreamResponse.status, + adapterName: activeAdapter.name, + outboundBody: sameTargetRequest?.body, + errorBody: opaqueBlobErrorBody, + alreadyAttempted: opaqueBlobRecoveryAttempted, + })) { + opaqueBlobRecoveryAttempted = true; + prepareOpaqueBlobRecovery(parsed); + invalidateSameTargetRequest(); + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuildAndRefetch("opaque-blob-rejection"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue recovery; + } // Anthropic 413 request_too_large: rebuild once with every image one tier lower // (spiral guard: single attempt). The biased response re-enters the 429 check above. if (shouldAttemptImageTierRetry({ @@ -4423,6 +4681,11 @@ async function handleResponsesInner( } break; } + // Binding normally records before the first send. Repeat it only after a successful recovery + // so an eviction during the extra round trip cannot leave the next cross-route turn cold. + if (opaqueBlobRecoveryAttempted && upstreamResponse.ok) { + updateReasoningReplayServingIdentity(parsed._reasoningReplayScope); + } if (!upstreamResponse.ok) { if (options.comboAttempt) { // No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads diff --git a/src/usage/log.ts b/src/usage/log.ts index fd93059e68..66654b8b74 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -29,6 +29,7 @@ export type AttemptRecoveryKind = | "rate-limit-429" | "anthropic-oauth-429" | "image-413" + | "opaque-blob-rejection" | "empty-completion"; export interface PersistedUsageAttempt { @@ -218,6 +219,7 @@ const ATTEMPT_RECOVERY_KINDS = new Set([ "rate-limit-429", "anthropic-oauth-429", "image-413", + "opaque-blob-rejection", "empty-completion", ]); const USAGE_STATUSES = new Set([ diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index fd49386687..4b9772c935 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -555,8 +555,17 @@ survive; `status` is also removed from blobless reasoning items. Missing, expire 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. +volatile identity. This deterministic pre-flight is the primary path and covers threads the process +has served while their record remains inside the TTL/LRU bounds. Missing, expired, evicted, and +pre-process history stays fail-soft on the first send. If a Responses upstream then returns its own +self-identifying opaque-blob 4xx (`invalid_encrypted_content`, or xAI's two `invalid-argument` +decoder errors), the proxy rebuilds once through the same sanitation path: reasoning +`encrypted_content` is removed and compaction blobs use the existing text degradation. A one-shot +guard makes a second rejection terminal, and a successful recovery records the current serving +identity so later route changes return to deterministic pre-flight. A cold-record cross-backend +switch therefore costs one extra upstream round trip and one turn of degraded reasoning, rather than +wedging the thread; unrelated 4xx responses and requests whose outbound body carries no blob never +enter this recovery. 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 @@ -573,10 +582,10 @@ conversation's last-serving identity and cause a later main-model turn to strip [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 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. +- 검토한 주요 대안: Strip every opaque blob, persist provenance across restarts, trust generic 4xx prose, rely only on a retry, or combine deterministic route comparison with a narrowly identified recovery. +- 선택한 방식: 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, pass a proven change into the Responses adapter before the first send, and use one self-identified opaque-blob recovery only when provenance was unknown. +- 다른 대안 대신 이 방식을 선택한 이유: Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and deterministic pre-flight avoids the extra paid or stateful upstream attempt whenever the process has evidence. The upstream's narrow error identity supplies authoritative evidence only for histories the process could not observe. +- 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning on the first send, known cross-route replay keeps the reasoning item without its undecodable blob, and a cold cross-route replay recovers with one extra round trip and one degraded-reasoning turn. A repeated rejection is surfaced unchanged after exactly one recovery attempt. 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 diff --git a/tests/responses-opaque-blob-recovery.test.ts b/tests/responses-opaque-blob-recovery.test.ts new file mode 100644 index 0000000000..4e7be9b77e --- /dev/null +++ b/tests/responses-opaque-blob-recovery.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearReasoningReplayCacheForTests } from "../src/responses/reasoning-replay-cache"; +import { OPAQUE_COMPACTION_NOTE } from "../src/responses/compaction"; +import { resetThoughtSignatureReplayForTests } from "../src/responses/thought-signature-replay"; +import { + handleResponses, + shouldAttemptOpaqueBlobRecovery, +} from "../src/server/responses/core"; +import type { RequestLogContext } from "../src/server/request-log"; +import type { OcxConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; +const originalOpenCodexHome = process.env.OPENCODEX_HOME; +const BLOB = "provider-minted-opaque-state"; +const OPENAI_BLOB_ERROR = JSON.stringify({ + error: { + message: "The encrypted content could not be verified.", + type: "invalid_request_error", + code: "invalid_encrypted_content", + }, +}); +const XAI_DECODE_ERROR = JSON.stringify({ + code: "invalid-argument", + error: "Could not decode the compaction blob: invalid payload", +}); +const XAI_DECRYPT_ERROR = JSON.stringify({ + code: "invalid-argument", + error: "Could not decrypt the provided encrypted_content: invalid payload", +}); + +let testDir = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-opaque-blob-recovery-")); + process.env.OPENCODEX_HOME = testDir; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + rmSync(testDir, { recursive: true, force: true }); +}); + +function reasoningReplayInput(): Array> { + return [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "before" }], + }, + { + type: "reasoning", + content: [], + summary: [{ type: "summary_text", text: "prior reasoning" }], + encrypted_content: BLOB, + status: "completed", + }, + { + type: "compaction", + encrypted_content: BLOB, + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "after" }], + }, + ]; +} + +function serializedOutboundWithBlob(): string { + return JSON.stringify({ model: "model-a", input: reasoningReplayInput() }); +} + +function config(): OcxConfig { + return { + defaultProvider: "first", + providers: { + first: { + adapter: "openai-responses", + baseUrl: "https://first.example.test/v1", + authMode: "key", + apiKey: "first-test-key", + decodesNativeCompactionBlobs: true, + }, + second: { + adapter: "openai-responses", + baseUrl: "https://second.example.test/v1", + authMode: "key", + apiKey: "second-test-key", + }, + }, + } as OcxConfig; +} + +function request(provider = "first", threadId = "thread-opaque-recovery"): 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: `${provider}/model-a`, + stream: false, + store: false, + input: reasoningReplayInput(), + }), + }); +} + +function rejection(body = OPENAI_BLOB_ERROR): Response { + return new Response(body, { + status: 400, + headers: { "content-type": "application/json" }, + }); +} + +function success(id: string): Response { + return Response.json({ + id, + object: "response", + status: "completed", + model: "model-a", + output: [], + }); +} + +function hasBlob(body: Record): boolean { + return JSON.stringify(body).includes(BLOB); +} + +describe("opaque blob recovery trigger", () => { + const base = { + status: 400, + adapterName: "openai-responses", + outboundBody: serializedOutboundWithBlob(), + errorBody: OPENAI_BLOB_ERROR, + alreadyAttempted: false, + }; + + test("accepts OpenAI and both xAI opaque-state rejection identities", () => { + expect(shouldAttemptOpaqueBlobRecovery(base)).toBe(true); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody: XAI_DECODE_ERROR })).toBe(true); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody: XAI_DECRYPT_ERROR })).toBe(true); + }); + + test("rejects unrelated errors, 5xx, blobless sends, non-Responses adapters, and repeats", () => { + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + errorBody: JSON.stringify({ + error: { type: "invalid_request_error", code: "unknown_parameter", message: "Unknown parameter" }, + }), + })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, status: 500 })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + outboundBody: JSON.stringify({ model: "model-a", input: [{ type: "message", role: "user" }] }), + })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, adapterName: "openai-chat" })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, alreadyAttempted: true })).toBe(false); + }); +}); + +describe("opaque blob recovery through /v1/responses", () => { + test("rebuilds once without the rejected blob and preserves surrounding items", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 ? rejection() : success("resp-recovered"); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(), config(), logCtx); + expect(response.status).toBe(200); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(hasBlob(outbound[0]!)).toBe(true); + expect(hasBlob(outbound[1]!)).toBe(false); + const retriedInput = outbound[1]!.input as Array>; + expect(retriedInput[0]).toEqual(reasoningReplayInput()[0]); + expect(retriedInput[1]).toEqual({ + type: "reasoning", + content: [], + summary: [{ type: "summary_text", text: "prior reasoning" }], + }); + expect(retriedInput[2]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }); + expect(retriedInput[3]).toEqual(reasoningReplayInput()[3]); + expect(logCtx.activeAttempt?.sendCount).toBe(2); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + }); + + test("surfaces the second rejection after exactly one recovery attempt", async () => { + let upstreamCalls = 0; + globalThis.fetch = (async () => { + upstreamCalls += 1; + return rejection(); + }) as typeof fetch; + + const response = await handleResponses(request(), config(), { model: "", provider: "" }); + expect(response.status).toBe(400); + expect(await response.text()).toContain("invalid_encrypted_content"); + expect(upstreamCalls).toBe(2); + }); + + test("records a successful recovery so the next cross-route turn strips pre-flight", async () => { + const outbound = new Map>>([ + ["first", []], + ["second", []], + ]); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const provider = url.includes("first.example.test") ? "first" : "second"; + const body = JSON.parse(String(init?.body)) as Record; + outbound.get(provider)!.push(body); + if (hasBlob(body)) return rejection(XAI_DECODE_ERROR); + if (provider === "first") { + // Simulate an eviction during the extra round trip. The post-success record is what makes + // the following route change deterministic instead of cold again. + clearReasoningReplayCacheForTests(); + } + return success(`resp-${provider}`); + }) as typeof fetch; + + const first = await handleResponses(request("first"), config(), { model: "", provider: "" }); + expect(first.status).toBe(200); + await first.text(); + const second = await handleResponses(request("second"), config(), { model: "", provider: "" }); + expect(second.status).toBe(200); + await second.text(); + + expect(outbound.get("first")).toHaveLength(2); + expect(outbound.get("second")).toHaveLength(1); + expect(hasBlob(outbound.get("second")![0]!)).toBe(false); + }); +}); diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts index 2649feaa1b..8414287b01 100644 --- a/tests/usage-log.test.ts +++ b/tests/usage-log.test.ts @@ -138,6 +138,31 @@ describe("usage log", () => { expect(readUsageEntries()[0]?.attempts?.[0]?.recoveryKinds).toEqual(["empty-completion"]); }); + test("persists the opaque-blob rejection recovery kind on attempts", () => { + const entry: PersistedUsageEntry = { + requestId: "ocx-opaque-blob-kind", + timestamp: 1, + provider: "openai", + model: "gpt-5.6-sol", + status: 200, + durationMs: 4, + usageStatus: "reported", + attempts: [{ + ordinal: 1, + provider: "openai", + model: "gpt-5.6-sol", + adapter: "openai-responses", + status: 200, + durationMs: 4, + sendCount: 2, + recoveryKinds: ["opaque-blob-rejection", "opaque-blob-rejection"], + usageStatus: "reported", + }], + }; + appendUsageEntry(entry); + expect(readUsageEntries()[0]?.attempts?.[0]?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + }); + /** Build one minimal persisted-usage JSONL line for the given request id. */ const persistedLine = (requestId: string) => JSON.stringify({ requestId, From 32a803c4f32e191507e0d5c79c5ff9995a083589 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 19:58:58 -0700 Subject: [PATCH 14/17] fix(responses): compare serving identity for compaction blobs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scrubOcxCompactionItems` forwarded any non-`ocx1:` blob whenever the destination could decode native blobs. That is sound only if native blobs have a single minter, and they do not: xAI mints them as well, so an xAI-minted compaction blob replayed to an OpenAI-operated destination was forwarded verbatim and rejected. Reproduced against the live proxy on a thread whose serving identity had already changed and was known to have changed — the reasoning path stripped correctly while the compaction item sailed through: POST /v1/responses model=gpt-5.6-sol, thread last served by xai/grok-4.6 input: [{"type":"compaction","encrypted_content":}, ...] -> 400 invalid_encrypted_content "The encrypted content rmey...SQ== could not be verified." Reuse the signal the reasoning path already consumes rather than recomputing identity in the adapter: on a known mismatch a native blob degrades through the existing `compactionItemToText` note instead of being forwarded. With no known mismatch, behaviour is unchanged. This covers threads the process has served. A cold record — after a restart, TTL expiry or eviction — still forwards, which is a separate change. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 31 ++++++++++---- structure/04_transports-and-sidecars.md | 42 +++++++++++-------- tests/openai-responses-passthrough.test.ts | 48 ++++++++++++++++++---- tests/responses-compaction.test.ts | 31 +++++++++++++- 4 files changed, 115 insertions(+), 37 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 532b772813..3c1fcd49dd 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -245,15 +245,19 @@ function stripItemIdsWhenUnstored(body: unknown): unknown { * A compaction item carries an `encrypted_content` blob the client replays verbatim on every later * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are * transparent base64 rather than encryption, so no upstream can read them and they always become - * plain user messages. A foreign blob was minted by an OpenAI-operated backend: forwarding it to a - * different destination makes that upstream reject the turn ("Could not decode the compaction - * blob"), and because the item lives in the client transcript the rejection repeats on every later - * turn — including the compaction turn the proxy itself drives — leaving the session unable to - * recover. Off those destinations it degrades to the same note the bridged parser uses. + * plain user messages. Native blobs have multiple possible minters, so a destination's ability to + * decode its own blobs does not make a blob from a previous serving identity portable. On a known + * identity mismatch the blob degrades to the same note the bridged parser uses, even when the + * destination normally accepts native blobs. Without a known mismatch, the destination capability + * keeps the existing behavior. * * A bare `context_compaction` marker carries no blob and is forwarded untouched. */ -function scrubOcxCompactionItems(body: unknown, destinationDecodesNativeBlob: boolean): unknown { +function scrubOcxCompactionItems( + body: unknown, + destinationDecodesNativeBlob: boolean, + threadServingIdentityChanged: boolean, +): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; @@ -261,7 +265,11 @@ function scrubOcxCompactionItems(body: unknown, destinationDecodesNativeBlob: bo if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item; const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined; if (encrypted === undefined) return item; - if (decodeCompactionSummary(encrypted) === null && destinationDecodesNativeBlob) return item; + if ( + decodeCompactionSummary(encrypted) === null + && destinationDecodesNativeBlob + && !threadServingIdentityChanged + ) return item; changed = true; return { type: "message", @@ -1718,10 +1726,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody); } - const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody, destinationDecodesNativeCompactionBlob(provider)), { + const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; + const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( + outBody, + destinationDecodesNativeCompactionBlob(provider), + threadServingIdentityChanged, + ), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), - stripEncryptedContent: parsed._stripReasoningEncryptedContent === true, + stripEncryptedContent: threadServingIdentityChanged, }))))))); const finalBody = stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index fd49386687..b059805b0e 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -103,31 +103,37 @@ to GUI static serving. A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent -base64, so they always lower to plain user messages. A native blob is relayed only to destinations -known to decode them — the canonical ChatGPT forward surface, the official OpenAI API, or a provider -with the explicit `decodesNativeCompactionBlobs` capability. Forward auth alone is not evidence: -noncanonical forward providers receive no caller credentials and may point at any backend. On any -other routed destination the blob degrades to the same opaque note the bridged parser uses, because -forwarding it there fails the turn and the item outlives the failure in the client transcript, -repeating on every later turn including the compaction turn the proxy itself drives. With -`store: false`, request sanitization strips ids from every input item, including compact-wire items, -matching codex-rs (`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side -field backfill. +base64, so they always lower to plain user messages. A native blob is relayed only when there is no +known serving-identity mismatch and the destination is known to decode native blobs — the canonical +ChatGPT forward surface, the official OpenAI API, or a provider with the explicit +`decodesNativeCompactionBlobs` capability. The destination gate alone is insufficient because more +than one backend, including OpenAI and xAI, mints native blobs: a destination can decode its own blob +without being able to decode the previous backend's. The same serving-identity mismatch signal +therefore strips reasoning `encrypted_content` and degrades native compaction blobs through the +existing opaque-note path. When the thread has no recorded identity, the destination-only behavior +is deliberately unchanged. Forward auth alone is not evidence: noncanonical forward providers +receive no caller credentials and may point at any backend. On any other routed destination the blob +also degrades to the same opaque note the bridged parser uses, because forwarding it there fails the +turn and the item outlives the failure in the client transcript, repeating on every later turn +including the compaction turn the proxy itself drives. With `store: false`, request sanitization +strips ids from every input item, including compact-wire items, matching codex-rs +(`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side field backfill. [Decision Log] - 목적과 의도: Keep a session usable after its history crosses backends, instead of wedging it on a compaction blob the current upstream cannot decode. - 기존 구현 및 제약 조건: Compaction handling was binary — `ocx1:` envelopes were ours, everything - else was assumed to be OpenAI's and forwarded verbatim, with no record of which upstream minted a - blob. Response-side field backfill exempted only `compaction`, so its two sibling types received - synthesized ids the client then replayed. + else was treated as a native blob and gated only by the destination, even though multiple backends + mint mutually incompatible blobs. Response-side field backfill exempted only `compaction`, so its + two sibling types received synthesized ids the client then replayed. - 검토한 주요 대안: Tag every compaction item with its minting provider/credential/model identity; drop compaction items on any route change; gate relay on the destination that would decode them. -- 선택한 방식: Relay a native blob only to destinations that mint them and degrade it elsewhere, and - treat the compact wire family as one enumeration so id-bearing passes cannot diverge per type. -- 다른 대안 대신 이 방식을 선택한 이유: Full provenance tagging needs per-conversation state this - boundary does not have, while dropping on any change would discard compacted context that still - round-trips correctly; the destination test is decidable from the request alone. +- 선택한 방식: Reuse the thread's recorded serving identity to degrade native blobs after a known + route change; otherwise retain the destination capability gate, and treat the compact wire family + as one enumeration so id-bearing passes cannot diverge per type. +- 다른 대안 대신 이 방식을 선택한 이유: Full per-item provenance tagging is unnecessary when the + existing thread identity proves a route change, while dropping the item would silently discard + compacted context and widening unknown-identity behavior needs a separate decision. - 장점, 단점 및 영향: A cross-backend session degrades one compaction summary to a note instead of failing every later turn. A self-hosted OpenAI relay keeps its blobs only when explicitly opted in; other routed gateways see a note because routed compaction produces an `ocx1:` envelope. diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index bd03a46616..0e969c8a1b 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -2605,13 +2605,18 @@ describe("replayed compaction blobs", () => { decodesNativeCompactionBlobs: true, }; - function forwardedInput(target: PassthroughProvider, input: unknown[]): Record[] { + function forwardedInput( + target: PassthroughProvider, + input: unknown[], + threadServingIdentityChanged = false, + ): Record[] { const request = createResponsesPassthroughAdapter(target).buildRequest({ modelId: "grok-4.6", context: { messages: [] }, stream: true, options: {}, _rawBody: { model: "grok-4.6", store: false, input }, + ...(threadServingIdentityChanged ? { _stripReasoningEncryptedContent: true } : {}), }, { headers: new Headers({ authorization: "Bearer token" }) }); return (JSON.parse(request.body) as { input: Record[] }).input; } @@ -2642,23 +2647,48 @@ describe("replayed compaction blobs", () => { } }); + test("a known serving-identity change overrides the native-blob destination gate", () => { + const before = { type: "message", role: "user", content: [{ type: "input_text", text: "before" }] }; + const after = { type: "message", role: "user", content: [{ type: "input_text", text: "after" }] }; + + expect(forwardedInput(openaiKeyedProvider, [ + before, + { type: "compaction", encrypted_content: NATIVE_BLOB }, + after, + ], true)).toEqual([ + before, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }, + after, + ]); + }); + // The proxy's own envelope is transparent base64, so no upstream can read it anywhere. test("lowers proxy-minted ocx1 envelopes on every destination", () => { const item = { type: "compaction", encrypted_content: encodeCompactionSummary("prior work") }; for (const target of [provider, openaiKeyedProvider, routedProvider]) { - expect(forwardedInput(target, [item])[0]).toEqual({ - type: "message", - role: "user", - content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\nprior work` }], - }); + for (const threadServingIdentityChanged of [false, true]) { + expect(forwardedInput(target, [item], threadServingIdentityChanged)[0]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\nprior work` }], + }); + } } }); // A bare marker carries no blob, so there is nothing to mis-route. - test("leaves a bare context_compaction marker alone", () => { - const item = { type: "context_compaction" }; + test("leaves compaction items without encrypted_content alone", () => { for (const target of [provider, routedProvider]) { - expect(forwardedInput(target, [item])[0]).toEqual(item); + for (const type of ["compaction", "context_compaction"]) { + for (const threadServingIdentityChanged of [false, true]) { + const item = { type }; + expect(forwardedInput(target, [item], threadServingIdentityChanged)[0]).toEqual(item); + } + } } }); }); diff --git a/tests/responses-compaction.test.ts b/tests/responses-compaction.test.ts index 457cb1832f..5cc02d2e56 100644 --- a/tests/responses-compaction.test.ts +++ b/tests/responses-compaction.test.ts @@ -182,10 +182,16 @@ describe("forward-path ocx1 compaction scrub", () => { function forwardedBody( rawBody: Record, target = provider, + threadServingIdentityChanged = false, ): { input: Array> } { const adapter = createResponsesPassthroughAdapter(target as never); const request = adapter.buildRequest({ - modelId: "gpt-5.5", context: { messages: [] }, stream: true, options: {}, _rawBody: rawBody, + modelId: "gpt-5.5", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + ...(threadServingIdentityChanged ? { _stripReasoningEncryptedContent: true } : {}), }, { headers: new Headers() }); return JSON.parse(request.body as string) as { input: Array> }; } @@ -227,6 +233,29 @@ describe("forward-path ocx1 compaction scrub", () => { expect(body.input[0].encrypted_content).toBe("gAAAAA-real-openai-blob"); }); + test("known serving-identity changes degrade native blobs before OpenAI forwarding", () => { + const before = { type: "message", role: "user", content: [{ type: "input_text", text: "before" }] }; + const after = { type: "message", role: "user", content: [{ type: "input_text", text: "after" }] }; + const body = forwardedBody({ + model: "gpt-5.5", + input: [ + before, + { type: "compaction", encrypted_content: "xai-native-compaction-blob" }, + after, + ], + }, { ...provider, baseUrl: CODEX_FORWARD_BASE_URL }, true); + + expect(body.input).toEqual([ + before, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }, + after, + ]); + }); + test("noncanonical forward providers degrade OpenAI-encrypted compaction items", () => { const body = forwardedBody({ model: "gpt-5.5", From 400541884e368c51e24fd3c0211f817a939cd147 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 20:21:41 -0700 Subject: [PATCH 15/17] fix(responses): strip output-only reasoning status unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-backend fix kept `status` on any reasoning item that forwarded its `encrypted_content`, to honour "an item whose blob is forwarded is not otherwise modified". That invariant was defensive rather than observed, and it broke the cold-record recovery path. With no provenance record — after a restart, TTL expiry or eviction — the blob is retained, so `status` is retained too, and OpenAI rejects the request on the field before it ever validates the blob: 400 Unknown parameter: 'input[1].status'. The opaque-blob recovery correctly does not match that error, so the conversation stayed broken. Measured against the live backends: - OpenAI never mints `status` on a reasoning item (keys are content, encrypted_content, id, summary, type), so the retain branch could only ever fire for an item minted elsewhere — the exact item OpenAI then rejects. It never protected an OpenAI-minted item. - Grok accepts its own 1707-char blob with `status` removed: 200. - With `status` removed, that same item replayed to gpt-5.6-sol returns 200 and the usage log records sendCount=2, recoveryKinds=['opaque-blob-rejection'] — removing the field is what lets the request reach the blob check the recovery is armed for. The `content` rule is untouched: blanking predates this and is required by ChatGPT's input contract. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 18 +++++------ structure/04_transports-and-sidecars.md | 36 ++++++++++++---------- tests/deepseek-reasoning-replay.test.ts | 13 ++++++++ tests/openai-responses-passthrough.test.ts | 11 +++++-- 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 3c1fcd49dd..c5405b1c5a 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -70,7 +70,6 @@ export function sanitizeReasoningInputContent( const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content"); const stripEncryptedContent = hasOcxEnvelope || (opts?.stripEncryptedContent === true && hasEncryptedContent); - const retainsEncryptedContent = hasEncryptedContent && !stripEncryptedContent; // Codex serializes an absent reasoning content channel as `"content": null`. The field is // optional and null carries nothing, but a strict gateway rejects the item on its declared type // — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content` @@ -80,17 +79,16 @@ export function sanitizeReasoningInputContent( // Gated to routed destinations. An OpenAI-operated backend binds the blob to the item's exact // shape, so deleting a field there invalidates it (`The encrypted content ... could not be // verified`); the two requirements are exactly opposed, and a live regression proved it. That - // gate is also why this drop may touch an item that keeps its blob, which the status invariant - // below forbids: xAI demonstrably accepts its own blob without the null channel, and the - // destinations that bind blobs to item shape never reach this branch. + // gate is also why this drop may touch an item that keeps its blob: xAI demonstrably accepts its + // own blob without the null channel, and the destinations that bind blobs to item shape never + // reach this branch. This is independent of the output-only status removal below. const dropNullContentChannel = opts?.dropNullContentChannel === true && "content" in rec && !Array.isArray(rec.content); - // 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; + // `status` is output-only. Measured OpenAI reasoning items never contain it, and Grok accepts + // its own encrypted_content with status removed. Keeping a foreign status beside a retained + // blob makes OpenAI reject the field before blob validation, starving the provenance recovery + // of the opaque-blob error it needs. Content blanking remains the separate pre-existing rule. + const stripOutputStatus = hasOutputStatus; const blankContent = !dropNullContentChannel && !opts?.preserveRawReasoningContent && (hasRawContent || hasOcxEnvelope); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 4938dfe55c..70c8c0b37b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -549,20 +549,22 @@ 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 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. This deterministic pre-flight is the primary path and covers threads the process -has served while their record remains inside the TTL/LRU bounds. Missing, expired, evicted, and +Responses passthrough always removes output-only `status` from `reasoning` input items, including +items that retain opaque `encrypted_content`. The prior retains-blob-keeps-status invariant was +defensive rather than observed: measured OpenAI reasoning items never contain `status`, and Grok +accepts its own blob with `status` removed. Keeping it on a cold cross-backend replay instead made +OpenAI reject the unknown field before validating the blob, starving opaque-blob recovery of the +provenance error it needs. 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 but still +blanks `content`. 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 is removed while the reasoning item and +its summary survive; `status` has already been removed on every path. Missing, expired, or evicted +identity 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. This deterministic pre-flight is the primary path and covers threads the +process has served while their record remains inside the TTL/LRU bounds. Missing, expired, evicted, and pre-process history stays fail-soft on the first send. If a Responses upstream then returns its own self-identifying opaque-blob 4xx (`invalid_encrypted_content`, or xAI's two `invalid-argument` decoder errors), the proxy rebuilds once through the same sanitation path: reasoning @@ -589,9 +591,9 @@ conversation's last-serving identity and cause a later main-model turn to strip - 목적과 의도: 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, trust generic 4xx prose, rely only on a retry, or combine deterministic route comparison with a narrowly identified recovery. -- 선택한 방식: 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, pass a proven change into the Responses adapter before the first send, and use one self-identified opaque-blob recovery only when provenance was unknown. -- 다른 대안 대신 이 방식을 선택한 이유: Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and deterministic pre-flight avoids the extra paid or stateful upstream attempt whenever the process has evidence. The upstream's narrow error identity supplies authoritative evidence only for histories the process could not observe. -- 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning on the first send, known cross-route replay keeps the reasoning item without its undecodable blob, and a cold cross-route replay recovers with one extra round trip and one degraded-reasoning turn. A repeated rejection is surfaced unchanged after exactly one recovery attempt. +- 선택한 방식: Remove output-only `status` from every reasoning input item without changing the pre-existing raw-`content` blanking rule; compare and update a 64-entry/256 KiB/one-hour in-process serving-identity record using durable destination and credential dimensions at request time, pass a proven change into the Responses adapter before the first send, and use one self-identified opaque-blob recovery only when provenance was unknown. +- 다른 대안 대신 이 방식을 선택한 이유: The former blob/status coupling was defensive rather than observed, and live backends showed that removing `status` preserves same-backend Grok replay while allowing cold cross-backend requests to reach blob validation. Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and deterministic pre-flight avoids the extra paid or stateful upstream attempt whenever the process has evidence. The upstream's narrow error identity supplies authoritative evidence only for histories the process could not observe. +- 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning on the first send without replaying an output-only field, known cross-route replay keeps the reasoning item without its undecodable blob, and a cold cross-route replay can reach opaque-blob recovery instead of failing early on `status`. A repeated blob rejection is surfaced unchanged after exactly one recovery attempt. 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 diff --git a/tests/deepseek-reasoning-replay.test.ts b/tests/deepseek-reasoning-replay.test.ts index f15b53becd..b67b2c1798 100644 --- a/tests/deepseek-reasoning-replay.test.ts +++ b/tests/deepseek-reasoning-replay.test.ts @@ -29,6 +29,19 @@ function inputOf(result: unknown): Record[] { } describe("sanitizeReasoningInputContent scoping", () => { + test("retains native encrypted content while stripping output-only status", () => { + const out = inputOf(sanitizeReasoningInputContent({ + model: "m", + input: [reasoningItem({ encrypted_content: "native-blob", status: "completed" })], + })); + expect(out[0]).toEqual({ + type: "reasoning", + id: "rs_1", + content: [], + encrypted_content: "native-blob", + }); + }); + test("default behavior still blanks reasoning content (ChatGPT backend rule)", () => { const out = inputOf(sanitizeReasoningInputContent({ model: "m", input: [reasoningItem()] })); expect(out[0]!.content).toEqual([]); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 0e969c8a1b..61c40c66b5 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -781,7 +781,7 @@ describe("OpenAI Responses passthrough sanitization", () => { }); }); - test("keeps a blob-bearing reasoning item byte-identical when the route is unchanged", () => { + test("keeps a blob-bearing reasoning item but strips output-only status when the route is unchanged", () => { const adapter = createResponsesPassthroughAdapter(provider); const reasoningItem = { type: "reasoning", @@ -804,7 +804,13 @@ describe("OpenAI Responses passthrough sanitization", () => { }, { 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)); + expect(body.input[0]).toEqual({ + type: "reasoning", + id: "rs_same_backend", + summary: [{ type: "summary_text", text: "summary" }], + encrypted_content: "backend-minted-blob", + content: [], + }); }); test("keeps a native blob while blanking its raw reasoning content", () => { @@ -829,7 +835,6 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.input[0]).toEqual({ type: "reasoning", - status: "completed", summary: [], encrypted_content: "native-backend-blob", content: [], From 28fceecb535bcd5b71a0f8696b86517387a02e7a Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 20:46:02 -0700 Subject: [PATCH 16/17] refactor(responses): converge the two opaque-blob recovery call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two units landed separately and left duplication. The recovery unit was written on a branch without the compaction-provenance change, so it degraded compaction items itself by rewriting `parsed._rawBody.input` in place. Once both are merged that walk is redundant: it sets `_stripReasoningEncryptedContent`, which is exactly the signal the adapter's own compaction scrub consumes. Verified rather than assumed, since the two call sites rebuild through different adapters. Both reach `openai-responses` (the recovery predicate restricts to it), whose `buildRequest` consumes `_rawBody` and runs `scrubOcxCompactionItems`; the native passthrough site resolves a passthrough retry adapter, the generic site rebuilds through the retained `activeAdapter`. So the manual walk changes no outbound body on either path, and dropping it removes a mutation whose side effect outlived the request. The native Responses branch returns before the generic `recovery:` loop, so the recovery block was also written out twice. Whoever next adds a recovery kind to the generic loop would not know a second loop exists. Extract the shared predicate, guard, preparation, body cancellation and rebuild into one `attemptOpaqueBlobRecovery` helper both sites call, each keeping its own control flow and its site-specific rebuild — the generic one still invalidates the same-target request. Cross-reference comments on both loops name the other. No outbound behaviour changes. Existing recovery tests are untouched; added coverage for routed compaction recovery through the generic loop. Co-Authored-By: Claude Fable 5 --- src/server/responses/core.ts | 137 ++++++++++--------- tests/responses-opaque-blob-recovery.test.ts | 37 +++++ 2 files changed, 110 insertions(+), 64 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e72fbb5214..0199203755 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -25,7 +25,7 @@ import { updateReasoningReplayServingIdentity, } from "../../responses/reasoning-replay-cache"; import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; -import { buildCompactV1Output, COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, extractCompactUserMessages, isCompactionItemType } from "../../responses/compaction"; +import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { copyPreviousResponseReplayProvenance, @@ -611,27 +611,50 @@ async function opaqueBlobRejectionBodyForRecovery( function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void { parsed._stripReasoningEncryptedContent = true; - const body = parsed._rawBody; - if (!body || typeof body !== "object" || Array.isArray(body)) return; - const input = (body as { input?: unknown }).input; - if (!Array.isArray(input)) return; - let changed = false; - const recoveredInput = input.map(item => { - if (!item || typeof item !== "object" || Array.isArray(item)) return item; - const candidate = item as { type?: unknown; encrypted_content?: unknown }; - if (!isCompactionItemType(candidate.type) || typeof candidate.encrypted_content !== "string") { - return item; - } - changed = true; - return { - type: "message", - role: "user", - content: [{ type: "input_text", text: compactionItemToText(candidate.encrypted_content) }], - }; - }); - // Mutate only the raw input carrier so the adapter rebuild sees the same degradation as its - // ordinary compaction scrub. Keeping the body object preserves non-enumerable persistence marks. - if (changed) (body as { input: unknown[] }).input = recoveredInput; +} + +type OpaqueBlobRecoveryGuard = { attempted: boolean }; + +type OpaqueBlobRecoveryResult = + | { kind: "skipped" } + | { kind: "recovered"; response: Response } + | { kind: "failed"; response: Response }; + +async function attemptOpaqueBlobRecovery( + args: { + response: Response; + outboundBody?: string; + adapterName: string; + parsed: OcxParsedRequest; + guard: OpaqueBlobRecoveryGuard; + signal: AbortSignal; + }, + rebuild: (kind: AttemptRecoveryKind) => Promise, +): Promise { + const errorBody = await opaqueBlobRejectionBodyForRecovery( + args.response, + args.outboundBody, + args.adapterName, + args.guard.attempted, + args.signal, + ); + if (errorBody === undefined || !shouldAttemptOpaqueBlobRecovery({ + status: args.response.status, + adapterName: args.adapterName, + outboundBody: args.outboundBody, + errorBody, + alreadyAttempted: args.guard.attempted, + })) { + return { kind: "skipped" }; + } + + args.guard.attempted = true; + prepareOpaqueBlobRecovery(args.parsed); + try { void args.response.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuild("opaque-blob-rejection"); + return "failed" in result + ? { kind: "failed", response: result.failed } + : { kind: "recovered", response: result }; } function nonEmptyProviderApiKey(provider: OcxProviderConfig): string | undefined { @@ -3002,7 +3025,7 @@ async function handleResponsesInner( request.releaseBodyObservation?.(); } - let opaqueBlobRecoveryAttempted = false; + const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; let oauth401ReplayAttempted = false; const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider); let rateLimitRetries = 0; @@ -3070,6 +3093,7 @@ async function handleResponsesInner( } }; + // Keep recovery kinds in sync with the generic `recovery:` loop below. passthroughRecovery: for (;;) { // Native Responses providers return before the generic adapter recovery loop below. Keep @@ -3303,33 +3327,24 @@ async function handleResponsesInner( // eviction, or an older transcript). Inspect only a bounded clone of a 4xx whose exact outbound // Responses body still carries opaque state, then rebuild once through the ordinary adapter // sanitation path. A second rejection falls through unchanged because the guard stays armed. - const opaqueBlobErrorBody = await opaqueBlobRejectionBodyForRecovery( - upstreamResponse, - request.body, - adapter.name, - opaqueBlobRecoveryAttempted, - upstream.signal, - ); - if (opaqueBlobErrorBody !== undefined && shouldAttemptOpaqueBlobRecovery({ - status: upstreamResponse.status, - adapterName: adapter.name, + const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({ + response: upstreamResponse, outboundBody: request.body, - errorBody: opaqueBlobErrorBody, - alreadyAttempted: opaqueBlobRecoveryAttempted, - })) { - opaqueBlobRecoveryAttempted = true; - prepareOpaqueBlobRecovery(parsed); - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - const result = await rebuildAndRefetch("opaque-blob-rejection"); - if ("failed" in result) return result.failed; - upstreamResponse = result; + adapterName: adapter.name, + parsed, + guard: opaqueBlobRecoveryGuard, + signal: upstream.signal, + }, rebuildAndRefetch); + if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response; + if (opaqueBlobRecovery.kind === "recovered") { + upstreamResponse = opaqueBlobRecovery.response; continue passthroughRecovery; } break; } // Binding normally records before the first send. Repeat it only after a successful recovery // so an eviction during the extra round trip cannot leave the next cross-route turn cold. - if (opaqueBlobRecoveryAttempted && upstreamResponse.ok) { + if (opaqueBlobRecoveryGuard.attempted && upstreamResponse.ok) { updateReasoningReplayServingIdentity(parsed._reasoningReplayScope); } const headers = sanitizePassthroughHeaders(upstreamResponse.headers); @@ -4401,7 +4416,7 @@ async function handleResponsesInner( // adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a // 413→429 rotation cannot silently undo the tightening. let imageRetryAttempted = false; - let opaqueBlobRecoveryAttempted = false; + const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; let oauth401ReplayAttempted = false; /** * Rebuild the request from the current parsed input (and any image-tier bias) and refetch @@ -4479,6 +4494,7 @@ async function handleResponsesInner( return { failed: formatErrorResponse(502, "upstream_error", msg) }; } }; + // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above. recovery: for (;;) { if ( upstreamResponse.status === 401 @@ -4639,27 +4655,20 @@ async function handleResponsesInner( // the missing authoritative signal. Rebuild once through the same sanitation path used by a // known route switch; invalidating is mandatory because `parsed` mutates in place and the // same-target cache would otherwise replay the rejected bytes verbatim. - const opaqueBlobErrorBody = await opaqueBlobRejectionBodyForRecovery( - upstreamResponse, - sameTargetRequest?.body, - activeAdapter.name, - opaqueBlobRecoveryAttempted, - upstream.signal, - ); - if (opaqueBlobErrorBody !== undefined && shouldAttemptOpaqueBlobRecovery({ - status: upstreamResponse.status, - adapterName: activeAdapter.name, + const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({ + response: upstreamResponse, outboundBody: sameTargetRequest?.body, - errorBody: opaqueBlobErrorBody, - alreadyAttempted: opaqueBlobRecoveryAttempted, - })) { - opaqueBlobRecoveryAttempted = true; - prepareOpaqueBlobRecovery(parsed); + adapterName: activeAdapter.name, + parsed, + guard: opaqueBlobRecoveryGuard, + signal: upstream.signal, + }, recovery => { invalidateSameTargetRequest(); - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } - const result = await rebuildAndRefetch("opaque-blob-rejection"); - if ("failed" in result) return result.failed; - upstreamResponse = result; + return rebuildAndRefetch(recovery); + }); + if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response; + if (opaqueBlobRecovery.kind === "recovered") { + upstreamResponse = opaqueBlobRecovery.response; continue recovery; } // Anthropic 413 request_too_large: rebuild once with every image one tier lower @@ -4683,7 +4692,7 @@ async function handleResponsesInner( } // Binding normally records before the first send. Repeat it only after a successful recovery // so an eviction during the extra round trip cannot leave the next cross-route turn cold. - if (opaqueBlobRecoveryAttempted && upstreamResponse.ok) { + if (opaqueBlobRecoveryGuard.attempted && upstreamResponse.ok) { updateReasoningReplayServingIdentity(parsed._reasoningReplayScope); } if (!upstreamResponse.ok) { diff --git a/tests/responses-opaque-blob-recovery.test.ts b/tests/responses-opaque-blob-recovery.test.ts index 4e7be9b77e..77d0a6d547 100644 --- a/tests/responses-opaque-blob-recovery.test.ts +++ b/tests/responses-opaque-blob-recovery.test.ts @@ -202,6 +202,43 @@ describe("opaque blob recovery through /v1/responses", () => { expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); }); + test("degrades a compaction blob through the generic routed-compaction recovery resend", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 ? rejection(XAI_DECODE_ERROR) : success("resp-compact-recovered"); + }) as typeof fetch; + const body = { + model: "first/model-a", + stream: false, + store: false, + input: [...reasoningReplayInput(), { type: "compaction_trigger" }], + }; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-codex-parent-thread-id": "thread-routed-compaction-recovery", + }, + body: JSON.stringify(body), + }), config(), logCtx); + expect(response.status).toBe(200); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(hasBlob(outbound[0]!)).toBe(true); + expect(hasBlob(outbound[1]!)).toBe(false); + expect(outbound[1]!.input).toContainEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }); + expect(logCtx.activeAttempt?.sendCount).toBe(2); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + }); + test("surfaces the second rejection after exactly one recovery attempt", async () => { let upstreamCalls = 0; globalThis.fetch = (async () => { From e0c8912574de715aecbc0fb67f3ec868fb7d8e5e Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 20:55:38 -0700 Subject: [PATCH 17/17] test(responses): pin that the first send already drops reasoning status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery suite asserted the resend well but said nothing about the first outbound send beyond "it carries a blob". That the first send has `status` already stripped is load-bearing: the recovery is armed for the upstream's blob-rejection error, and if `status` survives, OpenAI answers 400 Unknown parameter: 'input[1].status'. before it validates the blob. The recovery correctly does not match that error, so it never fires. That exact regression shipped once — `stripOutputStatus` was gated on the item not forwarding its `encrypted_content`, which is precisely the cold provenance case — and the entire suite stayed green while the live path was unchanged. Assert the first send's reasoning item by shape: blob present, no `status`. Verified the guard bites: reintroducing the old condition turns this test red, where before it left the suite green. Co-Authored-By: Claude Fable 5 --- tests/responses-opaque-blob-recovery.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/responses-opaque-blob-recovery.test.ts b/tests/responses-opaque-blob-recovery.test.ts index 77d0a6d547..b547d322a3 100644 --- a/tests/responses-opaque-blob-recovery.test.ts +++ b/tests/responses-opaque-blob-recovery.test.ts @@ -184,6 +184,14 @@ describe("opaque blob recovery through /v1/responses", () => { expect(outbound).toHaveLength(2); expect(hasBlob(outbound[0]!)).toBe(true); + const initialInput = outbound[0]!.input as Array>; + // Guard the regression where status made upstream reject before checking the blob, so recovery never ran. + expect(initialInput[1]).toEqual({ + type: "reasoning", + content: [], + summary: [{ type: "summary_text", text: "prior reasoning" }], + encrypted_content: BLOB, + }); expect(hasBlob(outbound[1]!)).toBe(false); const retriedInput = outbound[1]!.input as Array>; expect(retriedInput[0]).toEqual(reasoningReplayInput()[0]);