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
25 changes: 22 additions & 3 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>;
Expand All @@ -56,6 +56,22 @@ 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.
//
// 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<string, unknown> = { ...rec };
delete next.content;
if (hasOcxEnvelope) delete next.encrypted_content;
return next;
}
if (!hasRawContent && !hasOcxEnvelope) return item;
if (hasOcxEnvelope) {
changed = true;
Expand Down Expand Up @@ -1572,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,
Expand Down
14 changes: 14 additions & 0 deletions src/providers/openai-tiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
92 changes: 92 additions & 0 deletions tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2145,3 +2145,95 @@ 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<string, unknown>): Record<string, unknown> {
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<string, unknown>[] }).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" }]);
});

// 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<string, unknown>[] }).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<string, unknown>[] }).input[0];
expect(out).not.toHaveProperty("content");
});

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");
});
});
Loading