custom-provider-pi 0.1.0
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/LICENSE +21 -0
- package/README.md +286 -0
- package/custom-provider.ts +2066 -0
- package/package.json +40 -0
|
@@ -0,0 +1,2066 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import https from "https";
|
|
6
|
+
import http from "http";
|
|
7
|
+
import type { ExtensionAPI, ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
const CONFIG_PATH = join(homedir(), ".pi", "agent", "custom-providers.json");
|
|
10
|
+
const SPEC_CACHE_PATH = join(homedir(), ".pi", "agent", "model-specs-cache.json");
|
|
11
|
+
|
|
12
|
+
// 远程规格缓存有效期(毫秒):24 小时
|
|
13
|
+
const SPEC_CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
14
|
+
// OpenRouter 公开模型目录(含各模型真实 context_length / max_completion_tokens,无需 key)
|
|
15
|
+
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
16
|
+
|
|
17
|
+
// 默认浏览器 UA:部分中转/反代服务(如 Cloudflare WAF)会按 User-Agent 指纹拦截
|
|
18
|
+
// SDK 请求(OpenAI/JS、Anthropic/JS 等)。未显式配置 UA 时默认使用浏览器 UA 规避。
|
|
19
|
+
// 官方 API(OpenAI/Anthropic/DeepSeek 等)不校验 UA,此默认值对其无副作用。
|
|
20
|
+
const DEFAULT_USER_AGENT =
|
|
21
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
22
|
+
|
|
23
|
+
// 预设 User-Agent:部分中转/反代服务会按 UA 指纹拦截非浏览器/SDK 请求。
|
|
24
|
+
// 版本号取各 CLI/工具当前最新稳定版(2026-08 查询),反代一般只校验前缀关键字。
|
|
25
|
+
const UA_PRESETS: Record<string, string> = {
|
|
26
|
+
"browser": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
27
|
+
// 真实 Claude Code 发送格式为 claude-code/<版本>(无后缀)
|
|
28
|
+
"claude-code": "claude-code/2.1.237",
|
|
29
|
+
"codex": "codex_cli_rs/0.148.0 (cli)",
|
|
30
|
+
"opencode": "opencode/1.18.19",
|
|
31
|
+
"cursor": "Cursor/3.16.0 (Windows; 64bit)",
|
|
32
|
+
"windsurf": "Windsurf/2.0.0 (Windows)",
|
|
33
|
+
"openwebui": "OpenWebUI/0.11.0",
|
|
34
|
+
"chatgpt": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 ChatGPT-Desktop/1.2025.0",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// 请求头模板(借鉴 LiveAgent:按客户端/CLI 预设整组请求头,而非只预设 UA):
|
|
38
|
+
// 不同客户端携带的头集合不同(Claude Code 有 x-app/anthropic-version/X-Stainless-* 等)。
|
|
39
|
+
// 选模板一次性灌入;选「自定义」逐头输入。
|
|
40
|
+
interface HeaderPreset {
|
|
41
|
+
label: string;
|
|
42
|
+
key?: string; // undefined = 自定义
|
|
43
|
+
headers: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
const HEADER_PRESETS: HeaderPreset[] = [
|
|
46
|
+
{ label: "浏览器(默认,仅 UA)", key: "browser", headers: { "User-Agent": UA_PRESETS.browser } },
|
|
47
|
+
{
|
|
48
|
+
label: "Claude Code CLI",
|
|
49
|
+
key: "claude-code",
|
|
50
|
+
headers: {
|
|
51
|
+
"User-Agent": UA_PRESETS["claude-code"],
|
|
52
|
+
"x-app": "cli",
|
|
53
|
+
"anthropic-version": "2023-06-01",
|
|
54
|
+
"content-type": "application/json",
|
|
55
|
+
"anthropic-dangerous-direct-browser-access": "true",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
label: "Codex CLI",
|
|
60
|
+
key: "codex",
|
|
61
|
+
headers: { "User-Agent": UA_PRESETS.codex, "accept": "application/json" },
|
|
62
|
+
},
|
|
63
|
+
{ label: "OpenCode CLI", key: "opencode", headers: { "User-Agent": UA_PRESETS.opencode } },
|
|
64
|
+
{ label: "Cursor IDE", key: "cursor", headers: { "User-Agent": UA_PRESETS.cursor } },
|
|
65
|
+
{ label: "Windsurf IDE", key: "windsurf", headers: { "User-Agent": UA_PRESETS.windsurf } },
|
|
66
|
+
{
|
|
67
|
+
label: "OpenWebUI",
|
|
68
|
+
key: "openwebui",
|
|
69
|
+
headers: { "User-Agent": UA_PRESETS.openwebui, "accept": "application/json" },
|
|
70
|
+
},
|
|
71
|
+
{ label: "ChatGPT Desktop", key: "chatgpt", headers: { "User-Agent": UA_PRESETS.chatgpt } },
|
|
72
|
+
{ label: "自定义(逐头输入)", key: undefined, headers: {} },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// 头名校验:HTTP token 字符集(含 ASCII 特殊符号,无空格/换行)
|
|
76
|
+
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
77
|
+
// 头值只允许可见 ASCII 与制表符:CR/LF 会造成 header 注入,非 ASCII 可能让请求出错
|
|
78
|
+
const HEADER_VALUE_RE = /^[\t\x20-\x7e]*$/;
|
|
79
|
+
// 设置这些头可能覆盖认证/协议逻辑:允许但给予提示
|
|
80
|
+
const SENSITIVE_HEADER_HINTS = ["authorization", "x-api-key", "x-goog-api-key", "anthropic-beta", "host", "content-length"];
|
|
81
|
+
|
|
82
|
+
// 合法的 provider 名称:小写字母/数字/中划线/下划线,长度 1-32
|
|
83
|
+
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/;
|
|
84
|
+
|
|
85
|
+
function hasHeader(headers: Record<string, string> | undefined, name: string): boolean {
|
|
86
|
+
if (!headers) return false;
|
|
87
|
+
const lower = name.toLowerCase();
|
|
88
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === lower);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- 远程规格查询(OpenRouter 公开目录,带磁盘缓存)----
|
|
92
|
+
|
|
93
|
+
// 输出上限处置(借鉴 LiveAgent normalizeModelLimits)
|
|
94
|
+
// 社区目录/中转对不公布独立输出上限的模型常给退化值「输出==窗口」,
|
|
95
|
+
// 照单全收会把「窗口−输出预留」的输入预算挤成零。处理:钳到保守上限,
|
|
96
|
+
// 并保底留 3/4 窗口给输入。
|
|
97
|
+
const MAX_OUTPUT_TOKEN_CAP = 32000;
|
|
98
|
+
|
|
99
|
+
function normalizeModelLimits(ctx: number, out: number): { contextWindow: number; maxTokens: number } {
|
|
100
|
+
if (ctx <= 0) return { contextWindow: ctx, maxTokens: out };
|
|
101
|
+
if (out < ctx) return { contextWindow: ctx, maxTokens: out };
|
|
102
|
+
return {
|
|
103
|
+
contextWindow: ctx,
|
|
104
|
+
maxTokens: Math.min(MAX_OUTPUT_TOKEN_CAP, Math.max(1, Math.floor(ctx / 4))),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 长上下文后缀约定:部分中转用 [1m] 标记模型为 1M 长上下文形态
|
|
109
|
+
function hasLongContextSuffix(modelId: string): boolean {
|
|
110
|
+
return /\[1m\]$/i.test(modelId.trim());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 官方 Anthropic/Vertex/Bedrock 端点不支持 1M 上下文(1M 仅第三方中转可用)
|
|
114
|
+
function isOfficialAnthropicEndpoint(baseUrl: string | undefined): boolean {
|
|
115
|
+
const lower = (baseUrl ?? "").toLowerCase();
|
|
116
|
+
return (
|
|
117
|
+
lower.includes("api.anthropic.com") ||
|
|
118
|
+
lower.includes("aiplatform.googleapis.com") ||
|
|
119
|
+
lower.includes("vertexai.googleapis.com") ||
|
|
120
|
+
lower.includes("amazonaws.com")
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 按协议/API 类型的未知模型兜底限额(借鉴 LiveAgent PROVIDER_FALLBACK_LIMITS)
|
|
125
|
+
const PROVIDER_FALLBACK: Record<string, { contextWindow: number; maxTokens: number }> = {
|
|
126
|
+
"anthropic-messages": { contextWindow: 200000, maxTokens: 32000 },
|
|
127
|
+
"google-generative-ai": { contextWindow: 1048576, maxTokens: 65536 },
|
|
128
|
+
"openai-completions": { contextWindow: 258000, maxTokens: 32000 },
|
|
129
|
+
"openai-responses": { contextWindow: 258000, maxTokens: 32000 },
|
|
130
|
+
};
|
|
131
|
+
const FALLBACK_DEFAULT: { contextWindow: number; maxTokens: number } = {
|
|
132
|
+
contextWindow: 128000,
|
|
133
|
+
maxTokens: 32000,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// 远程规格表:normalize 后的模型 id -> { contextWindow, maxTokens }
|
|
137
|
+
let remoteSpecStore: Map<string, { contextWindow: number; maxTokens: number }> | null = null;
|
|
138
|
+
|
|
139
|
+
// 归一化候选链(借鉴 LiveAgent normalizeModelIdCandidates):
|
|
140
|
+
// 原始 id → 小写 → 去 @版本 → 去 [1m] 后缀 → 去日期段 → 去变体词 → 剥路径前缀。
|
|
141
|
+
// 候选链让目录命中覆盖中转常见的装饰:bailian/deepseek-v4-pro、xxx@20250601、xxx[1m] 等。
|
|
142
|
+
function normalizeModelIdCandidates(modelId: string): string[] {
|
|
143
|
+
const candidates: string[] = [];
|
|
144
|
+
const push = (v: string) => {
|
|
145
|
+
if (v && !candidates.includes(v)) candidates.push(v);
|
|
146
|
+
};
|
|
147
|
+
const raw = modelId.trim();
|
|
148
|
+
push(raw);
|
|
149
|
+
const lower = raw.toLowerCase();
|
|
150
|
+
push(lower);
|
|
151
|
+
const withoutAt = lower.split("@")[0];
|
|
152
|
+
push(withoutAt);
|
|
153
|
+
const withoutSuffix = withoutAt.replace(/\[1m\]$/i, "");
|
|
154
|
+
push(withoutSuffix);
|
|
155
|
+
push(withoutSuffix.replace(/-20\d{6}$/, "")); // 日期段 -20250601
|
|
156
|
+
push(withoutSuffix.replace(/-\d{2,9}$/, "")); // 日期/序号 -0731
|
|
157
|
+
push(
|
|
158
|
+
withoutSuffix.replace(
|
|
159
|
+
/-(?:latest|recent|free|preview|stable|thinking|reasoning|turbo|hi|highspeed|dev|beta|auto|labs)$/,
|
|
160
|
+
""
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
// 路径前缀(bailian/x → x)放链尾:所有精确形态查空后才剥前缀
|
|
164
|
+
const lastSegment = withoutSuffix.split("/").pop() ?? "";
|
|
165
|
+
if (lastSegment && lastSegment !== withoutSuffix) {
|
|
166
|
+
push(lastSegment);
|
|
167
|
+
push(lastSegment.replace(/-20\d{6}$/, ""));
|
|
168
|
+
}
|
|
169
|
+
return candidates;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 将 OpenRouter 模型目录灌入远程规格表(同 id 多条时取更大上下文)
|
|
173
|
+
function ingestRemoteModels(data: any[]): void {
|
|
174
|
+
if (!remoteSpecStore) remoteSpecStore = new Map();
|
|
175
|
+
for (const m of data) {
|
|
176
|
+
const ctx = m.context_length;
|
|
177
|
+
// 部分模型未公布 max_completion_tokens(如 grok-4.5),给保守默认避免整条被跳过
|
|
178
|
+
const rawOut = (m.top_provider?.max_completion_tokens ?? m.max_completion_tokens) ?? 131072;
|
|
179
|
+
if (!ctx) continue;
|
|
180
|
+
// 入库即卫生化:输出吃满窗口的退化数据钳到保守上限,避免输入预算被挤成零
|
|
181
|
+
const limited = normalizeModelLimits(Number(ctx), rawOut);
|
|
182
|
+
const key = normalizeModelIdCandidates(m.id)[4] ?? m.id.toLowerCase(); // 去 @ 与 [1m] 后的规范形
|
|
183
|
+
const prev = remoteSpecStore.get(key);
|
|
184
|
+
if (
|
|
185
|
+
!prev ||
|
|
186
|
+
limited.contextWindow > prev.contextWindow ||
|
|
187
|
+
(limited.contextWindow === prev.contextWindow && limited.maxTokens > prev.maxTokens)
|
|
188
|
+
) {
|
|
189
|
+
remoteSpecStore.set(key, limited);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function loadSpecCache(): void {
|
|
195
|
+
try {
|
|
196
|
+
if (!existsSync(SPEC_CACHE_PATH)) return;
|
|
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 {
|
|
201
|
+
remoteSpecStore = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function saveSpecCache(): void {
|
|
206
|
+
try {
|
|
207
|
+
if (!remoteSpecStore) return;
|
|
208
|
+
writeFileSync(
|
|
209
|
+
SPEC_CACHE_PATH,
|
|
210
|
+
JSON.stringify({ fetchedAt: Date.now(), specs: [...remoteSpecStore.entries()] }),
|
|
211
|
+
"utf8"
|
|
212
|
+
);
|
|
213
|
+
} catch {
|
|
214
|
+
/* 缓存写失败不影响主流程 */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 从 OpenRouter 拉取模型目录并刷新本地规格表;失败静默降级(不影响已有配置/预设)
|
|
219
|
+
async function refreshRemoteSpecs(): Promise<void> {
|
|
220
|
+
try {
|
|
221
|
+
// 模型目录较大(~700KB),放宽超时到 30s;后台异步执行不阻塞启动
|
|
222
|
+
const json = await httpGet(OPENROUTER_MODELS_URL, undefined, undefined, 30000);
|
|
223
|
+
if (!json?.data || !Array.isArray(json.data)) throw new Error("OpenRouter 响应格式异常");
|
|
224
|
+
ingestRemoteModels(json.data);
|
|
225
|
+
saveSpecCache();
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error(
|
|
228
|
+
`[custom-provider] 拉取模型规格失败(使用缓存/预设降级): ${
|
|
229
|
+
error instanceof Error ? error.message : String(error)
|
|
230
|
+
}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 查询模型规格:远程实时表 → 本地已知预设 → undefined
|
|
236
|
+
function lookupModelSpec(modelId: string): { contextWindow: number; maxTokens: number } | undefined {
|
|
237
|
+
if (remoteSpecStore && remoteSpecStore.size > 0) {
|
|
238
|
+
// 候选链逐形态命中:deepseek-v4-flash-free → deepseek-v4-flash → deepseek-v4 …
|
|
239
|
+
for (const candidate of normalizeModelIdCandidates(modelId)) {
|
|
240
|
+
const hit = remoteSpecStore.get(candidate);
|
|
241
|
+
if (hit) return hit;
|
|
242
|
+
// 渐进去尾段(- 连接):最后一个候选之后逐步剥段
|
|
243
|
+
let probe = candidate;
|
|
244
|
+
for (let i = 0; i < 6 && probe; i++) {
|
|
245
|
+
const idx = probe.lastIndexOf("-");
|
|
246
|
+
if (idx <= 0) break;
|
|
247
|
+
probe = probe.slice(0, idx);
|
|
248
|
+
const h = remoteSpecStore.get(probe);
|
|
249
|
+
if (h) return h;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return getKnownSpec(modelId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- 本地已知规格预设(离线兜底;远程规格优先于它)----
|
|
257
|
+
const KNOWN_SPECS: Record<string, { contextWindow: number; maxTokens: number }> = {
|
|
258
|
+
// DeepSeek V4(1M 上下文为输入+输出共享预算)
|
|
259
|
+
"deepseek-v4-flash": { contextWindow: 1000000, maxTokens: 384000 },
|
|
260
|
+
"deepseek-v4-pro": { contextWindow: 1000000, maxTokens: 384000 },
|
|
261
|
+
// 智谱 GLM-5 系列
|
|
262
|
+
"glm-5": { contextWindow: 200000, maxTokens: 131072 },
|
|
263
|
+
"glm-5.1": { contextWindow: 200000, maxTokens: 131072 },
|
|
264
|
+
"glm-5.2": { contextWindow: 200000, maxTokens: 131072 },
|
|
265
|
+
"glm-5.3": { contextWindow: 200000, maxTokens: 131072 },
|
|
266
|
+
// xAI Grok(4.5/4.6 输出上限未官宣,取 128K 保守值)
|
|
267
|
+
"grok-4.5": { contextWindow: 500000, maxTokens: 131072 },
|
|
268
|
+
"grok-4.6": { contextWindow: 500000, maxTokens: 131072 },
|
|
269
|
+
// 腾讯混元 Hy3
|
|
270
|
+
"hy3": { contextWindow: 256000, maxTokens: 131072 },
|
|
271
|
+
"hy3-preview": { contextWindow: 256000, maxTokens: 131072 },
|
|
272
|
+
// MiniMax M2.x 系列(官方未公布超大上下文,保守 256K)
|
|
273
|
+
"mimo-v2": { contextWindow: 262144, maxTokens: 65536 },
|
|
274
|
+
"mimo-v2.1": { contextWindow: 262144, maxTokens: 65536 },
|
|
275
|
+
"mimo-v2.5": { contextWindow: 262144, maxTokens: 65536 },
|
|
276
|
+
"minimax-m2": { contextWindow: 262144, maxTokens: 65536 },
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
function getKnownSpec(modelId: string): { contextWindow: number; maxTokens: number } | undefined {
|
|
280
|
+
const candidates = normalizeModelIdCandidates(modelId);
|
|
281
|
+
for (const c of candidates) {
|
|
282
|
+
if (KNOWN_SPECS[c]) return KNOWN_SPECS[c];
|
|
283
|
+
}
|
|
284
|
+
// 前缀匹配兜底(deepseek-v4-flash-free → deepseek-v4-flash)
|
|
285
|
+
const key = Object.keys(KNOWN_SPECS).find((k) =>
|
|
286
|
+
candidates.some((c) => c === k || c.startsWith(k + "-") || c.startsWith(k + "_") || c.startsWith(k + ":"))
|
|
287
|
+
);
|
|
288
|
+
return key ? KNOWN_SPECS[key] : undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface IModel {
|
|
292
|
+
id: string;
|
|
293
|
+
name?: string;
|
|
294
|
+
api?: string;
|
|
295
|
+
baseUrl?: string;
|
|
296
|
+
reasoning?: boolean;
|
|
297
|
+
input?: ("text" | "image")[];
|
|
298
|
+
contextWindow?: number;
|
|
299
|
+
maxTokens?: number;
|
|
300
|
+
cost?: {
|
|
301
|
+
input?: number;
|
|
302
|
+
output?: number;
|
|
303
|
+
cacheRead?: number;
|
|
304
|
+
cacheWrite?: number;
|
|
305
|
+
};
|
|
306
|
+
headers?: Record<string, string>;
|
|
307
|
+
compat?: Record<string, any>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface IProvider {
|
|
311
|
+
name: string;
|
|
312
|
+
baseUrl: string;
|
|
313
|
+
api?: string;
|
|
314
|
+
apiKey?: string;
|
|
315
|
+
headers?: Record<string, string>;
|
|
316
|
+
authHeader?: boolean;
|
|
317
|
+
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;
|
|
322
|
+
/** false 表示已禁用(不注册、不出现在 /model);缺失视为启用 */
|
|
323
|
+
enabled?: boolean;
|
|
324
|
+
models: (string | IModel)[];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
interface IConfig {
|
|
328
|
+
providers: IProvider[];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function loadConfig(): IConfig {
|
|
332
|
+
try {
|
|
333
|
+
if (!existsSync(CONFIG_PATH)) return { providers: [] };
|
|
334
|
+
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
335
|
+
const config = JSON.parse(raw) as IConfig;
|
|
336
|
+
return config;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
console.error(`[custom-provider] 读取 ${CONFIG_PATH} 失败:`, error);
|
|
339
|
+
return { providers: [] };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function saveConfig(config: IConfig): void {
|
|
344
|
+
try {
|
|
345
|
+
const dir = join(homedir(), ".pi", "agent");
|
|
346
|
+
if (!existsSync(dir)) {
|
|
347
|
+
mkdirSync(dir, { recursive: true });
|
|
348
|
+
}
|
|
349
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
|
|
350
|
+
} catch (error) {
|
|
351
|
+
throw new Error(`保存配置失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function resolveValue(raw: string | undefined): string {
|
|
356
|
+
if (!raw) return "local";
|
|
357
|
+
|
|
358
|
+
// 环境变量插值: $VAR 或 ${VAR}
|
|
359
|
+
if (raw.startsWith("$")) {
|
|
360
|
+
const varName = raw.startsWith("${") && raw.endsWith("}")
|
|
361
|
+
? raw.slice(2, -1)
|
|
362
|
+
: raw.slice(1);
|
|
363
|
+
return process.env[varName] || "";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 命令执行: !command
|
|
367
|
+
if (raw.startsWith("!")) {
|
|
368
|
+
try {
|
|
369
|
+
return execSync(raw.slice(1), { encoding: "utf8" }).trim();
|
|
370
|
+
} catch {
|
|
371
|
+
return "";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return raw;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 仅用于探测请求(如拉取模型列表)时获取真实值;存储与注册保留原始 $VAR/!cmd 引用,
|
|
379
|
+
// 交由 pi 在每次请求时动态解析,环境变量变更可即时生效。
|
|
380
|
+
function resolveHeaders(headers?: Record<string, string>): Record<string, string> {
|
|
381
|
+
const resolved: Record<string, string> = {};
|
|
382
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
383
|
+
resolved[key] = resolveValue(value);
|
|
384
|
+
}
|
|
385
|
+
return resolved;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function inferApi(baseUrl: string, explicitApi?: string): string {
|
|
389
|
+
if (explicitApi) return explicitApi;
|
|
390
|
+
|
|
391
|
+
const lower = baseUrl.toLowerCase();
|
|
392
|
+
if (lower.includes("generativelanguage") || lower.includes("generativeai")) {
|
|
393
|
+
return "google-generative-ai";
|
|
394
|
+
}
|
|
395
|
+
if (lower.includes("anthropic")) {
|
|
396
|
+
return "anthropic-messages";
|
|
397
|
+
}
|
|
398
|
+
return "openai-completions";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 去掉 baseUrl 中用户可能粘贴的多余路径段,返回"规范根地址"(不含 /v1)
|
|
402
|
+
function stripBasePath(baseUrl: string): string {
|
|
403
|
+
return baseUrl
|
|
404
|
+
.replace(/\/+$/, "")
|
|
405
|
+
.replace(/\/v1\/models\/?$/i, "")
|
|
406
|
+
.replace(/\/chat\/completions\/?$/i, "")
|
|
407
|
+
.replace(/\/models\/?$/i, "")
|
|
408
|
+
.replace(/\/+$/, "");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function normalizeBaseUrl(baseUrl: string, api: string): string {
|
|
412
|
+
const root = stripBasePath(baseUrl);
|
|
413
|
+
|
|
414
|
+
switch (api) {
|
|
415
|
+
case "openai-completions":
|
|
416
|
+
case "openai-responses":
|
|
417
|
+
case "anthropic-messages":
|
|
418
|
+
// OpenAI/Anthropic 兼容协议要求 /v1 前缀;只在"不以 /v1 结尾"时补,
|
|
419
|
+
// 避免路径中段含 /v1/ 的历史误判
|
|
420
|
+
return /\/v1$/i.test(root) ? root : `${root}/v1`;
|
|
421
|
+
|
|
422
|
+
case "google-generative-ai":
|
|
423
|
+
// Google Generative AI 需要 /v1beta 或 /v1 前缀
|
|
424
|
+
if (!/(\/v1|\/v1beta)(\/|$)/i.test(root)) return `${root}/v1`;
|
|
425
|
+
return root;
|
|
426
|
+
|
|
427
|
+
default:
|
|
428
|
+
return root;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function prepareModel(raw: string | IModel, provider: IProvider): ProviderModelConfig {
|
|
433
|
+
const src = typeof raw === "string" ? { id: raw } : raw;
|
|
434
|
+
if (!src.id) {
|
|
435
|
+
throw new Error(`provider "${provider.name}" 下存在缺少 id 的模型`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 协议:模型级覆盖 > provider 级
|
|
439
|
+
const modelApi = (src.api as string | undefined) ?? inferApi(provider.baseUrl, provider.api);
|
|
440
|
+
const spec = lookupModelSpec(src.id);
|
|
441
|
+
|
|
442
|
+
// 规格填充优先级:显式配置 > [1m] 长上下文规则 > 远程实时规格 > 本地预设 > 按协议兜底
|
|
443
|
+
const hasExplicitCtx = src.contextWindow !== undefined;
|
|
444
|
+
const hasExplicitMax = src.maxTokens !== undefined;
|
|
445
|
+
let contextWindow: number | undefined = src.contextWindow;
|
|
446
|
+
let maxTokens: number | undefined = src.maxTokens;
|
|
447
|
+
|
|
448
|
+
if (!hasExplicitCtx) {
|
|
449
|
+
if (hasLongContextSuffix(src.id)) {
|
|
450
|
+
// [1m] 后缀:Anthropic 兼容中转的 1M 形态。
|
|
451
|
+
// 官方端点(api.anthropic.com / Vertex / Bedrock)不支持 1M,钳回 200K
|
|
452
|
+
contextWindow = isOfficialAnthropicEndpoint(provider.baseUrl)
|
|
453
|
+
? Math.min(spec?.contextWindow ?? 200000, 200000)
|
|
454
|
+
: 1000000;
|
|
455
|
+
} else {
|
|
456
|
+
contextWindow = spec?.contextWindow ?? PROVIDER_FALLBACK[modelApi]?.contextWindow ?? FALLBACK_DEFAULT.contextWindow;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (!hasExplicitMax) {
|
|
460
|
+
maxTokens = spec?.maxTokens ?? PROVIDER_FALLBACK[modelApi]?.maxTokens ?? FALLBACK_DEFAULT.maxTokens;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 数据卫生:非显式的输出值若吃满/超过窗口(社区目录退化数据),
|
|
464
|
+
// 舞到保守上限并保底留 3/4 窗口给输入,避免 pi 的上下文预算被挤成零。
|
|
465
|
+
// 显式用户配置完全信任,不自动钳。
|
|
466
|
+
if (!hasExplicitMax && maxTokens! >= contextWindow!) {
|
|
467
|
+
const limited = normalizeModelLimits(contextWindow!, maxTokens!);
|
|
468
|
+
contextWindow = limited.contextWindow;
|
|
469
|
+
maxTokens = limited.maxTokens;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const model: ProviderModelConfig = {
|
|
473
|
+
id: src.id,
|
|
474
|
+
name: src.name ?? src.id,
|
|
475
|
+
reasoning: src.reasoning ?? false,
|
|
476
|
+
input: src.input ?? ["text"],
|
|
477
|
+
contextWindow: contextWindow!,
|
|
478
|
+
maxTokens: maxTokens!,
|
|
479
|
+
cost: {
|
|
480
|
+
input: src.cost?.input ?? 0,
|
|
481
|
+
output: src.cost?.output ?? 0,
|
|
482
|
+
cacheRead: src.cost?.cacheRead ?? 0,
|
|
483
|
+
cacheWrite: src.cost?.cacheWrite ?? 0,
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
if (src.api) model.api = src.api as any;
|
|
487
|
+
if (src.baseUrl) model.baseUrl = src.baseUrl;
|
|
488
|
+
if (src.headers) model.headers = src.headers;
|
|
489
|
+
if (src.compat) model.compat = src.compat as any;
|
|
490
|
+
return model;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function buildProviderConfig(provider: IProvider): ProviderConfig {
|
|
494
|
+
const api = inferApi(provider.baseUrl, provider.api);
|
|
495
|
+
|
|
496
|
+
// 请求头原样透传(保留 $ENV / ${ENV} / !cmd 引用),pi 在每次请求时动态解析,
|
|
497
|
+
// 避免把解析结果明文固化为死值。
|
|
498
|
+
const providerHeaders: Record<string, string> = { ...(provider.headers ?? {}) };
|
|
499
|
+
|
|
500
|
+
// WAF 指纹加固:未显式设置 User-Agent 时,默认补浏览器 UA
|
|
501
|
+
if (!hasHeader(providerHeaders, "user-agent")) {
|
|
502
|
+
providerHeaders["User-Agent"] = DEFAULT_USER_AGENT;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const providerConfig: ProviderConfig = {
|
|
506
|
+
name: provider.name,
|
|
507
|
+
baseUrl: provider.baseUrl,
|
|
508
|
+
apiKey: provider.apiKey ?? "local",
|
|
509
|
+
models: provider.models.map((m) => prepareModel(m, provider)),
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
if (Object.keys(providerHeaders).length > 0) {
|
|
513
|
+
providerConfig.headers = providerHeaders;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// authHeader:非标准 API 需要 Authorization: Bearer 时由用户显式开启(pi 负责生成)
|
|
517
|
+
if (provider.authHeader) {
|
|
518
|
+
providerConfig.authHeader = true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 代理:写入环境变量使底层请求走代理(仅当用户显式配置且未设置时;
|
|
522
|
+
// 注意 Node 的 fetch 需 NODE_USE_ENV_PROXY=1 才启用,详见 README)
|
|
523
|
+
const proxyUrl = provider.proxy ? resolveValue(provider.proxy) : undefined;
|
|
524
|
+
if (proxyUrl) {
|
|
525
|
+
if (!process.env.HTTPS_PROXY) process.env.HTTPS_PROXY = proxyUrl;
|
|
526
|
+
if (!process.env.HTTP_PROXY) process.env.HTTP_PROXY = proxyUrl;
|
|
527
|
+
if (!process.env.ALL_PROXY) process.env.ALL_PROXY = proxyUrl;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const baseCompat = provider.compat || {};
|
|
531
|
+
providerConfig.models!.forEach((model) => {
|
|
532
|
+
if (!model.api) model.api = api as any;
|
|
533
|
+
if (!model.baseUrl) model.baseUrl = provider.baseUrl;
|
|
534
|
+
if (!model.compat) model.compat = {};
|
|
535
|
+
Object.assign(model.compat, baseCompat, model.compat);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
return providerConfig;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function fetchModels(
|
|
542
|
+
baseUrl: string,
|
|
543
|
+
apiKey: string,
|
|
544
|
+
api: string,
|
|
545
|
+
headers?: Record<string, string>
|
|
546
|
+
): Promise<string[]> {
|
|
547
|
+
const cleanBase = stripBasePath(baseUrl);
|
|
548
|
+
const v1Base = /\/v1$/i.test(cleanBase) ? cleanBase : `${cleanBase}/v1`;
|
|
549
|
+
|
|
550
|
+
// 尝试多种端点路径(google / anthropic 也走标准 /models 列表端点)
|
|
551
|
+
const endpoints = api === "google-generative-ai"
|
|
552
|
+
? [`${v1Base}/models`]
|
|
553
|
+
: [ `${v1Base}/models`, `${cleanBase}/models`, `${cleanBase}/api/models` ];
|
|
554
|
+
|
|
555
|
+
// 按协议组装认证头:anthropic 用 x-api-key,google 用 x-goog-api-key,其余 Bearer
|
|
556
|
+
const requestHeaders: Record<string, string> = { ...resolveHeaders(headers) };
|
|
557
|
+
let bearerKey = apiKey;
|
|
558
|
+
if (api === "anthropic-messages") {
|
|
559
|
+
if (apiKey) requestHeaders["x-api-key"] = apiKey;
|
|
560
|
+
requestHeaders["anthropic-version"] = "2023-06-01";
|
|
561
|
+
bearerKey = "";
|
|
562
|
+
} else if (api === "google-generative-ai") {
|
|
563
|
+
if (apiKey) requestHeaders["x-goog-api-key"] = apiKey;
|
|
564
|
+
bearerKey = "";
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const attempts: string[] = [];
|
|
568
|
+
|
|
569
|
+
for (const url of endpoints) {
|
|
570
|
+
try {
|
|
571
|
+
const json = await httpGet(url, bearerKey, requestHeaders);
|
|
572
|
+
|
|
573
|
+
// 支持多种响应格式
|
|
574
|
+
let models: string[] = [];
|
|
575
|
+
if (json.data && Array.isArray(json.data)) {
|
|
576
|
+
models = json.data.map((m: any) => m.id || m.name).filter(Boolean);
|
|
577
|
+
} else if (Array.isArray(json)) {
|
|
578
|
+
models = json.map((m: any) => m.id || m.name || m).filter(Boolean);
|
|
579
|
+
} else if (json.models && Array.isArray(json.models)) {
|
|
580
|
+
models = json.models.map((m: any) => m.id || m.name || m).filter(Boolean);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (models.length > 0) {
|
|
584
|
+
return models;
|
|
585
|
+
}
|
|
586
|
+
attempts.push(`${url} -> 响应中未找到模型列表`);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
attempts.push(`${url} -> ${error instanceof Error ? error.message : String(error)}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
throw new Error(
|
|
593
|
+
`拉取模型列表失败(已尝试 ${endpoints.length} 个端点):\n${attempts.join("\n")}`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function httpGet(
|
|
598
|
+
url: string,
|
|
599
|
+
apiKey?: string,
|
|
600
|
+
extraHeaders?: Record<string, string>,
|
|
601
|
+
timeoutMs = 10000
|
|
602
|
+
): Promise<any> {
|
|
603
|
+
return new Promise((resolve, reject) => {
|
|
604
|
+
const urlObj = new URL(url);
|
|
605
|
+
const lib = urlObj.protocol === "https:" ? https : http;
|
|
606
|
+
const options = {
|
|
607
|
+
hostname: urlObj.hostname,
|
|
608
|
+
port: urlObj.port,
|
|
609
|
+
path: urlObj.pathname + urlObj.search,
|
|
610
|
+
method: "GET",
|
|
611
|
+
headers: {
|
|
612
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
613
|
+
"Content-Type": "application/json",
|
|
614
|
+
...(extraHeaders ?? {}),
|
|
615
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const req = lib.request(options, (res) => {
|
|
620
|
+
let data = "";
|
|
621
|
+
res.on("data", (chunk) => (data += chunk));
|
|
622
|
+
res.on("end", () => {
|
|
623
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
624
|
+
try {
|
|
625
|
+
resolve(JSON.parse(data));
|
|
626
|
+
} catch (e) {
|
|
627
|
+
reject(new Error(`JSON 解析失败: ${e}`));
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
req.on("error", reject);
|
|
636
|
+
req.setTimeout(timeoutMs, () => {
|
|
637
|
+
req.destroy();
|
|
638
|
+
reject(new Error("请求超时"));
|
|
639
|
+
});
|
|
640
|
+
req.end();
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---- 名称校验 ----
|
|
645
|
+
function validateProviderName(name: string): string | null {
|
|
646
|
+
if (!name) return "名称不能为空";
|
|
647
|
+
if (name.length > 32) return "名称过长(最多 32 字符)";
|
|
648
|
+
if (!NAME_RE.test(name)) {
|
|
649
|
+
return "名称只能包含字母、数字、中划线、下划线,且不能以符号开头(如: deepseek, my-proxy, kimi_2)";
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// 应用高级配置中的按模型覆盖(modelOverrides: { modelId: { reasoning, input, contextWindow, ... } })
|
|
655
|
+
function applyModelOverrides(
|
|
656
|
+
models: (string | IModel)[],
|
|
657
|
+
overrides: Record<string, Partial<IModel>>
|
|
658
|
+
): (string | IModel)[] {
|
|
659
|
+
return models.map((m) => {
|
|
660
|
+
const id = typeof m === "string" ? m : m.id;
|
|
661
|
+
const o = overrides?.[id];
|
|
662
|
+
if (!o || typeof o !== "object") return m;
|
|
663
|
+
if (typeof m === "string") return { id, ...o } as IModel;
|
|
664
|
+
return { ...m, ...o };
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---- 命令行参数解析(/custom-provider 子命令 flags)----
|
|
669
|
+
|
|
670
|
+
interface ParsedArgs {
|
|
671
|
+
positional: string[];
|
|
672
|
+
flags: Map<string, string[]>;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// 无需取值的布尔 flag
|
|
676
|
+
const BOOLEAN_FLAGS = new Set(["auth-header", "force", "yes"]);
|
|
677
|
+
|
|
678
|
+
function unquoteFlag(s: string): string {
|
|
679
|
+
if (s.length >= 2) {
|
|
680
|
+
const c = s[0];
|
|
681
|
+
if ((c === '"' || c === "'") && s.endsWith(c)) return s.slice(1, -1);
|
|
682
|
+
}
|
|
683
|
+
return s;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// 解析 --key=value / --key value / 短 flag -f -y;引号包裹的值会被剥引号
|
|
687
|
+
function parseFlagArgs(args: string): ParsedArgs {
|
|
688
|
+
const positional: string[] = [];
|
|
689
|
+
const flags = new Map<string, string[]>();
|
|
690
|
+
const tokens = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
691
|
+
const addFlag = (key: string, value: string) => {
|
|
692
|
+
const arr = flags.get(key) ?? [];
|
|
693
|
+
arr.push(value);
|
|
694
|
+
flags.set(key, arr);
|
|
695
|
+
};
|
|
696
|
+
const SHORT_ALIAS: Record<string, string> = { f: "force", y: "yes" };
|
|
697
|
+
|
|
698
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
699
|
+
const tok = tokens[i];
|
|
700
|
+
if (tok.startsWith("--")) {
|
|
701
|
+
const eq = tok.indexOf("=");
|
|
702
|
+
if (eq >= 0) {
|
|
703
|
+
addFlag(tok.slice(2, eq).toLowerCase(), unquoteFlag(tok.slice(eq + 1)));
|
|
704
|
+
} else {
|
|
705
|
+
const key = tok.slice(2).toLowerCase();
|
|
706
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
707
|
+
addFlag(key, "true");
|
|
708
|
+
} else {
|
|
709
|
+
const next = tokens[i + 1];
|
|
710
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
711
|
+
addFlag(key, unquoteFlag(next));
|
|
712
|
+
i++;
|
|
713
|
+
} else {
|
|
714
|
+
addFlag(key, "true"); // 无值 flag 视为 true
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
} else if (/^-[a-z]$/i.test(tok)) {
|
|
719
|
+
addFlag(SHORT_ALIAS[tok.slice(1).toLowerCase()] ?? tok.slice(1).toLowerCase(), "true");
|
|
720
|
+
} else {
|
|
721
|
+
positional.push(unquoteFlag(tok));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return { positional, flags };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// 取 flag 的值:按名字顺序取第一个存在的,多值时取最后(后者覆盖前者)
|
|
728
|
+
function getFlag(flags: Map<string, string[]>, ...names: string[]): string | undefined {
|
|
729
|
+
for (const n of names) {
|
|
730
|
+
const arr = flags.get(n);
|
|
731
|
+
if (arr && arr.length > 0) return arr[arr.length - 1];
|
|
732
|
+
}
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export default function customProviderExtension(pi: ExtensionAPI) {
|
|
737
|
+
const registerProviders = () => {
|
|
738
|
+
const config = loadConfig();
|
|
739
|
+
config.providers.forEach((provider) => {
|
|
740
|
+
if (provider.enabled === false) return; // 已禁用的不注册
|
|
741
|
+
try {
|
|
742
|
+
const providerConfig = buildProviderConfig(provider);
|
|
743
|
+
pi.registerProvider(provider.name, providerConfig);
|
|
744
|
+
} catch (error) {
|
|
745
|
+
console.error(`[custom-provider] 注册 provider "${provider.name}" 失败:`, error);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// 保存配置并注册 provider;注册失败时回滚磁盘配置,避免"文件已写入但运行态不一致"
|
|
751
|
+
const persistProvider = (
|
|
752
|
+
config: IConfig,
|
|
753
|
+
provider: IProvider,
|
|
754
|
+
ctx: { ui: { notify(m: string, t?: "info" | "warning" | "error"): void } }
|
|
755
|
+
): boolean => {
|
|
756
|
+
const previous = JSON.parse(JSON.stringify(config.providers));
|
|
757
|
+
const idx = findProviderIndex(config, provider.name);
|
|
758
|
+
if (idx >= 0) {
|
|
759
|
+
// 覆盖时保留原有禁用状态(除非新配置显式指定 enabled)
|
|
760
|
+
if (config.providers[idx].enabled === false && provider.enabled === undefined) {
|
|
761
|
+
provider.enabled = false;
|
|
762
|
+
}
|
|
763
|
+
config.providers[idx] = provider;
|
|
764
|
+
} else {
|
|
765
|
+
config.providers.push(provider);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
saveConfig(config);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
ctx.ui.notify(`保存配置失败: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
const providerConfig = buildProviderConfig(provider);
|
|
777
|
+
pi.registerProvider(provider.name, providerConfig);
|
|
778
|
+
return true;
|
|
779
|
+
} catch (error) {
|
|
780
|
+
config.providers = previous;
|
|
781
|
+
try {
|
|
782
|
+
saveConfig(config);
|
|
783
|
+
} catch {
|
|
784
|
+
/* 回滚写失败时保留现状 */
|
|
785
|
+
}
|
|
786
|
+
ctx.ui.notify(
|
|
787
|
+
`注册 provider "${provider.name}" 失败,已回滚保存: ${
|
|
788
|
+
error instanceof Error ? error.message : String(error)
|
|
789
|
+
}`,
|
|
790
|
+
"error"
|
|
791
|
+
);
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
// 大小写不敏感的 provider 查找(优先精确匹配)
|
|
797
|
+
const findProviderIndex = (config: IConfig, name: string): number => {
|
|
798
|
+
const exact = config.providers.findIndex((p) => p.name === name);
|
|
799
|
+
if (exact >= 0) return exact;
|
|
800
|
+
const lower = name.toLowerCase();
|
|
801
|
+
return config.providers.findIndex((p) => p.name.toLowerCase() === lower);
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// 非交互式添加:/add-provider '{"name":"...","baseUrl":"...","apiKey":"...",...}'
|
|
805
|
+
const addProviderFromJson = (jsonText: string, ctx: any): boolean => {
|
|
806
|
+
let data: any;
|
|
807
|
+
try {
|
|
808
|
+
data = JSON.parse(jsonText);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
ctx.ui.notify(`JSON 解析失败: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const name = String(data.name ?? "").trim();
|
|
815
|
+
const nameErr = validateProviderName(name);
|
|
816
|
+
if (nameErr) {
|
|
817
|
+
ctx.ui.notify(`名称无效: ${nameErr}`, "error");
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
const baseUrl = String(data.baseUrl ?? "").trim();
|
|
821
|
+
if (!baseUrl) {
|
|
822
|
+
ctx.ui.notify("缺少必填字段 baseUrl", "error");
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
const models: (string | IModel)[] = Array.isArray(data.models) ? data.models.filter(Boolean) : [];
|
|
826
|
+
if (models.length === 0) {
|
|
827
|
+
ctx.ui.notify("model 列表不能为空(models 需为数组)", "error");
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const provider: IProvider = {
|
|
832
|
+
name,
|
|
833
|
+
baseUrl,
|
|
834
|
+
apiKey: data.apiKey !== undefined ? String(data.apiKey) : "local",
|
|
835
|
+
models,
|
|
836
|
+
};
|
|
837
|
+
if (data.api) provider.api = String(data.api);
|
|
838
|
+
// 统一 /v1 归一化(与交互路径一致),根据显式 api 或 URL 推断协议
|
|
839
|
+
const apiForNormalize = provider.api ?? inferApi(provider.baseUrl);
|
|
840
|
+
const normalized = normalizeBaseUrl(provider.baseUrl, apiForNormalize);
|
|
841
|
+
if (normalized !== provider.baseUrl) {
|
|
842
|
+
provider.baseUrl = normalized;
|
|
843
|
+
console.log(`[custom-provider] 已自动调整端点: → ${normalized}`);
|
|
844
|
+
}
|
|
845
|
+
if (typeof data.authHeader === "boolean") provider.authHeader = data.authHeader;
|
|
846
|
+
if (typeof data.proxy === "string" && data.proxy.trim()) provider.proxy = data.proxy;
|
|
847
|
+
if (typeof data.enabled === "boolean") provider.enabled = data.enabled;
|
|
848
|
+
if (data.headers && typeof data.headers === "object") {
|
|
849
|
+
// 与 flags/向导同一套校验:拒绝非法名称/值(含 CR/LF 注入)
|
|
850
|
+
const bad = Object.entries(data.headers).find(([k, v]) => {
|
|
851
|
+
const val = String(v);
|
|
852
|
+
return !HEADER_NAME_RE.test(k) || !HEADER_VALUE_RE.test(val) || val === "";
|
|
853
|
+
});
|
|
854
|
+
if (bad) {
|
|
855
|
+
ctx.ui.notify(`请求头 ${bad[0]} 名称或值含非法字符(不允许换行/非 ASCII/空值)`, "error");
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
provider.headers = data.headers;
|
|
859
|
+
}
|
|
860
|
+
if (data.compat && typeof data.compat === "object") provider.compat = data.compat;
|
|
861
|
+
|
|
862
|
+
const config = loadConfig();
|
|
863
|
+
const ok = persistProvider(config, provider, ctx);
|
|
864
|
+
if (ok) {
|
|
865
|
+
const msg = `Provider "${name}" 已添加并注册,共 ${models.length} 个模型`;
|
|
866
|
+
if (ctx.ui.notify) ctx.ui.notify(msg, "info");
|
|
867
|
+
else console.log(`[custom-provider] ${msg}`);
|
|
868
|
+
}
|
|
869
|
+
return ok;
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
// 参数归一化:兼容字符串与数组两种调用形态
|
|
873
|
+
const toArgText = (args: unknown): string =>
|
|
874
|
+
Array.isArray(args) ? args.join(" ") : typeof args === "string" ? args : "";
|
|
875
|
+
|
|
876
|
+
// 先读本地规格缓存并同步注册(离线也可用:缓存 → 预设 → 默认)
|
|
877
|
+
loadSpecCache();
|
|
878
|
+
registerProviders();
|
|
879
|
+
|
|
880
|
+
// 后台拉取 OpenRouter 最新模型规格;成功后热更新注册,使配置立即使用真实规格
|
|
881
|
+
refreshRemoteSpecs().then(() => {
|
|
882
|
+
registerProviders();
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// 监听 session_start 以支持热重载
|
|
886
|
+
pi.on("session_start", () => {
|
|
887
|
+
registerProviders();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// ================= 子命令实现 =================
|
|
891
|
+
|
|
892
|
+
// 交互式过滤模型列表(按关键字保留/排除);返回 null 表示保留全部
|
|
893
|
+
const filterModelsInteractive = async (
|
|
894
|
+
ctx: any,
|
|
895
|
+
models: (string | IModel)[]
|
|
896
|
+
): Promise<(string | IModel)[] | null> => {
|
|
897
|
+
if (models.length === 0 || !ctx.hasUI) return null;
|
|
898
|
+
const idOf = (m: string | IModel) => (typeof m === "string" ? m : m.id).toLowerCase();
|
|
899
|
+
|
|
900
|
+
const mode = await ctx.ui.select(
|
|
901
|
+
`共拉取 ${models.length} 个模型,如何处理?`,
|
|
902
|
+
["全部保留", "按关键字保留", "按关键字排除"]
|
|
903
|
+
);
|
|
904
|
+
if (!mode || mode === "全部保留") return null;
|
|
905
|
+
|
|
906
|
+
const kwInput = await ctx.ui.input(
|
|
907
|
+
mode === "按关键字保留"
|
|
908
|
+
? "保留含任一关键字的模型(逗号分隔,如: deepseek,glm;留空=全部)"
|
|
909
|
+
: "排除含任一关键字的模型(逗号分隔,如: qwen,mini;留空=不排除)",
|
|
910
|
+
mode === "按关键字保留" ? "deepseek,glm" : "qwen,mini"
|
|
911
|
+
);
|
|
912
|
+
if (!kwInput || !kwInput.trim()) return null;
|
|
913
|
+
const keywords = kwInput.split(",").map((s: string) => s.trim().toLowerCase()).filter(Boolean);
|
|
914
|
+
if (keywords.length === 0) return null;
|
|
915
|
+
|
|
916
|
+
const filtered = models.filter((m) => {
|
|
917
|
+
const id = idOf(m);
|
|
918
|
+
return mode === "按关键字保留"
|
|
919
|
+
? keywords.some((k: string) => id.includes(k))
|
|
920
|
+
: !keywords.some((k: string) => id.includes(k));
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
ctx.ui.notify(`过滤后剩 ${filtered.length} 个(原 ${models.length} 个)`, "info");
|
|
924
|
+
if (filtered.length === 0) {
|
|
925
|
+
const keepAll = await ctx.ui.confirm(
|
|
926
|
+
"过滤结果为空",
|
|
927
|
+
"选择 Yes 保留全部模型,No 取消本次操作"
|
|
928
|
+
);
|
|
929
|
+
if (keepAll) return null;
|
|
930
|
+
await ctx.ui.notify("已取消", "info");
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
const ok = await ctx.ui.confirm(
|
|
934
|
+
`确认写入这 ${filtered.length} 个模型?`,
|
|
935
|
+
"选择 Yes 仅保存过滤后的模型,No 保留全部"
|
|
936
|
+
);
|
|
937
|
+
return ok ? filtered : null;
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
// 交互式添加向导(TUI/RPC 对话引导)
|
|
941
|
+
const doAddInteractive = async (ctx: any, nameDefault: string): Promise<void> => {
|
|
942
|
+
try {
|
|
943
|
+
// ---- 1. 名称 ----
|
|
944
|
+
const name = await ctx.ui.input(
|
|
945
|
+
`Provider 名称(字母/数字/-/_,必填)`,
|
|
946
|
+
nameDefault || "deepseek"
|
|
947
|
+
);
|
|
948
|
+
if (!name) return;
|
|
949
|
+
|
|
950
|
+
const nameErr = validateProviderName(name);
|
|
951
|
+
if (nameErr) {
|
|
952
|
+
ctx.ui.notify(`名称无效: ${nameErr}`, "error");
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// 提前检查重名,避免用户填完所有信息才发现已存在
|
|
957
|
+
const early = loadConfig();
|
|
958
|
+
const existingIndex = early.providers.findIndex((p) => p.name === name);
|
|
959
|
+
if (existingIndex >= 0) {
|
|
960
|
+
const ok = await ctx.ui.confirm(
|
|
961
|
+
`Provider "${name}" 已存在,是否覆盖?`,
|
|
962
|
+
"选择 Yes 覆盖现有配置(模型详情将按本次输入重建),No 取消本次操作"
|
|
963
|
+
);
|
|
964
|
+
if (!ok) {
|
|
965
|
+
ctx.ui.notify("已取消", "info");
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ---- 2. 端点 ----
|
|
971
|
+
const baseUrl = await ctx.ui.input(
|
|
972
|
+
"API 端点 URL(完整地址,通常含 /v1;粘贴 /v1/models 也会被自动清理)",
|
|
973
|
+
"https://api.deepseek.com/v1"
|
|
974
|
+
);
|
|
975
|
+
if (!baseUrl) return;
|
|
976
|
+
|
|
977
|
+
// ---- 3. API Key ----
|
|
978
|
+
const apiKeyInput = await ctx.ui.input(
|
|
979
|
+
"API Key(支持 $ENV 环境变量 / !命令 / 字面量 sk-xxx / 留空表示无认证本地服务)",
|
|
980
|
+
"$DEEPSEEK_API_KEY"
|
|
981
|
+
);
|
|
982
|
+
const apiKey = apiKeyInput || "local";
|
|
983
|
+
|
|
984
|
+
// 提前解析校验:空结果(env 未设置 / 命令输出空)静默会导致请求 401,先警告
|
|
985
|
+
const resolvedKey = resolveValue(apiKey);
|
|
986
|
+
if (resolvedKey === "" && apiKey !== "local") {
|
|
987
|
+
const origin = apiKey.startsWith("$")
|
|
988
|
+
? `环境变量 ${apiKey} 未设置或为空`
|
|
989
|
+
: apiKey.startsWith("!")
|
|
990
|
+
? "命令执行无输出"
|
|
991
|
+
: "API Key 为空";
|
|
992
|
+
const proceed = await ctx.ui.confirm(
|
|
993
|
+
`${origin},解析结果为空`,
|
|
994
|
+
"保存后请求可能无认证(401)。仍要继续吗?选择 No 可重新输入"
|
|
995
|
+
);
|
|
996
|
+
if (!proceed) {
|
|
997
|
+
ctx.ui.notify("已取消添加", "info");
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ---- 4. 协议类型 ----
|
|
1003
|
+
const apiType = await ctx.ui.select(
|
|
1004
|
+
"API 协议类型(推荐「自动推断」,按 URL 自动识别)",
|
|
1005
|
+
["自动推断", "openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"]
|
|
1006
|
+
);
|
|
1007
|
+
if (!apiType) return;
|
|
1008
|
+
|
|
1009
|
+
// 所有路径都做 /v1 归一化(自动推断先推断协议,再按协议规范化)
|
|
1010
|
+
const inferredApi =
|
|
1011
|
+
apiType === "自动推断" ? inferApi(baseUrl) : apiType;
|
|
1012
|
+
const finalBaseUrl = normalizeBaseUrl(baseUrl, inferredApi);
|
|
1013
|
+
if (finalBaseUrl !== baseUrl) {
|
|
1014
|
+
ctx.ui.notify(`已自动调整端点: ${baseUrl} → ${finalBaseUrl}`, "info");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ---- 4.5 代理(可选:需要走代理出网的渠道,如本地/公司代理)----
|
|
1018
|
+
const needProxy = await ctx.ui.confirm(
|
|
1019
|
+
"需要通过代理访问此渠道吗?",
|
|
1020
|
+
"如本地代理 http://127.0.0.1:7890 或公司代理;不支持可跳过(选 No)"
|
|
1021
|
+
);
|
|
1022
|
+
let proxyUrl: string | undefined;
|
|
1023
|
+
if (needProxy) {
|
|
1024
|
+
const proxyInput = await ctx.ui.input(
|
|
1025
|
+
"代理地址(http://host:port 或 socks5://host:port,支持 $ENV 引用)",
|
|
1026
|
+
"http://127.0.0.1:7890"
|
|
1027
|
+
);
|
|
1028
|
+
proxyUrl = proxyInput?.trim() || undefined;
|
|
1029
|
+
if (proxyUrl) {
|
|
1030
|
+
ctx.ui.notify(`将请求经代理 ${proxyUrl} 出网(需 NODE_USE_ENV_PROXY=1 生效,见 README)`, "info");
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ---- 5. 是否自动拉取模型 ----
|
|
1035
|
+
const autoFetch = await ctx.ui.confirm(
|
|
1036
|
+
"自动从 /v1/models 拉取模型列表?",
|
|
1037
|
+
"若服务支持模型列表端点可自动获取;否则将手动输入"
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
// ---- 6. 请求头模板(预设整组请求头,不同 CLI 头集合不同;选「自定义」逐头输入)----
|
|
1041
|
+
const presetLabels = HEADER_PRESETS.map((p) => p.label);
|
|
1042
|
+
const presetChoice = await ctx.ui.select(
|
|
1043
|
+
"请求头模板?",
|
|
1044
|
+
presetLabels
|
|
1045
|
+
);
|
|
1046
|
+
|
|
1047
|
+
let customHeaders: Record<string, string> | undefined;
|
|
1048
|
+
|
|
1049
|
+
// 校验并写入一组请求头(名称/值合法性校验 + 敏感头提示)
|
|
1050
|
+
const applyHeaderSet = (raw: Record<string, string>, source: string) => {
|
|
1051
|
+
let ok = true;
|
|
1052
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1053
|
+
const val = String(v);
|
|
1054
|
+
if (!HEADER_NAME_RE.test(k) || !HEADER_VALUE_RE.test(val)) {
|
|
1055
|
+
ctx.ui.notify(`${source}: 请求头 ${k} 名称或值含非法字符(不允许换行/非 ASCII)→ 已跳过`, "error");
|
|
1056
|
+
ok = false;
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if (SENSITIVE_HEADER_HINTS.includes(k.toLowerCase())) {
|
|
1060
|
+
ctx.ui.notify(`${source}: ${k} 属于敏感头,设置后可能覆盖认证/协议逻辑,请确认服务端要求`, "warning");
|
|
1061
|
+
}
|
|
1062
|
+
customHeaders = customHeaders ?? {};
|
|
1063
|
+
customHeaders[k] = val; // 值保留 $ENV 引用,pi 请求时动态解析
|
|
1064
|
+
}
|
|
1065
|
+
return ok;
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
if (presetChoice) {
|
|
1069
|
+
const preset = HEADER_PRESETS.find((p) => p.label === presetChoice);
|
|
1070
|
+
if (preset?.key) {
|
|
1071
|
+
if (preset.key !== "browser") {
|
|
1072
|
+
applyHeaderSet({ ...preset.headers }, `模板「${preset.label}」`);
|
|
1073
|
+
ctx.ui.notify(
|
|
1074
|
+
`已应用请求头模板「${preset.label}」: ${Object.keys(preset.headers).join(", ")}`,
|
|
1075
|
+
"info"
|
|
1076
|
+
);
|
|
1077
|
+
} // 浏览器模板 = 不写头,由 pi 自动补充浏览器 UA
|
|
1078
|
+
} else {
|
|
1079
|
+
// 自定义:JSON 对象,或每行 "Key: Value"(值支持 $ENV)
|
|
1080
|
+
const headersInput = await ctx.ui.input(
|
|
1081
|
+
'自定义请求头(JSON 对象,或每行 "Key: Value":值支持 $ENV;留空取消)',
|
|
1082
|
+
'{"X-API-Key": "$MY_API_KEY"}'
|
|
1083
|
+
);
|
|
1084
|
+
if (headersInput && headersInput.trim()) {
|
|
1085
|
+
const t = headersInput.trim();
|
|
1086
|
+
try {
|
|
1087
|
+
const parsed = JSON.parse(t);
|
|
1088
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1089
|
+
applyHeaderSet({ ...parsed }, "自定义请求头");
|
|
1090
|
+
} else {
|
|
1091
|
+
ctx.ui.notify("JSON 需为对象({...})", "error");
|
|
1092
|
+
}
|
|
1093
|
+
} catch {
|
|
1094
|
+
// 非 JSON:按行解析 "Key: Value"
|
|
1095
|
+
const lines = t.split(/\r\n|\r|\n/);
|
|
1096
|
+
|
|
1097
|
+
const kv: Record<string, string> = {};
|
|
1098
|
+
let parseOk = true;
|
|
1099
|
+
for (const line of lines) {
|
|
1100
|
+
if (!line.trim()) continue;
|
|
1101
|
+
const idx = line.indexOf(":");
|
|
1102
|
+
if (idx <= 0) {
|
|
1103
|
+
ctx.ui.notify(`无法解析行: ${line}`, "error");
|
|
1104
|
+
parseOk = false;
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
kv[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
1108
|
+
}
|
|
1109
|
+
if (parseOk) applyHeaderSet(kv, "自定义请求头");
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---- 7. 补充/覆盖请求头(在模板/自定义基础上追加,可覆盖;无需直接跳过)----
|
|
1116
|
+
const needMore = await ctx.ui.confirm(
|
|
1117
|
+
"需要补充/覆盖请求头吗?",
|
|
1118
|
+
"在现有头部上追加或覆盖(如 X-API-Key),值支持 $ENV 插值"
|
|
1119
|
+
);
|
|
1120
|
+
if (needMore) {
|
|
1121
|
+
const headersInput = await ctx.ui.input(
|
|
1122
|
+
"请求头 JSON(合并进现有头部,可覆盖模板值)",
|
|
1123
|
+
'{"X-API-Key": "$MY_API_KEY"}'
|
|
1124
|
+
);
|
|
1125
|
+
if (headersInput && headersInput.trim()) {
|
|
1126
|
+
try {
|
|
1127
|
+
const parsed = JSON.parse(headersInput);
|
|
1128
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1129
|
+
applyHeaderSet({ ...parsed }, "补充请求头");
|
|
1130
|
+
} else {
|
|
1131
|
+
ctx.ui.notify("JSON 需为对象", "error");
|
|
1132
|
+
}
|
|
1133
|
+
} catch {
|
|
1134
|
+
ctx.ui.notify("JSON 格式错误,已跳过", "error");
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// ---- 8. 高级配置 // ---- 8. 高级配置:authHeader / compat / 按模型覆盖(vision、reasoning、cost 等)----
|
|
1140
|
+
const advancedSkeleton = {
|
|
1141
|
+
authHeader: false,
|
|
1142
|
+
compat: {},
|
|
1143
|
+
modelOverrides: {},
|
|
1144
|
+
};
|
|
1145
|
+
const needAdvanced = await ctx.ui.confirm(
|
|
1146
|
+
"需要高级配置吗?",
|
|
1147
|
+
"如 authHeader、compat、按模型覆盖(input 图像、reasoning、contextWindow、cost、maxTokens、api 协议)。支持双协议混用: modelOverrides 里给单个模型设 api + baseUrl"
|
|
1148
|
+
);
|
|
1149
|
+
let authHeader = false;
|
|
1150
|
+
let compat: Record<string, any> | undefined;
|
|
1151
|
+
let modelOverrides: Record<string, Partial<IModel>> | undefined;
|
|
1152
|
+
if (needAdvanced) {
|
|
1153
|
+
const advText = await ctx.ui.editor(
|
|
1154
|
+
"高级配置 JSON(对象字段:authHeader / compat / modelOverrides,直接编辑或留空跳过)",
|
|
1155
|
+
JSON.stringify(advancedSkeleton, null, 2)
|
|
1156
|
+
);
|
|
1157
|
+
if (advText && advText.trim()) {
|
|
1158
|
+
try {
|
|
1159
|
+
const adv = JSON.parse(advText);
|
|
1160
|
+
if (typeof adv.authHeader === "boolean") authHeader = adv.authHeader;
|
|
1161
|
+
if (adv.compat && typeof adv.compat === "object") compat = adv.compat;
|
|
1162
|
+
if (adv.modelOverrides && typeof adv.modelOverrides === "object") {
|
|
1163
|
+
modelOverrides = adv.modelOverrides;
|
|
1164
|
+
}
|
|
1165
|
+
if (adv.authHeader) {
|
|
1166
|
+
ctx.ui.notify(
|
|
1167
|
+
"authHeader=true 已开启:将以 Authorization: Bearer <API Key> 发送认证",
|
|
1168
|
+
"info"
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
} catch {
|
|
1172
|
+
ctx.ui.notify("高级配置 JSON 格式错误,已忽略", "error");
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// ---- 9. 收集模型 ----
|
|
1178
|
+
let models: (string | IModel)[] = [];
|
|
1179
|
+
if (autoFetch) {
|
|
1180
|
+
ctx.ui.notify("正在拉取模型列表…", "info");
|
|
1181
|
+
try {
|
|
1182
|
+
const modelIds = await fetchModels(finalBaseUrl, resolvedKey, inferredApi, customHeaders);
|
|
1183
|
+
models = modelIds;
|
|
1184
|
+
ctx.ui.notify(`成功拉取 ${models.length} 个模型`, "info");
|
|
1185
|
+
// 过滤:避免把渠道全量模型写入(按关键字保留/排除)
|
|
1186
|
+
if (ctx.hasUI && models.length > 0) {
|
|
1187
|
+
const filtered = await filterModelsInteractive(ctx, models);
|
|
1188
|
+
if (filtered) models = filtered;
|
|
1189
|
+
}
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
ctx.ui.notify(
|
|
1192
|
+
`${error instanceof Error ? error.message : String(error)}\n可手动输入模型,或重新检查端点/Key`,
|
|
1193
|
+
"error"
|
|
1194
|
+
);
|
|
1195
|
+
const fallback = await ctx.ui.input(
|
|
1196
|
+
"手动输入模型 ID(逗号分隔,可留空取消)",
|
|
1197
|
+
"model-a,model-b"
|
|
1198
|
+
);
|
|
1199
|
+
if (fallback && fallback.trim()) {
|
|
1200
|
+
models = fallback.split(",").map((s: string) => s.trim()).filter(Boolean);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
} else {
|
|
1204
|
+
const input = await ctx.ui.input(
|
|
1205
|
+
"模型 ID(逗号分隔,可留空取消)",
|
|
1206
|
+
"deepseek-chat,deepseek-reasoner"
|
|
1207
|
+
);
|
|
1208
|
+
if (input && input.trim()) {
|
|
1209
|
+
models = input.split(",").map((s: string) => s.trim()).filter(Boolean);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (models.length === 0) {
|
|
1214
|
+
ctx.ui.notify("未提供模型,取消添加", "warning");
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// 手动输入时提供连通性测试(自动拉取成功则已证明连通)
|
|
1219
|
+
if (!autoFetch) {
|
|
1220
|
+
const test = await ctx.ui.confirm(
|
|
1221
|
+
"测试连接?",
|
|
1222
|
+
`将请求 ${finalBaseUrl}/models 验证端点与 API Key`
|
|
1223
|
+
);
|
|
1224
|
+
if (test) {
|
|
1225
|
+
ctx.ui.notify("测试中…", "info");
|
|
1226
|
+
try {
|
|
1227
|
+
const ids = await fetchModels(finalBaseUrl, resolvedKey, inferredApi, customHeaders);
|
|
1228
|
+
ctx.ui.notify(`连接正常,检测到 ${ids.length} 个模型`, "info");
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1231
|
+
const keep = await ctx.ui.confirm(
|
|
1232
|
+
"连接测试失败",
|
|
1233
|
+
`${msg}\n仍要保存此配置吗?`
|
|
1234
|
+
);
|
|
1235
|
+
if (!keep) {
|
|
1236
|
+
ctx.ui.notify("已取消添加", "info");
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// ---- 10. 应用高级覆盖并持久化 ----
|
|
1244
|
+
if (modelOverrides) {
|
|
1245
|
+
models = applyModelOverrides(models, modelOverrides);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ---- 10.1 Anthropic 1M 长上下文提示:
|
|
1249
|
+
// 含 [1m] 后缀或大窗口模型的 anthropic-messages 中转,需要携带
|
|
1250
|
+
// anthropic-beta: context-1m-2025-08-07 请求头,1M 窗口才生效(官方端点不支持)
|
|
1251
|
+
const hasLongCtxModel =
|
|
1252
|
+
inferredApi === "anthropic-messages" &&
|
|
1253
|
+
!isOfficialAnthropicEndpoint(finalBaseUrl) &&
|
|
1254
|
+
models.some((m) => hasLongContextSuffix(typeof m === "string" ? m : m.id));
|
|
1255
|
+
if (hasLongCtxModel && !hasHeader(customHeaders, "anthropic-beta")) {
|
|
1256
|
+
const addBeta = await ctx.ui.confirm(
|
|
1257
|
+
"检测到 1M 长上下文模型([1m])",
|
|
1258
|
+
"将自动添加请求头 anthropic-beta: context-1m-2025-08-07,否则 1M 窗口不生效(官方端点不支持此头)。要添加吗?"
|
|
1259
|
+
);
|
|
1260
|
+
if (addBeta) {
|
|
1261
|
+
if (!customHeaders) customHeaders = {};
|
|
1262
|
+
customHeaders["anthropic-beta"] = "context-1m-2025-08-07";
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const newProvider: IProvider = {
|
|
1267
|
+
name,
|
|
1268
|
+
baseUrl: finalBaseUrl,
|
|
1269
|
+
apiKey,
|
|
1270
|
+
models,
|
|
1271
|
+
};
|
|
1272
|
+
if (proxyUrl) newProvider.proxy = proxyUrl;
|
|
1273
|
+
if (apiType !== "自动推断" || inferredApi !== "openai-completions") {
|
|
1274
|
+
// 显式选择的协议,或自动推断出的非默认协议,需存盘保证幂等
|
|
1275
|
+
newProvider.api = inferredApi;
|
|
1276
|
+
}
|
|
1277
|
+
if (authHeader) newProvider.authHeader = true;
|
|
1278
|
+
if (customHeaders && Object.keys(customHeaders).length > 0) {
|
|
1279
|
+
newProvider.headers = customHeaders;
|
|
1280
|
+
}
|
|
1281
|
+
if (compat && Object.keys(compat).length > 0) {
|
|
1282
|
+
newProvider.compat = compat;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const ok = persistProvider(loadConfig(), newProvider, ctx);
|
|
1286
|
+
if (ok) {
|
|
1287
|
+
ctx.ui.notify(
|
|
1288
|
+
`Provider "${name}" 已添加并注册,共 ${models.length} 个模型。用 /model 选择模型`,
|
|
1289
|
+
"info"
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
ctx.ui.notify(
|
|
1294
|
+
`添加失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
1295
|
+
"error"
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
// 由 flags 组装(无交互)Provider 配置;失败返回 null 并已通知原因
|
|
1301
|
+
const buildProviderFromFlags = (parsed: ParsedArgs, ctx: any): IProvider | null => {
|
|
1302
|
+
const { positional, flags } = parsed;
|
|
1303
|
+
const name = getFlag(flags, "name") || positional[0];
|
|
1304
|
+
if (!name || validateProviderName(name)) {
|
|
1305
|
+
ctx.ui.notify(`名称无效: ${validateProviderName(name) ?? "缺少名称(--name 或位置参数)"}`, "error");
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
const baseUrl = getFlag(flags, "base-url", "url");
|
|
1309
|
+
if (!baseUrl) {
|
|
1310
|
+
ctx.ui.notify("缺少 --base-url", "error");
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
const apiKey = getFlag(flags, "api-key", "key") ?? "local";
|
|
1314
|
+
const apiRaw = getFlag(flags, "api");
|
|
1315
|
+
const api = !apiRaw || apiRaw === "auto" ? inferApi(baseUrl) : apiRaw;
|
|
1316
|
+
|
|
1317
|
+
let models: (string | IModel)[] = [];
|
|
1318
|
+
const modelsCsv = getFlag(flags, "models");
|
|
1319
|
+
if (modelsCsv) models = modelsCsv.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1320
|
+
for (const m of flags.get("model") ?? []) {
|
|
1321
|
+
if (m.trim()) models.push(m.trim());
|
|
1322
|
+
}
|
|
1323
|
+
if (models.length === 0) {
|
|
1324
|
+
ctx.ui.notify("缺少 --models(逗号分隔)或 --model(可多次)", "error");
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// --model-api "模型id:协议" 与 --model-base-url "模型id:url"(可重复):
|
|
1329
|
+
// 让单个模型覆盖协议/端点(如同一网关内部分模型走 anthropic 协议)
|
|
1330
|
+
const modelApiPairs = flags.get("model-api") ?? [];
|
|
1331
|
+
const modelBaseUrlPairs = flags.get("model-base-url") ?? [];
|
|
1332
|
+
if (modelApiPairs.length > 0 || modelBaseUrlPairs.length > 0) {
|
|
1333
|
+
const byId = new Map<string, Partial<IModel>>();
|
|
1334
|
+
const applyPair = (pair: string, key: "api" | "baseUrl", label: string): boolean => {
|
|
1335
|
+
const idx = pair.indexOf(":");
|
|
1336
|
+
if (idx <= 0) {
|
|
1337
|
+
ctx.ui.notify(`${label} 格式错误(应为 "模型id:值"): ${pair}`, "error");
|
|
1338
|
+
return false;
|
|
1339
|
+
}
|
|
1340
|
+
const id = pair.slice(0, idx).trim();
|
|
1341
|
+
const val = pair.slice(idx + 1).trim();
|
|
1342
|
+
if (!id || !val) {
|
|
1343
|
+
ctx.ui.notify(`${label} 格式错误(模型id与值不能为空): ${pair}`, "error");
|
|
1344
|
+
return false;
|
|
1345
|
+
}
|
|
1346
|
+
const entry = byId.get(id) ?? {};
|
|
1347
|
+
if (key === "api" && !/^(?:(?:openai-completions|openai-responses|anthropic-messages|google-generative-ai|auto))$/.test(val)) {
|
|
1348
|
+
ctx.ui.notify(`未知协议类型: ${val}(可选: openai-completions / openai-responses / anthropic-messages / google-generative-ai)`, "error");
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
entry[key] = val;
|
|
1352
|
+
byId.set(id, entry);
|
|
1353
|
+
return true;
|
|
1354
|
+
};
|
|
1355
|
+
for (const p of modelApiPairs) {
|
|
1356
|
+
if (!applyPair(p, "api", "--model-api")) return null;
|
|
1357
|
+
}
|
|
1358
|
+
for (const p of modelBaseUrlPairs) {
|
|
1359
|
+
if (!applyPair(p, "baseUrl", "--model-base-url")) return null;
|
|
1360
|
+
}
|
|
1361
|
+
if (byId.size > 0) {
|
|
1362
|
+
models = models.map((m) => {
|
|
1363
|
+
const id = typeof m === "string" ? m : m.id;
|
|
1364
|
+
const patch = byId.get(id);
|
|
1365
|
+
if (!patch) return m;
|
|
1366
|
+
return typeof m === "string" ? ({ id, ...patch } as IModel) : { ...m, ...patch };
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const headers: Record<string, string> = {};
|
|
1372
|
+
for (const h of flags.get("header") ?? []) {
|
|
1373
|
+
const idx = h.indexOf(":");
|
|
1374
|
+
if (idx <= 0) {
|
|
1375
|
+
ctx.ui.notify(`--header 格式错误(应为 "Name: value"): ${h}`, "error");
|
|
1376
|
+
return null;
|
|
1377
|
+
}
|
|
1378
|
+
headers[h.slice(0, idx).trim()] = h.slice(idx + 1).trim();
|
|
1379
|
+
}
|
|
1380
|
+
const headersJson = getFlag(flags, "headers");
|
|
1381
|
+
if (headersJson) {
|
|
1382
|
+
try {
|
|
1383
|
+
const obj = JSON.parse(headersJson);
|
|
1384
|
+
if (typeof obj === "object" && obj !== null) Object.assign(headers, obj);
|
|
1385
|
+
else {
|
|
1386
|
+
ctx.ui.notify("--headers 需为 JSON 对象", "error");
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
} catch {
|
|
1390
|
+
ctx.ui.notify("--headers JSON 解析失败", "error");
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const provider: IProvider = {
|
|
1396
|
+
name,
|
|
1397
|
+
baseUrl: normalizeBaseUrl(baseUrl, api),
|
|
1398
|
+
apiKey,
|
|
1399
|
+
models,
|
|
1400
|
+
};
|
|
1401
|
+
const proxyFlag = getFlag(flags, "proxy");
|
|
1402
|
+
if (proxyFlag) provider.proxy = proxyFlag;
|
|
1403
|
+
if (apiRaw && apiRaw !== "auto") provider.api = apiRaw;
|
|
1404
|
+
else if (api !== "openai-completions") provider.api = api;
|
|
1405
|
+
if (getFlag(flags, "auth-header")) provider.authHeader = true;
|
|
1406
|
+
|
|
1407
|
+
const compatJson = getFlag(flags, "compat");
|
|
1408
|
+
if (compatJson) {
|
|
1409
|
+
try {
|
|
1410
|
+
const obj = JSON.parse(compatJson);
|
|
1411
|
+
if (obj && typeof obj === "object") provider.compat = obj;
|
|
1412
|
+
else {
|
|
1413
|
+
ctx.ui.notify("--compat 需为 JSON 对象", "error");
|
|
1414
|
+
return null;
|
|
1415
|
+
}
|
|
1416
|
+
} catch {
|
|
1417
|
+
ctx.ui.notify("--compat JSON 解析失败", "error");
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const overridesJson = getFlag(flags, "overrides");
|
|
1423
|
+
if (overridesJson) {
|
|
1424
|
+
try {
|
|
1425
|
+
const obj = JSON.parse(overridesJson);
|
|
1426
|
+
if (obj && typeof obj === "object") {
|
|
1427
|
+
provider.models = applyModelOverrides(provider.models, obj);
|
|
1428
|
+
} else {
|
|
1429
|
+
ctx.ui.notify("--overrides 需为 JSON 对象", "error");
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
} catch {
|
|
1433
|
+
ctx.ui.notify("--overrides JSON 解析失败", "error");
|
|
1434
|
+
return null;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// --profile 应用完整请求头模板(不覆盖 --header/--headers 显式写入的头)
|
|
1439
|
+
const profileFlag = getFlag(flags, "profile");
|
|
1440
|
+
if (profileFlag) {
|
|
1441
|
+
const preset = HEADER_PRESETS.find((p) => p.key === profileFlag.toLowerCase());
|
|
1442
|
+
if (!preset || preset.key === undefined) {
|
|
1443
|
+
const keys = HEADER_PRESETS.filter((p) => p.key).map((p) => p.key).join(", ");
|
|
1444
|
+
ctx.ui.notify(`未知模板 "${profileFlag}",可用: ${keys}`, "error");
|
|
1445
|
+
return null;
|
|
1446
|
+
}
|
|
1447
|
+
if (preset.key !== "browser") {
|
|
1448
|
+
const lowerKeys = new Set(Object.keys(headers).map((k) => k.toLowerCase()));
|
|
1449
|
+
for (const [k, v] of Object.entries(preset.headers)) {
|
|
1450
|
+
if (!lowerKeys.has(k.toLowerCase())) headers[k] = v; // 不覆盖显式
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// --ua 预设 User-Agent(preset 键名或原始字符串);显式 headers 里已有 User-Agent 则不覆盖
|
|
1456
|
+
const uaFlag = getFlag(flags, "ua", "user-agent");
|
|
1457
|
+
if (uaFlag && !hasHeader(headers, "user-agent") && !profileFlag) {
|
|
1458
|
+
const presetKey = uaFlag.toLowerCase();
|
|
1459
|
+
if (UA_PRESETS[presetKey]) {
|
|
1460
|
+
headers["User-Agent"] = UA_PRESETS[presetKey];
|
|
1461
|
+
} else {
|
|
1462
|
+
headers["User-Agent"] = uaFlag;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
if (Object.keys(headers).length > 0) provider.headers = headers;
|
|
1467
|
+
return provider;
|
|
1468
|
+
};
|
|
1469
|
+
|
|
1470
|
+
// add:JSON / flags 非交互 / 交互向导 三种路径
|
|
1471
|
+
const doAdd = async (argText: string, ctx: any): Promise<void> => {
|
|
1472
|
+
const t = argText.trim();
|
|
1473
|
+
|
|
1474
|
+
// 路径 1:JSON 参数(位置参数或 --json)
|
|
1475
|
+
if (t.startsWith("{")) {
|
|
1476
|
+
addProviderFromJson(t, ctx);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const parsed = parseFlagArgs(t);
|
|
1480
|
+
const jsonFlag = getFlag(parsed.flags, "json");
|
|
1481
|
+
if (jsonFlag) {
|
|
1482
|
+
addProviderFromJson(jsonFlag, ctx);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const baseUrl = getFlag(parsed.flags, "base-url", "url");
|
|
1487
|
+
const modelsCsv = getFlag(parsed.flags, "models");
|
|
1488
|
+
const modelFlags = parsed.flags.get("model") ?? [];
|
|
1489
|
+
const hasModels = !!modelsCsv || modelFlags.length > 0;
|
|
1490
|
+
|
|
1491
|
+
// 路径 2:flags 非交互添加
|
|
1492
|
+
if (baseUrl && hasModels) {
|
|
1493
|
+
const provider = buildProviderFromFlags(parsed, ctx);
|
|
1494
|
+
if (!provider) return;
|
|
1495
|
+
|
|
1496
|
+
const config = loadConfig();
|
|
1497
|
+
const exists = config.providers.some((p) => p.name === provider.name);
|
|
1498
|
+
if (exists && !getFlag(parsed.flags, "force") && !getFlag(parsed.flags, "yes")) {
|
|
1499
|
+
if (ctx.hasUI) {
|
|
1500
|
+
const ok = await ctx.ui.confirm(
|
|
1501
|
+
`Provider "${provider.name}" 已存在,是否覆盖?`,
|
|
1502
|
+
"选择 Yes 覆盖现有配置,No 取消(--force 可跳过此确认)"
|
|
1503
|
+
);
|
|
1504
|
+
if (!ok) {
|
|
1505
|
+
ctx.ui.notify("已取消", "info");
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
} else {
|
|
1509
|
+
ctx.ui.notify(`已存在 provider "${provider.name}",如需覆盖请加 --force`, "error");
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const ok = persistProvider(config, provider, ctx);
|
|
1515
|
+
if (ok) {
|
|
1516
|
+
ctx.ui.notify(
|
|
1517
|
+
`Provider "${provider.name}" 已添加并注册,共 ${provider.models.length} 个模型。用 /model 选择模型`,
|
|
1518
|
+
"info"
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// 路径 3:交互向导
|
|
1525
|
+
if (!ctx.hasUI) {
|
|
1526
|
+
ctx.ui.notify(
|
|
1527
|
+
"非交互环境请用参数添加: /custom-provider add --name x --base-url URL --models a,b [--api-key $K] [--api TYPE] [--force],或用 --json '{...}'",
|
|
1528
|
+
"warning"
|
|
1529
|
+
);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
await doAddInteractive(ctx, getFlag(parsed.flags, "name") || parsed.positional[0] || "deepseek");
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
// remove:按名称删除(--yes / -y 跳过确认)
|
|
1536
|
+
const doRemove = async (argText: string, ctx: any): Promise<void> => {
|
|
1537
|
+
const config = loadConfig();
|
|
1538
|
+
if (config.providers.length === 0) {
|
|
1539
|
+
ctx.ui.notify("暂无已配置的 provider,请先用 /custom-provider add 添加", "warning");
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
const parsed = parseFlagArgs(argText);
|
|
1544
|
+
let name = parsed.positional[0] || getFlag(parsed.flags, "name");
|
|
1545
|
+
if (!name && ctx.hasUI) {
|
|
1546
|
+
const selected = await ctx.ui.select(
|
|
1547
|
+
"选择要删除的 Provider",
|
|
1548
|
+
config.providers.map((p) => p.name)
|
|
1549
|
+
);
|
|
1550
|
+
if (!selected) return;
|
|
1551
|
+
name = selected;
|
|
1552
|
+
}
|
|
1553
|
+
if (!name) {
|
|
1554
|
+
ctx.ui.notify("请指定名称: /custom-provider remove <名称> [--yes]", "warning");
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const idx = findProviderIndex(config, name);
|
|
1559
|
+
if (idx < 0) {
|
|
1560
|
+
ctx.ui.notify(`未找到 provider "${name}"`, "error");
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (ctx.hasUI && !getFlag(parsed.flags, "yes")) {
|
|
1565
|
+
const ok = await ctx.ui.confirm(
|
|
1566
|
+
`删除 Provider "${name}"?(共 ${config.providers[idx].models.length} 个模型)`,
|
|
1567
|
+
"将同时注销该 provider,不可撤销;--yes 可跳过确认"
|
|
1568
|
+
);
|
|
1569
|
+
if (!ok) {
|
|
1570
|
+
ctx.ui.notify("已取消", "info");
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
config.providers.splice(idx, 1);
|
|
1576
|
+
try {
|
|
1577
|
+
saveConfig(config);
|
|
1578
|
+
pi.unregisterProvider(name);
|
|
1579
|
+
ctx.ui.notify(`Provider "${name}" 已删除并注销`, "info");
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
ctx.ui.notify(`删除失败: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
// refresh:重拉模型列表(保留已存在模型的详细配置)
|
|
1586
|
+
const doRefresh = async (argText: string, ctx: any): Promise<void> => {
|
|
1587
|
+
const config = loadConfig();
|
|
1588
|
+
if (config.providers.length === 0) {
|
|
1589
|
+
ctx.ui.notify("暂无已配置的 provider,请先用 /custom-provider add 添加", "warning");
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const parsed = parseFlagArgs(argText);
|
|
1594
|
+
const argName = parsed.positional[0] || getFlag(parsed.flags, "name");
|
|
1595
|
+
let target: IProvider | undefined;
|
|
1596
|
+
if (argName) {
|
|
1597
|
+
const argIdx = findProviderIndex(config, argName);
|
|
1598
|
+
if (argIdx < 0) {
|
|
1599
|
+
ctx.ui.notify(`未找到 provider "${argName}"`, "error");
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
target = config.providers[argIdx];
|
|
1603
|
+
} else if (ctx.hasUI) {
|
|
1604
|
+
const selected = await ctx.ui.select(
|
|
1605
|
+
"选择要刷新模型的 Provider",
|
|
1606
|
+
config.providers.map((p) => p.name)
|
|
1607
|
+
);
|
|
1608
|
+
if (!selected) return;
|
|
1609
|
+
target = config.providers.find((p) => p.name === selected);
|
|
1610
|
+
} else {
|
|
1611
|
+
target = config.providers[0];
|
|
1612
|
+
}
|
|
1613
|
+
if (!target) return;
|
|
1614
|
+
|
|
1615
|
+
if (target.enabled === false) {
|
|
1616
|
+
ctx.ui.notify(`Provider "${target.name}" 已禁用,请先 /custom-provider enable ${target.name}`, "warning");
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
ctx.ui.notify(`正在从 ${target.baseUrl} 拉取模型列表…`, "info");
|
|
1621
|
+
try {
|
|
1622
|
+
const apiKey = resolveValue(target.apiKey);
|
|
1623
|
+
const api = inferApi(target.baseUrl, target.api);
|
|
1624
|
+
const modelIds = await fetchModels(target.baseUrl, apiKey, api, target.headers);
|
|
1625
|
+
|
|
1626
|
+
if (modelIds.length === 0) {
|
|
1627
|
+
ctx.ui.notify("端点未返回任何模型", "error");
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// 合并:保留已存在模型的详细配置(contextWindow/maxTokens 等),新模型用默认配置
|
|
1632
|
+
const existing = new Map(
|
|
1633
|
+
target.models.map((m) => [typeof m === "string" ? m : m.id, m])
|
|
1634
|
+
);
|
|
1635
|
+
target.models = modelIds.map((id) => existing.get(id) ?? id);
|
|
1636
|
+
|
|
1637
|
+
const ok = persistProvider(config, target, ctx);
|
|
1638
|
+
if (ok) {
|
|
1639
|
+
ctx.ui.notify(
|
|
1640
|
+
`已更新 provider "${target.name}",共 ${target.models.length} 个模型。用 /model 切换`,
|
|
1641
|
+
"info"
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
// list:列出全部 provider
|
|
1650
|
+
const doList = (ctx: any): void => {
|
|
1651
|
+
const config = loadConfig();
|
|
1652
|
+
if (config.providers.length === 0) {
|
|
1653
|
+
ctx.ui.notify("暂无已配置的 provider,用 /custom-provider add 添加", "info");
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
const lines = config.providers.map((p) => {
|
|
1657
|
+
const api = p.api ?? inferApi(p.baseUrl);
|
|
1658
|
+
const state = p.enabled === false ? "✗ 禁用" : "✓ 启用";
|
|
1659
|
+
const ids = p.models.map((m) => (typeof m === "string" ? m : m.id));
|
|
1660
|
+
const preview =
|
|
1661
|
+
ids.length <= 4 ? ids.join(", ") : `${ids.slice(0, 4).join(", ")}, …(共 ${ids.length} 个)`;
|
|
1662
|
+
const proxyLine = p.proxy ? `\n 代理: ${p.proxy}` : "";
|
|
1663
|
+
return `• ${p.name} [${state}] [${api}]\n 端点: ${p.baseUrl}${proxyLine}\n 模型: ${preview}`;
|
|
1664
|
+
});
|
|
1665
|
+
ctx.ui.notify(`已配置 ${config.providers.length} 个 provider:\n\n${lines.join("\n\n")}\n\n启用/禁用: /custom-provider enable|disable <名称>` , "info");
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
// test:测试连接(已配置 provider 或临时端点)
|
|
1669
|
+
const doTest = async (argText: string, ctx: any): Promise<void> => {
|
|
1670
|
+
const parsed = parseFlagArgs(argText);
|
|
1671
|
+
const name = parsed.positional[0] || getFlag(parsed.flags, "name");
|
|
1672
|
+
const tmpBaseUrl = getFlag(parsed.flags, "base-url", "url");
|
|
1673
|
+
|
|
1674
|
+
let baseUrl: string;
|
|
1675
|
+
let apiKey: string;
|
|
1676
|
+
let api: string;
|
|
1677
|
+
let headers: Record<string, string> | undefined;
|
|
1678
|
+
let label: string;
|
|
1679
|
+
|
|
1680
|
+
if (tmpBaseUrl) {
|
|
1681
|
+
// 测试临时端点(不保存配置)
|
|
1682
|
+
baseUrl = tmpBaseUrl;
|
|
1683
|
+
apiKey = getFlag(parsed.flags, "api-key", "key") ?? "";
|
|
1684
|
+
const apiRaw = getFlag(parsed.flags, "api");
|
|
1685
|
+
api = !apiRaw || apiRaw === "auto" ? inferApi(baseUrl) : apiRaw;
|
|
1686
|
+
label = name || baseUrl;
|
|
1687
|
+
} else {
|
|
1688
|
+
if (!name) {
|
|
1689
|
+
ctx.ui.notify("请指定名称: /custom-provider test <名称>,或提供临时端点: test --base-url URL --api-key KEY", "warning");
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
const config = loadConfig();
|
|
1693
|
+
const pIdx = findProviderIndex(config, name);
|
|
1694
|
+
if (pIdx < 0) {
|
|
1695
|
+
ctx.ui.notify(`未找到 provider "${name}"`, "error");
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const p = config.providers[pIdx];
|
|
1699
|
+
if (p.enabled === false) {
|
|
1700
|
+
ctx.ui.notify(`Provider "${name}" 已禁用,请先 /custom-provider enable ${name}`, "warning");
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
baseUrl = p.baseUrl;
|
|
1704
|
+
apiKey = resolveValue(p.apiKey);
|
|
1705
|
+
api = inferApi(p.baseUrl, p.api);
|
|
1706
|
+
headers = p.headers;
|
|
1707
|
+
label = p.name;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
ctx.ui.notify(`正在测试 ${label}(${baseUrl})…`, "info");
|
|
1711
|
+
try {
|
|
1712
|
+
const ids = await fetchModels(baseUrl, apiKey, api, headers);
|
|
1713
|
+
ctx.ui.notify(`测试通过:端点可用,检测到 ${ids.length} 个模型`, "info");
|
|
1714
|
+
if (!tmpBaseUrl && !ids.length) ctx.ui.notify("提示: 端点可用但未返回模型,可尝试 /custom-provider refresh", "info");
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
ctx.ui.notify(`测试失败: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
// prune:修剪已配置 provider 的模型列表(交互过滤,或 --keep/--drop 按关键字)
|
|
1721
|
+
const doPrune = async (argText: string, ctx: any): Promise<void> => {
|
|
1722
|
+
const config = loadConfig();
|
|
1723
|
+
if (config.providers.length === 0) {
|
|
1724
|
+
ctx.ui.notify("暂无已配置的 provider", "warning");
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const parsed = parseFlagArgs(argText);
|
|
1728
|
+
let name = parsed.positional[0] || getFlag(parsed.flags, "name");
|
|
1729
|
+
if (!name && ctx.hasUI) {
|
|
1730
|
+
const selected = await ctx.ui.select(
|
|
1731
|
+
"选择要修剪模型的 Provider",
|
|
1732
|
+
config.providers.map((p) => p.name)
|
|
1733
|
+
);
|
|
1734
|
+
if (!selected) return;
|
|
1735
|
+
name = selected;
|
|
1736
|
+
}
|
|
1737
|
+
if (!name) {
|
|
1738
|
+
ctx.ui.notify("请指定名称: /custom-provider prune <名称> [--keep 关键词] [--drop 关键词]", "warning");
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const idx = findProviderIndex(config, name);
|
|
1742
|
+
if (idx < 0) {
|
|
1743
|
+
ctx.ui.notify(`未找到 provider "${name}"`, "error");
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const target = config.providers[idx];
|
|
1747
|
+
if (target.enabled === false) {
|
|
1748
|
+
ctx.ui.notify(`Provider "${name}" 已禁用,请先 /custom-provider enable ${name}`, "warning");
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const keepKw = getFlag(parsed.flags, "keep");
|
|
1753
|
+
const dropKw = getFlag(parsed.flags, "drop");
|
|
1754
|
+
const originalCount = target.models.length;
|
|
1755
|
+
let models = target.models;
|
|
1756
|
+
|
|
1757
|
+
if (keepKw || dropKw) {
|
|
1758
|
+
// 非交互:按关键字过滤(大小写不敏感子串匹配)
|
|
1759
|
+
const ks = (keepKw ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1760
|
+
const ds = (dropKw ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1761
|
+
if (ks.length === 0 && ds.length === 0) {
|
|
1762
|
+
ctx.ui.notify("--keep/--drop 关键词不能为空", "error");
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const filtered = models.filter((m) => {
|
|
1766
|
+
const id = (typeof m === "string" ? m : m.id).toLowerCase();
|
|
1767
|
+
if (ks.length > 0 && !ks.some((k) => id.includes(k))) return false;
|
|
1768
|
+
if (ds.length > 0 && ds.some((k) => id.includes(k))) return false;
|
|
1769
|
+
return true;
|
|
1770
|
+
});
|
|
1771
|
+
if (filtered.length === 0) {
|
|
1772
|
+
ctx.ui.notify(`过滤后为空(原 ${originalCount} 个),未做修改`, "warning");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (filtered.length === originalCount) {
|
|
1776
|
+
ctx.ui.notify("过滤条件未命中任何模型,配置未变", "info");
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
target.models = filtered;
|
|
1780
|
+
} else if (ctx.hasUI) {
|
|
1781
|
+
const filtered = await filterModelsInteractive(ctx, target.models);
|
|
1782
|
+
if (!filtered || filtered.length === originalCount) return; // null = 保留全部
|
|
1783
|
+
target.models = filtered;
|
|
1784
|
+
} else {
|
|
1785
|
+
ctx.ui.notify(
|
|
1786
|
+
"非交互环境请用: prune <名称> --keep \"kw1,kw2\" 或 --drop \"kw1,kw2\"",
|
|
1787
|
+
"warning"
|
|
1788
|
+
);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const removed = originalCount - target.models.length;
|
|
1793
|
+
const ok = persistProvider(config, target, ctx);
|
|
1794
|
+
if (ok) {
|
|
1795
|
+
ctx.ui.notify(
|
|
1796
|
+
`Provider "${name}" 模型已修剪: ${originalCount} → ${target.models.length}(移除 ${removed} 个)`,
|
|
1797
|
+
"info"
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1801
|
+
|
|
1802
|
+
// config:查看摘要 / 单个详情 / edit 编辑 / path 路径 / 重置(不实现 reset)
|
|
1803
|
+
const doConfig = async (argText: string, ctx: any): Promise<void> => {
|
|
1804
|
+
const parsed = parseFlagArgs(argText);
|
|
1805
|
+
const rawTarget = parsed.positional[0] || getFlag(parsed.flags, "name") || "";
|
|
1806
|
+
const target = rawTarget.toLowerCase();
|
|
1807
|
+
const config = loadConfig();
|
|
1808
|
+
|
|
1809
|
+
if (target === "path") {
|
|
1810
|
+
ctx.ui.notify(`配置文件: ${CONFIG_PATH}`, "info");
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
if (target === "edit") {
|
|
1815
|
+
if (!ctx.hasUI) {
|
|
1816
|
+
ctx.ui.notify("config edit 需要交互环境(TUI/RPC),可直接编辑 JSON 文件后 /reload", "warning");
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
const text = await ctx.ui.editor(
|
|
1820
|
+
`编辑配置文件(JSON,保存即校验并重新注册)`,
|
|
1821
|
+
JSON.stringify(config, null, 2)
|
|
1822
|
+
);
|
|
1823
|
+
if (!text || !text.trim()) {
|
|
1824
|
+
ctx.ui.notify("已取消编辑", "info");
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
try {
|
|
1828
|
+
const next = JSON.parse(text);
|
|
1829
|
+
if (!Array.isArray(next.providers)) throw new Error("缺少 providers 数组");
|
|
1830
|
+
for (const p of next.providers) {
|
|
1831
|
+
if (!p || !p.name || !p.baseUrl) throw new Error("存在缺少 name/baseUrl 的 provider");
|
|
1832
|
+
if (!Array.isArray(p.models)) throw new Error(`provider "${p.name}" 的 models 需为数组`);
|
|
1833
|
+
const nameErr = validateProviderName(String(p.name));
|
|
1834
|
+
if (nameErr) throw new Error(`provider 名称无效: ${nameErr}`);
|
|
1835
|
+
}
|
|
1836
|
+
saveConfig(next);
|
|
1837
|
+
registerProviders(); // 全量重注册(自动跳过 enabled=false)
|
|
1838
|
+
ctx.ui.notify(`配置已保存并重新注册(${next.providers.length} 个 provider)`, "info");
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
ctx.ui.notify(
|
|
1841
|
+
`配置无效,未保存: ${error instanceof Error ? error.message : String(error)}`,
|
|
1842
|
+
"error"
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
if (!target) {
|
|
1849
|
+
if (config.providers.length === 0) {
|
|
1850
|
+
ctx.ui.notify(`配置文件: ${CONFIG_PATH}(当前无 provider)`, "info");
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const lines = config.providers.map((p) => {
|
|
1854
|
+
const api = p.api ?? inferApi(p.baseUrl);
|
|
1855
|
+
const state = p.enabled === false ? "✗ 禁用" : "✓ 启用";
|
|
1856
|
+
const proxyLine = p.proxy ? `\n 代理: ${p.proxy}` : "";
|
|
1857
|
+
return `• ${p.name} [${state}] [${api}] ${p.models.length} 个模型\n 端点: ${p.baseUrl}${proxyLine}`;
|
|
1858
|
+
});
|
|
1859
|
+
ctx.ui.notify(
|
|
1860
|
+
`配置文件: ${CONFIG_PATH}\n已配置 ${config.providers.length} 个 provider:\n\n${lines.join("\n\n")}\n\nconfig <name> 查看详情 · config edit 编辑 · config path 路径`,
|
|
1861
|
+
"info"
|
|
1862
|
+
);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const pIdx = findProviderIndex(config, rawTarget);
|
|
1867
|
+
if (pIdx < 0) {
|
|
1868
|
+
ctx.ui.notify(`未找到 provider "${rawTarget}"(可用: edit / path / provider 名称)`, "error");
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
const p = config.providers[pIdx];
|
|
1872
|
+
ctx.ui.notify(`Provider "${p.name}" 配置:\n${JSON.stringify(p, null, 2)}`, "info");
|
|
1873
|
+
};
|
|
1874
|
+
|
|
1875
|
+
// enable / disable:切换启用状态(禁用者不注册,/model 中不再出现)
|
|
1876
|
+
const setEnabled = async (argText: string, ctx: any, enabled: boolean): Promise<void> => {
|
|
1877
|
+
const verb = enabled ? "enable" : "disable";
|
|
1878
|
+
const config = loadConfig();
|
|
1879
|
+
const parsed = parseFlagArgs(argText);
|
|
1880
|
+
let name = parsed.positional[0] || getFlag(parsed.flags, "name");
|
|
1881
|
+
|
|
1882
|
+
if (!name && ctx.hasUI) {
|
|
1883
|
+
const options = config.providers
|
|
1884
|
+
.filter((p) => (enabled ? p.enabled === false : p.enabled !== false))
|
|
1885
|
+
.map((p) => p.name);
|
|
1886
|
+
if (options.length === 0) {
|
|
1887
|
+
ctx.ui.notify(
|
|
1888
|
+
config.providers.length === 0 ? "暂无已配置的 provider" : `没有需要 ${verb} 的 provider`,
|
|
1889
|
+
"warning"
|
|
1890
|
+
);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
const selected = await ctx.ui.select(`选择要 ${verb} 的 Provider`, options);
|
|
1894
|
+
if (!selected) return;
|
|
1895
|
+
name = selected;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
if (!name) {
|
|
1899
|
+
ctx.ui.notify(`请指定名称: /custom-provider ${verb} <名称>`, "warning");
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
const idx = findProviderIndex(config, name);
|
|
1904
|
+
if (idx < 0) {
|
|
1905
|
+
ctx.ui.notify(`未找到 provider "${name}"`, "error");
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
const p = config.providers[idx];
|
|
1909
|
+
const already = enabled ? p.enabled !== false : p.enabled === false;
|
|
1910
|
+
if (already) {
|
|
1911
|
+
ctx.ui.notify(`Provider "${name}" 已经是${enabled ? "启用" : "禁用"}状态`, "info");
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
if (enabled) {
|
|
1916
|
+
delete p.enabled; // 启用后配置里不残留 false
|
|
1917
|
+
} else {
|
|
1918
|
+
p.enabled = false;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
try {
|
|
1922
|
+
saveConfig(config);
|
|
1923
|
+
if (enabled) {
|
|
1924
|
+
pi.registerProvider(p.name, buildProviderConfig(p));
|
|
1925
|
+
ctx.ui.notify(`Provider "${name}" 已启用并注册`, "info");
|
|
1926
|
+
} else {
|
|
1927
|
+
pi.unregisterProvider(p.name);
|
|
1928
|
+
ctx.ui.notify(`Provider "${name}" 已禁用并注销(配置保留,可随时 enable 恢复)`, "info");
|
|
1929
|
+
}
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
ctx.ui.notify(`操作失败: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1932
|
+
}
|
|
1933
|
+
};
|
|
1934
|
+
|
|
1935
|
+
// 子命令参数补全
|
|
1936
|
+
const SUBCOMMANDS = ["add", "remove", "refresh", "list", "test", "config", "enable", "disable", "prune", "help"];
|
|
1937
|
+
const providerNameCompletions = () =>
|
|
1938
|
+
loadConfig().providers.map((p) => ({ value: p.name, label: p.name }));
|
|
1939
|
+
|
|
1940
|
+
const HELP_TEXT = [
|
|
1941
|
+
"custom-provider —— 管理第三方 Provider",
|
|
1942
|
+
"",
|
|
1943
|
+
"用法:",
|
|
1944
|
+
" /custom-provider add [名称] [flags] 交互引导或参数添加",
|
|
1945
|
+
" /custom-provider remove <名称> 删除(--yes 跳过确认)",
|
|
1946
|
+
" /custom-provider refresh [名称] 重新拉取模型列表",
|
|
1947
|
+
" /custom-provider list 列出所有 provider(含启用状态)",
|
|
1948
|
+
" /custom-provider test <名称> 测试连接(或传 --base-url 测临时端点)",
|
|
1949
|
+
" /custom-provider config [edit|path|<名称>] 查看/编辑配置",
|
|
1950
|
+
" /custom-provider prune <名称> [--keep/--drop 关键词] 修剪模型列表(避免全量保留)",
|
|
1951
|
+
" /custom-provider enable|disable <名称> 启用/禁用 provider",
|
|
1952
|
+
" /custom-provider help 显示本帮助",
|
|
1953
|
+
"",
|
|
1954
|
+
"add 常用 flags:",
|
|
1955
|
+
" --name · --base-url/--url · --api-key/--key · --api TYPE",
|
|
1956
|
+
" --models \"m1,m2\" · --model m(可多次)",
|
|
1957
|
+
" --header \"K: V\"(可多次)· --headers '{\"k\":\"v\"}'",
|
|
1958
|
+
" --auth-header · --compat '{...}' · --overrides '{\"modelId\":{...}}'",
|
|
1959
|
+
" --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
|
+
"示例:",
|
|
1965
|
+
" /custom-provider add deepseek --base-url https://api.deepseek.com/v1 \\",
|
|
1966
|
+
" --api-key $DEEPSEEK_API_KEY --models deepseek-chat,deepseek-reasoner",
|
|
1967
|
+
" /custom-provider add --json '{\"name\":\"x\",\"baseUrl\":\"...\",\"models\":[\"a\"]}'",
|
|
1968
|
+
" /custom-provider test --base-url http://localhost:8080/v1 --api-key local",
|
|
1969
|
+
].join("\n");
|
|
1970
|
+
|
|
1971
|
+
pi.registerCommand("custom-provider", {
|
|
1972
|
+
description: "管理第三方 Provider:add / remove / refresh / list / test / help",
|
|
1973
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1974
|
+
// 注意: pi 会用 item.value 替换整个参数段(命令名后的全部文本),
|
|
1975
|
+
// 所以 value 必须是「子命令 + 完整名称」的完整参数,label 才是用于显示的名称。
|
|
1976
|
+
const trimmed = prefix.trim();
|
|
1977
|
+
const match = trimmed.match(/^(\S+)(?:\s+(.*))?$/);
|
|
1978
|
+
const first = (match?.[1] ?? "").toLowerCase();
|
|
1979
|
+
// 子命令后需要 provider 名(remove/refresh/test/enable/disable)
|
|
1980
|
+
if (
|
|
1981
|
+
SUBCOMMANDS.includes(first) &&
|
|
1982
|
+
first !== "add" &&
|
|
1983
|
+
first !== "list" &&
|
|
1984
|
+
first !== "help" &&
|
|
1985
|
+
first !== "config"
|
|
1986
|
+
) {
|
|
1987
|
+
const rest = match?.[2] ?? "";
|
|
1988
|
+
const items = providerNameCompletions()
|
|
1989
|
+
.filter((i) => i.value.toLowerCase().startsWith(rest.toLowerCase()))
|
|
1990
|
+
.map((i) => ({ value: `${first} ${i.value}`, label: i.label }));
|
|
1991
|
+
return items.length > 0 ? items : null;
|
|
1992
|
+
}
|
|
1993
|
+
if (first === "config") {
|
|
1994
|
+
const rest = match?.[2] ?? "";
|
|
1995
|
+
const extras = ["edit", "path"].map((s) => ({ value: `config ${s}`, label: s }));
|
|
1996
|
+
const providerItems = providerNameCompletions().map((i) => ({
|
|
1997
|
+
value: `config ${i.value}`,
|
|
1998
|
+
label: i.label,
|
|
1999
|
+
}));
|
|
2000
|
+
const items = [...extras, ...providerItems].filter((i) =>
|
|
2001
|
+
i.value.toLowerCase().startsWith(trimmed.toLowerCase())
|
|
2002
|
+
);
|
|
2003
|
+
return items.length > 0 ? items : null;
|
|
2004
|
+
}
|
|
2005
|
+
if (first === "add") {
|
|
2006
|
+
// 补全 --ua 预设键(value 保留完整参数段:add --ua <键>)
|
|
2007
|
+
const uaMatch = trimmed.match(/^add\s+--ua\s*(.*)$/i);
|
|
2008
|
+
if (uaMatch) {
|
|
2009
|
+
const p = uaMatch[1].toLowerCase();
|
|
2010
|
+
const items = Object.keys(UA_PRESETS)
|
|
2011
|
+
.filter((k) => k.toLowerCase().startsWith(p))
|
|
2012
|
+
.map((k) => ({
|
|
2013
|
+
value: `add --ua ${k}`,
|
|
2014
|
+
label: k,
|
|
2015
|
+
description: UA_PRESETS[k].slice(0, 60),
|
|
2016
|
+
}));
|
|
2017
|
+
return items.length > 0 ? items : null;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
const items = SUBCOMMANDS.filter((s) => s.startsWith(first)).map((s) => ({ value: s, label: s }));
|
|
2021
|
+
return items.length > 0 ? items : null;
|
|
2022
|
+
},
|
|
2023
|
+
handler: async (args, ctx) => {
|
|
2024
|
+
const t = toArgText(args).trim();
|
|
2025
|
+
const parsed = parseFlagArgs(t);
|
|
2026
|
+
const sub = (parsed.positional[0] || "").toLowerCase();
|
|
2027
|
+
// 去掉子命令本身,剩余参数交给对应实现
|
|
2028
|
+
const rest = t.replace(/^\S+/, "").trim();
|
|
2029
|
+
|
|
2030
|
+
switch (sub) {
|
|
2031
|
+
case "add":
|
|
2032
|
+
await doAdd(rest, ctx);
|
|
2033
|
+
break;
|
|
2034
|
+
case "remove":
|
|
2035
|
+
await doRemove(rest, ctx);
|
|
2036
|
+
break;
|
|
2037
|
+
case "refresh":
|
|
2038
|
+
await doRefresh(rest, ctx);
|
|
2039
|
+
break;
|
|
2040
|
+
case "list":
|
|
2041
|
+
doList(ctx);
|
|
2042
|
+
break;
|
|
2043
|
+
case "test":
|
|
2044
|
+
await doTest(rest, ctx);
|
|
2045
|
+
break;
|
|
2046
|
+
case "config":
|
|
2047
|
+
await doConfig(rest, ctx);
|
|
2048
|
+
break;
|
|
2049
|
+
case "enable":
|
|
2050
|
+
await setEnabled(rest, ctx, true);
|
|
2051
|
+
break;
|
|
2052
|
+
case "disable":
|
|
2053
|
+
await setEnabled(rest, ctx, false);
|
|
2054
|
+
break;
|
|
2055
|
+
case "prune":
|
|
2056
|
+
await doPrune(rest, ctx);
|
|
2057
|
+
break;
|
|
2058
|
+
case "help":
|
|
2059
|
+
ctx.ui.notify(HELP_TEXT, "info");
|
|
2060
|
+
break;
|
|
2061
|
+
default:
|
|
2062
|
+
ctx.ui.notify(sub ? `未知子命令 "${sub}",可用: ${SUBCOMMANDS.join(" / ")}` : HELP_TEXT, sub ? "error" : "info");
|
|
2063
|
+
}
|
|
2064
|
+
},
|
|
2065
|
+
});
|
|
2066
|
+
}
|