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
52 changes: 32 additions & 20 deletions packages/ai/src/providers/openai-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
},
Expand Down
78 changes: 77 additions & 1 deletion packages/ai/test/chatgpt-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, unknown>[] = [];
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<string, unknown>;
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<string, unknown>[] = [];
vi.stubGlobal(
Expand Down
Loading