Skip to content

Commit a5f74c3

Browse files
committed
feat(tui): offer the account's live ChatGPT models at connect
1 parent 10bcb63 commit a5f74c3

10 files changed

Lines changed: 562 additions & 38 deletions

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ This release changes no settings, OAuth-store, or session persistence format and
420420

421421
**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.
422422

423-
**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.
423+
**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.
424424

425425
### CLI Verbs and Flags
426426

‎src/provider/bounded-model-catalog.ts‎

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,43 @@ type DiscoveredModels = Extract<
8080
{ status: "models" }
8181
>["models"];
8282

83+
/**
84+
* Log why a live catalog fetch kept `keeping` instead of a fresh list. An
85+
* unreachable catalog is routine (offline, transient outage), so it stays at
86+
* debug; an empty or malformed answer means the endpoint's contract moved,
87+
* which only shows up here, so it warns.
88+
*/
89+
export function logCatalogFallback(
90+
catalogLabel: string,
91+
state: Exclude<CatalogDiscoveryState, { status: "models" }>,
92+
keeping: string,
93+
): void {
94+
// HTTP and size failures already name the catalog; transport and parse
95+
// errors do not, so the catalog is named exactly once either way.
96+
const reason = (message: string): string =>
97+
message.startsWith(catalogLabel) ? message : `${catalogLabel}: ${message}`;
98+
switch (state.status) {
99+
case "unavailable":
100+
catalogLogger.debug(
101+
"Model catalog unavailable ({reason}); keeping {fallback}",
102+
{ reason: reason(state.message), fallback: keeping },
103+
);
104+
return;
105+
case "empty":
106+
catalogLogger.warn(
107+
"Model catalog listed no models ({catalog}); keeping {fallback}",
108+
{ catalog: catalogLabel, fallback: keeping },
109+
);
110+
return;
111+
case "malformed":
112+
catalogLogger.warn(
113+
"Model catalog response rejected ({reason}); keeping {fallback}",
114+
{ reason: reason(state.message), fallback: keeping },
115+
);
116+
return;
117+
}
118+
}
119+
83120
export function createBoundedModelCatalog<
84121
Seed extends readonly string[],
85122
>(args: {
@@ -198,40 +235,14 @@ export function createBoundedModelCatalog<
198235
return selectableModelIds();
199236
}
200237

201-
// An unreachable catalog is routine (offline, transient outage), so it
202-
// stays at debug. An empty or malformed answer means the endpoint's
203-
// contract moved, which only shows up here, so it warns.
204238
function logFallback(
205239
state: Exclude<CatalogDiscoveryState, { status: "models" }>,
206240
): void {
207-
const fallback =
208-
snapshot === undefined ? "the built-in list" : "the last live list";
209-
// HTTP and size failures already name the catalog; transport and parse
210-
// errors do not, so the catalog is named exactly once either way.
211-
const reason = (message: string): string =>
212-
message.startsWith(catalogLabel)
213-
? message
214-
: `${catalogLabel}: ${message}`;
215-
switch (state.status) {
216-
case "unavailable":
217-
catalogLogger.debug(
218-
"Model catalog unavailable ({reason}); keeping {fallback}",
219-
{ reason: reason(state.message), fallback },
220-
);
221-
return;
222-
case "empty":
223-
catalogLogger.warn(
224-
"Model catalog listed no models ({catalog}); keeping {fallback}",
225-
{ catalog: catalogLabel, fallback },
226-
);
227-
return;
228-
case "malformed":
229-
catalogLogger.warn(
230-
"Model catalog response rejected ({reason}); keeping {fallback}",
231-
{ reason: reason(state.message), fallback },
232-
);
233-
return;
234-
}
241+
logCatalogFallback(
242+
catalogLabel,
243+
state,
244+
snapshot === undefined ? "the built-in list" : "the last live list",
245+
);
235246
}
236247

237248
function prefetchModels(): Promise<Seed | DiscoveredModels> {

‎src/provider/codex-model-catalog.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type * as CodexSession from "../auth/codex/session.js";
1212
import type * as OAuthStores from "../config/oauth-stores.js";
1313
import { codexProfilesToCatalogEntries } from "../config/codex-providers.js";
1414
import {
15+
discoverStagedCodexModels,
1516
prefetchCodexModels,
1617
resetCodexModelDiscoveryForTests,
1718
selectableCodexModelIds,
@@ -436,3 +437,54 @@ describe("contract drift", () => {
436437
expect(defined(misses[0])).toContain("response rejected");
437438
});
438439
});
440+
441+
describe("a sign-in not yet saved as a profile", () => {
442+
const staged = () => ({
443+
access: "staged-access",
444+
refresh: "staged-refresh",
445+
expiresAt: Date.now() + 3_600_000,
446+
accountId: "acct-new",
447+
});
448+
449+
test("asks with the staged token and account and returns the listed models", async () => {
450+
const seen: Headers[] = [];
451+
serve((_url, headers) => {
452+
seen.push(headers);
453+
return Response.json({
454+
models: [
455+
{ slug: "gpt-6-sol", visibility: "list", priority: 2 },
456+
{ slug: "gpt-5.6-luna", visibility: "list", priority: 1 },
457+
],
458+
});
459+
});
460+
461+
expect(await discoverStagedCodexModels(staged())).toEqual({
462+
status: "models",
463+
models: ["gpt-5.6-luna", "gpt-6-sol"],
464+
});
465+
expect(seen[0]?.get("authorization")).toBe("Bearer staged-access");
466+
expect(seen[0]?.get("chatgpt-account-id")).toBe("acct-new");
467+
});
468+
469+
test("reports an HTTP failure as unavailable instead of rejecting", async () => {
470+
serve(() => new Response("no", { status: 500 }));
471+
472+
expect((await discoverStagedCodexModels(staged())).status).toBe(
473+
"unavailable",
474+
);
475+
});
476+
477+
test("never fills a stored profile's list", async () => {
478+
serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]);
479+
480+
await discoverStagedCodexModels(staged());
481+
482+
expect(
483+
selectableCodexModelIds({
484+
name: "default",
485+
createdAt: 0,
486+
tokens: staged(),
487+
}),
488+
).toEqual(CODEX_DEFAULT_MODELS);
489+
});
490+
});

‎src/provider/codex-model-catalog.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import {
55
CODEX_DEFAULT_MODELS,
66
CODEX_MODELS_URL,
77
} from "../auth/codex/constants.js";
8-
import { type CodexAccess, getValidCodexToken } from "../auth/codex/session.js";
9-
import type { CodexProfile } from "../auth/codex/store.js";
8+
import {
9+
type CodexAccess,
10+
getValidCodexToken,
11+
refreshStagedCodexTokens,
12+
} from "../auth/codex/session.js";
13+
import type { CodexProfile, CodexTokens } from "../auth/codex/store.js";
1014
import { COMMAND_NAME } from "../branding.js";
1115
import { listCodexProfiles } from "../config/oauth-stores.js";
1216
import {
@@ -132,6 +136,15 @@ export async function prefetchCodexModels(): Promise<void> {
132136
await Promise.all(profiles.map(prefetchProfileModels));
133137
}
134138

139+
/** One fetch for a sign-in not yet saved as a profile. Never rejects; a failure is a non-`models` state. */
140+
export function discoverStagedCodexModels(
141+
tokens: CodexTokens,
142+
): Promise<CatalogDiscoveryState> {
143+
return createCodexModelCatalog("ChatGPT sign-in", () =>
144+
refreshStagedCodexTokens(tokens),
145+
).discoverModels();
146+
}
147+
135148
export function resetCodexModelDiscoveryForTests(): void {
136149
catalogs.clear();
137150
}

0 commit comments

Comments
 (0)