Skip to content

Commit 10bcb63

Browse files
committed
feat(tui): refresh the ChatGPT catalog after connecting an account
1 parent f9fcf1f commit 10bcb63

3 files changed

Lines changed: 184 additions & 1 deletion

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

425425
### CLI Verbs and Flags
426426

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Drives wireSettings().onConnectProvider with the setup surface, focus
2+
// repaint and catalog prefetches replaced, and asserts which connects refresh
3+
// the ChatGPT catalog. The gate keys on the connected provider's name rather
4+
// than the provider the connect started from, unlike the Go/Zen gates.
5+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
6+
import { tmpdir } from "node:os";
7+
import { join } from "node:path";
8+
import { afterEach, describe, expect, test } from "bun:test";
9+
10+
import { saveGlobalSettings } from "../config/settings.js";
11+
import {
12+
withMockedHomedir,
13+
withMockedModule,
14+
} from "../../testkit/mock-module.js";
15+
import type { RunnerServices, RunnerState } from "./runner/state.js";
16+
import { createGlobalSettingsWriter } from "../mcp/add-server.js";
17+
import pkg from "../../package.json" with { type: "json" };
18+
import type { ConnectProviderResult } from "./provider/connect.js";
19+
import type { ProductHostConnectRequest } from "./product-host.js";
20+
21+
let nextResult: ConnectProviderResult = { connected: false };
22+
let codexPrefetches = 0;
23+
let goPrefetches = 0;
24+
let zenPrefetches = 0;
25+
26+
{
27+
await withMockedModule(
28+
import.meta.resolve("./provider/connect.js"),
29+
(real: object) => ({
30+
...real,
31+
connectProviderInline: async () => nextResult,
32+
}),
33+
);
34+
await withMockedModule(
35+
import.meta.resolve("./shell/chrome.js"),
36+
(real: object) => ({ ...real, applyFocus: () => undefined }),
37+
);
38+
await withMockedModule(
39+
import.meta.resolve("../provider/codex-model-catalog.js"),
40+
(real: object) => ({
41+
...real,
42+
prefetchCodexModels: async () => {
43+
codexPrefetches += 1;
44+
},
45+
}),
46+
);
47+
await withMockedModule(
48+
import.meta.resolve("../provider/model-catalogs.js"),
49+
(real: object) => ({
50+
...real,
51+
prefetchGoModels: async () => {
52+
goPrefetches += 1;
53+
return [];
54+
},
55+
prefetchZenModels: async () => {
56+
zenPrefetches += 1;
57+
return [];
58+
},
59+
}),
60+
);
61+
}
62+
63+
afterEach(() => {
64+
codexPrefetches = 0;
65+
goPrefetches = 0;
66+
zenPrefetches = 0;
67+
nextResult = { connected: false };
68+
});
69+
70+
async function runConnect(
71+
providerArg: string,
72+
result: ConnectProviderResult,
73+
): Promise<void> {
74+
// Imported after the module mocks above install, so settings.ts binds to
75+
// the replaced connect flow and prefetches.
76+
const { wireSettings } = await import("./runner/settings.js");
77+
const dir = await mkdtemp(join(tmpdir(), "connect-catalog-refresh-"));
78+
const home = join(dir, "home");
79+
await mkdir(home, { recursive: true });
80+
const settingsPath = join(dir, "settings.json");
81+
await saveGlobalSettings(settingsPath, {
82+
providers: {},
83+
telemetry: { noticeShown: true },
84+
lastChangelogVersion:
85+
typeof pkg.version === "string" ? pkg.version : "0.0.0",
86+
});
87+
nextResult = result;
88+
const finished = Promise.withResolvers<undefined>();
89+
const state = {
90+
config: {
91+
cwd: dir,
92+
globalSettingsPath: settingsPath,
93+
providers: [],
94+
apiKey: "live-key",
95+
baseURL: "https://live/v1",
96+
model: "k1",
97+
providerName: "keeper",
98+
settings: { providers: {} },
99+
},
100+
localSettingsFile: join(dir, "local.json"),
101+
trueGlobalSettingsPath: settingsPath,
102+
host: {
103+
shell: {},
104+
renderer: {},
105+
refreshModels: () => undefined,
106+
// A successful connect reopens the picker on the new account in the
107+
// same synchronous step that schedules the catalog refreshes.
108+
openModels: () => finished.resolve(undefined),
109+
},
110+
systemNotice: () => undefined,
111+
} as unknown as RunnerState;
112+
const services = {
113+
hookManager: { getStatuses: () => [] },
114+
globalSettingsWriter: createGlobalSettingsWriter(settingsPath),
115+
permissionGate: { setProviderIdentity: () => undefined },
116+
buildSessionSources: () => ({ sources: [], defaultSource: "" }),
117+
directorHolder: {},
118+
computeAdvertised: () => [],
119+
toolset: { dynamicRunner: { currentDefinitions: () => [] } },
120+
hostHolder: { instance: undefined },
121+
} as unknown as RunnerServices;
122+
const request: ProductHostConnectRequest = {
123+
onComplete: (connected) => {
124+
if (!connected) finished.resolve(undefined);
125+
},
126+
};
127+
try {
128+
await withMockedHomedir(home, async () => {
129+
const wiring = await wireSettings(state, services);
130+
wiring.onConnectProvider(providerArg, request);
131+
await finished.promise;
132+
});
133+
} finally {
134+
await rm(dir, { recursive: true, force: true });
135+
}
136+
}
137+
138+
describe("post-connect ChatGPT catalog refresh gate", () => {
139+
test("connecting ChatGPT from the codex row refreshes the codex catalog", async () => {
140+
await runConnect("codex", {
141+
connected: true,
142+
providerName: "codex/work",
143+
model: "gpt-5.5",
144+
});
145+
expect(codexPrefetches).toBe(1);
146+
expect(goPrefetches).toBe(0);
147+
expect(zenPrefetches).toBe(0);
148+
});
149+
150+
test("a ChatGPT account reached from a non-codex entry still refreshes", async () => {
151+
// /connect openai -> Esc back to the provider list -> ChatGPT row.
152+
await runConnect("openai", {
153+
connected: true,
154+
providerName: "codex/work",
155+
model: "gpt-5.5",
156+
});
157+
expect(codexPrefetches).toBe(1);
158+
});
159+
160+
test("an API-key OpenAI connect does not refresh the codex catalog", async () => {
161+
await runConnect("openai", {
162+
connected: true,
163+
providerName: "openai",
164+
model: "gpt-5",
165+
});
166+
expect(codexPrefetches).toBe(0);
167+
});
168+
169+
test("a cancelled connect does not refresh the codex catalog", async () => {
170+
await runConnect("codex", { connected: false });
171+
expect(codexPrefetches).toBe(0);
172+
});
173+
});

‎src/tui/runner/settings.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ import { resolveWaitForApproval } from "../../agent/tool-execution-watchdog.js";
6565
import { hostOf, type RunnerServices, type RunnerState } from "./state.js";
6666
import type { ProductHostConnectRequest } from "../product-host.js";
6767
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
68+
import { isCodexProviderName } from "../../config/codex-providers.js";
69+
import { prefetchCodexModels } from "../../provider/codex-model-catalog.js";
6870
import { refreshCatalogAfterPrefetch } from "./catalog-refresh.js";
6971

7072
const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
@@ -384,6 +386,14 @@ export async function wireSettings(
384386
if (isZenProvider({ name: providerName })) {
385387
refreshCatalogAfterPrefetch(state, services, "zen", prefetchZenModels);
386388
}
389+
if (isCodexProviderName(connectedName)) {
390+
refreshCatalogAfterPrefetch(
391+
state,
392+
services,
393+
"chatgpt",
394+
prefetchCodexModels,
395+
);
396+
}
387397
})().catch(caughtDebug(tuiLogger, "provider connect failed"));
388398
};
389399

0 commit comments

Comments
 (0)