Skip to content

Commit 75fa109

Browse files
committed
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.
1 parent e1df050 commit 75fa109

7 files changed

Lines changed: 291 additions & 41 deletions

File tree

‎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 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.
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` 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.
424424

425425
### CLI Verbs and Flags
426426

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

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -277,11 +277,11 @@ describe("ChatGPT model catalog", () => {
277277
});
278278

279279
test("a profile name reconnected to another account does not inherit the old list", async () => {
280-
storedProfiles = [profile("work", "acct-old")];
280+
storedProfiles = [profile("work", "acct-old", 1)];
281281
serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]);
282282
await prefetchCodexModels();
283283

284-
expect(selectableCodexModelIds(profile("work", "acct-new"))).toEqual(
284+
expect(selectableCodexModelIds(profile("work", "acct-new", 2))).toEqual(
285285
CODEX_DEFAULT_MODELS,
286286
);
287287
});
@@ -341,7 +341,108 @@ describe("ChatGPT model catalog", () => {
341341
expect(selectableCodexModelIds(reread)).toEqual(["gpt-6-sol"]);
342342
});
343343

344-
test("a fetch after a failed token read keeps its list for the account it fetched as", async () => {
344+
test("a list stays with its sign-in when a refresh learns the account id and the next fetch fails", async () => {
345+
storedProfiles = [profile("legacy")];
346+
serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]);
347+
await prefetchCodexModels();
348+
349+
// A later refresh names the account and writes it back to the profile.
350+
const learned = profile("legacy", "acct-late");
351+
storedProfiles = [learned];
352+
serve(() => new Response("no", { status: 503 }));
353+
await prefetchCodexModels();
354+
355+
expect(selectableCodexModelIds(learned)).toEqual(["gpt-6-sol"]);
356+
});
357+
358+
test("a blank account id does not make two sign-ins of one profile name share a list", async () => {
359+
storedProfiles = [profile("work", "", 1)];
360+
serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]);
361+
await prefetchCodexModels();
362+
363+
expect(selectableCodexModelIds(profile("work", "", 1))).toEqual([
364+
"gpt-6-sol",
365+
]);
366+
expect(selectableCodexModelIds(profile("work", "", 2))).toEqual(
367+
CODEX_DEFAULT_MODELS,
368+
);
369+
});
370+
371+
describe("overlapping fetches", () => {
372+
const older = [{ slug: "gpt-5.6-luna", visibility: "list", priority: 1 }];
373+
const newer = [{ slug: "gpt-6-sol", visibility: "list", priority: 1 }];
374+
375+
/** A fetch that stays pending until `release` answers it with `models`. */
376+
function heldFetch() {
377+
let release: ((models: unknown) => void) | undefined;
378+
const held = new Promise<Response>((resolve) => {
379+
release = (models) => resolve(Response.json({ models }));
380+
});
381+
return {
382+
held,
383+
release: (models: unknown) => defined(release, "release")(models),
384+
};
385+
}
386+
387+
test("a startup fetch that returns after the connect list was kept does not replace it", async () => {
388+
const connected = profile("work", "acct-new", 5);
389+
storedProfiles = [connected];
390+
const slow = heldFetch();
391+
let requests = 0;
392+
serve(() => {
393+
requests += 1;
394+
return slow.held;
395+
});
396+
const startup = prefetchCodexModels();
397+
while (requests === 0)
398+
await new Promise((resolve) => setTimeout(resolve, 0));
399+
400+
rememberConnectedCodexModels(connected, ["gpt-6-sol", "gpt-5.6-luna"]);
401+
slow.release(older);
402+
await startup;
403+
404+
expect(selectableCodexModelIds(connected)).toEqual([
405+
"gpt-6-sol",
406+
"gpt-5.6-luna",
407+
]);
408+
});
409+
410+
test("an earlier request's answer does not replace a later one's", async () => {
411+
const work = profile("work", "acct-work", 1);
412+
storedProfiles = [work];
413+
const slow = heldFetch();
414+
let requests = 0;
415+
serve(() => {
416+
requests += 1;
417+
return requests === 1
418+
? slow.held
419+
: Promise.resolve(Response.json({ models: newer }));
420+
});
421+
const first = prefetchCodexModels();
422+
while (requests === 0)
423+
await new Promise((resolve) => setTimeout(resolve, 0));
424+
await prefetchCodexModels();
425+
expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol"]);
426+
427+
slow.release(older);
428+
await first;
429+
430+
expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol"]);
431+
});
432+
433+
test("a later request's answer replaces an earlier one's", async () => {
434+
const work = profile("work", "acct-work", 1);
435+
storedProfiles = [work];
436+
serveModels(older);
437+
await prefetchCodexModels();
438+
serveModels(newer);
439+
await prefetchCodexModels();
440+
441+
expect(selectableCodexModelIds(work)).toEqual(["gpt-6-sol"]);
442+
});
443+
});
444+
445+
test("a fetch after a failed token read keeps its list", async () => {
345446
storedProfiles = [profile("legacy")];
346447
tokenFailuresLeft = 1;
347448
accountLearnedOnRefresh = "acct-legacy";
@@ -445,19 +546,17 @@ describe("ChatGPT model catalog", () => {
445546
});
446547

447548
test("a reconnect whose first fetch fails does not show the previous account's list", async () => {
448-
storedProfiles = [profile("work", "acct-old")];
549+
storedProfiles = [profile("work", "acct-old", 1)];
449550
serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]);
450551
await prefetchCodexModels();
451552

452-
// The profile is listed under the old account, but by the time its token
453-
// is read the reconnect has stored the new one.
454-
accountLearnedOnRefresh = "acct-new";
553+
// The reconnect stored a new sign-in under the same profile name.
554+
const reconnected = profile("work", "acct-new", 2);
555+
storedProfiles = [reconnected];
455556
serve(() => new Response("no", { status: 503 }));
456557
await prefetchCodexModels();
457558

458-
expect(selectableCodexModelIds(profile("work", "acct-new"))).toEqual(
459-
CODEX_DEFAULT_MODELS,
460-
);
559+
expect(selectableCodexModelIds(reconnected)).toEqual(CODEX_DEFAULT_MODELS);
461560
});
462561

463562
describe("falls back to the built-in list", () => {

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

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -93,45 +93,60 @@ function createCodexModelCatalog(
9393
});
9494
}
9595

96-
// The last live list per login, so a list is only ever read back for the login
97-
// that served it and a reconnected profile never shows the previous account's
98-
// list. A login with no account id is told apart by its creation time: a token
99-
// refresh keeps that, and a new sign-in changes it.
100-
const liveLists = new Map<string, readonly [string, ...string[]]>();
101-
102-
function listKey(
103-
profile: Pick<CodexProfile, "name" | "createdAt">,
104-
accountId: string | undefined,
105-
) {
106-
return JSON.stringify([profile.name, accountId ?? profile.createdAt]);
96+
// The last live list per sign-in, so a list is only ever read back for the
97+
// sign-in that served it and a reconnected profile never shows the previous
98+
// login's list. A sign-in is its profile name and creation time: a token
99+
// refresh keeps the creation time, even when it teaches the profile its
100+
// account id, and a new sign-in changes it. The account id is not part of the
101+
// key because the token that served a fetch and the stored profile can name it
102+
// differently, or not at all.
103+
interface LiveList {
104+
readonly models: readonly [string, ...string[]];
105+
/** When the request that produced it started; a later one replaces it. */
106+
readonly ticket: number;
107+
}
108+
109+
const liveLists = new Map<string, LiveList>();
110+
let lastTicket = 0;
111+
112+
function listKey(profile: Pick<CodexProfile, "name" | "createdAt">) {
113+
return JSON.stringify([profile.name, profile.createdAt]);
114+
}
115+
116+
/** Replaces the key's list unless a later request's answer is already there. */
117+
function storeLiveList(
118+
key: string,
119+
models: readonly [string, ...string[]],
120+
ticket: number,
121+
): void {
122+
const current = liveLists.get(key);
123+
if (current === undefined || current.ticket < ticket) {
124+
liveLists.set(key, { models, ticket });
125+
}
107126
}
108127

109-
/** Sync picker ids for a ChatGPT profile: its login's last live list, else the built-in list. Never empty. */
128+
/** Sync picker ids for a ChatGPT profile: its sign-in's last live list, else the built-in list. Never empty. */
110129
export function selectableCodexModelIds(
111130
profile: CodexProfile,
112131
): readonly [string, ...string[]] {
113-
return (
114-
liveLists.get(listKey(profile, profile.tokens.accountId)) ??
115-
CODEX_DEFAULT_MODELS
116-
);
132+
return liveLists.get(listKey(profile))?.models ?? CODEX_DEFAULT_MODELS;
117133
}
118134

119135
async function prefetchProfileModels(profile: CodexProfile): Promise<void> {
120136
const catalogLabel = `ChatGPT profile ${profile.name}`;
121-
// A token refresh can name an account the stored profile does not have yet,
122-
// so the token that made the request picks where its list is kept. A token
123-
// read that fails gets one more try before the fetch counts as failed.
124-
let accountId = profile.tokens.accountId;
125-
const state = await createCodexModelCatalog(catalogLabel, async () => {
126-
const access = await getValidCodexToken(profile.name).catch(() =>
137+
// Requests are ordered by when they start: overlapping fetches for one
138+
// sign-in can answer in any order, and the older answer must not win. A
139+
// token read that fails gets one more try before the fetch counts as failed.
140+
lastTicket += 1;
141+
const ticket = lastTicket;
142+
const state = await createCodexModelCatalog(catalogLabel, () =>
143+
getValidCodexToken(profile.name).catch(() =>
127144
getValidCodexToken(profile.name),
128-
);
129-
accountId = access.accountId;
130-
return access;
131-
}).discoverModels();
132-
const key = listKey(profile, accountId);
145+
),
146+
).discoverModels();
147+
const key = listKey(profile);
133148
if (state.status === "models") {
134-
liveLists.set(key, state.models);
149+
storeLiveList(key, state.models, ticket);
135150
return;
136151
}
137152
// Empty and failed fetches leave a live list in place: stale-but-live beats
@@ -169,7 +184,10 @@ export function rememberConnectedCodexModels(
169184
): void {
170185
const [first, ...rest] = models;
171186
if (first === undefined) return;
172-
liveLists.set(listKey(profile, profile.tokens.accountId), [first, ...rest]);
187+
// Newer than every fetch started so far: one still in flight answers with
188+
// an older view and must not replace the list the operator just chose from.
189+
lastTicket += 1;
190+
storeLiveList(listKey(profile), [first, ...rest], lastTicket);
173191
}
174192

175193
export function resetCodexModelDiscoveryForTests(): void {

‎src/tui/provider-setup.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,109 @@ describe("runProviderSetup sign-in", () => {
11151115
expect(seen[0]?.model).toBe("gpt-5.6-luna");
11161116
});
11171117

1118+
describe("confirming the model step before the account's list arrives", () => {
1119+
test("waits for the list and saves its first model", async () => {
1120+
const seen: ProviderFormValues[] = [];
1121+
const remembered: (readonly string[])[] = [];
1122+
const discovery = deferredCodexDiscovery();
1123+
const { start, events } = stagedLoginStarter();
1124+
const { done, harness } = await mountLogin({
1125+
start,
1126+
onSubmit: async (values) => {
1127+
seen.push({ ...values });
1128+
},
1129+
rememberCodexModels: (_profile, models) => {
1130+
remembered.push(models);
1131+
},
1132+
discoverCodexModels: discovery.discover,
1133+
});
1134+
await signInToChatGPT(harness, events);
1135+
1136+
harness.pressKey("Enter");
1137+
await flush(harness);
1138+
expect(seen).toEqual([]);
1139+
1140+
discovery.land(0, ["gpt-5.6-luna", "gpt-6-sol"]);
1141+
await flush(harness);
1142+
expect(await done).toBe(true);
1143+
expect(seen[0]?.model).toBe("gpt-5.6-luna");
1144+
expect(remembered).toEqual([["gpt-5.6-luna", "gpt-6-sol"]]);
1145+
});
1146+
1147+
test("saves a model the operator moved to when the list has it", async () => {
1148+
const seen: ProviderFormValues[] = [];
1149+
const discovery = deferredCodexDiscovery();
1150+
const { start, events } = stagedLoginStarter();
1151+
const { done, harness } = await mountLogin({
1152+
start,
1153+
onSubmit: async (values) => {
1154+
seen.push({ ...values });
1155+
},
1156+
discoverCodexModels: discovery.discover,
1157+
});
1158+
await signInToChatGPT(harness, events);
1159+
const moved = defined(CODEX_DEFAULT_MODELS[1]);
1160+
harness.pressKey("ARROW_DOWN");
1161+
harness.pressKey("Enter");
1162+
await flush(harness);
1163+
expect(seen).toEqual([]);
1164+
1165+
discovery.land(0, ["gpt-5.6-luna", moved]);
1166+
await flush(harness);
1167+
expect(await done).toBe(true);
1168+
expect(seen[0]?.model).toBe(moved);
1169+
});
1170+
1171+
test("saves the built-in default when the fetch fails", async () => {
1172+
const seen: ProviderFormValues[] = [];
1173+
let fail: (() => void) | undefined;
1174+
const { start, events } = stagedLoginStarter();
1175+
const { done, harness } = await mountLogin({
1176+
start,
1177+
onSubmit: async (values) => {
1178+
seen.push({ ...values });
1179+
},
1180+
discoverCodexModels: () =>
1181+
new Promise((resolve) => {
1182+
fail = () => resolve({ status: "unavailable", message: "offline" });
1183+
}),
1184+
});
1185+
await signInToChatGPT(harness, events);
1186+
1187+
harness.pressKey("Enter");
1188+
await flush(harness);
1189+
expect(seen).toEqual([]);
1190+
1191+
defined(fail, "fetch")();
1192+
await flush(harness);
1193+
expect(await done).toBe(true);
1194+
expect(seen[0]?.model).toBe(CODEX_DEFAULT_MODELS[0]);
1195+
});
1196+
1197+
test("saves nothing when the operator cancels while it waits", async () => {
1198+
const seen: ProviderFormValues[] = [];
1199+
const discovery = deferredCodexDiscovery();
1200+
const { start, events } = stagedLoginStarter();
1201+
const { done, harness } = await mountLogin({
1202+
start,
1203+
onSubmit: async (values) => {
1204+
seen.push({ ...values });
1205+
},
1206+
discoverCodexModels: discovery.discover,
1207+
});
1208+
await signInToChatGPT(harness, events);
1209+
1210+
harness.pressKey("Enter");
1211+
await flush(harness);
1212+
harness.pressKey("Ctrl+C");
1213+
expect(await done).toBe(false);
1214+
1215+
discovery.land(0, ["gpt-5.6-luna"]);
1216+
await flush(harness);
1217+
expect(seen).toEqual([]);
1218+
});
1219+
});
1220+
11181221
describe("the list a sign-in showed", () => {
11191222
function rememberRecorder() {
11201223
const remembered: { name: string; models: readonly string[] }[] = [];

‎src/tui/provider/discovery.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export function createDiscoveryFlows(
4949

5050
const abandonCodexPrefetch = (): void => {
5151
state.codexPrefetchAttempt += 1;
52+
state.codexListPending = null;
5253
};
5354

5455
const beginOllamaDiscovery = (): void => {
@@ -225,7 +226,7 @@ export function createDiscoveryFlows(
225226
}
226227
const attempt = state.codexPrefetchAttempt;
227228
const prefill = state.choice.defaultModel;
228-
void state
229+
const pending = state
229230
.discoverCodexModels(login.tokens)
230231
.then((discovered) => {
231232
if (
@@ -285,7 +286,11 @@ export function createDiscoveryFlows(
285286
// discoverCodexModels never rejects, so only a failure while swapping
286287
// the list in lands here; the list already on screen stays usable.
287288
logger.warn`ChatGPT sign-in model list update failed error=${err instanceof Error ? err.message : String(err)}`;
289+
})
290+
.finally(() => {
291+
if (state.codexListPending === pending) state.codexListPending = null;
288292
});
293+
state.codexListPending = pending;
289294
};
290295

291296
return {

0 commit comments

Comments
 (0)