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
17 changes: 15 additions & 2 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 9 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.2.0",
version: "1.3.0",
subject: {
providerId: "openai",
baseUrl: "https://chatgpt.com/backend-api/codex",
Expand Down Expand Up @@ -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);
14 changes: 14 additions & 0 deletions structure/08_openai-provider-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
]
},
{
Expand Down
68 changes: 49 additions & 19 deletions tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading