jeo-code 0.11.1 → 0.11.3

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.
@@ -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
  }
@@ -69,13 +70,6 @@ const DEFAULT_LIMIT = 100;
69
70
  const CODEX_CLIENT_VERSION = "2.0.0";
70
71
  export const CODEX_MODELS_URL = `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_CLIENT_VERSION}`;
71
72
  const ANTIGRAVITY_MODELS_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
72
- const ANTIGRAVITY_MODEL_DENYLIST = new Set([
73
- "chat_20706",
74
- "chat_23310",
75
- "gemini-2.5-flash-thinking",
76
- "gemini-3-pro-low",
77
- "gemini-2.5-pro",
78
- ]);
79
73
 
80
74
  function anthropicHeaders(cred: Credential): Record<string, string> {
81
75
  if (cred.kind === "oauth") {
@@ -107,6 +101,7 @@ export function discoveryRequest(
107
101
  provider: ProviderName,
108
102
  cred: Credential | undefined,
109
103
  baseUrl?: string,
104
+ oauthAccountId?: string,
110
105
  ): { url: string; headers: Record<string, string>; method?: "GET" | "POST"; body?: string } {
111
106
  // Catalog-driven compat providers: OpenAI `${base}/models` (Bearer) or Anthropic
112
107
  // `${base}/v1/models` (x-api-key). Both return { data: [{ id }] }.
@@ -125,7 +120,7 @@ export function discoveryRequest(
125
120
  case "openai": {
126
121
  const token = cred?.kind === "oauth" || cred?.kind === "api_key" ? cred.token : "";
127
122
  if (cred?.kind === "oauth" && !baseUrl && !process.env.OPENAI_BASE_URL) {
128
- const accountId = extractChatgptAccountId(token);
123
+ const accountId = extractChatgptAccountId(token) || oauthAccountId;
129
124
  const headers: Record<string, string> = {
130
125
  Authorization: `Bearer ${token}`,
131
126
  "OpenAI-Beta": "responses=experimental",
@@ -180,11 +175,12 @@ export function discoveryRequest(
180
175
 
181
176
  /**
182
177
  * 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
178
+ * realtime — jeo uses chat/completions and Responses. Drop the families that cannot serve a
184
179
  * chat turn so pickers never offer a model that fails at call time.
185
180
  */
186
181
  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);
182
+ return !/(^|[-/])(text-embedding|embedding|tts|whisper|dall-e|moderation|omni-moderation|davinci|babbage|computer-use|realtime|audio|image|sora|transcribe|instruct)([-/]|$)/i.test(id)
183
+ && !/^codex([-/]|$)/i.test(id);
188
184
  }
189
185
 
190
186
  /**
@@ -195,11 +191,11 @@ function isGeminiChatModel(id: string): boolean {
195
191
  return !/(^|[-/])(embedding|aqa|tts|image|imagen|veo|lyria|nano-banana|deep-research|computer-use|antigravity)([-/]|$)/i.test(id);
196
192
  }
197
193
 
198
- type CodexModelRow = { slug?: string; id?: string; supported_in_api?: boolean; priority?: number };
194
+ type CodexModelRow = { slug?: string; id?: string; visibility?: string; supported_in_api?: boolean; priority?: number };
199
195
  type AntigravityModelRow = { slug?: string; id?: string; name?: string; isInternal?: boolean; model?: string };
200
196
 
201
197
  /** Parse a provider's models response body into normalized, chat-capable model ids. */
202
- export function parseModelsBody(provider: ProviderName, body: unknown): string[] {
198
+ export function parseModelsBody(provider: ProviderName, body: unknown, opts: { openaiCompatible?: boolean } = {}): string[] {
203
199
  const data = body as {
204
200
  data?: { id?: string }[];
205
201
  models?: ({ name?: string; supportedGenerationMethods?: string[] } & CodexModelRow)[];
@@ -244,10 +240,11 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
244
240
  /** Array of ids OR an object keyed by deprecated id. */
245
241
  deprecatedModelIds?: string[] | Record<string, unknown>;
246
242
  };
247
- const roleIds = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
243
+ const normalizeId = (id: string): string => id.replace(/^models\//, "").replace(/^antigravity\//, "");
244
+ const roleIds = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").map(normalizeId) : []);
248
245
  const deprecated = Array.isArray(payload.deprecatedModelIds)
249
246
  ? roleIds(payload.deprecatedModelIds)
250
- : Object.keys(payload.deprecatedModelIds ?? {});
247
+ : Object.keys(payload.deprecatedModelIds ?? {}).map(normalizeId);
251
248
  const agentIds = new Set(
252
249
  (Array.isArray(payload.agentModelSorts) ? payload.agentModelSorts : [])
253
250
  .flatMap(sort => (Array.isArray(sort?.groups) ? sort.groups : []))
@@ -262,18 +259,16 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
262
259
  ...deprecated,
263
260
  ]);
264
261
  const rawModels = payload.models;
265
- const ids = Array.isArray(rawModels)
266
- ? rawModels.map(m => m.slug ?? m.id ?? m.name ?? "").filter(Boolean)
267
- : Object.entries(rawModels ?? {})
268
- .filter(([id, model]) =>
269
- !ANTIGRAVITY_MODEL_DENYLIST.has(id) &&
270
- model?.isInternal !== true &&
271
- (agentIds.size > 0 ? agentIds.has(id) : !nonChat.has(id)))
272
- .map(([id]) => id);
273
- return ids
274
- .map(id => id.replace(/^models\//, ""))
275
- .filter(Boolean)
276
- .map(id => id.startsWith("antigravity/") ? id : `antigravity/${id}`);
262
+ const entries = Array.isArray(rawModels)
263
+ ? rawModels.map(model => [model?.slug ?? model?.id ?? model?.name ?? "", model] as const)
264
+ : Object.entries(rawModels ?? {});
265
+ return entries
266
+ .map(([id, model]) => [typeof id === "string" ? normalizeId(id) : "", model] as const)
267
+ .filter(([id, model]) =>
268
+ id.length > 0 &&
269
+ model?.isInternal !== true &&
270
+ (agentIds.size > 0 ? agentIds.has(id) : !nonChat.has(id)))
271
+ .map(([id]) => `antigravity/${id}`);
277
272
  }
278
273
  if (provider === "gemini") {
279
274
  // Keep only models the generateContent endpoint can serve (skip embeddings/tts/aqa/etc).
@@ -285,14 +280,16 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
285
280
  }
286
281
  if (provider === "openai" && data.models?.some(m => m.slug || m.id)) {
287
282
  return data.models
288
- .filter(m => m.supported_in_api !== false)
283
+ // supported_in_api describes API-key availability, not ChatGPT entitlement.
284
+ // Codex's ChatGPT picker uses visibility, including subscription-only rows.
285
+ .filter(m => m.visibility === undefined || m.visibility === "list")
289
286
  .map(m => m.slug ?? m.id ?? "")
290
287
  // Review-only entries (e.g. codex-auto-review) are not chat-turn models.
291
288
  .filter(id => id && !/(^|[-/])auto-review([-/]|$)/i.test(id));
292
289
  }
293
290
  // anthropic / openai: { data: [{ id }] }
294
291
  const ids = (data.data ?? []).map(m => m.id ?? "").filter(Boolean);
295
- return provider === "openai" ? ids.filter(isOpenAiChatModel) : ids;
292
+ return provider === "openai" && !opts.openaiCompatible ? ids.filter(isOpenAiChatModel) : ids;
296
293
  }
297
294
 
298
295
  /**
@@ -305,23 +302,23 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
305
302
  */
306
303
  export function catalogOr(result: ProviderModelsResult): ProviderModelsResult {
307
304
  if (result.ok && result.models.length > 0) return result;
305
+ // OpenAI has live list endpoints for both auth modes. Neither a failed request
306
+ // nor an authoritative empty list establishes access to the static snapshot.
307
+ if (result.provider === "openai") return result;
308
308
  // Antigravity's available models depend on the Cloud Code Assist agent backend
309
309
  // and must never be faked from a hard-coded catalog.
310
310
  if (result.provider === "antigravity") return result;
311
311
  // No catalog-fallback for oauth-sourced gemini failures: OAuth can LIST but
312
312
  // not SERVE gemini models (see listProviderModels' gemini gate).
313
313
  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
314
+ // Other OAuth providers fall back to their static catalog. API-key providers
315
+ // only fall back when the provider has NO
317
316
  // models-list endpoint at all (HTTP 404 / not found) — never for an auth
318
317
  // rejection, so a wrong key stays a failure instead of fabricating catalog rows.
319
318
  const eligible = result.source === "oauth"
320
319
  || (result.source === "api_key" && isMissingModelsEndpoint(result.error));
321
320
  if (!eligible) return result;
322
- const ids = result.provider === "openai" && result.source === "oauth"
323
- ? [...CODEX_MODELS]
324
- : result.provider === "kimi" && result.source === "oauth"
321
+ const ids = result.provider === "kimi" && result.source === "oauth"
325
322
  // Kimi OAuth (Kimi Code subscription) serves ONLY the Kimi Code catalog —
326
323
  // moonshot API-platform ids would 404 against api.kimi.com/coding.
327
324
  ? KIMI_CODE_MODELS.map(id => `kimi/${id}`)
@@ -345,7 +342,8 @@ export async function listProviderModels(
345
342
  opts: DiscoveryOptions = {},
346
343
  ): Promise<ProviderModelsResult> {
347
344
  const fetchImpl = opts.fetchImpl ?? fetch;
348
- const limit = opts.limit ?? DEFAULT_LIMIT;
345
+ const limit = opts.limit ?? (provider === "openai" ? Number.POSITIVE_INFINITY : DEFAULT_LIMIT);
346
+ let baseUrl = opts.baseUrl;
349
347
 
350
348
  let cred: Credential | undefined;
351
349
  let oauthAccountId: string | undefined;
@@ -363,6 +361,7 @@ export async function listProviderModels(
363
361
  cred = raw;
364
362
  source = cred.kind === "oauth" ? "oauth" : cred.kind === "api_key" ? "api_key" : "none";
365
363
  const config = opts.config ?? (await readGlobalConfig());
364
+ if (provider === "openai") baseUrl = (baseUrl ?? config.openaiBaseUrl ?? process.env.OPENAI_BASE_URL)?.replace(/\/+$/, "");
366
365
 
367
366
  if (provider === "antigravity") {
368
367
  // Antigravity lists models from the LIVE Cloud Code Assist endpoint
@@ -391,6 +390,12 @@ export async function listProviderModels(
391
390
  cred = { kind: "api_key", provider: prov, token: config.providers[prov]! };
392
391
  source = "api_key";
393
392
  }
393
+ if (provider === "openai" && baseUrl && cred.kind === "oauth") {
394
+ // Subscription credentials belong to ChatGPT, not a configured proxy.
395
+ const key = config.providers?.openai;
396
+ cred = key ? { kind: "api_key", provider: "openai", token: key } : { kind: "none", provider: "openai" };
397
+ source = key ? "api_key" : "keyless";
398
+ }
394
399
  if (provider === "openai" && source === "oauth" && cred.kind === "oauth") {
395
400
  const stored = await getStoredOAuth("openai").catch(() => undefined);
396
401
  const jwtAccountId = extractChatgptAccountId(cred.token);
@@ -406,23 +411,32 @@ export async function listProviderModels(
406
411
  if (provider === "gemini" && cred.kind === "oauth") {
407
412
  return { provider, models: [], ok: false, source, error: "OAuth cannot serve gemini models — set GEMINI_API_KEY, or use antigravity/* (Cloud Code Assist)" };
408
413
  }
409
- const isLocalOpenAi = provider === "openai" && !!(opts.baseUrl ?? process.env.OPENAI_BASE_URL);
414
+ const isLocalOpenAi = provider === "openai" && !!baseUrl;
410
415
  if (source === "none" && !isLocalOpenAi) {
411
416
  return { provider, models: [], ok: false, source, error: "not logged in" };
412
417
  }
413
418
  }
414
419
 
415
- const { url, headers, method, body: requestBody } = discoveryRequest(provider, cred, opts.baseUrl);
420
+ const { url, headers, method, body: requestBody } = discoveryRequest(provider, cred, baseUrl, oauthAccountId);
421
+ const scope = { ...(oauthAccountId ? { accountId: oauthAccountId } : {}), ...(baseUrl ? { baseUrl } : {}) };
416
422
  const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT;
417
423
  const signal = opts.signal ?? AbortSignal.timeout(timeout);
418
424
  try {
419
425
  const res = await fetchImpl(url, { method: method ?? "GET", headers, body: requestBody, signal });
420
426
  if (!res.ok) {
421
427
  const reason = res.status === 401 || res.status === 403 ? "auth rejected" : `HTTP ${res.status}`;
422
- return { provider, models: [], ok: false, source, error: reason };
428
+ return { provider, models: [], ok: false, source, error: reason, ...scope };
423
429
  }
424
430
  const body = await res.json();
425
- let ids = parseModelsBody(provider, body);
431
+ if (provider === "openai") {
432
+ const payload = body as { models?: unknown; data?: unknown } | null;
433
+ const rows = Array.isArray(payload?.models) ? payload.models : payload?.data;
434
+ if (!Array.isArray(rows) || rows.some(row => !row || typeof row !== "object"
435
+ || ![row.slug, row.id].some(id => typeof id === "string" && id.trim().length > 0))) {
436
+ return { provider, models: [], ok: false, source, error: "invalid models response", ...scope };
437
+ }
438
+ }
439
+ let ids = parseModelsBody(provider, body, { openaiCompatible: provider === "openai" && !!baseUrl });
426
440
  // Gemini paginates: follow nextPageToken (bounded) so the available list is
427
441
  // COMPLETE — page 1 alone silently dropped the newest models (round-15).
428
442
  if (provider === "gemini") {
@@ -436,26 +450,28 @@ export async function listProviderModels(
436
450
  pageToken = pageBody.nextPageToken;
437
451
  }
438
452
  }
439
- const models = [...new Set(ids)].sort().slice(0, limit);
453
+ const allModels = [...new Set(ids)].sort();
454
+ const models = allModels.slice(0, limit);
440
455
  // Record live ids for routing supplements. For OpenAI OAuth, keep the
441
456
  // existing Codex allow-list widening separate: API-key/custom-base discovery
442
457
  // must not make OAuth Codex calls accept unrelated API models.
443
- recordLiveProviderModels(provider, models, {
458
+ recordLiveProviderModels(provider, allModels, {
444
459
  source,
445
- baseUrl: opts.baseUrl,
460
+ baseUrl,
461
+ replace: provider === "openai",
446
462
  ...(provider === "openai" && source === "oauth" && oauthAccountId ? { accountId: oauthAccountId } : {}),
447
463
  });
448
- if (provider === "openai" && source === "oauth") recordLiveCodexModels(models, oauthAccountId);
464
+ if (provider === "openai" && source === "oauth" && !baseUrl) recordLiveCodexModels(allModels, oauthAccountId);
449
465
  return {
450
466
  provider,
451
467
  models,
452
468
  ok: true,
453
469
  source,
454
- ...(oauthAccountId ? { accountId: oauthAccountId } : {}),
470
+ ...scope,
455
471
  };
456
472
  } catch (err) {
457
473
  const msg = (err as Error)?.name === "TimeoutError" || (err as Error)?.name === "AbortError" ? "timeout" : "unreachable";
458
- return { provider, models: [], ok: false, source, error: msg };
474
+ return { provider, models: [], ok: false, source, error: msg, ...scope };
459
475
  }
460
476
  }
461
477
 
@@ -495,13 +511,24 @@ export async function discoverModels(
495
511
  const providers = opts.providers ?? [...PROVIDER_NAMES];
496
512
  const useFallback = opts.catalogFallback !== false;
497
513
  const results = await Promise.all(
498
- providers.map(p =>
499
- listProviderModels(p, {
514
+ providers.map(async p => {
515
+ const discoveryOpts = {
500
516
  ...opts,
501
517
  config: cfg,
502
518
  baseUrl: p === "ollama" ? (cfg.ollamaBaseUrl ?? opts.baseUrl) : p === "lmstudio" ? (cfg.lmstudioBaseUrl ?? opts.baseUrl) : p === "openai" ? (cfg.openaiBaseUrl ?? opts.baseUrl) : opts.baseUrl,
503
- }),
504
- ),
519
+ };
520
+ const primary = await listProviderModels(p, discoveryOpts);
521
+ // Both credentials can coexist. API discovery alone omits subscription-only
522
+ // models and never teaches the Codex call gate what this account can serve.
523
+ if (p === "openai" && primary.source === "api_key" && !discoveryOpts.baseUrl && !process.env.OPENAI_BASE_URL) {
524
+ const credential = await resolveCredential("openai");
525
+ if (credential.kind === "oauth") {
526
+ return [primary, await listProviderModels(p, { ...discoveryOpts, preferOAuth: true })];
527
+ }
528
+ }
529
+ return [primary];
530
+ }),
505
531
  );
506
- return useFallback ? results.map(catalogOr) : results;
532
+ const flat = results.flat();
533
+ return useFallback ? flat.map(catalogOr) : flat;
507
534
  }
@@ -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
  }
package/src/autopilot.ts CHANGED
@@ -180,6 +180,7 @@ export function foldBest(goal: Goal, best: number | undefined, score: number): n
180
180
  /**
181
181
  * Single source of truth for the ratchet keep/revert decision. Shared by step,
182
182
  * loop, and status so they can never diverge.
183
+ * - every goal: a failed eval always reverts, regardless of its printed score.
183
184
  * - gate goal: keep iff the eval passed (score is irrelevant).
184
185
  * - min/max goal: a non-measurable (NaN) score can never prove improvement, so
185
186
  * it is always reverted; otherwise keep iff it improves on the best so far.
@@ -190,7 +191,8 @@ export function decideStep(
190
191
  passed: boolean,
191
192
  best: number | undefined,
192
193
  ): "keep" | "revert" {
193
- if (goal === "gate") return passed ? "keep" : "revert";
194
+ if (!passed) return "revert";
195
+ if (goal === "gate") return "keep";
194
196
  if (Number.isNaN(score)) return "revert";
195
197
  return isImprovement(goal, score, best) ? "keep" : "revert";
196
198
  }
@@ -255,8 +257,11 @@ function cmdStep(flags: Record<string, string>): void {
255
257
  if (decision === "revert" && flags["on-revert"]) {
256
258
  try {
257
259
  execSync(flags["on-revert"], { stdio: "inherit", shell: getShell() });
258
- } catch {
259
- console.error("jeo autopilot: --on-revert hook failed (decision still logged)");
260
+ } catch (error) {
261
+ appendLog({ type: "stop", reason: "rollback_failed", change, score, passed, prevBest: best ?? null, output, error: String(error) });
262
+ console.error(`jeo autopilot: stop — --on-revert hook failed: ${String(error)}`);
263
+ process.exitCode = 1;
264
+ return;
260
265
  }
261
266
  }
262
267
 
@@ -295,6 +300,7 @@ function cmdLoop(flags: Record<string, string>): void {
295
300
  if (!runnerOk) {
296
301
  appendLog({ type: "stop", reason: "runner_failed", iteration: i });
297
302
  console.log(`jeo autopilot: stop — runner failed at iteration ${i}`);
303
+ process.exitCode = 1;
298
304
  return;
299
305
  }
300
306
 
@@ -304,8 +310,11 @@ function cmdLoop(flags: Record<string, string>): void {
304
310
  if (decision === "revert" && flags["on-revert"]) {
305
311
  try {
306
312
  execSync(flags["on-revert"], { stdio: "inherit", shell: getShell() });
307
- } catch {
308
- /* logged below regardless */
313
+ } catch (error) {
314
+ appendLog({ type: "stop", reason: "rollback_failed", iteration: i, change: `loop#${i}`, score, passed, prevBest: best ?? null, output, error: String(error) });
315
+ console.error(`jeo autopilot: stop — --on-revert hook failed at iteration ${i}: ${String(error)}`);
316
+ process.exitCode = 1;
317
+ return;
309
318
  }
310
319
  }
311
320
  appendLog({ type: "step", iteration: i, change: `loop#${i}`, score, passed, decision, prevBest: best ?? null, output });
package/src/cli/runner.ts CHANGED
@@ -112,8 +112,8 @@ export const COMMANDS: readonly CommandSpec[] = [
112
112
  },
113
113
  {
114
114
  name: "notify",
115
- summary: "Configure/inspect remote subagent notifications over Telegram (gjc notify parity).",
116
- usage: "notify [setup [--token <t> --chat-id <id>]|status]",
115
+ summary: "Configure/inspect Telegram, Discord, and Slack notification channels.",
116
+ usage: "notify [setup|status|health|test] [--provider telegram|discord|slack] [--token-env NAME] [--app-token-env NAME] [--chat-id ID|--channel-id ID] [--allowed-user-ids IDS]",
117
117
  loader: async () => {
118
118
  const m = await import("../commands/notify");
119
119
  return args => m.runNotifyCommand(args);
@@ -121,7 +121,7 @@ export const COMMANDS: readonly CommandSpec[] = [
121
121
  },
122
122
  {
123
123
  name: "daemon",
124
- summary: "Manage the background Telegram notification/subagent-control daemon.",
124
+ summary: "Manage the shared Telegram/Discord/Slack notification daemon.",
125
125
  usage: "daemon [status|start|stop|reload]",
126
126
  loader: async () => {
127
127
  const m = await import("../commands/daemon");
@@ -197,7 +197,7 @@ export const COMMANDS: readonly CommandSpec[] = [
197
197
  },
198
198
  {
199
199
  name: "notify-daemon-run",
200
- summary: "(internal) Foreground worker for the Telegram notification daemon, spawned by 'jeo daemon start'.",
200
+ summary: "(internal) Foreground notification worker, spawned by 'jeo daemon start'.",
201
201
  usage: "notify-daemon-run",
202
202
  loader: async () => {
203
203
  const m = await import("../agent/notify/telegram-daemon");