Skip to content

Commit 75655fe

Browse files
committed
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.
1 parent a5f74c3 commit 75655fe

9 files changed

Lines changed: 283 additions & 30 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` 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.
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.
424424

425425
### CLI Verbs and Flags
426426

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

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { codexProfilesToCatalogEntries } from "../config/codex-providers.js";
1414
import {
1515
discoverStagedCodexModels,
1616
prefetchCodexModels,
17+
rememberConnectedCodexModels,
1718
resetCodexModelDiscoveryForTests,
1819
selectableCodexModelIds,
1920
} from "./codex-model-catalog.js";
@@ -111,10 +112,14 @@ const RECORDED_LISTED_IDS = [
111112
"gpt-5.6-luna",
112113
];
113114

114-
function profile(name: string, accountId?: string): CodexProfile {
115+
function profile(
116+
name: string,
117+
accountId?: string,
118+
createdAt = 0,
119+
): CodexProfile {
115120
return {
116121
name,
117-
createdAt: 0,
122+
createdAt,
118123
tokens: {
119124
access: `stored-${name}`,
120125
refresh: "r",
@@ -126,6 +131,8 @@ function profile(name: string, accountId?: string): CodexProfile {
126131

127132
let storedProfiles: CodexProfile[] = [];
128133
let tokenFailure: Error | undefined;
134+
// Token reads that fail before they start succeeding.
135+
let tokenFailuresLeft = 0;
129136
// Simulates a token refresh that learns the account id and writes it back to
130137
// the stored profile.
131138
let accountLearnedOnRefresh: string | undefined;
@@ -143,6 +150,10 @@ await withMockedModule(
143150
...real,
144151
getValidCodexToken: async (name: string) => {
145152
if (tokenFailure !== undefined) throw tokenFailure;
153+
if (tokenFailuresLeft > 0) {
154+
tokenFailuresLeft -= 1;
155+
throw new Error("token read failed");
156+
}
146157
const stored = storedProfiles.find((p) => p.name === name);
147158
if (stored === undefined) throw new Error(`no profile ${name}`);
148159
const learned = accountLearnedOnRefresh;
@@ -165,6 +176,7 @@ afterEach(() => {
165176
globalThis.fetch = originalFetch;
166177
storedProfiles = [];
167178
tokenFailure = undefined;
179+
tokenFailuresLeft = 0;
168180
accountLearnedOnRefresh = undefined;
169181
resetCodexModelDiscoveryForTests();
170182
});
@@ -329,6 +341,109 @@ describe("ChatGPT model catalog", () => {
329341
expect(selectableCodexModelIds(reread)).toEqual(["gpt-6-sol"]);
330342
});
331343

344+
test("a fetch after a failed token read keeps its list for the account it fetched as", async () => {
345+
storedProfiles = [profile("legacy")];
346+
tokenFailuresLeft = 1;
347+
accountLearnedOnRefresh = "acct-legacy";
348+
serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]);
349+
350+
await prefetchCodexModels();
351+
352+
expect(selectableCodexModelIds(defined(storedProfiles[0]))).toEqual([
353+
"gpt-6-sol",
354+
]);
355+
});
356+
357+
describe("a login with no account id", () => {
358+
const list = [{ slug: "gpt-6-sol", visibility: "list", priority: 1 }];
359+
360+
async function reconnectThenServe(response: () => Response) {
361+
storedProfiles = [profile("work", undefined, 1)];
362+
serveModels(list);
363+
await prefetchCodexModels();
364+
expect(selectableCodexModelIds(profile("work", undefined, 1))).toEqual([
365+
"gpt-6-sol",
366+
]);
367+
368+
const reconnected = profile("work", undefined, 2);
369+
storedProfiles = [reconnected];
370+
serve(response);
371+
await prefetchCodexModels();
372+
return reconnected;
373+
}
374+
375+
test("does not inherit the list of the login it replaced when its fetch fails", async () => {
376+
const reconnected = await reconnectThenServe(
377+
() => new Response("no", { status: 503 }),
378+
);
379+
expect(selectableCodexModelIds(reconnected)).toEqual(
380+
CODEX_DEFAULT_MODELS,
381+
);
382+
});
383+
384+
test("does not inherit the list of the login it replaced when its catalog is empty", async () => {
385+
const reconnected = await reconnectThenServe(() =>
386+
Response.json({ models: [] }),
387+
);
388+
expect(selectableCodexModelIds(reconnected)).toEqual(
389+
CODEX_DEFAULT_MODELS,
390+
);
391+
});
392+
393+
test("keeps its list across a token refresh, which keeps its creation time", async () => {
394+
storedProfiles = [profile("work", undefined, 1)];
395+
serveModels(list);
396+
await prefetchCodexModels();
397+
398+
const refreshed = profile("work", undefined, 1);
399+
refreshed.tokens = { ...refreshed.tokens, refresh: "rotated" };
400+
expect(selectableCodexModelIds(refreshed)).toEqual(["gpt-6-sol"]);
401+
});
402+
});
403+
404+
describe("the models shown while connecting", () => {
405+
const shown: [string, ...string[]] = ["gpt-6-sol", "gpt-5.6-luna"];
406+
407+
test("stay the profile's list when the fetch after connecting fails", async () => {
408+
const connected = profile("work", "acct-new", 5);
409+
rememberConnectedCodexModels(connected, shown);
410+
storedProfiles = [connected];
411+
serve(() => new Response("no", { status: 503 }));
412+
413+
await prefetchCodexModels();
414+
415+
expect(selectableCodexModelIds(connected)).toEqual(shown);
416+
});
417+
418+
test("cover a login with no account id", () => {
419+
const connected = profile("work", undefined, 5);
420+
rememberConnectedCodexModels(connected, shown);
421+
422+
expect(selectableCodexModelIds(connected)).toEqual(shown);
423+
expect(selectableCodexModelIds(profile("work", undefined, 6))).toEqual(
424+
CODEX_DEFAULT_MODELS,
425+
);
426+
});
427+
428+
test("give way to a later successful fetch", async () => {
429+
const connected = profile("work", "acct-new", 5);
430+
rememberConnectedCodexModels(connected, shown);
431+
storedProfiles = [connected];
432+
serveModels([{ slug: "gpt-6.1-sol", visibility: "list", priority: 1 }]);
433+
434+
await prefetchCodexModels();
435+
436+
expect(selectableCodexModelIds(connected)).toEqual(["gpt-6.1-sol"]);
437+
});
438+
439+
test("an empty list is not remembered", () => {
440+
const connected = profile("work", "acct-new", 5);
441+
rememberConnectedCodexModels(connected, []);
442+
443+
expect(selectableCodexModelIds(connected)).toEqual(CODEX_DEFAULT_MODELS);
444+
});
445+
});
446+
332447
test("a reconnect whose first fetch fails does not show the previous account's list", async () => {
333448
storedProfiles = [profile("work", "acct-old")];
334449
serveModels([{ slug: "gpt-6-sol", visibility: "list", priority: 1 }]);

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

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { listCodexProfiles } from "../config/oauth-stores.js";
1616
import {
1717
type CatalogDiscoveryState,
1818
createBoundedModelCatalog,
19+
logCatalogFallback,
1920
} from "./bounded-model-catalog.js";
2021
import { DEFAULT_MODELS_REQUEST_TIMEOUT_MS } from "./models-endpoint.js";
2122

@@ -92,42 +93,54 @@ function createCodexModelCatalog(
9293
});
9394
}
9495

95-
// One catalog per profile and account, so a list is only ever read back for
96-
// the account that served it and a reconnected profile never shows the
97-
// previous account's list.
98-
const catalogs = new Map<string, ReturnType<typeof createCodexModelCatalog>>();
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[]]>();
99101

100-
function catalogKey(profileName: string, accountId: string | undefined) {
101-
return JSON.stringify([profileName, accountId ?? null]);
102+
function listKey(
103+
profile: Pick<CodexProfile, "name" | "createdAt">,
104+
accountId: string | undefined,
105+
) {
106+
return JSON.stringify([profile.name, accountId ?? profile.createdAt]);
102107
}
103108

104-
/** Sync picker ids for a ChatGPT profile: its account's last live list, else the built-in list. Never empty. */
109+
/** Sync picker ids for a ChatGPT profile: its login's last live list, else the built-in list. Never empty. */
105110
export function selectableCodexModelIds(
106111
profile: CodexProfile,
107112
): readonly [string, ...string[]] {
108113
return (
109-
catalogs
110-
.get(catalogKey(profile.name, profile.tokens.accountId))
111-
?.selectableModelIds() ?? CODEX_DEFAULT_MODELS
114+
liveLists.get(listKey(profile, profile.tokens.accountId)) ??
115+
CODEX_DEFAULT_MODELS
112116
);
113117
}
114118

115119
async function prefetchProfileModels(profile: CodexProfile): Promise<void> {
116-
// A token refresh can name an account the stored profile does not have
117-
// yet, so the refreshed token picks the catalog. When the refresh fails,
118-
// the catalog's own request fails the same way and logs why.
119-
const accountId = await getValidCodexToken(profile.name).then(
120-
(access) => access.accountId,
121-
() => profile.tokens.accountId,
122-
);
123-
const key = catalogKey(profile.name, accountId);
124-
const catalog =
125-
catalogs.get(key) ??
126-
createCodexModelCatalog(`ChatGPT profile ${profile.name}`, () =>
120+
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(() =>
127127
getValidCodexToken(profile.name),
128128
);
129-
catalogs.set(key, catalog);
130-
await catalog.prefetchModels();
129+
accountId = access.accountId;
130+
return access;
131+
}).discoverModels();
132+
const key = listKey(profile, accountId);
133+
if (state.status === "models") {
134+
liveLists.set(key, state.models);
135+
return;
136+
}
137+
// Empty and failed fetches leave a live list in place: stale-but-live beats
138+
// the built-in list.
139+
logCatalogFallback(
140+
catalogLabel,
141+
state,
142+
liveLists.has(key) ? "the last live list" : "the built-in list",
143+
);
131144
}
132145

133146
/** Fetch every stored ChatGPT profile's live catalog; failures keep the fallback and log why. */
@@ -145,6 +158,20 @@ export function discoverStagedCodexModels(
145158
).discoverModels();
146159
}
147160

161+
/**
162+
* Keep the list a sign-in showed once its profile is saved, so the fetch after
163+
* connecting failing leaves the models the operator just saw, not the built-in
164+
* list.
165+
*/
166+
export function rememberConnectedCodexModels(
167+
profile: CodexProfile,
168+
models: readonly string[],
169+
): void {
170+
const [first, ...rest] = models;
171+
if (first === undefined) return;
172+
liveLists.set(listKey(profile, profile.tokens.accountId), [first, ...rest]);
173+
}
174+
148175
export function resetCodexModelDiscoveryForTests(): void {
149-
catalogs.clear();
176+
liveLists.clear();
150177
}

‎src/tui/onboarding.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,15 @@ describe("runOnboarding settings source", () => {
267267
expiresAt: 0,
268268
},
269269
commit: () => writeXAIAuthProfile(testHome, "work"),
270+
profile: {
271+
name: "work",
272+
createdAt: 0,
273+
tokens: {
274+
access: "work-access-token",
275+
refresh: "work-refresh-token",
276+
expiresAt: 0,
277+
},
278+
},
270279
},
271280
},
272281
);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ function stagedCodexOAuth(
6565
providerName: "codex/work",
6666
tokens: stagedCodexTokens,
6767
commit,
68+
profile: { name: "work", createdAt: 0, tokens: stagedCodexTokens },
6869
};
6970
}
7071

0 commit comments

Comments
 (0)