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/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 f1156cb447..59e01c417f 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -54,6 +54,24 @@ 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. `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. + * + * 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 { + return isCanonicalOpenAiForwardProvider(provider) + || (provider.adapter === "openai-responses" + && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL) + || provider.decodesNativeCompactionBlobs === true; +} + 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/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 efd1c87dbb..d3c23ecff0 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -70,6 +70,37 @@ 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 +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 + 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 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 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..b6d4224a73 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,96 @@ 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 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({ + 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 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 destinations known to decode it", () => { + const item = { type: "compaction", encrypted_content: NATIVE_BLOB }; + for (const target of [provider, openaiKeyedProvider, optedInRelayProvider]) { + 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-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)", () => { 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"); + } }); });