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.
@@ -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).",
@@ -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
+ }