@fanchaozz/provider-manager 0.1.0 → 0.2.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 CHANGED
@@ -15,7 +15,7 @@
15
15
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
16
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
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";
18
+ import { fetchListing, inferModel, diffModels } from "./sync.ts";
19
19
  import { ModelChecklist, FormEditor, type FormField } from "./components.ts";
20
20
 
21
21
  // ============================================================================
@@ -27,17 +27,6 @@ const API_OPTIONS: string[] = [
27
27
  ...ALLOWED_APIS,
28
28
  "(none / 由 model 字段指定)",
29
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
30
 
42
31
  /** 新 model 的默认配置。调 /providers model <pid> add 或 dashboard n 走 addModelFlow 时
43
32
  * 会问 "Use defaults?",回答 yes → 套这里的所有值;回答 no → 逐个问。 */
@@ -161,9 +150,8 @@ async function askSelect(
161
150
  return result;
162
151
  }
163
152
 
164
- async function askConfirm(ctx: ExtensionCommandContext, title: string, message: string, defaultValue = true): Promise<boolean | undefined> {
153
+ async function askConfirm(ctx: ExtensionCommandContext, title: string, message: string): Promise<boolean | undefined> {
165
154
  return ctx.ui.confirm(title, message);
166
- // 注:confirm 不支持 defaultValue,UI 自带 yes/no
167
155
  }
168
156
 
169
157
  /** 包 FormEditor 进 ctx.ui.custom dialog。返回 { saved, values } 或 { saved: false, values: initial }。 */
@@ -196,164 +184,74 @@ async function runFormEditor<T extends Record<string, unknown>>(
196
184
  // ============================================================================
197
185
 
198
186
  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;
187
+ if (ctx.mode !== "tui") {
188
+ ctx.ui.notify("add provider 需要 TUI 模式。打开 /providers 后按 n", "warning");
189
+ onDone?.();
190
+ return;
191
+ }
208
192
 
193
+ // 与 editProviderFlow 同形:一次性表单采集所有字段(含 id)
194
+ // 错误(id 重复 / id 非法)不走 notify 弹窗 — 留在表单里提示,点 s 后再调
209
195
  const json = await readModelsJson();
196
+ const fields: FormField[] = [
197
+ { key: "id", label: "id", type: "text", hint: "[a-z0-9_-]+", validate: (s) => {
198
+ if (!s) return "id required";
199
+ if (!/^[a-z0-9_-]+$/i.test(s as string)) return "id must match [a-z0-9_-]+";
200
+ if (json.providers[s as string]) return `provider "${s}" already exists`;
201
+ return null;
202
+ } },
203
+ { key: "name", label: "Display name", type: "text", hint: "(empty = unset)" },
204
+ { key: "baseUrl", label: "baseUrl", type: "text" },
205
+ { key: "apiKey", label: "apiKey", type: "secret" },
206
+ { key: "api", label: "api", type: "select", options: API_OPTIONS, hint: "(empty = unset)" },
207
+ { key: "authHeader", label: "authHeader", type: "select", options: ["no", "yes"] },
208
+ { key: "proxy", label: "proxy", type: "text", hint: "(empty = unset; http://host:port)" },
209
+ ];
210
+ const initial: Record<string, unknown> = {
211
+ id: "",
212
+ name: "",
213
+ baseUrl: "",
214
+ apiKey: "",
215
+ api: "",
216
+ authHeader: "no",
217
+ proxy: "",
218
+ };
219
+ const result = await runFormEditor(ctx, `Add provider`, fields, initial);
220
+ if (!result.saved) { onDone?.(); return; }
221
+ const v = result.values;
222
+ const id = (v.id as string).trim();
223
+ // 二次校验:表单后还会再查一次(同进程可能别的并发写)
210
224
  if (json.providers[id]) {
211
225
  ctx.ui.notify(`Provider "${id}" already exists. Use /providers remove ${id} first.`, "error");
226
+ onDone?.();
212
227
  return;
213
228
  }
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
229
  const newProv: ProviderConfig = {
228
- ...(name ? { name } : {}),
229
- baseUrl,
230
- apiKey: apiKey || undefined,
231
- api: apiChoice,
230
+ ...((v.name as string) ? { name: v.name as string } : {}),
231
+ baseUrl: ((v.baseUrl as string) || "") || undefined,
232
+ apiKey: ((v.apiKey as string) || "") || undefined,
233
+ api: ((v.api as string) || "") || undefined,
234
+ authHeader: v.authHeader === "yes",
235
+ proxy: ((v.proxy as string) || "") || undefined,
232
236
  models: [],
233
237
  };
234
238
  try {
235
- await writeModelsJson({ ...json, providers: { ...json.providers, [id]: newProv } });
236
- ctx.ui.notify(`✓ Provider "${id}" added. Open /providers to add models.`, "success");
239
+ const fresh = await readModelsJson();
240
+ await writeModelsJson({ ...fresh, providers: { ...fresh.providers, [id]: newProv } });
241
+ ctx.ui.notify(`✓ Provider "${id}" added. Use 'y' to sync models.`, "success");
237
242
  } catch (err) {
238
243
  ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
239
244
  }
240
245
  onDone?.();
241
246
  }
242
247
 
248
+ /** @deprecated Models are only added/removed via sync. Kept as a stub so old imports do not crash; logs a notice. */
243
249
  export async function addModelFlow(
244
250
  ctx: ExtensionCommandContext,
245
- providerId: string,
251
+ _providerId: string,
246
252
  onDone?: () => void,
247
253
  ): 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
- }
254
+ ctx.ui.notify("新增 model 请用 sync(按 y)。该入口已停用。", "warning");
357
255
  onDone?.();
358
256
  }
359
257
 
@@ -381,6 +279,7 @@ export async function editProviderFlow(
381
279
  { key: "apiKey", label: "apiKey", type: "secret" },
382
280
  { key: "api", label: "api", type: "select", options: API_OPTIONS, hint: "1-N 选" },
383
281
  { key: "authHeader", label: "authHeader", type: "select", options: ["no", "yes"] },
282
+ { key: "proxy", label: "proxy", type: "text", hint: "(empty = unset; http://host:port)" },
384
283
  ];
385
284
  const initial: Record<string, unknown> = {
386
285
  name: cur.name ?? "",
@@ -388,6 +287,7 @@ export async function editProviderFlow(
388
287
  apiKey: cur.apiKey ?? "",
389
288
  api: cur.api ?? "",
390
289
  authHeader: cur.authHeader ? "yes" : "no",
290
+ proxy: cur.proxy ?? "",
391
291
  };
392
292
  const result = await runFormEditor(ctx, `Edit provider "${providerId}"`, fields, initial);
393
293
  if (!result.saved) { onDone?.(); return; }
@@ -399,6 +299,7 @@ export async function editProviderFlow(
399
299
  apiKey: ((v.apiKey as string) || "") || undefined,
400
300
  api: ((v.api as string) || "") || undefined,
401
301
  authHeader: v.authHeader === "yes",
302
+ proxy: ((v.proxy as string) || "") || undefined,
402
303
  };
403
304
  try {
404
305
  await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: next } });
@@ -557,7 +458,7 @@ export async function syncFlow(ctx: ExtensionCommandContext, opts: SyncOpts = {}
557
458
  ctx.ui.notify(`Fetching models from ${prov.baseUrl}...`, "info");
558
459
  let result;
559
460
  try {
560
- result = await fetchListing({ baseUrl: prov.baseUrl, apiKey, apiKind, signal: ctx.signal, timeoutMs: 10000 });
461
+ result = await fetchListing({ baseUrl: prov.baseUrl, apiKey, apiKind, proxy: prov.proxy, signal: ctx.signal, timeoutMs: 10000 });
561
462
  } catch (err) {
562
463
  ctx.ui.notify(`Fetch failed: ${err instanceof Error ? err.message : err}`, "error");
563
464
  opts.onDone?.();
@@ -566,7 +467,9 @@ export async function syncFlow(ctx: ExtensionCommandContext, opts: SyncOpts = {}
566
467
  if (result.warnings.length) ctx.ui.notify(result.warnings.join("; "), "warning");
567
468
  if (result.models.length === 0 && (prov.models ?? []).length === 0) { ctx.ui.notify("No models found. Check baseUrl / api key.", "warning"); opts.onDone?.(); return; }
568
469
  const existing = (prov.models ?? []).map((m) => ({ id: m.id }));
569
- const { toAdd } = diffModels(result.models, existing);
470
+ // 关键修复:传 loadDefaultModelConfig() defaults,使 toAdd 使用用户级 default(不是代码内置默认)
471
+ const userDefaults = loadDefaultModelConfig();
472
+ const { toAdd } = diffModels(result.models, existing, { defaults: userDefaults });
570
473
  // wire pi done directly to checklist onConfirm/onCancel (otherwise dialog never closes)
571
474
  // checklist shows ALL models in this provider:
572
475
  // - existing: label " (existing)", default checked (uncheck = remove)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchaozz/provider-manager",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -10,12 +10,15 @@
10
10
  "README_EN.md"
11
11
  ],
12
12
  "keywords": [
13
- "pi",
13
+ "pi-package",
14
14
  "pi-extension",
15
15
  "provider",
16
16
  "model",
17
17
  "ai"
18
18
  ],
19
+ "pi": {
20
+ "extensions": ["./"]
21
+ },
19
22
  "license": "MIT",
20
23
  "author": "fanchaozz <fanchaozz@users.noreply.github.com>",
21
24
  "homepage": "https://github.com/fanchaozz/provider-manager#readme",
package/store.ts CHANGED
@@ -71,6 +71,7 @@ export type ProviderConfig = {
71
71
  headers?: Record<string, string>;
72
72
  compat?: Record<string, unknown>;
73
73
  authHeader?: boolean;
74
+ proxy?: string;
74
75
  models?: ModelConfig[];
75
76
  modelOverrides?: Record<string, ModelOverrideConfig>;
76
77
  };
package/sync.ts CHANGED
@@ -107,10 +107,11 @@ export async function fetchListing(opts: {
107
107
  baseUrl: string;
108
108
  apiKey?: string;
109
109
  apiKind: ApiKind;
110
+ proxy?: string;
110
111
  signal?: AbortSignal;
111
112
  timeoutMs?: number;
112
113
  }): Promise<FetchResult> {
113
- const { baseUrl, apiKey, apiKind, signal, timeoutMs = 10000 } = opts;
114
+ const { baseUrl, apiKey, apiKind, proxy, signal, timeoutMs = 10000 } = opts;
114
115
  const warnings: string[] = [];
115
116
 
116
117
  // 用 AbortController 双重保护:外部 signal + 超时
@@ -119,11 +120,30 @@ export async function fetchListing(opts: {
119
120
  const onAbort = () => ctrl.abort(signal!.reason);
120
121
  if (signal) signal.addEventListener("abort", onAbort);
121
122
 
123
+ // proxy:调 fetch 前设 env,取走后清(Node 18+ 的 undici fetch 读 HTTPS_PROXY/HTTP_PROXY)
124
+ // 限制:env 是进程全局的,sync 一次只 1 个 fetch,其他并发 fetch 会看到同样 proxy
125
+ const envPrev = proxy ? { HTTPS_PROXY: process.env.HTTPS_PROXY, HTTP_PROXY: process.env.HTTP_PROXY } : null;
126
+ if (proxy) {
127
+ process.env.HTTPS_PROXY = proxy;
128
+ process.env.HTTP_PROXY = proxy;
129
+ }
130
+
122
131
  try {
123
132
  const base = baseUrl.replace(/\/+$/, "");
133
+ // pre-flight: undici 要求 header value 是 Latin-1(每字符 code < 256)。
134
+ // apiKey 从复制粘贴过来常含 •、中文、emoji 等,会让 fetch 抛 "Cannot convert argument to a ByteString"。
135
+ // 这里提前检测并给 actionable 错误。
136
+ if (apiKey) {
137
+ for (let i = 0; i < apiKey.length; i++) {
138
+ if (apiKey.charCodeAt(i) > 255) {
139
+ const code = apiKey.codePointAt(i) ?? 0;
140
+ throw new Error(`apiKey contains non-Latin-1 character at position ${i} (U+${code.toString(16).toUpperCase()}). Re-enter the key in the provider form.`);
141
+ }
142
+ }
143
+ }
144
+
124
145
  let url: string;
125
146
  let headers: Record<string, string> = {};
126
- let body: any;
127
147
 
128
148
  if (apiKind === "google") {
129
149
  // GET {base}/models?key=$KEY → { models: [{ name, ... }] }
@@ -191,6 +211,11 @@ export async function fetchListing(opts: {
191
211
  } finally {
192
212
  clearTimeout(timer);
193
213
  if (signal) signal.removeEventListener("abort", onAbort);
214
+ // 还原 env(避免污染后续 fetch)
215
+ if (envPrev) {
216
+ process.env.HTTPS_PROXY = envPrev.HTTPS_PROXY;
217
+ process.env.HTTP_PROXY = envPrev.HTTP_PROXY;
218
+ }
194
219
  }
195
220
  }
196
221
 
@@ -199,7 +224,7 @@ export async function fetchListing(opts: {
199
224
  // ============================================================================
200
225
 
201
226
  export async function syncExisting(
202
- providers: Record<string, { baseUrl?: string; apiKey?: string; api?: string }>,
227
+ providers: Record<string, { baseUrl?: string; apiKey?: string; api?: string; proxy?: string }>,
203
228
  signal?: AbortSignal,
204
229
  timeoutMs?: number,
205
230
  ): Promise<Array<{ providerId: string; result: FetchResult | { error: string } }>> {
@@ -212,6 +237,7 @@ export async function syncExisting(
212
237
  baseUrl: p.baseUrl,
213
238
  apiKey: p.apiKey,
214
239
  apiKind,
240
+ proxy: p.proxy,
215
241
  signal,
216
242
  timeoutMs,
217
243
  });