@fanchaozz/provider-manager 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/forms.ts ADDED
@@ -0,0 +1,622 @@
1
+ /**
2
+ * forms.ts — TUI 表单流程
3
+ *
4
+ * 每个 flow 是一串 ctx.ui.input / ctx.ui.select / ctx.ui.confirm 调用,
5
+ * 最后写盘 models.json + 通知用户。
6
+ *
7
+ * pi 实际 API(位置 string 参数,不是 object):
8
+ * input(title, placeholder?, opts?) -> Promise<string | undefined>
9
+ * select(title, options: string[], opts?) -> Promise<string | undefined>
10
+ * confirm(title, message, opts?) -> Promise<boolean>
11
+ *
12
+ * 与 LLM 工具(tools.ts)不共用——LLM 工具走自己的参数 schema。
13
+ */
14
+
15
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
+ import { readModelsJson, writeModelsJson, backupExists, restoreBackup, type ModelsJson, type ProviderConfig, type ModelConfig, ALLOWED_APIS } from "./store.ts";
18
+ import { fetchListing, inferModel, isNoise, diffModels, type FetchedModel } from "./sync.ts";
19
+ import { ModelChecklist, FormEditor, type FormField } from "./components.ts";
20
+
21
+ // ============================================================================
22
+ // 共用 prompt helpers
23
+ // ============================================================================
24
+
25
+ // select 的 options 必须是 string[],不能是 {label,value}。直接把 value 字符串化。
26
+ const API_OPTIONS: string[] = [
27
+ ...ALLOWED_APIS,
28
+ "(none / 由 model 字段指定)",
29
+ ];
30
+ const INPUT_OPTIONS: string[] = ["text", "image (supports image)"];
31
+
32
+ /** thinking level 预设(避免用户手输 JSON)。
33
+ * null = 禁用该 level,string = 映射到 provider 那个字符串值。 */
34
+ const THINKING_PRESETS: { label: string; map: ModelConfig["thinkingLevelMap"] }[] = [
35
+ { label: "(不设 / 用 provider 默认)", map: {} },
36
+ { label: "Anthropic 风格 (low/medium/high/max → 同名 + off=null, minimal=null)", map: { off: null, minimal: null, low: "low", medium: "medium", high: "high", xhigh: null, max: "max" } },
37
+ { label: "OpenAI o1 风格 (low/medium/high → 同名 + off=null, minimal=null)", map: { off: null, minimal: null, low: "low", medium: "medium", high: "high" } },
38
+ { label: "只暴露 high+max (其他全 null)", map: { off: null, minimal: null, low: null, medium: null, high: "high", xhigh: null, max: "max" } },
39
+ { label: "Custom (Enter 自填 JSON)", map: { __custom: true } as any },
40
+ ];
41
+
42
+ /** 新 model 的默认配置。调 /providers model <pid> add 或 dashboard n 走 addModelFlow 时
43
+ * 会问 "Use defaults?",回答 yes → 套这里的所有值;回答 no → 逐个问。 */
44
+ export const DEFAULT_MODEL_CONFIG: {
45
+ reasoning: boolean;
46
+ input: ("text" | "image")[];
47
+ contextWindow: number;
48
+ maxTokens: number;
49
+ thinkingLevelMap: ModelConfig["thinkingLevelMap"];
50
+ } = {
51
+ reasoning: true,
52
+ input: ["text", "image"],
53
+ contextWindow: 128000,
54
+ maxTokens: 16384,
55
+ thinkingLevelMap: {
56
+ off: null,
57
+ minimal: null,
58
+ low: null,
59
+ medium: "medium", // 默认只勾 medium
60
+ high: null,
61
+ xhigh: null,
62
+ max: null,
63
+ },
64
+ };
65
+
66
+ // ============================================================================
67
+ // user-level override: ~/.pi/agent/provider-manager.json#defaultModel
68
+ // ============================================================================
69
+
70
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
71
+ import { existsSync } from "node:fs";
72
+ import { dirname, join } from "node:path";
73
+
74
+ export function getDefaultModelConfigPath(): string {
75
+ const ov = (globalThis as any)[Symbol.for("pi-provider-manager:default-model-path-override")] as string | undefined;
76
+ if (ov) return ov;
77
+ return join(getAgentDir(), "provider-manager.json");
78
+ }
79
+
80
+ /**
81
+ * If ~/.pi/agent/provider-manager.json does not exist, write current code DEFAULT_MODEL_CONFIG to it.
82
+ * Existing file is left untouched. Returns path written or null.
83
+ * 同步执行:index.ts 启动后立即调用;`pi -p` 模式进程会立即退出,async 会被中断。
84
+ */
85
+ export function ensureDefaultConfigFile(): string | null {
86
+ try {
87
+ const p = getDefaultModelConfigPath();
88
+ if (existsSync(p)) return null;
89
+ mkdirSync(dirname(p), { recursive: true });
90
+ writeFileSync(p, JSON.stringify({
91
+ _defaultModel: "New model defaults used by /providers model <pid> add and dashboard n. When asked Use default config? answering yes applies these; no = per-field prompts. Edit then save -> next add picks up changes.",
92
+ defaultModel: DEFAULT_MODEL_CONFIG,
93
+ }, null, 2) + "\n", { mode: 0o600 });
94
+ return p;
95
+ } catch (err) {
96
+ console.error(`[provider-manager] ensureDefaultConfigFile failed:`, err);
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function isValidDefaultModelConfig(v: any): v is typeof DEFAULT_MODEL_CONFIG {
102
+ return (
103
+ v && typeof v === "object" &&
104
+ typeof v.reasoning === "boolean" &&
105
+ Array.isArray(v.input) && v.input.every((x: any) => x === "text" || x === "image") && v.input.length > 0 &&
106
+ typeof v.contextWindow === "number" && v.contextWindow > 0 && Number.isFinite(v.contextWindow) &&
107
+ typeof v.maxTokens === "number" && v.maxTokens > 0 && Number.isFinite(v.maxTokens) &&
108
+ v.thinkingLevelMap && typeof v.thinkingLevelMap === "object" && !Array.isArray(v.thinkingLevelMap)
109
+ );
110
+ }
111
+
112
+ /**
113
+ * 加载 default model config:先读 ~/.pi/agent/provider-manager.json 的 defaultModel 字段,
114
+ * 不存在或非法 → 走代码 DEFAULT_MODEL_CONFIG+同步异常不崩+
115
+ */
116
+ export function loadDefaultModelConfig(): typeof DEFAULT_MODEL_CONFIG {
117
+ try {
118
+ const p = getDefaultModelConfigPath();
119
+ if (!existsSync(p)) return DEFAULT_MODEL_CONFIG;
120
+ const raw = readFileSync(p, "utf8");
121
+ const parsed = JSON.parse(raw);
122
+ const cfg = parsed?.defaultModel;
123
+ if (isValidDefaultModelConfig(cfg)) return cfg;
124
+ } catch {
125
+ // 回退到代码默认
126
+ }
127
+ return DEFAULT_MODEL_CONFIG;
128
+ }
129
+
130
+ async function askInput(
131
+ ctx: ExtensionCommandContext,
132
+ opts: { message: string; placeholder?: string; secret?: boolean; defaultValue?: string; validate?: (s: string) => string | null },
133
+ ): Promise<string | undefined> {
134
+ // title 拼到 message 里(pi 只能传一个 string)
135
+ let title = opts.message;
136
+ if (opts.secret) title += " (input hidden)";
137
+ const result = await ctx.ui.input(title, opts.placeholder);
138
+ if (result === undefined) return undefined; // Esc 取消
139
+ const trimmed = result.trim();
140
+ // 空输入 + 有 defaultValue → 保留原值(empty = keep)
141
+ if (trimmed === "" && opts.defaultValue !== undefined) {
142
+ const v = opts.defaultValue;
143
+ if (opts.validate) {
144
+ const err = opts.validate(v);
145
+ if (err) { ctx.ui.notify(err, "error"); return undefined; }
146
+ }
147
+ return v;
148
+ }
149
+ if (opts.validate) {
150
+ const err = opts.validate(trimmed);
151
+ if (err) { ctx.ui.notify(err, "error"); return undefined; }
152
+ }
153
+ return trimmed;
154
+ }
155
+ async function askSelect(
156
+ ctx: ExtensionCommandContext,
157
+ opts: { message: string; options: string[]; defaultValue?: string },
158
+ ): Promise<string | undefined> {
159
+ const result = await ctx.ui.select(opts.message, opts.options);
160
+ if (result === undefined) return undefined;
161
+ return result;
162
+ }
163
+
164
+ async function askConfirm(ctx: ExtensionCommandContext, title: string, message: string, defaultValue = true): Promise<boolean | undefined> {
165
+ return ctx.ui.confirm(title, message);
166
+ // 注:confirm 不支持 defaultValue,UI 自带 yes/no
167
+ }
168
+
169
+ /** 包 FormEditor 进 ctx.ui.custom dialog。返回 { saved, values } 或 { saved: false, values: initial }。 */
170
+ async function runFormEditor<T extends Record<string, unknown>>(
171
+ ctx: ExtensionCommandContext,
172
+ title: string,
173
+ fields: FormField[],
174
+ initial: T,
175
+ ): Promise<{ saved: boolean; values: T }> {
176
+ const result = await ctx.ui.custom<{ saved: boolean; values: T } | undefined>((_tui, theme, _kb, done) => {
177
+ return new FormEditor({
178
+ title,
179
+ fields,
180
+ initial,
181
+ theme,
182
+ onSave: (values: T) => done({ saved: true, values }),
183
+ onCancel: () => done(undefined),
184
+ });
185
+ }).catch((err) => {
186
+ // 框架抛错(不是用户取消 Esc)要让用户知道
187
+ console.error(`[provider-manager] form editor error:`, err);
188
+ ctx.ui.notify(`Form editor error: ${err instanceof Error ? err.message : err}`, "error");
189
+ return undefined;
190
+ });
191
+ return result ?? { saved: false, values: initial };
192
+ }
193
+
194
+ // ============================================================================
195
+ // Provider CRUD
196
+ // ============================================================================
197
+
198
+ export async function addProviderFlow(ctx: ExtensionCommandContext, onDone: () => void): Promise<void> {
199
+ const id = await askInput(ctx, {
200
+ message: "Provider id (lowercase / digits / _ / -; e.g. my-provider):",
201
+ placeholder: "my-provider",
202
+ validate: (s) => {
203
+ if (!/^[a-z0-9_-]+$/i.test(s)) return "id must match [a-z0-9_-]+";
204
+ return null;
205
+ },
206
+ });
207
+ if (!id) return;
208
+
209
+ const json = await readModelsJson();
210
+ if (json.providers[id]) {
211
+ ctx.ui.notify(`Provider "${id}" already exists. Use /providers remove ${id} first.`, "error");
212
+ return;
213
+ }
214
+
215
+ const name = await askInput(ctx, { message: `Display name (optional):`, placeholder: id });
216
+ if (name === undefined) return;
217
+
218
+ const baseUrl = await askInput(ctx, { message: "baseUrl (e.g. http://localhost:11434/v1):", placeholder: "https://api.example.com/v1" });
219
+ if (baseUrl === undefined) return;
220
+
221
+ const apiKey = await askInput(ctx, { message: "apiKey (empty = no key):", secret: true });
222
+ if (apiKey === undefined) return;
223
+
224
+ const apiChoice = await askSelect(ctx, { message: "API type:", options: API_OPTIONS });
225
+ if (apiChoice === undefined) return;
226
+
227
+ const newProv: ProviderConfig = {
228
+ ...(name ? { name } : {}),
229
+ baseUrl,
230
+ apiKey: apiKey || undefined,
231
+ api: apiChoice,
232
+ models: [],
233
+ };
234
+ try {
235
+ await writeModelsJson({ ...json, providers: { ...json.providers, [id]: newProv } });
236
+ ctx.ui.notify(`✓ Provider "${id}" added. Open /providers to add models.`, "success");
237
+ } catch (err) {
238
+ ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
239
+ }
240
+ onDone?.();
241
+ }
242
+
243
+ export async function addModelFlow(
244
+ ctx: ExtensionCommandContext,
245
+ providerId: string,
246
+ onDone?: () => void,
247
+ ): Promise<void> {
248
+ const json = await readModelsJson();
249
+ const prov = json.providers[providerId];
250
+ if (!prov) {
251
+ ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error");
252
+ onDone?.();
253
+ return;
254
+ }
255
+
256
+ const id = await askInput(ctx, { message: "Model id (e.g. gpt-5 / claude-opus-4-7):", validate: (s) => s ? null : "id required" });
257
+ if (!id) return;
258
+
259
+ if ((prov.models ?? []).some((m) => m.id === id)) {
260
+ ctx.ui.notify(`Model "${id}" already exists in "${providerId}".`, "error");
261
+ onDone?.();
262
+ return;
263
+ }
264
+
265
+ const name = await askInput(ctx, { message: `Display name (optional):`, placeholder: id });
266
+ if (name === undefined) return;
267
+
268
+ // 用默认配置?reasoning/input/ctx/max/thinkingLevelMap 都用 DEFAULT_MODEL_CONFIG+可被 ~/.pi/agent/provider-manager.json#defaultModel 覆盖
269
+ const DEFAULT_CFG = loadDefaultModelConfig();
270
+ const useDefaults = await askConfirm(
271
+ ctx,
272
+ "Use default config?",
273
+ `reasoning=${DEFAULT_CFG.reasoning ? "yes" : "no"} · input=${DEFAULT_CFG.input.join("+")} · ctx=${DEFAULT_CFG.contextWindow} · max=${DEFAULT_CFG.maxTokens} · thinkingLevelMap: medium=medium, others=null. (After creation, use 'e' to customize.)`,
274
+ );
275
+ if (useDefaults === undefined) return;
276
+
277
+ let reasoning: boolean;
278
+ let input: ("text" | "image")[];
279
+ let ctxWindow: number;
280
+ let maxTokens: number;
281
+ let thinkingLevelMap: ModelConfig["thinkingLevelMap"] | undefined;
282
+
283
+ if (useDefaults) {
284
+ reasoning = DEFAULT_CFG.reasoning;
285
+ input = [...DEFAULT_CFG.input];
286
+ ctxWindow = DEFAULT_CFG.contextWindow;
287
+ maxTokens = DEFAULT_CFG.maxTokens;
288
+ thinkingLevelMap = { ...DEFAULT_CFG.thinkingLevelMap };
289
+ } else {
290
+ reasoning = await askConfirm(ctx, "Supports extended thinking?", "Yes for o1/o3/reasoning models, No otherwise.");
291
+ if (reasoning === undefined) return;
292
+
293
+ const inputType = await askSelect(ctx, { message: "Input type:", options: INPUT_OPTIONS });
294
+ if (inputType === undefined) return;
295
+ input = inputType === "text" ? ["text"] : ["text", "image"];
296
+
297
+ const ctxWindowStr = await askInput(ctx, { message: "context window tokens (empty = 128000):", placeholder: "128000", validate: (s) => !s || /^\d+$/.test(s) ? null : "must be a number" });
298
+ if (ctxWindowStr === undefined) return;
299
+ ctxWindow = ctxWindowStr ? parseInt(ctxWindowStr, 10) : 128000;
300
+
301
+ const maxTokensStr = await askInput(ctx, { message: "max output tokens (empty = 16384):", placeholder: "16384", validate: (s) => !s || /^\d+$/.test(s) ? null : "must be a number" });
302
+ if (maxTokensStr === undefined) return;
303
+ maxTokens = maxTokensStr ? parseInt(maxTokensStr, 10) : 16384;
304
+
305
+ if (reasoning) {
306
+ const presetLabels = THINKING_PRESETS.map((p) => p.label);
307
+ const pick = await askSelect(ctx, {
308
+ message: "Thinking level map (provider-dependent):",
309
+ options: presetLabels,
310
+ defaultValue: presetLabels[0],
311
+ });
312
+ if (pick === undefined) return;
313
+ const preset = THINKING_PRESETS.find((p) => p.label === pick);
314
+ if (preset && Object.keys(preset.map).length > 0) {
315
+ if ((preset.map as any).__custom) {
316
+ const mapStr = await askInput(ctx, {
317
+ message: 'thinking level map (JSON, e.g. {"low":"low","medium":"medium"}):',
318
+ placeholder: '{"off":null,"low":"low"}',
319
+ });
320
+ if (mapStr === undefined) return;
321
+ if (mapStr.trim()) {
322
+ try {
323
+ const parsed = JSON.parse(mapStr);
324
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
325
+ thinkingLevelMap = parsed as ModelConfig["thinkingLevelMap"];
326
+ } else {
327
+ ctx.ui.notify("thinking level map must be a JSON object; skipping", "warning");
328
+ }
329
+ } catch (err) {
330
+ ctx.ui.notify(`thinking level map JSON invalid: ${err instanceof Error ? err.message : err}; skipping`, "warning");
331
+ }
332
+ }
333
+ } else {
334
+ thinkingLevelMap = preset.map;
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ const model: ModelConfig = {
341
+ id,
342
+ name: name || undefined,
343
+ reasoning,
344
+ input,
345
+ contextWindow: ctxWindow,
346
+ maxTokens,
347
+ ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
348
+ };
349
+
350
+ const newProv: ProviderConfig = { ...prov, models: [...(prov.models ?? []), model] };
351
+ try {
352
+ await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: newProv } });
353
+ ctx.ui.notify(`✓ Model "${id}" added to "${providerId}".`, "success");
354
+ } catch (err) {
355
+ ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
356
+ }
357
+ onDone?.();
358
+ }
359
+
360
+ export async function editProviderFlow(
361
+ ctx: ExtensionCommandContext,
362
+ providerId: string,
363
+ onDone?: () => void,
364
+ ): Promise<void> {
365
+ const json = await readModelsJson();
366
+ const cur = json.providers[providerId];
367
+ if (!cur) {
368
+ ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error");
369
+ onDone?.();
370
+ return;
371
+ }
372
+ if (ctx.mode !== "tui") {
373
+ ctx.ui.notify("edit 需要 TUI 模式。打开 /providers 选中 provider 后按 e", "warning");
374
+ onDone?.();
375
+ return;
376
+ }
377
+
378
+ const fields: FormField[] = [
379
+ { key: "name", label: "Display name", type: "text", hint: "(empty = unset)" },
380
+ { key: "baseUrl", label: "baseUrl", type: "text" },
381
+ { key: "apiKey", label: "apiKey", type: "secret" },
382
+ { key: "api", label: "api", type: "select", options: API_OPTIONS, hint: "1-N 选" },
383
+ { key: "authHeader", label: "authHeader", type: "select", options: ["no", "yes"] },
384
+ ];
385
+ const initial: Record<string, unknown> = {
386
+ name: cur.name ?? "",
387
+ baseUrl: cur.baseUrl ?? "",
388
+ apiKey: cur.apiKey ?? "",
389
+ api: cur.api ?? "",
390
+ authHeader: cur.authHeader ? "yes" : "no",
391
+ };
392
+ const result = await runFormEditor(ctx, `Edit provider "${providerId}"`, fields, initial);
393
+ if (!result.saved) { onDone?.(); return; }
394
+ const v = result.values;
395
+ const next: ProviderConfig = {
396
+ ...cur,
397
+ name: ((v.name as string) || "") || undefined,
398
+ baseUrl: ((v.baseUrl as string) || "") || undefined,
399
+ apiKey: ((v.apiKey as string) || "") || undefined,
400
+ api: ((v.api as string) || "") || undefined,
401
+ authHeader: v.authHeader === "yes",
402
+ };
403
+ try {
404
+ await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: next } });
405
+ ctx.ui.notify(`✓ Provider "${providerId}" updated.`, "success");
406
+ } catch (err) {
407
+ ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
408
+ }
409
+ onDone?.();
410
+ }
411
+
412
+ export async function deleteProviderFlow(
413
+ ctx: ExtensionCommandContext,
414
+ providerId: string,
415
+ onDone?: () => void,
416
+ ): Promise<void> {
417
+ const json = await readModelsJson();
418
+ if (!json.providers[providerId]) {
419
+ ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error");
420
+ onDone?.();
421
+ return;
422
+ }
423
+ const ok = await askConfirm(
424
+ ctx,
425
+ `Delete provider "${providerId}"?`,
426
+ `This removes ${json.providers[providerId].models?.length ?? 0} model(s). Can be restored from .bak via /providers reset.`,
427
+ );
428
+ if (ok === undefined || !ok) {
429
+ onDone?.();
430
+ return;
431
+ }
432
+ const { [providerId]: _, ...rest } = json.providers;
433
+ try {
434
+ await writeModelsJson({ providers: rest });
435
+ ctx.ui.notify(`✓ Provider "${providerId}" deleted (restore with /providers reset).`, "success");
436
+ } catch (err) {
437
+ ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
438
+ }
439
+ onDone?.();
440
+ }
441
+ // ============================================================================
442
+ // Model CRUD (continued)
443
+ // ============================================================================
444
+
445
+ export async function editModelFlow(
446
+ ctx: ExtensionCommandContext,
447
+ providerId: string,
448
+ modelId: string,
449
+ onDone?: () => void,
450
+ ): Promise<void> {
451
+ const json = await readModelsJson();
452
+ const prov = json.providers[providerId];
453
+ if (!prov) { ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error"); onDone?.(); return; }
454
+ const cur = (prov.models ?? []).find((m) => m.id === modelId);
455
+ if (!cur) { ctx.ui.notify(`Model "${modelId}" does not exist in "${providerId}".`, "error"); onDone?.(); return; }
456
+ if (ctx.mode !== "tui") { ctx.ui.notify("edit 需要 TUI 模式。打开 /providers 选中 model 后按 e", "warning"); onDone?.(); return; }
457
+
458
+ const fields: FormField[] = [
459
+ { key: "name", label: "Display name", type: "text", hint: "(empty = unset)" },
460
+ { key: "reasoning", label: "reasoning", type: "select", options: ["no", "yes"] },
461
+ { key: "input", label: "input", type: "multiselect", options: ["text", "image"] },
462
+ { key: "contextWindow", label: "contextWindow", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
463
+ { key: "maxTokens", label: "maxTokens", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
464
+ { key: "thinkingLevelMap", label: "thinkingLevelMap", type: "levelmap", hint: "(empty = remove)" },
465
+ ];
466
+ const initial: Record<string, unknown> = {
467
+ name: cur.name ?? "",
468
+ reasoning: cur.reasoning === true || cur.reasoning === "yes" ? "yes" : "no",
469
+ input: Array.isArray(cur.input) ? cur.input.filter((x) => x === "text" || x === "image") : [],
470
+ contextWindow: cur.contextWindow ?? 0,
471
+ maxTokens: cur.maxTokens ?? 0,
472
+ thinkingLevelMap: cur.thinkingLevelMap ?? null,
473
+ };
474
+ const result = await runFormEditor(ctx, `Edit model "${providerId}/${modelId}"`, fields, initial);
475
+ if (!result.saved) { onDone?.(); return; }
476
+ const v = result.values;
477
+ const next: ModelConfig = {
478
+ ...cur,
479
+ name: ((v.name as string) || "") || undefined,
480
+ reasoning: v.reasoning === "yes",
481
+ input: Array.isArray(v.input) ? v.input : (v.input === "image" ? ["text", "image"] : ["text"]),
482
+ contextWindow: (v.contextWindow as number) || undefined,
483
+ maxTokens: (v.maxTokens as number) || undefined,
484
+ thinkingLevelMap: (v.thinkingLevelMap as Record<string, unknown> | null) ?? undefined,
485
+ };
486
+ const newModels = (prov.models ?? []).map((m) => (m.id === modelId ? next : m));
487
+ const newProv: ProviderConfig = { ...prov, models: newModels };
488
+ try {
489
+ await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: newProv } });
490
+ ctx.ui.notify(`✓ Model "${modelId}" updated.`, "success");
491
+ } catch (err) {
492
+ ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
493
+ }
494
+ onDone?.();
495
+ }
496
+
497
+ export async function deleteModelFlow(
498
+ ctx: ExtensionCommandContext,
499
+ providerId: string,
500
+ modelId: string,
501
+ onDone?: () => void,
502
+ ): Promise<void> {
503
+ const json = await readModelsJson();
504
+ const prov = json.providers[providerId];
505
+ if (!prov) { ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error"); onDone?.(); return; }
506
+ if (!(prov.models ?? []).some((m) => m.id === modelId)) { ctx.ui.notify(`Model "${modelId}" not in "${providerId}".`, "error"); onDone?.(); return; }
507
+ const ok = await askConfirm(ctx, `Delete model "${modelId}"?`, "This removes it from models.json. Can be restored from .bak via /providers reset.");
508
+ if (ok === undefined || !ok) { onDone?.(); return; }
509
+ const newModels = (prov.models ?? []).filter((m) => m.id !== modelId);
510
+ const newProv: ProviderConfig = { ...prov, models: newModels };
511
+ try {
512
+ await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: newProv } });
513
+ ctx.ui.notify(`✓ Model "${modelId}" deleted (restore with /providers reset).`, "success");
514
+ } catch (err) {
515
+ ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
516
+ }
517
+ onDone?.();
518
+ }
519
+
520
+ // ============================================================================
521
+ // Backup restore / sync
522
+ // ============================================================================
523
+
524
+ export async function restoreFromBackupFlow(ctx: ExtensionCommandContext, onDone: () => void): Promise<void> {
525
+ if (!backupExists()) { ctx.ui.notify("No backup found. Nothing to restore.", "warning"); onDone?.(); return; }
526
+ const ok = await askConfirm(ctx, "Restore from .bak?", "Current models.json will be overwritten with .bak content. .bak itself is preserved.");
527
+ if (ok === undefined || !ok) { onDone?.(); return; }
528
+ const restored = await restoreBackup();
529
+ if (restored) ctx.ui.notify("✓ Restored from .bak.", "success");
530
+ else ctx.ui.notify("Restore failed.", "error");
531
+ onDone?.();
532
+ }
533
+
534
+ export type SyncOpts = { sourceProviderId?: string; onDone?: () => void };
535
+
536
+ export async function syncFlow(ctx: ExtensionCommandContext, opts: SyncOpts = {}): Promise<void> {
537
+ const json = await readModelsJson();
538
+ const ids = Object.keys(json.providers);
539
+ if (ids.length === 0) { ctx.ui.notify("models.json is empty. Add a provider first.", "warning"); opts.onDone?.(); return; }
540
+ let sourceId = opts.sourceProviderId;
541
+ // 预先检查:没有任何 provider 有 baseUrl → 直接报错
542
+ const allNoBaseUrl = ids.length > 0 && ids.every((id) => !json.providers[id]?.baseUrl);
543
+ if (allNoBaseUrl) {
544
+ ctx.ui.notify("没有 baseUrl。先去 /providers 改一下 baseUrl 再 sync。", "error");
545
+ opts.onDone?.();
546
+ return;
547
+ }
548
+ if (!sourceId) {
549
+ const picked = await askSelect(ctx, { message: "Sync from which provider?", options: ids });
550
+ if (picked === undefined) { opts.onDone?.(); return; }
551
+ sourceId = picked;
552
+ }
553
+ const prov = json.providers[sourceId];
554
+ if (!prov || !prov.baseUrl) { ctx.ui.notify(`Provider "${sourceId}" 没有 baseUrl,先去 /providers 改一下 baseUrl 再 sync。`, "error"); opts.onDone?.(); return; }
555
+ const apiKey = prov.apiKey ?? "";
556
+ const apiKind: "openai-compat" | "google" = prov.api === "google-generative-ai" ? "google" : "openai-compat";
557
+ ctx.ui.notify(`Fetching models from ${prov.baseUrl}...`, "info");
558
+ let result;
559
+ try {
560
+ result = await fetchListing({ baseUrl: prov.baseUrl, apiKey, apiKind, signal: ctx.signal, timeoutMs: 10000 });
561
+ } catch (err) {
562
+ ctx.ui.notify(`Fetch failed: ${err instanceof Error ? err.message : err}`, "error");
563
+ opts.onDone?.();
564
+ return;
565
+ }
566
+ if (result.warnings.length) ctx.ui.notify(result.warnings.join("; "), "warning");
567
+ if (result.models.length === 0 && (prov.models ?? []).length === 0) { ctx.ui.notify("No models found. Check baseUrl / api key.", "warning"); opts.onDone?.(); return; }
568
+ const existing = (prov.models ?? []).map((m) => ({ id: m.id }));
569
+ const { toAdd } = diffModels(result.models, existing);
570
+ // wire pi done directly to checklist onConfirm/onCancel (otherwise dialog never closes)
571
+ // checklist shows ALL models in this provider:
572
+ // - existing: label " (existing)", default checked (uncheck = remove)
573
+ // - toAdd (remote new): default unchecked (check = add)
574
+ const items = [
575
+ ...(prov.models ?? []).map((m) => ({ id: m.id, label: `${m.id} (existing)`, hint: "uncheck to remove" })),
576
+ ...toAdd.map((m) => ({ id: m.id, label: m.id, hint: `reasoning=${m.reasoning} input=${m.input.join(",")} ctx=${m.contextWindow}` })),
577
+ ];
578
+ const selectedIds = await ctx.ui.custom<Set<string> | string[]>((_t, theme, _kb, done) => {
579
+ const checklist = new ModelChecklist({
580
+ title: `Sync "${sourceId}": ${toAdd.length} new, ${(prov.models ?? []).length} existing`,
581
+ items,
582
+ preSelect: (it) => it.id ? (prov.models ?? []).some((m) => m.id === it.id) : true,
583
+ theme, // 构造时传 theme,pi 框架会注入
584
+ onConfirm: (sel) => done(new Set(sel)),
585
+ onCancel: () => done(undefined),
586
+ });
587
+ return checklist;
588
+ }).catch((err) => {
589
+ // 框架抛错(不是用户取消)要让用户知道
590
+ console.error(`[provider-manager] sync checklist error:`, err);
591
+ ctx.ui.notify(`Sync checklist error: ${err instanceof Error ? err.message : err}`, "error");
592
+ return undefined;
593
+ });
594
+ if (selectedIds === undefined || selectedIds === null) { ctx.ui.notify("Sync cancelled.", "info"); opts.onDone?.(); return; }
595
+ const pickedIds = selectedIds instanceof Set ? selectedIds : new Set(selectedIds as string[]);
596
+ // merge: checked = keep/add; unchecked = remove
597
+ const existingIds = (prov.models ?? []).map((m) => m.id);
598
+ const allIds = new Set([...existingIds, ...toAdd.map((m) => m.id)]);
599
+ const finalModels: ModelConfig[] = [];
600
+ for (const id of allIds) {
601
+ if (!pickedIds.has(id)) continue;
602
+ const fromRemote = toAdd.find((m) => m.id === id);
603
+ if (fromRemote) finalModels.push(fromRemote);
604
+ else {
605
+ const fromLocal = (prov.models ?? []).find((m) => m.id === id);
606
+ if (fromLocal) finalModels.push(fromLocal);
607
+ }
608
+ }
609
+ if (finalModels.length === 0) {
610
+ ctx.ui.notify("No models selected. Sync cancelled (nothing kept).", "info");
611
+ opts.onDone?.();
612
+ return;
613
+ }
614
+ const newProv: ProviderConfig = { ...prov, models: finalModels };
615
+ try {
616
+ await writeModelsJson({ ...json, providers: { ...json.providers, [sourceId!]: newProv } });
617
+ ctx.ui.notify(`Synced "${sourceId}": ${finalModels.length} model(s) kept. Press Ctrl+L to pick model.`, "success");
618
+ } catch (err) {
619
+ ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
620
+ }
621
+ opts.onDone?.();
622
+ }
package/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * index.ts — pi extension 入口
3
+ *
4
+ * 注册命令 / 工具 / 事件钩子。
5
+ * v1 scope:只 CRUD ~/.pi/agent/models.json;不切模型,不做登录 UI。
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { registerCommands } from "./commands.ts";
10
+ import { ensureDefaultConfigFile } from "./forms.ts";
11
+
12
+ export default function (pi: ExtensionAPI) {
13
+ // 初始化 ~/.pi/agent/provider-manager.json(若不存在)。`pi install` 后用户立即有可编辑的配置,
14
+ // 不必单独提供 json 模板。已存在则跳过;失败只 log 不抛。
15
+ ensureDefaultConfigFile();
16
+
17
+ registerCommands(pi);
18
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@fanchaozz/provider-manager",
3
+ "version": "0.1.0",
4
+ "description": "A pi extension that manages custom providers and models in ~/.pi/agent/models.json via a TUI dashboard, /providers slash command, and remote model sync.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "files": [
8
+ "*.ts",
9
+ "README.md",
10
+ "README_EN.md"
11
+ ],
12
+ "keywords": [
13
+ "pi",
14
+ "pi-extension",
15
+ "provider",
16
+ "model",
17
+ "ai"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "fanchaozz <fanchaozz@users.noreply.github.com>",
21
+ "homepage": "https://github.com/fanchaozz/provider-manager#readme",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/fanchaozz/provider-manager.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/fanchaozz/provider-manager/issues"
28
+ },
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*"
34
+ },
35
+ "scripts": {
36
+ "test": "for f in _test_*.mts; do node --experimental-strip-types --no-warnings \"$f\" 2>&1 | tail -3; done",
37
+ "ci:test": "npm install --no-save @earendil-works/pi-coding-agent && for f in _test_*.mts; do node --experimental-strip-types --no-warnings \"$f\" 2>&1 | tail -3; done"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }