custom-provider-pi 0.1.4 → 0.1.6
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 +51 -7
- package/custom-provider.ts +238 -115
- 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>` |
|
|
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,15 +208,56 @@ 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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
|
@@ -305,7 +349,7 @@ pi install local:/path/to/custom-provider
|
|
|
305
349
|
"enabled": true,
|
|
306
350
|
"lbKeys": ["$K1", "$K2"], // 可选:多 Key 负载均衡
|
|
307
351
|
"lbCooldown": 60,
|
|
308
|
-
"proxy": "http://127.0.0.1:7890", //
|
|
352
|
+
"proxy": "http://127.0.0.1:7890", // 可选:代理(URL / "disable" / 不配置=继承环境变量)
|
|
309
353
|
"headers": { "X-Custom": "value" },
|
|
310
354
|
"models": [
|
|
311
355
|
"deepseek-chat", // 字符串 = provider 默认协议
|
package/custom-provider.ts
CHANGED
|
@@ -36,7 +36,7 @@ const UA_PRESETS: Record<string, string> = {
|
|
|
36
36
|
|
|
37
37
|
// 请求头模板(借鉴 LiveAgent:按客户端/CLI 预设整组请求头,而非只预设 UA):
|
|
38
38
|
// 不同客户端携带的头集合不同(Claude Code 有 x-app/anthropic-version/X-Stainless-* 等)。
|
|
39
|
-
//
|
|
39
|
+
// 选模板一次性灌入;选"自定义"逐头输入。
|
|
40
40
|
interface HeaderPreset {
|
|
41
41
|
label: string;
|
|
42
42
|
key?: string; // undefined = 自定义
|
|
@@ -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
|
-
//
|
|
78
|
-
const HEADER_VALUE_RE = /^[\
|
|
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
|
|
|
@@ -91,8 +91,8 @@ function hasHeader(headers: Record<string, string> | undefined, name: string): b
|
|
|
91
91
|
// ---- 远程规格查询(OpenRouter 公开目录,带磁盘缓存)----
|
|
92
92
|
|
|
93
93
|
// 输出上限处置(借鉴 LiveAgent normalizeModelLimits)
|
|
94
|
-
//
|
|
95
|
-
//
|
|
94
|
+
// 社区目录/中转对不公布独立输出上限的模型常给退化值"输出==窗口",
|
|
95
|
+
// 照单全收会把"窗口−输出预留"的输入预算挤成零。处理:钳到保守上限,
|
|
96
96
|
// 并保底留 3/4 窗口给输入。
|
|
97
97
|
const MAX_OUTPUT_TOKEN_CAP = 32000;
|
|
98
98
|
|
|
@@ -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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
|
@@ -315,15 +327,17 @@ interface IProvider {
|
|
|
315
327
|
headers?: Record<string, string>;
|
|
316
328
|
authHeader?: boolean;
|
|
317
329
|
compat?: Record<string, any>;
|
|
318
|
-
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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;
|
|
@@ -525,6 +559,11 @@ function buildProviderConfig(provider: IProvider): ProviderConfig {
|
|
|
525
559
|
providerHeaders["X-LB-POOL"] = provider.name;
|
|
526
560
|
}
|
|
527
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
|
+
|
|
528
567
|
if (Object.keys(providerHeaders).length > 0) {
|
|
529
568
|
providerConfig.headers = providerHeaders;
|
|
530
569
|
}
|
|
@@ -536,15 +575,6 @@ function buildProviderConfig(provider: IProvider): ProviderConfig {
|
|
|
536
575
|
providerConfig.authHeader = true;
|
|
537
576
|
}
|
|
538
577
|
|
|
539
|
-
// 代理:写入环境变量使底层请求走代理(仅当用户显式配置且未设置时;
|
|
540
|
-
// 注意 Node 的 fetch 需 NODE_USE_ENV_PROXY=1 才启用,详见 README)
|
|
541
|
-
const proxyUrl = provider.proxy ? resolveValue(provider.proxy) : undefined;
|
|
542
|
-
if (proxyUrl) {
|
|
543
|
-
if (!process.env.HTTPS_PROXY) process.env.HTTPS_PROXY = proxyUrl;
|
|
544
|
-
if (!process.env.HTTP_PROXY) process.env.HTTP_PROXY = proxyUrl;
|
|
545
|
-
if (!process.env.ALL_PROXY) process.env.ALL_PROXY = proxyUrl;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
578
|
const baseCompat = provider.compat || {};
|
|
549
579
|
providerConfig.models!.forEach((model) => {
|
|
550
580
|
if (!model.api) model.api = api as any;
|
|
@@ -595,15 +625,8 @@ async function fetchModels(
|
|
|
595
625
|
try {
|
|
596
626
|
const json = await httpGet(url, bearerKey, requestHeaders);
|
|
597
627
|
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
if (json.data && Array.isArray(json.data)) {
|
|
601
|
-
models = json.data.map((m: any) => m.id || m.name).filter(Boolean);
|
|
602
|
-
} else if (Array.isArray(json)) {
|
|
603
|
-
models = json.map((m: any) => m.id || m.name || m).filter(Boolean);
|
|
604
|
-
} else if (json.models && Array.isArray(json.models)) {
|
|
605
|
-
models = json.models.map((m: any) => m.id || m.name || m).filter(Boolean);
|
|
606
|
-
}
|
|
628
|
+
// 使用统一的解析函数
|
|
629
|
+
const models = parseModelListResponse(json);
|
|
607
630
|
|
|
608
631
|
if (models.length > 0) {
|
|
609
632
|
return models;
|
|
@@ -643,8 +666,8 @@ function httpGet(
|
|
|
643
666
|
|
|
644
667
|
const req = lib.request(options, (res) => {
|
|
645
668
|
let data = "";
|
|
646
|
-
|
|
647
|
-
|
|
669
|
+
const onData = (chunk: any) => (data += chunk);
|
|
670
|
+
const onEnd = () => {
|
|
648
671
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
649
672
|
try {
|
|
650
673
|
resolve(JSON.parse(data));
|
|
@@ -652,21 +675,41 @@ function httpGet(
|
|
|
652
675
|
reject(new Error(`JSON 解析失败: ${e}`));
|
|
653
676
|
}
|
|
654
677
|
} else {
|
|
655
|
-
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
|
678
|
+
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || "Unknown error"}`));
|
|
656
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);
|
|
657
689
|
});
|
|
658
690
|
});
|
|
659
691
|
|
|
660
692
|
req.on("error", reject);
|
|
661
693
|
req.setTimeout(timeoutMs, () => {
|
|
662
694
|
req.destroy();
|
|
663
|
-
reject(new Error(
|
|
695
|
+
reject(new Error(`请求超时(${timeoutMs}ms)`));
|
|
664
696
|
});
|
|
665
697
|
req.end();
|
|
666
698
|
});
|
|
667
699
|
}
|
|
668
700
|
|
|
669
|
-
//
|
|
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
|
+
}
|
|
670
713
|
async function probeEndpoint(
|
|
671
714
|
url: string,
|
|
672
715
|
apiKey?: string,
|
|
@@ -692,15 +735,8 @@ async function probeEndpoint(
|
|
|
692
735
|
}
|
|
693
736
|
const mergedHeaders = { ...resolveHeaders(headers), ...authHeaders };
|
|
694
737
|
const json = await httpGet(ep, useXApiKey ? undefined : resolvedKey, Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined, 8000);
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
if (json.data && Array.isArray(json.data)) {
|
|
698
|
-
models = json.data.map((m: any) => m.id || m.name).filter(Boolean);
|
|
699
|
-
} else if (Array.isArray(json)) {
|
|
700
|
-
models = json.map((m: any) => m.id || m.name || m).filter(Boolean);
|
|
701
|
-
} else if (json.models && Array.isArray(json.models)) {
|
|
702
|
-
models = json.models.map((m: any) => m.id || m.name || m).filter(Boolean);
|
|
703
|
-
}
|
|
738
|
+
// 使用统一的解析函数
|
|
739
|
+
const models = parseModelListResponse(json);
|
|
704
740
|
if (models.length > 0) return { api, models };
|
|
705
741
|
} catch {
|
|
706
742
|
continue;
|
|
@@ -805,7 +841,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
805
841
|
const registerProviders = () => {
|
|
806
842
|
const config = loadConfig();
|
|
807
843
|
config.providers.forEach((provider) => {
|
|
808
|
-
if (provider.enabled === false) return;
|
|
844
|
+
if (provider.enabled === false) return;
|
|
809
845
|
try {
|
|
810
846
|
const providerConfig = buildProviderConfig(provider);
|
|
811
847
|
pi.registerProvider(provider.name, providerConfig);
|
|
@@ -912,7 +948,10 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
912
948
|
console.log(`[custom-provider] 已自动调整端点: → ${normalized}`);
|
|
913
949
|
}
|
|
914
950
|
if (typeof data.authHeader === "boolean") provider.authHeader = data.authHeader;
|
|
915
|
-
if (typeof data.proxy === "string" && data.proxy.trim())
|
|
951
|
+
if (typeof data.proxy === "string" && data.proxy.trim()) {
|
|
952
|
+
// 支持 "disable" 或直接指定代理 URL
|
|
953
|
+
provider.proxy = data.proxy.trim();
|
|
954
|
+
}
|
|
916
955
|
if (typeof data.enabled === "boolean") provider.enabled = data.enabled;
|
|
917
956
|
// 多 Key 负载均衡(JSON 路径):lbKeys: ["$KEY_A","sk-plain"], lbCooldown: 30
|
|
918
957
|
if (Array.isArray(data.lbKeys) && (data.lbKeys as string[]).length > 0) {
|
|
@@ -977,6 +1016,8 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
977
1016
|
cooldownEnd: number[]; // 每个 key 的冷却结束时间戳
|
|
978
1017
|
consecutive429s: number[];// 指数退避计数
|
|
979
1018
|
perKeyCooldowns: (number | null | undefined)[]; // 每 key 的冷却覆盖
|
|
1019
|
+
// 添加互斥锁,防止并发竞态
|
|
1020
|
+
private picking: boolean = false;
|
|
980
1021
|
|
|
981
1022
|
constructor(keys: string[], defaultCooldownSec: number, perKey?: (number | null | undefined)[]) {
|
|
982
1023
|
this.keys = keys;
|
|
@@ -986,24 +1027,35 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
986
1027
|
this.perKeyCooldowns = perKey ?? [];
|
|
987
1028
|
}
|
|
988
1029
|
|
|
989
|
-
pick(): string | null {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
const idx = (this.cursor + i) % n;
|
|
995
|
-
if (this.cooldownEnd[idx] <= now) {
|
|
996
|
-
this.cursor = (idx + 1) % n; // 下次从下一个开始
|
|
997
|
-
return this.keys[idx];
|
|
998
|
-
}
|
|
1030
|
+
pick(): { key: string; index: number } | null {
|
|
1031
|
+
// 简单的自旋锁,避免并发选择同一个 key
|
|
1032
|
+
if (this.picking) {
|
|
1033
|
+
// 如果正在选择,等待一个微任务后重试(最多重试一次)
|
|
1034
|
+
return null;
|
|
999
1035
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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;
|
|
1004
1058
|
}
|
|
1005
|
-
this.cursor = (bestIdx + 1) % n;
|
|
1006
|
-
return this.keys[bestIdx];
|
|
1007
1059
|
}
|
|
1008
1060
|
|
|
1009
1061
|
on429(idx: number): void {
|
|
@@ -1027,8 +1079,6 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
1027
1079
|
|
|
1028
1080
|
// 运行时 LB 状态:provider name → key pool
|
|
1029
1081
|
let lbPools: Map<string, LBKeyPool> = new Map();
|
|
1030
|
-
let lastUsedPool: string | null = null; // 上一次请求的 provider name
|
|
1031
|
-
let lastUsedKeyIdx: number = -1;
|
|
1032
1082
|
|
|
1033
1083
|
function loadLBPools(): void {
|
|
1034
1084
|
lbPools.clear();
|
|
@@ -1049,38 +1099,89 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
1049
1099
|
// 加载 LB 池 + 注册事件(启动时执行一次)
|
|
1050
1100
|
loadLBPools();
|
|
1051
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 轮询
|
|
1052
1142
|
pi.on("before_provider_headers", (event) => {
|
|
1053
|
-
//
|
|
1143
|
+
// 移除标记头(不转发给上游)
|
|
1144
|
+
delete event.headers["X-PROXY-CONFIG"];
|
|
1145
|
+
|
|
1146
|
+
// ---- LB key 轮询 ----
|
|
1054
1147
|
const poolName = event.headers["X-LB-POOL"] as string | undefined;
|
|
1055
1148
|
if (!poolName) return;
|
|
1149
|
+
delete event.headers["X-LB-POOL"];
|
|
1150
|
+
|
|
1056
1151
|
const pool = lbPools.get(poolName);
|
|
1057
|
-
if (!pool)
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
if (!key) {
|
|
1063
|
-
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(全部冷却中或并发冲突)`);
|
|
1064
1157
|
return;
|
|
1065
1158
|
}
|
|
1159
|
+
|
|
1160
|
+
const { key, index: keyIdx } = picked;
|
|
1066
1161
|
// 记录本次使用的 key 索引(用于 after_provider_response 冷却)
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
lastUsedKeyIdx = keyIdx;
|
|
1162
|
+
// 通过 headers 传递上下文,避免全局变量竞态
|
|
1163
|
+
event.headers["X-LB-KEY-INDEX"] = String(keyIdx);
|
|
1070
1164
|
// 替换 Authorization 为真实 key(SDK 已发送 Bearer $LB)
|
|
1071
1165
|
event.headers["Authorization"] = `Bearer ${key}`;
|
|
1072
|
-
// 移除标记头(不转发给上游)
|
|
1073
|
-
delete event.headers["X-LB-POOL"];
|
|
1074
1166
|
});
|
|
1075
1167
|
|
|
1076
1168
|
pi.on("after_provider_response", (event) => {
|
|
1077
|
-
|
|
1078
|
-
const
|
|
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);
|
|
1079
1175
|
if (!pool) return;
|
|
1176
|
+
|
|
1177
|
+
const keyIdx = Number(keyIdxStr);
|
|
1178
|
+
if (isNaN(keyIdx) || keyIdx < 0 || keyIdx >= pool.keys.length) return;
|
|
1179
|
+
|
|
1080
1180
|
if (event.status === 429) {
|
|
1081
|
-
pool.on429(
|
|
1181
|
+
pool.on429(keyIdx);
|
|
1182
|
+
console.warn(`[custom-provider] LB key #${keyIdx} 触发 429,进入冷却`);
|
|
1082
1183
|
} else if (event.status >= 200 && event.status < 300) {
|
|
1083
|
-
pool.onSuccess(
|
|
1184
|
+
pool.onSuccess(keyIdx);
|
|
1084
1185
|
}
|
|
1085
1186
|
});
|
|
1086
1187
|
|
|
@@ -1269,7 +1370,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
1269
1370
|
|
|
1270
1371
|
// ---- 4. 协议类型 ----
|
|
1271
1372
|
const apiType = await ctx.ui.select(
|
|
1272
|
-
"API
|
|
1373
|
+
"API 协议类型(推荐"自动推断",按 URL 自动识别)",
|
|
1273
1374
|
["自动推断", "openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"]
|
|
1274
1375
|
);
|
|
1275
1376
|
if (!apiType) return;
|
|
@@ -1282,21 +1383,27 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
1282
1383
|
ctx.ui.notify(`已自动调整端点: ${baseUrl} → ${finalBaseUrl}`, "info");
|
|
1283
1384
|
}
|
|
1284
1385
|
|
|
1285
|
-
// ---- 4.5
|
|
1286
|
-
const
|
|
1287
|
-
"
|
|
1288
|
-
"
|
|
1386
|
+
// ---- 4.5 代理配置(每个 provider 独立配置)----
|
|
1387
|
+
const proxyChoice = await ctx.ui.select(
|
|
1388
|
+
"代理配置?",
|
|
1389
|
+
["不走代理(继承环境变量)", "指定代理地址", "明确禁用代理(disable)"]
|
|
1289
1390
|
);
|
|
1290
|
-
let
|
|
1291
|
-
if (
|
|
1391
|
+
let proxyMode: string | undefined;
|
|
1392
|
+
if (proxyChoice && proxyChoice.startsWith("指定")) {
|
|
1292
1393
|
const proxyInput = await ctx.ui.input(
|
|
1293
|
-
"代理地址(http://host:port 或
|
|
1394
|
+
"代理地址(http://host:port 或 https://host:port,支持 $ENV 引用)",
|
|
1294
1395
|
"http://127.0.0.1:7890"
|
|
1295
1396
|
);
|
|
1296
|
-
|
|
1297
|
-
if (
|
|
1298
|
-
|
|
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");
|
|
1299
1403
|
}
|
|
1404
|
+
} else if (proxyChoice && proxyChoice.startsWith("明确")) {
|
|
1405
|
+
proxyMode = "disable";
|
|
1406
|
+
ctx.ui.notify("该 provider 将明确不走代理(覆盖环境变量)", "info");
|
|
1300
1407
|
}
|
|
1301
1408
|
|
|
1302
1409
|
// ---- 4.6 多 Key 负载均衡(应对 RPM/RTM 限制;选 Yes 输入多个 API Key)----
|
|
@@ -1331,7 +1438,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
1331
1438
|
"若服务支持模型列表端点可自动获取;否则将手动输入"
|
|
1332
1439
|
);
|
|
1333
1440
|
|
|
1334
|
-
// ---- 6. 请求头模板(预设整组请求头,不同 CLI
|
|
1441
|
+
// ---- 6. 请求头模板(预设整组请求头,不同 CLI 头集合不同;选"自定义"逐头输入)----
|
|
1335
1442
|
const presetLabels = HEADER_PRESETS.map((p) => p.label);
|
|
1336
1443
|
const presetChoice = await ctx.ui.select(
|
|
1337
1444
|
"请求头模板?",
|
|
@@ -1363,9 +1470,9 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
1363
1470
|
const preset = HEADER_PRESETS.find((p) => p.label === presetChoice);
|
|
1364
1471
|
if (preset?.key) {
|
|
1365
1472
|
if (preset.key !== "browser") {
|
|
1366
|
-
applyHeaderSet({ ...preset.headers },
|
|
1473
|
+
applyHeaderSet({ ...preset.headers }, `模板"${preset.label}"`);
|
|
1367
1474
|
ctx.ui.notify(
|
|
1368
|
-
|
|
1475
|
+
`已应用请求头模板"${preset.label}": ${Object.keys(preset.headers).join(", ")}`,
|
|
1369
1476
|
"info"
|
|
1370
1477
|
);
|
|
1371
1478
|
} // 浏览器模板 = 不写头,由 pi 自动补充浏览器 UA
|
|
@@ -1563,7 +1670,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
1563
1670
|
apiKey,
|
|
1564
1671
|
models,
|
|
1565
1672
|
};
|
|
1566
|
-
if (
|
|
1673
|
+
if (proxyMode) newProvider.proxy = proxyMode;
|
|
1567
1674
|
if (lbKeys && lbKeys.length > 0) {
|
|
1568
1675
|
newProvider.lbKeys = lbKeys;
|
|
1569
1676
|
if (lbCooldown) newProvider.lbCooldown = lbCooldown;
|
|
@@ -1698,7 +1805,10 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
1698
1805
|
models,
|
|
1699
1806
|
};
|
|
1700
1807
|
const proxyFlag = getFlag(flags, "proxy");
|
|
1701
|
-
if (proxyFlag)
|
|
1808
|
+
if (proxyFlag) {
|
|
1809
|
+
// 支持 "disable" 或直接指定代理 URL
|
|
1810
|
+
provider.proxy = proxyFlag === "disable" ? "disable" : proxyFlag;
|
|
1811
|
+
}
|
|
1702
1812
|
if (apiRaw && apiRaw !== "auto") provider.api = apiRaw;
|
|
1703
1813
|
else if (api !== "openai-completions") provider.api = api;
|
|
1704
1814
|
if (getFlag(flags, "auth-header")) provider.authHeader = true;
|
|
@@ -2296,18 +2406,31 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
2296
2406
|
" --header \"K: V\"(可多次)· --headers '{\"k\":\"v\"}'",
|
|
2297
2407
|
" --profile <模板键>(完整请求头模板:claude-code / codex / browser 等)",
|
|
2298
2408
|
" --ua <预设键|原始UA>(如 claude-code / codex / browser)",
|
|
2299
|
-
" --
|
|
2409
|
+
" --proxy <URL|disable>(指定代理地址或明确禁用,如 http://127.0.0.1:7890 或 disable,支持 $ENV)",
|
|
2410
|
+
" --auth-header",
|
|
2300
2411
|
" --lb-keys \"$K1,$K2\"(多 Key 负载均衡)· --lb-cooldown 60(冷却秒数)",
|
|
2301
2412
|
" --model-api 'id:协议'(可多次,如 claude-x:anthropic-messages)",
|
|
2302
2413
|
" --model-base-url 'id:url'(和 --model-api 搭配混用双协议)",
|
|
2303
2414
|
" --compat '{...}' · --overrides '{\"modelId\":{...}}'",
|
|
2304
2415
|
" --force(覆盖已存在)· --json '{...}'(完整配置)",
|
|
2305
2416
|
" ",
|
|
2417
|
+
"代理配置说明:",
|
|
2418
|
+
" - proxy: \"http://127.0.0.1:7890\" → 该 provider 走指定代理",
|
|
2419
|
+
" - proxy: \"disable\" → 明确不走代理(覆盖环境变量)",
|
|
2420
|
+
" - 不配置 proxy → 继承 process.env 的 HTTPS_PROXY 等环境变量",
|
|
2421
|
+
" - 每个 provider 的代理配置互相独立,互不干扰",
|
|
2422
|
+
" ",
|
|
2306
2423
|
"负载均衡示例(同渠道多 Key 轮询,429 后自动冷却 60s):",
|
|
2307
2424
|
" /custom-provider add relay --base-url https://api.gw.com/v1 \\",
|
|
2308
2425
|
" --lb-keys \"$KEY_A,$KEY_B,sk-plain\" --lb-cooldown 60 --force",
|
|
2309
2426
|
" JSON: --json '{\"name\":\"relay\",\"baseUrl\":\"...\",\"lbKeys\":[\"k1\",\"k2\"],\"lbCooldown\":30}'",
|
|
2310
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
|
+
" ",
|
|
2311
2434
|
"示例:",
|
|
2312
2435
|
" /custom-provider add deepseek --base-url https://api.deepseek.com/v1 \\",
|
|
2313
2436
|
" --api-key $DEEPSEEK_API_KEY --models deepseek-chat,deepseek-reasoner",
|
|
@@ -2319,7 +2442,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
|
|
|
2319
2442
|
description: "管理第三方 Provider:add / remove / refresh / list / test / help",
|
|
2320
2443
|
getArgumentCompletions: (prefix: string) => {
|
|
2321
2444
|
// 注意: pi 会用 item.value 替换整个参数段(命令名后的全部文本),
|
|
2322
|
-
// 所以 value
|
|
2445
|
+
// 所以 value 必须是"子命令 + 完整名称"的完整参数,label 才是用于显示的名称。
|
|
2323
2446
|
const trimmed = prefix.trim();
|
|
2324
2447
|
const match = trimmed.match(/^(\S+)(?:\s+(.*))?$/);
|
|
2325
2448
|
const first = (match?.[1] ?? "").toLowerCase();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "custom-provider-pi",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "pi 扩展:统一管理第三方模型 Provider。子命令体系 /custom-provider add|remove|refresh|list|test|config|enable|disable|prune,支持交互向导、flags/JSON 非交互添加、双协议混用(OpenAI + Anthropic
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|