Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ collision-safe public function tool. Matching request history and JSON/SSE funct
translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward
keeps the native private type unchanged.

The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that
its stricter backend rejects: fully textual `system` messages inside `input` are appended to the
top-level `instructions` string in request order, and the top-level `truncation` field is removed.
This rewrite is destination-scoped. Key-auth public/custom Responses providers and noncanonical
forward gateways keep both fields unchanged; a multimodal system message is never partially folded
or silently dropped.

For `key` auth, [`retryOn429`](/reference/configuration/) applies here too: a pre-stream 429
waits and replays the identical request on the same key before any other handling, exactly like
the translated `openai-chat` / Anthropic request path. Custom `runTurn` transports are not part
Expand Down
3 changes: 3 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ non-empty `model`. `input` may be a string or an array of Responses items.

Unknown item types are accepted as loose typed items for forward compatibility. Translated adapters
handle only the item types they recognize, and may reject a feature their provider cannot represent.
On the canonical ChatGPT Codex forward route, text-only `system` input messages are folded into
top-level `instructions`, and `truncation` is removed because that destination rejects both public
Responses shapes. Other Responses destinations preserve them.

### JSON and SSE output

Expand Down
64 changes: 64 additions & 0 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,69 @@ function stripUnsupportedForwardParams(body: unknown): unknown {
return rest;
}

/** Return the lossless text represented by one system message, or null when it is multimodal. */
function canonicalForwardSystemText(item: Record<string, unknown>): string | null {
const content = item.content;
if (content === undefined) return "";
if (typeof content === "string") return content;
if (!Array.isArray(content)) return null;
let text = "";
for (const block of content) {
if (!isPlainObject(block)) return null;
if (block.type !== "input_text" && block.type !== "text") return null;
if (typeof block.text !== "string") return null;
text += block.text;
}
return text;
}

/**
* The public Responses API accepts input system messages and `truncation`, but the canonical
* ChatGPT Codex forward endpoint rejects both. Fold only fully textual system messages into the
* existing top-level instructions and remove the unsupported flag at this destination boundary.
*
* The fold is atomic: if any system message contains a non-text block, keep every message in
* place so the proxy never silently drops multimodal content. The backend may still reject that
* unsupported shape, but it will not receive a partially rewritten prompt.
*/
function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown {
if (!isPlainObject(body)) return body;
const stripTruncation = Object.hasOwn(body, "truncation");
const input = Array.isArray(body.input) ? body.input : undefined;
if (!input) {
if (!stripTruncation) return body;
const { truncation: _truncation, ...rest } = body;
return rest;
}

const foldedText: string[] = [];
let sawSystemMessage = false;
let canFoldAllSystemMessages = true;
for (const item of input) {
if (!isPlainObject(item) || item.role !== "system") continue;
sawSystemMessage = true;
const text = canonicalForwardSystemText(item);
if (text === null) {
canFoldAllSystemMessages = false;
break;
}
foldedText.push(text);
}
if (!stripTruncation && (!sawSystemMessage || !canFoldAllSystemMessages)) return body;

const next: Record<string, unknown> = { ...body };
if (stripTruncation) delete next.truncation;
if (sawSystemMessage && canFoldAllSystemMessages) {
next.input = input.filter(item => !isPlainObject(item) || item.role !== "system");
const folded = foldedText.join("\n\n");
if (folded !== "") {
const existing = typeof body.instructions === "string" ? body.instructions : "";
next.instructions = existing !== "" ? `${existing}\n\n${folded}` : folded;
}
}
return next;
}

const IMAGE_GEN_NAMESPACE = "image_gen";
const HOSTED_IMAGE_GENERATION_TOOL = "image_generation";
const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`;
Expand Down Expand Up @@ -1794,6 +1857,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
// third-party forward gateway may still accept it, so this must not be widened.
if (isCanonicalOpenAiForwardProvider(provider)) {
outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId);
outBody = normalizeCanonicalForwardPromptEnvelope(outBody);
}
} else {
outBody = preferConfiguredHostedTools(
Expand Down
18 changes: 17 additions & 1 deletion src/compatibility/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const FIXTURE_ID = "openai-codex-forward-gpt56-sol-v1";
export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManifest({
schemaVersion: 1,
id: "openai.codex-forward.gpt-5-6-sol.responses",
version: "1.0.0",
version: "1.1.0",
subject: {
providerId: "openai",
baseUrl: "https://chatgpt.com/backend-api/codex",
Expand Down Expand Up @@ -69,6 +69,22 @@ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManife
limitation: "The field is removed before dispatch.",
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["metadata-removed"] }],
},
{
id: "system-input-messages",
feature: "request.input.system_messages",
disposition: "translated",
summary: "Text-only input system messages are folded into top-level instructions.",
limitation: "The destination does not accept system-role input items; multimodal system messages stay unchanged rather than being dropped.",
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["system-message-folded", "system-message-removed"] }],
},
{
id: "truncation",
feature: "request.truncation",
disposition: "unsupported",
summary: "The ChatGPT Codex forward route does not receive truncation.",
limitation: "The field is removed only for the canonical forward destination; public and custom Responses providers keep it.",
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["truncation-removed"] }],
},
{
id: "prompt-cache-retention",
feature: "request.prompt_cache_retention",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,23 @@
},
"request": {
"model": "gpt-5.6-sol",
"input": "ping",
"input": [
{
"type": "message",
"role": "system",
"content": [{ "type": "input_text", "text": "Fixture system instruction" }]
},
{
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": "ping" }]
}
],
"instructions": "Fixture instructions",
"previous_response_id": "resp_fixture",
"stream": true,
"store": false,
"truncation": "disabled",
"max_output_tokens": 32000,
"metadata": { "fixture": "not-forwarded" },
"reasoning": { "effort": "low" },
Expand Down Expand Up @@ -54,6 +67,18 @@
},
{ "id": "max-output-tokens-removed", "operator": "absent", "path": "/body/max_output_tokens" },
{ "id": "metadata-removed", "operator": "absent", "path": "/body/metadata" },
{ "id": "system-message-folded", "operator": "equals", "path": "/body/instructions", "expected": "Fixture instructions\n\nFixture system instruction" },
{
"id": "system-message-removed",
"operator": "equals",
"path": "/body/input/0",
"expected": {
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": "ping" }]
}
},
{ "id": "truncation-removed", "operator": "absent", "path": "/body/truncation" },
{ "id": "previous-response-id-removed", "operator": "absent", "path": "/body/previous_response_id" },
{ "id": "prompt-cache-key-preserved", "operator": "equals", "path": "/body/prompt_cache_key", "expected": "project-cache-v1" },
{ "id": "prompt-cache-retention-removed", "operator": "absent", "path": "/body/prompt_cache_retention" }
Expand Down
120 changes: 120 additions & 0 deletions tests/responses-forward-prompt-envelope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, test } from "bun:test";
import { createResponsesPassthroughAdapter as createProductionAdapter } from "../src/adapters/openai-responses";
import type { OcxProviderConfig } from "../src/types";
import { withTestTranslatorBudget } from "./helpers/translator-budget";

const createAdapter = (provider: OcxProviderConfig) =>
withTestTranslatorBudget(createProductionAdapter(provider));

const canonicalForward: OcxProviderConfig = {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
};

function outboundBody(provider: OcxProviderConfig, rawBody: Record<string, unknown>): Record<string, unknown> {
const adapter = createAdapter(provider);
const request = adapter.buildRequest({
modelId: String(rawBody.model ?? "gpt-5.6-luna"),
context: { messages: [] },
stream: rawBody.stream === true,
options: {},
_rawBody: rawBody,
}, { headers: new Headers({ authorization: "Bearer test-token" }) });
try {
return JSON.parse(request.body) as Record<string, unknown>;
} finally {
request.releaseBodyObservation?.();
}
}

describe("canonical ChatGPT forward prompt envelope", () => {
test("folds textual system messages after existing instructions and strips truncation", () => {
const functionCall = {
type: "function_call",
call_id: "call_keep",
name: "shell",
arguments: "{}",
};
const body = outboundBody(canonicalForward, {
model: "gpt-5.6-luna",
instructions: "Existing instructions",
truncation: "disabled",
input: [
{ type: "message", role: "system", content: "First system instruction" },
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
{
type: "message",
role: "system",
content: [
{ type: "input_text", text: "Second" },
{ type: "text", text: " system instruction" },
],
},
functionCall,
],
});

expect(body.truncation).toBeUndefined();
expect(body.instructions).toBe(
"Existing instructions\n\nFirst system instruction\n\nSecond system instruction",
);
expect(body.input).toEqual([
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
functionCall,
]);
});

test("keeps every system message when any one contains non-text content", () => {
const input = [
{ type: "message", role: "system", content: "text" },
{
type: "message",
role: "system",
content: [{ type: "input_image", image_url: "data:image/png;base64,AA==" }],
},
{ type: "message", role: "user", content: "hello" },
];
const body = outboundBody(canonicalForward, {
model: "gpt-5.6-luna",
truncation: "disabled",
input,
});

expect(body.truncation).toBeUndefined();
expect(body.instructions).toBeUndefined();
expect(body.input).toEqual(input);
});

test.each([
{
name: "key-auth public Responses provider",
provider: {
adapter: "openai-responses",
baseUrl: "https://api.openai.com/v1",
authMode: "key" as const,
apiKey: "test-key",
},
},
{
name: "noncanonical forward gateway",
provider: {
adapter: "openai-responses",
baseUrl: "https://gateway.example/v1",
authMode: "forward" as const,
},
},
])("preserves the public Responses envelope for $name", ({ provider }) => {
const input = [{ type: "message", role: "system", content: "keep me" }];
const body = outboundBody(provider, {
model: "gpt-5.6-luna",
instructions: "existing",
truncation: "disabled",
input,
});

expect(body.truncation).toBe("disabled");
expect(body.instructions).toBe("existing");
expect(body.input).toEqual(input);
});
});
Loading