jeo-code 0.9.16 → 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.
@@ -18,7 +18,9 @@ import { antigravityAdapter } from "./providers/antigravity";
18
18
  import { kimiAdapter } from "./providers/kimi";
19
19
  import { makeOpenAICompatibleAdapter } from "./providers/openai-compatible";
20
20
  import { makeAnthropicCompatibleAdapter } from "./providers/anthropic-compatible";
21
- import { OPENAI_COMPAT_PROVIDERS } from "./providers/openai-compatible-catalog";
21
+ import { OPENAI_COMPAT_PROVIDERS, type OpenAICompatProviderDef } from "./providers/openai-compatible-catalog";
22
+ import { customProviderDefs, onCustomProvidersChanged, type CustomProviderDef } from "./providers/custom-providers";
23
+ import type { ProviderAdapter, ProviderName } from "./types";
22
24
 
23
25
  providerRegistry.register("anthropic", anthropicAdapter);
24
26
  providerRegistry.register("openai", openaiAdapter);
@@ -29,11 +31,58 @@ providerRegistry.register("lmstudio", lmstudioAdapter);
29
31
  providerRegistry.register("xai", xaiAdapter);
30
32
  providerRegistry.register("kimi", kimiAdapter);
31
33
 
34
+ /** Build the thin factory adapter for one catalog row, selected by wire protocol. */
35
+ function adapterFor(def: OpenAICompatProviderDef): ProviderAdapter {
36
+ return def.protocol === "anthropic"
37
+ ? makeAnthropicCompatibleAdapter({ name: def.name, baseUrl: def.baseUrl })
38
+ : makeOpenAICompatibleAdapter({ name: def.name, baseUrl: def.baseUrl, thinkingFormat: def.thinkingFormat });
39
+ }
40
+
32
41
  // gjc-style data-driven providers: every catalog entry gets a thin factory adapter,
33
42
  // selected by wire protocol. Add a provider by adding ONE catalog row.
34
43
  for (const def of OPENAI_COMPAT_PROVIDERS) {
35
- const adapter = def.protocol === "anthropic"
36
- ? makeAnthropicCompatibleAdapter({ name: def.name, baseUrl: def.baseUrl })
37
- : makeOpenAICompatibleAdapter({ name: def.name, baseUrl: def.baseUrl, thinkingFormat: def.thinkingFormat });
38
- providerRegistry.register(def.name, adapter);
44
+ providerRegistry.register(def.name, adapterFor(def));
39
45
  }
46
+
47
+ /** Every adapter registered above (built-ins + compiled-in catalog). Snapshotted BEFORE
48
+ * any custom provider is registered so it stays a pure built-in set. */
49
+ const BUILTIN_ADAPTER_NAMES: ReadonlySet<ProviderName> = new Set(providerRegistry.listProviders());
50
+
51
+ /** Custom-provider adapters currently registered, so a removal can be un-registered
52
+ * precisely (never touching a built-in that happens to share a name). */
53
+ const registeredCustom = new Set<ProviderName>();
54
+
55
+ /**
56
+ * (Re)register every user-defined provider from `config.customProviders`.
57
+ *
58
+ * Idempotent and diff-based: providers dropped from the config are unregistered so a
59
+ * removed endpoint stops resolving mid-session, and surviving ones are rebuilt so an
60
+ * edited base URL / protocol takes effect without restarting jeo.
61
+ */
62
+ export function syncCustomProviderAdapters(defs: readonly CustomProviderDef[] = customProviderDefs()): void {
63
+ // A custom row whose id collides with a shipped provider is INERT: `openaiCompatDef`
64
+ // already prefers the built-in, so registering its adapter would silently reroute a
65
+ // built-in provider — and, worse, removing that row later would unregister the
66
+ // built-in adapter entirely. Drop collisions here so the two layers agree.
67
+ const usable = defs.filter(d => !BUILTIN_ADAPTER_NAMES.has(d.name));
68
+ const next = new Set<ProviderName>(usable.map(d => d.name));
69
+ for (const name of registeredCustom) {
70
+ if (!next.has(name)) {
71
+ providerRegistry.unregister(name);
72
+ registeredCustom.delete(name);
73
+ }
74
+ }
75
+ for (const def of usable) {
76
+ providerRegistry.register(def.name, adapterFor(def));
77
+ registeredCustom.add(def.name);
78
+ }
79
+ }
80
+
81
+ // Keep the adapter set in lockstep with the custom-provider store: any code path that
82
+ // calls `setCustomProviders` (startup config load, `/provider add`, `/provider remove`)
83
+ // re-registers adapters without having to remember to do it itself.
84
+ onCustomProvidersChanged(defs => syncCustomProviderAdapters(defs));
85
+
86
+ // Pick up providers registered BEFORE this module was first imported (config load
87
+ // races module import order in short-lived commands like `jeo doctor`).
88
+ syncCustomProviderAdapters();
package/src/ai/types.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import type { Credential } from "../auth";
2
2
 
3
- export type ProviderName = "anthropic" | "openai" | "gemini" | "antigravity" | "ollama" | "lmstudio" | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax" | "tencent" | "deepinfra" | "litellm";
3
+ /** Providers compiled into jeo (built-ins + the OpenAI/Anthropic-compatible catalog). */
4
+ export type BuiltinProviderName = "anthropic" | "openai" | "gemini" | "antigravity" | "ollama" | "lmstudio" | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax" | "tencent" | "deepinfra" | "litellm";
5
+
6
+ /**
7
+ * A provider id at runtime: a compiled-in name, OR a user-registered custom provider id
8
+ * (`config.customProviders`). The `string & {}` arm keeps editor autocomplete on the
9
+ * literal union while allowing the open set custom providers require — without it,
10
+ * every registry/lookup/record call site would need a cast to accept a user id.
11
+ */
12
+ export type ProviderName = BuiltinProviderName | (string & {});
4
13
 
5
14
 
6
15
  /** An image attached to a (user) message — base64 payload + IANA media type. */
package/src/cli/runner.ts CHANGED
@@ -32,6 +32,15 @@ export const COMMANDS: readonly CommandSpec[] = [
32
32
  return args => m.runAuthCommand(args);
33
33
  },
34
34
  },
35
+ {
36
+ name: "provider",
37
+ summary: "Register and probe OpenAI/Anthropic-compatible providers (custom endpoints + presets).",
38
+ usage: "provider [list|add|remove|presets|test] [--id <id>] [--base-url <url>] [--preset <name>]",
39
+ loader: async () => {
40
+ const m = await import("../commands/provider");
41
+ return args => m.runProviderCommand(args);
42
+ },
43
+ },
35
44
  {
36
45
  name: "export",
37
46
  summary: "Export a saved session transcript to Markdown (or --json).",
@@ -5,7 +5,8 @@ import { type StoredOAuth } from "../agent/state";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { readGlobalConfig } from "../agent/state";
7
7
  import { jeoEnv } from "../util/env";
8
- import { listProviderModels, CODEX_MODELS_URL } from "../ai/model-discovery";
8
+ import { listProviderModels } from "../ai/model-discovery";
9
+
9
10
  import { writeModelCache } from "../ai/model-cache";
10
11
  import {
11
12
  OAUTH_FLOWS,
@@ -123,7 +124,7 @@ async function runAuthLogin(rest: string[]): Promise<void> {
123
124
  rl.close();
124
125
  await loginOAuth(chosen, manualToken.trim());
125
126
  console.log(`[SUCCESS] Stored manual OAuth bearer for ${chosen} (no auto-refresh).`);
126
- if (chosen === "openai") await reportOpenAiCodexModels();
127
+ if (LIVE_MODEL_REPORT_PROVIDERS.has(chosen)) await reportLiveModels(chosen);
127
128
  return;
128
129
  }
129
130
 
@@ -131,7 +132,7 @@ async function runAuthLogin(rest: string[]): Promise<void> {
131
132
  const { email } = await interactiveOAuthLogin(chosen, rl);
132
133
  console.log(`\n[SUCCESS] OAuth login complete for ${chosen}${email ? ` (${email})` : ""}.`);
133
134
  console.log("Stored access + refresh tokens in ~/.jeo/config.json; jeo will auto-refresh on expiry.");
134
- if (chosen === "openai") await reportOpenAiCodexModels();
135
+ if (LIVE_MODEL_REPORT_PROVIDERS.has(chosen)) await reportLiveModels(chosen);
135
136
  } catch (err) {
136
137
  console.log(`\n[FAILED] ${(err as Error).message}`);
137
138
  console.log("Tip: paste the redirect URL when prompted, or use 'jeo auth login <provider> --token <bearer>'.");
@@ -142,30 +143,35 @@ async function runAuthLogin(rest: string[]): Promise<void> {
142
143
  }
143
144
 
144
145
  /**
145
- * Best-effort, timeout-bounded live Codex model check run immediately after a
146
- * successful `jeo auth login openai`. Uses `preferOAuth` so the report reflects
147
- * the account that was JUST logged in — never the catalog fallback, and never
148
- * silently swapped to an unrelated pre-existing `providers.openai` API key.
149
- * A failure here is NEVER fatal: login has already succeeded and is not rolled
150
- * back by this check. Prints only model ids/count — no tokens/secrets.
146
+ * Best-effort, timeout-bounded live model check run immediately after a
147
+ * successful `jeo auth login <provider>` for the providers whose model set
148
+ * changes fastest (openai/Codex, anthropic, antigravity/Cloud Code Assist).
149
+ * Uses `preferOAuth` so the report reflects the account that was JUST logged
150
+ * in — never the catalog fallback, and never silently swapped to an
151
+ * unrelated pre-existing API key. A failure here is NEVER fatal: login has
152
+ * already succeeded and is not rolled back by this check. Prints only model
153
+ * ids/count — no tokens/secrets.
151
154
  */
152
- async function reportOpenAiCodexModels(): Promise<void> {
155
+ const LIVE_MODEL_REPORT_PROVIDERS = new Set<AuthProvider>(["openai", "anthropic", "antigravity"]);
156
+
157
+ async function reportLiveModels(provider: AuthProvider): Promise<void> {
153
158
  try {
154
- const result = await listProviderModels("openai", { preferOAuth: true });
159
+ const result = await listProviderModels(provider, { preferOAuth: true });
155
160
  if (result.ok) {
156
161
  // Persist immediately: the very next `jeo` launch then knows this account's
157
162
  // real model set (including ids newer than the maintained static snapshot)
158
163
  // before it makes any network call.
159
164
  await writeModelCache([result]);
160
- console.log(`Live Codex models (${result.models.length}): ${result.models.join(", ")}`);
165
+ console.log(`Live ${provider} models (${result.models.length}): ${result.models.join(", ")}`);
161
166
  } else {
162
- console.log(`[WARN] Could not verify live Codex models from ${CODEX_MODELS_URL}: ${result.error ?? "unknown error"} (non-fatal — login succeeded).`);
167
+ console.log(`[WARN] Could not verify live ${provider} models: ${result.error ?? "unknown error"} (non-fatal — login succeeded).`);
163
168
  }
164
169
  } catch (err) {
165
- console.log(`[WARN] Could not verify live Codex models from ${CODEX_MODELS_URL}: ${(err as Error).message} (non-fatal — login succeeded).`);
170
+ console.log(`[WARN] Could not verify live ${provider} models: ${(err as Error).message} (non-fatal — login succeeded).`);
166
171
  }
167
172
  }
168
173
 
174
+
169
175
  async function runAuthImport(rest: string[]): Promise<void> {
170
176
  const provider = rest.find(a => a !== "--import") as AuthProvider | undefined;
171
177
  if (!provider) {
@@ -0,0 +1,413 @@
1
+ /**
2
+ * `/provider add|list|remove|presets` — the custom-provider onboarding surface.
3
+ *
4
+ * Everything here is a PURE function over parsed tokens + a config snapshot, returning
5
+ * the lines to print and the config patch to persist. The slash handler in `launch.ts`
6
+ * only does the IO (read config, save patch, refresh the model cache), which is what
7
+ * makes this whole flow unit-testable without a TUI, a network, or a real `~/.jeo`.
8
+ *
9
+ * Backwards compatibility matters here: `/provider add --base-url <url>` (no `--id`)
10
+ * kept meaning "rebind the built-in openai provider" for two releases, and users have
11
+ * that in their notes/scripts. That spelling still does exactly what it did; the new
12
+ * named-provider behavior is opt-in via `--id` or `--preset`.
13
+ */
14
+ import type { Config } from "../../agent/state";
15
+ import {
16
+ assertValidProviderId,
17
+ normalizeCustomBaseUrl,
18
+ normalizeProviderId,
19
+ parseModelList,
20
+ parseProviderCompatibility,
21
+ redactSecret,
22
+ type CustomProviderConfig,
23
+ type ProviderCompatibility,
24
+ } from "../../ai/providers/custom-providers";
25
+ import {
26
+ expandProviderPreset,
27
+ findProviderPreset,
28
+ formatProviderPresetList,
29
+ } from "../../ai/providers/provider-presets";
30
+ import { isBuiltinCompatProvider } from "../../ai/providers/openai-compatible-catalog";
31
+
32
+ /** Flags accepted by `/provider add`. */
33
+ export interface ParsedProviderAdd {
34
+ id?: string;
35
+ baseUrl?: string;
36
+ compat?: string;
37
+ apiKeyEnv?: string;
38
+ apiKey?: string;
39
+ label?: string;
40
+ defaultModel?: string;
41
+ models: string[];
42
+ preset?: string;
43
+ force: boolean;
44
+ /** `/provider add clear` — legacy: drop `openaiBaseUrl`. */
45
+ clear: boolean;
46
+ /** Unrecognized `--flag`s, reported instead of silently ignored. */
47
+ unknown: string[];
48
+ }
49
+
50
+ /**
51
+ * Parse `/provider add` tokens. Bare positionals stay supported (`/provider add <url>
52
+ * <model>`) because the previous release accepted them; a positional that looks like a
53
+ * URL becomes `--base-url`, anything else becomes the default model.
54
+ */
55
+ export function parseProviderAddArgs(tokens: readonly string[]): ParsedProviderAdd {
56
+ const out: ParsedProviderAdd = { models: [], force: false, clear: false, unknown: [] };
57
+ const rest = [...tokens];
58
+ if ((rest[0] ?? "").toLowerCase() === "clear") return { ...out, clear: true };
59
+ for (let i = 0; i < rest.length; i++) {
60
+ const t = rest[i]!;
61
+ const next = (): string | undefined => rest[++i];
62
+ switch (t) {
63
+ case "--id":
64
+ case "--name":
65
+ out.id = next();
66
+ break;
67
+ case "--base-url":
68
+ case "--url":
69
+ out.baseUrl = next();
70
+ break;
71
+ case "--compat":
72
+ case "--protocol":
73
+ out.compat = (next() ?? "").toLowerCase();
74
+ break;
75
+ case "--api-key-env":
76
+ case "--key-env":
77
+ out.apiKeyEnv = next();
78
+ break;
79
+ case "--api-key":
80
+ case "--key":
81
+ out.apiKey = next();
82
+ break;
83
+ case "--label":
84
+ out.label = next();
85
+ break;
86
+ case "--default-model":
87
+ out.defaultModel = next();
88
+ break;
89
+ case "--model":
90
+ case "--models":
91
+ {
92
+ const v = next();
93
+ if (v) out.models.push(v);
94
+ }
95
+ break;
96
+ case "--preset":
97
+ out.preset = next();
98
+ break;
99
+ case "--force":
100
+ out.force = true;
101
+ break;
102
+ default:
103
+ if (t.startsWith("--")) out.unknown.push(t);
104
+ else if (out.baseUrl === undefined && /^https?:\/\//i.test(t)) out.baseUrl = t;
105
+ else if (out.defaultModel === undefined) out.defaultModel = t;
106
+ break;
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ export interface ProviderAddResult {
113
+ /** Lines to print. */
114
+ lines: string[];
115
+ /** Provider id that was registered, when a custom provider was created. */
116
+ id?: string;
117
+ /** The entry to persist under `config.customProviders[id]`. */
118
+ config?: CustomProviderConfig;
119
+ /** Legacy path: set/clear `config.openaiBaseUrl` instead of adding a named provider. */
120
+ legacyOpenaiBaseUrl?: string | null;
121
+ /** Model id to make the active default, when the user asked for one. */
122
+ selectModel?: string;
123
+ /** True when nothing should be persisted (usage output / validation error). */
124
+ noop: boolean;
125
+ }
126
+
127
+ const USAGE: readonly string[] = [
128
+ "Usage:",
129
+ " /provider add --id <id> --base-url <url> [--compat openai|anthropic]",
130
+ " [--api-key-env <ENV>] [--api-key <key>] [--model a,b] [--label <name>] [--force]",
131
+ " /provider add --preset <preset> [--base-url <url>] [--id <id>] · list presets: /provider presets",
132
+ " /provider list · show registered custom providers",
133
+ " /provider remove <id> · unregister a custom provider",
134
+ " /provider add --base-url <url> · legacy: rebind the built-in openai provider",
135
+ " /provider add clear · legacy: clear that openai base URL",
136
+ ];
137
+
138
+ export function providerAddUsage(): readonly string[] {
139
+ return USAGE;
140
+ }
141
+
142
+ /**
143
+ * Turn parsed flags + the current config into a decision. Never throws for user error —
144
+ * every invalid combination comes back as printable `lines` with `noop: true`, because
145
+ * this runs inside the REPL loop where an exception would kill the session.
146
+ */
147
+ export function planProviderAdd(parsed: ParsedProviderAdd, config: Pick<Config, "customProviders" | "openaiBaseUrl">): ProviderAddResult {
148
+ const existing = config.customProviders ?? {};
149
+
150
+ if (parsed.unknown.length) {
151
+ return { lines: [`Unknown flag(s): ${parsed.unknown.join(", ")}`, ...USAGE], noop: true };
152
+ }
153
+
154
+ // ---- legacy: `/provider add clear` -------------------------------------
155
+ if (parsed.clear) {
156
+ return {
157
+ lines: ["OpenAI-compatible base URL cleared — saved to ~/.jeo/config.json.", "(custom providers are unaffected — remove one with /provider remove <id>)"],
158
+ legacyOpenaiBaseUrl: null,
159
+ noop: false,
160
+ };
161
+ }
162
+
163
+ // ---- preset path -------------------------------------------------------
164
+ if (parsed.preset) {
165
+ let expanded;
166
+ try {
167
+ expanded = expandProviderPreset({
168
+ preset: parsed.preset,
169
+ id: parsed.id,
170
+ baseUrl: parsed.baseUrl,
171
+ apiKeyEnv: parsed.apiKeyEnv,
172
+ apiKey: parsed.apiKey,
173
+ models: parsed.models.length ? parseModelList(parsed.models) : undefined,
174
+ });
175
+ } catch (err) {
176
+ return { lines: [(err as Error).message], noop: true };
177
+ }
178
+ const collision = checkCollision(expanded.id, existing, parsed.force);
179
+ if (collision) return { lines: collision, noop: true };
180
+ const cfg: CustomProviderConfig = {
181
+ ...expanded.config,
182
+ label: parsed.label?.trim() || expanded.config.label,
183
+ defaultModel: parsed.defaultModel?.trim() || expanded.config.defaultModel,
184
+ };
185
+ return {
186
+ lines: describeRegistration(expanded.id, cfg, `preset ${expanded.preset.id}`),
187
+ id: expanded.id,
188
+ config: cfg,
189
+ selectModel: selectableModel(expanded.id, cfg),
190
+ noop: false,
191
+ };
192
+ }
193
+
194
+ // ---- named custom provider --------------------------------------------
195
+ if (parsed.id) {
196
+ let id: string;
197
+ try {
198
+ id = assertValidProviderId(parsed.id);
199
+ } catch (err) {
200
+ return { lines: [(err as Error).message], noop: true };
201
+ }
202
+ if (isBuiltinCompatProvider(id)) {
203
+ return {
204
+ lines: [
205
+ `'${id}' is already a built-in provider — it does not need registering.`,
206
+ `Set its key with ${id.toUpperCase().replace(/[.\-]/g, "_")}_API_KEY, or pick a different --id.`,
207
+ ],
208
+ noop: true,
209
+ };
210
+ }
211
+ if (!parsed.baseUrl) {
212
+ return { lines: [`--base-url is required when adding provider '${id}'.`, ...USAGE], noop: true };
213
+ }
214
+ let baseUrl: string;
215
+ try {
216
+ baseUrl = normalizeCustomBaseUrl(parsed.baseUrl);
217
+ } catch (err) {
218
+ return { lines: [(err as Error).message], noop: true };
219
+ }
220
+ let protocol: ProviderCompatibility = "openai";
221
+ if (parsed.compat) {
222
+ try {
223
+ protocol = parseProviderCompatibility(parsed.compat);
224
+ } catch (err) {
225
+ return { lines: [(err as Error).message], noop: true };
226
+ }
227
+ }
228
+ const collision = checkCollision(id, existing, parsed.force);
229
+ if (collision) return { lines: collision, noop: true };
230
+ const models = parseModelList(parsed.models);
231
+ const cfg: CustomProviderConfig = {
232
+ label: parsed.label?.trim() || undefined,
233
+ baseUrl,
234
+ protocol,
235
+ apiKeyEnv: parsed.apiKeyEnv?.trim() || undefined,
236
+ apiKey: parsed.apiKey?.trim() || undefined,
237
+ models: models.length ? models : undefined,
238
+ defaultModel: parsed.defaultModel?.trim() || models[0],
239
+ };
240
+ return {
241
+ lines: describeRegistration(id, cfg, "custom"),
242
+ id,
243
+ config: cfg,
244
+ selectModel: selectableModel(id, cfg),
245
+ noop: false,
246
+ };
247
+ }
248
+
249
+ // ---- legacy: `/provider add --base-url <url>` (no id) -------------------
250
+ if (parsed.baseUrl) {
251
+ if (parsed.compat && parsed.compat !== "openai") {
252
+ return {
253
+ lines: [
254
+ `--compat ${parsed.compat} needs a named provider: add --id <id> so it gets its own routing prefix.`,
255
+ ...USAGE,
256
+ ],
257
+ noop: true,
258
+ };
259
+ }
260
+ let url: string;
261
+ try {
262
+ url = normalizeCustomBaseUrl(parsed.baseUrl);
263
+ } catch (err) {
264
+ return { lines: [(err as Error).message], noop: true };
265
+ }
266
+ const lines = [
267
+ `OpenAI-compatible endpoint set: ${url} — saved to ~/.jeo/config.json.`,
268
+ `Tip: '/provider add --id <name> --base-url ${url}' registers it as its own provider (own prefix + models) instead of rebinding openai/.`,
269
+ ];
270
+ return {
271
+ lines,
272
+ legacyOpenaiBaseUrl: url,
273
+ selectModel: parsed.defaultModel ? qualify("openai", parsed.defaultModel) : undefined,
274
+ noop: false,
275
+ };
276
+ }
277
+
278
+ // ---- bare `/provider add` ---------------------------------------------
279
+ const current = config.openaiBaseUrl;
280
+ return {
281
+ lines: [
282
+ current ? `OpenAI-compatible base URL: ${current}` : "No OpenAI-compatible base URL set.",
283
+ ...USAGE,
284
+ ],
285
+ noop: true,
286
+ };
287
+ }
288
+
289
+ function checkCollision(id: string, existing: Record<string, CustomProviderConfig>, force: boolean): string[] | undefined {
290
+ if (!existing[id] || force) return undefined;
291
+ return [
292
+ `Provider '${id}' already exists (${existing[id]!.baseUrl}).`,
293
+ // Surface-neutral wording: this text is printed by BOTH `/provider` (REPL) and
294
+ // `jeo provider` (shell), so it must not name a prefix that is wrong in one of them.
295
+ `Re-run with --force to overwrite it, or remove it first: provider remove ${id}`,
296
+ ];
297
+ }
298
+
299
+ function qualify(id: string, model: string): string {
300
+ const bare = model.startsWith(`${id}/`) ? model.slice(id.length + 1) : model;
301
+ return `${id}/${bare}`;
302
+ }
303
+
304
+ function selectableModel(id: string, cfg: CustomProviderConfig): string | undefined {
305
+ const bare = cfg.defaultModel ?? cfg.models?.[0];
306
+ return bare ? qualify(id, bare) : undefined;
307
+ }
308
+
309
+ function describeRegistration(id: string, cfg: CustomProviderConfig, origin: string): string[] {
310
+ const lines = [
311
+ `Registered provider '${id}' (${origin}) — saved to ~/.jeo/config.json.`,
312
+ ` endpoint : ${cfg.baseUrl}`,
313
+ ` protocol : ${cfg.protocol ?? "openai"}`,
314
+ ];
315
+ if (cfg.apiKey) lines.push(` key : ${redactSecret(cfg.apiKey)} (stored in config)`);
316
+ lines.push(` key env : ${cfg.apiKeyEnv ?? `${id.replace(/[.\-]/g, "_").toUpperCase()}_API_KEY`}`);
317
+ if (cfg.models?.length) lines.push(` models : ${cfg.models.map(m => `${id}/${m}`).join(", ")}`);
318
+ lines.push(`Use it with: /model ${selectableModel(id, cfg) ?? `${id}/<model>`} (or: jeo launch --model ${selectableModel(id, cfg) ?? `${id}/<model>`})`);
319
+ return lines;
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // list / remove / presets
324
+ // ---------------------------------------------------------------------------
325
+
326
+ export interface ProviderRemoveResult {
327
+ lines: string[];
328
+ /** Id to delete from `config.customProviders`; absent when nothing should change. */
329
+ removeId?: string;
330
+ }
331
+
332
+ export function planProviderRemove(
333
+ rawId: string | undefined,
334
+ config: Pick<Config, "customProviders">,
335
+ ): ProviderRemoveResult {
336
+ const existing = config.customProviders ?? {};
337
+ const ids = Object.keys(existing);
338
+ if (!rawId) {
339
+ return {
340
+ lines: ids.length
341
+ ? ["Usage: provider remove <id>", `Registered: ${ids.join(", ")}`]
342
+ : ["No custom providers registered. Add one with 'provider add --id <id> --base-url <url>'."],
343
+ };
344
+ }
345
+ const id = normalizeProviderId(rawId);
346
+ if (!existing[id]) {
347
+ if (isBuiltinCompatProvider(id)) {
348
+ return { lines: [`'${id}' is a built-in provider and cannot be removed.`] };
349
+ }
350
+ return {
351
+ lines: [
352
+ `No custom provider '${id}'.`,
353
+ ids.length ? `Registered: ${ids.join(", ")}` : "Nothing registered yet.",
354
+ ],
355
+ };
356
+ }
357
+ return {
358
+ lines: [
359
+ `Removed custom provider '${id}' (${existing[id]!.baseUrl}) — saved to ~/.jeo/config.json.`,
360
+ `Models under '${id}/' no longer resolve; pick another with /model.`,
361
+ ],
362
+ removeId: id,
363
+ };
364
+ }
365
+
366
+ /** Rendered `/provider list` for the custom set (built-ins stay in the main panel). */
367
+ export function formatCustomProviderList(
368
+ config: Pick<Config, "customProviders">,
369
+ env: NodeJS.ProcessEnv = process.env,
370
+ ): string[] {
371
+ const entries = Object.entries(config.customProviders ?? {});
372
+ if (!entries.length) {
373
+ return [
374
+ "No custom providers registered.",
375
+ "Add one: provider add --id my-proxy --base-url https://api.example.com/v1",
376
+ "Or use a preset: provider add --preset litellm --base-url http://localhost:4000/v1",
377
+ ];
378
+ }
379
+ const lines = [`Custom providers (${entries.length}):`];
380
+ for (const [id, cfg] of entries) {
381
+ const envVar = cfg.apiKeyEnv || `${id.replace(/[.\-]/g, "_").toUpperCase()}_API_KEY`;
382
+ const source = env[envVar] ? `env ${envVar}` : cfg.apiKey ? `config ${redactSecret(cfg.apiKey)}` : `MISSING (set ${envVar})`;
383
+ const models = cfg.models?.length ? ` · ${cfg.models.length} model(s)` : "";
384
+ const preset = cfg.preset ? ` · preset ${cfg.preset}` : "";
385
+ lines.push(` ${id} [${cfg.protocol ?? "openai"}] ${cfg.baseUrl}`);
386
+ lines.push(` key: ${source}${models}${preset}`);
387
+ }
388
+ return lines;
389
+ }
390
+
391
+ /** Rendered `/provider presets`. */
392
+ export function formatPresetsCommand(): string[] {
393
+ return [
394
+ "Provider presets (use with: provider add --preset <id> [--base-url <url>]):",
395
+ ...formatProviderPresetList(),
396
+ ];
397
+ }
398
+
399
+ /** True when `token` names a known preset — used to accept `/provider add <preset>`. */
400
+ export function looksLikePreset(token: string | undefined): boolean {
401
+ return !!token && !token.startsWith("--") && findProviderPreset(token) !== undefined;
402
+ }
403
+
404
+ /** Apply an add/remove decision to the `customProviders` map, returning a NEW map. */
405
+ export function applyCustomProviderPatch(
406
+ existing: Record<string, CustomProviderConfig> | undefined,
407
+ change: { addId?: string; addConfig?: CustomProviderConfig; removeId?: string },
408
+ ): Record<string, CustomProviderConfig> | undefined {
409
+ const next: Record<string, CustomProviderConfig> = { ...(existing ?? {}) };
410
+ if (change.removeId) delete next[change.removeId];
411
+ if (change.addId && change.addConfig) next[change.addId] = change.addConfig;
412
+ return Object.keys(next).length ? next : undefined;
413
+ }