From d8426a6a5ada8659524c2d7b692904bc2f57af1d Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:03:50 -0700 Subject: [PATCH 1/2] 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 b85252b142..4e8af679f2 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 6e86b181b3236a016c32820d540995675f541fd2 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:24:07 -0700 Subject: [PATCH 2/2] 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 4e8af679f2..1d3fdfc349 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; @@ -1584,7 +1588,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; } - const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); + const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { + preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, + 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",