From 1d547fee411f624097e2614efe0218db30ff773c Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 27 Aug 2026 14:19:11 +0000 Subject: [PATCH] fix(openai): strip unsupported forward cache options --- src/adapters/openai-responses.ts | 17 ++++- src/compatibility/openai-responses.ts | 10 ++- structure/08_openai-provider-tiers.md | 14 ++++ .../openai-codex-forward-gpt56-sol-v1.json | 4 +- tests/openai-responses-passthrough.test.ts | 68 +++++++++++++------ 5 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d69d2909b1..70e6e7a1d7 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -318,8 +318,8 @@ function stripUnsupportedReasoningParams(body: unknown): unknown { } /** - * GPT-5.6 replaced the legacy 24-hour retention field with `prompt_cache_options.ttl`, and the - * ChatGPT backend 400s the whole request when the retired field is present (issue #2092). + * GPT-5.6 retired the legacy 24-hour retention field, and the ChatGPT backend 400s the whole + * request when that field is present (issue #2092). * * The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract, * and implicit caching still applies when the caller sent no replacement options. Inventing a @@ -339,6 +339,18 @@ function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): u return rest; } +/** + * Public Responses clients can send `prompt_cache_options`, but the canonical ChatGPT Codex + * backend rejects the top-level field before inference (issue #2765). Custom forward gateways and + * API-key Responses providers own different wire contracts, so the caller applies this only after + * the canonical destination predicate succeeds. + */ +function stripCanonicalForwardPromptCacheOptions(body: unknown): unknown { + if (!isPlainObject(body) || !Object.hasOwn(body, "prompt_cache_options")) return body; + const { prompt_cache_options: _options, ...rest } = body; + return rest; +} + /** * A false model capability prevents Codex from emitting summary fields after the catalog refresh. * Strip them here as well so an already-running client with a stale catalog cannot keep sending an @@ -2001,6 +2013,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 = stripCanonicalForwardPromptCacheOptions(outBody); outBody = normalizeCanonicalForwardPromptEnvelope(outBody); outBody = normalizeCanonicalForwardContinuationEnvelope(outBody); } diff --git a/src/compatibility/openai-responses.ts b/src/compatibility/openai-responses.ts index 75384dfaf2..35f62cec81 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.2.0", + version: "1.3.0", subject: { providerId: "openai", baseUrl: "https://chatgpt.com/backend-api/codex", @@ -109,5 +109,13 @@ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManife limitation: "The field is removed without inventing a replacement prompt_cache_options value.", evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-retention-removed"] }], }, + { + id: "prompt-cache-options", + feature: "request.prompt_cache_options", + disposition: "unsupported", + summary: "The ChatGPT Codex forward route does not receive public prompt cache options.", + 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: ["prompt-cache-options-removed"] }], + }, ], } as const); diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 0fd70b3b58..ab15088fb4 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,20 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +The two routes also keep separate request-compatibility contracts. The canonical ChatGPT Codex +forward destination removes public `prompt_cache_options` because that backend rejects the field +before inference; `prompt_cache_key` remains supported. `openai-apikey` and noncanonical/custom +Responses destinations preserve caller-provided options because their upstream contracts may +support them. + +[Decision Log] +- 목적과 의도: Let public Responses clients use the Codex-login route without one unsupported prompt-cache extension failing the whole turn. +- 기존 구현 및 제약 조건: Parsing already preserves unknown top-level fields in `_rawBody`, and the canonical backend rejects `prompt_cache_options`; API-key and custom providers may accept the same field. +- 검토한 주요 대안: Add the field to the Zod schema; strip it for every Responses provider; translate it to a legacy retention hint; remove it only at the canonical destination boundary. +- 선택한 방식: Keep parser passthrough unchanged and strip the caller field only after `isCanonicalOpenAiForwardProvider` succeeds. +- 다른 대안 대신 이 방식을 선택한 이유: Schema admission does not change `_rawBody`, global stripping would remove supported public API behavior, and translation would invent cache policy. +- 장점, 단점 및 영향: VS Code and other public-shape clients avoid the canonical backend rejection while API-key/custom routes retain their wire options; canonical callers cannot request this cache option through OpenCodex. + Pool affinity preserves the existing `x-codex-parent-thread-id` supplied by ordinary Codex clients. The parent id is trimmed and bounded under the same 512-byte component limit as the Desktop fallback. When Codex Desktop omits it or sends an unusable value, the complete bounded `session-id` 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 bc33f244e9..9712c558e3 100644 --- a/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json +++ b/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json @@ -45,6 +45,7 @@ "reasoning": { "effort": "low" }, "prompt_cache_key": "project-cache-v1", "prompt_cache_retention": "24h", + "prompt_cache_options": { "ttl": 3600 }, "tools": [ { "type": "custom", @@ -91,7 +92,8 @@ { "id": "unstored-item-reference-removed", "operator": "absent", "path": "/body/input/1" }, { "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" } + { "id": "prompt-cache-retention-removed", "operator": "absent", "path": "/body/prompt_cache_retention" }, + { "id": "prompt-cache-options-removed", "operator": "absent", "path": "/body/prompt_cache_options" } ] }, { diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index a7ef5e57f2..da65671339 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -1754,27 +1754,57 @@ describe("OpenAI Responses passthrough sanitization", () => { }, ); - test("keeps caller-sent prompt_cache_options while dropping the retired retention", () => { - const adapter = createResponsesPassthroughAdapter(provider); - const request = adapter.buildRequest({ - modelId: "gpt-5.6-sol", - context: { messages: [] }, - stream: true, - options: {}, - _rawBody: { - model: "gpt-5.6-sol", - input: "hi", - prompt_cache_retention: "24h", - prompt_cache_options: { ttl: "30m" }, + test.each(["gpt-5.5", "gpt-5.6-luna"])( + "drops caller-sent prompt_cache_options for canonical forward model %s", + modelId => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: modelId, + input: "hi", + prompt_cache_options: { ttl: "30m" }, + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { prompt_cache_options?: { ttl?: string } }; + + expect(body.prompt_cache_options).toBeUndefined(); + }, + ); + + test("keeps prompt_cache_options for noncanonical forward and API-key providers", () => { + for (const configuredProvider of [ + { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "forward" as const, }, - }, { headers: new Headers({ authorization: "Bearer token" }) }); - const body = JSON.parse(request.body) as { - prompt_cache_retention?: string; - prompt_cache_options?: { ttl?: string }; - }; + { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key" as const, + apiKey: "test-key", + }, + ]) { + const adapter = createResponsesPassthroughAdapter(configuredProvider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: "hi", + prompt_cache_options: { ttl: "30m" }, + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { prompt_cache_options?: { ttl?: string } }; - expect(body.prompt_cache_retention).toBeUndefined(); - expect(body.prompt_cache_options).toEqual({ ttl: "30m" }); + expect(body.prompt_cache_options).toEqual({ ttl: "30m" }); + } }); test("a near-miss model id is not swept up by the gpt-5.6 family match", () => {