@fanchaozz/provider-manager 0.1.1 → 0.2.1

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 → 逐个问。 */
@@ -47,6 +36,7 @@ export const DEFAULT_MODEL_CONFIG: {
47
36
  contextWindow: number;
48
37
  maxTokens: number;
49
38
  thinkingLevelMap: ModelConfig["thinkingLevelMap"];
39
+ compat: { supportsDeveloperRole: boolean };
50
40
  } = {
51
41
  reasoning: true,
52
42
  input: ["text", "image"],
@@ -61,6 +51,8 @@ export const DEFAULT_MODEL_CONFIG: {
61
51
  xhigh: null,
62
52
  max: null,
63
53
  },
54
+ // Zhipu GLM 等 OpenAI-compat 网关拒收 role:"developer"(会返 422)。默认 false → pi 用 system role。
55
+ compat: { supportsDeveloperRole: false },
64
56
  };
65
57
 
66
58
  // ============================================================================
@@ -99,13 +91,19 @@ export function ensureDefaultConfigFile(): string | null {
99
91
  }
100
92
 
101
93
  function isValidDefaultModelConfig(v: any): v is typeof DEFAULT_MODEL_CONFIG {
94
+ const compatOk = !v.compat
95
+ || (typeof v.compat === "object" && !Array.isArray(v.compat) && (
96
+ v.compat.supportsDeveloperRole === undefined
97
+ || typeof v.compat.supportsDeveloperRole === "boolean"
98
+ ));
102
99
  return (
103
100
  v && typeof v === "object" &&
104
101
  typeof v.reasoning === "boolean" &&
105
102
  Array.isArray(v.input) && v.input.every((x: any) => x === "text" || x === "image") && v.input.length > 0 &&
106
103
  typeof v.contextWindow === "number" && v.contextWindow > 0 && Number.isFinite(v.contextWindow) &&
107
104
  typeof v.maxTokens === "number" && v.maxTokens > 0 && Number.isFinite(v.maxTokens) &&
108
- v.thinkingLevelMap && typeof v.thinkingLevelMap === "object" && !Array.isArray(v.thinkingLevelMap)
105
+ v.thinkingLevelMap && typeof v.thinkingLevelMap === "object" && !Array.isArray(v.thinkingLevelMap) &&
106
+ compatOk
109
107
  );
110
108
  }
111
109
 
@@ -120,7 +118,11 @@ export function loadDefaultModelConfig(): typeof DEFAULT_MODEL_CONFIG {
120
118
  const raw = readFileSync(p, "utf8");
121
119
  const parsed = JSON.parse(raw);
122
120
  const cfg = parsed?.defaultModel;
123
- if (isValidDefaultModelConfig(cfg)) return cfg;
121
+ if (isValidDefaultModelConfig(cfg)) {
122
+ // 补全缺失的 compat(老 config 没有这个字段时默认为 false)
123
+ if (!cfg.compat) cfg.compat = { supportsDeveloperRole: false };
124
+ return cfg;
125
+ }
124
126
  } catch {
125
127
  // 回退到代码默认
126
128
  }
@@ -161,9 +163,8 @@ async function askSelect(
161
163
  return result;
162
164
  }
163
165
 
164
- async function askConfirm(ctx: ExtensionCommandContext, title: string, message: string, defaultValue = true): Promise<boolean | undefined> {
166
+ async function askConfirm(ctx: ExtensionCommandContext, title: string, message: string): Promise<boolean | undefined> {
165
167
  return ctx.ui.confirm(title, message);
166
- // 注:confirm 不支持 defaultValue,UI 自带 yes/no
167
168
  }
168
169
 
169
170
  /** 包 FormEditor 进 ctx.ui.custom dialog。返回 { saved, values } 或 { saved: false, values: initial }。 */
@@ -196,164 +197,74 @@ async function runFormEditor<T extends Record<string, unknown>>(
196
197
  // ============================================================================
197
198
 
198
199
  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;
200
+ if (ctx.mode !== "tui") {
201
+ ctx.ui.notify("add provider 需要 TUI 模式。打开 /providers 后按 n", "warning");
202
+ onDone?.();
203
+ return;
204
+ }
208
205
 
206
+ // 与 editProviderFlow 同形:一次性表单采集所有字段(含 id)
207
+ // 错误(id 重复 / id 非法)不走 notify 弹窗 — 留在表单里提示,点 s 后再调
209
208
  const json = await readModelsJson();
209
+ const fields: FormField[] = [
210
+ { key: "id", label: "id", type: "text", hint: "[a-z0-9_-]+", validate: (s) => {
211
+ if (!s) return "id required";
212
+ if (!/^[a-z0-9_-]+$/i.test(s as string)) return "id must match [a-z0-9_-]+";
213
+ if (json.providers[s as string]) return `provider "${s}" already exists`;
214
+ return null;
215
+ } },
216
+ { key: "name", label: "Display name", type: "text", hint: "(empty = unset)" },
217
+ { key: "baseUrl", label: "baseUrl", type: "text" },
218
+ { key: "apiKey", label: "apiKey", type: "secret" },
219
+ { key: "api", label: "api", type: "select", options: API_OPTIONS, hint: "(empty = unset)" },
220
+ { key: "authHeader", label: "authHeader", type: "select", options: ["no", "yes"] },
221
+ { key: "proxy", label: "proxy", type: "text", hint: "(empty = unset; http://host:port)" },
222
+ ];
223
+ const initial: Record<string, unknown> = {
224
+ id: "",
225
+ name: "",
226
+ baseUrl: "",
227
+ apiKey: "",
228
+ api: "",
229
+ authHeader: "no",
230
+ proxy: "",
231
+ };
232
+ const result = await runFormEditor(ctx, `Add provider`, fields, initial);
233
+ if (!result.saved) { onDone?.(); return; }
234
+ const v = result.values;
235
+ const id = (v.id as string).trim();
236
+ // 二次校验:表单后还会再查一次(同进程可能别的并发写)
210
237
  if (json.providers[id]) {
211
238
  ctx.ui.notify(`Provider "${id}" already exists. Use /providers remove ${id} first.`, "error");
239
+ onDone?.();
212
240
  return;
213
241
  }
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
242
  const newProv: ProviderConfig = {
228
- ...(name ? { name } : {}),
229
- baseUrl,
230
- apiKey: apiKey || undefined,
231
- api: apiChoice,
243
+ ...((v.name as string) ? { name: v.name as string } : {}),
244
+ baseUrl: ((v.baseUrl as string) || "") || undefined,
245
+ apiKey: ((v.apiKey as string) || "") || undefined,
246
+ api: ((v.api as string) || "") || undefined,
247
+ authHeader: v.authHeader === "yes",
248
+ proxy: ((v.proxy as string) || "") || undefined,
232
249
  models: [],
233
250
  };
234
251
  try {
235
- await writeModelsJson({ ...json, providers: { ...json.providers, [id]: newProv } });
236
- ctx.ui.notify(`✓ Provider "${id}" added. Open /providers to add models.`, "success");
252
+ const fresh = await readModelsJson();
253
+ await writeModelsJson({ ...fresh, providers: { ...fresh.providers, [id]: newProv } });
254
+ ctx.ui.notify(`✓ Provider "${id}" added. Use 'y' to sync models.`, "success");
237
255
  } catch (err) {
238
256
  ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
239
257
  }
240
258
  onDone?.();
241
259
  }
242
260
 
261
+ /** @deprecated Models are only added/removed via sync. Kept as a stub so old imports do not crash; logs a notice. */
243
262
  export async function addModelFlow(
244
263
  ctx: ExtensionCommandContext,
245
- providerId: string,
264
+ _providerId: string,
246
265
  onDone?: () => void,
247
266
  ): 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
- }
267
+ ctx.ui.notify("新增 model 请用 sync(按 y)。该入口已停用。", "warning");
357
268
  onDone?.();
358
269
  }
359
270
 
@@ -381,6 +292,7 @@ export async function editProviderFlow(
381
292
  { key: "apiKey", label: "apiKey", type: "secret" },
382
293
  { key: "api", label: "api", type: "select", options: API_OPTIONS, hint: "1-N 选" },
383
294
  { key: "authHeader", label: "authHeader", type: "select", options: ["no", "yes"] },
295
+ { key: "proxy", label: "proxy", type: "text", hint: "(empty = unset; http://host:port)" },
384
296
  ];
385
297
  const initial: Record<string, unknown> = {
386
298
  name: cur.name ?? "",
@@ -388,6 +300,7 @@ export async function editProviderFlow(
388
300
  apiKey: cur.apiKey ?? "",
389
301
  api: cur.api ?? "",
390
302
  authHeader: cur.authHeader ? "yes" : "no",
303
+ proxy: cur.proxy ?? "",
391
304
  };
392
305
  const result = await runFormEditor(ctx, `Edit provider "${providerId}"`, fields, initial);
393
306
  if (!result.saved) { onDone?.(); return; }
@@ -399,6 +312,7 @@ export async function editProviderFlow(
399
312
  apiKey: ((v.apiKey as string) || "") || undefined,
400
313
  api: ((v.api as string) || "") || undefined,
401
314
  authHeader: v.authHeader === "yes",
315
+ proxy: ((v.proxy as string) || "") || undefined,
402
316
  };
403
317
  try {
404
318
  await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: next } });
@@ -462,6 +376,8 @@ export async function editModelFlow(
462
376
  { key: "contextWindow", label: "contextWindow", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
463
377
  { key: "maxTokens", label: "maxTokens", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
464
378
  { key: "thinkingLevelMap", label: "thinkingLevelMap", type: "levelmap", hint: "(empty = remove)" },
379
+ // Zhipu GLM 等 OpenAI-compat 网关不接受 role:"developer" (会返 422)。默认 no 用 system role。
380
+ { key: "supportsDeveloperRole", label: "supportsDeveloperRole (compat)", type: "select", options: ["no", "yes"], hint: "Zhipu GLM 等需 no (用 system role)" },
465
381
  ];
466
382
  const initial: Record<string, unknown> = {
467
383
  name: cur.name ?? "",
@@ -470,6 +386,7 @@ export async function editModelFlow(
470
386
  contextWindow: cur.contextWindow ?? 0,
471
387
  maxTokens: cur.maxTokens ?? 0,
472
388
  thinkingLevelMap: cur.thinkingLevelMap ?? null,
389
+ supportsDeveloperRole: (cur.compat as any)?.supportsDeveloperRole === true ? "yes" : "no",
473
390
  };
474
391
  const result = await runFormEditor(ctx, `Edit model "${providerId}/${modelId}"`, fields, initial);
475
392
  if (!result.saved) { onDone?.(); return; }
@@ -482,6 +399,7 @@ export async function editModelFlow(
482
399
  contextWindow: (v.contextWindow as number) || undefined,
483
400
  maxTokens: (v.maxTokens as number) || undefined,
484
401
  thinkingLevelMap: (v.thinkingLevelMap as Record<string, unknown> | null) ?? undefined,
402
+ compat: { ...(cur.compat ?? {}), supportsDeveloperRole: v.supportsDeveloperRole === "yes" },
485
403
  };
486
404
  const newModels = (prov.models ?? []).map((m) => (m.id === modelId ? next : m));
487
405
  const newProv: ProviderConfig = { ...prov, models: newModels };
@@ -557,7 +475,7 @@ export async function syncFlow(ctx: ExtensionCommandContext, opts: SyncOpts = {}
557
475
  ctx.ui.notify(`Fetching models from ${prov.baseUrl}...`, "info");
558
476
  let result;
559
477
  try {
560
- result = await fetchListing({ baseUrl: prov.baseUrl, apiKey, apiKind, signal: ctx.signal, timeoutMs: 10000 });
478
+ result = await fetchListing({ baseUrl: prov.baseUrl, apiKey, apiKind, proxy: prov.proxy, signal: ctx.signal, timeoutMs: 10000 });
561
479
  } catch (err) {
562
480
  ctx.ui.notify(`Fetch failed: ${err instanceof Error ? err.message : err}`, "error");
563
481
  opts.onDone?.();
@@ -566,7 +484,9 @@ export async function syncFlow(ctx: ExtensionCommandContext, opts: SyncOpts = {}
566
484
  if (result.warnings.length) ctx.ui.notify(result.warnings.join("; "), "warning");
567
485
  if (result.models.length === 0 && (prov.models ?? []).length === 0) { ctx.ui.notify("No models found. Check baseUrl / api key.", "warning"); opts.onDone?.(); return; }
568
486
  const existing = (prov.models ?? []).map((m) => ({ id: m.id }));
569
- const { toAdd } = diffModels(result.models, existing);
487
+ // 关键修复:传 loadDefaultModelConfig() defaults,使 toAdd 使用用户级 default(不是代码内置默认)
488
+ const userDefaults = loadDefaultModelConfig();
489
+ const { toAdd } = diffModels(result.models, existing, { defaults: userDefaults });
570
490
  // wire pi done directly to checklist onConfirm/onCancel (otherwise dialog never closes)
571
491
  // checklist shows ALL models in this provider:
572
492
  // - 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.1",
3
+ "version": "0.2.1",
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",
@@ -17,7 +17,9 @@
17
17
  "ai"
18
18
  ],
19
19
  "pi": {
20
- "extensions": ["./"]
20
+ "extensions": [
21
+ "./"
22
+ ]
21
23
  },
22
24
  "license": "MIT",
23
25
  "author": "fanchaozz <fanchaozz@users.noreply.github.com>",
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
  });