Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) }],
};
});

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/providers/openai-tiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/responses/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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");
}
Expand Down
4 changes: 2 additions & 2 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 7 additions & 11 deletions src/server/responses/responses-field-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
sseDataPayload,
type SseBlockRewrite,
} from "../sse-payload-rewrite";
import { isCompactionItemType } from "../../responses/compaction";

function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
Expand Down Expand Up @@ -119,24 +120,19 @@ 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<string> = 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.
* Returns the same object reference if nothing changed.
*/
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);
Expand Down
5 changes: 5 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -2135,6 +2141,96 @@ describe("OpenAI Responses forward-mode unsupported param stripping", () => {
});
});

describe("replayed compaction blobs", () => {
type PassthroughProvider = Parameters<typeof createResponsesPassthroughAdapter>[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<string, unknown>[] {
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<string, unknown>[] }).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(
Expand Down
Loading
Loading