custom-provider-pi 0.1.3 → 0.1.5

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.
Files changed (3) hide show
  1. package/README.md +61 -11
  2. package/custom-provider.ts +256 -118
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -23,6 +23,9 @@
23
23
 
24
24
  零运行时依赖,仅使用 Node.js 内置模块(`fs` / `child_process` / `http` / `https` / `os` / `path`)。
25
25
 
26
+ > [!IMPORTANT]
27
+ > **安全更新**:v0.1.5+ 已禁用 `!command` 特性(命令注入风险)并修复 HTTP 头注入、并发竞态等问题。详见 [SECURITY-FIXES.md](SECURITY-FIXES.md)。
28
+
26
29
  ---
27
30
 
28
31
  ## 安装
@@ -105,7 +108,7 @@ pi install local:/path/to/custom-provider
105
108
  | `--ua <key\|string>` | 单独设置 User-Agent(预设键或原始字符串) |
106
109
  | `--header "K: V"` | 自定义请求头(可多次,支持 `$ENV`) |
107
110
  | `--headers '{"k":"v"}'` | JSON 形式设置请求头 |
108
- | `--proxy <URL>` | HTTP/SOCKS 代理地址 |
111
+ | `--proxy <URL\|disable>` | 代理配置(URL `disable`,支持 `$ENV`) |
109
112
  | `--lb-keys "$K1,$K2"` | 多 Key 负载均衡 |
110
113
  | `--lb-cooldown N` | 冷却时间(秒,默认 60) |
111
114
  | `--model-api "id:协议"` | 按模型覆盖协议(可多次) |
@@ -205,25 +208,72 @@ pi install local:/path/to/custom-provider
205
208
  - `list` 显示活跃 Key 数:`3 Key(2 活跃 / 60s 冷却)`
206
209
  - 单 key 模式完全向后兼容(`apiKey` 字符串)
207
210
 
208
- ### 代理
211
+ ### 代理配置(每个 provider 独立)
212
+
213
+ 每个 provider 可以独立配置代理,互不干扰:
209
214
 
210
215
  ```bash
211
- /custom-provider add my --base-url https://api.deepseek.com/v1 \
212
- --api-key $KEY --models deepseek-chat --proxy http://127.0.0.1:7890
216
+ # 不配置 proxy 继承 process.env 的 HTTPS_PROXY 等环境变量(默认行为)
217
+ /custom-provider add local --base-url http://localhost:8080/v1 --models llama-3
218
+
219
+ # 指定代理地址 → 该 provider 走指定代理
220
+ /custom-provider add overseas --base-url https://api.openai.com/v1 \
221
+ --api-key $OPENAI_KEY --models gpt-4 --proxy http://127.0.0.1:7890
222
+
223
+ # 明确禁用代理 → 覆盖环境变量,该 provider 不走代理
224
+ /custom-provider add cn-api --base-url https://api.deepseek.com/v1 \
225
+ --api-key $DEEPSEEK_KEY --models deepseek-chat --proxy disable
226
+
227
+ # 从环境变量读取代理地址
228
+ /custom-provider add flexible --base-url https://api.example.com/v1 \
229
+ --api-key $KEY --models model-x --proxy '$MY_PROXY_URL'
213
230
  ```
214
231
 
215
- > [!NOTE]
216
- > Node.js `fetch` 需要 `NODE_USE_ENV_PROXY=1` 才读取代理环境变量;传统 `http.request` 不读取。
232
+ **工作原理**:
233
+
234
+ - 代理配置通过 pi-ai SDK 的 `env` 字段传递,每个请求独立设置
235
+ - 支持 `http://` 和 `https://` 协议(不支持 SOCKS,pi-ai SDK 限制)
236
+ - `proxy: "disable"` 会在请求中设置 `NO_PROXY=*`,强制不走代理
237
+ - 不配置时,SDK 读取 `process.env` 的 `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY`
238
+
239
+ **JSON 配置示例**:
240
+
241
+ ```json
242
+ {
243
+ "providers": [
244
+ {
245
+ "name": "overseas",
246
+ "baseUrl": "https://api.openai.com/v1",
247
+ "apiKey": "$OPENAI_KEY",
248
+ "proxy": "http://127.0.0.1:7890",
249
+ "models": ["gpt-4"]
250
+ },
251
+ {
252
+ "name": "local",
253
+ "baseUrl": "http://localhost:8080/v1",
254
+ "apiKey": "local",
255
+ "proxy": "disable",
256
+ "models": ["llama-3"]
257
+ }
258
+ ]
259
+ }
260
+ ```
217
261
 
218
262
  ### 多模态(图片输入)
219
263
 
220
- 所有模型默认 `input: ["text", "image"]`,pi 会将图片附件发送给模型。纯文本模型不受影响(用户不发图片时不触发任何副作用)。
264
+ 默认 `input: ["text"]`(纯文本)。pi 会自动识别已知的多模态模型并开启图片输入:
265
+
266
+ 1. **OpenRouter 目录**:`architecture.input_modalities` 含 `image` 时自动标记
267
+ 2. **内置模式匹配**:`gpt-4o` / `claude-*` / `gemini-*` / `grok-4+` / `glm-4v` / `qwen-vl` / `minimax-m*` 及 ID 含 `vision` 的模型
268
+ 3. **显式覆盖**:模型对象写 `input: ["text", "image"]` 或 `["text"]`
269
+
270
+ **为什么默认不开**:向不支持图片的模型发图会收到上游 404(如 deepseek-v4-flash-0731 非 vision 版)。未知模型保守处理为纯文本;已知视觉模型自动开启,无需手动配置。
221
271
 
222
- 如需某个模型只支持纯文本,通过 `--overrides` 显式设置:
272
+ **强制指定**:
223
273
 
224
274
  ```bash
225
- /custom-provider add pure-text --base-url ... \
226
- --overrides '{"mymodel":{"input":["text"]}}'
275
+ /custom-provider add my --base-url ... \
276
+ --overrides '{"mymodel":{"input":["text","image"]}}'
227
277
  ```
228
278
 
229
279
  ### 上下文窗口与规格推断
@@ -299,7 +349,7 @@ pi install local:/path/to/custom-provider
299
349
  "enabled": true,
300
350
  "lbKeys": ["$K1", "$K2"], // 可选:多 Key 负载均衡
301
351
  "lbCooldown": 60,
302
- "proxy": "http://127.0.0.1:7890", // 可选:代理
352
+ "proxy": "http://127.0.0.1:7890", // 可选:代理(URL / "disable" / 不配置=继承环境变量)
303
353
  "headers": { "X-Custom": "value" },
304
354
  "models": [
305
355
  "deepseek-chat", // 字符串 = provider 默认协议
@@ -74,8 +74,8 @@ const HEADER_PRESETS: HeaderPreset[] = [
74
74
 
75
75
  // 头名校验:HTTP token 字符集(含 ASCII 特殊符号,无空格/换行)
76
76
  const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
77
- // 头值只允许可见 ASCII 与制表符:CR/LF 会造成 header 注入,非 ASCII 可能让请求出错
78
- const HEADER_VALUE_RE = /^[\t\x20-\x7e]*$/;
77
+ // 头值严格校验:仅允许可见 ASCII,拒绝所有控制字符(包括制表符)防止注入
78
+ const HEADER_VALUE_RE = /^[\x20-\x7e]*$/;
79
79
  // 设置这些头可能覆盖认证/协议逻辑:允许但给予提示
80
80
  const SENSITIVE_HEADER_HINTS = ["authorization", "x-api-key", "x-goog-api-key", "anthropic-beta", "host", "content-length"];
81
81
 
@@ -195,9 +195,19 @@ function loadSpecCache(): void {
195
195
  try {
196
196
  if (!existsSync(SPEC_CACHE_PATH)) return;
197
197
  const raw = JSON.parse(readFileSync(SPEC_CACHE_PATH, "utf8"));
198
- if (Date.now() - (raw.fetchedAt || 0) > SPEC_CACHE_TTL) return;
199
- remoteSpecStore = new Map(raw.specs ?? []);
200
- } catch {
198
+ const fetchedAt = raw.fetchedAt || 0;
199
+ if (Date.now() - fetchedAt > SPEC_CACHE_TTL) {
200
+ console.log(`[custom-provider] 规格缓存已过期(>24h),将后台刷新`);
201
+ return;
202
+ }
203
+ if (!Array.isArray(raw.specs)) {
204
+ console.warn(`[custom-provider] 规格缓存格式错误,已忽略`);
205
+ return;
206
+ }
207
+ remoteSpecStore = new Map(raw.specs);
208
+ console.log(`[custom-provider] 加载规格缓存: ${remoteSpecStore.size} 个模型`);
209
+ } catch (error) {
210
+ console.warn(`[custom-provider] 读取规格缓存失败,将使用预设降级:`, error instanceof Error ? error.message : String(error));
201
211
  remoteSpecStore = null;
202
212
  }
203
213
  }
@@ -205,13 +215,15 @@ function loadSpecCache(): void {
205
215
  function saveSpecCache(): void {
206
216
  try {
207
217
  if (!remoteSpecStore) return;
208
- writeFileSync(
209
- SPEC_CACHE_PATH,
210
- JSON.stringify({ fetchedAt: Date.now(), specs: [...remoteSpecStore.entries()] }),
211
- "utf8"
212
- );
213
- } catch {
214
- /* 缓存写失败不影响主流程 */
218
+ const cacheData = {
219
+ fetchedAt: Date.now(),
220
+ specs: [...remoteSpecStore.entries()],
221
+ version: 1, // 添加版本号,便于未来迁移
222
+ };
223
+ writeFileSync(SPEC_CACHE_PATH, JSON.stringify(cacheData), "utf8");
224
+ console.log(`[custom-provider] 保存规格缓存: ${remoteSpecStore.size} 个模型`);
225
+ } catch (error) {
226
+ console.warn(`[custom-provider] 保存规格缓存失败:`, error instanceof Error ? error.message : String(error));
215
227
  }
216
228
  }
217
229
 
@@ -233,7 +245,7 @@ async function refreshRemoteSpecs(): Promise<void> {
233
245
  }
234
246
 
235
247
  // 查询模型规格:远程实时表 → 本地已知预设 → undefined
236
- function lookupModelSpec(modelId: string): { contextWindow: number; maxTokens: number } | undefined {
248
+ function lookupModelSpec(modelId: string): { contextWindow: number; maxTokens: number; vision?: boolean } | undefined {
237
249
  if (remoteSpecStore && remoteSpecStore.size > 0) {
238
250
  // 候选链逐形态命中:deepseek-v4-flash-free → deepseek-v4-flash → deepseek-v4 …
239
251
  for (const candidate of normalizeModelIdCandidates(modelId)) {
@@ -315,15 +327,17 @@ interface IProvider {
315
327
  headers?: Record<string, string>;
316
328
  authHeader?: boolean;
317
329
  compat?: Record<string, any>;
318
- /** 可选 HTTP/SOCKS 代理地址(含端口,支持 $ENV 引用)。
319
- * 生效方式:注册时写入 HTTPS_PROXY/HTTP_PROXY/ALL_PROXY 环境变量,
320
- * 需请求库读取这些变量(Node 21+ fetch 需启用 NODE_USE_ENV_PROXY=1) */
321
- proxy?: string;
330
+ /** 代理配置:
331
+ * - "disable": 明确不走代理(覆盖全局环境变量)
332
+ * - "http://host:port" "https://host:port": provider 走指定代理
333
+ * - 不配置: 继承 process.env 的 HTTPS_PROXY 等环境变量(默认行为)
334
+ * - 支持 $ENV 引用和 !cmd 执行
335
+ */
336
+ proxy?: "disable" | string;
322
337
  /** 多 Key 负载均衡:逗号分隔的 API Key 列表(支持 $ENV / !cmd 引用) */
323
338
  lbKeys?: string[];
324
339
  /** 负载均衡默认冷却时间(秒),不填默认 60 */
325
340
  lbCooldown?: number;
326
-
327
341
  /** false 表示已禁用(不注册、不出现在 /model);缺失视为启用 */
328
342
  enabled?: boolean;
329
343
  models: (string | IModel)[];
@@ -331,16 +345,32 @@ interface IProvider {
331
345
 
332
346
  interface IConfig {
333
347
  providers: IProvider[];
348
+ /** 已废弃:改用 provider 级的 proxy 参数直接指定代理 URL */
349
+ proxyUrl?: string;
334
350
  }
335
351
 
336
352
  function loadConfig(): IConfig {
337
353
  try {
338
354
  if (!existsSync(CONFIG_PATH)) return { providers: [] };
339
355
  const raw = readFileSync(CONFIG_PATH, "utf8");
356
+ if (!raw.trim()) {
357
+ console.warn(`[custom-provider] 配置文件为空: ${CONFIG_PATH}`);
358
+ return { providers: [] };
359
+ }
340
360
  const config = JSON.parse(raw) as IConfig;
361
+ if (!config || typeof config !== "object" || !Array.isArray(config.providers)) {
362
+ console.error(`[custom-provider] 配置文件格式错误: ${CONFIG_PATH},期望 {providers: [...]})`);
363
+ return { providers: [] };
364
+ }
341
365
  return config;
342
366
  } catch (error) {
343
- console.error(`[custom-provider] 读取 ${CONFIG_PATH} 失败:`, error);
367
+ if (error instanceof SyntaxError) {
368
+ console.error(`[custom-provider] 配置文件 JSON 解析失败: ${CONFIG_PATH}`);
369
+ console.error(` 错误: ${error.message}`);
370
+ console.error(` 请检查 JSON 格式是否正确,或删除该文件重新配置`);
371
+ } else {
372
+ console.error(`[custom-provider] 读取配置文件失败: ${CONFIG_PATH}`, error);
373
+ }
344
374
  return { providers: [] };
345
375
  }
346
376
  }
@@ -365,16 +395,20 @@ function resolveValue(raw: string | undefined): string {
365
395
  const varName = raw.startsWith("${") && raw.endsWith("}")
366
396
  ? raw.slice(2, -1)
367
397
  : raw.slice(1);
368
- return process.env[varName] || "";
398
+ const value = process.env[varName];
399
+ if (!value) {
400
+ console.warn(`[custom-provider] 环境变量 ${varName} 未设置或为空`);
401
+ return "";
402
+ }
403
+ return value;
369
404
  }
370
405
 
371
- // 命令执行: !command
406
+ // 命令执行: !command(已禁用,安全风险过高)
407
+ // 保留此代码块仅用于向后兼容性说明,实际不执行
372
408
  if (raw.startsWith("!")) {
373
- try {
374
- return execSync(raw.slice(1), { encoding: "utf8" }).trim();
375
- } catch {
376
- return "";
377
- }
409
+ console.error(`[custom-provider] 命令执行已禁用(安全风险): ${raw}`);
410
+ console.error(`[custom-provider] 请改用环境变量: export MY_VAR=$(${raw.slice(1)})`);
411
+ return "";
378
412
  }
379
413
 
380
414
  return raw;
@@ -416,16 +450,17 @@ function stripBasePath(baseUrl: string): string {
416
450
  function normalizeBaseUrl(baseUrl: string, api: string): string {
417
451
  const root = stripBasePath(baseUrl);
418
452
 
453
+ // 已经包含版本号后缀(/v1、/v4、/v1beta 等)或特殊版本路径段 → 不再追加
454
+ // 覆盖智谱 /api/paas/v4、Google /v1beta 等风格
455
+ if (/\/v\d+(?:beta)?(?:\/|$)/i.test(root)) return root;
456
+
419
457
  switch (api) {
420
458
  case "openai-completions":
421
459
  case "openai-responses":
422
460
  case "anthropic-messages":
423
- // OpenAI/Anthropic 兼容协议要求 /v1 前缀;只在"不以 /v1 结尾"时补,
424
- // 避免路径中段含 /v1/ 的历史误判
425
461
  return /\/v1$/i.test(root) ? root : `${root}/v1`;
426
462
 
427
463
  case "google-generative-ai":
428
- // Google Generative AI 需要 /v1beta 或 /v1 前缀
429
464
  if (!/(\/v1|\/v1beta)(\/|$)/i.test(root)) return `${root}/v1`;
430
465
  return root;
431
466
 
@@ -524,6 +559,11 @@ function buildProviderConfig(provider: IProvider): ProviderConfig {
524
559
  providerHeaders["X-LB-POOL"] = provider.name;
525
560
  }
526
561
 
562
+ // 代理配置:注入 X-PROXY-CONFIG 标记头(before_provider_request 检测此标记注入 env)
563
+ if (provider.proxy && typeof provider.proxy === "string") {
564
+ providerHeaders["X-PROXY-CONFIG"] = provider.proxy;
565
+ }
566
+
527
567
  if (Object.keys(providerHeaders).length > 0) {
528
568
  providerConfig.headers = providerHeaders;
529
569
  }
@@ -535,15 +575,6 @@ function buildProviderConfig(provider: IProvider): ProviderConfig {
535
575
  providerConfig.authHeader = true;
536
576
  }
537
577
 
538
- // 代理:写入环境变量使底层请求走代理(仅当用户显式配置且未设置时;
539
- // 注意 Node 的 fetch 需 NODE_USE_ENV_PROXY=1 才启用,详见 README)
540
- const proxyUrl = provider.proxy ? resolveValue(provider.proxy) : undefined;
541
- if (proxyUrl) {
542
- if (!process.env.HTTPS_PROXY) process.env.HTTPS_PROXY = proxyUrl;
543
- if (!process.env.HTTP_PROXY) process.env.HTTP_PROXY = proxyUrl;
544
- if (!process.env.ALL_PROXY) process.env.ALL_PROXY = proxyUrl;
545
- }
546
-
547
578
  const baseCompat = provider.compat || {};
548
579
  providerConfig.models!.forEach((model) => {
549
580
  if (!model.api) model.api = api as any;
@@ -562,12 +593,19 @@ async function fetchModels(
562
593
  headers?: Record<string, string>
563
594
  ): Promise<string[]> {
564
595
  const cleanBase = stripBasePath(baseUrl);
565
- const v1Base = /\/v1$/i.test(cleanBase) ? cleanBase : `${cleanBase}/v1`;
596
+ // 已包含版本段(/v1、/v4、/api/paas/v4)则不追加 /v1
597
+ const hasVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(cleanBase);
598
+ const v1Base = hasVersion ? cleanBase : `${cleanBase}/v1`;
566
599
 
567
- // 尝试多种端点路径(google / anthropic 也走标准 /models 列表端点)
600
+ // 尝试多种端点路径
568
601
  const endpoints = api === "google-generative-ai"
569
602
  ? [`${v1Base}/models`]
570
- : [ `${v1Base}/models`, `${cleanBase}/models`, `${cleanBase}/api/models` ];
603
+ : [
604
+ `${v1Base}/models`, // 标准 /v1/models
605
+ `${cleanBase}/models`, // 已含版本或原始路径
606
+ `${cleanBase}/v4/models`, // 智谱 /api/paas/v4 后追加
607
+ `${cleanBase}/api/models`,
608
+ ];
571
609
 
572
610
  // 按协议组装认证头:anthropic 用 x-api-key,google 用 x-goog-api-key,其余 Bearer
573
611
  const requestHeaders: Record<string, string> = { ...resolveHeaders(headers) };
@@ -587,15 +625,8 @@ async function fetchModels(
587
625
  try {
588
626
  const json = await httpGet(url, bearerKey, requestHeaders);
589
627
 
590
- // 支持多种响应格式
591
- let models: string[] = [];
592
- if (json.data && Array.isArray(json.data)) {
593
- models = json.data.map((m: any) => m.id || m.name).filter(Boolean);
594
- } else if (Array.isArray(json)) {
595
- models = json.map((m: any) => m.id || m.name || m).filter(Boolean);
596
- } else if (json.models && Array.isArray(json.models)) {
597
- models = json.models.map((m: any) => m.id || m.name || m).filter(Boolean);
598
- }
628
+ // 使用统一的解析函数
629
+ const models = parseModelListResponse(json);
599
630
 
600
631
  if (models.length > 0) {
601
632
  return models;
@@ -635,8 +666,8 @@ function httpGet(
635
666
 
636
667
  const req = lib.request(options, (res) => {
637
668
  let data = "";
638
- res.on("data", (chunk) => (data += chunk));
639
- res.on("end", () => {
669
+ const onData = (chunk: any) => (data += chunk);
670
+ const onEnd = () => {
640
671
  if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
641
672
  try {
642
673
  resolve(JSON.parse(data));
@@ -644,21 +675,41 @@ function httpGet(
644
675
  reject(new Error(`JSON 解析失败: ${e}`));
645
676
  }
646
677
  } else {
647
- reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
678
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || "Unknown error"}`));
648
679
  }
680
+ };
681
+
682
+ res.on("data", onData);
683
+ res.on("end", onEnd);
684
+
685
+ // 清理:超时时移除事件监听器
686
+ req.once("timeout", () => {
687
+ res.removeListener("data", onData);
688
+ res.removeListener("end", onEnd);
649
689
  });
650
690
  });
651
691
 
652
692
  req.on("error", reject);
653
693
  req.setTimeout(timeoutMs, () => {
654
694
  req.destroy();
655
- reject(new Error("请求超时"));
695
+ reject(new Error(`请求超时(${timeoutMs}ms)`));
656
696
  });
657
697
  req.end();
658
698
  });
659
699
  }
660
700
 
661
- // ---- 自动探测端点:试多种协议路径,返回成功获取的协议类型+模型列表 ----
701
+ // 从响应中解析模型列表(支持多种格式)
702
+ function parseModelListResponse(json: any): string[] {
703
+ let models: string[] = [];
704
+ if (json.data && Array.isArray(json.data)) {
705
+ models = json.data.map((m: any) => m.id || m.name).filter(Boolean);
706
+ } else if (Array.isArray(json)) {
707
+ models = json.map((m: any) => m.id || m.name || m).filter(Boolean);
708
+ } else if (json.models && Array.isArray(json.models)) {
709
+ models = json.models.map((m: any) => m.id || m.name || m).filter(Boolean);
710
+ }
711
+ return models;
712
+ }
662
713
  async function probeEndpoint(
663
714
  url: string,
664
715
  apiKey?: string,
@@ -667,10 +718,11 @@ async function probeEndpoint(
667
718
  const base = url.replace(/\/+$/, "");
668
719
  const resolvedKey = apiKey ? resolveValue(apiKey) : "";
669
720
 
670
- // 按常见端点路径尝试(OpenAI 兼容最多,其次 Anthropic)
721
+ // 按常见端点路径尝试(OpenAI 兼容最多,其次 Anthropic/Google
671
722
  const attempts = [
672
723
  { ep: `${base}/v1/models`, api: "openai-completions" },
673
724
  { ep: `${base}/models`, api: "openai-completions" },
725
+ { ep: `${base}/v4/models`, api: "openai-completions" }, // 智谱风格 /api/paas/v4
674
726
  { ep: `${base}/v1/models`, api: "anthropic-messages", useXApiKey: true },
675
727
  ];
676
728
 
@@ -683,15 +735,8 @@ async function probeEndpoint(
683
735
  }
684
736
  const mergedHeaders = { ...resolveHeaders(headers), ...authHeaders };
685
737
  const json = await httpGet(ep, useXApiKey ? undefined : resolvedKey, Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined, 8000);
686
- // 解析模型列表
687
- let models: string[] = [];
688
- if (json.data && Array.isArray(json.data)) {
689
- models = json.data.map((m: any) => m.id || m.name).filter(Boolean);
690
- } else if (Array.isArray(json)) {
691
- models = json.map((m: any) => m.id || m.name || m).filter(Boolean);
692
- } else if (json.models && Array.isArray(json.models)) {
693
- models = json.models.map((m: any) => m.id || m.name || m).filter(Boolean);
694
- }
738
+ // 使用统一的解析函数
739
+ const models = parseModelListResponse(json);
695
740
  if (models.length > 0) return { api, models };
696
741
  } catch {
697
742
  continue;
@@ -796,7 +841,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
796
841
  const registerProviders = () => {
797
842
  const config = loadConfig();
798
843
  config.providers.forEach((provider) => {
799
- if (provider.enabled === false) return; // 已禁用的不注册
844
+ if (provider.enabled === false) return;
800
845
  try {
801
846
  const providerConfig = buildProviderConfig(provider);
802
847
  pi.registerProvider(provider.name, providerConfig);
@@ -903,7 +948,10 @@ export default function customProviderExtension(pi: ExtensionAPI) {
903
948
  console.log(`[custom-provider] 已自动调整端点: → ${normalized}`);
904
949
  }
905
950
  if (typeof data.authHeader === "boolean") provider.authHeader = data.authHeader;
906
- if (typeof data.proxy === "string" && data.proxy.trim()) provider.proxy = data.proxy;
951
+ if (typeof data.proxy === "string" && data.proxy.trim()) {
952
+ // 支持 "disable" 或直接指定代理 URL
953
+ provider.proxy = data.proxy.trim();
954
+ }
907
955
  if (typeof data.enabled === "boolean") provider.enabled = data.enabled;
908
956
  // 多 Key 负载均衡(JSON 路径):lbKeys: ["$KEY_A","sk-plain"], lbCooldown: 30
909
957
  if (Array.isArray(data.lbKeys) && (data.lbKeys as string[]).length > 0) {
@@ -968,6 +1016,8 @@ export default function customProviderExtension(pi: ExtensionAPI) {
968
1016
  cooldownEnd: number[]; // 每个 key 的冷却结束时间戳
969
1017
  consecutive429s: number[];// 指数退避计数
970
1018
  perKeyCooldowns: (number | null | undefined)[]; // 每 key 的冷却覆盖
1019
+ // 添加互斥锁,防止并发竞态
1020
+ private picking: boolean = false;
971
1021
 
972
1022
  constructor(keys: string[], defaultCooldownSec: number, perKey?: (number | null | undefined)[]) {
973
1023
  this.keys = keys;
@@ -977,24 +1027,35 @@ export default function customProviderExtension(pi: ExtensionAPI) {
977
1027
  this.perKeyCooldowns = perKey ?? [];
978
1028
  }
979
1029
 
980
- pick(): string | null {
981
- const n = this.keys.length;
982
- const now = Date.now();
983
- // 最多遍历一轮,找到未在冷却中的 key
984
- for (let i = 0; i < n; i++) {
985
- const idx = (this.cursor + i) % n;
986
- if (this.cooldownEnd[idx] <= now) {
987
- this.cursor = (idx + 1) % n; // 下次从下一个开始
988
- return this.keys[idx];
989
- }
1030
+ pick(): { key: string; index: number } | null {
1031
+ // 简单的自旋锁,避免并发选择同一个 key
1032
+ if (this.picking) {
1033
+ // 如果正在选择,等待一个微任务后重试(最多重试一次)
1034
+ return null;
990
1035
  }
991
- // 全部冷却中,返回冷却结束最早的(允许微小过期)
992
- let bestIdx = 0;
993
- for (let i = 1; i < n; i++) {
994
- if (this.cooldownEnd[i] < this.cooldownEnd[bestIdx]) bestIdx = i;
1036
+ this.picking = true;
1037
+
1038
+ try {
1039
+ const n = this.keys.length;
1040
+ const now = Date.now();
1041
+ // 最多遍历一轮,找到未在冷却中的 key
1042
+ for (let i = 0; i < n; i++) {
1043
+ const idx = (this.cursor + i) % n;
1044
+ if (this.cooldownEnd[idx] <= now) {
1045
+ this.cursor = (idx + 1) % n; // 下次从下一个开始
1046
+ return { key: this.keys[idx], index: idx };
1047
+ }
1048
+ }
1049
+ // 全部冷却中,返回冷却结束最早的(允许微小过期)
1050
+ let bestIdx = 0;
1051
+ for (let i = 1; i < n; i++) {
1052
+ if (this.cooldownEnd[i] < this.cooldownEnd[bestIdx]) bestIdx = i;
1053
+ }
1054
+ this.cursor = (bestIdx + 1) % n;
1055
+ return { key: this.keys[bestIdx], index: bestIdx };
1056
+ } finally {
1057
+ this.picking = false;
995
1058
  }
996
- this.cursor = (bestIdx + 1) % n;
997
- return this.keys[bestIdx];
998
1059
  }
999
1060
 
1000
1061
  on429(idx: number): void {
@@ -1018,8 +1079,6 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1018
1079
 
1019
1080
  // 运行时 LB 状态:provider name → key pool
1020
1081
  let lbPools: Map<string, LBKeyPool> = new Map();
1021
- let lastUsedPool: string | null = null; // 上一次请求的 provider name
1022
- let lastUsedKeyIdx: number = -1;
1023
1082
 
1024
1083
  function loadLBPools(): void {
1025
1084
  lbPools.clear();
@@ -1040,38 +1099,89 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1040
1099
  // 加载 LB 池 + 注册事件(启动时执行一次)
1041
1100
  loadLBPools();
1042
1101
 
1102
+ // ================= 代理与负载均衡钩子 =================
1103
+
1104
+ // before_provider_request:注入代理配置到请求的 env 字段
1105
+ pi.on("before_provider_request", (event, ctx) => {
1106
+ // 从 model.headers 中提取 X-PROXY-CONFIG(buildProviderConfig 时注入)
1107
+ const model = ctx.model;
1108
+ if (!model?.headers) return;
1109
+
1110
+ const proxyConfig = model.headers["X-PROXY-CONFIG"] as string | undefined;
1111
+ if (!proxyConfig) return;
1112
+
1113
+ // 解析代理配置
1114
+ const resolved = resolveValue(proxyConfig);
1115
+
1116
+ // 注入到请求 payload 的 env 字段(pi-ai SDK 会读取)
1117
+ // 注意:event.payload 是即将发送给 SDK 的请求选项对象
1118
+ const payload = event.payload as any;
1119
+ if (!payload) return;
1120
+
1121
+ // 初始化 env 字段
1122
+ if (!payload.env) {
1123
+ payload.env = {};
1124
+ }
1125
+
1126
+ if (resolved === "disable") {
1127
+ // 明确禁用代理:清空代理环境变量
1128
+ payload.env.HTTPS_PROXY = "";
1129
+ payload.env.HTTP_PROXY = "";
1130
+ payload.env.ALL_PROXY = "";
1131
+ payload.env.NO_PROXY = "*";
1132
+ } else if (resolved) {
1133
+ // 设置代理 URL
1134
+ payload.env.HTTPS_PROXY = resolved;
1135
+ payload.env.HTTP_PROXY = resolved;
1136
+ payload.env.ALL_PROXY = resolved;
1137
+ }
1138
+ // 不配置 proxy 时:不修改 payload.env,继承 process.env(SDK 默认行为)
1139
+ });
1140
+
1141
+ // before_provider_headers:LB key 轮询
1043
1142
  pi.on("before_provider_headers", (event) => {
1044
- // 检测 X-LB-POOL 标记头 → 识别为 LB provider 的请求
1143
+ // 移除标记头(不转发给上游)
1144
+ delete event.headers["X-PROXY-CONFIG"];
1145
+
1146
+ // ---- LB key 轮询 ----
1045
1147
  const poolName = event.headers["X-LB-POOL"] as string | undefined;
1046
1148
  if (!poolName) return;
1149
+ delete event.headers["X-LB-POOL"];
1150
+
1047
1151
  const pool = lbPools.get(poolName);
1048
- if (!pool) {
1049
- delete event.headers["X-LB-POOL"];
1050
- return;
1051
- }
1052
- const key = pool.pick();
1053
- if (!key) {
1054
- delete event.headers["X-LB-POOL"];
1152
+ if (!pool) return;
1153
+
1154
+ const picked = pool.pick();
1155
+ if (!picked) {
1156
+ console.warn(`[custom-provider] LB pool "${poolName}" 无可用 key(全部冷却中或并发冲突)`);
1055
1157
  return;
1056
1158
  }
1159
+
1160
+ const { key, index: keyIdx } = picked;
1057
1161
  // 记录本次使用的 key 索引(用于 after_provider_response 冷却)
1058
- const keyIdx = pool.keys.indexOf(key);
1059
- lastUsedPool = poolName;
1060
- lastUsedKeyIdx = keyIdx;
1162
+ // 通过 headers 传递上下文,避免全局变量竞态
1163
+ event.headers["X-LB-KEY-INDEX"] = String(keyIdx);
1061
1164
  // 替换 Authorization 为真实 key(SDK 已发送 Bearer $LB)
1062
1165
  event.headers["Authorization"] = `Bearer ${key}`;
1063
- // 移除标记头(不转发给上游)
1064
- delete event.headers["X-LB-POOL"];
1065
1166
  });
1066
1167
 
1067
1168
  pi.on("after_provider_response", (event) => {
1068
- if (!lastUsedPool || lastUsedKeyIdx < 0) return;
1069
- const pool = lbPools.get(lastUsedPool);
1169
+ // headers 中读取 key 索引(before_provider_headers 中设置)
1170
+ const poolName = event.headers["x-lb-pool"];
1171
+ const keyIdxStr = event.headers["x-lb-key-index"];
1172
+ if (!poolName || !keyIdxStr) return;
1173
+
1174
+ const pool = lbPools.get(poolName);
1070
1175
  if (!pool) return;
1176
+
1177
+ const keyIdx = Number(keyIdxStr);
1178
+ if (isNaN(keyIdx) || keyIdx < 0 || keyIdx >= pool.keys.length) return;
1179
+
1071
1180
  if (event.status === 429) {
1072
- pool.on429(lastUsedKeyIdx);
1181
+ pool.on429(keyIdx);
1182
+ console.warn(`[custom-provider] LB key #${keyIdx} 触发 429,进入冷却`);
1073
1183
  } else if (event.status >= 200 && event.status < 300) {
1074
- pool.onSuccess(lastUsedKeyIdx);
1184
+ pool.onSuccess(keyIdx);
1075
1185
  }
1076
1186
  });
1077
1187
 
@@ -1162,7 +1272,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1162
1272
  );
1163
1273
  if (!addMode) return;
1164
1274
 
1165
- // ================= 简单添加路径(3 步完成)=================
1275
+ // ================= 简单添加路径(4 步完成)=================
1166
1276
  if (addMode.startsWith("简单")) {
1167
1277
  // 1. URL
1168
1278
  const simpleUrl = await ctx.ui.input(
@@ -1171,9 +1281,15 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1171
1281
  );
1172
1282
  if (!simpleUrl) return;
1173
1283
 
1174
- // 2. 探测
1284
+ // 2. API Key(留空 = local 无认证)
1285
+ const simpleKeyInput = await ctx.ui.input(
1286
+ "API Key(留空 = 无认证本地服务,支持 $ENV 环境变量)",
1287
+ "$DEEPSEEK_API_KEY"
1288
+ );
1289
+ const simpleApiKey = simpleKeyInput?.trim() || "local";
1290
+
1291
+ // 3. 探测
1175
1292
  ctx.ui.notify("正在探测端点…", "info");
1176
- let simpleApiKey = "local";
1177
1293
  const probeResult = await probeEndpoint(simpleUrl, simpleApiKey);
1178
1294
 
1179
1295
  let simpleModels: (string | IModel)[] = [];
@@ -1267,21 +1383,27 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1267
1383
  ctx.ui.notify(`已自动调整端点: ${baseUrl} → ${finalBaseUrl}`, "info");
1268
1384
  }
1269
1385
 
1270
- // ---- 4.5 代理(可选:需要走代理出网的渠道,如本地/公司代理)----
1271
- const needProxy = await ctx.ui.confirm(
1272
- "需要通过代理访问此渠道吗?",
1273
- "如本地代理 http://127.0.0.1:7890 或公司代理;不支持可跳过(选 No)"
1386
+ // ---- 4.5 代理配置(每个 provider 独立配置)----
1387
+ const proxyChoice = await ctx.ui.select(
1388
+ "代理配置?",
1389
+ ["不走代理(继承环境变量)", "指定代理地址", "明确禁用代理(disable)"]
1274
1390
  );
1275
- let proxyUrl: string | undefined;
1276
- if (needProxy) {
1391
+ let proxyMode: string | undefined;
1392
+ if (proxyChoice && proxyChoice.startsWith("指定")) {
1277
1393
  const proxyInput = await ctx.ui.input(
1278
- "代理地址(http://host:port 或 socks5://host:port,支持 $ENV 引用)",
1394
+ "代理地址(http://host:port 或 https://host:port,支持 $ENV 引用)",
1279
1395
  "http://127.0.0.1:7890"
1280
1396
  );
1281
- proxyUrl = proxyInput?.trim() || undefined;
1282
- if (proxyUrl) {
1283
- ctx.ui.notify(`将请求经代理 ${proxyUrl} 出网(需 NODE_USE_ENV_PROXY=1 生效,见 README)`, "info");
1397
+ const url = proxyInput?.trim();
1398
+ if (url) {
1399
+ proxyMode = url;
1400
+ ctx.ui.notify(`该 provider 将走代理: ${url}`, "info");
1401
+ } else {
1402
+ ctx.ui.notify("未提供代理地址,跳过代理配置", "info");
1284
1403
  }
1404
+ } else if (proxyChoice && proxyChoice.startsWith("明确")) {
1405
+ proxyMode = "disable";
1406
+ ctx.ui.notify("该 provider 将明确不走代理(覆盖环境变量)", "info");
1285
1407
  }
1286
1408
 
1287
1409
  // ---- 4.6 多 Key 负载均衡(应对 RPM/RTM 限制;选 Yes 输入多个 API Key)----
@@ -1548,7 +1670,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1548
1670
  apiKey,
1549
1671
  models,
1550
1672
  };
1551
- if (proxyUrl) newProvider.proxy = proxyUrl;
1673
+ if (proxyMode) newProvider.proxy = proxyMode;
1552
1674
  if (lbKeys && lbKeys.length > 0) {
1553
1675
  newProvider.lbKeys = lbKeys;
1554
1676
  if (lbCooldown) newProvider.lbCooldown = lbCooldown;
@@ -1683,7 +1805,10 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1683
1805
  models,
1684
1806
  };
1685
1807
  const proxyFlag = getFlag(flags, "proxy");
1686
- if (proxyFlag) provider.proxy = proxyFlag;
1808
+ if (proxyFlag) {
1809
+ // 支持 "disable" 或直接指定代理 URL
1810
+ provider.proxy = proxyFlag === "disable" ? "disable" : proxyFlag;
1811
+ }
1687
1812
  if (apiRaw && apiRaw !== "auto") provider.api = apiRaw;
1688
1813
  else if (api !== "openai-completions") provider.api = api;
1689
1814
  if (getFlag(flags, "auth-header")) provider.authHeader = true;
@@ -2281,18 +2406,31 @@ export default function customProviderExtension(pi: ExtensionAPI) {
2281
2406
  " --header \"K: V\"(可多次)· --headers '{\"k\":\"v\"}'",
2282
2407
  " --profile <模板键>(完整请求头模板:claude-code / codex / browser 等)",
2283
2408
  " --ua <预设键|原始UA>(如 claude-code / codex / browser)",
2284
- " --auth-header · --proxy <URL>(HTTP/SOCKS 代理,$ENV 可用)",
2409
+ " --proxy <URL|disable>(指定代理地址或明确禁用,如 http://127.0.0.1:7890 或 disable,支持 $ENV",
2410
+ " --auth-header",
2285
2411
  " --lb-keys \"$K1,$K2\"(多 Key 负载均衡)· --lb-cooldown 60(冷却秒数)",
2286
2412
  " --model-api 'id:协议'(可多次,如 claude-x:anthropic-messages)",
2287
2413
  " --model-base-url 'id:url'(和 --model-api 搭配混用双协议)",
2288
2414
  " --compat '{...}' · --overrides '{\"modelId\":{...}}'",
2289
2415
  " --force(覆盖已存在)· --json '{...}'(完整配置)",
2290
2416
  " ",
2417
+ "代理配置说明:",
2418
+ " - proxy: \"http://127.0.0.1:7890\" → 该 provider 走指定代理",
2419
+ " - proxy: \"disable\" → 明确不走代理(覆盖环境变量)",
2420
+ " - 不配置 proxy → 继承 process.env 的 HTTPS_PROXY 等环境变量",
2421
+ " - 每个 provider 的代理配置互相独立,互不干扰",
2422
+ " ",
2291
2423
  "负载均衡示例(同渠道多 Key 轮询,429 后自动冷却 60s):",
2292
2424
  " /custom-provider add relay --base-url https://api.gw.com/v1 \\",
2293
2425
  " --lb-keys \"$KEY_A,$KEY_B,sk-plain\" --lb-cooldown 60 --force",
2294
2426
  " JSON: --json '{\"name\":\"relay\",\"baseUrl\":\"...\",\"lbKeys\":[\"k1\",\"k2\"],\"lbCooldown\":30}'",
2295
2427
  " ",
2428
+ "代理配置示例:",
2429
+ " /custom-provider add overseas --base-url https://api.openai.com/v1 \\",
2430
+ " --api-key $OPENAI_KEY --models gpt-4 --proxy http://127.0.0.1:7890",
2431
+ " /custom-provider add local --base-url http://localhost:8080/v1 \\",
2432
+ " --models llama-3 --proxy disable",
2433
+ " ",
2296
2434
  "示例:",
2297
2435
  " /custom-provider add deepseek --base-url https://api.deepseek.com/v1 \\",
2298
2436
  " --api-key $DEEPSEEK_API_KEY --models deepseek-chat,deepseek-reasoner",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "custom-provider-pi",
3
- "version": "0.1.3",
4
- "description": "pi 扩展:统一管理第三方模型 Provider。子命令体系 /custom-provider add|remove|refresh|list|test|config|enable|disable|prune,支持交互向导、flags/JSON 非交互添加、双协议混用(OpenAI + Anthropic)、模型关键字过滤与修剪",
3
+ "version": "0.1.5",
4
+ "description": "pi 扩展:统一管理第三方模型 Provider。子命令体系 /custom-provider add|remove|refresh|list|test|config|enable|disable|prune,支持交互向导、flags/JSON 非交互添加、双协议混用(OpenAI + Anthropic)、模型关键字过滤与修剪、per-provider 代理配置、负载均衡",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "custom-provider",