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.
- package/README.md +61 -11
- package/custom-provider.ts +256 -118
- 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,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
|
-
|
|
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
|
|
|
220
|
-
|
|
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
|
-
|
|
272
|
+
**强制指定**:
|
|
223
273
|
|
|
224
274
|
```bash
|
|
225
|
-
/custom-provider add
|
|
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 默认协议
|
package/custom-provider.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
-
/**
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
600
|
+
// 尝试多种端点路径
|
|
568
601
|
const endpoints = api === "google-generative-ai"
|
|
569
602
|
? [`${v1Base}/models`]
|
|
570
|
-
: [
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1069
|
-
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);
|
|
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(
|
|
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(
|
|
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
|
-
// ================= 简单添加路径(
|
|
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
|
|
1272
|
-
"
|
|
1273
|
-
"
|
|
1386
|
+
// ---- 4.5 代理配置(每个 provider 独立配置)----
|
|
1387
|
+
const proxyChoice = await ctx.ui.select(
|
|
1388
|
+
"代理配置?",
|
|
1389
|
+
["不走代理(继承环境变量)", "指定代理地址", "明确禁用代理(disable)"]
|
|
1274
1390
|
);
|
|
1275
|
-
let
|
|
1276
|
-
if (
|
|
1391
|
+
let proxyMode: string | undefined;
|
|
1392
|
+
if (proxyChoice && proxyChoice.startsWith("指定")) {
|
|
1277
1393
|
const proxyInput = await ctx.ui.input(
|
|
1278
|
-
"代理地址(http://host:port 或
|
|
1394
|
+
"代理地址(http://host:port 或 https://host:port,支持 $ENV 引用)",
|
|
1279
1395
|
"http://127.0.0.1:7890"
|
|
1280
1396
|
);
|
|
1281
|
-
|
|
1282
|
-
if (
|
|
1283
|
-
|
|
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 (
|
|
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)
|
|
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
|
-
" --
|
|
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.
|
|
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",
|