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
27 changes: 27 additions & 0 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading