jeo-code 0.11.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,17 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.11.2] - 2026-09-30
13
+ _OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries._
14
+
15
+ ### Fixed
16
+ - Use Codex `visibility` for ChatGPT model discovery; retain visible subscription-only models even when `supported_in_api` is false. Keep modern GPT Codex models in API-key discovery and fetch both catalogs when both credentials are configured.
17
+ - Preserve the full OpenAI model list by default, de-duplicate picker rows, and keep empty or failed live results honest instead of fabricating availability from a static snapshot.
18
+ - Replace account-scoped OpenAI model observations on refresh, migrate old caches, and separate authentication sources and custom endpoints. Use stored account identity for opaque OAuth credentials and the proxy's own API key for custom endpoints.
19
+
20
+ ### Validation
21
+ - Regression coverage for visibility, dual credentials, complete lists, account headers, proxy scoping, cache replacement, and authoritative empty catalogs.
22
+
12
23
  ## [0.11.1] - 2026-08-25
13
24
  _Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited._
14
25
 
package/README.ja.md CHANGED
@@ -319,10 +319,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
319
319
 
320
320
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
321
321
  - **[Unreleased]**
322
+ - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
322
323
  - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
323
324
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
324
325
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
325
- - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
326
326
 
327
327
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
328
328
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -320,10 +320,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
320
320
 
321
321
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
322
322
  - **[Unreleased]**
323
+ - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
323
324
  - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
324
325
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
325
326
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
326
- - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
327
327
 
328
328
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
329
329
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -327,10 +327,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
327
327
 
328
328
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
329
329
  - **[Unreleased]**
330
+ - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
330
331
  - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
331
332
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
332
333
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
333
- - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
334
334
 
335
335
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
336
336
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -317,10 +317,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
317
317
 
318
318
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
319
319
  - **[Unreleased]**
320
+ - **[0.11.2]** (2026-09-30) — OpenAI model pickers now follow the authenticated API and Codex catalogs, including subscription-only models, without hidden or stale entries.
320
321
  - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
321
322
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
322
323
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
323
- - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
324
324
 
325
325
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
326
326
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -11,7 +11,7 @@
11
11
  * Persisting the discovered lists (gjc parity) closes both gaps: the next launch
12
12
  * rehydrates the account's REAL model set from disk before any network call, and a
13
13
  * background refresh keeps it current. The cache is an optimization — a missing,
14
- * corrupt, or stale file only means "fall back to live discovery + static catalog",
14
+ * corrupt, or stale file only means live discovery must refresh the catalog,
15
15
  * never an error.
16
16
  */
17
17
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -37,15 +37,16 @@ export interface CachedProviderModels {
37
37
  }
38
38
 
39
39
  export interface ModelCacheFile {
40
- version: 2;
40
+ version: 3;
41
41
  updatedAt: number;
42
42
  providers: CachedProviderModels[];
43
43
  }
44
44
 
45
- const CACHE_VERSION = 2;
45
+ // Earlier snapshots could persist hidden or fabricated OpenAI entries.
46
+ const CACHE_VERSION = 3;
46
47
  /** Refresh in the background once the cache is older than this (6h). */
47
48
  export const MODEL_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
48
- /** Bound a single provider's persisted list so a pathological aggregator cannot bloat the file. */
49
+ /** Bound other providers' cache rows; preserve complete authoritative OpenAI lists. */
49
50
  const MAX_MODELS_PER_PROVIDER = 500;
50
51
 
51
52
  function cacheDir(): string {
@@ -62,9 +63,9 @@ export function normalizeCacheEntries(raw: unknown): CachedProviderModels[] {
62
63
  for (const entry of raw) {
63
64
  const row = entry as Partial<CachedProviderModels>;
64
65
  if (!row || typeof row.provider !== "string" || !Array.isArray(row.models)) continue;
65
- const models = row.models.filter((m): m is string => typeof m === "string" && m.trim().length > 0).slice(0, MAX_MODELS_PER_PROVIDER);
66
- if (models.length === 0) continue;
67
66
  const provider = row.provider as ProviderName;
67
+ const models = row.models.filter((m): m is string => typeof m === "string" && m.trim().length > 0).slice(0, provider === "openai" ? undefined : MAX_MODELS_PER_PROVIDER);
68
+ if (models.length === 0 && (provider !== "openai" || row.models.length !== 0)) continue;
68
69
  const source: ModelCacheSource =
69
70
  row.source === "oauth" || row.source === "api_key" || row.source === "keyless" || row.source === "none" ? row.source : "none";
70
71
  const baseUrl = typeof row.baseUrl === "string" ? row.baseUrl.replace(/\/+$/, "") : undefined;
@@ -111,26 +112,26 @@ export function isModelCacheStale(cache: ModelCacheFile | null, now = Date.now()
111
112
  */
112
113
  export function mergeCacheEntries(
113
114
  previous: readonly CachedProviderModels[],
114
- results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string }[],
115
+ results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string; fallback?: boolean }[],
115
116
  ): CachedProviderModels[] {
116
117
  const byKey = new Map<string, CachedProviderModels>();
117
- const keyOf = (provider: string, baseUrl?: string, accountId?: string) =>
118
- `${provider}\u0000${baseUrl ?? ""}\u0000${accountId ?? ""}`;
118
+ const keyOf = (provider: string, baseUrl?: string, accountId?: string, source?: ModelCacheSource) =>
119
+ `${provider}\u0000${baseUrl?.replace(/\/+$/, "") ?? ""}\u0000${accountId ?? ""}\u0000${provider === "openai" ? source ?? "none" : ""}`;
119
120
  for (const entry of previous) {
120
121
  const isOpenAIOAuth = entry.provider === "openai" && entry.source === "oauth";
121
122
  if (isOpenAIOAuth && !entry.accountId) continue;
122
- byKey.set(keyOf(entry.provider, entry.baseUrl, isOpenAIOAuth ? entry.accountId : undefined), entry);
123
+ byKey.set(keyOf(entry.provider, entry.baseUrl, isOpenAIOAuth ? entry.accountId : undefined, entry.source), entry);
123
124
  }
124
125
  for (const result of results) {
125
- if (!result.ok || result.models.length === 0) continue;
126
+ if (!result.ok || result.fallback || (result.models.length === 0 && result.provider !== "openai")) continue;
126
127
  const source: ModelCacheSource = result.source ?? "none";
127
128
  const isOpenAIOAuth = result.provider === "openai" && source === "oauth";
128
129
  const baseUrl = typeof result.baseUrl === "string" ? result.baseUrl.replace(/\/+$/, "") : undefined;
129
130
  const accountId = typeof result.accountId === "string" ? result.accountId.trim() : "";
130
131
  if (isOpenAIOAuth && !accountId) continue;
131
- byKey.set(keyOf(result.provider, baseUrl, isOpenAIOAuth ? accountId : undefined), {
132
+ byKey.set(keyOf(result.provider, baseUrl, isOpenAIOAuth ? accountId : undefined, source), {
132
133
  provider: result.provider,
133
- models: [...result.models].slice(0, MAX_MODELS_PER_PROVIDER),
134
+ models: [...result.models].slice(0, result.provider === "openai" ? undefined : MAX_MODELS_PER_PROVIDER),
134
135
  source,
135
136
  ...(baseUrl ? { baseUrl } : {}),
136
137
  ...(isOpenAIOAuth ? { accountId } : {}),
@@ -141,11 +142,16 @@ export function mergeCacheEntries(
141
142
 
142
143
  /** Persist discovery results (best-effort; never throws, never blocks a turn). */
143
144
  export async function writeModelCache(
144
- results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string }[],
145
+ results: readonly { provider: ProviderName; models: readonly string[]; ok: boolean; source?: ModelCacheSource; baseUrl?: string; accountId?: string; fallback?: boolean }[],
145
146
  ): Promise<void> {
146
147
  // Same hermeticity rule as saveGlobalConfig: a test run may only write into an
147
148
  // explicitly sandboxed JEO_CONFIG_DIR, never the developer's real ~/.jeo.
148
149
  if (process.env.NODE_ENV === "test" && !jeoEnv("CONFIG_DIR")) return;
150
+ // A failed/fallback-only refresh must not make an old snapshot appear fresh.
151
+ const hasFreshResult = results.some(result => result.ok && !result.fallback
152
+ && (result.models.length > 0 || result.provider === "openai")
153
+ && !(result.provider === "openai" && result.source === "oauth" && !result.accountId?.trim()));
154
+ if (!hasFreshResult) return;
149
155
  try {
150
156
  const existing = await readModelCache();
151
157
  const providers = mergeCacheEntries(existing?.providers ?? [], results);
@@ -173,9 +179,10 @@ export function applyCachedModels(cache: ModelCacheFile | null, openAIOauthAccou
173
179
  recordLiveProviderModels(entry.provider, entry.models, {
174
180
  source: entry.source,
175
181
  baseUrl: entry.baseUrl,
182
+ replace: entry.provider === "openai",
176
183
  ...(isOpenAIOAuth && entry.accountId ? { accountId: entry.accountId } : {}),
177
184
  });
178
- if (isOpenAIOAuth && entry.accountId) {
185
+ if (isOpenAIOAuth && entry.accountId && !entry.baseUrl) {
179
186
  recordLiveCodexModels(entry.models, entry.accountId);
180
187
  }
181
188
  applied += entry.models.length;
@@ -179,9 +179,8 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
179
179
 
180
180
  /**
181
181
  * OpenAI models the ChatGPT/Codex subscription backend (`codex/responses`) actually
182
- * serves. The Codex backend rejects standard API ids (gpt-4o, o3, …) and exposes no
183
- * usable list endpoint, so an OAuth-only OpenAI login surfaces exactly these instead
184
- * of the full chat-completions catalog. Verified live against a ChatGPT account.
182
+ * serves in the bundled offline snapshot. A confirmed account-specific model list
183
+ * replaces this snapshot for OAuth eligibility, including a successful empty list.
185
184
  */
186
185
  export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
187
186
 
@@ -236,7 +235,7 @@ export function recordLiveCodexModels(ids: readonly string[], accountId?: string
236
235
  const explicitScope = normalizeOpenAIOauthScope(accountId);
237
236
  if (explicitScope) setOpenAIOauthAccountScope(explicitScope);
238
237
  const scope = explicitScope || EPHEMERAL_OPENAI_OAUTH_SCOPE;
239
- const bucket = liveCodexModels.get(scope) ?? new Set<string>();
238
+ const bucket = new Set<string>();
240
239
  for (const id of ids) {
241
240
  const trimmed = id.trim();
242
241
  if (trimmed) bucket.add(trimmed);
@@ -244,10 +243,11 @@ export function recordLiveCodexModels(ids: readonly string[], accountId?: string
244
243
  liveCodexModels.set(scope, bucket);
245
244
  }
246
245
 
247
- /** True for the static Codex snapshot or a live id confirmed for the active scope. */
246
+ /** A confirmed account catalog is authoritative; use the snapshot before discovery. */
248
247
  export function isCodexModel(model: string): boolean {
249
248
  const wire = model.startsWith("openai/") ? model.slice(7) : model;
250
- return CODEX_MODELS.includes(wire) || (liveCodexModels.get(activeOpenAIOauthScope)?.has(wire) ?? false);
249
+ const confirmed = liveCodexModels.get(activeOpenAIOauthScope);
250
+ return confirmed ? confirmed.has(wire) : CODEX_MODELS.includes(wire);
251
251
  }
252
252
 
253
253
  /** Test-only reset for live Codex observations and the active account scope. */
@@ -330,9 +330,10 @@ function liveModelRecordKey(
330
330
  canonical: string,
331
331
  baseUrl: string | undefined,
332
332
  accountId?: string,
333
+ source?: LiveProviderModelSource,
333
334
  ): string {
334
335
  const normalizedAccountId = provider === "openai" ? normalizeOpenAIOauthScope(accountId) : "";
335
- return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${normalizedAccountId}\u0000${canonical}`;
336
+ return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${normalizedAccountId}\u0000${provider === "openai" ? source ?? "none" : ""}\u0000${canonical}`;
336
337
  }
337
338
 
338
339
  function liveModelFallbackRow(provider: ProviderName, canonical: string, providerModel: string): CatalogModel {
@@ -351,12 +352,12 @@ function liveModelFallbackRow(provider: ProviderName, canonical: string, provide
351
352
  };
352
353
  }
353
354
 
354
- /** Record live ids returned by a provider's own model-list endpoint. Additive only:
355
- * transient partial responses must not evict an id observed earlier this session. */
355
+ /** Record live ids. Complete OpenAI refreshes may replace their matching scope;
356
+ * partial observations and other providers remain additive. */
356
357
  export function recordLiveProviderModels(
357
358
  provider: ProviderName,
358
359
  ids: readonly string[],
359
- opts: { source?: LiveProviderModelSource; baseUrl?: string; accountId?: string } = {},
360
+ opts: { source?: LiveProviderModelSource; baseUrl?: string; accountId?: string; replace?: boolean } = {},
360
361
  ): void {
361
362
  const source = opts.source ?? "none";
362
363
  const baseUrl = normalizeBaseUrl(opts.baseUrl);
@@ -366,6 +367,14 @@ export function recordLiveProviderModels(
366
367
  if (provider === "openai" && source === "oauth" && scopedAccountId) {
367
368
  setOpenAIOauthAccountScope(scopedAccountId);
368
369
  }
370
+ if (provider === "openai" && opts.replace) {
371
+ for (const [key, record] of liveProviderModels) {
372
+ if (record.row.provider === provider && record.source === source && record.baseUrl === baseUrl
373
+ && oauthScope(record.accountId) === oauthScope(scopedAccountId)) {
374
+ removeLiveProviderRecord(key, record);
375
+ }
376
+ }
377
+ }
369
378
  for (const id of ids) {
370
379
  const canonical = liveCanonicalId(provider, id);
371
380
  if (!canonical) continue;
@@ -374,7 +383,7 @@ export function recordLiveProviderModels(
374
383
  const row = known
375
384
  ? { ...known, canonical, provider, providerModel }
376
385
  : liveModelFallbackRow(provider, canonical, providerModel);
377
- const recordKey = liveModelRecordKey(provider, canonical, baseUrl, scopedAccountId);
386
+ const recordKey = liveModelRecordKey(provider, canonical, baseUrl, scopedAccountId, source);
378
387
  liveProviderModels.set(recordKey, {
379
388
  row,
380
389
  source,
@@ -13,7 +13,6 @@ import type { ProviderName } from "./types";
13
13
  import { PROVIDER_NAMES } from "./provider-status";
14
14
  import {
15
15
  catalogByProvider,
16
- CODEX_MODELS,
17
16
  KIMI_CODE_MODELS,
18
17
  recordLiveCodexModels,
19
18
  recordLiveProviderModels,
@@ -32,6 +31,8 @@ export interface ProviderModelsResult {
32
31
  source: "oauth" | "api_key" | "keyless" | "none";
33
32
  /** Stable OpenAI OAuth account scope for account-specific cache rows. */
34
33
  accountId?: string;
34
+ /** Actual custom endpoint queried, for endpoint-scoped persistent caches. */
35
+ baseUrl?: string;
35
36
  /** Present on failure: a short, human-readable reason. */
36
37
  error?: string;
37
38
  /** True when the live endpoint was unusable and ids came from the static catalog. */
@@ -45,7 +46,7 @@ export interface DiscoveryOptions {
45
46
  /** Per-request timeout; default 5000ms. */
46
47
  timeoutMs?: number;
47
48
  signal?: AbortSignal;
48
- /** Cap the number of returned ids per provider; default 100. */
49
+ /** Cap returned ids; OpenAI defaults to the full list, other providers to 100. */
49
50
  limit?: number;
50
51
  /** Config snapshot used for provider base URLs. */
51
52
  config?: Config;
@@ -54,8 +55,8 @@ export interface DiscoveryOptions {
54
55
  * the just-stored OAuth credential even when an unrelated `providers.openai`
55
56
  * API key is also configured, so the report reflects the account that was
56
57
  * JUST logged in rather than silently swapping to the API key. Does not
57
- * change any other discovery caller — the TUI/`discoverModels` default
58
- * (API key preferred when configured) is untouched.
58
+ * change single-provider discovery's API-key preference. `discoverModels`
59
+ * queries both OpenAI catalogs when both credentials are configured.
59
60
  */
60
61
  preferOAuth?: boolean;
61
62
  }
@@ -107,6 +108,7 @@ export function discoveryRequest(
107
108
  provider: ProviderName,
108
109
  cred: Credential | undefined,
109
110
  baseUrl?: string,
111
+ oauthAccountId?: string,
110
112
  ): { url: string; headers: Record<string, string>; method?: "GET" | "POST"; body?: string } {
111
113
  // Catalog-driven compat providers: OpenAI `${base}/models` (Bearer) or Anthropic
112
114
  // `${base}/v1/models` (x-api-key). Both return { data: [{ id }] }.
@@ -125,7 +127,7 @@ export function discoveryRequest(
125
127
  case "openai": {
126
128
  const token = cred?.kind === "oauth" || cred?.kind === "api_key" ? cred.token : "";
127
129
  if (cred?.kind === "oauth" && !baseUrl && !process.env.OPENAI_BASE_URL) {
128
- const accountId = extractChatgptAccountId(token);
130
+ const accountId = extractChatgptAccountId(token) || oauthAccountId;
129
131
  const headers: Record<string, string> = {
130
132
  Authorization: `Bearer ${token}`,
131
133
  "OpenAI-Beta": "responses=experimental",
@@ -180,11 +182,12 @@ export function discoveryRequest(
180
182
 
181
183
  /**
182
184
  * OpenAI `/v1/models` lists every model family — embeddings, audio/tts, image, moderation,
183
- * realtime — but jeo only calls chat/completions. Drop the families that can never serve a
185
+ * realtime — jeo uses chat/completions and Responses. Drop the families that cannot serve a
184
186
  * chat turn so pickers never offer a model that fails at call time.
185
187
  */
186
188
  function isOpenAiChatModel(id: string): boolean {
187
- return !/(^|[-/])(text-embedding|embedding|tts|whisper|dall-e|moderation|omni-moderation|davinci|babbage|computer-use|realtime|audio|image|sora|transcribe|instruct|codex)([-/]|$)/i.test(id);
189
+ return !/(^|[-/])(text-embedding|embedding|tts|whisper|dall-e|moderation|omni-moderation|davinci|babbage|computer-use|realtime|audio|image|sora|transcribe|instruct)([-/]|$)/i.test(id)
190
+ && !/^codex([-/]|$)/i.test(id);
188
191
  }
189
192
 
190
193
  /**
@@ -195,11 +198,11 @@ function isGeminiChatModel(id: string): boolean {
195
198
  return !/(^|[-/])(embedding|aqa|tts|image|imagen|veo|lyria|nano-banana|deep-research|computer-use|antigravity)([-/]|$)/i.test(id);
196
199
  }
197
200
 
198
- type CodexModelRow = { slug?: string; id?: string; supported_in_api?: boolean; priority?: number };
201
+ type CodexModelRow = { slug?: string; id?: string; visibility?: string; supported_in_api?: boolean; priority?: number };
199
202
  type AntigravityModelRow = { slug?: string; id?: string; name?: string; isInternal?: boolean; model?: string };
200
203
 
201
204
  /** Parse a provider's models response body into normalized, chat-capable model ids. */
202
- export function parseModelsBody(provider: ProviderName, body: unknown): string[] {
205
+ export function parseModelsBody(provider: ProviderName, body: unknown, opts: { openaiCompatible?: boolean } = {}): string[] {
203
206
  const data = body as {
204
207
  data?: { id?: string }[];
205
208
  models?: ({ name?: string; supportedGenerationMethods?: string[] } & CodexModelRow)[];
@@ -285,14 +288,16 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
285
288
  }
286
289
  if (provider === "openai" && data.models?.some(m => m.slug || m.id)) {
287
290
  return data.models
288
- .filter(m => m.supported_in_api !== false)
291
+ // supported_in_api describes API-key availability, not ChatGPT entitlement.
292
+ // Codex's ChatGPT picker uses visibility, including subscription-only rows.
293
+ .filter(m => m.visibility === undefined || m.visibility === "list")
289
294
  .map(m => m.slug ?? m.id ?? "")
290
295
  // Review-only entries (e.g. codex-auto-review) are not chat-turn models.
291
296
  .filter(id => id && !/(^|[-/])auto-review([-/]|$)/i.test(id));
292
297
  }
293
298
  // anthropic / openai: { data: [{ id }] }
294
299
  const ids = (data.data ?? []).map(m => m.id ?? "").filter(Boolean);
295
- return provider === "openai" ? ids.filter(isOpenAiChatModel) : ids;
300
+ return provider === "openai" && !opts.openaiCompatible ? ids.filter(isOpenAiChatModel) : ids;
296
301
  }
297
302
 
298
303
  /**
@@ -305,23 +310,23 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
305
310
  */
306
311
  export function catalogOr(result: ProviderModelsResult): ProviderModelsResult {
307
312
  if (result.ok && result.models.length > 0) return result;
313
+ // OpenAI has live list endpoints for both auth modes. Neither a failed request
314
+ // nor an authoritative empty list establishes access to the static snapshot.
315
+ if (result.provider === "openai") return result;
308
316
  // Antigravity's available models depend on the Cloud Code Assist agent backend
309
317
  // and must never be faked from a hard-coded catalog.
310
318
  if (result.provider === "antigravity") return result;
311
319
  // No catalog-fallback for oauth-sourced gemini failures: OAuth can LIST but
312
320
  // not SERVE gemini models (see listProviderModels' gemini gate).
313
321
  if (result.provider === "gemini" && result.source === "oauth") return result;
314
- // OAuth: OpenAI/Codex legitimately rejects the standard /models endpoint while
315
- // the fixed Codex ids still work; other OAuth providers fall back to their full
316
- // static catalog too. API-key providers only fall back when the provider has NO
322
+ // Other OAuth providers fall back to their static catalog. API-key providers
323
+ // only fall back when the provider has NO
317
324
  // models-list endpoint at all (HTTP 404 / not found) — never for an auth
318
325
  // rejection, so a wrong key stays a failure instead of fabricating catalog rows.
319
326
  const eligible = result.source === "oauth"
320
327
  || (result.source === "api_key" && isMissingModelsEndpoint(result.error));
321
328
  if (!eligible) return result;
322
- const ids = result.provider === "openai" && result.source === "oauth"
323
- ? [...CODEX_MODELS]
324
- : result.provider === "kimi" && result.source === "oauth"
329
+ const ids = result.provider === "kimi" && result.source === "oauth"
325
330
  // Kimi OAuth (Kimi Code subscription) serves ONLY the Kimi Code catalog —
326
331
  // moonshot API-platform ids would 404 against api.kimi.com/coding.
327
332
  ? KIMI_CODE_MODELS.map(id => `kimi/${id}`)
@@ -345,7 +350,8 @@ export async function listProviderModels(
345
350
  opts: DiscoveryOptions = {},
346
351
  ): Promise<ProviderModelsResult> {
347
352
  const fetchImpl = opts.fetchImpl ?? fetch;
348
- const limit = opts.limit ?? DEFAULT_LIMIT;
353
+ const limit = opts.limit ?? (provider === "openai" ? Number.POSITIVE_INFINITY : DEFAULT_LIMIT);
354
+ let baseUrl = opts.baseUrl;
349
355
 
350
356
  let cred: Credential | undefined;
351
357
  let oauthAccountId: string | undefined;
@@ -363,6 +369,7 @@ export async function listProviderModels(
363
369
  cred = raw;
364
370
  source = cred.kind === "oauth" ? "oauth" : cred.kind === "api_key" ? "api_key" : "none";
365
371
  const config = opts.config ?? (await readGlobalConfig());
372
+ if (provider === "openai") baseUrl = (baseUrl ?? config.openaiBaseUrl ?? process.env.OPENAI_BASE_URL)?.replace(/\/+$/, "");
366
373
 
367
374
  if (provider === "antigravity") {
368
375
  // Antigravity lists models from the LIVE Cloud Code Assist endpoint
@@ -391,6 +398,12 @@ export async function listProviderModels(
391
398
  cred = { kind: "api_key", provider: prov, token: config.providers[prov]! };
392
399
  source = "api_key";
393
400
  }
401
+ if (provider === "openai" && baseUrl && cred.kind === "oauth") {
402
+ // Subscription credentials belong to ChatGPT, not a configured proxy.
403
+ const key = config.providers?.openai;
404
+ cred = key ? { kind: "api_key", provider: "openai", token: key } : { kind: "none", provider: "openai" };
405
+ source = key ? "api_key" : "keyless";
406
+ }
394
407
  if (provider === "openai" && source === "oauth" && cred.kind === "oauth") {
395
408
  const stored = await getStoredOAuth("openai").catch(() => undefined);
396
409
  const jwtAccountId = extractChatgptAccountId(cred.token);
@@ -406,23 +419,32 @@ export async function listProviderModels(
406
419
  if (provider === "gemini" && cred.kind === "oauth") {
407
420
  return { provider, models: [], ok: false, source, error: "OAuth cannot serve gemini models — set GEMINI_API_KEY, or use antigravity/* (Cloud Code Assist)" };
408
421
  }
409
- const isLocalOpenAi = provider === "openai" && !!(opts.baseUrl ?? process.env.OPENAI_BASE_URL);
422
+ const isLocalOpenAi = provider === "openai" && !!baseUrl;
410
423
  if (source === "none" && !isLocalOpenAi) {
411
424
  return { provider, models: [], ok: false, source, error: "not logged in" };
412
425
  }
413
426
  }
414
427
 
415
- const { url, headers, method, body: requestBody } = discoveryRequest(provider, cred, opts.baseUrl);
428
+ const { url, headers, method, body: requestBody } = discoveryRequest(provider, cred, baseUrl, oauthAccountId);
429
+ const scope = { ...(oauthAccountId ? { accountId: oauthAccountId } : {}), ...(baseUrl ? { baseUrl } : {}) };
416
430
  const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT;
417
431
  const signal = opts.signal ?? AbortSignal.timeout(timeout);
418
432
  try {
419
433
  const res = await fetchImpl(url, { method: method ?? "GET", headers, body: requestBody, signal });
420
434
  if (!res.ok) {
421
435
  const reason = res.status === 401 || res.status === 403 ? "auth rejected" : `HTTP ${res.status}`;
422
- return { provider, models: [], ok: false, source, error: reason };
436
+ return { provider, models: [], ok: false, source, error: reason, ...scope };
423
437
  }
424
438
  const body = await res.json();
425
- let ids = parseModelsBody(provider, body);
439
+ if (provider === "openai") {
440
+ const payload = body as { models?: unknown; data?: unknown } | null;
441
+ const rows = Array.isArray(payload?.models) ? payload.models : payload?.data;
442
+ if (!Array.isArray(rows) || rows.some(row => !row || typeof row !== "object"
443
+ || ![row.slug, row.id].some(id => typeof id === "string" && id.trim().length > 0))) {
444
+ return { provider, models: [], ok: false, source, error: "invalid models response", ...scope };
445
+ }
446
+ }
447
+ let ids = parseModelsBody(provider, body, { openaiCompatible: provider === "openai" && !!baseUrl });
426
448
  // Gemini paginates: follow nextPageToken (bounded) so the available list is
427
449
  // COMPLETE — page 1 alone silently dropped the newest models (round-15).
428
450
  if (provider === "gemini") {
@@ -436,26 +458,28 @@ export async function listProviderModels(
436
458
  pageToken = pageBody.nextPageToken;
437
459
  }
438
460
  }
439
- const models = [...new Set(ids)].sort().slice(0, limit);
461
+ const allModels = [...new Set(ids)].sort();
462
+ const models = allModels.slice(0, limit);
440
463
  // Record live ids for routing supplements. For OpenAI OAuth, keep the
441
464
  // existing Codex allow-list widening separate: API-key/custom-base discovery
442
465
  // must not make OAuth Codex calls accept unrelated API models.
443
- recordLiveProviderModels(provider, models, {
466
+ recordLiveProviderModels(provider, allModels, {
444
467
  source,
445
- baseUrl: opts.baseUrl,
468
+ baseUrl,
469
+ replace: provider === "openai",
446
470
  ...(provider === "openai" && source === "oauth" && oauthAccountId ? { accountId: oauthAccountId } : {}),
447
471
  });
448
- if (provider === "openai" && source === "oauth") recordLiveCodexModels(models, oauthAccountId);
472
+ if (provider === "openai" && source === "oauth" && !baseUrl) recordLiveCodexModels(allModels, oauthAccountId);
449
473
  return {
450
474
  provider,
451
475
  models,
452
476
  ok: true,
453
477
  source,
454
- ...(oauthAccountId ? { accountId: oauthAccountId } : {}),
478
+ ...scope,
455
479
  };
456
480
  } catch (err) {
457
481
  const msg = (err as Error)?.name === "TimeoutError" || (err as Error)?.name === "AbortError" ? "timeout" : "unreachable";
458
- return { provider, models: [], ok: false, source, error: msg };
482
+ return { provider, models: [], ok: false, source, error: msg, ...scope };
459
483
  }
460
484
  }
461
485
 
@@ -495,13 +519,24 @@ export async function discoverModels(
495
519
  const providers = opts.providers ?? [...PROVIDER_NAMES];
496
520
  const useFallback = opts.catalogFallback !== false;
497
521
  const results = await Promise.all(
498
- providers.map(p =>
499
- listProviderModels(p, {
522
+ providers.map(async p => {
523
+ const discoveryOpts = {
500
524
  ...opts,
501
525
  config: cfg,
502
526
  baseUrl: p === "ollama" ? (cfg.ollamaBaseUrl ?? opts.baseUrl) : p === "lmstudio" ? (cfg.lmstudioBaseUrl ?? opts.baseUrl) : p === "openai" ? (cfg.openaiBaseUrl ?? opts.baseUrl) : opts.baseUrl,
503
- }),
504
- ),
527
+ };
528
+ const primary = await listProviderModels(p, discoveryOpts);
529
+ // Both credentials can coexist. API discovery alone omits subscription-only
530
+ // models and never teaches the Codex call gate what this account can serve.
531
+ if (p === "openai" && primary.source === "api_key" && !discoveryOpts.baseUrl && !process.env.OPENAI_BASE_URL) {
532
+ const credential = await resolveCredential("openai");
533
+ if (credential.kind === "oauth") {
534
+ return [primary, await listProviderModels(p, { ...discoveryOpts, preferOAuth: true })];
535
+ }
536
+ }
537
+ return [primary];
538
+ }),
505
539
  );
506
- return useFallback ? results.map(catalogOr) : results;
540
+ const flat = results.flat();
541
+ return useFallback ? flat.map(catalogOr) : flat;
507
542
  }
@@ -16,9 +16,13 @@ export interface PickEntry {
16
16
  /** Flatten successful discovery results into an ordered, 1-based pick list. */
17
17
  export function flattenModels(results: ProviderModelsResult[]): PickEntry[] {
18
18
  const out: PickEntry[] = [];
19
+ const seen = new Set<string>();
19
20
  for (const r of results) {
20
21
  if (!r.ok) continue;
21
22
  for (const model of r.models) {
23
+ const key = `${r.provider}\u0000${model}`;
24
+ if (seen.has(key)) continue;
25
+ seen.add(key);
22
26
  out.push({ index: out.length + 1, provider: r.provider, model });
23
27
  }
24
28
  }
@@ -55,7 +55,7 @@ import { callLlm, type Message } from "../agent/loop";
55
55
  import { friendlyProviderError } from "../util/provider-error";
56
56
  import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
57
57
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
58
- import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
58
+ import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, liveProviderCatalogModels, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
59
59
  import { rehydrateLiveModels, writeModelCache } from "../ai/model-cache";
60
60
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
61
61
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
@@ -335,8 +335,8 @@ function providerDefaultModel(p: ProviderName): string {
335
335
  }
336
336
 
337
337
  /**
338
- * Pick-list entries for ONE provider, with static fallbacks so the list is never
339
- * empty.
338
+ * Pick-list entries for ONE provider. OpenAI availability comes only from live
339
+ * results; other providers retain their offline catalog fallbacks.
340
340
  *
341
341
  * Live discovery yields ids only for a logged-in, reachable provider, and
342
342
  * `catalogOr` backfills the static catalog for OAuth sources and for API-key
@@ -348,6 +348,9 @@ function providerDefaultModel(p: ProviderName): string {
348
348
  export function providerPickEntries(live: ProviderModelsResult[], want: ProviderName): PickEntry[] {
349
349
  const fromLive = flattenModels(live.filter(r => r.provider === want));
350
350
  if (fromLive.length) return fromLive;
351
+ // An OpenAI empty/error response is authoritative for this picker. Offering
352
+ // the static capability catalog here would fabricate account availability.
353
+ if (want === "openai") return [];
351
354
  const catalog = catalogByProvider(want);
352
355
  if (catalog.length) {
353
356
  return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
@@ -2427,7 +2430,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2427
2430
  // the OAuth Codex gate from the first keystroke, instead of only after the network
2428
2431
  // discovery below happens to land.
2429
2432
  const aliasNames = Object.keys(await listAliases());
2430
- const cachedModels = await rehydrateLiveModels().catch(() => null);
2433
+ await rehydrateLiveModels().catch(() => null);
2434
+ const completionConfig = await readGlobalConfig();
2435
+ const cachedCompletionModels = liveProviderCatalogModels({ openaiBaseUrl: completionConfig.openaiBaseUrl ?? process.env.OPENAI_BASE_URL });
2431
2436
  void getLiveModels()
2432
2437
  .then(r => {
2433
2438
  liveModelsCache ??= r;
@@ -2442,14 +2447,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2442
2447
  // access to were un-completable for the first seconds of every session.
2443
2448
  const liveFromDiscovery = liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : null;
2444
2449
  const cachedFor = (p: string): string[] =>
2445
- cachedModels?.providers.filter(entry => entry.provider === p).flatMap(entry => entry.models) ?? [];
2450
+ cachedCompletionModels.filter(entry => entry.provider === p).map(entry => entry.canonical);
2446
2451
  return {
2447
2452
  ...base,
2448
2453
  slashCommands: [...base.slashCommands, ...skillSlashDetails.map(d => d.command)],
2449
- liveModels: liveFromDiscovery ?? (cachedModels?.providers.flatMap(entry => entry.models) ?? []),
2454
+ liveModels: liveFromDiscovery ?? cachedCompletionModels.map(entry => entry.canonical),
2450
2455
  aliases: aliasNames,
2451
2456
  skillNames: resolvedSkillTokens,
2452
- modelsForProvider: p => liveModelsCache?.find(r => r.provider === p)?.models ?? cachedFor(p),
2457
+ modelsForProvider: p => liveModelsCache
2458
+ ? flattenModels(liveModelsCache.filter(r => r.provider === p)).map(entry => entry.model)
2459
+ : cachedFor(p),
2453
2460
  mentionPaths,
2454
2461
  };
2455
2462
  };