From 5904178c349c555704b5f461ef38a47a47324074 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 03:16:11 +0900 Subject: [PATCH] fix(responses): drop the retired prompt_cache_retention for gpt-5.6 The ChatGPT backend 400s a gpt-5.6 request that still carries prompt_cache_retention: "Unsupported parameter". GPT-5.6 replaced the field with prompt_cache_options.ttl. Strip it on the canonical ChatGPT forward path for the gpt-5.6 family only. The retired value is not translated into the replacement field: 5.6 carries a different TTL contract and implicit caching still applies, so inventing one would change a caching decision the caller never made. The narrowness is the fix, not an omission. An older model may still honor the field, and a self-hosted or third-party forward gateway may still accept it, so both axes are pinned by non-match tests. Based on @lilinxiong's implementation in #2102, with an exact-or-dashed-prefix family match so a future gpt-5.60 is not swept up. Closes #2092 --- src/adapters/openai-responses.ts | 27 +++++++ tests/openai-responses-passthrough.test.ts | 86 ++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index a8bbab8bfd..fc70252553 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -185,6 +185,28 @@ function stripUnsupportedReasoningParams(body: unknown): unknown { return { ...body, reasoning: Object.keys(rest).length > 0 ? rest : undefined }; } +/** + * 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). + * + * 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 + * value here would silently change a caching decision the caller never made. + * + * Deliberately narrow on both axes, because a wider strip is a behavior change rather than a fix: + * only the gpt-5.6 family (an older model may still honor the field), and only on the canonical + * ChatGPT backend, which is the deployment that rejects it. Matching is exact-or-dashed-prefix so + * a future `gpt-5.60` is not swept up by a bare `startsWith`. + */ +function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): unknown { + if (!isPlainObject(body)) return body; + if (typeof modelId !== "string") return body; + if (modelId !== "gpt-5.6" && !modelId.startsWith("gpt-5.6-")) return body; + if (!Object.hasOwn(body, "prompt_cache_retention")) return body; + const { prompt_cache_retention: _retention, ...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 @@ -1491,6 +1513,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } if (forward) { outBody = stripUnsupportedForwardParams(outBody); + // Only the canonical ChatGPT backend rejects the retired field; a self-hosted or + // third-party forward gateway may still accept it, so this must not be widened. + if (isCanonicalOpenAiForwardProvider(provider)) { + outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId); + } } else { outBody = preferConfiguredHostedTools( outBody, diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index c2e9d38a71..ca1fe971b9 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -822,6 +822,92 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.prompt_cache_retention).toBe("24h"); }); + /** + * Issue #2092: the ChatGPT backend 400s a gpt-5.6 request that still carries the retired + * `prompt_cache_retention`. The strip is deliberately narrow, and these cases pin the + * narrowness itself — a wider strip passes the first block and fails the second. + */ + test.each(["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"])( + "drops the retired prompt_cache_retention for %s without inventing replacement options", + modelId => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: modelId, input: "hi", prompt_cache_retention: "24h" }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { + prompt_cache_retention?: string; + prompt_cache_options?: unknown; + }; + + expect(body.prompt_cache_retention).toBeUndefined(); + // Translating "24h" into the replacement field would invent a caching decision the + // caller never made, so absence must stay absence. + expect(body.prompt_cache_options).toBeUndefined(); + }, + ); + + 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" }, + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { + prompt_cache_retention?: string; + prompt_cache_options?: { ttl?: string }; + }; + + expect(body.prompt_cache_retention).toBeUndefined(); + 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", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.60", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "gpt-5.60", input: "hi", prompt_cache_retention: "24h" }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { prompt_cache_retention?: string }; + + // A bare startsWith("gpt-5.6") would strip here and silently change an unrelated model. + expect(body.prompt_cache_retention).toBe("24h"); + }); + + test("a noncanonical forward gateway keeps the field even for gpt-5.6", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "forward" as const, + }); + 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" }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { prompt_cache_retention?: string }; + + // Only the canonical ChatGPT backend is known to reject it. Stripping everywhere would be + // a behavior change for deployments that still honor the field. + expect(body.prompt_cache_retention).toBe("24h"); + }); + const expandedRawBody = { model: "gpt-5.5", previous_response_id: "resp_1",