custom-provider-pi 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,8 @@ Tab 键可自动补全子命令与 provider 名(大小写不敏感)。
81
81
  | `--ua <预设\|原始UA>` | 预设 User-Agent(见下),或直接给自定义字符串 |
82
82
  | `--profile <模板键>` | 应用完整请求头模板(`claude-code` / `codex` / `opencode` / `browser` 等,含 UA + 客户端典型头集合) |
83
83
  | `--proxy <URL>` | HTTP/SOCKS 代理地址(支持 `$ENV`) |
84
+ | `--lb-keys "$K1,$K2"` | 多 Key 负载均衡(轮询 + 429 自动冷却) |
85
+ | `--lb-cooldown 60` | 冷却秒数(默认 60) |
84
86
 
85
87
  ### JSON 参数
86
88
 
@@ -180,6 +182,35 @@ Tab 键可自动补全子命令与 provider 名(大小写不敏感)。
180
182
  ```
181
183
 
182
184
  > ⚠️ Node.js 的 `fetch` 需要 `NODE_USE_ENV_PROXY=1` 才会读取代理环境变量(Vite/Tauri 等桌面应用的内置 fetch 也类似)。传统 `http.request` / `https.request` 不读取这些变量;如需在 Node 底层走代理,请用 `global-agent` 等库或升级到 Node ≥ 22 并设置该环境变量。
185
+
186
+ ### 负载均衡(多 Key 轮询 + 自动冷却)
187
+
188
+ 应对中转服务的 RPM/TPM/RTM 限制:一个渠道配多个 API Key,轮询使用;某个 Key 触发限流(HTTP 429)后自动冷却,恢复后重新参与轮询。连续 429 会指数退避(×2,上限 10 分钟);成功请求重置退避计数。
189
+
190
+ **交互向导:** add 时选「需要多 Key 负载均衡?」→ 输入逗号分隔的 Keys → 设定冷却时间(默认 60s)
191
+
192
+ **flags:**
193
+
194
+ ```
195
+ /custom-provider add relay --base-url https://api.gw.example.com/v1 \
196
+ --lb-keys "$KEY_A,$KEY_B,sk-plain" --lb-cooldown 60 --models gpt-4o
197
+ ```
198
+
199
+ **JSON 配置:**
200
+
201
+ ```json
202
+ {
203
+ "name": "relay",
204
+ "baseUrl": "https://api.gw.example.com/v1",
205
+ "lbKeys": ["$KEY_A", "$KEY_B", "sk-plain-c"],
206
+ "lbCooldown": 30,
207
+ "models": ["gpt-4o"]
208
+ }
209
+ ```
210
+
211
+ - Key 值支持 `$ENV` / `!cmd` 引用,注册时解析一次存入内存池
212
+ - `/list` 显示活跃 Key 数(如 `3 Key(2 活跃 / 60s 冷却)`)
213
+ - 单 key 模式不受影响(`apiKey` 字符串仍向后兼容)
183
214
  ## prune:模型修剪
184
215
 
185
216
  添加时自动拉取后已提供关键字过滤;对已有 provider 可用 `prune` 事后修剪:
@@ -319,6 +319,10 @@ interface IProvider {
319
319
  * 生效方式:注册时写入 HTTPS_PROXY/HTTP_PROXY/ALL_PROXY 环境变量,
320
320
  * 需请求库读取这些变量(Node 21+ 的 fetch 需启用 NODE_USE_ENV_PROXY=1) */
321
321
  proxy?: string;
322
+ /** 多 Key 负载均衡:逗号分隔的 API Key 列表(支持 $ENV / !cmd 引用) */
323
+ lbKeys?: string[];
324
+ /** 负载均衡默认冷却时间(秒),不填默认 60 */
325
+ lbCooldown?: number;
322
326
  /** false 表示已禁用(不注册、不出现在 /model);缺失视为启用 */
323
327
  enabled?: boolean;
324
328
  models: (string | IModel)[];
@@ -505,16 +509,28 @@ function buildProviderConfig(provider: IProvider): ProviderConfig {
505
509
  const providerConfig: ProviderConfig = {
506
510
  name: provider.name,
507
511
  baseUrl: provider.baseUrl,
508
- apiKey: provider.apiKey ?? "local",
512
+ // LB 模式:apiKey 用占位符(SDK 生成 Bearer <placeholder>,before_provider_headers 替换)
513
+ // LB 模式:apiKey 用第一个 key 的原始值作占位(保证 pi 鉴权检查通过,
514
+ // before_provider_headers 会在发送前替换为轮询到的真实 key)
515
+ apiKey: provider.lbKeys && provider.lbKeys.length > 0
516
+ ? resolveValue(provider.lbKeys[0]) || "lb-pool"
517
+ : (provider.apiKey ?? "local"),
509
518
  models: provider.models.map((m) => prepareModel(m, provider)),
510
519
  };
511
520
 
521
+ // LB 模式:注入 X-LB-POOL 标记头(before_provider_headers 检测此标记识别 LB 请求)
522
+ if (provider.lbKeys && provider.lbKeys.length > 0) {
523
+ providerHeaders["X-LB-POOL"] = provider.name;
524
+ }
525
+
512
526
  if (Object.keys(providerHeaders).length > 0) {
513
527
  providerConfig.headers = providerHeaders;
514
528
  }
515
529
 
516
- // authHeader:非标准 API 需要 Authorization: Bearer 时由用户显式开启(pi 负责生成)
517
- if (provider.authHeader) {
530
+ // authHeader:LB 模式强制 true(保证 Authorization 头存在供 hook 覆盖)
531
+ if (provider.lbKeys && provider.lbKeys.length > 0) {
532
+ providerConfig.authHeader = true;
533
+ } else if (provider.authHeader) {
518
534
  providerConfig.authHeader = true;
519
535
  }
520
536
 
@@ -767,6 +783,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
767
783
 
768
784
  try {
769
785
  saveConfig(config);
786
+ loadLBPools(); // 同步刷新 LB key 池
770
787
  } catch (error) {
771
788
  ctx.ui.notify(`保存配置失败: ${error instanceof Error ? error.message : String(error)}`, "error");
772
789
  return false;
@@ -845,6 +862,13 @@ export default function customProviderExtension(pi: ExtensionAPI) {
845
862
  if (typeof data.authHeader === "boolean") provider.authHeader = data.authHeader;
846
863
  if (typeof data.proxy === "string" && data.proxy.trim()) provider.proxy = data.proxy;
847
864
  if (typeof data.enabled === "boolean") provider.enabled = data.enabled;
865
+ // 多 Key 负载均衡(JSON 路径):lbKeys: ["$KEY_A","sk-plain"], lbCooldown: 30
866
+ if (Array.isArray(data.lbKeys) && (data.lbKeys as string[]).length > 0) {
867
+ provider.lbKeys = (data.lbKeys as string[]).map(String).filter(Boolean);
868
+ if (typeof data.lbCooldown === "number" && data.lbCooldown > 0) {
869
+ provider.lbCooldown = data.lbCooldown;
870
+ }
871
+ }
848
872
  if (data.headers && typeof data.headers === "object") {
849
873
  // 与 flags/向导同一套校验:拒绝非法名称/值(含 CR/LF 注入)
850
874
  const bad = Object.entries(data.headers).find(([k, v]) => {
@@ -885,6 +909,127 @@ export default function customProviderExtension(pi: ExtensionAPI) {
885
909
  // 监听 session_start 以支持热重载
886
910
  pi.on("session_start", () => {
887
911
  registerProviders();
912
+ loadLBPools();
913
+ });
914
+
915
+ // ================= 负载均衡(LBKeyPool)=================
916
+ // 多 Key 轮询 + 429 自动冷却(默认 60s)
917
+ // 通过 before_provider_headers 替换 Authorization / after_provider_response 冷却
918
+ // 原理:LB 模式的 provider 在 headers 里注入 X-LB-POOL: <name> 标记头,
919
+ // before_provider_headers 检测此标记 → 识别为 LB 请求 → 替换 Authorization
920
+
921
+ class LBKeyPool {
922
+ keys: string[]; // 解析后的 key 值
923
+ cooldownMs: number; // 默认冷却毫秒
924
+ cursor: number = 0; // 轮询游标
925
+ cooldownEnd: number[]; // 每个 key 的冷却结束时间戳
926
+ consecutive429s: number[];// 指数退避计数
927
+ perKeyCooldowns: (number | null | undefined)[]; // 每 key 的冷却覆盖
928
+
929
+ constructor(keys: string[], defaultCooldownSec: number, perKey?: (number | null | undefined)[]) {
930
+ this.keys = keys;
931
+ this.cooldownMs = defaultCooldownSec * 1000;
932
+ this.cooldownEnd = new Array(keys.length).fill(0);
933
+ this.consecutive429s = new Array(keys.length).fill(0);
934
+ this.perKeyCooldowns = perKey ?? [];
935
+ }
936
+
937
+ pick(): string | null {
938
+ const n = this.keys.length;
939
+ const now = Date.now();
940
+ // 最多遍历一轮,找到未在冷却中的 key
941
+ for (let i = 0; i < n; i++) {
942
+ const idx = (this.cursor + i) % n;
943
+ if (this.cooldownEnd[idx] <= now) {
944
+ this.cursor = (idx + 1) % n; // 下次从下一个开始
945
+ return this.keys[idx];
946
+ }
947
+ }
948
+ // 全部冷却中,返回冷却结束最早的(允许微小过期)
949
+ let bestIdx = 0;
950
+ for (let i = 1; i < n; i++) {
951
+ if (this.cooldownEnd[i] < this.cooldownEnd[bestIdx]) bestIdx = i;
952
+ }
953
+ this.cursor = (bestIdx + 1) % n;
954
+ return this.keys[bestIdx];
955
+ }
956
+
957
+ on429(idx: number): void {
958
+ this.consecutive429s[idx]++;
959
+ const base = this.perKeyCooldowns[idx] ?? this.cooldownMs;
960
+ // 指数退避:连续429时 × 2^n,上限 10 分钟
961
+ const multiplier = Math.min(this.consecutive429s[idx], 10);
962
+ const cooldown = Math.min(base * Math.pow(2, multiplier - 1), 600000);
963
+ this.cooldownEnd[idx] = Date.now() + cooldown;
964
+ }
965
+
966
+ onSuccess(idx: number): void {
967
+ this.consecutive429s[idx] = 0;
968
+ }
969
+
970
+ activeCount(): number {
971
+ const now = Date.now();
972
+ return this.keys.filter((_, i) => this.cooldownEnd[i] <= now).length;
973
+ }
974
+ }
975
+
976
+ // 运行时 LB 状态:provider name → key pool
977
+ let lbPools: Map<string, LBKeyPool> = new Map();
978
+ let lastUsedPool: string | null = null; // 上一次请求的 provider name
979
+ let lastUsedKeyIdx: number = -1;
980
+
981
+ function loadLBPools(): void {
982
+ lbPools.clear();
983
+ const config = loadConfig();
984
+ for (const provider of config.providers) {
985
+ if (provider.enabled === false) continue;
986
+ // 优先从 IProvider.lbKeys 读取;兼容旧格式 compat.lb.keys
987
+ const keys: string[] = provider.lbKeys ?? (provider.compat?.lb?.keys as string[] | undefined) ?? [];
988
+ const defaultCooldown = provider.lbCooldown ?? (provider.compat?.lb?.cooldown as number | undefined) ?? 60;
989
+ if (keys.length > 0) {
990
+ const resolved = keys.map(resolveValue).filter(Boolean);
991
+ if (resolved.length === 0) continue;
992
+ lbPools.set(provider.name, new LBKeyPool(resolved, defaultCooldown));
993
+ }
994
+ }
995
+ }
996
+
997
+ // 加载 LB 池 + 注册事件(启动时执行一次)
998
+ loadLBPools();
999
+
1000
+ pi.on("before_provider_headers", (event) => {
1001
+ // 检测 X-LB-POOL 标记头 → 识别为 LB provider 的请求
1002
+ const poolName = event.headers["X-LB-POOL"] as string | undefined;
1003
+ if (!poolName) return;
1004
+ const pool = lbPools.get(poolName);
1005
+ if (!pool) {
1006
+ delete event.headers["X-LB-POOL"];
1007
+ return;
1008
+ }
1009
+ const key = pool.pick();
1010
+ if (!key) {
1011
+ delete event.headers["X-LB-POOL"];
1012
+ return;
1013
+ }
1014
+ // 记录本次使用的 key 索引(用于 after_provider_response 冷却)
1015
+ const keyIdx = pool.keys.indexOf(key);
1016
+ lastUsedPool = poolName;
1017
+ lastUsedKeyIdx = keyIdx;
1018
+ // 替换 Authorization 为真实 key(SDK 已发送 Bearer $LB)
1019
+ event.headers["Authorization"] = `Bearer ${key}`;
1020
+ // 移除标记头(不转发给上游)
1021
+ delete event.headers["X-LB-POOL"];
1022
+ });
1023
+
1024
+ pi.on("after_provider_response", (event) => {
1025
+ if (!lastUsedPool || lastUsedKeyIdx < 0) return;
1026
+ const pool = lbPools.get(lastUsedPool);
1027
+ if (!pool) return;
1028
+ if (event.status === 429) {
1029
+ pool.on429(lastUsedKeyIdx);
1030
+ } else if (event.status >= 200 && event.status < 300) {
1031
+ pool.onSuccess(lastUsedKeyIdx);
1032
+ }
888
1033
  });
889
1034
 
890
1035
  // ================= 子命令实现 =================
@@ -1031,6 +1176,32 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1031
1176
  }
1032
1177
  }
1033
1178
 
1179
+ // ---- 4.6 多 Key 负载均衡(应对 RPM/RTM 限制;选 Yes 输入多个 API Key)----
1180
+ const needLB = await ctx.ui.confirm(
1181
+ "需要多 Key 负载均衡?",
1182
+ "应对 RPM/RTM 等限流:多个 Key 轮询,受限后自动冷却 60s 再恢复"
1183
+ );
1184
+ let lbKeys: string[] | undefined;
1185
+ let lbCooldown: number | undefined;
1186
+ if (needLB) {
1187
+ const lbInput = await ctx.ui.input(
1188
+ "API Keys(逗号分隔,支持 $ENV / !cmd 引用)",
1189
+ "$KEY_A,$KEY_B,sk-plain-c"
1190
+ );
1191
+ if (lbInput && lbInput.trim()) {
1192
+ lbKeys = lbInput.split(",").map((s: string) => s.trim()).filter(Boolean);
1193
+ const cdInput = await ctx.ui.input(
1194
+ "默认冷却时间(秒),受限后自动暂停该 Key,恢复后重新参与轮询",
1195
+ "60"
1196
+ );
1197
+ const cd = Number(cdInput?.trim() || "60");
1198
+ if (cd > 0) lbCooldown = cd;
1199
+ }
1200
+ if (lbKeys && lbKeys.length > 0) {
1201
+ ctx.ui.notify(`已启用 ${lbKeys.length} Key 负载均衡,冷却 ${lbCooldown ?? 60}s`, "info");
1202
+ }
1203
+ }
1204
+
1034
1205
  // ---- 5. 是否自动拉取模型 ----
1035
1206
  const autoFetch = await ctx.ui.confirm(
1036
1207
  "自动从 /v1/models 拉取模型列表?",
@@ -1270,6 +1441,10 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1270
1441
  models,
1271
1442
  };
1272
1443
  if (proxyUrl) newProvider.proxy = proxyUrl;
1444
+ if (lbKeys && lbKeys.length > 0) {
1445
+ newProvider.lbKeys = lbKeys;
1446
+ if (lbCooldown) newProvider.lbCooldown = lbCooldown;
1447
+ }
1273
1448
  if (apiType !== "自动推断" || inferredApi !== "openai-completions") {
1274
1449
  // 显式选择的协议,或自动推断出的非默认协议,需存盘保证幂等
1275
1450
  newProvider.api = inferredApi;
@@ -1403,6 +1578,15 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1403
1578
  if (apiRaw && apiRaw !== "auto") provider.api = apiRaw;
1404
1579
  else if (api !== "openai-completions") provider.api = api;
1405
1580
  if (getFlag(flags, "auth-header")) provider.authHeader = true;
1581
+ const lbKeysFlag = getFlag(flags, "lb-keys");
1582
+ if (lbKeysFlag) {
1583
+ provider.lbKeys = lbKeysFlag.split(",").map((s) => s.trim()).filter(Boolean);
1584
+ const lbCooldownFlag = getFlag(flags, "lb-cooldown");
1585
+ if (lbCooldownFlag) {
1586
+ const n = Number(lbCooldownFlag);
1587
+ if (n > 0) provider.lbCooldown = n;
1588
+ }
1589
+ }
1406
1590
 
1407
1591
  const compatJson = getFlag(flags, "compat");
1408
1592
  if (compatJson) {
@@ -1576,6 +1760,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1576
1760
  try {
1577
1761
  saveConfig(config);
1578
1762
  pi.unregisterProvider(name);
1763
+ lbPools.delete(name); // 同步移除 LB 池
1579
1764
  ctx.ui.notify(`Provider "${name}" 已删除并注销`, "info");
1580
1765
  } catch (error) {
1581
1766
  ctx.ui.notify(`删除失败: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -1660,7 +1845,9 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1660
1845
  const preview =
1661
1846
  ids.length <= 4 ? ids.join(", ") : `${ids.slice(0, 4).join(", ")}, …(共 ${ids.length} 个)`;
1662
1847
  const proxyLine = p.proxy ? `\n 代理: ${p.proxy}` : "";
1663
- return `• ${p.name} [${state}] [${api}]\n 端点: ${p.baseUrl}${proxyLine}\n 模型: ${preview}`;
1848
+ const lbPool = lbPools.get(p.name);
1849
+ const lbLine = lbPool ? `\n 负载均衡: ${lbPool.keys.length} Key(${lbPool.activeCount()} 活跃 / ${lbPool.cooldownMs / 1000}s 冷却)` : "";
1850
+ return `• ${p.name} [${state}] [${api}]\n 端点: ${p.baseUrl}${proxyLine}${lbLine}\n 模型: ${preview}`;
1664
1851
  });
1665
1852
  ctx.ui.notify(`已配置 ${config.providers.length} 个 provider:\n\n${lines.join("\n\n")}\n\n启用/禁用: /custom-provider enable|disable <名称>` , "info");
1666
1853
  };
@@ -1854,7 +2041,9 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1854
2041
  const api = p.api ?? inferApi(p.baseUrl);
1855
2042
  const state = p.enabled === false ? "✗ 禁用" : "✓ 启用";
1856
2043
  const proxyLine = p.proxy ? `\n 代理: ${p.proxy}` : "";
1857
- return `• ${p.name} [${state}] [${api}] ${p.models.length} 个模型\n 端点: ${p.baseUrl}${proxyLine}`;
2044
+ const lbPool = lbPools.get(p.name);
2045
+ const lbLine = lbPool ? `\n 负载均衡: ${lbPool.keys.length} Key(${lbPool.activeCount()} 活跃 / ${lbPool.cooldownMs / 1000}s 冷却)` : "";
2046
+ return `• ${p.name} [${state}] [${api}] ${p.models.length} 个模型\n 端点: ${p.baseUrl}${proxyLine}${lbLine}`;
1858
2047
  });
1859
2048
  ctx.ui.notify(
1860
2049
  `配置文件: ${CONFIG_PATH}\n已配置 ${config.providers.length} 个 provider:\n\n${lines.join("\n\n")}\n\nconfig <name> 查看详情 · config edit 编辑 · config path 路径`,
@@ -1955,13 +2144,21 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1955
2144
  " --name · --base-url/--url · --api-key/--key · --api TYPE",
1956
2145
  " --models \"m1,m2\" · --model m(可多次)",
1957
2146
  " --header \"K: V\"(可多次)· --headers '{\"k\":\"v\"}'",
1958
- " --auth-header · --compat '{...}' · --overrides '{\"modelId\":{...}}'",
2147
+ " --profile <模板键>(完整请求头模板:claude-code / codex / browser 等)",
2148
+ " --ua <预设键|原始UA>(如 claude-code / codex / browser)",
2149
+ " --auth-header · --proxy <URL>(HTTP/SOCKS 代理,$ENV 可用)",
2150
+ " --lb-keys \"$K1,$K2\"(多 Key 负载均衡)· --lb-cooldown 60(冷却秒数)",
2151
+ " --model-api 'id:协议'(可多次,如 claude-x:anthropic-messages)",
2152
+ " --model-base-url 'id:url'(和 --model-api 搭配混用双协议)",
2153
+ " --compat '{...}' · --overrides '{\"modelId\":{...}}'",
1959
2154
  " --force(覆盖已存在)· --json '{...}'(完整配置)",
1960
- " --ua <预设|原始UA>(如 claude-code / codex / opencode / cursor,或直接给自定义串)",
1961
- " --model-api 'id:协议'(可多次,如 claude-x:anthropic-messages)",
1962
- " --model-base-url 'id:url'(可多次,和 --model-api 搭配混用双协议)",
1963
- "",
1964
- "示例:",
2155
+ " ",
2156
+ "负载均衡示例(同渠道多 Key 轮询,429 后自动冷却 60s):",
2157
+ " /custom-provider add relay --base-url https://api.gw.com/v1 \\",
2158
+ " --lb-keys \"$KEY_A,$KEY_B,sk-plain\" --lb-cooldown 60 --force",
2159
+ " JSON: --json '{\"name\":\"relay\",\"baseUrl\":\"...\",\"lbKeys\":[\"k1\",\"k2\"],\"lbCooldown\":30}'",
2160
+ " ",
2161
+ "示例:",
1965
2162
  " /custom-provider add deepseek --base-url https://api.deepseek.com/v1 \\",
1966
2163
  " --api-key $DEEPSEEK_API_KEY --models deepseek-chat,deepseek-reasoner",
1967
2164
  " /custom-provider add --json '{\"name\":\"x\",\"baseUrl\":\"...\",\"models\":[\"a\"]}'",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "custom-provider-pi",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "pi 扩展:统一管理第三方模型 Provider。子命令体系 /custom-provider add|remove|refresh|list|test|config|enable|disable|prune,支持交互向导、flags/JSON 非交互添加、双协议混用(OpenAI + Anthropic)、模型关键字过滤与修剪",
5
5
  "keywords": [
6
6
  "pi-package",