From f9fcf1f29fe4475e1b925e244e1a250474a7463a Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Wed, 28 Oct 2026 22:39:41 -0700 Subject: [PATCH 1/5] feat(provider): list the models a ChatGPT login serves The ChatGPT picker offered a built-in list that went stale as OpenAI added models and retired others for ChatGPT sign-in, so a fresh login could default to a model its account no longer serves. The backend returns no models to client_version 0.50.0, so requests now send the current Codex CLI version, including the sign-in scope check. --- docs/IMPLEMENTATION.md | 2 + src/auth/codex/constants.ts | 23 +- src/auth/oauth-scope-check.ts | 14 +- src/config/codex-providers.ts | 8 +- src/provider/codex-model-catalog.test.ts | 438 +++++++++++++++++++++++ src/provider/codex-model-catalog.ts | 137 +++++++ src/tui/runner/wiring.ts | 9 + 7 files changed, 608 insertions(+), 23 deletions(-) create mode 100644 src/provider/codex-model-catalog.test.ts create mode 100644 src/provider/codex-model-catalog.ts diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fe13f6d5d..381f335f2 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`). The list is cached per profile and account for the process, 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/codex-model-catalog.test.ts b/src/provider/codex-model-catalog.test.ts new file mode 100644 index 000000000..445e74ee9 --- /dev/null +++ b/src/provider/codex-model-catalog.test.ts @@ -0,0 +1,438 @@ +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 { + prefetchCodexModels, + 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): CodexProfile { + return { + name, + createdAt: 0, + tokens: { + access: `stored-${name}`, + refresh: "r", + expiresAt: 1, + ...(accountId !== undefined ? { accountId } : {}), + }, + }; +} + +let storedProfiles: CodexProfile[] = []; +let tokenFailure: Error | undefined; +// 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; + 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; + 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 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"); + }); +}); diff --git a/src/provider/codex-model-catalog.ts b/src/provider/codex-model-catalog.ts new file mode 100644 index 000000000..3550557d6 --- /dev/null +++ b/src/provider/codex-model-catalog.ts @@ -0,0 +1,137 @@ +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 } from "../auth/codex/session.js"; +import type { CodexProfile } from "../auth/codex/store.js"; +import { COMMAND_NAME } from "../branding.js"; +import { listCodexProfiles } from "../config/oauth-stores.js"; +import { + type CatalogDiscoveryState, + createBoundedModelCatalog, +} 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, + }); +} + +// One catalog per profile and account, so a list is only ever read back for +// the account that served it and a reconnected profile never shows the +// previous account's list. +const catalogs = new Map>(); + +function catalogKey(profileName: string, accountId: string | undefined) { + return JSON.stringify([profileName, accountId ?? null]); +} + +/** Sync picker ids for a ChatGPT profile: its account's last live list, else the built-in list. Never empty. */ +export function selectableCodexModelIds( + profile: CodexProfile, +): readonly [string, ...string[]] { + return ( + catalogs + .get(catalogKey(profile.name, profile.tokens.accountId)) + ?.selectableModelIds() ?? CODEX_DEFAULT_MODELS + ); +} + +async function prefetchProfileModels(profile: CodexProfile): Promise { + // A token refresh can name an account the stored profile does not have + // yet, so the refreshed token picks the catalog. When the refresh fails, + // the catalog's own request fails the same way and logs why. + const accountId = await getValidCodexToken(profile.name).then( + (access) => access.accountId, + () => profile.tokens.accountId, + ); + const key = catalogKey(profile.name, accountId); + const catalog = + catalogs.get(key) ?? + createCodexModelCatalog(`ChatGPT profile ${profile.name}`, () => + getValidCodexToken(profile.name), + ); + catalogs.set(key, catalog); + await catalog.prefetchModels(); +} + +/** 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)); +} + +export function resetCodexModelDiscoveryForTests(): void { + catalogs.clear(); +} 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, From 10bcb63ba9225d0ceca63b4ddf599e9dd24c2a57 Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Wed, 28 Oct 2026 22:59:06 -0700 Subject: [PATCH 2/5] feat(tui): refresh the ChatGPT catalog after connecting an account --- docs/IMPLEMENTATION.md | 2 +- .../provider-connect-catalog-refresh.test.ts | 173 ++++++++++++++++++ src/tui/runner/settings.ts | 10 + 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/tui/provider-connect-catalog-refresh.test.ts diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 381f335f2..8d8cbf141 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -420,7 +420,7 @@ 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`). The list is cached per profile and account for the process, 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. +**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 with `/connect` refreshes every profile's list for the picker. The list is cached per profile and account for the process, 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 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/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")); }; From a5f74c3ae2ce56c102d2fcd55bf1367a73f4ea3d Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Wed, 28 Oct 2026 23:03:05 -0700 Subject: [PATCH 3/5] feat(tui): offer the account's live ChatGPT models at connect --- docs/IMPLEMENTATION.md | 2 +- src/provider/bounded-model-catalog.ts | 73 +++--- src/provider/codex-model-catalog.test.ts | 52 ++++ src/provider/codex-model-catalog.ts | 17 +- src/tui/provider-setup.test.ts | 321 +++++++++++++++++++++++ src/tui/provider/choices.ts | 3 +- src/tui/provider/discovery.ts | 108 +++++++- src/tui/provider/setup.ts | 12 + src/tui/provider/surface.ts | 1 + src/tui/provider/types.ts | 11 + 10 files changed, 562 insertions(+), 38 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8d8cbf141..96c5100d3 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -420,7 +420,7 @@ 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 with `/connect` refreshes every profile's list for the picker. The list is cached per profile and account for the process, 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. +**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` then refreshes every profile's list for the picker. The list is cached per profile and account for the process, 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 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 index 445e74ee9..29a11b66b 100644 --- a/src/provider/codex-model-catalog.test.ts +++ b/src/provider/codex-model-catalog.test.ts @@ -12,6 +12,7 @@ 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, resetCodexModelDiscoveryForTests, selectableCodexModelIds, @@ -436,3 +437,54 @@ describe("contract drift", () => { 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 index 3550557d6..b204ba725 100644 --- a/src/provider/codex-model-catalog.ts +++ b/src/provider/codex-model-catalog.ts @@ -5,8 +5,12 @@ import { CODEX_DEFAULT_MODELS, CODEX_MODELS_URL, } from "../auth/codex/constants.js"; -import { type CodexAccess, getValidCodexToken } from "../auth/codex/session.js"; -import type { CodexProfile } from "../auth/codex/store.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 { @@ -132,6 +136,15 @@ export async function prefetchCodexModels(): Promise { 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(); +} + export function resetCodexModelDiscoveryForTests(): void { catalogs.clear(); } diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index 3162f30df..a535f5980 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,7 @@ async function mountLogin(opts: { loginTimeoutMs?: number; listOAuthProfiles?: OAuthProfileLister; clipboard?: ProviderSetupConfig["clipboard"]; + discoverCodexModels?: ProviderSetupConfig["discoverCodexModels"]; }): Promise<{ done: Promise; harness: Harness }> { return mountConfig({ onSubmit: opts.onSubmit ?? (async () => undefined), @@ -686,9 +693,64 @@ async function mountLogin(opts: { ? { loginTimeoutMs: opts.loginTimeoutMs } : {}), ...(opts.clipboard !== undefined ? { clipboard: opts.clipboard } : {}), + ...(opts.discoverCodexModels !== undefined + ? { discoverCodexModels: opts.discoverCodexModels } + : {}), }); } +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 +1087,265 @@ 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"); + }); + + 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/setup.ts b/src/tui/provider/setup.ts index b86d84796..48f77e29c 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -26,6 +26,7 @@ import { prefetchGoModels as prefetchGoModelsRequest, prefetchZenModels as prefetchZenModelsRequest, } from "../../provider/model-catalogs.js"; +import { discoverStagedCodexModels as discoverCodexModelsRequest } 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 +167,10 @@ export async function runProviderSetup( goPrefetchAttempt: 0, prefetchZenModels: config.prefetchZenModels ?? prefetchZenModelsRequest, zenPrefetchAttempt: 0, + discoverCodexModels: + config.discoverCodexModels ?? discoverCodexModelsRequest, + codexPrefetchAttempt: 0, + codexListLogin: null, listRows: initialRows, list: createOverlayList(renderer as CliRenderer, { count: initialRows.length, @@ -244,6 +249,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 +262,7 @@ export async function runProviderSetup( isAccountNameStep, isGoModelListStep, isZenModelListStep, + isCodexModelListStep, }; const isLoginStep = (): boolean => currentStep() === "login"; @@ -490,6 +501,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..1eb50251b 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -24,6 +24,7 @@ import type { prefetchGoModels as prefetchGoModelsRequest, prefetchZenModels as prefetchZenModelsRequest, } from "../../provider/model-catalogs.js"; +import type { discoverStagedCodexModels as discoverCodexModelsRequest } 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"; @@ -162,6 +163,8 @@ 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; /** 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 +228,11 @@ export interface SetupState { goPrefetchAttempt: number; readonly prefetchZenModels: typeof prefetchZenModelsRequest; zenPrefetchAttempt: number; + readonly discoverCodexModels: typeof discoverCodexModelsRequest; + 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 +263,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 +291,5 @@ export interface SetupSelectors { isAccountNameStep(): boolean; isGoModelListStep(): boolean; isZenModelListStep(): boolean; + isCodexModelListStep(): boolean; } From 75655fe5e52bfaab5083647831d7dc2b8846890e Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Sun, 1 Nov 2026 22:04:25 -0500 Subject: [PATCH 4/5] fix(provider): keep each ChatGPT list with the login that served it The fetched list was saved under the account from a token read made before the request. When that read failed, the request's own read could still succeed and name the account, so the picker looked under that account, found nothing, and showed the built-in models. The token that makes the request now picks where its list is kept, and a failed read gets one more try. Two logins with no account id shared one saved list, so a reconnect whose fetch failed or came back empty showed the previous login's models while requests used the new token. Such a login is now told apart by its creation time, which a token refresh keeps and a new sign-in changes. The list shown while connecting was thrown away, so a failed fetch after connecting fell back to the built-in models. It is now kept once the submit saves the profile, before the picker reads it. --- docs/IMPLEMENTATION.md | 2 +- src/provider/codex-model-catalog.test.ts | 119 ++++++++++++++++++++++- src/provider/codex-model-catalog.ts | 75 +++++++++----- src/tui/onboarding.test.ts | 9 ++ src/tui/provider-setup-submit.test.ts | 1 + src/tui/provider-setup.test.ts | 76 +++++++++++++++ src/tui/provider/oauth.ts | 1 + src/tui/provider/setup.ts | 20 +++- src/tui/provider/types.ts | 10 +- 9 files changed, 283 insertions(+), 30 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 96c5100d3..6c74854b5 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -420,7 +420,7 @@ 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` then refreshes every profile's list for the picker. The list is cached per profile and account for the process, 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. +**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 diff --git a/src/provider/codex-model-catalog.test.ts b/src/provider/codex-model-catalog.test.ts index 29a11b66b..8548e87f7 100644 --- a/src/provider/codex-model-catalog.test.ts +++ b/src/provider/codex-model-catalog.test.ts @@ -14,6 +14,7 @@ import { codexProfilesToCatalogEntries } from "../config/codex-providers.js"; import { discoverStagedCodexModels, prefetchCodexModels, + rememberConnectedCodexModels, resetCodexModelDiscoveryForTests, selectableCodexModelIds, } from "./codex-model-catalog.js"; @@ -111,10 +112,14 @@ const RECORDED_LISTED_IDS = [ "gpt-5.6-luna", ]; -function profile(name: string, accountId?: string): CodexProfile { +function profile( + name: string, + accountId?: string, + createdAt = 0, +): CodexProfile { return { name, - createdAt: 0, + createdAt, tokens: { access: `stored-${name}`, refresh: "r", @@ -126,6 +131,8 @@ function profile(name: string, accountId?: string): CodexProfile { 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; @@ -143,6 +150,10 @@ await withMockedModule( ...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; @@ -165,6 +176,7 @@ afterEach(() => { globalThis.fetch = originalFetch; storedProfiles = []; tokenFailure = undefined; + tokenFailuresLeft = 0; accountLearnedOnRefresh = undefined; resetCodexModelDiscoveryForTests(); }); @@ -329,6 +341,109 @@ describe("ChatGPT model catalog", () => { 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 }]); diff --git a/src/provider/codex-model-catalog.ts b/src/provider/codex-model-catalog.ts index b204ba725..29de91f91 100644 --- a/src/provider/codex-model-catalog.ts +++ b/src/provider/codex-model-catalog.ts @@ -16,6 +16,7 @@ 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"; @@ -92,42 +93,54 @@ function createCodexModelCatalog( }); } -// One catalog per profile and account, so a list is only ever read back for -// the account that served it and a reconnected profile never shows the -// previous account's list. -const catalogs = new Map>(); +// 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 catalogKey(profileName: string, accountId: string | undefined) { - return JSON.stringify([profileName, accountId ?? null]); +function listKey( + profile: Pick, + accountId: string | undefined, +) { + return JSON.stringify([profile.name, accountId ?? profile.createdAt]); } -/** Sync picker ids for a ChatGPT profile: its account's last live list, else the built-in list. Never empty. */ +/** 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 ( - catalogs - .get(catalogKey(profile.name, profile.tokens.accountId)) - ?.selectableModelIds() ?? CODEX_DEFAULT_MODELS + liveLists.get(listKey(profile, profile.tokens.accountId)) ?? + CODEX_DEFAULT_MODELS ); } async function prefetchProfileModels(profile: CodexProfile): Promise { - // A token refresh can name an account the stored profile does not have - // yet, so the refreshed token picks the catalog. When the refresh fails, - // the catalog's own request fails the same way and logs why. - const accountId = await getValidCodexToken(profile.name).then( - (access) => access.accountId, - () => profile.tokens.accountId, - ); - const key = catalogKey(profile.name, accountId); - const catalog = - catalogs.get(key) ?? - createCodexModelCatalog(`ChatGPT profile ${profile.name}`, () => + 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), ); - catalogs.set(key, catalog); - await catalog.prefetchModels(); + 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. */ @@ -145,6 +158,20 @@ export function discoverStagedCodexModels( ).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 { - catalogs.clear(); + 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-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 a535f5980..c6bffc1ac 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -684,6 +684,7 @@ async function mountLogin(opts: { listOAuthProfiles?: OAuthProfileLister; clipboard?: ProviderSetupConfig["clipboard"]; discoverCodexModels?: ProviderSetupConfig["discoverCodexModels"]; + rememberCodexModels?: ProviderSetupConfig["rememberCodexModels"]; }): Promise<{ done: Promise; harness: Harness }> { return mountConfig({ onSubmit: opts.onSubmit ?? (async () => undefined), @@ -696,6 +697,9 @@ async function mountLogin(opts: { ...(opts.discoverCodexModels !== undefined ? { discoverCodexModels: opts.discoverCodexModels } : {}), + ...(opts.rememberCodexModels !== undefined + ? { rememberCodexModels: opts.rememberCodexModels } + : {}), }); } @@ -1111,6 +1115,78 @@ describe("runProviderSetup sign-in", () => { 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(); 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 48f77e29c..f5eca3224 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -26,7 +26,10 @@ import { prefetchGoModels as prefetchGoModelsRequest, prefetchZenModels as prefetchZenModelsRequest, } from "../../provider/model-catalogs.js"; -import { discoverStagedCodexModels as discoverCodexModelsRequest } from "../../provider/codex-model-catalog.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"; @@ -169,6 +172,8 @@ export async function runProviderSetup( zenPrefetchAttempt: 0, discoverCodexModels: config.discoverCodexModels ?? discoverCodexModelsRequest, + rememberCodexModels: + config.rememberCodexModels ?? rememberCodexModelsRequest, codexPrefetchAttempt: 0, codexListLogin: null, listRows: initialRows, @@ -364,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; diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts index 1eb50251b..823af4eb9 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -24,7 +24,10 @@ import type { prefetchGoModels as prefetchGoModelsRequest, prefetchZenModels as prefetchZenModelsRequest, } from "../../provider/model-catalogs.js"; -import type { discoverStagedCodexModels as discoverCodexModelsRequest } from "../../provider/codex-model-catalog.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"; @@ -93,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 { @@ -165,6 +170,8 @@ export interface ProviderSetupConfig { 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. */ @@ -229,6 +236,7 @@ export interface SetupState { 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. From 75fa10994b518298c1fbf43ee4cc87f54cd9842e Mon Sep 17 00:00:00 2001 From: Ben Wachman Date: Mon, 2 Nov 2026 20:18:41 -0500 Subject: [PATCH 5/5] fix(provider): key ChatGPT lists by sign-in and order their fetches A fetch stored its list under the account id on the token that served it, while the picker and the save after connecting read the id on the stored profile. A refresh that taught the profile its id, followed by a failed fetch, left the live list under the old key and showed the built-in one. Lists are now keyed by profile name and creation time, which a token refresh keeps and a reconnect changes, so every reader and writer agrees and a blank account id no longer counts as an identity. Overlapping fetches could answer in any order, so a startup fetch that returned after the list shown at connect was kept replaced it with an older view. Each request now takes a ticket when it starts, and an older answer never replaces a newer list. The connect list is newer than every fetch started before it was kept. Confirming the connect model step before the fetch returned saved the built-in default and dropped the live list, so the account's first model never became the saved one. Confirming now waits for the fetch in flight, bounded by its own timeout, then saves the pick made against the list. --- docs/IMPLEMENTATION.md | 2 +- src/provider/codex-model-catalog.test.ts | 119 +++++++++++++++++++++-- src/provider/codex-model-catalog.ts | 76 +++++++++------ src/tui/provider-setup.test.ts | 103 ++++++++++++++++++++ src/tui/provider/discovery.ts | 7 +- src/tui/provider/setup.ts | 22 +++++ src/tui/provider/types.ts | 3 + 7 files changed, 291 insertions(+), 41 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 6c74854b5..3d0190b28 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -420,7 +420,7 @@ 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. +**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 sign-in for the process (profile name and when it signed in, which a token refresh keeps and a reconnect changes), and an older fetch never replaces a newer list. Confirming the connect model step while the fetch is still in flight waits for it, so the saved model is picked from the account's list. 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 diff --git a/src/provider/codex-model-catalog.test.ts b/src/provider/codex-model-catalog.test.ts index 8548e87f7..2210681b7 100644 --- a/src/provider/codex-model-catalog.test.ts +++ b/src/provider/codex-model-catalog.test.ts @@ -277,11 +277,11 @@ describe("ChatGPT model catalog", () => { }); test("a profile name reconnected to another account does not inherit the old list", async () => { - storedProfiles = [profile("work", "acct-old")]; + storedProfiles = [profile("work", "acct-old", 1)]; serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]); await prefetchCodexModels(); - expect(selectableCodexModelIds(profile("work", "acct-new"))).toEqual( + expect(selectableCodexModelIds(profile("work", "acct-new", 2))).toEqual( CODEX_DEFAULT_MODELS, ); }); @@ -341,7 +341,108 @@ describe("ChatGPT model catalog", () => { 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 () => { + test("a list stays with its sign-in when a refresh learns the account id and the next fetch fails", async () => { + storedProfiles = [profile("legacy")]; + serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]); + await prefetchCodexModels(); + + // A later refresh names the account and writes it back to the profile. + const learned = profile("legacy", "acct-late"); + storedProfiles = [learned]; + serve(() => new Response("no", { status: 503 })); + await prefetchCodexModels(); + + expect(selectableCodexModelIds(learned)).toEqual(["gpt-6-sol"]); + }); + + test("a blank account id does not make two sign-ins of one profile name share a list", async () => { + storedProfiles = [profile("work", "", 1)]; + serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]); + await prefetchCodexModels(); + + expect(selectableCodexModelIds(profile("work", "", 1))).toEqual([ + "gpt-6-sol", + ]); + expect(selectableCodexModelIds(profile("work", "", 2))).toEqual( + CODEX_DEFAULT_MODELS, + ); + }); + + describe("overlapping fetches", () => { + const older = [{ slug: "gpt-5.6-luna", visibility: "list", priority: 1 }]; + const newer = [{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]; + + /** A fetch that stays pending until `release` answers it with `models`. */ + function heldFetch() { + let release: ((models: unknown) => void) | undefined; + const held = new Promise((resolve) => { + release = (models) => resolve(Response.json({ models })); + }); + return { + held, + release: (models: unknown) => defined(release, "release")(models), + }; + } + + test("a startup fetch that returns after the connect list was kept does not replace it", async () => { + const connected = profile("work", "acct-new", 5); + storedProfiles = [connected]; + const slow = heldFetch(); + let requests = 0; + serve(() => { + requests += 1; + return slow.held; + }); + const startup = prefetchCodexModels(); + while (requests === 0) + await new Promise((resolve) => setTimeout(resolve, 0)); + + rememberConnectedCodexModels(connected, ["gpt-6-sol", "gpt-5.6-luna"]); + slow.release(older); + await startup; + + expect(selectableCodexModelIds(connected)).toEqual([ + "gpt-6-sol", + "gpt-5.6-luna", + ]); + }); + + test("an earlier request's answer does not replace a later one's", async () => { + const work = profile("work", "acct-work", 1); + storedProfiles = [work]; + const slow = heldFetch(); + let requests = 0; + serve(() => { + requests += 1; + return requests === 1 + ? slow.held + : Promise.resolve(Response.json({ models: newer })); + }); + const first = prefetchCodexModels(); + while (requests === 0) + await new Promise((resolve) => setTimeout(resolve, 0)); + await prefetchCodexModels(); + expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol"]); + + slow.release(older); + await first; + + expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol"]); + }); + + test("a later request's answer replaces an earlier one's", async () => { + const work = profile("work", "acct-work", 1); + storedProfiles = [work]; + serveModels(older); + await prefetchCodexModels(); + serveModels(newer); + await prefetchCodexModels(); + + expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol"]); + }); + }); + + test("a fetch after a failed token read keeps its list", async () => { storedProfiles = [profile("legacy")]; tokenFailuresLeft = 1; accountLearnedOnRefresh = "acct-legacy"; @@ -445,19 +546,17 @@ describe("ChatGPT model catalog", () => { }); test("a reconnect whose first fetch fails does not show the previous account's list", async () => { - storedProfiles = [profile("work", "acct-old")]; + storedProfiles = [profile("work", "acct-old", 1)]; 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"; + // The reconnect stored a new sign-in under the same profile name. + const reconnected = profile("work", "acct-new", 2); + storedProfiles = [reconnected]; serve(() => new Response("no", { status: 503 })); await prefetchCodexModels(); - expect(selectableCodexModelIds(profile("work", "acct-new"))).toEqual( - CODEX_DEFAULT_MODELS, - ); + expect(selectableCodexModelIds(reconnected)).toEqual(CODEX_DEFAULT_MODELS); }); describe("falls back to the built-in list", () => { diff --git a/src/provider/codex-model-catalog.ts b/src/provider/codex-model-catalog.ts index 29de91f91..382d444ea 100644 --- a/src/provider/codex-model-catalog.ts +++ b/src/provider/codex-model-catalog.ts @@ -93,45 +93,60 @@ function createCodexModelCatalog( }); } -// 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]); +// The last live list per sign-in, so a list is only ever read back for the +// sign-in that served it and a reconnected profile never shows the previous +// login's list. A sign-in is its profile name and creation time: a token +// refresh keeps the creation time, even when it teaches the profile its +// account id, and a new sign-in changes it. The account id is not part of the +// key because the token that served a fetch and the stored profile can name it +// differently, or not at all. +interface LiveList { + readonly models: readonly [string, ...string[]]; + /** When the request that produced it started; a later one replaces it. */ + readonly ticket: number; +} + +const liveLists = new Map(); +let lastTicket = 0; + +function listKey(profile: Pick) { + return JSON.stringify([profile.name, profile.createdAt]); +} + +/** Replaces the key's list unless a later request's answer is already there. */ +function storeLiveList( + key: string, + models: readonly [string, ...string[]], + ticket: number, +): void { + const current = liveLists.get(key); + if (current === undefined || current.ticket < ticket) { + liveLists.set(key, { models, ticket }); + } } -/** Sync picker ids for a ChatGPT profile: its login's last live list, else the built-in list. Never empty. */ +/** Sync picker ids for a ChatGPT profile: its sign-in'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 - ); + return liveLists.get(listKey(profile))?.models ?? 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(() => + // Requests are ordered by when they start: overlapping fetches for one + // sign-in can answer in any order, and the older answer must not win. A + // token read that fails gets one more try before the fetch counts as failed. + lastTicket += 1; + const ticket = lastTicket; + const state = await createCodexModelCatalog(catalogLabel, () => + getValidCodexToken(profile.name).catch(() => getValidCodexToken(profile.name), - ); - accountId = access.accountId; - return access; - }).discoverModels(); - const key = listKey(profile, accountId); + ), + ).discoverModels(); + const key = listKey(profile); if (state.status === "models") { - liveLists.set(key, state.models); + storeLiveList(key, state.models, ticket); return; } // Empty and failed fetches leave a live list in place: stale-but-live beats @@ -169,7 +184,10 @@ export function rememberConnectedCodexModels( ): void { const [first, ...rest] = models; if (first === undefined) return; - liveLists.set(listKey(profile, profile.tokens.accountId), [first, ...rest]); + // Newer than every fetch started so far: one still in flight answers with + // an older view and must not replace the list the operator just chose from. + lastTicket += 1; + storeLiveList(listKey(profile), [first, ...rest], lastTicket); } export function resetCodexModelDiscoveryForTests(): void { diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index c6bffc1ac..9072c5a9e 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -1115,6 +1115,109 @@ describe("runProviderSetup sign-in", () => { expect(seen[0]?.model).toBe("gpt-5.6-luna"); }); + describe("confirming the model step before the account's list arrives", () => { + test("waits for the list and saves its first model", async () => { + const seen: ProviderFormValues[] = []; + const remembered: (readonly string[])[] = []; + const discovery = deferredCodexDiscovery(); + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + rememberCodexModels: (_profile, models) => { + remembered.push(models); + }, + discoverCodexModels: discovery.discover, + }); + await signInToChatGPT(harness, events); + + harness.pressKey("Enter"); + await flush(harness); + expect(seen).toEqual([]); + + discovery.land(0, ["gpt-5.6-luna", "gpt-6-sol"]); + await flush(harness); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe("gpt-5.6-luna"); + expect(remembered).toEqual([["gpt-5.6-luna", "gpt-6-sol"]]); + }); + + test("saves a model the operator moved to when the list has it", 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"); + harness.pressKey("Enter"); + await flush(harness); + expect(seen).toEqual([]); + + discovery.land(0, ["gpt-5.6-luna", moved]); + await flush(harness); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe(moved); + }); + + test("saves the built-in default when the fetch fails", async () => { + const seen: ProviderFormValues[] = []; + let fail: (() => void) | undefined; + const { start, events } = stagedLoginStarter(); + const { done, harness } = await mountLogin({ + start, + onSubmit: async (values) => { + seen.push({ ...values }); + }, + discoverCodexModels: () => + new Promise((resolve) => { + fail = () => resolve({ status: "unavailable", message: "offline" }); + }), + }); + await signInToChatGPT(harness, events); + + harness.pressKey("Enter"); + await flush(harness); + expect(seen).toEqual([]); + + defined(fail, "fetch")(); + await flush(harness); + expect(await done).toBe(true); + expect(seen[0]?.model).toBe(CODEX_DEFAULT_MODELS[0]); + }); + + test("saves nothing when the operator cancels while it waits", 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("Enter"); + await flush(harness); + harness.pressKey("Ctrl+C"); + expect(await done).toBe(false); + + discovery.land(0, ["gpt-5.6-luna"]); + await flush(harness); + expect(seen).toEqual([]); + }); + }); + describe("the list a sign-in showed", () => { function rememberRecorder() { const remembered: { name: string; models: readonly string[] }[] = []; diff --git a/src/tui/provider/discovery.ts b/src/tui/provider/discovery.ts index ea8249be3..10cdba3a5 100644 --- a/src/tui/provider/discovery.ts +++ b/src/tui/provider/discovery.ts @@ -49,6 +49,7 @@ export function createDiscoveryFlows( const abandonCodexPrefetch = (): void => { state.codexPrefetchAttempt += 1; + state.codexListPending = null; }; const beginOllamaDiscovery = (): void => { @@ -225,7 +226,7 @@ export function createDiscoveryFlows( } const attempt = state.codexPrefetchAttempt; const prefill = state.choice.defaultModel; - void state + const pending = state .discoverCodexModels(login.tokens) .then((discovered) => { if ( @@ -285,7 +286,11 @@ export function createDiscoveryFlows( // 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)}`; + }) + .finally(() => { + if (state.codexListPending === pending) state.codexListPending = null; }); + state.codexListPending = pending; }; return { diff --git a/src/tui/provider/setup.ts b/src/tui/provider/setup.ts index f5eca3224..4a036cbb3 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -175,6 +175,7 @@ export async function runProviderSetup( rememberCodexModels: config.rememberCodexModels ?? rememberCodexModelsRequest, codexPrefetchAttempt: 0, + codexListPending: null, codexListLogin: null, listRows: initialRows, list: createOverlayList(renderer as CliRenderer, { @@ -331,6 +332,27 @@ export async function runProviderSetup( } function submit(skipValidation: boolean): void { + // Confirming before the account's list arrives would save the built-in + // default and drop the list. Wait for the fetch (bounded by its own + // timeout) so the pick is made against the list the account is served. + const pendingList = state.codexListPending; + if (pendingList === null) { + submitNow(skipValidation); + return; + } + state.submitting = true; + state.submitPhase = "testing"; + clearError(); + surface.paint(); + stopRamp(state); + startRamp(state, () => surface.paintStatus()); + void pendingList.then(() => { + if (state.settled) return; + submitNow(skipValidation); + }); + } + + function submitNow(skipValidation: boolean): void { state.submitting = true; state.submitPhase = "testing"; clearError(); diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts index 823af4eb9..6029961b8 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -238,6 +238,9 @@ export interface SetupState { readonly discoverCodexModels: typeof discoverCodexModelsRequest; readonly rememberCodexModels: typeof rememberCodexModelsRequest; codexPrefetchAttempt: number; + // The ChatGPT list fetch in flight for the model step, settled once its + // result is on screen; null when none is. + codexListPending: Promise | null; // The sign-in whose live list the ChatGPT model step shows; null while it // shows the built-in list. codexListLogin: OAuthResult | null;