diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fe13f6d5d..6c74854b5 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -420,6 +420,8 @@ This release changes no settings, OAuth-store, or session persistence format and **OpenCode Go multi-protocol.** Selectable ids come from live `GET /zen/go/v1/models` (process-cached; packaged seed when cold or the fetch fails). Byte and model-count caps fail closed as malformed — never a truncated prefix. Protocol routing stays on the local map; unknown live ids use Chat Completions. `buildGoSource` / `resolveGoEndpoint` pick the adapter and base URL per model (not a single provider-wide OpenAI route). When Go is the active provider, subscription usage is fetched for the status bar and omitted on auth/network failure. +**ChatGPT model catalog.** Each ChatGPT (Codex OAuth) profile lists the models its account serves: after startup the TUI fetches `GET /codex/models` with the profile's refreshed token and account id, keeps entries whose `visibility` is `list`, orders them by `priority`, and the first becomes the profile's default (`src/provider/codex-model-catalog.ts`). Connecting an account fetches the same list with the just-issued token for the connect model step, which preselects its first model unless the operator has moved to another one; `/connect` keeps that list for the saved profile, then refreshes every profile's list for the picker. The list is cached per profile and account for the process (a login with no account id is told apart by when it signed in), and fleet workers spawned from the TUI read it through the live catalog. Until a fetch succeeds the profile uses `CODEX_DEFAULT_MODELS`; a later failed fetch keeps the last live list. Headless runs never fetch, so they and their workers always use `CODEX_DEFAULT_MODELS`. The backend filters the catalog by the request's `client_version`: an old `CODEX_CLIENT_VERSION` returns fewer models or none. Only an empty list logs a warning ("listed no models"); a shortened list is silent, so when a model the Codex CLI offers is missing here, bump `CODEX_CLIENT_VERSION` to a current Codex CLI release. + ### CLI Verbs and Flags Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts` diff --git a/src/auth/codex/constants.ts b/src/auth/codex/constants.ts index 7a3786e81..a663623ca 100644 --- a/src/auth/codex/constants.ts +++ b/src/auth/codex/constants.ts @@ -1,5 +1,5 @@ import { callbackTargetFor } from "@corbits/oauth-core"; -import { codexOAuthConfig } from "@corbits/codex-provider"; +import { CODEX_BASE_URL, codexOAuthConfig } from "@corbits/codex-provider"; export { CODEX_BASE_URL, @@ -12,19 +12,24 @@ export const CODEX_CALLBACK_HOST = callbackTarget.host; export const CODEX_CALLBACK_PORT = callbackTarget.port; export const CODEX_CALLBACK_PATH = callbackTarget.path; -// The account's available model catalog. The models endpoint requires a -// client_version query param. -export const CODEX_MODELS_PATH = "/codex/models"; -export const CODEX_CLIENT_VERSION = "0.50.0"; +const CODEX_MODELS_PATH = "/codex/models"; +// The backend filters /codex/models by client_version: an older version gets +// fewer models or none, and the per-model minimal_client_version does not +// predict which. Track a released Codex CLI version. +export const CODEX_CLIENT_VERSION = "0.165.0"; +// The account's available model catalog, read by both the sign-in scope +// check and the live model catalog so the two cannot drift. +export const CODEX_MODELS_URL = `${CODEX_BASE_URL}${CODEX_MODELS_PATH}?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`; // Client identity the Codex backend expects on usage/model requests, matching // the public Codex CLI originator. export const CODEX_ORIGINATOR = "codex_cli_rs"; -// The models a ChatGPT sign-in can select, in the Codex backend's priority -// order (GET /codex/models); the first entry is the ChatGPT default. OpenAI -// retires models for ChatGPT sign-in on its own schedule, independently of the -// OpenAI API, so this list and its default do not follow the API-key path. +// The models a ChatGPT sign-in can select when its live catalog (GET +// /codex/models) is unavailable, in the backend's priority order; the first +// entry is the ChatGPT default. OpenAI retires models for ChatGPT sign-in on +// its own schedule, independently of the OpenAI API, so this list and its +// default do not follow the API-key path. export const CODEX_DEFAULT_MODELS = [ "gpt-6.1-sol", "gpt-6-astra", diff --git a/src/auth/oauth-scope-check.ts b/src/auth/oauth-scope-check.ts index 1c0a25819..3a145dbac 100644 --- a/src/auth/oauth-scope-check.ts +++ b/src/auth/oauth-scope-check.ts @@ -11,11 +11,7 @@ import { isOAuthTokenEndpointError } from "./token-session-boundary.js"; -import { - CODEX_BASE_URL, - CODEX_MODELS_PATH, - CODEX_CLIENT_VERSION, -} from "./codex/constants.js"; +import { CODEX_MODELS_URL } from "./codex/constants.js"; import { refreshStagedCodexTokens } from "./codex/session.js"; import type { CodexTokens } from "./codex/store.js"; import { codexAuthHeadersForToken } from "./codex/auth-headers.js"; @@ -151,10 +147,10 @@ async function checkCodexScope( commandName, ), (headers) => - fetch( - `${CODEX_BASE_URL}${CODEX_MODELS_PATH}?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`, - { headers, signal: AbortSignal.timeout(SCOPE_CHECK_TIMEOUT_MS) }, - ), + fetch(CODEX_MODELS_URL, { + headers, + signal: AbortSignal.timeout(SCOPE_CHECK_TIMEOUT_MS), + }), ); } diff --git a/src/config/codex-providers.ts b/src/config/codex-providers.ts index 5a5803262..67f60b48b 100644 --- a/src/config/codex-providers.ts +++ b/src/config/codex-providers.ts @@ -1,8 +1,6 @@ -import { - CODEX_BASE_URL, - CODEX_DEFAULT_MODELS, -} from "../auth/codex/constants.js"; +import { CODEX_BASE_URL } from "../auth/codex/constants.js"; import type { CodexProfile } from "../auth/codex/store.js"; +import { selectableCodexModelIds } from "../provider/codex-model-catalog.js"; import { createOAuthProviderProjection } from "./oauth-providers.js"; export const CODEX_PROVIDER_PREFIX = "codex/"; @@ -13,7 +11,7 @@ export const CODEX_PROVIDER_PREFIX = "codex/"; const projection = createOAuthProviderProjection({ prefix: CODEX_PROVIDER_PREFIX, baseURL: CODEX_BASE_URL, - models: () => CODEX_DEFAULT_MODELS, + models: selectableCodexModelIds, catalogExtras: (profile) => ({ codexProfile: profile.name, ...(profile.tokens.accountId !== undefined diff --git a/src/provider/bounded-model-catalog.ts b/src/provider/bounded-model-catalog.ts index ec643a1c0..875a3efc9 100644 --- a/src/provider/bounded-model-catalog.ts +++ b/src/provider/bounded-model-catalog.ts @@ -80,6 +80,43 @@ type DiscoveredModels = Extract< { status: "models" } >["models"]; +/** + * Log why a live catalog fetch kept `keeping` instead of a fresh list. An + * unreachable catalog is routine (offline, transient outage), so it stays at + * debug; an empty or malformed answer means the endpoint's contract moved, + * which only shows up here, so it warns. + */ +export function logCatalogFallback( + catalogLabel: string, + state: Exclude, + keeping: string, +): void { + // HTTP and size failures already name the catalog; transport and parse + // errors do not, so the catalog is named exactly once either way. + const reason = (message: string): string => + message.startsWith(catalogLabel) ? message : `${catalogLabel}: ${message}`; + switch (state.status) { + case "unavailable": + catalogLogger.debug( + "Model catalog unavailable ({reason}); keeping {fallback}", + { reason: reason(state.message), fallback: keeping }, + ); + return; + case "empty": + catalogLogger.warn( + "Model catalog listed no models ({catalog}); keeping {fallback}", + { catalog: catalogLabel, fallback: keeping }, + ); + return; + case "malformed": + catalogLogger.warn( + "Model catalog response rejected ({reason}); keeping {fallback}", + { reason: reason(state.message), fallback: keeping }, + ); + return; + } +} + export function createBoundedModelCatalog< Seed extends readonly string[], >(args: { @@ -198,40 +235,14 @@ export function createBoundedModelCatalog< return selectableModelIds(); } - // An unreachable catalog is routine (offline, transient outage), so it - // stays at debug. An empty or malformed answer means the endpoint's - // contract moved, which only shows up here, so it warns. function logFallback( state: Exclude, ): void { - const fallback = - snapshot === undefined ? "the built-in list" : "the last live list"; - // HTTP and size failures already name the catalog; transport and parse - // errors do not, so the catalog is named exactly once either way. - const reason = (message: string): string => - message.startsWith(catalogLabel) - ? message - : `${catalogLabel}: ${message}`; - switch (state.status) { - case "unavailable": - catalogLogger.debug( - "Model catalog unavailable ({reason}); keeping {fallback}", - { reason: reason(state.message), fallback }, - ); - return; - case "empty": - catalogLogger.warn( - "Model catalog listed no models ({catalog}); keeping {fallback}", - { catalog: catalogLabel, fallback }, - ); - return; - case "malformed": - catalogLogger.warn( - "Model catalog response rejected ({reason}); keeping {fallback}", - { reason: reason(state.message), fallback }, - ); - return; - } + logCatalogFallback( + catalogLabel, + state, + snapshot === undefined ? "the built-in list" : "the last live list", + ); } function prefetchModels(): Promise { diff --git a/src/provider/codex-model-catalog.test.ts b/src/provider/codex-model-catalog.test.ts new file mode 100644 index 000000000..8548e87f7 --- /dev/null +++ b/src/provider/codex-model-catalog.test.ts @@ -0,0 +1,605 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { configureSync, getConfig, resetSync } from "@intx/log"; + +import { defined } from "../../testkit/defined.js"; +import { withMockedModule } from "../../testkit/mock-module.js"; +import { + CODEX_CLIENT_VERSION, + CODEX_DEFAULT_MODELS, +} from "../auth/codex/constants.js"; +import type { CodexProfile } from "../auth/codex/store.js"; +import type * as CodexSession from "../auth/codex/session.js"; +import type * as OAuthStores from "../config/oauth-stores.js"; +import { codexProfilesToCatalogEntries } from "../config/codex-providers.js"; +import { + discoverStagedCodexModels, + prefetchCodexModels, + rememberConnectedCodexModels, + resetCodexModelDiscoveryForTests, + selectableCodexModelIds, +} from "./codex-model-catalog.js"; + +// GET /codex/models for a Plus account at client_version 0.165.0 on +// 2026-10-28, trimmed to the fields the catalog reads plus two it ignores. +const RECORDED_MODELS = [ + { + slug: "gpt-6.1-sol", + visibility: "list", + priority: 1, + context_window: 500000, + minimal_client_version: "0.153.0", + }, + { + slug: "gpt-6-astra", + visibility: "list", + priority: 2, + context_window: 272000, + minimal_client_version: "0.153.0", + }, + { + slug: "gpt-6-chat-thinking", + visibility: "hide", + priority: 2, + context_window: 272000, + minimal_client_version: "0.144.0", + }, + { + slug: "gpt-6.1-terra", + visibility: "list", + priority: 3, + context_window: 500000, + minimal_client_version: "0.153.0", + }, + { + slug: "gpt-6-sol", + visibility: "list", + priority: 4, + context_window: 272000, + minimal_client_version: "0.155.0", + }, + { + slug: "gpt-reserve", + visibility: "hide", + priority: 4, + context_window: 272000, + minimal_client_version: "0.144.0", + }, + { + slug: "gpt-6-luna", + visibility: "list", + priority: 5, + context_window: 272000, + minimal_client_version: "0.155.0", + }, + { + slug: "gpt-5.6-sol", + visibility: "list", + priority: 6, + context_window: 272000, + minimal_client_version: "0.144.0", + }, + { + slug: "gpt-5.6-terra", + visibility: "list", + priority: 8, + context_window: 272000, + minimal_client_version: "0.144.0", + }, + { + slug: "gpt-5.6-luna", + visibility: "list", + priority: 9, + context_window: 272000, + minimal_client_version: "0.144.0", + }, + { + slug: "codex-auto-review", + visibility: "hide", + priority: 43, + context_window: 272000, + minimal_client_version: "0.98.0", + }, +]; + +const RECORDED_LISTED_IDS = [ + "gpt-6.1-sol", + "gpt-6-astra", + "gpt-6.1-terra", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", +]; + +function profile( + name: string, + accountId?: string, + createdAt = 0, +): CodexProfile { + return { + name, + createdAt, + tokens: { + access: `stored-${name}`, + refresh: "r", + expiresAt: 1, + ...(accountId !== undefined ? { accountId } : {}), + }, + }; +} + +let storedProfiles: CodexProfile[] = []; +let tokenFailure: Error | undefined; +// Token reads that fail before they start succeeding. +let tokenFailuresLeft = 0; +// Simulates a token refresh that learns the account id and writes it back to +// the stored profile. +let accountLearnedOnRefresh: string | undefined; + +await withMockedModule( + import.meta.resolve("../config/oauth-stores.js"), + (real: typeof OAuthStores) => ({ + ...real, + listCodexProfiles: async () => storedProfiles, + }), +); +await withMockedModule( + import.meta.resolve("../auth/codex/session.js"), + (real: typeof CodexSession) => ({ + ...real, + getValidCodexToken: async (name: string) => { + if (tokenFailure !== undefined) throw tokenFailure; + if (tokenFailuresLeft > 0) { + tokenFailuresLeft -= 1; + throw new Error("token read failed"); + } + const stored = storedProfiles.find((p) => p.name === name); + if (stored === undefined) throw new Error(`no profile ${name}`); + const learned = accountLearnedOnRefresh; + if (learned !== undefined) { + storedProfiles = storedProfiles.map((p) => + p.name === name ? profile(name, learned) : p, + ); + } + return { + access: `fresh-${name}`, + accountId: learned ?? stored.tokens.accountId, + }; + }, + }), +); + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + storedProfiles = []; + tokenFailure = undefined; + tokenFailuresLeft = 0; + accountLearnedOnRefresh = undefined; + resetCodexModelDiscoveryForTests(); +}); + +function serve( + body: (url: URL, headers: Headers) => Response | Promise, +): void { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => + body(new URL(String(input)), new Headers(init?.headers))) as typeof fetch; +} + +function serveModels(models: unknown): void { + serve(() => Response.json({ models })); +} + +describe("ChatGPT model catalog", () => { + test("a ChatGPT profile lists the models its account serves, first by priority as default", async () => { + const work = profile("work", "acct-work"); + storedProfiles = [work]; + serveModels( + RECORDED_MODELS.filter((m) => m.slug.startsWith("gpt-5.6")).reverse(), + ); + + await prefetchCodexModels(); + + const [entry] = codexProfilesToCatalogEntries([work]); + expect(entry?.models).toEqual([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + ]); + expect(entry?.defaultModel).toBe("gpt-5.6-sol"); + }); + + test("the recorded catalog lists its visible models in priority order", async () => { + const work = profile("work", "acct-work"); + storedProfiles = [work]; + serveModels(RECORDED_MODELS); + + await prefetchCodexModels(); + + expect([...selectableCodexModelIds(work)]).toEqual(RECORDED_LISTED_IDS); + }); + + test("an entry that breaks the per-model contract is skipped, not the whole catalog", async () => { + const work = profile("work", "acct-work"); + storedProfiles = [work]; + serveModels([ + { slug: "gpt-6-sol", visibility: "list", priority: 1 }, + { slug: "gpt-6-luna", visibility: "list" }, + { slug: "gpt-6-astra", visibility: "beta", priority: 2 }, + { slug: "gpt-5.6-sol", visibility: "list", priority: 3 }, + ]); + + await prefetchCodexModels(); + + expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol", "gpt-5.6-sol"]); + }); + + test("asks for the profile's catalog with a fresh token, its account and the pinned client version", async () => { + const work = profile("work", "acct-work"); + storedProfiles = [work]; + const seen: { url: URL; headers: Headers }[] = []; + serve((url, headers) => { + seen.push({ url, headers }); + return Response.json({ models: RECORDED_MODELS }); + }); + + await prefetchCodexModels(); + + expect(seen).toHaveLength(1); + const [request] = seen; + expect(request?.url.pathname).toBe("/backend-api/codex/models"); + expect(request?.url.searchParams.get("client_version")).toBe( + CODEX_CLIENT_VERSION, + ); + expect(request?.headers.get("authorization")).toBe("Bearer fresh-work"); + expect(request?.headers.get("chatgpt-account-id")).toBe("acct-work"); + }); + + test("each profile keeps its own account's list", async () => { + const work = profile("work", "acct-work"); + const home = profile("home", "acct-home"); + storedProfiles = [work, home]; + serve((_url, headers) => + Response.json({ + models: + headers.get("chatgpt-account-id") === "acct-work" + ? [{ slug: "gpt-6-sol", visibility: "list", priority: 1 }] + : [{ slug: "gpt-5.6-luna", visibility: "list", priority: 1 }], + }), + ); + + await prefetchCodexModels(); + + expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol"]); + expect(selectableCodexModelIds(home)).toEqual(["gpt-5.6-luna"]); + }); + + test("a profile name reconnected to another account does not inherit the old list", async () => { + storedProfiles = [profile("work", "acct-old")]; + serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]); + await prefetchCodexModels(); + + expect(selectableCodexModelIds(profile("work", "acct-new"))).toEqual( + CODEX_DEFAULT_MODELS, + ); + }); + + test("trims slugs, drops blank ones and keeps a duplicate at its best priority", async () => { + const work = profile("work", "acct-work"); + storedProfiles = [work]; + serveModels([ + { slug: "gpt-6-luna", visibility: "list", priority: 5 }, + { slug: " gpt-6-sol ", visibility: "list", priority: 2 }, + { slug: "gpt-6-sol", visibility: "list", priority: 1 }, + { slug: " ", visibility: "list", priority: 0 }, + ]); + + await prefetchCodexModels(); + + expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol", "gpt-6-luna"]); + }); + + test("models with equal priority keep the server's order", async () => { + const work = profile("work", "acct-work"); + storedProfiles = [work]; + serveModels([ + { slug: "gpt-6-luna", visibility: "list", priority: 1 }, + { slug: "gpt-6-sol", visibility: "list", priority: 1 }, + { slug: "gpt-6.1-sol", visibility: "list", priority: 0 }, + ]); + + await prefetchCodexModels(); + + expect(selectableCodexModelIds(work)).toEqual([ + "gpt-6.1-sol", + "gpt-6-luna", + "gpt-6-sol", + ]); + }); + + test("a non-object entry is skipped like any other malformed entry", async () => { + const work = profile("work", "acct-work"); + storedProfiles = [work]; + serveModels([null, { slug: "gpt-6-sol", visibility: "list", priority: 1 }]); + + await prefetchCodexModels(); + + expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol"]); + }); + + test("a profile whose refresh learns its account keeps the live list", async () => { + storedProfiles = [profile("legacy")]; + accountLearnedOnRefresh = "acct-legacy"; + serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]); + + await prefetchCodexModels(); + + const reread = defined(storedProfiles[0]); + expect(reread.tokens.accountId).toBe("acct-legacy"); + expect(selectableCodexModelIds(reread)).toEqual(["gpt-6-sol"]); + }); + + test("a fetch after a failed token read keeps its list for the account it fetched as", async () => { + storedProfiles = [profile("legacy")]; + tokenFailuresLeft = 1; + accountLearnedOnRefresh = "acct-legacy"; + serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]); + + await prefetchCodexModels(); + + expect(selectableCodexModelIds(defined(storedProfiles[0]))).toEqual([ + "gpt-6-sol", + ]); + }); + + describe("a login with no account id", () => { + const list = [{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]; + + async function reconnectThenServe(response: () => Response) { + storedProfiles = [profile("work", undefined, 1)]; + serveModels(list); + await prefetchCodexModels(); + expect(selectableCodexModelIds(profile("work", undefined, 1))).toEqual([ + "gpt-6-sol", + ]); + + const reconnected = profile("work", undefined, 2); + storedProfiles = [reconnected]; + serve(response); + await prefetchCodexModels(); + return reconnected; + } + + test("does not inherit the list of the login it replaced when its fetch fails", async () => { + const reconnected = await reconnectThenServe( + () => new Response("no", { status: 503 }), + ); + expect(selectableCodexModelIds(reconnected)).toEqual( + CODEX_DEFAULT_MODELS, + ); + }); + + test("does not inherit the list of the login it replaced when its catalog is empty", async () => { + const reconnected = await reconnectThenServe(() => + Response.json({ models: [] }), + ); + expect(selectableCodexModelIds(reconnected)).toEqual( + CODEX_DEFAULT_MODELS, + ); + }); + + test("keeps its list across a token refresh, which keeps its creation time", async () => { + storedProfiles = [profile("work", undefined, 1)]; + serveModels(list); + await prefetchCodexModels(); + + const refreshed = profile("work", undefined, 1); + refreshed.tokens = { ...refreshed.tokens, refresh: "rotated" }; + expect(selectableCodexModelIds(refreshed)).toEqual(["gpt-6-sol"]); + }); + }); + + describe("the models shown while connecting", () => { + const shown: [string, ...string[]] = ["gpt-6-sol", "gpt-5.6-luna"]; + + test("stay the profile's list when the fetch after connecting fails", async () => { + const connected = profile("work", "acct-new", 5); + rememberConnectedCodexModels(connected, shown); + storedProfiles = [connected]; + serve(() => new Response("no", { status: 503 })); + + await prefetchCodexModels(); + + expect(selectableCodexModelIds(connected)).toEqual(shown); + }); + + test("cover a login with no account id", () => { + const connected = profile("work", undefined, 5); + rememberConnectedCodexModels(connected, shown); + + expect(selectableCodexModelIds(connected)).toEqual(shown); + expect(selectableCodexModelIds(profile("work", undefined, 6))).toEqual( + CODEX_DEFAULT_MODELS, + ); + }); + + test("give way to a later successful fetch", async () => { + const connected = profile("work", "acct-new", 5); + rememberConnectedCodexModels(connected, shown); + storedProfiles = [connected]; + serveModels([{ slug: "gpt-6.1-sol", visibility: "list", priority: 1 }]); + + await prefetchCodexModels(); + + expect(selectableCodexModelIds(connected)).toEqual(["gpt-6.1-sol"]); + }); + + test("an empty list is not remembered", () => { + const connected = profile("work", "acct-new", 5); + rememberConnectedCodexModels(connected, []); + + expect(selectableCodexModelIds(connected)).toEqual(CODEX_DEFAULT_MODELS); + }); + }); + + test("a reconnect whose first fetch fails does not show the previous account's list", async () => { + storedProfiles = [profile("work", "acct-old")]; + serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]); + await prefetchCodexModels(); + + // The profile is listed under the old account, but by the time its token + // is read the reconnect has stored the new one. + accountLearnedOnRefresh = "acct-new"; + serve(() => new Response("no", { status: 503 })); + await prefetchCodexModels(); + + expect(selectableCodexModelIds(profile("work", "acct-new"))).toEqual( + CODEX_DEFAULT_MODELS, + ); + }); + + describe("falls back to the built-in list", () => { + const work = profile("work", "acct-work"); + + const cases: { name: string; arrange: () => void }[] = [ + { name: "before any fetch", arrange: () => undefined }, + { + name: "when the account lists no models", + arrange: () => serveModels([]), + }, + { + name: "when every entry is hidden", + arrange: () => + serveModels(RECORDED_MODELS.filter((m) => m.visibility === "hide")), + }, + { + name: "on an HTTP error", + arrange: () => serve(() => new Response("no", { status: 401 })), + }, + { + name: "when no entry matches the per-model contract", + arrange: () => + serveModels([ + { slug: "gpt-6-sol", visibility: "list", priority: "1" }, + { slug: "gpt-6-luna", visibility: "list", priority: "2" }, + ]), + }, + { + name: "when the body is not a model catalog", + arrange: () => serve(() => Response.json({ data: [] })), + }, + { + name: "when the body exceeds the size cap", + arrange: () => + serve( + () => + new Response("{}", { + status: 200, + headers: { "Content-Length": String(64 * 1024 * 1024) }, + }), + ), + }, + { + name: "when the token cannot be refreshed", + arrange: () => { + tokenFailure = new Error("refresh failed"); + serveModels(RECORDED_MODELS); + }, + }, + ]; + + for (const { name, arrange } of cases) { + test(name, async () => { + storedProfiles = [work]; + arrange(); + if (name !== "before any fetch") await prefetchCodexModels(); + + expect(selectableCodexModelIds(work)).toEqual(CODEX_DEFAULT_MODELS); + const [entry] = codexProfilesToCatalogEntries([work]); + expect(entry?.defaultModel).toBe(CODEX_DEFAULT_MODELS[0]); + }); + } + }); +}); + +describe("contract drift", () => { + test("a catalog whose every entry breaks the per-model contract warns as rejected, not empty", async () => { + const work = profile("work", "acct-work"); + storedProfiles = [work]; + serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: "1" }]); + const records: string[] = []; + const previous = getConfig(); + configureSync({ + reset: true, + sinks: { + test: (record) => { + records.push(record.message.map(String).join("")); + }, + }, + loggers: [{ category: [], lowestLevel: "debug", sinks: ["test"] }], + }); + try { + await prefetchCodexModels(); + } finally { + if (previous === null) resetSync(); + else configureSync({ ...previous, reset: true }); + } + + const misses = records.filter((text) => text.includes("ChatGPT profile")); + expect(misses).toHaveLength(1); + expect(defined(misses[0])).toContain("response rejected"); + }); +}); + +describe("a sign-in not yet saved as a profile", () => { + const staged = () => ({ + access: "staged-access", + refresh: "staged-refresh", + expiresAt: Date.now() + 3_600_000, + accountId: "acct-new", + }); + + test("asks with the staged token and account and returns the listed models", async () => { + const seen: Headers[] = []; + serve((_url, headers) => { + seen.push(headers); + return Response.json({ + models: [ + { slug: "gpt-6-sol", visibility: "list", priority: 2 }, + { slug: "gpt-5.6-luna", visibility: "list", priority: 1 }, + ], + }); + }); + + expect(await discoverStagedCodexModels(staged())).toEqual({ + status: "models", + models: ["gpt-5.6-luna", "gpt-6-sol"], + }); + expect(seen[0]?.get("authorization")).toBe("Bearer staged-access"); + expect(seen[0]?.get("chatgpt-account-id")).toBe("acct-new"); + }); + + test("reports an HTTP failure as unavailable instead of rejecting", async () => { + serve(() => new Response("no", { status: 500 })); + + expect((await discoverStagedCodexModels(staged())).status).toBe( + "unavailable", + ); + }); + + test("never fills a stored profile's list", async () => { + serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]); + + await discoverStagedCodexModels(staged()); + + expect( + selectableCodexModelIds({ + name: "default", + createdAt: 0, + tokens: staged(), + }), + ).toEqual(CODEX_DEFAULT_MODELS); + }); +}); diff --git a/src/provider/codex-model-catalog.ts b/src/provider/codex-model-catalog.ts new file mode 100644 index 000000000..29de91f91 --- /dev/null +++ b/src/provider/codex-model-catalog.ts @@ -0,0 +1,177 @@ +import { type } from "arktype"; + +import { codexAuthHeadersForToken } from "../auth/codex/auth-headers.js"; +import { + CODEX_DEFAULT_MODELS, + CODEX_MODELS_URL, +} from "../auth/codex/constants.js"; +import { + type CodexAccess, + getValidCodexToken, + refreshStagedCodexTokens, +} from "../auth/codex/session.js"; +import type { CodexProfile, CodexTokens } from "../auth/codex/store.js"; +import { COMMAND_NAME } from "../branding.js"; +import { listCodexProfiles } from "../config/oauth-stores.js"; +import { + type CatalogDiscoveryState, + createBoundedModelCatalog, + logCatalogFallback, +} from "./bounded-model-catalog.js"; +import { DEFAULT_MODELS_REQUEST_TIMEOUT_MS } from "./models-endpoint.js"; + +// Each entry carries its base instructions, so the catalog runs to hundreds +// of KB and grows with every model. +const MAX_CODEX_CATALOG_BYTES = 4 * 1024 * 1024; +const MAX_CODEX_CATALOG_MODELS = 1024; + +const CodexModelsResponse = type({ models: "unknown[]" }); + +// Validated per entry so one model with a new visibility value or a missing +// field drops only that model, not the account's whole catalog. +const CodexModelEntry = type({ + slug: "string", + visibility: "string", + priority: "number", +}); + +function parseCodexModels( + raw: unknown, + cap: { readonly maxModels: number; readonly oversizeMessage: string }, +): CatalogDiscoveryState { + const parsed = CodexModelsResponse(raw); + if (parsed instanceof type.errors) { + return { status: "malformed", message: parsed.summary }; + } + if (parsed.models.length > cap.maxModels) { + return { status: "malformed", message: cap.oversizeMessage }; + } + const entries = parsed.models.map((model) => CodexModelEntry(model)); + const valid = entries.filter( + (entry): entry is typeof CodexModelEntry.infer => + !(entry instanceof type.errors), + ); + // Every entry failing the per-entry contract means the contract moved; + // reporting that as an empty catalog would point at client_version instead. + const firstError = entries.find((entry) => entry instanceof type.errors); + if (valid.length === 0 && firstError instanceof type.errors) { + return { status: "malformed", message: firstError.summary }; + } + const listed = valid + .filter((entry) => entry.visibility === "list") + .sort((a, b) => a.priority - b.priority); + const [first, ...rest] = new Set( + listed.map(({ slug }) => slug.trim()).filter((slug) => slug.length > 0), + ); + return first === undefined + ? { status: "empty" } + : { status: "models", models: [first, ...rest] }; +} + +function createCodexModelCatalog( + catalogLabel: string, + token: () => Promise, +) { + return createBoundedModelCatalog({ + seedIds: CODEX_DEFAULT_MODELS, + catalogLabel, + maxBytes: MAX_CODEX_CATALOG_BYTES, + maxModels: MAX_CODEX_CATALOG_MODELS, + request: async ({ timeoutMs, signal }) => { + const access = await token(); + const timeout = AbortSignal.timeout( + timeoutMs ?? DEFAULT_MODELS_REQUEST_TIMEOUT_MS, + ); + return fetch(CODEX_MODELS_URL, { + method: "GET", + headers: codexAuthHeadersForToken(access, COMMAND_NAME), + signal: + signal === undefined ? timeout : AbortSignal.any([signal, timeout]), + }); + }, + parse: parseCodexModels, + }); +} + +// The last live list per login, so a list is only ever read back for the login +// that served it and a reconnected profile never shows the previous account's +// list. A login with no account id is told apart by its creation time: a token +// refresh keeps that, and a new sign-in changes it. +const liveLists = new Map(); + +function listKey( + profile: Pick, + accountId: string | undefined, +) { + return JSON.stringify([profile.name, accountId ?? profile.createdAt]); +} + +/** Sync picker ids for a ChatGPT profile: its login's last live list, else the built-in list. Never empty. */ +export function selectableCodexModelIds( + profile: CodexProfile, +): readonly [string, ...string[]] { + return ( + liveLists.get(listKey(profile, profile.tokens.accountId)) ?? + CODEX_DEFAULT_MODELS + ); +} + +async function prefetchProfileModels(profile: CodexProfile): Promise { + const catalogLabel = `ChatGPT profile ${profile.name}`; + // A token refresh can name an account the stored profile does not have yet, + // so the token that made the request picks where its list is kept. A token + // read that fails gets one more try before the fetch counts as failed. + let accountId = profile.tokens.accountId; + const state = await createCodexModelCatalog(catalogLabel, async () => { + const access = await getValidCodexToken(profile.name).catch(() => + getValidCodexToken(profile.name), + ); + accountId = access.accountId; + return access; + }).discoverModels(); + const key = listKey(profile, accountId); + if (state.status === "models") { + liveLists.set(key, state.models); + return; + } + // Empty and failed fetches leave a live list in place: stale-but-live beats + // the built-in list. + logCatalogFallback( + catalogLabel, + state, + liveLists.has(key) ? "the last live list" : "the built-in list", + ); +} + +/** Fetch every stored ChatGPT profile's live catalog; failures keep the fallback and log why. */ +export async function prefetchCodexModels(): Promise { + const profiles = await listCodexProfiles(); + await Promise.all(profiles.map(prefetchProfileModels)); +} + +/** One fetch for a sign-in not yet saved as a profile. Never rejects; a failure is a non-`models` state. */ +export function discoverStagedCodexModels( + tokens: CodexTokens, +): Promise { + return createCodexModelCatalog("ChatGPT sign-in", () => + refreshStagedCodexTokens(tokens), + ).discoverModels(); +} + +/** + * Keep the list a sign-in showed once its profile is saved, so the fetch after + * connecting failing leaves the models the operator just saw, not the built-in + * list. + */ +export function rememberConnectedCodexModels( + profile: CodexProfile, + models: readonly string[], +): void { + const [first, ...rest] = models; + if (first === undefined) return; + liveLists.set(listKey(profile, profile.tokens.accountId), [first, ...rest]); +} + +export function resetCodexModelDiscoveryForTests(): void { + liveLists.clear(); +} diff --git a/src/tui/onboarding.test.ts b/src/tui/onboarding.test.ts index 3d3940a06..156d3e85b 100644 --- a/src/tui/onboarding.test.ts +++ b/src/tui/onboarding.test.ts @@ -267,6 +267,15 @@ describe("runOnboarding settings source", () => { expiresAt: 0, }, commit: () => writeXAIAuthProfile(testHome, "work"), + profile: { + name: "work", + createdAt: 0, + tokens: { + access: "work-access-token", + refresh: "work-refresh-token", + expiresAt: 0, + }, + }, }, }, ); diff --git a/src/tui/provider-connect-catalog-refresh.test.ts b/src/tui/provider-connect-catalog-refresh.test.ts new file mode 100644 index 000000000..674323a65 --- /dev/null +++ b/src/tui/provider-connect-catalog-refresh.test.ts @@ -0,0 +1,173 @@ +// Drives wireSettings().onConnectProvider with the setup surface, focus +// repaint and catalog prefetches replaced, and asserts which connects refresh +// the ChatGPT catalog. The gate keys on the connected provider's name rather +// than the provider the connect started from, unlike the Go/Zen gates. +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; + +import { saveGlobalSettings } from "../config/settings.js"; +import { + withMockedHomedir, + withMockedModule, +} from "../../testkit/mock-module.js"; +import type { RunnerServices, RunnerState } from "./runner/state.js"; +import { createGlobalSettingsWriter } from "../mcp/add-server.js"; +import pkg from "../../package.json" with { type: "json" }; +import type { ConnectProviderResult } from "./provider/connect.js"; +import type { ProductHostConnectRequest } from "./product-host.js"; + +let nextResult: ConnectProviderResult = { connected: false }; +let codexPrefetches = 0; +let goPrefetches = 0; +let zenPrefetches = 0; + +{ + await withMockedModule( + import.meta.resolve("./provider/connect.js"), + (real: object) => ({ + ...real, + connectProviderInline: async () => nextResult, + }), + ); + await withMockedModule( + import.meta.resolve("./shell/chrome.js"), + (real: object) => ({ ...real, applyFocus: () => undefined }), + ); + await withMockedModule( + import.meta.resolve("../provider/codex-model-catalog.js"), + (real: object) => ({ + ...real, + prefetchCodexModels: async () => { + codexPrefetches += 1; + }, + }), + ); + await withMockedModule( + import.meta.resolve("../provider/model-catalogs.js"), + (real: object) => ({ + ...real, + prefetchGoModels: async () => { + goPrefetches += 1; + return []; + }, + prefetchZenModels: async () => { + zenPrefetches += 1; + return []; + }, + }), + ); +} + +afterEach(() => { + codexPrefetches = 0; + goPrefetches = 0; + zenPrefetches = 0; + nextResult = { connected: false }; +}); + +async function runConnect( + providerArg: string, + result: ConnectProviderResult, +): Promise { + // Imported after the module mocks above install, so settings.ts binds to + // the replaced connect flow and prefetches. + const { wireSettings } = await import("./runner/settings.js"); + const dir = await mkdtemp(join(tmpdir(), "connect-catalog-refresh-")); + const home = join(dir, "home"); + await mkdir(home, { recursive: true }); + const settingsPath = join(dir, "settings.json"); + await saveGlobalSettings(settingsPath, { + providers: {}, + telemetry: { noticeShown: true }, + lastChangelogVersion: + typeof pkg.version === "string" ? pkg.version : "0.0.0", + }); + nextResult = result; + const finished = Promise.withResolvers(); + const state = { + config: { + cwd: dir, + globalSettingsPath: settingsPath, + providers: [], + apiKey: "live-key", + baseURL: "https://live/v1", + model: "k1", + providerName: "keeper", + settings: { providers: {} }, + }, + localSettingsFile: join(dir, "local.json"), + trueGlobalSettingsPath: settingsPath, + host: { + shell: {}, + renderer: {}, + refreshModels: () => undefined, + // A successful connect reopens the picker on the new account in the + // same synchronous step that schedules the catalog refreshes. + openModels: () => finished.resolve(undefined), + }, + systemNotice: () => undefined, + } as unknown as RunnerState; + const services = { + hookManager: { getStatuses: () => [] }, + globalSettingsWriter: createGlobalSettingsWriter(settingsPath), + permissionGate: { setProviderIdentity: () => undefined }, + buildSessionSources: () => ({ sources: [], defaultSource: "" }), + directorHolder: {}, + computeAdvertised: () => [], + toolset: { dynamicRunner: { currentDefinitions: () => [] } }, + hostHolder: { instance: undefined }, + } as unknown as RunnerServices; + const request: ProductHostConnectRequest = { + onComplete: (connected) => { + if (!connected) finished.resolve(undefined); + }, + }; + try { + await withMockedHomedir(home, async () => { + const wiring = await wireSettings(state, services); + wiring.onConnectProvider(providerArg, request); + await finished.promise; + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe("post-connect ChatGPT catalog refresh gate", () => { + test("connecting ChatGPT from the codex row refreshes the codex catalog", async () => { + await runConnect("codex", { + connected: true, + providerName: "codex/work", + model: "gpt-5.5", + }); + expect(codexPrefetches).toBe(1); + expect(goPrefetches).toBe(0); + expect(zenPrefetches).toBe(0); + }); + + test("a ChatGPT account reached from a non-codex entry still refreshes", async () => { + // /connect openai -> Esc back to the provider list -> ChatGPT row. + await runConnect("openai", { + connected: true, + providerName: "codex/work", + model: "gpt-5.5", + }); + expect(codexPrefetches).toBe(1); + }); + + test("an API-key OpenAI connect does not refresh the codex catalog", async () => { + await runConnect("openai", { + connected: true, + providerName: "openai", + model: "gpt-5", + }); + expect(codexPrefetches).toBe(0); + }); + + test("a cancelled connect does not refresh the codex catalog", async () => { + await runConnect("codex", { connected: false }); + expect(codexPrefetches).toBe(0); + }); +}); diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index ec57d1a3d..4c5af5711 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -65,6 +65,7 @@ function stagedCodexOAuth( providerName: "codex/work", tokens: stagedCodexTokens, commit, + profile: { name: "work", createdAt: 0, tokens: stagedCodexTokens }, }; } diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index 3162f30df..c6bffc1ac 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { configureSync, getConfig, resetSync } from "@intx/log"; import { defined } from "../../testkit/defined.js"; +import { CODEX_DEFAULT_MODELS } from "../auth/codex/constants.js"; import { OAuthProviderScopeError } from "../auth/oauth-scope-check.js"; import { resetGoModelDiscoveryForTests } from "../provider/model-catalogs.js"; import type { OllamaDiscoveryState } from "../provider/ollama.js"; @@ -544,6 +546,10 @@ async function mountConfig( onSubmit: async () => undefined, showTelemetryNotice: false, createRenderer: async () => harness.renderer, + discoverCodexModels: async () => ({ + status: "unavailable", + message: "no network in tests", + }), ...config, }); await harness.renderOnce(); @@ -677,6 +683,8 @@ async function mountLogin(opts: { loginTimeoutMs?: number; listOAuthProfiles?: OAuthProfileLister; clipboard?: ProviderSetupConfig["clipboard"]; + discoverCodexModels?: ProviderSetupConfig["discoverCodexModels"]; + rememberCodexModels?: ProviderSetupConfig["rememberCodexModels"]; }): Promise<{ done: Promise; harness: Harness }> { return mountConfig({ onSubmit: opts.onSubmit ?? (async () => undefined), @@ -686,9 +694,67 @@ async function mountLogin(opts: { ? { loginTimeoutMs: opts.loginTimeoutMs } : {}), ...(opts.clipboard !== undefined ? { clipboard: opts.clipboard } : {}), + ...(opts.discoverCodexModels !== undefined + ? { discoverCodexModels: opts.discoverCodexModels } + : {}), + ...(opts.rememberCodexModels !== undefined + ? { rememberCodexModels: opts.rememberCodexModels } + : {}), }); } +type CodexDiscovery = Awaited< + ReturnType> +>; + +/** ChatGPT catalog stub whose fetches stay pending until a test lands them. */ +function deferredCodexDiscovery(): { + discover: NonNullable; + land: (fetch: number, models: readonly [string, ...string[]]) => void; +} { + const pending: ((result: CodexDiscovery) => void)[] = []; + return { + discover: () => + new Promise((resolve) => { + pending.push(resolve); + }), + land: (fetch, models) => + defined( + pending[fetch], + `fetch ${String(fetch)}`, + )({ + status: "models", + models, + }), + }; +} + +/** Pick ChatGPT, accept the suggested account name, finish the staged sign-in. */ +async function signInToChatGPT( + harness: Harness, + events: ReturnType["events"], +): Promise { + await pickRow(harness, PROVIDER_IDS, "codex"); + await nameOAuthAccount(harness); + events.complete(stagedLogin("default")); + await flush(harness); +} + +/** Esc back to the finished sign-in step, then Enter into the model step again. */ +async function returnToModelStep(harness: Harness): Promise { + harness.pressKey("Escape"); + // ESC needs a disambiguation delay on the mock stdin path. + await new Promise((resolve) => setTimeout(resolve, 60)); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); +} + +/** Move past every listed ChatGPT model to the trailing type-a-model row. */ +function focusTypeModelRow(harness: Harness): void { + for (const _model of CODEX_DEFAULT_MODELS) harness.pressKey("ARROW_DOWN"); +} + /** Clear the prefilled OAuth name suggestion (typing over it would append). */ async function clearOAuthNameField(harness: Harness): Promise { await flush(harness); @@ -1025,6 +1091,337 @@ describe("runProviderSetup sign-in", () => { expect(opts[0]?.oauth?.commit).toBeFunction(); }); + test("a ChatGPT sign-in lists the account's models and preselects its first", async () => { + const seen: ProviderFormValues[] = []; + const asked: string[] = []; + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: async (tokens) => { + asked.push(tokens.access); + return { status: "models", models: ["gpt-5.6-luna", "gpt-6-sol"] }; + }, + }); + await signInToChatGPT(harness, events); + + expect(asked).toEqual(["test-access"]); + expect(harness.captureCharFrame()).not.toContain(CODEX_DEFAULT_MODELS[0]); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe("gpt-5.6-luna"); + }); + + describe("the list a sign-in showed", () => { + function rememberRecorder() { + const remembered: { name: string; models: readonly string[] }[] = []; + const rememberCodexModels: NonNullable< + ProviderSetupConfig["rememberCodexModels"] + > = (profile, models) => { + remembered.push({ name: profile.name, models }); + }; + return { remembered, rememberCodexModels }; + } + + test("is kept once the submit saves the profile, before setup returns", async () => { + const { remembered, rememberCodexModels } = rememberRecorder(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + rememberCodexModels, + discoverCodexModels: async () => ({ + status: "models", + models: ["gpt-5.6-luna", "gpt-6-sol"], + }), + }); + await signInToChatGPT(harness, events); + + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(remembered).toEqual([ + { name: "default", models: ["gpt-5.6-luna", "gpt-6-sol"] }, + ]); + }); + + test("is not kept when the submit fails", async () => { + const { remembered, rememberCodexModels } = rememberRecorder(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + rememberCodexModels, + onSubmit: async () => { + throw new Error("connection test failed"); + }, + discoverCodexModels: async () => ({ + status: "models", + models: ["gpt-5.6-luna", "gpt-6-sol"], + }), + }); + await signInToChatGPT(harness, events); + + harness.pressKey("Enter"); + await flush(harness); + expect(remembered).toEqual([]); + + harness.pressKey("Ctrl+C"); + expect(await done).toBe(false); + }); + + test("is not kept when it is only the built-in list", async () => { + const { remembered, rememberCodexModels } = rememberRecorder(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + rememberCodexModels, + }); + await signInToChatGPT(harness, events); + + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(remembered).toEqual([]); + }); + }); + + test("a model the operator moved to survives the account's list arriving", async () => { + const seen: ProviderFormValues[] = []; + const discovery = deferredCodexDiscovery(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: discovery.discover, + }); + await signInToChatGPT(harness, events); + const moved = defined(CODEX_DEFAULT_MODELS[1]); + harness.pressKey("ARROW_DOWN"); + await flush(harness); + + discovery.land(0, ["gpt-5.6-luna", moved]); + await flush(harness); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe(moved); + }); + + test("moving away from the prefilled model and back still takes the account's first", async () => { + const seen: ProviderFormValues[] = []; + const discovery = deferredCodexDiscovery(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: discovery.discover, + }); + await signInToChatGPT(harness, events); + harness.pressKey("ARROW_DOWN"); + harness.pressKey("ARROW_UP"); + await flush(harness); + + discovery.land(0, ["gpt-5.6-luna", defined(CODEX_DEFAULT_MODELS[0])]); + await flush(harness); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe("gpt-5.6-luna"); + }); + + test("focus on the type-a-model row survives the account's list arriving", async () => { + const seen: ProviderFormValues[] = []; + const discovery = deferredCodexDiscovery(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: discovery.discover, + }); + await signInToChatGPT(harness, events); + focusTypeModelRow(harness); + await flush(harness); + + discovery.land(0, ["gpt-5.6-luna", "gpt-6-sol"]); + await flush(harness); + harness.pressKey("Enter"); + await flush(harness); + for (const ch of "my-model") harness.pressKey(ch); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe("my-model"); + }); + + test("a typed model entered before the account's list arrives is kept", async () => { + const seen: ProviderFormValues[] = []; + const discovery = deferredCodexDiscovery(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: discovery.discover, + }); + await signInToChatGPT(harness, events); + focusTypeModelRow(harness); + harness.pressKey("Enter"); + await flush(harness); + + discovery.land(0, ["gpt-5.6-luna", "gpt-6-sol"]); + await flush(harness); + for (const ch of "my-model") harness.pressKey(ch); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe("my-model"); + }); + + test("a failed fetch on returning to the model step keeps the account's list", async () => { + const seen: ProviderFormValues[] = []; + const results: CodexDiscovery[] = [ + { status: "models", models: ["gpt-5.6-luna", "gpt-6-sol"] }, + { status: "unavailable", message: "offline" }, + ]; + let calls = 0; + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: async () => { + const result = defined(results[calls]); + calls += 1; + return result; + }, + }); + await signInToChatGPT(harness, events); + + await returnToModelStep(harness); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(calls).toBe(2); + expect(seen[0]?.model).toBe("gpt-5.6-luna"); + }); + + test("an empty sign-in catalog warns and keeps the listed models", async () => { + const seen: ProviderFormValues[] = []; + const records: { level: string; text: string }[] = []; + const previous = getConfig(); + configureSync({ + reset: true, + sinks: { + test: (record) => { + records.push({ + level: record.level, + text: record.message.map(String).join(""), + }); + }, + }, + loggers: [{ category: [], lowestLevel: "debug", sinks: ["test"] }], + }); + try { + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: async () => ({ status: "empty" }), + }); + await signInToChatGPT(harness, events); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + } finally { + if (previous === null) resetSync(); + else configureSync({ ...previous, reset: true }); + } + + expect(seen[0]?.model).toBe(CODEX_DEFAULT_MODELS[0]); + const misses = records.filter((r) => r.text.includes("ChatGPT sign-in")); + expect(misses.map((r) => r.level)).toEqual(["warning"]); + }); + + test("signing in to another account whose fetch fails is not offered the first account's list", async () => { + const seen: ProviderFormValues[] = []; + const results: CodexDiscovery[] = [ + { status: "models", models: ["account-a-only", "gpt-6-sol"] }, + { status: "unavailable", message: "offline" }, + ]; + let calls = 0; + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: async () => { + const result = defined(results[calls]); + calls += 1; + return result; + }, + }); + await signInToChatGPT(harness, events); + expect(harness.captureCharFrame()).toContain("account-a-only"); + + for (const _step of ["login", "name"]) { + harness.pressKey("Escape"); + // ESC needs a disambiguation delay on the mock stdin path. + await new Promise((resolve) => setTimeout(resolve, 60)); + await flush(harness); + } + await clearOAuthNameField(harness); + type(harness, "other"); + harness.pressKey("Enter"); + await flush(harness); + events.complete(stagedLogin("other")); + await flush(harness); + + expect(harness.captureCharFrame()).not.toContain("account-a-only"); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(calls).toBe(2); + expect(seen[0]?.name).toBe("codex/other"); + expect(seen[0]?.model).toBe(CODEX_DEFAULT_MODELS[0]); + }); + + test("a superseded fetch that lands late is ignored", async () => { + const seen: ProviderFormValues[] = []; + const discovery = deferredCodexDiscovery(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: discovery.discover, + }); + await signInToChatGPT(harness, events); + await returnToModelStep(harness); + + discovery.land(1, ["gpt-6-sol", "gpt-5.6-luna"]); + await flush(harness); + discovery.land(0, ["gpt-5.6-luna"]); + await flush(harness); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe("gpt-6-sol"); + }); + test("definitive OAuth scope failure cannot be saved anyway", async () => { await withTempDir(async (dir) => { const settingsPath = join(dir, "settings.json"); diff --git a/src/tui/provider/choices.ts b/src/tui/provider/choices.ts index 65a7aff47..7b9bfa4cd 100644 --- a/src/tui/provider/choices.ts +++ b/src/tui/provider/choices.ts @@ -416,7 +416,7 @@ export function chooseProviderRow( state.stepIndex += 1; } -/** Rebuild the pick-list rows for the model step, then prefetch the Go catalog. */ +/** Rebuild the pick-list rows for the model step, then prefetch its live catalog. */ export function enterModelListRows( state: SetupState, renderer: CliRenderer, @@ -438,6 +438,7 @@ export function enterModelListRows( }); discovery.beginGoPrefetch(); discovery.beginZenPrefetch(); + discovery.beginCodexPrefetch(); } /** Rebuild the pick-list rows for the provider step, keeping the prior pick focused. */ diff --git a/src/tui/provider/discovery.ts b/src/tui/provider/discovery.ts index 3a4e83b38..ea8249be3 100644 --- a/src/tui/provider/discovery.ts +++ b/src/tui/provider/discovery.ts @@ -1,8 +1,9 @@ /** * Background model discovery for the setup surface: Ollama's installed-model - * list (replaces the seeded pick-list once it resolves) and the Go / Zen - * catalog prefetches. All mutate shared state, repaint, and ignore - * resolutions from superseded attempts. + * list (replaces the seeded pick-list once it resolves), the Go / Zen + * catalog prefetches, and the signed-in ChatGPT account's catalog. All + * mutate shared state, repaint, and ignore resolutions from superseded + * attempts. */ import { createOverlayList } from "../shell/overlay-list.js"; @@ -10,9 +11,11 @@ import type { CliRenderer } from "@opentui/core"; import { getLogger } from "@intx/log"; import { modelChoiceRows, + modelFromRowId, providerListHeight, TYPE_MODEL_ID, } from "./choices.js"; +import { CODEX_DEFAULT_MODELS } from "../../auth/codex/constants.js"; import { startRamp, stopRamp } from "./surface.js"; import type { DiscoveryFlows, @@ -21,6 +24,7 @@ import type { Surface, } from "./types.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { logCatalogFallback } from "../../provider/bounded-model-catalog.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "tui", "provider-discovery"]); @@ -43,6 +47,10 @@ export function createDiscoveryFlows( state.zenPrefetchAttempt += 1; }; + const abandonCodexPrefetch = (): void => { + state.codexPrefetchAttempt += 1; + }; + const beginOllamaDiscovery = (): void => { if (!selectors.isOllamaModelStep()) return; abandonOllamaDiscovery(); @@ -188,6 +196,98 @@ export function createDiscoveryFlows( }); }; + // Unlike Go and Zen, the list belongs to the account just signed in, so its + // first model replaces the prefilled default as the pick unless the + // operator has already moved to another model or to the type-a-model row. + // A fetch that fails leaves the list on screen as it is, which is only + // right while that list belongs to this sign-in: a different account + // starts again from the built-in list. + const beginCodexPrefetch = (): void => { + if (!selectors.isCodexModelListStep()) return; + const login = state.loginResult; + if (login?.kind !== "codex" || state.choice === null) return; + abandonCodexPrefetch(); + if (state.codexListLogin !== null && state.codexListLogin !== login) { + state.codexListLogin = null; + state.choice = { + ...state.choice, + models: CODEX_DEFAULT_MODELS, + defaultModel: CODEX_DEFAULT_MODELS[0], + }; + state.values.model = state.choice.defaultModel; + state.listRows = modelChoiceRows(state.choice); + state.list = createOverlayList(state.renderer as CliRenderer, { + count: state.listRows.length, + items: providerListHeight(state.renderer), + activeIndex: 0, + }); + surface.paint(); + } + const attempt = state.codexPrefetchAttempt; + const prefill = state.choice.defaultModel; + void state + .discoverCodexModels(login.tokens) + .then((discovered) => { + if ( + state.settled || + attempt !== state.codexPrefetchAttempt || + !selectors.isCodexModelListStep() || + state.choice === null + ) { + return; + } + if (discovered.status !== "models") { + logCatalogFallback( + "ChatGPT sign-in", + discovered, + "the listed models", + ); + return; + } + const ids = discovered.models; + const listed = state.choice.models; + const same = + ids.length === listed.length && + ids.every((id, i) => id === listed[i]); + if (same) return; + const choiceId = state.choice.id; + const focusedId = state.listRows[state.list.activeIndex]?.id; + const focused = + focusedId === undefined || focusedId === TYPE_MODEL_ID + ? undefined + : modelFromRowId(choiceId, focusedId); + const pick = + focused !== undefined && focused !== prefill && ids.includes(focused) + ? focused + : ids[0]; + state.choice = { + ...state.choice, + models: [...ids], + defaultModel: ids[0], + }; + state.codexListLogin = login; + state.listRows = modelChoiceRows(state.choice); + const focusTypeRow = focusedId === TYPE_MODEL_ID; + if (!focusTypeRow) state.values.model = pick; + const found = state.listRows.findIndex((row) => + focusTypeRow + ? row.id === TYPE_MODEL_ID + : modelFromRowId(choiceId, row.id) === pick, + ); + state.list = createOverlayList(state.renderer as CliRenderer, { + count: state.listRows.length, + items: providerListHeight(state.renderer), + activeIndex: found >= 0 ? found : 0, + }); + surface.paint(); + }) + .catch((err: unknown) => { + // discoverCodexModels never rejects, so only a failure while swapping + // the list in lands here; the list already on screen stays usable. + logger.warn`ChatGPT sign-in model list update failed error=${err instanceof Error ? err.message : String(err)}`; + }); + }; + return { beginOllamaDiscovery, abandonOllamaDiscovery, @@ -195,5 +295,7 @@ export function createDiscoveryFlows( abandonGoPrefetch, beginZenPrefetch, abandonZenPrefetch, + beginCodexPrefetch, + abandonCodexPrefetch, }; } diff --git a/src/tui/provider/oauth.ts b/src/tui/provider/oauth.ts index 22f16ee51..3f4f6f3ff 100644 --- a/src/tui/provider/oauth.ts +++ b/src/tui/provider/oauth.ts @@ -288,6 +288,7 @@ export function createLoginFlow( tokens: staged.profile.tokens, commit: staged.commit, providerName: oauthProviderName(kind, staged.profile.name), + profile: staged.profile, }; state.loginResult = result; state.values.name = result.providerName; diff --git a/src/tui/provider/setup.ts b/src/tui/provider/setup.ts index b86d84796..f5eca3224 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -26,6 +26,10 @@ import { prefetchGoModels as prefetchGoModelsRequest, prefetchZenModels as prefetchZenModelsRequest, } from "../../provider/model-catalogs.js"; +import { + discoverStagedCodexModels as discoverCodexModelsRequest, + rememberConnectedCodexModels as rememberCodexModelsRequest, +} from "../../provider/codex-model-catalog.js"; import { isZenProviderId } from "../../../packages/zen/src/index.js"; import { resolveSideMargin } from "../geometry/zones.js"; import { createSystemClipboard } from "../system-clipboard.js"; @@ -166,6 +170,12 @@ export async function runProviderSetup( goPrefetchAttempt: 0, prefetchZenModels: config.prefetchZenModels ?? prefetchZenModelsRequest, zenPrefetchAttempt: 0, + discoverCodexModels: + config.discoverCodexModels ?? discoverCodexModelsRequest, + rememberCodexModels: + config.rememberCodexModels ?? rememberCodexModelsRequest, + codexPrefetchAttempt: 0, + codexListLogin: null, listRows: initialRows, list: createOverlayList(renderer as CliRenderer, { count: initialRows.length, @@ -244,6 +254,11 @@ export async function runProviderSetup( isZenProviderId(state.choice.id) && !state.choice.custom && !state.typedModel; + const isCodexModelListStep = (): boolean => + currentStep() === "model" && + state.choice !== null && + state.choice.oauth === "codex" && + !state.typedModel; const selectors: SetupSelectors = { steps, currentStep, @@ -252,6 +267,7 @@ export async function runProviderSetup( isAccountNameStep, isGoModelListStep, isZenModelListStep, + isCodexModelListStep, }; const isLoginStep = (): boolean => currentStep() === "login"; @@ -353,7 +369,18 @@ export async function runProviderSetup( ...(state.loginResult !== null ? { oauth: state.loginResult } : {}), }) .then( - () => settle(true), + () => { + // Before settling: the picker reads the list as soon as setup returns. + const login = state.loginResult; + if ( + login?.kind === "codex" && + state.codexListLogin === login && + state.choice !== null + ) { + state.rememberCodexModels(login.profile, state.choice.models); + } + settle(true); + }, (err: unknown) => { stopRamp(state); state.submitting = false; @@ -490,6 +517,7 @@ export async function runProviderSetup( } if (isGoModelListStep()) discovery.abandonGoPrefetch(); if (isZenModelListStep()) discovery.abandonZenPrefetch(); + if (isCodexModelListStep()) discovery.abandonCodexPrefetch(); state.stepIndex = previousSamplingStepIndex( steps(), state.stepIndex, diff --git a/src/tui/provider/surface.ts b/src/tui/provider/surface.ts index 441b8b48c..bfac9f2ea 100644 --- a/src/tui/provider/surface.ts +++ b/src/tui/provider/surface.ts @@ -100,6 +100,7 @@ export function teardownSurface( discovery.abandonOllamaDiscovery(); discovery.abandonGoPrefetch(); discovery.abandonZenPrefetch(); + discovery.abandonCodexPrefetch(); state.renderer.keyInput.off("keypress", onKey); surface.input.off(InputRenderableEvents.ENTER, onEnter); surface.input.off(InputRenderableEvents.INPUT, onInput); diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts index ead3c4b3e..823af4eb9 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -24,6 +24,10 @@ import type { prefetchGoModels as prefetchGoModelsRequest, prefetchZenModels as prefetchZenModelsRequest, } from "../../provider/model-catalogs.js"; +import type { + discoverStagedCodexModels as discoverCodexModelsRequest, + rememberConnectedCodexModels as rememberCodexModelsRequest, +} from "../../provider/codex-model-catalog.js"; import type { ResidualCatalogEntry } from "../residuals.js"; import type { OverlayList } from "../shell/internals.js"; import type { SetupStep } from "./steps.js"; @@ -92,6 +96,8 @@ export interface OAuthResult { readonly commit: () => Promise; /** Settings/catalog name the stored profile projects to. */ readonly providerName: string; + /** The profile `commit` saves. */ + readonly profile: AuthProfile; } export interface ProviderPreset { @@ -162,6 +168,10 @@ export interface ProviderSetupConfig { readonly prefetchGoModels?: typeof prefetchGoModelsRequest; /** Zen catalog prefetch override so setup tests stay off the network. */ readonly prefetchZenModels?: typeof prefetchZenModelsRequest; + /** ChatGPT sign-in catalog override so setup tests stay off the network. */ + readonly discoverCodexModels?: typeof discoverCodexModelsRequest; + /** Where a saved ChatGPT sign-in's live list goes; a test double in setup tests. */ + readonly rememberCodexModels?: typeof rememberCodexModelsRequest; /** Start on the given provider's first form step, skipping the pick-list. */ readonly initialProviderId?: string; /** Prefill the OAuth account-name step with the profile slug being re-keyed. */ @@ -225,6 +235,12 @@ export interface SetupState { goPrefetchAttempt: number; readonly prefetchZenModels: typeof prefetchZenModelsRequest; zenPrefetchAttempt: number; + readonly discoverCodexModels: typeof discoverCodexModelsRequest; + readonly rememberCodexModels: typeof rememberCodexModelsRequest; + codexPrefetchAttempt: number; + // The sign-in whose live list the ChatGPT model step shows; null while it + // shows the built-in list. + codexListLogin: OAuthResult | null; listRows: readonly ResidualCatalogEntry[]; list: OverlayList; settled: boolean; @@ -255,6 +271,8 @@ export interface DiscoveryFlows { abandonGoPrefetch(): void; beginZenPrefetch(): void; abandonZenPrefetch(): void; + beginCodexPrefetch(): void; + abandonCodexPrefetch(): void; } /** Multi-instance "name" step (OAuth accounts and API-key instances). */ @@ -281,4 +299,5 @@ export interface SetupSelectors { isAccountNameStep(): boolean; isGoModelListStep(): boolean; isZenModelListStep(): boolean; + isCodexModelListStep(): boolean; } diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index a5a613e9f..ebdb2bc6f 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -65,6 +65,8 @@ import { resolveWaitForApproval } from "../../agent/tool-execution-watchdog.js"; import { hostOf, type RunnerServices, type RunnerState } from "./state.js"; import type { ProductHostConnectRequest } from "../product-host.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { isCodexProviderName } from "../../config/codex-providers.js"; +import { prefetchCodexModels } from "../../provider/codex-model-catalog.js"; import { refreshCatalogAfterPrefetch } from "./catalog-refresh.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -384,6 +386,14 @@ export async function wireSettings( if (isZenProvider({ name: providerName })) { refreshCatalogAfterPrefetch(state, services, "zen", prefetchZenModels); } + if (isCodexProviderName(connectedName)) { + refreshCatalogAfterPrefetch( + state, + services, + "chatgpt", + prefetchCodexModels, + ); + } })().catch(caughtDebug(tuiLogger, "provider connect failed")); }; diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index fd3ca75d7..683b9ab33 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -78,6 +78,7 @@ import { buildFleetDryContinuationMessage, buildMailboxMailMessage, } from "../../session/runtime-assembly.js"; +import { prefetchCodexModels } from "../../provider/codex-model-catalog.js"; import { refreshCatalogAfterPrefetch } from "./catalog-refresh.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -245,6 +246,14 @@ export function wirePostStartup( if (state.config.providers.some((p) => isZenProvider(p))) { refreshCatalogAfterPrefetch(state, services, "zen", prefetchZenModels); } + if (state.config.providers.some((p) => p.codexProfile !== undefined)) { + refreshCatalogAfterPrefetch( + state, + services, + "chatgpt", + prefetchCodexModels, + ); + } const shutdownRuntime = createRuntimeShutdown({ disposeHost: hostOf(state).dispose,