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.
@@ -67,6 +67,16 @@ import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/
67
67
 
68
68
  import { listAliases } from "../ai/model-registry";
69
69
  import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
70
+ import {
71
+ applyCustomProviderPatch,
72
+ formatCustomProviderList,
73
+ formatPresetsCommand,
74
+ looksLikePreset,
75
+ parseProviderAddArgs,
76
+ planProviderAdd,
77
+ planProviderRemove,
78
+ providerAddUsage,
79
+ } from "./launch/provider-slash";
70
80
 
71
81
  import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting, applyTargetChoices } from "../agent/subagents";
72
82
  import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
@@ -4640,8 +4650,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4640
4650
  " OAuth / subscription : /provider login [anthropic|openai|gemini|antigravity|<subscription>] (alias: /login)",
4641
4651
  " subscriptions (token): alibaba-coding-plan, qwen-portal, xiaomi-token-plan-*, minimax-code*",
4642
4652
  " API key (cloud) : /provider key [provider] [key] (groq, deepseek, mistral, openrouter, …)",
4643
- " API-compatible : /provider add --base-url <url> [--model <model>] [--compat openai] (reads OPENAI_API_KEY)",
4644
- " show current / clear: /provider add · /provider add clear",
4653
+ " API-compatible : /provider add --id <id> --base-url <url> [--compat openai|anthropic] [--model a,b]",
4654
+ " presets: /provider add --preset <id> [--base-url <url>] · list: /provider presets",
4655
+ " manage: /provider list · /provider remove <id>",
4656
+ " legacy (rebinds openai/): /provider add --base-url <url> · clear: /provider add clear",
4645
4657
  " Logout : /logout <provider>",
4646
4658
  " Headless OAuth : paste the redirect URL or code when the login prompt asks.",
4647
4659
  "Switch the active model or provider with /model.",
@@ -4656,8 +4668,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4656
4668
  else if (action === "api-key") name = "key";
4657
4669
  else if (action === "api-add") {
4658
4670
  console.log("Add an API-compatible endpoint:");
4659
- console.log(" /provider add --base-url <url> [--model <model>] [--compat openai]");
4660
- console.log(" reads OPENAI_API_KEY · show current / clear: /provider add · /provider add clear");
4671
+ for (const line of providerAddUsage()) console.log(line);
4661
4672
  continue;
4662
4673
  }
4663
4674
  // action === undefined (cancelled) → fall through to the readiness panel.
@@ -4810,56 +4821,63 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4810
4821
  }
4811
4822
  continue;
4812
4823
  }
4813
- // `/provider add --base-url <url> [--model <m>] [--compat openai]` → register an
4814
- // OpenAI-compatible endpoint (sets openaiBaseUrl). gjc flag style; bare positional
4815
- // URL/model still accepted for leniency. `/provider add clear` removes it.
4816
- if (name === "add") {
4817
- const rest = tokens.slice(1);
4818
- if ((rest[0] ?? "").toLowerCase() === "clear") {
4819
- await saveConfigPatch(() => ({ openaiBaseUrl: undefined }));
4824
+ // `/provider presets` → the preset catalog (fixed + parameterized gateways).
4825
+ if (name === "presets" || name === "preset") {
4826
+ logLines(formatPresetsCommand());
4827
+ continue;
4828
+ }
4829
+ // `/provider list` → registered custom providers (built-ins stay in the panel).
4830
+ if (name === "list" || name === "ls") {
4831
+ logLines(formatCustomProviderList(await readGlobalConfig()));
4832
+ continue;
4833
+ }
4834
+ // `/provider remove <id>` → unregister a custom provider.
4835
+ if (name === "remove" || name === "rm" || name === "delete") {
4836
+ const decision = planProviderRemove(tokens[1], await readGlobalConfig());
4837
+ logLines(decision.lines);
4838
+ if (decision.removeId) {
4839
+ await saveConfigPatch(raw => ({
4840
+ customProviders: applyCustomProviderPatch(raw.customProviders, { removeId: decision.removeId }),
4841
+ }));
4842
+ // Re-read so `withEnvOverlay` republishes the surviving set into the runtime
4843
+ // registry (the listener in register-providers unregisters the dropped adapter).
4844
+ await readGlobalConfig();
4820
4845
  await refreshLiveModelsCache();
4821
- console.log("OpenAI-compatible base URL cleared — saved to ~/.jeo/config.json.");
4822
- continue;
4823
4846
  }
4824
- let baseUrl: string | undefined;
4825
- let modelArg: string | undefined;
4826
- let compat: string | undefined;
4827
- for (let i = 0; i < rest.length; i++) {
4828
- const t = rest[i]!;
4829
- if (t === "--base-url" || t === "--url") baseUrl = rest[++i];
4830
- else if (t === "--model") modelArg = rest[++i];
4831
- else if (t === "--compat") compat = (rest[++i] ?? "").toLowerCase();
4832
- else if (t === "--api-key-env" || t === "--provider") { i++; /* gjc-only: jeo has no named-provider registry — ignored */ }
4833
- else if (!t.startsWith("--") && baseUrl === undefined) baseUrl = t; // positional url
4834
- else if (!t.startsWith("--") && modelArg === undefined) modelArg = t; // positional model
4835
- }
4836
- if (compat && compat !== "openai") {
4837
- console.log(`Only --compat openai is supported (got '${compat}') — jeo registers a single OpenAI-compatible endpoint.`);
4838
- continue;
4839
- }
4840
- if (!baseUrl) {
4841
- const cur = (await readGlobalConfig()).openaiBaseUrl;
4842
- console.log(cur ? `OpenAI-compatible base URL: ${cur}` : "No OpenAI-compatible base URL set.");
4843
- console.log("Set one with: /provider add --base-url <url> [--model <model>] · clear with: /provider add clear");
4844
- continue;
4847
+ continue;
4848
+ }
4849
+ // `/provider add …` → register a NAMED custom provider (--id / --preset), or, in the
4850
+ // legacy no-id spelling, rebind the built-in openai endpoint. All parsing/validation
4851
+ // lives in provider-slash.ts so it is unit-testable without a TUI.
4852
+ if (name === "add") {
4853
+ const parsedAdd = parseProviderAddArgs(tokens.slice(1));
4854
+ // `/provider add litellm --base-url …` — a bare preset name is accepted as --preset.
4855
+ if (!parsedAdd.preset && looksLikePreset(tokens[1])) parsedAdd.preset = tokens[1];
4856
+ const cfgNow = await readGlobalConfig();
4857
+ const plan = planProviderAdd(parsedAdd, cfgNow);
4858
+ logLines(plan.lines);
4859
+ if (plan.noop) continue;
4860
+ if (plan.legacyOpenaiBaseUrl !== undefined) {
4861
+ const url = plan.legacyOpenaiBaseUrl;
4862
+ await saveConfigPatch(() => ({ openaiBaseUrl: url ?? undefined }));
4845
4863
  }
4846
- const url = normalizeBaseUrl(baseUrl, "");
4847
- if (!url) {
4848
- console.log("Provide a base URL, e.g. /provider add --base-url http://localhost:1234/v1");
4849
- continue;
4864
+ if (plan.id && plan.config) {
4865
+ await saveConfigPatch(raw => ({
4866
+ customProviders: applyCustomProviderPatch(raw.customProviders, { addId: plan.id, addConfig: plan.config }),
4867
+ }));
4868
+ // Republish into the runtime registry before discovery runs, so the very next
4869
+ // /models probe already targets the new endpoint.
4870
+ await readGlobalConfig();
4850
4871
  }
4851
- await saveConfigPatch(() => ({ openaiBaseUrl: url }));
4852
- if (modelArg) {
4853
- const qualified = qualifyModelId(modelArg, "openai");
4854
- sessionModel = qualified;
4855
- await saveConfigPatch(raw => rememberModelPatch(raw, qualified));
4872
+ if (plan.selectModel) {
4873
+ sessionModel = plan.selectModel;
4874
+ await saveConfigPatch(raw => rememberModelPatch(raw, plan.selectModel!));
4856
4875
  await persistSessionModel();
4857
- console.log(`OpenAI-compatible endpoint set: ${url} · default model ${qualified} — saved to ~/.jeo/config.json.`);
4858
- } else {
4859
- console.log(`OpenAI-compatible endpoint set: ${url} — saved to ~/.jeo/config.json.`);
4876
+ console.log(`Active model → ${plan.selectModel}`);
4860
4877
  }
4878
+ const probeProvider = plan.id ?? "openai";
4861
4879
  const live = await refreshLiveModelsCache();
4862
- const forProvider = live.filter(r => r.provider === "openai");
4880
+ const forProvider = live.filter(r => r.provider === probeProvider);
4863
4881
  const pick = flattenModels(forProvider);
4864
4882
  if (pick.length) {
4865
4883
  lastPickIndex = pick;
@@ -4867,7 +4885,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4867
4885
  logLines(formatPickListWithCapabilities(pick, { cap: 12 }));
4868
4886
  } else {
4869
4887
  const failed = forProvider.find(r => !r.ok);
4870
- console.log(failed?.error ? ` could not list models: ${failed.error}` : " no models discovered yet — the endpoint may need a key (set OPENAI_API_KEY).");
4888
+ const envHint = plan.id
4889
+ ? `set ${plan.config?.apiKeyEnv ?? `${plan.id.replace(/[.\-]/g, "_").toUpperCase()}_API_KEY`}`
4890
+ : "set OPENAI_API_KEY";
4891
+ console.log(failed?.error ? ` could not list models: ${failed.error}` : ` no models discovered yet — the endpoint may need a key (${envHint}).`);
4871
4892
  }
4872
4893
  continue;
4873
4894
  }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * `jeo provider …` — manage providers from the shell, not just from inside the REPL.
3
+ *
4
+ * The slash command (`/provider add …`) already existed, but it is only reachable from
5
+ * an interactive session. Provisioning a machine, a CI runner, or a devcontainer means
6
+ * scripting it, so the same registry is exposed as a real subcommand with the SAME
7
+ * flags and the SAME planners (`provider-slash.ts`) — one implementation, two surfaces,
8
+ * so they can never drift.
9
+ *
10
+ * Beyond parity this adds `jeo provider test <id>`, a live endpoint probe. Registering a
11
+ * provider is cheap and silent; discovering at the first real inference call that the
12
+ * base URL was wrong (or the key env var is not exported in THIS shell) is not. `add`
13
+ * runs the probe automatically and reports the result without refusing the write — a
14
+ * user may legitimately register an endpoint before the key exists.
15
+ */
16
+ import { readGlobalConfig, saveConfigPatch } from "../agent/state";
17
+ import {
18
+ applyCustomProviderPatch,
19
+ formatCustomProviderList,
20
+ formatPresetsCommand,
21
+ looksLikePreset,
22
+ parseProviderAddArgs,
23
+ planProviderAdd,
24
+ planProviderRemove,
25
+ providerAddUsage,
26
+ } from "./launch/provider-slash";
27
+ import {
28
+ credentialSourceOf,
29
+ customProviderDef,
30
+ customProviderDefs,
31
+ normalizeProviderId,
32
+ } from "../ai/providers/custom-providers";
33
+ import { listProviderModels } from "../ai/model-discovery";
34
+ import { describeProvider } from "../ai/provider-status";
35
+
36
+ const USAGE = [
37
+ "Usage: jeo provider <command>",
38
+ "",
39
+ " list Show registered custom providers",
40
+ " add --id <id> --base-url <url> [...] Register an OpenAI/Anthropic-compatible provider",
41
+ " add --preset <preset> [--base-url <url>]",
42
+ " remove <id> Unregister a custom provider",
43
+ " presets List the built-in provider presets",
44
+ " test <id> Probe a provider's endpoint and credential",
45
+ "",
46
+ "Flags for add:",
47
+ " --id <id> Routing prefix + config key (e.g. my-proxy → my-proxy/<model>)",
48
+ " --base-url <url> API base URL (https://… or http://…)",
49
+ " --compat <protocol> openai (default) | anthropic",
50
+ " --api-key-env <ENV> Env var holding the key (default: <ID>_API_KEY)",
51
+ " --api-key <key> Literal key, stored in ~/.jeo/config.json (prefer --api-key-env)",
52
+ " --model a,b Known model ids for the offline pick-list",
53
+ " --label <name> Display name",
54
+ " --force Overwrite an existing provider with the same id",
55
+ ].join("\n");
56
+
57
+ export async function runProviderCommand(args: string[]): Promise<void> {
58
+ const sub = (args[0] ?? "").toLowerCase();
59
+ switch (sub) {
60
+ case "":
61
+ case "help":
62
+ case "--help":
63
+ case "-h":
64
+ console.log(USAGE);
65
+ return;
66
+ case "list":
67
+ case "ls":
68
+ return runList();
69
+ case "presets":
70
+ case "preset":
71
+ console.log(formatPresetsCommand().join("\n"));
72
+ return;
73
+ case "add":
74
+ return runAdd(args.slice(1));
75
+ case "remove":
76
+ case "rm":
77
+ case "delete":
78
+ return runRemove(args[1]);
79
+ case "test":
80
+ case "probe":
81
+ case "check":
82
+ return runTest(args[1]);
83
+ default:
84
+ console.log(`Unknown provider subcommand: ${sub}\n\n${USAGE}`);
85
+ process.exitCode = 1;
86
+ }
87
+ }
88
+
89
+ async function runList(): Promise<void> {
90
+ console.log(formatCustomProviderList(await readGlobalConfig()).join("\n"));
91
+ }
92
+
93
+ async function runAdd(rest: string[]): Promise<void> {
94
+ const parsed = parseProviderAddArgs(rest);
95
+ if (!parsed.preset && looksLikePreset(rest[0])) parsed.preset = rest[0];
96
+
97
+ const plan = planProviderAdd(parsed, await readGlobalConfig());
98
+ console.log(plan.lines.join("\n"));
99
+ if (plan.noop) {
100
+ // A usage dump is informational; a rejected registration is a scripting failure and
101
+ // must be visible to `set -e`. `--base-url`-less bare invocations print usage only.
102
+ if (rest.length > 0) process.exitCode = 1;
103
+ return;
104
+ }
105
+
106
+ if (plan.legacyOpenaiBaseUrl !== undefined) {
107
+ const url = plan.legacyOpenaiBaseUrl;
108
+ await saveConfigPatch(() => ({ openaiBaseUrl: url ?? undefined }));
109
+ }
110
+ if (plan.id && plan.config) {
111
+ await saveConfigPatch(raw => ({
112
+ customProviders: applyCustomProviderPatch(raw.customProviders, { addId: plan.id, addConfig: plan.config }),
113
+ }));
114
+ // Re-read so `withEnvOverlay` republishes the new provider into the runtime registry
115
+ // before the probe below resolves its credential.
116
+ await readGlobalConfig();
117
+ console.log("");
118
+ await probe(plan.id, { failOnError: false });
119
+ }
120
+ }
121
+
122
+ async function runRemove(rawId: string | undefined): Promise<void> {
123
+ const decision = planProviderRemove(rawId, await readGlobalConfig());
124
+ console.log(decision.lines.join("\n"));
125
+ if (!decision.removeId) {
126
+ if (rawId) process.exitCode = 1;
127
+ return;
128
+ }
129
+ await saveConfigPatch(raw => ({
130
+ customProviders: applyCustomProviderPatch(raw.customProviders, { removeId: decision.removeId }),
131
+ }));
132
+ await readGlobalConfig();
133
+ }
134
+
135
+ async function runTest(rawId: string | undefined): Promise<void> {
136
+ if (!rawId) {
137
+ const ids = customProviderDefs().map(d => d.name);
138
+ console.log(
139
+ ids.length
140
+ ? `Usage: jeo provider test <id>\nRegistered: ${ids.join(", ")}`
141
+ : "No custom providers registered. Add one with 'jeo provider add --id <id> --base-url <url>'.",
142
+ );
143
+ process.exitCode = 1;
144
+ return;
145
+ }
146
+ await probe(normalizeProviderId(rawId), { failOnError: true });
147
+ }
148
+
149
+ /**
150
+ * Probe one provider: report its credential source, then try to list models at its
151
+ * endpoint. Separating the two matters — "no key" and "wrong URL" need different fixes,
152
+ * and a single "failed" line sends users down the wrong path.
153
+ */
154
+ async function probe(id: string, opts: { failOnError: boolean }): Promise<void> {
155
+ // A fresh config read republishes the custom set, so a provider added moments ago in
156
+ // this same process is resolvable here.
157
+ const cfg = await readGlobalConfig();
158
+ const def = customProviderDef(id);
159
+ if (!def) {
160
+ const status = await describeProvider(id, cfg);
161
+ if (!status.ready && status.kind === "none") {
162
+ console.log(`No provider '${id}'. Run 'jeo provider list' or 'jeo provider add --id ${id} --base-url <url>'.`);
163
+ if (opts.failOnError) process.exitCode = 1;
164
+ return;
165
+ }
166
+ console.log(`'${id}' is a built-in provider — check it with 'jeo doctor'.`);
167
+ if (opts.failOnError) process.exitCode = 1;
168
+ return;
169
+ }
170
+
171
+ const source = credentialSourceOf(def);
172
+ console.log(`Probing ${id} → ${def.baseUrl} (${def.protocol ?? "openai"})`);
173
+ if (source === "none") {
174
+ console.log(` credential: MISSING — export ${def.apiKeyEnv}, or re-add with --api-key <key>.`);
175
+ } else {
176
+ console.log(` credential: ${source === "env" ? `env ${def.apiKeyEnv}` : "stored in config"}`);
177
+ }
178
+
179
+ const result = await listProviderModels(id, { timeoutMs: 8000 });
180
+ if (result.ok) {
181
+ // Discovery already returns ids the router accepts; `qualify` only guards the case
182
+ // where an endpoint echoes bare ids so the printed command is copy-pasteable.
183
+ const qualify = (m: string) => (m.startsWith(`${id}/`) ? m : `${id}/${m}`);
184
+ const shown = result.models.slice(0, 8).map(qualify);
185
+ console.log(` models : ${result.models.length} discovered${result.fallback ? " (from the static catalog — the live list endpoint was unusable)" : ""}`);
186
+ if (shown.length) console.log(` ${shown.join(", ")}${result.models.length > shown.length ? ", …" : ""}`);
187
+ const first = result.models[0];
188
+ console.log(` status : OK — use it with 'jeo launch --model ${first ? qualify(first) : `${id}/<model>`}'`);
189
+ return;
190
+ }
191
+
192
+ console.log(` status : FAILED — ${result.error ?? "no models returned"}`);
193
+ console.log(
194
+ source === "none"
195
+ ? ` next : set ${def.apiKeyEnv} and re-run 'jeo provider test ${id}'.`
196
+ : ` next : verify the base URL (a ${def.protocol === "anthropic" ? "/v1/models" : "/models"} route must exist under it) and that the key is valid for this endpoint.`,
197
+ );
198
+ if (opts.failOnError) process.exitCode = 1;
199
+ }
@@ -15,7 +15,9 @@
15
15
  */
16
16
  import { SLASH_COMMANDS, SLASH_COMMAND_DESCRIPTIONS } from "./slash";
17
17
  import { catalogIds } from "../../ai/model-catalog-compat";
18
- import { PROVIDER_NAMES } from "../../ai/provider-status";
18
+ import { PROVIDER_NAMES, allProviderNames } from "../../ai/provider-status";
19
+ import { PROVIDER_PRESET_IDS } from "../../ai/providers/provider-presets";
20
+ import { customProviderNames } from "../../ai/providers/custom-providers";
19
21
  import { SUBAGENT_ROLES } from "../../agent/subagents";
20
22
  import { skillNames } from "../../skills/catalog";
21
23
  import { listThemes } from "./themes";
@@ -35,6 +37,10 @@ export interface CompletionContext {
35
37
  skillNames?: string[];
36
38
  /** Live model ids for a given provider (for `/provider <p> <model>`). */
37
39
  modelsForProvider: (provider: string) => string[];
40
+ /** Preset ids offered after `/provider add --preset`. */
41
+ providerPresets: string[];
42
+ /** Ids of the user's registered custom providers (for `/provider remove <id>`). */
43
+ customProviders: string[];
38
44
  /** Sync path suggestions for free-text `@path` mentions (relative to cwd). */
39
45
  mentionPaths?: (prefix: string) => string[];
40
46
  }
@@ -66,9 +72,13 @@ export function staticCompletionContext(): Omit<CompletionContext, "liveModels"
66
72
  return {
67
73
  slashCommands: [...SLASH_COMMANDS],
68
74
  catalogModels: catalogIds(),
69
- providers: [...PROVIDER_NAMES],
75
+ // Custom providers are runtime state, so list from `allProviderNames()` — a user's
76
+ // own endpoint must be completable the same as a shipped one.
77
+ providers: [...allProviderNames()],
70
78
  roleIds: SUBAGENT_ROLES.map(r => r.id),
71
79
  thinkingLevels: [...THINKING_LEVELS],
80
+ providerPresets: [...PROVIDER_PRESET_IDS],
81
+ customProviders: [...customProviderNames()],
72
82
  };
73
83
  }
74
84
 
@@ -271,13 +281,25 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
271
281
  case "/fast":
272
282
  return argIndex === 0 ? finish(["on", "off", "status"], "subcommand") : { completions: [], token, kind: "none" };
273
283
  case "/provider": {
274
- // /provider is onboarding-only (gjc parity): login + add. Model/provider
275
- // switching completes under /model, not here.
284
+ // /provider is onboarding-only (gjc parity): login, key, and the custom-provider
285
+ // registry (add/list/remove/presets). Model switching completes under /model.
276
286
  const cloud = ["anthropic", "openai", "gemini", "antigravity"];
277
- if (argIndex === 0) return finish(["login", "add", "help"], "subcommand");
287
+ if (argIndex === 0) return finish(["login", "key", "add", "list", "remove", "presets", "help"], "subcommand");
278
288
  const sub = tokens[1]?.toLowerCase();
279
289
  if (sub === "login" || sub === "auth") return argIndex === 1 ? finish(cloud, "provider") : { completions: [], token, kind: "none" };
280
- if (sub === "add") return finish(["--base-url", "--model", "--compat", "clear"], "subcommand");
290
+ // Removal only ever targets a CUSTOM provider, so completing built-ins here would
291
+ // just offer choices the command refuses.
292
+ if (sub === "remove" || sub === "rm" || sub === "delete") {
293
+ return argIndex === 1 ? finish([...ctx.customProviders], "provider") : { completions: [], token, kind: "none" };
294
+ }
295
+ if (sub === "add") {
296
+ // Right after `--preset`, offer preset ids; otherwise the flag set.
297
+ if (tokens[argIndex]?.toLowerCase() === "--preset") return finish([...ctx.providerPresets], "subcommand");
298
+ return finish(
299
+ ["--id", "--base-url", "--compat", "--api-key-env", "--api-key", "--model", "--label", "--preset", "--force", "clear"],
300
+ "subcommand",
301
+ );
302
+ }
281
303
  return { completions: [], token, kind: "none" };
282
304
  }
283
305
  case "/logout":
@@ -7,7 +7,8 @@
7
7
  import { SelectList, renderSelectList, type SelectItem, type RenderSelectOptions } from "./select-list";
8
8
  import { catalogForProvider, type ModelCatalogEntry } from "../../ai/model-catalog-compat";
9
9
  import { companyLabel } from "../../ai/model-catalog";
10
- import { PROVIDER_NAMES, type ProviderStatus } from "../../ai/provider-status";
10
+ import { PROVIDER_NAMES, allProviderNames, type ProviderStatus } from "../../ai/provider-status";
11
+ import { authStateFor, sortRankedProviders } from "../../ai/provider-ranking";
11
12
  import type { ProviderName } from "../../ai/types";
12
13
 
13
14
  /** Human context-window size: 200000 → "200k", 1000000 → "1M", 0 → "". */
@@ -46,14 +47,23 @@ export function buildModelChoices(statuses: ProviderStatus[], opts: ModelPickerO
46
47
  const unicode = opts.unicode !== false;
47
48
  const includeUnready = opts.includeUnready !== false;
48
49
  const readyOf = new Map<ProviderName, boolean>(statuses.map(s => [s.name, s.ready]));
50
+ const statusOf = new Map<ProviderName, ProviderStatus>(statuses.map(s => [s.name, s]));
49
51
 
50
- // Provider order: ready first, then the canonical order.
51
- const providers = [...PROVIDER_NAMES].sort((a, b) => {
52
- const ra = readyOf.get(a) ? 0 : 1;
53
- const rb = readyOf.get(b) ? 0 : 1;
54
- if (ra !== rb) return ra - rb;
55
- return PROVIDER_NAMES.indexOf(a) - PROVIDER_NAMES.indexOf(b);
56
- });
52
+ // Provider order comes from the SHARED ranking (usable → broken → famous → rest), not
53
+ // this component's own list-index sort. That old sort ranked by `PROVIDER_NAMES.indexOf`,
54
+ // which returns -1 for a user-registered custom provider — floating it above the
55
+ // provider the user is actually logged into.
56
+ const providers = sortRankedProviders(
57
+ [...allProviderNames()].map(name => {
58
+ const status = statusOf.get(name);
59
+ return {
60
+ id: name,
61
+ label: companyLabel(name),
62
+ authState: status ? authStateFor(status) : ("none" as const),
63
+ custom: status?.custom,
64
+ };
65
+ }),
66
+ ).map(p => p.id as ProviderName);
57
67
 
58
68
  const items: SelectItem<string>[] = [];
59
69
  for (const provider of providers) {