jeo-code 0.9.17 → 0.10.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 +24 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/config-schema.ts +20 -0
- package/src/agent/state.ts +19 -1
- package/src/ai/model-discovery.ts +9 -2
- package/src/ai/model-manager.ts +5 -2
- 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/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
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,30 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.10.0] - 2026-08-25
|
|
13
|
+
_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._
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Named custom providers (`config.customProviders`)** (`src/ai/providers/custom-providers.ts`) — register any OpenAI- or Anthropic-compatible endpoint as a first-class provider with its own routing prefix, base URL, wire protocol, credential source (env var or literal key), model list and default model. Registered providers are published through the SAME `openaiCompatDef` lookup the compiled-in catalog uses, so routing, live `/models` discovery, `/model`, provider status, pickers and credential resolution pick them up with no new per-call-site branching. Validation is strict and actionable at registration time: provider ids must match `^[a-z0-9][a-z0-9._-]*$` (a `/` would collide with the `<provider>/<model>` separator), built-in ids are reserved, and base URLs must be absolute `http(s)` — so a typo fails with one clear message instead of an opaque fetch error at the first inference call. A single malformed row is skipped with its reason rather than failing the whole config.
|
|
17
|
+
- **15 provider presets** (`src/ai/providers/provider-presets.ts`) — one-flag onboarding for known gateways: `litellm`, `vllm`, `sglang`, `llama-cpp`, `azure-openai`, `vercel-ai-gateway`, `cloudflare-ai-gateway`, `openai-compatible-proxy`, `anthropic-compatible-proxy`, `glm`, `alibaba-token-plan`, `cline-pass`, `commandcode-goat`, `ollama-cloud`, `github-copilot`. Parameterized presets (proxies the user runs) require `--base-url`; fixed presets pin theirs and reject one.
|
|
18
|
+
- **`jeo provider` CLI** (`src/commands/provider.ts`) — `list` / `add` / `remove` / `presets` / `test`, sharing the exact planners the slash command uses so the two surfaces cannot drift. `jeo provider test <id>` probes the endpoint and reports credential source and endpoint health separately ("no key" and "wrong URL" need different fixes), exiting non-zero on failure so CI and provisioning scripts can gate on it. `add` runs the same probe automatically without refusing the write — registering an endpoint before its key exists is legitimate.
|
|
19
|
+
- **`/provider list`, `/provider remove <id>`, `/provider presets`**, plus `/provider add --id/--preset/--compat/--api-key-env/--api-key/--model/--label/--force`, with autocomplete for preset ids and — on `remove` — only custom ids, since the command refuses built-ins.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **Provider ordering is now one shared ranking** (`src/ai/provider-ranking.ts`) used by the model picker: usable → broken-credential → curated famous list → the rest. The picker previously sorted by `PROVIDER_NAMES.indexOf`, which returns `-1` for a provider that is not compiled in — so a user's own custom provider sorted ABOVE the provider they were actually logged into. A stored-but-unusable login now also ranks above untouched providers, because it is the one that needs attention.
|
|
23
|
+
- `ProviderName` accepts custom ids alongside the literal union, and `describeAllProviders()` / `allProviderNames()` include custom providers so they are never invisible in the UI meant to expose them.
|
|
24
|
+
- `/provider add --base-url <url>` with no `--id` is unchanged (still rebinds the built-in `openai` endpoint), and now points at the named alternative. `/provider add clear` explicitly leaves custom providers alone.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **`resolveProvider()` ignored custom routing prefixes** (`src/ai/model-manager.ts`) — it matched against the static `OPENAI_COMPAT_NAMES` snapshot, so `my-proxy/some-model` fell through every prefix check and hit the final `anthropic` fallthrough, silently routing a custom-provider request to Anthropic. Now matches against the live built-in + custom name list.
|
|
28
|
+
- **Removing a custom provider whose id shadowed a built-in unregistered the BUILT-IN adapter** (`src/ai/register-providers.ts`) — a hand-edited row named e.g. `groq` would overwrite the shipped adapter on load and delete it on removal, leaving a compiled-in provider unreachable for the rest of the session. Shadowing rows are now inert at the adapter layer too, matching the lookup layer which already prefers built-ins.
|
|
29
|
+
- `normalizeCustomBaseUrl("localhost:1234/v1")` reported "must use http or https (got 'localhost')" because `new URL()` happily parses that as a `localhost:` scheme; a missing authority marker is now detected first and reported as the missing-scheme error it actually is.
|
|
30
|
+
|
|
31
|
+
### Verified
|
|
32
|
+
- `bun run typecheck` clean; `bun test` shows no new failures against the 0.9.17 baseline.
|
|
33
|
+
- 88 new tests across `test/custom-provider-registry.test.ts`, `test/provider-presets-ranking.test.ts`, `test/provider-slash-add.test.ts` and `test/autocomplete.test.ts`.
|
|
34
|
+
- End-to-end against a live local OpenAI-compatible gateway: `jeo provider add --preset openai-compatible-proxy --id gw --base-url http://localhost:4599/v1` registered it and reported the missing credential (exit 1); with the key exported, `jeo provider test gw` discovered both served model ids and exited 0.
|
|
35
|
+
|
|
12
36
|
## [0.9.17] - 2026-08-18
|
|
13
37
|
_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._
|
|
14
38
|
|
package/README.ja.md
CHANGED
|
@@ -287,10 +287,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
287
287
|
|
|
288
288
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
289
289
|
- **[Unreleased]**
|
|
290
|
+
- **[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.
|
|
290
291
|
- **[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.
|
|
291
292
|
- **[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.
|
|
292
293
|
- **[0.9.15]** (2026-08-18) — The 0.9.14 fix wired `promptInput` into the `$a $b …` one-shot skill-chain's `io.input`, but that chain runs before the interactive REPL's own `readline.Interface` exists — so a bundled workflow skill (`$deep-interview`, with or without `--tmux`) invoked directly from the command line still froze forever on its first question.
|
|
293
|
-
- **[0.9.14]** (2026-08-04) — A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt.
|
|
294
294
|
|
|
295
295
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
296
296
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -288,10 +288,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
288
288
|
|
|
289
289
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
290
290
|
- **[Unreleased]**
|
|
291
|
+
- **[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.
|
|
291
292
|
- **[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.
|
|
292
293
|
- **[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.
|
|
293
294
|
- **[0.9.15]** (2026-08-18) — The 0.9.14 fix wired `promptInput` into the `$a $b …` one-shot skill-chain's `io.input`, but that chain runs before the interactive REPL's own `readline.Interface` exists — so a bundled workflow skill (`$deep-interview`, with or without `--tmux`) invoked directly from the command line still froze forever on its first question.
|
|
294
|
-
- **[0.9.14]** (2026-08-04) — A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt.
|
|
295
295
|
|
|
296
296
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
297
297
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -295,10 +295,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
295
295
|
|
|
296
296
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
297
297
|
- **[Unreleased]**
|
|
298
|
+
- **[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.
|
|
298
299
|
- **[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.
|
|
299
300
|
- **[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.
|
|
300
301
|
- **[0.9.15]** (2026-08-18) — The 0.9.14 fix wired `promptInput` into the `$a $b …` one-shot skill-chain's `io.input`, but that chain runs before the interactive REPL's own `readline.Interface` exists — so a bundled workflow skill (`$deep-interview`, with or without `--tmux`) invoked directly from the command line still froze forever on its first question.
|
|
301
|
-
- **[0.9.14]** (2026-08-04) — A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt.
|
|
302
302
|
|
|
303
303
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
304
304
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -287,10 +287,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
287
287
|
|
|
288
288
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
289
289
|
- **[Unreleased]**
|
|
290
|
+
- **[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.
|
|
290
291
|
- **[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.
|
|
291
292
|
- **[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.
|
|
292
293
|
- **[0.9.15]** (2026-08-18) — The 0.9.14 fix wired `promptInput` into the `$a $b …` one-shot skill-chain's `io.input`, but that chain runs before the interactive REPL's own `readline.Interface` exists — so a bundled workflow skill (`$deep-interview`, with or without `--tmux`) invoked directly from the command line still froze forever on its first question.
|
|
293
|
-
- **[0.9.14]** (2026-08-04) — A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt.
|
|
294
294
|
|
|
295
295
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
296
296
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -24,6 +24,22 @@ const OAuthEntry = z.union([z.string(), StoredOAuthSchema]);
|
|
|
24
24
|
// are validated/kept rather than stripped. Adding a provider = one catalog row.
|
|
25
25
|
const compatKeySchema = Object.fromEntries(OPENAI_COMPAT_PROVIDERS.map(p => [p.name, z.string().optional()]));
|
|
26
26
|
const compatOAuthSchema = Object.fromEntries(OPENAI_COMPAT_PROVIDERS.map(p => [p.name, OAuthEntry.optional()]));
|
|
27
|
+
/** One user-registered provider (`/provider add`). Mirrors `CustomProviderConfig`.
|
|
28
|
+
* Kept permissive on `baseUrl` (a plain string) because the real validation lives in
|
|
29
|
+
* `toCustomProviderDef` — a bad URL must surface as ONE actionable provider error,
|
|
30
|
+
* not a whole-config schema rejection that would strand every other setting. */
|
|
31
|
+
const CustomProviderSchema = z.object({
|
|
32
|
+
label: z.string().optional(),
|
|
33
|
+
baseUrl: z.string().min(1),
|
|
34
|
+
protocol: z.enum(["openai", "anthropic"]).optional(),
|
|
35
|
+
apiKeyEnv: z.string().optional(),
|
|
36
|
+
apiKey: z.string().optional(),
|
|
37
|
+
models: z.array(z.string()).optional(),
|
|
38
|
+
defaultModel: z.string().optional(),
|
|
39
|
+
thinkingFormat: z.enum(["openai", "openrouter", "qwen", "zai"]).optional(),
|
|
40
|
+
preset: z.string().optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
27
43
|
const HookConfigSchema = z.object({
|
|
28
44
|
enabled: z.boolean().optional(),
|
|
29
45
|
hooks: z
|
|
@@ -67,6 +83,10 @@ export const ConfigSchema = z
|
|
|
67
83
|
...compatOAuthSchema,
|
|
68
84
|
})
|
|
69
85
|
.optional(),
|
|
86
|
+
/** User-registered OpenAI/Anthropic-compatible providers, keyed by routing id.
|
|
87
|
+
* Unlike `openaiBaseUrl` (which rebinds the single built-in `openai` provider),
|
|
88
|
+
* each entry here is its own provider with its own prefix, models and credential. */
|
|
89
|
+
customProviders: z.record(z.string(), CustomProviderSchema).optional(),
|
|
70
90
|
ollamaBaseUrl: z.string().optional(),
|
|
71
91
|
ollamaNumCtx: z.number().int().positive().optional(),
|
|
72
92
|
openaiBaseUrl: z.string().optional(),
|
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,
|
|
@@ -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,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
|
}
|