jeo-code 0.9.17 → 0.11.0
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 +46 -0
- package/README.ja.md +35 -3
- package/README.ko.md +35 -3
- package/README.md +36 -4
- package/README.zh.md +33 -3
- package/package.json +1 -1
- package/src/agent/config-schema.ts +26 -0
- package/src/agent/context-files.ts +90 -11
- package/src/agent/notify/daemon-control.ts +79 -12
- package/src/agent/state.ts +19 -1
- package/src/agent/task-tool.ts +27 -3
- package/src/ai/model-discovery.ts +9 -2
- package/src/ai/model-manager.ts +5 -2
- package/src/ai/model-retirements.ts +123 -0
- package/src/ai/provider-ranking.ts +153 -0
- package/src/ai/provider-registry.ts +10 -0
- package/src/ai/provider-status.ts +41 -2
- package/src/ai/providers/custom-providers.ts +321 -0
- package/src/ai/providers/openai-compatible-catalog.ts +31 -5
- package/src/ai/providers/provider-presets.ts +292 -0
- package/src/ai/register-providers.ts +54 -5
- package/src/ai/types.ts +10 -1
- package/src/cli/runner.ts +9 -0
- package/src/commands/daemon.ts +8 -0
- package/src/commands/doctor.ts +14 -0
- package/src/commands/launch/provider-slash.ts +413 -0
- package/src/commands/launch.ts +69 -48
- package/src/commands/provider.ts +199 -0
- package/src/tui/components/autocomplete.ts +28 -6
- package/src/tui/components/model-picker.ts +18 -8
|
@@ -42,13 +42,52 @@ export function parseEtimeToMs(raw: string): number | undefined {
|
|
|
42
42
|
return ((days * 24 + h!) * 60 + m!) * 60 * 1000 + s! * 1000;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
/**
|
|
46
|
+
* Read a process's start time from `/proc` — Linux only, no subprocess.
|
|
47
|
+
*
|
|
48
|
+
* Field 22 of `/proc/<pid>/stat` is the process start time in clock ticks since
|
|
49
|
+
* boot; `/proc/uptime` gives seconds since boot. Together they yield an epoch
|
|
50
|
+
* timestamp WITHOUT spawning anything. That matters because the `ps` path below is
|
|
51
|
+
* exactly what disappears in the environments a Telegram daemon actually runs in:
|
|
52
|
+
* distroless and scratch containers ship no `ps`, and hardened sandboxes/seccomp
|
|
53
|
+
* profiles routinely block the setuid `/bin/ps`. When the lookup dies there, the
|
|
54
|
+
* PID-reuse guard silently degrades to a bare `kill(pid, 0)` — see
|
|
55
|
+
* {@link isLockOwnerAlive} for why that is the dangerous case.
|
|
56
|
+
*
|
|
57
|
+
* The 22nd field is parsed from the LAST `)` rather than by splitting on spaces:
|
|
58
|
+
* field 2 is the executable name in parentheses and may itself contain spaces.
|
|
59
|
+
*/
|
|
60
|
+
function procStartTimeMs(pid: number): number | undefined {
|
|
61
|
+
if (process.platform !== "linux") return undefined;
|
|
62
|
+
try {
|
|
63
|
+
const stat = fsSync.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
64
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
|
|
65
|
+
const fields = afterComm.split(" ");
|
|
66
|
+
// stat field 22 (1-based) == index 19 of the post-comm remainder (fields 3+).
|
|
67
|
+
const startTicks = Number(fields[19]);
|
|
68
|
+
if (!Number.isFinite(startTicks)) return undefined;
|
|
69
|
+
const uptimeSec = Number(fsSync.readFileSync("/proc/uptime", "utf-8").split(" ")[0]);
|
|
70
|
+
if (!Number.isFinite(uptimeSec)) return undefined;
|
|
71
|
+
// USER_HZ is 100 on every Linux ABI that matters; sysconf(_SC_CLK_TCK) is not
|
|
72
|
+
// reachable without a native addon, and jeo is zero-native-dependency by design.
|
|
73
|
+
const startSecSinceBoot = startTicks / 100;
|
|
74
|
+
const bootEpochMs = Date.now() - uptimeSec * 1000;
|
|
75
|
+
return bootEpochMs + startSecSinceBoot * 1000;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Best-effort actual OS process start time in epoch ms. Tries `/proc` first (Linux,
|
|
82
|
+
* no subprocess, works in distroless/sandboxed containers), then `ps -o etime=`
|
|
83
|
+
* (POSIX-portable across macOS/Linux; unsupported on Windows, where there is no
|
|
84
|
+
* equivalent zero-dependency lookup). Returns `undefined` when the lookup is
|
|
85
|
+
* unavailable or unparseable — callers MUST treat that as "cannot verify" and fall
|
|
86
|
+
* back to the existence-only check, not as "dead". */
|
|
50
87
|
export async function processStartTimeMs(pid: number): Promise<number | undefined> {
|
|
51
88
|
if (process.platform === "win32") return undefined;
|
|
89
|
+
const viaProc = procStartTimeMs(pid);
|
|
90
|
+
if (viaProc !== undefined) return viaProc;
|
|
52
91
|
const output = await new Promise<string | undefined>(resolve => {
|
|
53
92
|
let child: ReturnType<typeof nodeSpawn>;
|
|
54
93
|
try {
|
|
@@ -67,6 +106,13 @@ export async function processStartTimeMs(pid: number): Promise<number | undefine
|
|
|
67
106
|
return ms === undefined ? undefined : Date.now() - ms;
|
|
68
107
|
}
|
|
69
108
|
|
|
109
|
+
/** True when this host can actually verify a process's start time. Exported so
|
|
110
|
+
* status output (and tests) can distinguish "the guard says the owner is alive"
|
|
111
|
+
* from "the guard could not check" instead of conflating the two. */
|
|
112
|
+
export async function canVerifyProcessStartTime(): Promise<boolean> {
|
|
113
|
+
return (await processStartTimeMs(process.pid)) !== undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
70
116
|
/** `etime` is truncated to whole seconds and adds a little spawn/poll jitter;
|
|
71
117
|
* genuine PID reuse shows up as a gap of minutes/hours/days, never a few
|
|
72
118
|
* seconds, so a generous-but-tight tolerance loses no real detections. */
|
|
@@ -82,10 +128,23 @@ const START_TIME_TOLERANCE_MS = 10_000;
|
|
|
82
128
|
* unavailable (Windows, `ps` missing, permission denied) — never a
|
|
83
129
|
* regression versus the old behavior in that case. */
|
|
84
130
|
export async function isLockOwnerAlive(lock: DaemonLockInfo): Promise<boolean> {
|
|
85
|
-
|
|
131
|
+
return (await inspectLockOwner(lock)).alive;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether the recorded owner is alive, AND whether that answer was actually
|
|
135
|
+
* verified against the OS start time or merely assumed from `kill(pid, 0)`. */
|
|
136
|
+
export interface LockOwnerCheck {
|
|
137
|
+
alive: boolean;
|
|
138
|
+
/** False when the host could not report the process start time (no `/proc`,
|
|
139
|
+
* no usable `ps`, Windows) — `alive` is then an existence-only guess. */
|
|
140
|
+
verified: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function inspectLockOwner(lock: DaemonLockInfo): Promise<LockOwnerCheck> {
|
|
144
|
+
if (!isPidAlive(lock.pid)) return { alive: false, verified: true };
|
|
86
145
|
const real = await processStartTimeMs(lock.pid);
|
|
87
|
-
if (real === undefined) return true;
|
|
88
|
-
return Math.abs(real - lock.startedAt) <= START_TIME_TOLERANCE_MS;
|
|
146
|
+
if (real === undefined) return { alive: true, verified: false };
|
|
147
|
+
return { alive: Math.abs(real - lock.startedAt) <= START_TIME_TOLERANCE_MS, verified: true };
|
|
89
148
|
}
|
|
90
149
|
|
|
91
150
|
export async function readDaemonLock(): Promise<DaemonLockInfo | undefined> {
|
|
@@ -123,6 +182,10 @@ export interface DaemonStatus {
|
|
|
123
182
|
stale: boolean;
|
|
124
183
|
pid?: number;
|
|
125
184
|
startedAt?: number;
|
|
185
|
+
/** False when this host cannot report a process start time (no `/proc`, no usable
|
|
186
|
+
* `ps`, Windows), so `running` is an existence-only guess that cannot rule out PID
|
|
187
|
+
* reuse. Present only when a lock file exists. */
|
|
188
|
+
ownerVerified?: boolean;
|
|
126
189
|
}
|
|
127
190
|
|
|
128
191
|
export async function isNotifyConfigured(): Promise<boolean> {
|
|
@@ -133,8 +196,10 @@ export async function isNotifyConfigured(): Promise<boolean> {
|
|
|
133
196
|
export async function daemonStatus(): Promise<DaemonStatus> {
|
|
134
197
|
const [lock, configured] = await Promise.all([readDaemonLock(), isNotifyConfigured()]);
|
|
135
198
|
if (!lock) return { configured, running: false, stale: false };
|
|
136
|
-
const alive = await
|
|
137
|
-
|
|
199
|
+
const { alive, verified } = await inspectLockOwner(lock);
|
|
200
|
+
// Surface the degraded case instead of hiding it: on a host with no `/proc` and no
|
|
201
|
+
// usable `ps`, "running" is an existence-only guess that cannot rule out PID reuse.
|
|
202
|
+
return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
|
|
138
203
|
}
|
|
139
204
|
|
|
140
205
|
/** Self-invocation argv for the daemon child (mirrors `memory.ts`'s
|
|
@@ -200,7 +265,8 @@ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<
|
|
|
200
265
|
|
|
201
266
|
export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
|
|
202
267
|
const lock = await readDaemonLock();
|
|
203
|
-
|
|
268
|
+
const owner = lock ? await inspectLockOwner(lock) : undefined;
|
|
269
|
+
if (!lock || !owner?.alive) {
|
|
204
270
|
await fs.unlink(notifyDaemonLockPath()).catch(() => {});
|
|
205
271
|
return { ok: true, message: "daemon was not running" };
|
|
206
272
|
}
|
|
@@ -212,7 +278,8 @@ export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
|
|
|
212
278
|
const stopped = await waitFor(async () => !isPidAlive(lock.pid), 3_000);
|
|
213
279
|
if (!stopped) return { ok: false, message: `daemon (pid ${lock.pid}) did not exit within 3s` };
|
|
214
280
|
await fs.unlink(notifyDaemonLockPath()).catch(() => {});
|
|
215
|
-
|
|
281
|
+
const caveat = owner.verified ? "" : " (start time unverifiable on this host — PID reuse could not be ruled out)";
|
|
282
|
+
return { ok: true, message: `daemon stopped (was pid ${lock.pid})${caveat}` };
|
|
216
283
|
}
|
|
217
284
|
|
|
218
285
|
export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; message: string }> {
|
package/src/agent/state.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as os from "node:os";
|
|
|
4
4
|
import { parseConfig } from "./config-schema";
|
|
5
5
|
import type { AuthProvider } from "../auth/storage";
|
|
6
6
|
import { OPENAI_COMPAT_PROVIDERS } from "../ai/providers/openai-compatible-catalog";
|
|
7
|
+
import { resolveCustomApiKey, setCustomProviders, type CustomProviderConfig } from "../ai/providers/custom-providers";
|
|
7
8
|
import { jeoEnv } from "../util/env";
|
|
8
9
|
|
|
9
10
|
/** Persisted OAuth credential set (access + refresh + expiry) for a provider. */
|
|
@@ -28,8 +29,13 @@ export interface HookConfig {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface Config {
|
|
31
|
-
/** Per-provider API keys, keyed by AuthProvider (cloud keys + catalog OpenAI-compatible).
|
|
32
|
+
/** Per-provider API keys, keyed by AuthProvider (cloud keys + catalog OpenAI-compatible).
|
|
33
|
+
* User-registered custom provider ids are published into this same map by
|
|
34
|
+
* `withEnvOverlay` so credential resolution needs no second lookup path. */
|
|
32
35
|
providers: Partial<Record<AuthProvider, string>>;
|
|
36
|
+
/** User-registered OpenAI/Anthropic-compatible providers (`/provider add`), keyed by
|
|
37
|
+
* routing id. Each is a full provider: own prefix, base URL, models and credential. */
|
|
38
|
+
customProviders?: Record<string, CustomProviderConfig>;
|
|
33
39
|
/**
|
|
34
40
|
* OAuth credentials. `resolveCredential()` returns these before API keys so refresh
|
|
35
41
|
* metadata is not lost, but provider execution/status applies the GJC parity rule:
|
|
@@ -292,6 +298,18 @@ function withEnvOverlay(cfg: Config): Config {
|
|
|
292
298
|
const key = def.name as AuthProvider; // every catalog name is an AuthProvider
|
|
293
299
|
if (!providers[key] && process.env[def.apiKeyEnv]) providers[key] = process.env[def.apiKeyEnv];
|
|
294
300
|
}
|
|
301
|
+
// User-registered custom providers. Publishing them here is what makes every generic
|
|
302
|
+
// path (routing, discovery, /model, status, credential resolution) treat a custom
|
|
303
|
+
// endpoint exactly like a compiled-in catalog row: `setCustomProviders` refreshes the
|
|
304
|
+
// lookup `openaiCompatDef` reads, and the key lands in the SAME `providers` map
|
|
305
|
+
// `resolveCredential` already consults. Env wins over a literal key stored on disk so
|
|
306
|
+
// a rotated shell secret takes effect without editing config.json.
|
|
307
|
+
const { defs: customDefs } = setCustomProviders(cfg.customProviders);
|
|
308
|
+
const providerKeys = providers as Record<string, string | undefined>;
|
|
309
|
+
for (const def of customDefs) {
|
|
310
|
+
const resolved = resolveCustomApiKey(def);
|
|
311
|
+
if (!providerKeys[def.name] && resolved) providerKeys[def.name] = resolved;
|
|
312
|
+
}
|
|
295
313
|
return {
|
|
296
314
|
...cfg,
|
|
297
315
|
providers,
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -111,6 +111,27 @@ export interface TaskToolOptions {
|
|
|
111
111
|
* no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
|
|
112
112
|
export const MAX_FANOUT = 4;
|
|
113
113
|
|
|
114
|
+
/** Clamp for {@link resolveFanoutLimit}. Below 1 there is no fan-out at all; above 16
|
|
115
|
+
* the parent turn is monopolized no matter how much provider capacity exists. */
|
|
116
|
+
export const MIN_FANOUT_LIMIT = 1;
|
|
117
|
+
export const MAX_FANOUT_LIMIT = 16;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Effective fan-out concurrency for this account.
|
|
121
|
+
*
|
|
122
|
+
* {@link MAX_FANOUT} is a reasonable default, not a universal one: four concurrent
|
|
123
|
+
* Anthropic streams trip a Tier-1 rate limit immediately (turning a fan-out into a
|
|
124
|
+
* backoff storm that is SLOWER than running sequentially), while an account with real
|
|
125
|
+
* headroom leaves throughput on the table at four. `config.subagentConcurrency` lets
|
|
126
|
+
* that be tuned per machine; the clamp keeps a typo (`0`, `-1`, `500`) from producing a
|
|
127
|
+
* batch that never runs or never yields.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveFanoutLimit(config?: { subagentConcurrency?: number }): number {
|
|
130
|
+
const raw = config?.subagentConcurrency;
|
|
131
|
+
if (raw === undefined || !Number.isFinite(raw)) return MAX_FANOUT;
|
|
132
|
+
return Math.min(MAX_FANOUT_LIMIT, Math.max(MIN_FANOUT_LIMIT, Math.trunc(raw)));
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
/** Small bounded reroute budget for a SUBAGENT's own 429 (distinct from
|
|
115
136
|
* launch.ts's `ROUTE_FALLBACK_MAX_ATTEMPTS`, default 3): a subagent is already
|
|
116
137
|
* bounded by its own step/token budget and the parent turn's patience, so this
|
|
@@ -635,9 +656,12 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
635
656
|
};
|
|
636
657
|
}
|
|
637
658
|
}
|
|
638
|
-
// Both roles fan out CONCURRENTLY, bounded at
|
|
639
|
-
// MAX_FANOUT's docstring for the disjoint-file-scope expectation
|
|
640
|
-
|
|
659
|
+
// Both roles fan out CONCURRENTLY, bounded at the account's effective limit
|
|
660
|
+
// (gjc parity — see MAX_FANOUT's docstring for the disjoint-file-scope expectation
|
|
661
|
+
// this relies on). `config.subagentConcurrency` tunes it per machine so a
|
|
662
|
+
// rate-limited account can drop below the default instead of backoff-storming.
|
|
663
|
+
const fanoutLimit = resolveFanoutLimit(opts.config as { subagentConcurrency?: number });
|
|
664
|
+
const limit = Math.min(items.length, fanoutLimit);
|
|
641
665
|
// Load project context ONCE per batch instead of re-scanning AGENTS.md for
|
|
642
666
|
// every fan-out task (redundant IO + duplicated tokens).
|
|
643
667
|
const batchContext = await loadProjectContext(cwd);
|
|
@@ -95,7 +95,11 @@ function authProviderFor(provider: ProviderName): AuthProvider | undefined {
|
|
|
95
95
|
// Local providers (ollama/lmstudio) are keyless and do not resolve through the
|
|
96
96
|
// auth core. API-key providers (incl. xai/kimi) DO — so discovery sends their key.
|
|
97
97
|
if (provider === "ollama" || provider === "lmstudio") return undefined;
|
|
98
|
-
|
|
98
|
+
// A user-registered custom provider id is not in the `AuthProvider` literal union,
|
|
99
|
+
// but `withEnvOverlay` publishes its key into the SAME `config.providers` map that
|
|
100
|
+
// `resolveCredential` reads — so the lookup is keyed identically. Same cast the
|
|
101
|
+
// model-manager's generic credential path already makes.
|
|
102
|
+
return provider as AuthProvider;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/** Build the discovery request (url + headers) for a provider/credential. */
|
|
@@ -468,7 +472,10 @@ export async function listProviderModels(
|
|
|
468
472
|
* service answers in single-digit ms, so this stays cheap on the turn hot path
|
|
469
473
|
* and still fails fast on a genuinely unreachable host. */
|
|
470
474
|
export async function isLocalProviderReachable(
|
|
471
|
-
|
|
475
|
+
// Accepts any ProviderName: `ProviderName` now includes user-registered custom ids,
|
|
476
|
+
// so a `provider === "ollama" || provider === "lmstudio"` guard no longer narrows to
|
|
477
|
+
// the two literals at call sites. The probe itself is protocol-generic.
|
|
478
|
+
provider: ProviderName,
|
|
472
479
|
baseUrl: string | undefined,
|
|
473
480
|
opts: Pick<DiscoveryOptions, "timeoutMs" | "fetchImpl"> = {},
|
|
474
481
|
): Promise<boolean> {
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { expandAlias, resolveModelId, effectiveAliasesFor, BUILTIN_ALIASES } fro
|
|
|
8
8
|
import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
|
|
9
9
|
import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel, isCodexModel, findLiveCatalogModel } from "./model-catalog";
|
|
10
10
|
import { xaiCredential } from "./providers/xai";
|
|
11
|
-
import {
|
|
11
|
+
import { allCompatNames, isOpenAICompatProvider } from "./providers/openai-compatible-catalog";
|
|
12
12
|
import { withRetry, defaultRetryable, withConnectionContext, isConnectionError, ConnectionContextError, type RetryOptions } from "../util/retry";
|
|
13
13
|
import { jeoEnv } from "../util/env";
|
|
14
14
|
import type { Config } from "../agent/state";
|
|
@@ -39,7 +39,10 @@ export function resolveProvider(model: string): ProviderName {
|
|
|
39
39
|
if (m.startsWith("antigravity/")) return "antigravity";
|
|
40
40
|
if (m.startsWith("xai/")) return "xai";
|
|
41
41
|
if (m.startsWith("kimi/")) return "kimi";
|
|
42
|
-
|
|
42
|
+
// Built-ins FIRST, then user-registered custom providers: a custom id can never be a
|
|
43
|
+
// built-in name (`assertValidProviderId` reserves them), but ordering keeps routing
|
|
44
|
+
// deterministic even for a hand-edited config that predates that check.
|
|
45
|
+
for (const p of allCompatNames()) if (m.startsWith(`${p}/`)) return p;
|
|
43
46
|
if (m.startsWith("openai/")) return "openai";
|
|
44
47
|
if (m.startsWith("google/")) return "gemini";
|
|
45
48
|
// Loose substring heuristics for BARE (unprefixed) ids only.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retired model ids and where they went.
|
|
3
|
+
*
|
|
4
|
+
* A model id that a provider has sunset does not fail *nicely*: the id is simply
|
|
5
|
+
* unknown at the API, so the call comes back as a 404 / "model not found" from deep
|
|
6
|
+
* inside the adapter. That is a bad failure for jeo specifically, because model ids get
|
|
7
|
+
* **pinned in three durable places** — `config.defaultModel`, per-role
|
|
8
|
+
* `config.subagents.<role>.model`, and `config.roles.*` — and a pinned id keeps failing
|
|
9
|
+
* every turn until the user works out which of the three is stale. The live catalog
|
|
10
|
+
* refresh does not help: discovery only reports what still EXISTS, so a retired pin has
|
|
11
|
+
* nothing to match against and silently stays broken.
|
|
12
|
+
*
|
|
13
|
+
* This table maps a retired id to its documented successor so the manager can (a) route
|
|
14
|
+
* the call to something that works and (b) tell the user exactly which pin to update.
|
|
15
|
+
* It is deliberately small and hand-curated: only ids jeo itself shipped in its catalog
|
|
16
|
+
* or aliases, so nobody's config can be silently rewritten to a model they never chose.
|
|
17
|
+
*
|
|
18
|
+
* Rules for entries:
|
|
19
|
+
* - `replacement` MUST be a currently-catalogued id, and MUST stay on the SAME provider.
|
|
20
|
+
* Silently moving a user to a different vendor (different pricing, different data
|
|
21
|
+
* handling, different account) is not a substitution jeo gets to make on its own.
|
|
22
|
+
* - `note` is shown to the user once per session, so it should name the successor and
|
|
23
|
+
* read as an explanation, not a warning banner.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface ModelRetirement {
|
|
27
|
+
/** The retired id, exactly as it appeared in jeo's catalog/aliases. */
|
|
28
|
+
readonly retired: string;
|
|
29
|
+
/** Current catalog id that serves the same role on the SAME provider. */
|
|
30
|
+
readonly replacement: string;
|
|
31
|
+
/** One-line reason surfaced to the user. */
|
|
32
|
+
readonly note: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MODEL_RETIREMENTS: readonly ModelRetirement[] = [
|
|
36
|
+
{
|
|
37
|
+
retired: "antigravity/gemini-3.1-pro-high",
|
|
38
|
+
replacement: "antigravity/gemini-pro-agent",
|
|
39
|
+
note: "Antigravity retired gemini-3.1-pro-high; gemini-pro-agent is its code-agent successor.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
retired: "gemini-3.1-pro-high",
|
|
43
|
+
replacement: "antigravity/gemini-pro-agent",
|
|
44
|
+
note: "Antigravity retired gemini-3.1-pro-high; gemini-pro-agent is its code-agent successor.",
|
|
45
|
+
},
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
const BY_RETIRED = new Map(MODEL_RETIREMENTS.map(r => [r.retired.toLowerCase(), r]));
|
|
49
|
+
|
|
50
|
+
/** The retirement record for `model`, or undefined when the id is still current. */
|
|
51
|
+
export function findRetirement(model: string | undefined): ModelRetirement | undefined {
|
|
52
|
+
const key = model?.trim().toLowerCase();
|
|
53
|
+
return key ? BY_RETIRED.get(key) : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when `model` names an id jeo knows has been sunset. */
|
|
57
|
+
export function isRetiredModel(model: string | undefined): boolean {
|
|
58
|
+
return findRetirement(model) !== undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Map a possibly-retired id onto a working one.
|
|
63
|
+
*
|
|
64
|
+
* Returns the input unchanged when it is current, so this is safe to call on every
|
|
65
|
+
* resolution path. Chains are followed (a successor that was itself later retired)
|
|
66
|
+
* with a hard bound, so a malformed table can never spin here.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveRetiredModel(model: string): { model: string; retirement?: ModelRetirement } {
|
|
69
|
+
let current = model;
|
|
70
|
+
let first: ModelRetirement | undefined;
|
|
71
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
72
|
+
const hit = findRetirement(current);
|
|
73
|
+
if (!hit) break;
|
|
74
|
+
first ??= hit;
|
|
75
|
+
if (hit.replacement.toLowerCase() === current.toLowerCase()) break; // self-reference guard
|
|
76
|
+
current = hit.replacement;
|
|
77
|
+
}
|
|
78
|
+
return first ? { model: current, retirement: first } : { model };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which durable config pins still name a retired model, and what to change them to.
|
|
83
|
+
* Used by `jeo doctor` and by the launch banner so the user is told *where* the stale
|
|
84
|
+
* id lives rather than just that some call failed.
|
|
85
|
+
*/
|
|
86
|
+
export interface RetiredPin {
|
|
87
|
+
/** Human-readable location, e.g. `defaultModel` or `subagents.executor.model`. */
|
|
88
|
+
location: string;
|
|
89
|
+
retired: string;
|
|
90
|
+
replacement: string;
|
|
91
|
+
note: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function findRetiredPins(config: {
|
|
95
|
+
defaultModel?: string;
|
|
96
|
+
roles?: Record<string, string | undefined>;
|
|
97
|
+
subagents?: Record<string, { model?: string } | undefined>;
|
|
98
|
+
}): RetiredPin[] {
|
|
99
|
+
const pins: RetiredPin[] = [];
|
|
100
|
+
const add = (location: string, model: string | undefined) => {
|
|
101
|
+
const hit = findRetirement(model);
|
|
102
|
+
if (hit) pins.push({ location, retired: model!, replacement: hit.replacement, note: hit.note });
|
|
103
|
+
};
|
|
104
|
+
add("defaultModel", config.defaultModel);
|
|
105
|
+
for (const [role, model] of Object.entries(config.roles ?? {})) add(`roles.${role}`, model);
|
|
106
|
+
for (const [role, cfg] of Object.entries(config.subagents ?? {})) add(`subagents.${role}.model`, cfg?.model);
|
|
107
|
+
return pins;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One-time-per-session dedupe so a retired pin explains itself once, not every turn. */
|
|
111
|
+
const announced = new Set<string>();
|
|
112
|
+
|
|
113
|
+
export function shouldAnnounceRetirement(retired: string): boolean {
|
|
114
|
+
const key = retired.toLowerCase();
|
|
115
|
+
if (announced.has(key)) return false;
|
|
116
|
+
announced.add(key);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Test hook — clears the per-session announce dedupe. */
|
|
121
|
+
export function resetRetirementAnnouncements(): void {
|
|
122
|
+
announced.clear();
|
|
123
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared provider ordering — the single source of truth behind every provider-facing
|
|
3
|
+
* surface (`/provider`, `/model`, the model/provider pickers, `jeo doctor`).
|
|
4
|
+
*
|
|
5
|
+
* Before this module each surface kept its own ad-hoc sort: `model-picker` sorted by
|
|
6
|
+
* `PROVIDER_NAMES` index, `/provider` printed catalog order, and custom providers
|
|
7
|
+
* (which have no compiled-in index at all) landed wherever `indexOf` returned -1 —
|
|
8
|
+
* i.e. FIRST, above the user's actual logged-in provider. The ranking below is
|
|
9
|
+
* deliberately state-first so the list a user sees always leads with providers they
|
|
10
|
+
* can actually call right now.
|
|
11
|
+
*
|
|
12
|
+
* Order: providers you have → providers you have but that are broken → a curated
|
|
13
|
+
* "famous" list → everything else alphabetically. Ties break on label, then id, so
|
|
14
|
+
* the order is total and stable (no reflow when async credential checks resolve).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Auth/config state of a provider as seen by the calling surface.
|
|
19
|
+
*
|
|
20
|
+
* - `valid` — credential present and believed usable right now.
|
|
21
|
+
* - `checking` — credential present, async validation still in flight. Ranked WITH
|
|
22
|
+
* `valid` so rows do not jump when the check resolves.
|
|
23
|
+
* - `configured` — no OAuth record, but the provider is usable (custom provider with
|
|
24
|
+
* a key, keyless local server).
|
|
25
|
+
* - `invalid` — credential present but known-bad (expired OAuth with no refresh).
|
|
26
|
+
* - `none` — nothing stored, nothing configured.
|
|
27
|
+
*/
|
|
28
|
+
export type ProviderAuthState = "valid" | "checking" | "configured" | "invalid" | "none";
|
|
29
|
+
|
|
30
|
+
export const PROVIDER_RANK_TIER = {
|
|
31
|
+
existing: 0,
|
|
32
|
+
problematic: 1,
|
|
33
|
+
famous: 2,
|
|
34
|
+
other: 3,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export type ProviderRankTier = (typeof PROVIDER_RANK_TIER)[keyof typeof PROVIDER_RANK_TIER];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Curated order for the "famous" tier — providers a user is most likely to want when
|
|
41
|
+
* they have nothing configured yet. Regional/plan variants sit immediately behind
|
|
42
|
+
* their primary so related rows stay grouped.
|
|
43
|
+
*/
|
|
44
|
+
export const FAMOUS_PROVIDER_ORDER: readonly string[] = [
|
|
45
|
+
"anthropic",
|
|
46
|
+
"openai",
|
|
47
|
+
"gemini",
|
|
48
|
+
"antigravity",
|
|
49
|
+
"xai",
|
|
50
|
+
"kimi",
|
|
51
|
+
"zai",
|
|
52
|
+
"minimax",
|
|
53
|
+
"minimax-code",
|
|
54
|
+
"minimax-code-cn",
|
|
55
|
+
"groq",
|
|
56
|
+
"deepseek",
|
|
57
|
+
"openrouter",
|
|
58
|
+
"mistral",
|
|
59
|
+
"together",
|
|
60
|
+
"cerebras",
|
|
61
|
+
"fireworks",
|
|
62
|
+
"deepinfra",
|
|
63
|
+
"nvidia",
|
|
64
|
+
"alibaba-coding-plan",
|
|
65
|
+
"qwen-portal",
|
|
66
|
+
"xiaomi",
|
|
67
|
+
"xiaomi-token-plan-sgp",
|
|
68
|
+
"xiaomi-token-plan-ams",
|
|
69
|
+
"xiaomi-token-plan-cn",
|
|
70
|
+
"tencent",
|
|
71
|
+
"qianfan",
|
|
72
|
+
"huggingface",
|
|
73
|
+
"nanogpt",
|
|
74
|
+
"synthetic",
|
|
75
|
+
"venice",
|
|
76
|
+
"zenmux",
|
|
77
|
+
"litellm",
|
|
78
|
+
"ollama",
|
|
79
|
+
"lmstudio",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const FAMOUS_PROVIDER_INDEX = new Map(FAMOUS_PROVIDER_ORDER.map((id, i) => [id, i]));
|
|
83
|
+
|
|
84
|
+
/** A provider as ranked by a surface. `label` is what the user sees. */
|
|
85
|
+
export interface RankableProvider {
|
|
86
|
+
id: string;
|
|
87
|
+
label: string;
|
|
88
|
+
authState: ProviderAuthState;
|
|
89
|
+
/** User-registered custom providers sort ahead of unconfigured built-ins in the
|
|
90
|
+
* `other` tier — the user explicitly added them, so they are not "everything else". */
|
|
91
|
+
custom?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A provider's position: its tier plus its rank inside that tier. */
|
|
95
|
+
export interface ProviderRank {
|
|
96
|
+
tier: ProviderRankTier;
|
|
97
|
+
intraTierRank: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function providerRankTier(authState: ProviderAuthState, id: string, custom?: boolean): ProviderRankTier {
|
|
101
|
+
if (authState === "valid" || authState === "checking" || authState === "configured") {
|
|
102
|
+
return PROVIDER_RANK_TIER.existing;
|
|
103
|
+
}
|
|
104
|
+
if (authState === "invalid") return PROVIDER_RANK_TIER.problematic;
|
|
105
|
+
if (FAMOUS_PROVIDER_INDEX.has(id)) return PROVIDER_RANK_TIER.famous;
|
|
106
|
+
// An unconfigured custom provider still beat "some cloud you never mentioned" —
|
|
107
|
+
// the user typed its id into their own config.
|
|
108
|
+
return custom ? PROVIDER_RANK_TIER.famous : PROVIDER_RANK_TIER.other;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Position within the famous list, or `undefined` for providers not on it. */
|
|
112
|
+
export function famousProviderIndex(id: string): number | undefined {
|
|
113
|
+
return FAMOUS_PROVIDER_INDEX.get(id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function rankProvider(provider: RankableProvider): ProviderRank {
|
|
117
|
+
return {
|
|
118
|
+
tier: providerRankTier(provider.authState, provider.id, provider.custom),
|
|
119
|
+
intraTierRank: FAMOUS_PROVIDER_INDEX.get(provider.id) ?? Number.MAX_SAFE_INTEGER,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Total order over providers: tier, then famous-list position, then display label,
|
|
125
|
+
* then id. The trailing id comparison guarantees no ties (stable across renders).
|
|
126
|
+
*/
|
|
127
|
+
export function compareRankedProviders(left: RankableProvider, right: RankableProvider): number {
|
|
128
|
+
const l = rankProvider(left);
|
|
129
|
+
const r = rankProvider(right);
|
|
130
|
+
if (l.tier !== r.tier) return l.tier - r.tier;
|
|
131
|
+
if (l.intraTierRank !== r.intraTierRank) return l.intraTierRank - r.intraTierRank;
|
|
132
|
+
const byLabel = left.label.localeCompare(right.label);
|
|
133
|
+
if (byLabel !== 0) return byLabel;
|
|
134
|
+
return left.id.localeCompare(right.id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Convenience wrapper returning a NEW array in ranked order (never mutates input). */
|
|
138
|
+
export function sortRankedProviders<T extends RankableProvider>(providers: readonly T[]): T[] {
|
|
139
|
+
return [...providers].sort(compareRankedProviders);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Map a jeo `ProviderStatus`-shaped record onto the ranking's auth-state vocabulary. */
|
|
143
|
+
export function authStateFor(status: {
|
|
144
|
+
ready: boolean;
|
|
145
|
+
kind: "api_key" | "oauth" | "keyless" | "none";
|
|
146
|
+
loggedIn?: boolean;
|
|
147
|
+
}): ProviderAuthState {
|
|
148
|
+
if (status.ready) return status.kind === "oauth" ? "valid" : "configured";
|
|
149
|
+
// A stored login that cannot serve a call is the "problematic" tier: it needs the
|
|
150
|
+
// user's attention (re-login / add a key) more urgently than an untouched provider.
|
|
151
|
+
if (status.loggedIn) return "invalid";
|
|
152
|
+
return "none";
|
|
153
|
+
}
|
|
@@ -15,6 +15,16 @@ class ProviderRegistry {
|
|
|
15
15
|
return this.adapters.get(name);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Drop an adapter. Used when a user removes a custom provider mid-session so the
|
|
19
|
+
* next `/model` or routing pass cannot resolve a provider that no longer exists. */
|
|
20
|
+
unregister(name: ProviderName): boolean {
|
|
21
|
+
return this.adapters.delete(name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
has(name: ProviderName): boolean {
|
|
25
|
+
return this.adapters.has(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
listProviders(): ProviderName[] {
|
|
19
29
|
return Array.from(this.adapters.keys());
|
|
20
30
|
}
|
|
@@ -10,9 +10,20 @@ import { OAUTH_FLOW_REGISTRY } from "../auth/flows";
|
|
|
10
10
|
import type { ProviderName } from "./types";
|
|
11
11
|
|
|
12
12
|
import { OPENAI_COMPAT_NAMES, openaiCompatDef } from "./providers/openai-compatible-catalog";
|
|
13
|
+
import { customProviderDef, customProviderNames, credentialSourceOf, resolveCustomApiKey } from "./providers/custom-providers";
|
|
13
14
|
|
|
15
|
+
/** Compiled-in providers, in declaration order. Custom providers are NOT here — they
|
|
16
|
+
* are discovered at runtime, so use {@link allProviderNames} for anything the user sees. */
|
|
14
17
|
export const PROVIDER_NAMES: readonly ProviderName[] = ["anthropic", "openai", "gemini", "antigravity", "ollama", "lmstudio", "xai", "kimi", ...OPENAI_COMPAT_NAMES];
|
|
15
18
|
|
|
19
|
+
/** Built-in providers PLUS the user's registered custom providers. Every user-facing
|
|
20
|
+
* surface (pickers, `/provider`, autocomplete, doctor) should list from here so a
|
|
21
|
+
* custom endpoint is never invisible in the UI that is supposed to expose it. */
|
|
22
|
+
export function allProviderNames(): readonly ProviderName[] {
|
|
23
|
+
const custom = customProviderNames();
|
|
24
|
+
return custom.length ? [...PROVIDER_NAMES, ...custom] : PROVIDER_NAMES;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
/** Cloud providers that authenticate via API key / OAuth. Ollama is keyless. */
|
|
17
28
|
export const CLOUD_PROVIDERS: readonly AuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"];
|
|
18
29
|
|
|
@@ -35,6 +46,8 @@ export interface ProviderStatus {
|
|
|
35
46
|
oauthEmail?: string;
|
|
36
47
|
/** Epoch ms expiry of the stored OAuth access token, when known. */
|
|
37
48
|
oauthExpires?: number;
|
|
49
|
+
/** True for user-registered custom providers (`config.customProviders`). */
|
|
50
|
+
custom?: boolean;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
/** The env var that supplies a provider's API key. Catalog providers carry their
|
|
@@ -46,6 +59,13 @@ export function providerEnvVar(name: ProviderName): string | undefined {
|
|
|
46
59
|
return `${name.toUpperCase()}_API_KEY`;
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
/** The API key a custom provider will actually use, or undefined. Exported so setup /
|
|
63
|
+
* doctor can probe a custom endpoint without re-deriving the env-vs-literal rule. */
|
|
64
|
+
export function customProviderApiKey(name: ProviderName): string | undefined {
|
|
65
|
+
const def = customProviderDef(name);
|
|
66
|
+
return def ? resolveCustomApiKey(def) : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
/** Human label for a credential kind. */
|
|
50
70
|
export function credentialLabel(kind: CredentialKind): string {
|
|
51
71
|
switch (kind) {
|
|
@@ -113,6 +133,25 @@ function effectiveCredential(provider: AuthProvider, cred: Credential, cfg: Conf
|
|
|
113
133
|
/** Resolve the status of a single provider. */
|
|
114
134
|
export async function describeProvider(name: ProviderName, config?: Config): Promise<ProviderStatus> {
|
|
115
135
|
const cfg = config ?? (await readGlobalConfig());
|
|
136
|
+
// Custom providers resolve their credential from their OWN env var / stored literal,
|
|
137
|
+
// not from the fixed `providers` union — report that source verbatim so a user who
|
|
138
|
+
// set the wrong env var sees WHICH variable jeo is looking at.
|
|
139
|
+
const custom = customProviderDef(name);
|
|
140
|
+
if (custom) {
|
|
141
|
+
const source = credentialSourceOf(custom);
|
|
142
|
+
const ready = source !== "none";
|
|
143
|
+
return {
|
|
144
|
+
name,
|
|
145
|
+
kind: ready ? "api_key" : "none",
|
|
146
|
+
label: ready
|
|
147
|
+
? `API key (${source === "env" ? custom.apiKeyEnv : "stored in config"}) · custom${custom.preset ? ` · preset ${custom.preset}` : ""}`
|
|
148
|
+
: `none (set ${custom.apiKeyEnv}, or 'jeo provider key ${name} <key>')`,
|
|
149
|
+
baseUrl: custom.baseUrl,
|
|
150
|
+
envVar: custom.apiKeyEnv,
|
|
151
|
+
ready,
|
|
152
|
+
custom: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
116
155
|
if (name === "ollama" || name === "lmstudio") {
|
|
117
156
|
const baseUrl = name === "ollama"
|
|
118
157
|
? (cfg.ollamaBaseUrl ?? "http://localhost:11434")
|
|
@@ -183,8 +222,8 @@ export async function describeProvider(name: ProviderName, config?: Config): Pro
|
|
|
183
222
|
};
|
|
184
223
|
}
|
|
185
224
|
|
|
186
|
-
/** Resolve the status of every provider (single config read). */
|
|
225
|
+
/** Resolve the status of every provider (single config read), custom providers included. */
|
|
187
226
|
export async function describeAllProviders(config?: Config): Promise<ProviderStatus[]> {
|
|
188
227
|
const cfg = config ?? (await readGlobalConfig());
|
|
189
|
-
return Promise.all(
|
|
228
|
+
return Promise.all(allProviderNames().map(name => describeProvider(name, cfg)));
|
|
190
229
|
}
|