diff --git a/packages/ai/src/providers/openai-codex.ts b/packages/ai/src/providers/openai-codex.ts index e2be0d80f..50c66d804 100644 --- a/packages/ai/src/providers/openai-codex.ts +++ b/packages/ai/src/providers/openai-codex.ts @@ -24,6 +24,14 @@ function credentialId(credential: Credential | undefined): string | undefined { : undefined; } +function withChatGPTCompat(model: Model<"openai-responses">): Model<"openai-responses"> { + return { + ...model, + // ChatGPT subscription requests reject the API-key model's explicit-cache parameter. + compat: { ...model.compat, supportsExplicitPromptCacheMode: false }, + }; +} + function parseCatalog(value: unknown): Model<"openai-responses">[] { if (!value || typeof value !== "object" || !("models" in value) || !Array.isArray(value.models)) { throw new Error("Invalid ChatGPT model catalog"); @@ -57,20 +65,22 @@ function parseCatalog(value: unknown): Model<"openai-responses">[] { item.max_output_tokens > 0 ? item.max_output_tokens : (known?.maxTokens ?? 16_384); - models.push({ - ...known, - id: item.slug, - name: item.display_name, - api: "openai-responses", - provider: "openai-codex", - baseUrl: BASE_URL, - reasoning: known?.reasoning ?? false, - input: known?.input ?? ["text"], - contextWindow, - maxTokens: Math.min(maxTokens, contextWindow), - // ChatGPT plan usage is not billed at API-key token prices. - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }); + models.push( + withChatGPTCompat({ + ...known, + id: item.slug, + name: item.display_name, + api: "openai-responses", + provider: "openai-codex", + baseUrl: BASE_URL, + reasoning: known?.reasoning ?? false, + input: known?.input ?? ["text"], + contextWindow, + maxTokens: Math.min(maxTokens, contextWindow), + // ChatGPT plan usage is not billed at API-key token prices. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }), + ); } return models; } @@ -165,12 +175,14 @@ export function openaiCodexProvider(): Provider<"openai-responses"> { update: () => { activate(owner); if (stored) { - catalog = stored.models.filter( - (model): model is Model<"openai-responses"> => - model.provider === "openai-codex" && - model.api === "openai-responses" && - model.baseUrl === BASE_URL, - ); + catalog = stored.models + .filter( + (model): model is Model<"openai-responses"> => + model.provider === "openai-codex" && + model.api === "openai-responses" && + model.baseUrl === BASE_URL, + ) + .map(withChatGPTCompat); catalogOwner = owner; } }, diff --git a/packages/ai/test/chatgpt-inference.test.ts b/packages/ai/test/chatgpt-inference.test.ts index 9650568a0..df9d8bb71 100644 --- a/packages/ai/test/chatgpt-inference.test.ts +++ b/packages/ai/test/chatgpt-inference.test.ts @@ -5,8 +5,9 @@ import { Type } from "typebox"; import { afterEach, expect, it, vi } from "vitest"; import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; import type { OAuthCredential } from "../src/auth/types.ts"; -import { createModels } from "../src/models.ts"; +import { createModels, hasApi } from "../src/models.ts"; import { InMemoryModelsStore } from "../src/models-store.ts"; +import { openaiProvider } from "../src/providers/openai.ts"; import { openaiCodexProvider } from "../src/providers/openai-codex.ts"; import type { Context } from "../src/types.ts"; @@ -167,6 +168,81 @@ it("streams text and usage over public Responses with subscription billing", asy }); }); +it.each(["fresh", "cached offline", "cached after refresh failure"])( + "omits unsupported prompt-cache options for summaries with a %s catalog", + async (catalogState) => { + const bodies: Record[] = []; + let failCatalogRefresh = false; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + if (String(input).endsWith("/models")) { + if (failCatalogRefresh) return new Response(null, { status: 503 }); + return Response.json(catalog); + } + const body = JSON.parse(String(init?.body)) as Record; + bodies.push(body); + if ("prompt_cache_options" in body) { + return Response.json( + { + error: { + message: "prompt_cache_options is not supported on this model", + type: "invalid_request_error", + param: "prompt_cache_options", + code: "invalid_parameter", + }, + }, + { status: 400 }, + ); + } + return sse([...textEvents(), completed()]); + }), + ); + const initial = await runtime(); + let models = initial.models; + if (catalogState !== "fresh") { + const stored = await initial.store.read("openai-codex"); + if (!stored) throw new Error("Catalog not persisted"); + // Simulate a catalog persisted before the hotfix with inherited API-key capabilities. + await initial.store.write("openai-codex", { + ...stored, + models: stored.models.map((model) => ({ + ...model, + compat: { ...model.compat, supportsExplicitPromptCacheMode: true }, + })), + }); + models = createModels({ credentials: initial.credentials, modelsStore: initial.store }); + models.setProvider(openaiCodexProvider()); + failCatalogRefresh = catalogState === "cached after refresh failure"; + const refresh = await models.refresh({ allowNetwork: failCatalogRefresh }); + expect(refresh.errors.size).toBe(failCatalogRefresh ? 1 : 0); + } + const model = models.getModel("openai-codex", "gpt-6.1-sol"); + if (!model || !hasApi(model, "openai-responses")) throw new Error("Responses model not discovered"); + const options = { cacheRetention: "none" as const, sessionId: "summary-session" }; + for (const result of [ + await models.completeSimple(model, context, options), + await models.complete(model, context, options), + ]) { + expect(result.stopReason).toBe("stop"); + expect(result.content).toEqual([expect.objectContaining({ type: "text", text: "Hello" })]); + } + expect(model.compat?.supportsExplicitPromptCacheMode).toBe(false); + expect(model.compat?.supportsStrictMode).toBe(true); + expect(model.compat?.supportsOpenAIGrammarTools).toBe(true); + const apiKeyModel = openaiProvider() + .getModels() + .find((entry) => entry.id === model.id); + expect(apiKeyModel?.compat?.supportsExplicitPromptCacheMode).toBe(true); + expect(bodies).toHaveLength(2); + for (const body of bodies) { + expect(body).not.toHaveProperty("prompt_cache_options"); + expect(body).not.toHaveProperty("prompt_cache_key"); + expect(body).not.toHaveProperty("prompt_cache_retention"); + } + }, +); + it("streams tool arguments and replays tool results and encrypted reasoning", async () => { const bodies: Record[] = []; vi.stubGlobal(