@bytetrue/pi-vendor 0.1.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/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @bytetrue/pi-vendor
2
+
3
+ Pi extension for managing custom providers in `~/.pi/agent/models.json`.
4
+
5
+ Use `/vendor` to open a provider list, edit a provider draft, manage its models in a separate model-list flow, and save back to `models.json` only when you are ready.
6
+
7
+ - Provider edits stay in memory until save.
8
+ - Manual model IDs, local template matching, and OpenAI-compatible `/models` imports all use the same enrichment flow.
9
+ - The installed Pi official model catalog is checked first; local templates and safe defaults only fill gaps.
10
+ - Set `PI_CODING_AGENT_DIR` to redirect the agent dir in tests or backup workflows.
11
+ - After saving, open `/model` if you want pi to refresh the available model list.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@bytetrue/pi-vendor",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: wizard for managing custom providers in ~/.pi/agent/models.json.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "models.json",
9
+ "provider",
10
+ "vendor",
11
+ "llm",
12
+ "agent"
13
+ ],
14
+ "type": "module",
15
+ "license": "MIT",
16
+ "author": "byte",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ByteTrue/pi-package-mono.git",
20
+ "directory": "packages/pi-vendor"
21
+ },
22
+ "files": [
23
+ "src/**",
24
+ "!src/**/*.test.ts",
25
+ "README.md"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./src/index.ts"
30
+ ]
31
+ },
32
+ "scripts": {
33
+ "test": "vitest run",
34
+ "typecheck": "tsc --noEmit"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "typescript": "^5.0.0",
42
+ "vitest": "^3.0.0"
43
+ }
44
+ }
package/src/command.ts ADDED
@@ -0,0 +1,595 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { createCustomInput, createCustomSelect } from "./custom-select.js";
4
+ import { enrichModelId } from "./enrich.js";
5
+ import { fuzzyFilter } from "./fuzzy.js";
6
+ import {
7
+ createNewProviderDraft,
8
+ createProviderDraft,
9
+ getModelsJsonPath,
10
+ readModelsJson,
11
+ type ModelsJson,
12
+ type ProviderConfig,
13
+ type ProviderDraft,
14
+ type ProviderModelConfig,
15
+ upsertProvider,
16
+ writeModelsJson,
17
+ } from "./models-json.js";
18
+ import { formatOfficialCandidate, groupOfficialModelsById, listAllOfficialModels, loadOfficialCatalog, stripOfficialRoutingFields } from "./official-catalog.js";
19
+ import { fetchOpenAIModelIds } from "./openai-models.js";
20
+
21
+ const COMMAND_NAME = "vendor";
22
+ const VENDOR_OVERLAY_OPTIONS = { anchor: "center", width: 92 } as const;
23
+
24
+ const PROVIDER_MENU = {
25
+ editKey: "Edit provider key",
26
+ editName: "Edit display name",
27
+ editBaseUrl: "Edit base URL",
28
+ editApiKey: "Edit API key / env reference",
29
+ editApiFormat: "Edit API format",
30
+ editAuthHeader: "Edit auth header flag",
31
+ editCompat: "Edit compatibility JSON",
32
+ manageModels: "Manage models",
33
+ preview: "Preview provider JSON",
34
+ save: "Save provider",
35
+ cancel: "Cancel",
36
+ } as const;
37
+
38
+ const MODEL_MENU = {
39
+ addManual: "Add manual model id",
40
+ importModels: "Import from /models endpoint",
41
+ remove: "Remove model",
42
+ replace: "Replace/edit model JSON",
43
+ preview: "Preview selected models",
44
+ back: "Back to provider form",
45
+ } as const;
46
+
47
+ function cloneJson<T>(value: T): T {
48
+ return JSON.parse(JSON.stringify(value)) as T;
49
+ }
50
+
51
+ function providerLabel(key: string, config: ProviderConfig): string {
52
+ const name = config.name?.trim();
53
+ const baseUrl = config.baseUrl?.trim();
54
+ return [key, name ? `(${name})` : null, baseUrl ? `- ${baseUrl}` : null].filter(Boolean).join(" ");
55
+ }
56
+
57
+ function modelLabel(index: number, model: ProviderModelConfig): string {
58
+ const name = model.name?.trim();
59
+ return `${index + 1}. ${model.id}${name && name !== model.id ? ` - ${name}` : ""}`;
60
+ }
61
+
62
+ function modelList(config: ProviderConfig): ProviderModelConfig[] {
63
+ return Array.isArray(config.models) ? config.models.map((model) => cloneJson(model)) : [];
64
+ }
65
+
66
+ function upsertModel(models: ProviderModelConfig[], model: ProviderModelConfig): ProviderModelConfig[] {
67
+ const next = models.map((entry) => cloneJson(entry));
68
+ const index = next.findIndex((entry) => entry.id === model.id);
69
+ if (index >= 0) {
70
+ next[index] = cloneJson(model);
71
+ return next;
72
+ }
73
+ next.push(cloneJson(model));
74
+ return next;
75
+ }
76
+
77
+ function removeModelAtIndex(models: ProviderModelConfig[], index: number): ProviderModelConfig[] {
78
+ return models.filter((_, current) => current !== index).map((entry) => cloneJson(entry));
79
+ }
80
+
81
+ function replaceModelAtIndex(models: ProviderModelConfig[], index: number, model: ProviderModelConfig): ProviderModelConfig[] {
82
+ const next = removeModelAtIndex(models, index);
83
+ return upsertModel(next, model);
84
+ }
85
+
86
+ /**
87
+ * Custom select with wrap-around navigation and pagination using ctx.ui.custom().
88
+ * - Up/down arrows: navigate within current page (with wrap-around)
89
+ * - Left/right arrows: change page
90
+ * - Enter: select current item
91
+ * - Escape: go back
92
+ */
93
+ type SelectResult<T extends string> = { type: "select"; value: T } | null;
94
+
95
+ async function customSelect<T extends string>(ctx: any, title: string, items: string[], defaultValue?: string, escapeLabel?: string): Promise<SelectResult<T>> {
96
+ if (items.length === 0) return null;
97
+ return ctx.ui.custom(
98
+ createCustomSelect<T>({ title, items, defaultValue, maxVisible: 10, escapeLabel }),
99
+ {
100
+ overlay: true,
101
+ overlayOptions: VENDOR_OVERLAY_OPTIONS,
102
+ },
103
+ );
104
+ }
105
+
106
+ /** Helper to extract string value from customSelect result */
107
+ function selectValue(result: SelectResult<string>): string | null {
108
+ if (result && result.type === "select") return result.value;
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Custom input using ctx.ui.custom() with border.
114
+ */
115
+ async function customInput(ctx: any, title: string, placeholder?: string, defaultValue?: string): Promise<string | null> {
116
+ return ctx.ui.custom(
117
+ createCustomInput({ title, placeholder, defaultValue }),
118
+ {
119
+ overlay: true,
120
+ overlayOptions: VENDOR_OVERLAY_OPTIONS,
121
+ },
122
+ );
123
+ }
124
+
125
+ async function promptInput(ctx: any, title: string, current: string, hint: string): Promise<string | null> {
126
+ const value = await customInput(ctx, title, hint ? `${hint}${current ? ` (current: ${current})` : ""}` : current, current);
127
+ if (value == null) return null;
128
+ const trimmed = value.trim();
129
+ return trimmed.length > 0 ? trimmed : current;
130
+ }
131
+
132
+ async function promptJsonObject<T extends object>(ctx: any, title: string, current: T): Promise<T | null | undefined> {
133
+ const text = await ctx.ui.editor(title, `${JSON.stringify(current, null, 2)}\n`);
134
+ if (text == null) return null;
135
+ try {
136
+ const parsed = JSON.parse(text);
137
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
138
+ throw new Error("expected a JSON object");
139
+ }
140
+ return parsed as T;
141
+ } catch (error) {
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ ctx.ui.notify(`Invalid JSON: ${message}`, "error");
144
+ return undefined;
145
+ }
146
+ }
147
+
148
+ async function previewProviderJson(ctx: any, draft: ProviderDraft): Promise<void> {
149
+ await ctx.ui.editor("Preview provider JSON", `${JSON.stringify(draft.config, null, 2)}\n`);
150
+ }
151
+
152
+ async function previewModelsJson(ctx: any, models: ProviderModelConfig[]): Promise<void> {
153
+ await ctx.ui.editor("Preview selected models", `${JSON.stringify(models, null, 2)}\n`);
154
+ }
155
+
156
+ async function selectOfficialCandidate(ctx: any, candidates: Array<{ provider: string; model: { id: string } }>): Promise<number | null> {
157
+ const labels = candidates.map((candidate) => formatOfficialCandidate(candidate));
158
+ const choice = await customSelect(ctx, `Choose an official config for ${candidates[0]?.model.id ?? "model"}`, labels);
159
+ const value = selectValue(choice);
160
+ if (value == null) return null;
161
+ return labels.indexOf(value);
162
+ }
163
+
164
+ async function addEnrichedModel(ctx: any, draft: ProviderDraft, modelId: string): Promise<boolean> {
165
+ const outcome = await enrichModelId(modelId);
166
+ if (outcome.kind === "official-ambiguous") {
167
+ const choice = await selectOfficialCandidate(ctx, outcome.candidates);
168
+ if (choice == null) return false;
169
+ const chosen = outcome.candidates[choice];
170
+ if (!chosen) return false;
171
+ draft.config.models = upsertModel(modelList(draft.config), stripOfficialRoutingFields(chosen.model));
172
+ ctx.ui.notify(`Added ${chosen.model.id} from ${chosen.provider}`, "info");
173
+ return true;
174
+ }
175
+
176
+ let model = outcome.model;
177
+ if (outcome.source === "default") {
178
+ const edited = await ctx.ui.editor(`Review model ${modelId}`, `${JSON.stringify(model, null, 2)}\n`);
179
+ if (edited == null) return false;
180
+ try {
181
+ const parsed = JSON.parse(edited);
182
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || typeof (parsed as { id?: unknown }).id !== "string") {
183
+ throw new Error("expected an object with a string id");
184
+ }
185
+ model = parsed as ProviderModelConfig;
186
+ } catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ ctx.ui.notify(`Invalid model JSON: ${message}`, "error");
189
+ return false;
190
+ }
191
+ }
192
+
193
+ draft.config.models = upsertModel(modelList(draft.config), model);
194
+ ctx.ui.notify(`Added ${model.id} from ${outcome.source}`, "info");
195
+ return true;
196
+ }
197
+
198
+ async function addManualModel(ctx: any, draft: ProviderDraft): Promise<"done" | "back"> {
199
+ // Load official catalog for fuzzy search
200
+ const catalog = await loadOfficialCatalog();
201
+ const allModels = listAllOfficialModels(catalog);
202
+
203
+ // Fuzzy search loop
204
+ for (;;) {
205
+ const query = await customInput(ctx, "Search models", "Type to search official models (or press Enter to list all)");
206
+ if (query == null) return "back";
207
+
208
+ const filtered = fuzzyFilter(allModels, query, (entry) => entry.modelId);
209
+ const groups = groupOfficialModelsById(filtered);
210
+
211
+ if (groups.length === 0) {
212
+ const choice = await customSelect(ctx, "No matching model ids", [
213
+ "Enter custom model id...",
214
+ "Search again",
215
+ "Cancel",
216
+ ]);
217
+ const value = selectValue(choice);
218
+ if (value == null || value === "Search again") continue;
219
+ if (value === "Cancel") return "back";
220
+ if (value === "Enter custom model id...") {
221
+ const customId = await customInput(ctx, "Custom model id", "Enter a model id, e.g. my-custom-model");
222
+ if (customId == null) continue;
223
+ if (!customId.trim()) continue;
224
+ return await addEnrichedModel(ctx, draft, customId.trim()) ? "done" : "back";
225
+ }
226
+ continue;
227
+ }
228
+
229
+ const labels = groups.map((group) => group.modelId);
230
+ const title = query.trim()
231
+ ? `Found ${groups.length} model id(s)`
232
+ : `Official model ids (${groups.length})`;
233
+
234
+ for (;;) {
235
+ const choice = await customSelect(ctx, title, [
236
+ ...labels,
237
+ "Enter custom model id...",
238
+ "Search again",
239
+ "Cancel",
240
+ ]);
241
+ const value = selectValue(choice);
242
+ if (value == null || value === "Search again") break;
243
+ if (value === "Cancel") return "back";
244
+
245
+ if (value === "Enter custom model id...") {
246
+ const customId = await customInput(ctx, "Custom model id", "Enter a model id, e.g. my-custom-model");
247
+ if (customId == null) continue;
248
+ if (!customId.trim()) continue;
249
+ return await addEnrichedModel(ctx, draft, customId.trim()) ? "done" : "back";
250
+ }
251
+
252
+ const selectedGroup = groups.find((group) => group.modelId === value);
253
+ if (!selectedGroup) continue;
254
+
255
+ const providerLabels = selectedGroup.entries.map((entry) => formatOfficialCandidate({ provider: entry.provider, model: entry.model }));
256
+ const providerChoice = await customSelect(ctx, `Choose provider for ${selectedGroup.modelId}`, providerLabels);
257
+ const providerValue = selectValue(providerChoice);
258
+ if (providerValue == null) continue;
259
+
260
+ const providerIndex = providerLabels.indexOf(providerValue);
261
+ const selectedEntry = selectedGroup.entries[providerIndex];
262
+ if (!selectedEntry) continue;
263
+
264
+ draft.config.models = upsertModel(modelList(draft.config), stripOfficialRoutingFields(selectedEntry.model));
265
+ ctx.ui.notify(`Added ${selectedEntry.model.id} from ${selectedEntry.provider}`, "info");
266
+ return "done";
267
+ }
268
+ }
269
+ }
270
+
271
+ async function importFromOpenAIModels(ctx: any, draft: ProviderDraft): Promise<"done" | "back"> {
272
+ try {
273
+ const ids = await fetchOpenAIModelIds({ baseUrl: draft.config.baseUrl, apiKey: draft.config.apiKey });
274
+ if (ids.length === 0) {
275
+ ctx.ui.notify("/models returned no model ids", "warning");
276
+ return "done";
277
+ }
278
+
279
+ let remaining = [...ids];
280
+ while (remaining.length > 0) {
281
+ const choice = await customSelect(ctx, "Add a model from /models", [...remaining, "Done"]);
282
+ const value = selectValue(choice);
283
+ if (value == null) return "back";
284
+ if (value === "Done") return "done";
285
+ if (await addEnrichedModel(ctx, draft, value)) {
286
+ remaining = remaining.filter((id) => id !== value);
287
+ } else {
288
+ return "back";
289
+ }
290
+ }
291
+ return "done";
292
+ } catch (error) {
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ ctx.ui.notify(`Could not import models: ${message}`, "warning");
295
+ return "done";
296
+ }
297
+ }
298
+
299
+ async function editModelJson(ctx: any, draft: ProviderDraft, index: number): Promise<void> {
300
+ const current = modelList(draft.config)[index];
301
+ if (!current) return;
302
+ const next = await ctx.ui.editor(`Edit model ${current.id} JSON`, `${JSON.stringify(current, null, 2)}\n`);
303
+ if (next == null) return;
304
+ try {
305
+ const parsed = JSON.parse(next);
306
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || typeof (parsed as { id?: unknown }).id !== "string") {
307
+ throw new Error("expected an object with a string id");
308
+ }
309
+ draft.config.models = replaceModelAtIndex(modelList(draft.config), index, parsed as ProviderModelConfig);
310
+ ctx.ui.notify(`Updated model ${(parsed as ProviderModelConfig).id}`, "info");
311
+ } catch (error) {
312
+ const message = error instanceof Error ? error.message : String(error);
313
+ ctx.ui.notify(`Invalid model JSON: ${message}`, "error");
314
+ }
315
+ }
316
+
317
+ async function removeModel(ctx: any, draft: ProviderDraft, index: number): Promise<void> {
318
+ const current = modelList(draft.config)[index];
319
+ if (!current) return;
320
+ const confirmed = await ctx.ui.confirm(`Remove ${current.id}?`, "This only affects the in-memory draft until you save.");
321
+ if (!confirmed) return;
322
+ draft.config.models = removeModelAtIndex(modelList(draft.config), index);
323
+ }
324
+
325
+ async function manageModels(ctx: any, draft: ProviderDraft): Promise<void> {
326
+ const MANAGE_ACTIONS = {
327
+ add: "Add model",
328
+ remove: "Remove model",
329
+ replace: "Replace/edit model",
330
+ preview: "Preview models",
331
+ back: "Back",
332
+ } as const;
333
+
334
+ for (;;) {
335
+ // First level: choose action
336
+ const actionChoice = await customSelect(ctx, "Manage models", [
337
+ MANAGE_ACTIONS.add,
338
+ MANAGE_ACTIONS.remove,
339
+ MANAGE_ACTIONS.replace,
340
+ MANAGE_ACTIONS.preview,
341
+ MANAGE_ACTIONS.back,
342
+ ]);
343
+ const action = selectValue(actionChoice);
344
+ if (action == null || action === MANAGE_ACTIONS.back) return;
345
+
346
+ if (action === MANAGE_ACTIONS.add) {
347
+ for (;;) {
348
+ const addChoice = await customSelect(ctx, "Add model", [
349
+ MODEL_MENU.addManual,
350
+ MODEL_MENU.importModels,
351
+ MODEL_MENU.back,
352
+ ]);
353
+ const addValue = selectValue(addChoice);
354
+ if (addValue == null || addValue === MODEL_MENU.back) break;
355
+ const result = addValue === MODEL_MENU.addManual
356
+ ? await addManualModel(ctx, draft)
357
+ : await importFromOpenAIModels(ctx, draft);
358
+ if (result === "done") break;
359
+ }
360
+ continue;
361
+ }
362
+
363
+ const models = modelList(draft.config);
364
+ if (models.length === 0) {
365
+ ctx.ui.notify("No models to manage. Add a model first.", "info");
366
+ continue;
367
+ }
368
+
369
+ if (action === MANAGE_ACTIONS.remove) {
370
+ const labels = models.map((model, index) => modelLabel(index, model));
371
+ const choice = await customSelect(ctx, "Remove model", [...labels, MODEL_MENU.back]);
372
+ const value = selectValue(choice);
373
+ if (value == null || value === MODEL_MENU.back) continue;
374
+ const selectedIndex = labels.indexOf(value);
375
+ if (selectedIndex >= 0) {
376
+ await removeModel(ctx, draft, selectedIndex);
377
+ }
378
+ continue;
379
+ }
380
+
381
+ if (action === MANAGE_ACTIONS.replace) {
382
+ const labels = models.map((model, index) => modelLabel(index, model));
383
+ const choice = await customSelect(ctx, "Replace/edit model", [...labels, MODEL_MENU.back]);
384
+ const value = selectValue(choice);
385
+ if (value == null || value === MODEL_MENU.back) continue;
386
+ const selectedIndex = labels.indexOf(value);
387
+ if (selectedIndex >= 0) {
388
+ await editModelJson(ctx, draft, selectedIndex);
389
+ }
390
+ continue;
391
+ }
392
+
393
+ if (action === MANAGE_ACTIONS.preview) {
394
+ await previewModelsJson(ctx, models);
395
+ continue;
396
+ }
397
+ }
398
+ }
399
+
400
+ async function editProviderKey(ctx: any, draft: ProviderDraft): Promise<void> {
401
+ const next = await customInput(ctx, "Provider key", "", draft.key);
402
+ if (next == null || !next.trim()) return;
403
+ draft.key = next.trim();
404
+ }
405
+
406
+ async function editProviderDraft(ctx: any, draft: ProviderDraft): Promise<ProviderDraft | "back" | null> {
407
+ for (;;) {
408
+ const choice = await customSelect(ctx, `Vendor: ${draft.key}`, [
409
+ PROVIDER_MENU.editKey,
410
+ PROVIDER_MENU.editName,
411
+ PROVIDER_MENU.editBaseUrl,
412
+ PROVIDER_MENU.editApiKey,
413
+ PROVIDER_MENU.editApiFormat,
414
+ PROVIDER_MENU.editAuthHeader,
415
+ PROVIDER_MENU.editCompat,
416
+ PROVIDER_MENU.manageModels,
417
+ PROVIDER_MENU.preview,
418
+ PROVIDER_MENU.save,
419
+ PROVIDER_MENU.cancel,
420
+ ], undefined, "goes back");
421
+ const choiceValue = selectValue(choice);
422
+ if (choiceValue == null) return "back";
423
+ if (choiceValue === PROVIDER_MENU.cancel) return null;
424
+ if (choiceValue === PROVIDER_MENU.editKey) {
425
+ await editProviderKey(ctx, draft);
426
+ continue;
427
+ }
428
+ if (choiceValue === PROVIDER_MENU.editName) {
429
+ const next = await promptInput(ctx, "Display name", draft.config.name ?? "", "Enter a display name");
430
+ if (next != null) draft.config.name = next;
431
+ continue;
432
+ }
433
+ if (choiceValue === PROVIDER_MENU.editBaseUrl) {
434
+ const next = await promptInput(ctx, "Base URL", draft.config.baseUrl ?? "", "Enter the provider base URL");
435
+ if (next != null) draft.config.baseUrl = next;
436
+ continue;
437
+ }
438
+ if (choiceValue === PROVIDER_MENU.editApiKey) {
439
+ const next = await promptInput(ctx, "API key / env reference", draft.config.apiKey ?? "", "Use a literal key or an env ref like $OPENAI_API_KEY");
440
+ if (next != null) draft.config.apiKey = next;
441
+ continue;
442
+ }
443
+ if (choiceValue === PROVIDER_MENU.editApiFormat) {
444
+ const choices = ["openai-completions", "openai-responses", "anthropic-messages", "custom value..."];
445
+ const current = draft.config.api?.trim();
446
+ const defaultChoice = current && choices.includes(current) ? current : current ? "custom value..." : "openai-completions";
447
+ const apiChoice = await customSelect(ctx, "API format", choices, defaultChoice);
448
+ const apiChoiceValue = selectValue(apiChoice);
449
+ if (apiChoiceValue == null) continue;
450
+ if (apiChoiceValue === "custom value...") {
451
+ const next = await promptInput(ctx, "Custom API format", draft.config.api ?? "", "Enter the provider api value");
452
+ if (next != null) draft.config.api = next;
453
+ continue;
454
+ }
455
+ draft.config.api = apiChoiceValue;
456
+ continue;
457
+ }
458
+ if (choiceValue === PROVIDER_MENU.editAuthHeader) {
459
+ const current = draft.config.authHeader;
460
+ const apiChoice = await customSelect(ctx, "authHeader", ["true", "false", "unset"], current === undefined ? "unset" : current ? "true" : "false");
461
+ const apiChoiceValue = selectValue(apiChoice);
462
+ if (apiChoiceValue == null) continue;
463
+ if (apiChoiceValue === "unset") {
464
+ delete draft.config.authHeader;
465
+ } else {
466
+ draft.config.authHeader = apiChoiceValue === "true";
467
+ }
468
+ continue;
469
+ }
470
+ if (choiceValue === PROVIDER_MENU.editCompat) {
471
+ const next = await promptJsonObject<Record<string, unknown>>(ctx, "Compatibility JSON", draft.config.compat ?? {});
472
+ if (next != null && next !== undefined) draft.config.compat = next;
473
+ continue;
474
+ }
475
+ if (choiceValue === PROVIDER_MENU.manageModels) {
476
+ await manageModels(ctx, draft);
477
+ continue;
478
+ }
479
+ if (choiceValue === PROVIDER_MENU.preview) {
480
+ await previewProviderJson(ctx, draft);
481
+ continue;
482
+ }
483
+ if (choiceValue === PROVIDER_MENU.save) {
484
+ return draft;
485
+ }
486
+ }
487
+ }
488
+
489
+ function pickProvider(modelsJson: ModelsJson): Array<{ key: string; label: string }> {
490
+ const providers = modelsJson.providers ?? {};
491
+ return Object.entries(providers)
492
+ .map(([key, config]) => ({ key, label: providerLabel(key, config) }))
493
+ .sort((left, right) => left.label.localeCompare(right.label));
494
+ }
495
+
496
+ async function chooseProviderDraft(ctx: any, modelsJson: ModelsJson): Promise<ProviderDraft | null> {
497
+ const providers = pickProvider(modelsJson);
498
+ for (;;) {
499
+ const choice = await customSelect(ctx, "Custom providers", [...providers.map((provider) => provider.label), "Add provider..."], undefined, "exits");
500
+ const choiceValue = selectValue(choice);
501
+ if (choiceValue == null) return null;
502
+ if (choiceValue === "Add provider...") {
503
+ const key = await customInput(ctx, "Provider key", "Enter a unique provider key");
504
+ if (key == null || !key.trim()) continue;
505
+ const trimmed = key.trim();
506
+ const existing = modelsJson.providers?.[trimmed];
507
+ return existing ? createProviderDraft(trimmed, existing) : createNewProviderDraft(trimmed);
508
+ }
509
+
510
+ const picked = providers.find((provider) => provider.label === choiceValue);
511
+ if (!picked) continue;
512
+ const existing = modelsJson.providers?.[picked.key];
513
+ return existing ? createProviderDraft(picked.key, existing) : createNewProviderDraft(picked.key);
514
+ }
515
+ }
516
+
517
+ export function registerVendorCommand(pi: ExtensionAPI): void {
518
+ pi.registerCommand(COMMAND_NAME, {
519
+ description: "Manage custom providers in ~/.pi/agent/models.json",
520
+ handler: async (_args, ctx) => {
521
+ if (!ctx.hasUI) {
522
+ ctx.ui.notify(`/vendor needs interactive mode. Edit ${getModelsJsonPath()} directly if you want to work non-interactively.`, "error");
523
+ return;
524
+ }
525
+
526
+ let modelsJson: ModelsJson;
527
+ try {
528
+ modelsJson = readModelsJson();
529
+ } catch (error) {
530
+ const message = error instanceof Error ? error.message : String(error);
531
+ ctx.ui.notify(message, "error");
532
+ return;
533
+ }
534
+
535
+ for (;;) {
536
+ const draft = await chooseProviderDraft(ctx, modelsJson);
537
+ if (!draft) {
538
+ ctx.ui.notify("Vendor config unchanged", "info");
539
+ return;
540
+ }
541
+
542
+ const edited = await editProviderDraft(ctx, draft);
543
+ if (edited === "back") continue;
544
+ if (!edited) {
545
+ ctx.ui.notify("Vendor config unchanged", "info");
546
+ return;
547
+ }
548
+
549
+ let currentModels: ModelsJson;
550
+ try {
551
+ currentModels = readModelsJson();
552
+ } catch (error) {
553
+ const message = error instanceof Error ? error.message : String(error);
554
+ ctx.ui.notify(`Could not re-read models.json: ${message}`, "error");
555
+ return;
556
+ }
557
+
558
+ if (draft.originalKey !== edited.key) {
559
+ const confirmed = await ctx.ui.confirm(
560
+ `Rename provider ${draft.originalKey} -> ${edited.key}?`,
561
+ `The old provider entry will be removed from ${getModelsJsonPath()} when you save.`,
562
+ );
563
+ if (!confirmed) {
564
+ ctx.ui.notify("Vendor config unchanged", "info");
565
+ return;
566
+ }
567
+ }
568
+ if (edited.key !== draft.originalKey && currentModels.providers?.[edited.key]) {
569
+ const confirmed = await ctx.ui.confirm(
570
+ `Overwrite existing provider ${edited.key}?`,
571
+ `This will replace the current entry in ${getModelsJsonPath()} when you save.`,
572
+ );
573
+ if (!confirmed) {
574
+ ctx.ui.notify("Vendor config unchanged", "info");
575
+ return;
576
+ }
577
+ }
578
+
579
+ try {
580
+ const next = upsertProvider(currentModels, edited, { previousKey: draft.originalKey });
581
+ writeModelsJson(next);
582
+ ctx.ui.notify("Saved provider. Open /model to refresh model selection.", "info");
583
+ } catch (error) {
584
+ const message = error instanceof Error ? error.message : String(error);
585
+ ctx.ui.notify(`Failed to save models.json: ${message}`, "error");
586
+ }
587
+ return;
588
+ }
589
+ },
590
+ });
591
+ }
592
+
593
+ export default async function registerVendor(pi: ExtensionAPI): Promise<void> {
594
+ registerVendorCommand(pi);
595
+ }