From add8a55f26ef06af3e9f83e134a2920df03b78ff Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 15:05:48 -0700 Subject: [PATCH 1/2] 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 b85252b142..6b901c11c4 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) }], }; }); @@ -1572,7 +1580,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; } - const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); + const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody, 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 efd1c87dbb..40caaf912e 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 abc2b9f4f00b04890f9e2b83611e289767da9866 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:36:52 -0700 Subject: [PATCH 2/2] 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 72fbc10033..20ce2c0330 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 40caaf912e..d3c23ecff0 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)", () => {