jeo-code 0.11.0 → 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 +26 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/notify/session-endpoint.ts +15 -0
- package/src/ai/model-cache.ts +22 -15
- package/src/ai/model-catalog.ts +20 -11
- package/src/ai/model-discovery.ts +68 -33
- package/src/ai/model-picker.ts +4 -0
- package/src/commands/launch.ts +236 -218
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,32 @@ 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
|
+
|
|
23
|
+
## [0.11.1] - 2026-08-25
|
|
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._
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **A one-shot run never exited when `notifications.enabled` was set** (`src/agent/notify/session-endpoint.ts`, `src/commands/launch.ts`) — `startSessionNotifyEndpoint` binds a `Bun.serve` socket plus a snapshot-poll `setInterval` for the whole launch lifetime, but its teardown lived only on the interactive REPL's exit path. A one-shot run leaves through one of ~17 early `return`s, none of which reach it, so the listening socket and the ref'd timer kept the event loop alive indefinitely. Fixed on both sides: the server and poll timer are now `unref`'d so the endpoint can never be the reason the process stays up, and the entire one-shot block is wrapped in a `try/finally` that stops the endpoint — which also removes the session discovery file, so the Telegram daemon stops dialling a dead socket after a scripted run.
|
|
28
|
+
- This was invisible for two compounding reasons: it only reproduces on a machine with notifications actually configured, and Bun timers/servers do not appear in `process._getActiveHandles()` — so the hung process reported **zero** active handles while refusing to exit. It was found by monkey-patching `setInterval`/`setTimeout` to record creation sites and dumping the still-ref'd ones after `dispatch()` returned.
|
|
29
|
+
- **The `bashTool` orphan-reaping assertions compared raw command output to the string `"0"`** (`test/tools-fs.test.ts`) — `bashTool` merges stdout and stderr, so any warning `pgrep` wrote to stderr false-failed a run in which zero strays actually existed. The count is now parsed rather than string-matched, and the orphan check is gated on process enumeration actually working (hardened sandboxes answer `pgrep: Cannot get process list`). The abort **contract** itself (`success:false` / `"Command aborted"`) is still asserted unconditionally, so a restricted host keeps covering the behaviour that matters.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- `test/notify-endpoint-exit.test.ts` — pins the exit contract deterministically by pointing `JEO_CONFIG_DIR` at a temp config with notifications ON, rather than depending on the developer's real `~/.jeo/config.json`. Covers both halves independently: the `finally` teardown (one-shot exits, no leftover discovery file) and the `unref` guard (any future path that forgets `stop()` still cannot pin the process open).
|
|
33
|
+
|
|
34
|
+
### Verified
|
|
35
|
+
- `bun run typecheck` clean; `bun test` **3348 pass, 0 fail** — down from 22 failures two releases ago and 6 at 0.11.0. Suite wall time also dropped ~148s → ~88s, since the hung specs no longer burn their full 25s/5s timeouts.
|
|
36
|
+
- The unref guard was verified by reverting it and confirming the new unit test fails, then restoring it.
|
|
37
|
+
|
|
12
38
|
## [0.11.0] - 2026-08-25
|
|
13
39
|
_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._
|
|
14
40
|
|
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.
|
|
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.
|
|
322
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.
|
|
323
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.
|
|
324
|
-
- **[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.
|
|
325
|
-
- **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
|
|
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.
|
|
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.
|
|
323
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.
|
|
324
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.
|
|
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
|
-
- **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
|
|
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.
|
|
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.
|
|
330
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.
|
|
331
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.
|
|
332
|
-
- **[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.
|
|
333
|
-
- **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
|
|
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.
|
|
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.
|
|
320
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.
|
|
321
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.
|
|
322
|
-
- **[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.
|
|
323
|
-
- **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
|
|
324
324
|
|
|
325
325
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
326
326
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -229,7 +229,22 @@ export class SessionNotifyEndpoint {
|
|
|
229
229
|
},
|
|
230
230
|
},
|
|
231
231
|
});
|
|
232
|
+
// Same reasoning as the poll timer below: a listening socket also keeps the Bun
|
|
233
|
+
// event loop alive. This server exists to MIRROR a session, never to outlive it,
|
|
234
|
+
// so it must not be able to decide the process stays up. `stop()` still tears it
|
|
235
|
+
// down explicitly on the paths that have one — this only removes its veto over exit.
|
|
236
|
+
(this.server as { unref?: () => void }).unref?.();
|
|
232
237
|
this.pollTimer = setInterval(() => this.broadcastIfChanged(), SNAPSHOT_POLL_MS);
|
|
238
|
+
// Unref the poll timer so it can NEVER be the reason the process stays alive.
|
|
239
|
+
// This endpoint mirrors an already-running session; it has no work of its own
|
|
240
|
+
// worth holding the event loop open for. Left ref'd, any code path that starts
|
|
241
|
+
// the endpoint but does not reach an explicit `stop()` hangs the process
|
|
242
|
+
// forever — which is exactly what happened to every non-interactive invocation
|
|
243
|
+
// (`echo "…" | jeo`, `jeo -p "…"` in CI): the one-shot branch returns long
|
|
244
|
+
// before the interactive REPL's exit teardown runs. Bun timers do not appear in
|
|
245
|
+
// `process._getActiveHandles()`, so that hang presented as a process with zero
|
|
246
|
+
// active handles refusing to exit.
|
|
247
|
+
(this.pollTimer as { unref?: () => void }).unref?.();
|
|
233
248
|
await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
|
|
234
249
|
const payload = {
|
|
235
250
|
url: `ws://127.0.0.1:${this.server.port}`,
|
package/src/ai/model-cache.ts
CHANGED
|
@@ -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
|
|
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:
|
|
40
|
+
version: 3;
|
|
41
41
|
updatedAt: number;
|
|
42
42
|
providers: CachedProviderModels[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
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
|
|
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;
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -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
|
|
183
|
-
*
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
355
|
-
*
|
|
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
|
|
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
|
|
58
|
-
*
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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
|
|
315
|
-
//
|
|
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 === "
|
|
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" && !!
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
466
|
+
recordLiveProviderModels(provider, allModels, {
|
|
444
467
|
source,
|
|
445
|
-
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(
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
540
|
+
const flat = results.flat();
|
|
541
|
+
return useFallback ? flat.map(catalogOr) : flat;
|
|
507
542
|
}
|
package/src/ai/model-picker.ts
CHANGED
|
@@ -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/commands/launch.ts
CHANGED
|
@@ -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
|
|
339
|
-
*
|
|
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) }));
|
|
@@ -1974,239 +1977,250 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1974
1977
|
};
|
|
1975
1978
|
|
|
1976
1979
|
if (isOneShot) {
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
// a slow, flaky, and semantically wrong round-trip. These no-arg session/system
|
|
1989
|
-
// commands have a meaningful one-shot effect (or are simple no-ops) and exit.
|
|
1990
|
-
{
|
|
1991
|
-
const cmd = messageContent.trim();
|
|
1992
|
-
if (cmd === "/exit" || cmd === "/quit") {
|
|
1993
|
-
return;
|
|
1994
|
-
}
|
|
1995
|
-
if (cmd === "/clear" || cmd === "/session new" || cmd === "/session drop" || cmd === "/session delete") {
|
|
1996
|
-
// Reset history to just the system prompt and overwrite the session file so
|
|
1997
|
-
// the persisted transcript matches (a fresh session for /session new and /session drop).
|
|
1998
|
-
history.length = 1;
|
|
1999
|
-
if (sessionId && !flags.noSession) {
|
|
2000
|
-
try {
|
|
2001
|
-
sessionId = (await createSession(cwd, undefined, sessionModel)).id;
|
|
2002
|
-
} catch { /* best-effort: in-memory clear already done */ }
|
|
2003
|
-
}
|
|
2004
|
-
console.log("(history cleared)");
|
|
2005
|
-
return;
|
|
2006
|
-
}
|
|
2007
|
-
if (cmd === "/" || cmd === "/?" || cmd === "/help") {
|
|
2008
|
-
for (const line of formatSlashCommandList("/", skillSlashDetails)) console.log(line);
|
|
2009
|
-
console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
|
|
2010
|
-
return;
|
|
2011
|
-
}
|
|
2012
|
-
if (cmd === "/usage") {
|
|
2013
|
-
const cfg = await readGlobalConfig();
|
|
2014
|
-
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2015
|
-
const result = handleUsage(ctx, sessionUsage);
|
|
2016
|
-
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2017
|
-
return;
|
|
2018
|
-
}
|
|
2019
|
-
if (cmd === "/context") {
|
|
2020
|
-
const cfg = await readGlobalConfig();
|
|
2021
|
-
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2022
|
-
const result = await handleContext(ctx);
|
|
2023
|
-
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2024
|
-
return;
|
|
2025
|
-
}
|
|
2026
|
-
if (cmd === "/tools") {
|
|
2027
|
-
const cfg = await readGlobalConfig();
|
|
2028
|
-
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2029
|
-
const result = await handleTools(ctx);
|
|
2030
|
-
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2031
|
-
return;
|
|
2032
|
-
}
|
|
2033
|
-
if (cmd === "/hotkeys") {
|
|
2034
|
-
const cfg = await readGlobalConfig();
|
|
2035
|
-
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2036
|
-
const result = handleHotkeys(ctx);
|
|
2037
|
-
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2038
|
-
return;
|
|
1980
|
+
// A one-shot run has ~17 exit points below. The session notify endpoint (a
|
|
1981
|
+
// listening socket + a discovery file the Telegram daemon polls) is created for
|
|
1982
|
+
// the WHOLE launch lifetime up at `startSessionNotifyEndpoint`, but its teardown
|
|
1983
|
+
// lives on the interactive REPL's exit path — which none of those 17 returns
|
|
1984
|
+
// reach. The endpoint's socket/timer are unref'd so a leak can no longer HANG the
|
|
1985
|
+
// process, but a one-shot run would still leave a stale discovery file behind for
|
|
1986
|
+
// the daemon to keep dialling. One `finally` covers every exit point.
|
|
1987
|
+
try {
|
|
1988
|
+
let messageContent = joinedArgs;
|
|
1989
|
+
if (!process.stdin.isTTY && joinedArgs.length === 0) {
|
|
1990
|
+
messageContent = (await Bun.stdin.text()).trim();
|
|
2039
1991
|
}
|
|
2040
|
-
if (
|
|
2041
|
-
|
|
2042
|
-
const cfg = await readGlobalConfig();
|
|
2043
|
-
const activeModel = sessionModel || defaultModel;
|
|
2044
|
-
// `Config` (state.ts) predates config-schema.ts's `compaction` field and is
|
|
2045
|
-
// out of this change's scope — read the passthrough-preserved value via a
|
|
2046
|
-
// narrow structural cast instead of widening the shared interface.
|
|
2047
|
-
const focus = parsed.focus ?? (cfg as { compaction?: { handoffFocus?: string } }).compaction?.handoffFocus;
|
|
2048
|
-
const res = await buildHandoffDocument(history, { model: activeModel, focus });
|
|
2049
|
-
for (const line of handoffLines(res, process.stdout.columns)) console.log(line);
|
|
1992
|
+
if (!messageContent) {
|
|
1993
|
+
console.log("No input provided.");
|
|
2050
1994
|
return;
|
|
2051
1995
|
}
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
1996
|
+
// One-shot piped/`-p` input that is a control slash command must be handled
|
|
1997
|
+
// HERE — never forwarded to the model. `echo "/clear" | jeo` (and Ctrl-D after
|
|
1998
|
+
// a piped command) previously sent the literal "/clear" to the LLM as a prompt:
|
|
1999
|
+
// a slow, flaky, and semantically wrong round-trip. These no-arg session/system
|
|
2000
|
+
// commands have a meaningful one-shot effect (or are simple no-ops) and exit.
|
|
2001
|
+
{
|
|
2002
|
+
const cmd = messageContent.trim();
|
|
2003
|
+
if (cmd === "/exit" || cmd === "/quit") {
|
|
2004
|
+
return;
|
|
2061
2005
|
}
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
delete process.env.JEO_WIKI_ROOT;
|
|
2074
|
-
sessionWikiRoot = undefined;
|
|
2075
|
-
await refreshSessionMemory("");
|
|
2076
|
-
} else if (decision.kind === "set") {
|
|
2077
|
-
await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
|
|
2078
|
-
process.env.JEO_WIKI_ROOT = decision.root;
|
|
2079
|
-
sessionWikiRoot = decision.root;
|
|
2080
|
-
await refreshSessionMemory("");
|
|
2006
|
+
if (cmd === "/clear" || cmd === "/session new" || cmd === "/session drop" || cmd === "/session delete") {
|
|
2007
|
+
// Reset history to just the system prompt and overwrite the session file so
|
|
2008
|
+
// the persisted transcript matches (a fresh session for /session new and /session drop).
|
|
2009
|
+
history.length = 1;
|
|
2010
|
+
if (sessionId && !flags.noSession) {
|
|
2011
|
+
try {
|
|
2012
|
+
sessionId = (await createSession(cwd, undefined, sessionModel)).id;
|
|
2013
|
+
} catch { /* best-effort: in-memory clear already done */ }
|
|
2014
|
+
}
|
|
2015
|
+
console.log("(history cleared)");
|
|
2016
|
+
return;
|
|
2081
2017
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
return;
|
|
2087
|
-
}
|
|
2088
|
-
if (cmd === "/evolve") {
|
|
2089
|
-
await runEvolveSimulation(animateAsciiArt);
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
// One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
|
|
2094
|
-
// single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
|
|
2095
|
-
const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
|
|
2096
|
-
const isBundleWorkflow = isWorkflowSkill(inv.skill.name);
|
|
2097
|
-
if (isBundleWorkflow) {
|
|
2098
|
-
const startMsg: Message = {
|
|
2099
|
-
role: "system",
|
|
2100
|
-
content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
|
|
2101
|
-
};
|
|
2102
|
-
history.push(startMsg);
|
|
2103
|
-
if (sessionId) {
|
|
2104
|
-
await appendMessage(sessionId, startMsg, cwd);
|
|
2018
|
+
if (cmd === "/" || cmd === "/?" || cmd === "/help") {
|
|
2019
|
+
for (const line of formatSlashCommandList("/", skillSlashDetails)) console.log(line);
|
|
2020
|
+
console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
|
|
2021
|
+
return;
|
|
2105
2022
|
}
|
|
2023
|
+
if (cmd === "/usage") {
|
|
2024
|
+
const cfg = await readGlobalConfig();
|
|
2025
|
+
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2026
|
+
const result = handleUsage(ctx, sessionUsage);
|
|
2027
|
+
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
if (cmd === "/context") {
|
|
2031
|
+
const cfg = await readGlobalConfig();
|
|
2032
|
+
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2033
|
+
const result = await handleContext(ctx);
|
|
2034
|
+
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (cmd === "/tools") {
|
|
2038
|
+
const cfg = await readGlobalConfig();
|
|
2039
|
+
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2040
|
+
const result = await handleTools(ctx);
|
|
2041
|
+
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (cmd === "/hotkeys") {
|
|
2045
|
+
const cfg = await readGlobalConfig();
|
|
2046
|
+
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2047
|
+
const result = handleHotkeys(ctx);
|
|
2048
|
+
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
if (cmd === "/handoff" || cmd.startsWith("/handoff ")) {
|
|
2052
|
+
const parsed = parseHandoffCommand(cmd) ?? {};
|
|
2053
|
+
const cfg = await readGlobalConfig();
|
|
2054
|
+
const activeModel = sessionModel || defaultModel;
|
|
2055
|
+
// `Config` (state.ts) predates config-schema.ts's `compaction` field and is
|
|
2056
|
+
// out of this change's scope — read the passthrough-preserved value via a
|
|
2057
|
+
// narrow structural cast instead of widening the shared interface.
|
|
2058
|
+
const focus = parsed.focus ?? (cfg as { compaction?: { handoffFocus?: string } }).compaction?.handoffFocus;
|
|
2059
|
+
const res = await buildHandoffDocument(history, { model: activeModel, focus });
|
|
2060
|
+
for (const line of handoffLines(res, process.stdout.columns)) console.log(line);
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
if (cmd === "/theme" || cmd.startsWith("/theme ")) {
|
|
2064
|
+
const decision = decideThemeSlash(cmd, listThemes(), resolveTheme().name);
|
|
2065
|
+
for (const line of decision.lines) console.log(line);
|
|
2066
|
+
if (decision.kind === "set") {
|
|
2067
|
+
process.env.JEO_TUI_THEME = decision.themeName!;
|
|
2068
|
+
await saveConfigPatch(raw => ({ theme: decision.themeName }));
|
|
2069
|
+
// No refreshUiTheme() here — one-shot mode returns immediately after this
|
|
2070
|
+
// block, so there is no live TUI paint state to re-resolve (and
|
|
2071
|
+
// refreshUiTheme is declared later in this closure, out of scope here).
|
|
2072
|
+
}
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
if (cmd === "/wiki" || cmd.startsWith("/wiki ")) {
|
|
2076
|
+
const decision = decideWikiSlash(
|
|
2077
|
+
cmd,
|
|
2078
|
+
resolveWikiRoot(await readGlobalConfig()),
|
|
2079
|
+
!!jeoEnv("WIKI_ROOT"),
|
|
2080
|
+
);
|
|
2081
|
+
for (const line of decision.lines) console.log(line);
|
|
2082
|
+
if (decision.kind === "clear") {
|
|
2083
|
+
await saveConfigPatch(() => ({ wikiRoot: undefined }));
|
|
2084
|
+
delete process.env.JEO_WIKI_ROOT;
|
|
2085
|
+
sessionWikiRoot = undefined;
|
|
2086
|
+
await refreshSessionMemory("");
|
|
2087
|
+
} else if (decision.kind === "set") {
|
|
2088
|
+
await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
|
|
2089
|
+
process.env.JEO_WIKI_ROOT = decision.root;
|
|
2090
|
+
sessionWikiRoot = decision.root;
|
|
2091
|
+
await refreshSessionMemory("");
|
|
2092
|
+
}
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
if (cmd === "/config" || cmd === "/settings") {
|
|
2096
|
+
for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (cmd === "/evolve") {
|
|
2100
|
+
await runEvolveSimulation(animateAsciiArt);
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
// One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
|
|
2105
|
+
// single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
|
|
2106
|
+
const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
|
|
2107
|
+
const isBundleWorkflow = isWorkflowSkill(inv.skill.name);
|
|
2108
|
+
if (isBundleWorkflow) {
|
|
2109
|
+
const startMsg: Message = {
|
|
2110
|
+
role: "system",
|
|
2111
|
+
content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
|
|
2112
|
+
};
|
|
2113
|
+
history.push(startMsg);
|
|
2114
|
+
if (sessionId) {
|
|
2115
|
+
await appendMessage(sessionId, startMsg, cwd);
|
|
2116
|
+
}
|
|
2106
2117
|
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2118
|
+
const harness = createInFlightAbortHarness({
|
|
2119
|
+
captureEsc: false,
|
|
2120
|
+
onAbortNotice: msg => console.log(msg),
|
|
2121
|
+
onHardExit: () => forceExitFromCtrlC(),
|
|
2122
|
+
});
|
|
2123
|
+
const ac = harness.controller;
|
|
2113
2124
|
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2125
|
+
const opts = {
|
|
2126
|
+
cwd,
|
|
2127
|
+
signal: ac.signal,
|
|
2128
|
+
onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
|
|
2129
|
+
io: {
|
|
2130
|
+
output: (line: string) => {
|
|
2131
|
+
console.log(line);
|
|
2132
|
+
},
|
|
2133
|
+
// This `$a $b …` one-shot chain runs BEFORE the interactive REPL's
|
|
2134
|
+
// `promptInput`/`rl` exist (this whole dispatch is reached and returns
|
|
2135
|
+
// long before the `const promptInput = …` further down the function
|
|
2136
|
+
// executes) — referencing `promptInput` here throws a TDZ
|
|
2137
|
+
// ReferenceError ("Cannot access 'promptInput' before initialization")
|
|
2138
|
+
// the instant the workflow engine asks its first question. That error
|
|
2139
|
+
// is caught and stashed into session history (never printed to the
|
|
2140
|
+
// terminal), so the visible symptom is just a frozen prompt forever
|
|
2141
|
+
// (the "jeo --tmux $deep-interview hangs" / "$deep-interview hangs"
|
|
2142
|
+
// report). Omitting `input` here is safe and correct: no other
|
|
2143
|
+
// readline exists yet at this point, so deep-interview.ts's own
|
|
2144
|
+
// fallback `createInterface({input: process.stdin, ...})` (used
|
|
2145
|
+
// whenever `opts.io?.input` is absent) has nothing to fight over raw
|
|
2146
|
+
// mode with. The INTERACTIVE `runSkillInvocation` sibling path below
|
|
2147
|
+
// runs AFTER `promptInput` is declared and correctly wires it — do not
|
|
2148
|
+
// "fix" this block by copying that wiring here again.
|
|
2121
2149
|
},
|
|
2122
|
-
// This `$a $b …` one-shot chain runs BEFORE the interactive REPL's
|
|
2123
|
-
// `promptInput`/`rl` exist (this whole dispatch is reached and returns
|
|
2124
|
-
// long before the `const promptInput = …` further down the function
|
|
2125
|
-
// executes) — referencing `promptInput` here throws a TDZ
|
|
2126
|
-
// ReferenceError ("Cannot access 'promptInput' before initialization")
|
|
2127
|
-
// the instant the workflow engine asks its first question. That error
|
|
2128
|
-
// is caught and stashed into session history (never printed to the
|
|
2129
|
-
// terminal), so the visible symptom is just a frozen prompt forever
|
|
2130
|
-
// (the "jeo --tmux $deep-interview hangs" / "$deep-interview hangs"
|
|
2131
|
-
// report). Omitting `input` here is safe and correct: no other
|
|
2132
|
-
// readline exists yet at this point, so deep-interview.ts's own
|
|
2133
|
-
// fallback `createInterface({input: process.stdin, ...})` (used
|
|
2134
|
-
// whenever `opts.io?.input` is absent) has nothing to fight over raw
|
|
2135
|
-
// mode with. The INTERACTIVE `runSkillInvocation` sibling path below
|
|
2136
|
-
// runs AFTER `promptInput` is declared and correctly wires it — do not
|
|
2137
|
-
// "fix" this block by copying that wiring here again.
|
|
2138
|
-
},
|
|
2139
2150
|
|
|
2140
|
-
|
|
2151
|
+
args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
|
|
2141
2152
|
|
|
2142
|
-
|
|
2153
|
+
};
|
|
2143
2154
|
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2155
|
+
let ok = false;
|
|
2156
|
+
let reason: string | undefined;
|
|
2157
|
+
try {
|
|
2158
|
+
const res: { ok: boolean; reason?: string } = await runWorkflowEngine(inv.skill.name, opts);
|
|
2159
|
+
ok = res.ok;
|
|
2160
|
+
reason = res.reason;
|
|
2161
|
+
} catch (err: any) {
|
|
2162
|
+
ok = false;
|
|
2163
|
+
reason = err.message;
|
|
2164
|
+
} finally {
|
|
2165
|
+
harness.dispose();
|
|
2166
|
+
}
|
|
2156
2167
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2168
|
+
const endMsg: Message = {
|
|
2169
|
+
role: "system",
|
|
2170
|
+
content: ok
|
|
2171
|
+
? `[workflow:${inv.skill.name}:finish]`
|
|
2172
|
+
: `[workflow:${inv.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
|
|
2173
|
+
};
|
|
2174
|
+
if (sessionId) {
|
|
2175
|
+
await appendMessage(sessionId, endMsg, cwd);
|
|
2176
|
+
}
|
|
2177
|
+
return;
|
|
2165
2178
|
}
|
|
2166
|
-
return;
|
|
2167
|
-
}
|
|
2168
2179
|
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2180
|
+
const useOneShotTui = shouldUseOneShotTui(flags.noTui);
|
|
2181
|
+
if (!useOneShotTui) {
|
|
2182
|
+
console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
|
|
2183
|
+
}
|
|
2184
|
+
const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
|
|
2185
|
+
const { reply, rendered, usage } = await runTurn(task, useOneShotTui, undefined, { userCard: null });
|
|
2186
|
+
if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
2187
|
+
else if (usage) console.log(usage.trim());
|
|
2188
|
+
};
|
|
2178
2189
|
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2190
|
+
// `$a $b … [intent]` — run every resolved skill in order; a lone `$skill` is a chain of 1.
|
|
2191
|
+
const skillChain = parseSkillChain(messageContent, resolvedSkills);
|
|
2192
|
+
if (skillChain && skillChain.invocations.length) {
|
|
2193
|
+
if (skillChain.unresolved.length) {
|
|
2194
|
+
console.log(`(skipping unknown skill${skillChain.unresolved.length > 1 ? "s" : ""}: ${skillChain.unresolved.map(u => `$${u}`).join(", ")})`);
|
|
2195
|
+
}
|
|
2196
|
+
if (skillChain.invocations.length > 1) {
|
|
2197
|
+
console.log(`▶ Chaining ${skillChain.invocations.length} skills: ${skillChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
|
|
2198
|
+
}
|
|
2199
|
+
for (const inv of skillChain.invocations) {
|
|
2200
|
+
await runOneSkillShot(inv);
|
|
2201
|
+
}
|
|
2202
|
+
await maybeDistillOneShot();
|
|
2203
|
+
return;
|
|
2184
2204
|
}
|
|
2185
|
-
|
|
2186
|
-
|
|
2205
|
+
// A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
|
|
2206
|
+
const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
|
|
2207
|
+
if (skillInvocation) {
|
|
2208
|
+
await runOneSkillShot(skillInvocation);
|
|
2209
|
+
await maybeDistillOneShot();
|
|
2210
|
+
return;
|
|
2187
2211
|
}
|
|
2188
|
-
|
|
2189
|
-
await
|
|
2212
|
+
try {
|
|
2213
|
+
const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
|
|
2214
|
+
if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
2215
|
+
else if (usage) console.log(usage.trim());
|
|
2216
|
+
} catch (err) {
|
|
2217
|
+
console.log(`! ${friendlyProviderError(err)}`);
|
|
2190
2218
|
}
|
|
2191
2219
|
await maybeDistillOneShot();
|
|
2192
2220
|
return;
|
|
2221
|
+
} finally {
|
|
2222
|
+
await sessionNotifyEndpoint?.stop().catch(() => {});
|
|
2193
2223
|
}
|
|
2194
|
-
// A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
|
|
2195
|
-
const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
|
|
2196
|
-
if (skillInvocation) {
|
|
2197
|
-
await runOneSkillShot(skillInvocation);
|
|
2198
|
-
await maybeDistillOneShot();
|
|
2199
|
-
return;
|
|
2200
|
-
}
|
|
2201
|
-
try {
|
|
2202
|
-
const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
|
|
2203
|
-
if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
2204
|
-
else if (usage) console.log(usage.trim());
|
|
2205
|
-
} catch (err) {
|
|
2206
|
-
console.log(`! ${friendlyProviderError(err)}`);
|
|
2207
|
-
}
|
|
2208
|
-
await maybeDistillOneShot();
|
|
2209
|
-
return;
|
|
2210
2224
|
}
|
|
2211
2225
|
|
|
2212
2226
|
// INTERACTIVE mode
|
|
@@ -2416,7 +2430,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2416
2430
|
// the OAuth Codex gate from the first keystroke, instead of only after the network
|
|
2417
2431
|
// discovery below happens to land.
|
|
2418
2432
|
const aliasNames = Object.keys(await listAliases());
|
|
2419
|
-
|
|
2433
|
+
await rehydrateLiveModels().catch(() => null);
|
|
2434
|
+
const completionConfig = await readGlobalConfig();
|
|
2435
|
+
const cachedCompletionModels = liveProviderCatalogModels({ openaiBaseUrl: completionConfig.openaiBaseUrl ?? process.env.OPENAI_BASE_URL });
|
|
2420
2436
|
void getLiveModels()
|
|
2421
2437
|
.then(r => {
|
|
2422
2438
|
liveModelsCache ??= r;
|
|
@@ -2431,14 +2447,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2431
2447
|
// access to were un-completable for the first seconds of every session.
|
|
2432
2448
|
const liveFromDiscovery = liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : null;
|
|
2433
2449
|
const cachedFor = (p: string): string[] =>
|
|
2434
|
-
|
|
2450
|
+
cachedCompletionModels.filter(entry => entry.provider === p).map(entry => entry.canonical);
|
|
2435
2451
|
return {
|
|
2436
2452
|
...base,
|
|
2437
2453
|
slashCommands: [...base.slashCommands, ...skillSlashDetails.map(d => d.command)],
|
|
2438
|
-
liveModels: liveFromDiscovery ??
|
|
2454
|
+
liveModels: liveFromDiscovery ?? cachedCompletionModels.map(entry => entry.canonical),
|
|
2439
2455
|
aliases: aliasNames,
|
|
2440
2456
|
skillNames: resolvedSkillTokens,
|
|
2441
|
-
modelsForProvider: p => liveModelsCache
|
|
2457
|
+
modelsForProvider: p => liveModelsCache
|
|
2458
|
+
? flattenModels(liveModelsCache.filter(r => r.provider === p)).map(entry => entry.model)
|
|
2459
|
+
: cachedFor(p),
|
|
2442
2460
|
mentionPaths,
|
|
2443
2461
|
};
|
|
2444
2462
|
};
|