From 50b50e1b9a35095ebf62fe214fef6691ab71275c Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Wed, 26 Aug 2026 08:43:09 +0000 Subject: [PATCH] fix(responses): normalize canonical forward prompt envelope --- .../src/content/docs/reference/adapters.md | 7 + .../content/docs/reference/proxy-formats.md | 3 + src/adapters/openai-responses.ts | 64 ++++++++++ src/compatibility/openai-responses.ts | 18 ++- .../openai-codex-forward-gpt56-sol-v1.json | 27 +++- .../responses-forward-prompt-envelope.test.ts | 120 ++++++++++++++++++ 6 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 tests/responses-forward-prompt-envelope.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index b6e7c6006d..18179f1b6d 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -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 diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 83dda745cf..094591b008 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -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 diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c304e6823f..2294c083c5 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -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 | 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 = { ...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}.`; @@ -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( diff --git a/src/compatibility/openai-responses.ts b/src/compatibility/openai-responses.ts index d3cde061bd..fd9393d028 100644 --- a/src/compatibility/openai-responses.ts +++ b/src/compatibility/openai-responses.ts @@ -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", @@ -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", diff --git a/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json b/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json index fab1f40292..7852a38bac 100644 --- a/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json +++ b/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json @@ -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" }, @@ -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" } diff --git a/tests/responses-forward-prompt-envelope.test.ts b/tests/responses-forward-prompt-envelope.test.ts new file mode 100644 index 0000000000..7d52d45151 --- /dev/null +++ b/tests/responses-forward-prompt-envelope.test.ts @@ -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): Record { + 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; + } 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); + }); +});