@zxh126/pi-qqbot 2.4.1 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/billing.ts CHANGED
@@ -1,579 +1,579 @@
1
- /**
2
- * 欠费/鉴权失败的识别,以及「远程换 key / 换 endpoint」的凭据接手逻辑。
3
- *
4
- * 为什么需要:provider 返回非 2xx 时 pi 不产生任何 text_delta,只在 assistant 消息上留下
5
- * `stopReason=error` + `errorMessage`。QQ 用户看到的是「没反应」,而真正要做的动作
6
- * (充值、换 key、换个服务商)全都发生在远端,因此桥接必须:
7
- * 1. 把「哪家、哪个模型、服务端原话」用中文讲清楚;
8
- * 2. 允许用户直接发一个新 key,或发 endpoint + key,由桥接写进 pi 的 auth.json /
9
- * models.json,并**真的探测一次**,再把可选模型列表发给用户挑。
10
- *
11
- * 约定:
12
- * - 明文 key 只在内存里出现,日志/回执一律走 `maskSecret()`;
13
- * - 落盘只碰 pi 自己的 `auth.json` / `models.json`,用 pi 同款 proper-lockfile 锁;
14
- * - 纯逻辑(分类、解析、文案、探测、id 生成)都导出,单测可直接跑。
15
- */
16
- import fs from "node:fs";
17
- import os from "node:os";
18
- import path from "node:path";
19
- import lockfile from "proper-lockfile";
20
-
21
- // ---------------------------------------------------------------------------
22
- // 1. 错误分类与文案
23
- // ---------------------------------------------------------------------------
24
-
25
- export type ProviderErrorKind = "billing" | "auth" | "permission" | "rate" | "other";
26
-
27
- const BILLING_PATTERNS: readonly RegExp[] = [
28
- /insufficient[_\s-]?(?:balance|quota|credit|funds)/i,
29
- /exceeded your current quota/i,
30
- /payment[_\s-]?required/i,
31
- /\bbilling\b/i,
32
- /余额|额度|欠费|充值|欠款/,
33
- ];
34
-
35
- const AUTH_PATTERNS: readonly RegExp[] = [
36
- /invalid[_\s-]?api[_\s-]?key/i,
37
- /authentication[_\s-]?fail/i,
38
- /\bunauthorized\b/i,
39
- /\bapi key\b[^\n]{0,60}\binvalid\b/i,
40
- /\b401\b/,
41
- ];
42
-
43
- const RATE_PATTERNS: readonly RegExp[] = [/rate[_\s-]?limit/i, /\b429\b/, /too many requests/i];
44
-
45
- /** 套餐没开 / 没有该模型权限(如 GLM 的 1311「当前订阅套餐暂未开放XXX权限」) */
46
- const PERMISSION_PATTERNS: readonly RegExp[] = [
47
- /\b1311\b/,
48
- /权限/,
49
- /未开放|未开通/,
50
- /insufficient[_\s-]?permission/i,
51
- /permission[_\s-]?denied/i,
52
- /not[^\n]{0,30}(?:available|enabled)[^\n]{0,30}(?:plan|subscription|account|product)/i,
53
- ];
54
-
55
- /** 把 `errorMessage` 归类;billing / permission 优先(402 里常同时带 invalid_request_error) */
56
- export function classifyProviderError(text: string): ProviderErrorKind {
57
- const value = String(text ?? "");
58
- if (BILLING_PATTERNS.some((re) => re.test(value))) return "billing";
59
- if (PERMISSION_PATTERNS.some((re) => re.test(value))) return "permission";
60
- if (AUTH_PATTERNS.some((re) => re.test(value))) return "auth";
61
- if (RATE_PATTERNS.some((re) => re.test(value))) return "rate";
62
- return "other";
63
- }
64
-
65
- export function kindLabel(kind: ProviderErrorKind): string {
66
- switch (kind) {
67
- case "billing":
68
- return "余额/额度不足";
69
- case "auth":
70
- return "凭据无效或已失效";
71
- case "permission":
72
- return "套餐没开这个模型";
73
- case "rate":
74
- return "被限流";
75
- default:
76
- return "接口报错";
77
- }
78
- }
79
-
80
- export interface ErrorContext {
81
- kind: ProviderErrorKind;
82
- /** `provider/model`,未知时给 "(未知模型)" */
83
- model?: string | null;
84
- /** 已脱敏 + 压过空白 + 截断的服务端原文 */
85
- detail: string;
86
- }
87
-
88
- /** 与服务端不通用的简短回执(非欠费类也用它) */
89
- export function errorNotice(ctx: ErrorContext): string {
90
- const model = ctx.model?.trim() || "(未知模型)";
91
- return [`❌ 模型接口报错(${kindLabel(ctx.kind)})`, `当前模型:${model}`, `服务端原文:${ctx.detail}`].join("\n");
92
- }
93
-
94
- /** 欠费/鉴权类的回执:带「怎么恢复」的三条路,用户直接回消息即可 */
95
- export function billingNotice(ctx: ErrorContext): string {
96
- const model = ctx.model?.trim() || "(未知模型)";
97
- const head =
98
- ctx.kind === "auth"
99
- ? "❌ 当前模型的凭据不可用(key 失效、被禁用或填错)"
100
- : ctx.kind === "permission"
101
- ? "❌ 当前模型对你的账号不可用(套餐没开或没有该模型权限)"
102
- : "❌ 当前模型的余额/额度不足,暂时无法继续对话";
103
- return [
104
- head,
105
- `当前模型:${model}`,
106
- `服务端原文:${ctx.detail}`,
107
- "",
108
- "恢复方式(直接回消息即可,key 我不会写进日志)—",
109
- "1. 有新 key:只发 key(任意前缀都行,比如 sk-… / ark-… / AIza…,带引号也认)→ 我更新当前模型所属服务的 key",
110
- "2. 换服务商:发「https://api.xxx.com/v1 <你的 key>」→ 我拉它的模型列表,再把编号发给你挑",
111
- "3. 先换现成模型:发 /model 看列表,或 /model provider/id 直接切",
112
- ].join("\n");
113
- }
114
-
115
- /** 编号列表最多列几个(与 slash.ts 的 PICKER_MAX_ITEMS 对齐) */
116
- export const MODEL_PICK_LIMIT = 10;
117
-
118
- /** 新 endpoint 注册后的模型选择回执(配合 `/qq pick <n>`) */
119
- export function formatModelPicker(
120
- providerId: string,
121
- baseUrl: string,
122
- models: readonly ResolvedModelConfig[],
123
- borrowed = 0,
124
- ): string {
125
- const shown = models.slice(0, MODEL_PICK_LIMIT);
126
- const lines = [
127
- `✅ 已接入新服务:${providerId}(${normalizeBaseUrl(baseUrl)})`,
128
- `可选模型(${models.length} 个${models.length > shown.length ? `,先列前 ${shown.length} 个` : ""}):`,
129
- ...shown.map((model, index) => {
130
- const meta = { contextWindow: model.contextWindow, maxTokens: model.maxTokens };
131
- return ` ${index + 1}. ${modelLabel(model.id, meta)}`;
132
- }),
133
- ];
134
- if (borrowed > 0) {
135
- lines.push(`(其中 ${borrowed} 个模型的上下文/输出上限是从 pi 内置目录里同 id 模型补全的)`);
136
- } else {
137
- lines.push("(没找到同 id 的内置模型,上下文/输出上限用了保守默认值,需要精确值可以改 models.json)");
138
- }
139
- lines.push(`回复编号切换到该模型;回复「取消」放弃(10 分钟内有效)。`);
140
- lines.push(`需要的话也可以直接发 /model 看全部已配置模型。`);
141
- return lines.join("\n");
142
- }
143
-
144
- /** registerProvider / models.json 用的模型配置(未知服务商只能先给保守默认值) */
145
- export interface ResolvedModelConfig {
146
- id: string;
147
- name: string;
148
- reasoning: boolean;
149
- input: ("text" | "image")[];
150
- cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
151
- contextWindow: number;
152
- maxTokens: number;
153
- thinkingLevelMap?: Record<string, unknown>;
154
- compat?: Record<string, unknown>;
155
- inputLimits?: Record<string, unknown>;
156
- }
157
-
158
- export function providerModelConfig(id: string, meta?: ModelMeta | null): ResolvedModelConfig {
159
- const config: ResolvedModelConfig = {
160
- id,
161
- name: id,
162
- reasoning: meta?.reasoning ?? false,
163
- input: meta?.input ?? ["text"],
164
- cost: meta?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
165
- contextWindow: meta?.contextWindow ?? 128_000,
166
- maxTokens: meta?.maxTokens ?? 16_384,
167
- };
168
- if (meta?.thinkingLevelMap) config.thinkingLevelMap = meta.thinkingLevelMap;
169
- if (meta?.compat) config.compat = meta.compat;
170
- if (meta?.inputLimits) config.inputLimits = meta.inputLimits;
171
- return config;
172
- }
173
-
174
- /** 从现有模型目录里能借来的元数据 */
175
- export interface ModelMeta {
176
- contextWindow?: number;
177
- maxTokens?: number;
178
- reasoning?: boolean;
179
- input?: ("text" | "image")[];
180
- thinkingLevelMap?: Record<string, unknown>;
181
- cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
182
- compat?: Record<string, unknown>;
183
- inputLimits?: Record<string, unknown>;
184
- }
185
-
186
- const hostOf = (url: unknown): string => {
187
- try {
188
- return new URL(String(url)).host.toLowerCase();
189
- } catch {
190
- return "";
191
- }
192
- };
193
-
194
- /**
195
- * 按模型 id 从 pi 已有目录(内置 catalog + 已配置 provider)里借元数据。
196
- *
197
- * 背景(来自另一个 pi 的实践):服务的 `/models` 往往只给 id,不给上下文窗/输出上限;
198
- * 而 pi 内置目录里已经有同一模型(如 glm-5.3 在 `zai` / `zai-coding-cn` 下)的
199
- * contextWindow / maxTokens / input / thinkingLevelMap / compat,直接拿就行。
200
- * 优先选 host 相同的那个(同一厂商的另一端点),其次 `openai-completions`。
201
- */
202
- export function borrowModelMeta(
203
- id: string,
204
- catalog: readonly any[],
205
- baseUrl?: string,
206
- ): ModelMeta | null {
207
- const targetHost = hostOf(normalizeBaseUrl(baseUrl ?? ""));
208
- const candidates = catalog.filter((model) => model && String(model.id) === id);
209
- if (candidates.length === 0) return null;
210
- // 同一 host 打过包的另一端点最可信;其次 OpenAI 兼容;最后随便挑一个
211
- const family = targetHost ? targetHost.split(".").slice(-2).join(".") : "";
212
- const ranked = [...candidates].sort((a, b) => {
213
- const score = (model: any) => {
214
- const host = hostOf(model.baseUrl);
215
- let value = 0;
216
- if (host && targetHost && host === targetHost) value += 4;
217
- else if (family && host.endsWith(family)) value += 3;
218
- if (model.api === "openai-completions") value += 1;
219
- return value;
220
- };
221
- return score(b) - score(a);
222
- });
223
- const best = ranked[0];
224
- const meta: ModelMeta = {};
225
- if (typeof best.contextWindow === "number") meta.contextWindow = best.contextWindow;
226
- if (typeof best.maxTokens === "number") meta.maxTokens = best.maxTokens;
227
- if (typeof best.reasoning === "boolean") meta.reasoning = best.reasoning;
228
- if (Array.isArray(best.input)) meta.input = best.input;
229
- if (best.thinkingLevelMap) meta.thinkingLevelMap = best.thinkingLevelMap;
230
- if (best.cost) meta.cost = best.cost;
231
- if (best.compat) meta.compat = best.compat;
232
- if (best.inputLimits) meta.inputLimits = best.inputLimits;
233
- return Object.keys(meta).length > 0 ? meta : null;
234
- }
235
-
236
- /** 过滤掉 `/models` 里跟对话无关的 id(embedding / rerank / 语音 / 生图等) */
237
- const NON_CHAT_ID =
238
- /(embed|rerank|tts|whisper|moderation|cogview|dall-?e|stable-?diffusion|text-to-image|image-gen|asr|ocr-only)/i;
239
-
240
- export function filterChatModelIds(ids: readonly string[]): string[] {
241
- return ids.filter((id) => typeof id === "string" && id.length > 0 && !NON_CHAT_ID.test(id));
242
- }
243
-
244
- /** 模型名字后面附上上下文大小,方便用户在 QQ 里一眼看出差别 */
245
- export function modelLabel(id: string, meta?: ModelMeta | null): string {
246
- const ctx = meta?.contextWindow;
247
- if (typeof ctx !== "number" || ctx <= 0) return id;
248
- const human = ctx >= 1_000_000 ? `${Math.round(ctx / 100_000) / 10}M` : `${Math.round(ctx / 1000)}K`;
249
- return `${id}(${human} 上下文)`;
250
- }
251
-
252
- // ---------------------------------------------------------------------------
253
- // 2. 用户消息解析(新 key / endpoint + key)
254
- // ---------------------------------------------------------------------------
255
-
256
- export type CredentialIntake = (
257
- | { kind: "key"; key: string }
258
- | { kind: "endpoint"; baseUrl: string; key: string; models: string[] }
259
- ) & {
260
- /** 用户写了显式前缀(`key …` / `endpoint …`);否则是裸 key,只在欠费上下文里认 */
261
- explicit: boolean;
262
- };
263
-
264
- const PREFIX_ENDPOINT = /^(?:\/)?(?:endpoint|baseurl|base[_-]?url|url|接口|端点)\s*[::]?\s+/i;
265
- const PREFIX_KEY = /^(?:\/)?(?:key|apikey|api[_-]?key|密钥|新key)\s*[::]?\s+/i;
266
- const PREFIX_MODELS = /^(?:models?|模型|模型id)\s*[::]?\s*/i;
267
-
268
- /**
269
- * 去掉用户粘上来时可能带的包裹符号:`"…"` `'…'` `` `…` `` `“…”` `<…>` `【…】`。
270
- * 手机输入法很常见,不该因此认不出 key。
271
- */
272
- export function stripWrapping(value: string): string {
273
- let text = String(value ?? "").trim();
274
- const pairs: [string, string][] = [
275
- ['"', '"'],
276
- ["'", "'"],
277
- ["`", "`"],
278
- ["“", "”"],
279
- ["‘", "’"],
280
- ["<", ">"],
281
- ["【", "】"],
282
- ];
283
- let changed = true;
284
- while (changed && text.length > 1) {
285
- changed = false;
286
- for (const [open, close] of pairs) {
287
- if (text.length > open.length + close.length && text.startsWith(open) && text.endsWith(close)) {
288
- text = text.slice(open.length, text.length - close.length).trim();
289
- changed = true;
290
- }
291
- }
292
- }
293
- return text;
294
- }
295
-
296
- /**
297
- * 像不像一个 API key。
298
- *
299
- * **不假设任何前缀**:key 可能是 `sk-…`、`ark-…`、`AIza…`、纯十六进制、带 `+/=` 的 base64、
300
- * 甚至是 `ollama` 这种本地服务的占位字符串。判据只有三条:非空、可打印 ASCII、无空白。
301
- *
302
- * `bare` 模式用于「用户没写任何前缀,整条消息就是一串字符」的情形(欠费时直接贴 key):
303
- * 这时额外收紧一点(长度 ≥ 12 且不能是纯小写单词),避免把 `configuration.ts`
304
- * 这类正常正文当成 key 吞掉;带前缀(`key …` / `endpoint … …`)时不做这些限制。
305
- */
306
- export function looksLikeSecret(value: string, options: { bare?: boolean } = {}): boolean {
307
- const text = stripWrapping(value);
308
- if (text.length < 4 || text.length > 400) return false;
309
- if (/\s/.test(text)) return false;
310
- // 可打印 ASCII(不含引号以外的奇怪控制字符);非 ASCII 的 key 不存在,而中文正文必须排除
311
- if (!/^[\x21-\x7e]+$/.test(text)) return false;
312
- if (looksLikeEndpoint(text)) return false;
313
- if (!options.bare) return true;
314
- if (text.length < 12) return false;
315
- if (/\//.test(text)) return false; // 裸串里的路径别当 key(`docs/setup` 这类)
316
- // 需要一点熵:有数字、有大写或分隔符;纯小写单词(config、readme)不算
317
- return /[0-9]/.test(text) || /[A-Z]/.test(text) || /[-_+=:~]/.test(text);
318
- }
319
-
320
- export function looksLikeEndpoint(value: string): boolean {
321
- return /^https?:\/\/[^\s/]+/i.test(String(value ?? "").trim());
322
- }
323
-
324
- /** `sk-abcd1234…` → 只留头尾,给回执与日志用 */
325
- export function maskSecret(value: string): string {
326
- const text = String(value ?? "").trim();
327
- if (text.length <= 10) return text ? `${text.slice(0, 2)}***` : "";
328
- return `${text.slice(0, 5)}…${text.slice(-4)}`;
329
- }
330
-
331
- function parseModelIds(tokens: string[]): string[] {
332
- if (tokens.length === 0) return [];
333
- const joined = tokens.join(" ").replace(PREFIX_MODELS, "");
334
- return joined
335
- .split(/[,,、\s]+/)
336
- .map((item) => item.trim())
337
- .filter(Boolean);
338
- }
339
-
340
- /**
341
- * 认三类写法(其余一律当普通对话,避免误吞用户正文):
342
- * `<任意一串字符>` → 更新当前 provider 的 key(不要求任何前缀)
343
- * `key <任意一串字符>` / `key: …` → 同上(显式前缀,欠费之外也认)
344
- * `https://host/v1 <你的 key> [models a,b]` → 注册新 provider 并拉模型列表
345
- * `endpoint https://host/v1 <你的 key> ...` → 同上(显式前缀)
346
- */
347
- export function parseCredentialMessage(text: string): CredentialIntake | null {
348
- const raw = String(text ?? "").trim();
349
- if (!raw || raw.length > 2000) return null;
350
-
351
- const endpointForced = PREFIX_ENDPOINT.test(raw);
352
- const keyForced = !endpointForced && PREFIX_KEY.test(raw);
353
- const body = endpointForced ? raw.replace(PREFIX_ENDPOINT, "") : keyForced ? raw.replace(PREFIX_KEY, "") : raw;
354
- const tokens = body.split(/\s+/).filter(Boolean);
355
- if (tokens.length === 0) return null;
356
-
357
- if (looksLikeEndpoint(tokens[0])) {
358
- const baseUrl = tokens[0];
359
- const key = stripWrapping(tokens[1] ?? "");
360
- // 跟在 URL 后面的 key 已经算显式:不按裸串收紧(允许 `ollama` 这种占位 key)
361
- if (!looksLikeSecret(key)) return null;
362
- return { kind: "endpoint", baseUrl, key, models: parseModelIds(tokens.slice(2)), explicit: endpointForced };
363
- }
364
-
365
- const candidate = tokens.length === 1 ? stripWrapping(tokens[0]) : keyForced ? stripWrapping(body) : "";
366
- if (candidate && looksLikeSecret(candidate, { bare: !keyForced })) return { kind: "key", key: candidate, explicit: keyForced };
367
- return null;
368
- }
369
-
370
- // ---------------------------------------------------------------------------
371
- // 3. endpoint 探测(顺手验证 key)
372
- // ---------------------------------------------------------------------------
373
-
374
- export interface ProbeResult {
375
- ok: boolean;
376
- /** 从 /models 里取到的模型 id(去重、排序) */
377
- ids: string[];
378
- /** 真正命中的地址 */
379
- url?: string;
380
- status?: number;
381
- error?: string;
382
- }
383
-
384
- /** 去掉尾斜杠与 `/chat/completions` 结尾,得到 provider 的 baseUrl */
385
- export function normalizeBaseUrl(value: string): string {
386
- return String(value ?? "")
387
- .trim()
388
- .replace(/\/+$/, "")
389
- .replace(/\/chat\/completions$/i, "")
390
- .replace(/\/completions$/i, "");
391
- }
392
-
393
- export function extractModelIds(body: unknown): string[] {
394
- const ids: string[] = [];
395
- const push = (value: unknown) => {
396
- if (typeof value === "string" && value) ids.push(value);
397
- else if (value && typeof value === "object" && typeof (value as any).id === "string") ids.push((value as any).id);
398
- };
399
- const root = body as any;
400
- const data = root?.data ?? root?.models ?? root;
401
- if (Array.isArray(data)) data.forEach(push);
402
- else if (data && typeof data === "object") Object.keys(data).forEach(push);
403
- return [...new Set(ids)].sort();
404
- }
405
-
406
- /**
407
- * `GET <base>/models`(base 不带版本号时再试一次 `/v1`)。
408
- * 401/403 视为「key 无效」立刻返回;其他失败继续试下一个候选地址。
409
- */
410
- export async function probeEndpoint(
411
- baseUrl: string,
412
- key: string,
413
- fetchFn: typeof fetch = fetch,
414
- timeoutMs = 15000,
415
- ): Promise<ProbeResult> {
416
- const base = normalizeBaseUrl(baseUrl);
417
- const bases = /\/v\d+$/i.test(base) ? [base] : [base, `${base}/v1`];
418
- let last: ProbeResult = { ok: false, ids: [], error: "没有可用地址" };
419
- for (const candidate of bases) {
420
- const url = `${candidate}/models`;
421
- try {
422
- const response = await fetchFn(url, {
423
- method: "GET",
424
- headers: { authorization: `Bearer ${key}`, accept: "application/json" },
425
- signal: AbortSignal.timeout(timeoutMs),
426
- });
427
- if (response.status === 401 || response.status === 403) {
428
- return { ok: false, ids: [], url, status: response.status, error: `key 无效或无权访问(HTTP ${response.status})` };
429
- }
430
- if (!response.ok) {
431
- last = { ok: false, ids: [], url, status: response.status, error: `HTTP ${response.status}` };
432
- continue;
433
- }
434
- const body = await response.json().catch(() => null);
435
- return { ok: true, ids: extractModelIds(body), url, status: response.status };
436
- } catch (error) {
437
- last = { ok: false, ids: [], url, error: String((error as any)?.message ?? error).slice(0, 160) };
438
- }
439
- }
440
- return last;
441
- }
442
-
443
- /** 由 endpoint 生成稳定的 provider id(`qq-<host>`,撞名时加序号) */
444
- export function providerIdForEndpoint(baseUrl: string, taken: readonly string[] = []): string {
445
- let host = "endpoint";
446
- try {
447
- host = new URL(normalizeBaseUrl(baseUrl)).host || "endpoint";
448
- } catch {
449
- // 非法 URL:交给调用方提前拒绝,这里只保证 id 合法
450
- }
451
- const slug =
452
- host
453
- .toLowerCase()
454
- .replace(/[^a-z0-9]+/g, "-")
455
- .replace(/^-+|-+$/g, "")
456
- .slice(0, 24) || "endpoint";
457
- const base = `qq-${slug}`;
458
- if (!taken.includes(base)) return base;
459
- for (let index = 2; index < 100; index += 1) {
460
- const candidate = `${base}-${index}`;
461
- if (!taken.includes(candidate)) return candidate;
462
- }
463
- return `${base}-${Date.now()}`;
464
- }
465
-
466
- // ---------------------------------------------------------------------------
467
- // 4. 落盘:auth.json(换 key)/ models.json(新 endpoint)
468
- // ---------------------------------------------------------------------------
469
-
470
- export function agentDir(): string {
471
- const envDir = process.env.PI_CODING_AGENT_DIR?.trim();
472
- if (envDir) return envDir.replace(/^~(?=$|[\\/])/, os.homedir());
473
- return path.join(os.homedir(), ".pi", "agent");
474
- }
475
-
476
- export const authFilePath = () => path.join(agentDir(), "auth.json");
477
- export const modelsFilePath = () => path.join(agentDir(), "models.json");
478
-
479
- function readJson(file: string): any {
480
- try {
481
- const text = fs.readFileSync(file, "utf8");
482
- if (!text.trim()) return {};
483
- return JSON.parse(text.replace(/^\uFEFF/, ""));
484
- } catch {
485
- return {};
486
- }
487
- }
488
-
489
- /** 原子写:同目录临时文件 + rename,避免 pi 读到半个文件 */
490
- function writeJsonAtomic(file: string, data: unknown): void {
491
- fs.mkdirSync(path.dirname(file), { recursive: true });
492
- const tmp = `${file}.${process.pid}.tmp`;
493
- fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
494
- fs.renameSync(tmp, file);
495
- }
496
-
497
- /** 与 pi 同款锁(realpath:false),拿不到锁就退化成「原子写」,不让换 key 卡死 */
498
- async function withLock<T>(file: string, fn: () => Promise<T> | T): Promise<T> {
499
- if (!fs.existsSync(file)) {
500
- fs.mkdirSync(path.dirname(file), { recursive: true });
501
- fs.writeFileSync(file, "{}\n", { encoding: "utf8", mode: 0o600 });
502
- }
503
- let release: (() => Promise<void>) | null = null;
504
- try {
505
- release = await lockfile.lock(file, { realpath: false, retries: 3, stale: 15000 });
506
- } catch {
507
- release = null;
508
- }
509
- try {
510
- return await fn();
511
- } finally {
512
- await release?.().catch(() => {});
513
- }
514
- }
515
-
516
- export interface WriteResult {
517
- ok: boolean;
518
- path: string;
519
- error?: string;
520
- }
521
-
522
- /** 把 key 写进 auth.json 的 `{ "<provider>": { type: "api_key", key } }` */
523
- export async function updateProviderKey(provider: string, key: string): Promise<WriteResult> {
524
- const file = authFilePath();
525
- try {
526
- await withLock(file, () => {
527
- const data = readJson(file);
528
- const previous = data[provider];
529
- data[provider] = { ...(previous && typeof previous === "object" ? previous : {}), type: "api_key", key };
530
- writeJsonAtomic(file, data);
531
- });
532
- return { ok: true, path: file };
533
- } catch (error) {
534
- return { ok: false, path: file, error: String((error as any)?.message ?? error).slice(0, 200) };
535
- }
536
- }
537
-
538
- export interface EndpointProviderInput {
539
- providerId: string;
540
- baseUrl: string;
541
- apiKey: string;
542
- api?: string;
543
- /** 完整的模型配置项(含从内置目录借来的 contextWindow / compat 等) */
544
- models: ResolvedModelConfig[];
545
- name?: string;
546
- }
547
-
548
- /** 在 models.json 里写入/覆盖一个 provider(models 全量替换,其它 provider 保持不动) */
549
- export async function upsertEndpointProvider(input: EndpointProviderInput): Promise<WriteResult> {
550
- const file = modelsFilePath();
551
- try {
552
- await withLock(file, () => {
553
- const data = readJson(file);
554
- const providers = data.providers && typeof data.providers === "object" ? data.providers : {};
555
- providers[input.providerId] = {
556
- name: input.name ?? `QQ 接入 (${input.providerId})`,
557
- baseUrl: normalizeBaseUrl(input.baseUrl),
558
- api: input.api ?? "openai-completions",
559
- apiKey: input.apiKey,
560
- models: input.models.map((model) => ({ ...model })),
561
- };
562
- writeJsonAtomic(file, {
563
- $schema: "https://pi.dev/schemas/models.schema.json",
564
- ...data,
565
- providers,
566
- });
567
- });
568
- return { ok: true, path: file };
569
- } catch (error) {
570
- return { ok: false, path: file, error: String((error as any)?.message ?? error).slice(0, 200) };
571
- }
572
- }
573
-
574
- /** models.json 里已经存在的 provider id(用于生成不撞名的 id) */
575
- export function existingProviderIds(): string[] {
576
- const data = readJson(modelsFilePath());
577
- const providers = data?.providers;
578
- return providers && typeof providers === "object" ? Object.keys(providers) : [];
579
- }
1
+ /**
2
+ * 欠费/鉴权失败的识别,以及「远程换 key / 换 endpoint」的凭据接手逻辑。
3
+ *
4
+ * 为什么需要:provider 返回非 2xx 时 pi 不产生任何 text_delta,只在 assistant 消息上留下
5
+ * `stopReason=error` + `errorMessage`。QQ 用户看到的是「没反应」,而真正要做的动作
6
+ * (充值、换 key、换个服务商)全都发生在远端,因此桥接必须:
7
+ * 1. 把「哪家、哪个模型、服务端原话」用中文讲清楚;
8
+ * 2. 允许用户直接发一个新 key,或发 endpoint + key,由桥接写进 pi 的 auth.json /
9
+ * models.json,并**真的探测一次**,再把可选模型列表发给用户挑。
10
+ *
11
+ * 约定:
12
+ * - 明文 key 只在内存里出现,日志/回执一律走 `maskSecret()`;
13
+ * - 落盘只碰 pi 自己的 `auth.json` / `models.json`,用 pi 同款 proper-lockfile 锁;
14
+ * - 纯逻辑(分类、解析、文案、探测、id 生成)都导出,单测可直接跑。
15
+ */
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import lockfile from "proper-lockfile";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // 1. 错误分类与文案
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export type ProviderErrorKind = "billing" | "auth" | "permission" | "rate" | "other";
26
+
27
+ const BILLING_PATTERNS: readonly RegExp[] = [
28
+ /insufficient[_\s-]?(?:balance|quota|credit|funds)/i,
29
+ /exceeded your current quota/i,
30
+ /payment[_\s-]?required/i,
31
+ /\bbilling\b/i,
32
+ /余额|额度|欠费|充值|欠款/,
33
+ ];
34
+
35
+ const AUTH_PATTERNS: readonly RegExp[] = [
36
+ /invalid[_\s-]?api[_\s-]?key/i,
37
+ /authentication[_\s-]?fail/i,
38
+ /\bunauthorized\b/i,
39
+ /\bapi key\b[^\n]{0,60}\binvalid\b/i,
40
+ /\b401\b/,
41
+ ];
42
+
43
+ const RATE_PATTERNS: readonly RegExp[] = [/rate[_\s-]?limit/i, /\b429\b/, /too many requests/i];
44
+
45
+ /** 套餐没开 / 没有该模型权限(如 GLM 的 1311「当前订阅套餐暂未开放XXX权限」) */
46
+ const PERMISSION_PATTERNS: readonly RegExp[] = [
47
+ /\b1311\b/,
48
+ /权限/,
49
+ /未开放|未开通/,
50
+ /insufficient[_\s-]?permission/i,
51
+ /permission[_\s-]?denied/i,
52
+ /not[^\n]{0,30}(?:available|enabled)[^\n]{0,30}(?:plan|subscription|account|product)/i,
53
+ ];
54
+
55
+ /** 把 `errorMessage` 归类;billing / permission 优先(402 里常同时带 invalid_request_error) */
56
+ export function classifyProviderError(text: string): ProviderErrorKind {
57
+ const value = String(text ?? "");
58
+ if (BILLING_PATTERNS.some((re) => re.test(value))) return "billing";
59
+ if (PERMISSION_PATTERNS.some((re) => re.test(value))) return "permission";
60
+ if (AUTH_PATTERNS.some((re) => re.test(value))) return "auth";
61
+ if (RATE_PATTERNS.some((re) => re.test(value))) return "rate";
62
+ return "other";
63
+ }
64
+
65
+ export function kindLabel(kind: ProviderErrorKind): string {
66
+ switch (kind) {
67
+ case "billing":
68
+ return "余额/额度不足";
69
+ case "auth":
70
+ return "凭据无效或已失效";
71
+ case "permission":
72
+ return "套餐没开这个模型";
73
+ case "rate":
74
+ return "被限流";
75
+ default:
76
+ return "接口报错";
77
+ }
78
+ }
79
+
80
+ export interface ErrorContext {
81
+ kind: ProviderErrorKind;
82
+ /** `provider/model`,未知时给 "(未知模型)" */
83
+ model?: string | null;
84
+ /** 已脱敏 + 压过空白 + 截断的服务端原文 */
85
+ detail: string;
86
+ }
87
+
88
+ /** 与服务端不通用的简短回执(非欠费类也用它) */
89
+ export function errorNotice(ctx: ErrorContext): string {
90
+ const model = ctx.model?.trim() || "(未知模型)";
91
+ return [`❌ 模型接口报错(${kindLabel(ctx.kind)})`, `当前模型:${model}`, `服务端原文:${ctx.detail}`].join("\n");
92
+ }
93
+
94
+ /** 欠费/鉴权类的回执:带「怎么恢复」的三条路,用户直接回消息即可 */
95
+ export function billingNotice(ctx: ErrorContext): string {
96
+ const model = ctx.model?.trim() || "(未知模型)";
97
+ const head =
98
+ ctx.kind === "auth"
99
+ ? "❌ 当前模型的凭据不可用(key 失效、被禁用或填错)"
100
+ : ctx.kind === "permission"
101
+ ? "❌ 当前模型对你的账号不可用(套餐没开或没有该模型权限)"
102
+ : "❌ 当前模型的余额/额度不足,暂时无法继续对话";
103
+ return [
104
+ head,
105
+ `当前模型:${model}`,
106
+ `服务端原文:${ctx.detail}`,
107
+ "",
108
+ "恢复方式(直接回消息即可,key 我不会写进日志)—",
109
+ "1. 有新 key:只发 key(任意前缀都行,比如 sk-… / ark-… / AIza…,带引号也认)→ 我更新当前模型所属服务的 key",
110
+ "2. 换服务商:发「https://api.xxx.com/v1 <你的 key>」→ 我拉它的模型列表,再把编号发给你挑",
111
+ "3. 先换现成模型:发 /model 看列表,或 /model provider/id 直接切",
112
+ ].join("\n");
113
+ }
114
+
115
+ /** 编号列表最多列几个(与 slash.ts 的 PICKER_MAX_ITEMS 对齐) */
116
+ export const MODEL_PICK_LIMIT = 10;
117
+
118
+ /** 新 endpoint 注册后的模型选择回执(配合 `/qq pick <n>`) */
119
+ export function formatModelPicker(
120
+ providerId: string,
121
+ baseUrl: string,
122
+ models: readonly ResolvedModelConfig[],
123
+ borrowed = 0,
124
+ ): string {
125
+ const shown = models.slice(0, MODEL_PICK_LIMIT);
126
+ const lines = [
127
+ `✅ 已接入新服务:${providerId}(${normalizeBaseUrl(baseUrl)})`,
128
+ `可选模型(${models.length} 个${models.length > shown.length ? `,先列前 ${shown.length} 个` : ""}):`,
129
+ ...shown.map((model, index) => {
130
+ const meta = { contextWindow: model.contextWindow, maxTokens: model.maxTokens };
131
+ return ` ${index + 1}. ${modelLabel(model.id, meta)}`;
132
+ }),
133
+ ];
134
+ if (borrowed > 0) {
135
+ lines.push(`(其中 ${borrowed} 个模型的上下文/输出上限是从 pi 内置目录里同 id 模型补全的)`);
136
+ } else {
137
+ lines.push("(没找到同 id 的内置模型,上下文/输出上限用了保守默认值,需要精确值可以改 models.json)");
138
+ }
139
+ lines.push(`回复编号切换到该模型;回复「取消」放弃(10 分钟内有效)。`);
140
+ lines.push(`需要的话也可以直接发 /model 看全部已配置模型。`);
141
+ return lines.join("\n");
142
+ }
143
+
144
+ /** registerProvider / models.json 用的模型配置(未知服务商只能先给保守默认值) */
145
+ export interface ResolvedModelConfig {
146
+ id: string;
147
+ name: string;
148
+ reasoning: boolean;
149
+ input: ("text" | "image")[];
150
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
151
+ contextWindow: number;
152
+ maxTokens: number;
153
+ thinkingLevelMap?: Record<string, unknown>;
154
+ compat?: Record<string, unknown>;
155
+ inputLimits?: Record<string, unknown>;
156
+ }
157
+
158
+ export function providerModelConfig(id: string, meta?: ModelMeta | null): ResolvedModelConfig {
159
+ const config: ResolvedModelConfig = {
160
+ id,
161
+ name: id,
162
+ reasoning: meta?.reasoning ?? false,
163
+ input: meta?.input ?? ["text"],
164
+ cost: meta?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
165
+ contextWindow: meta?.contextWindow ?? 128_000,
166
+ maxTokens: meta?.maxTokens ?? 16_384,
167
+ };
168
+ if (meta?.thinkingLevelMap) config.thinkingLevelMap = meta.thinkingLevelMap;
169
+ if (meta?.compat) config.compat = meta.compat;
170
+ if (meta?.inputLimits) config.inputLimits = meta.inputLimits;
171
+ return config;
172
+ }
173
+
174
+ /** 从现有模型目录里能借来的元数据 */
175
+ export interface ModelMeta {
176
+ contextWindow?: number;
177
+ maxTokens?: number;
178
+ reasoning?: boolean;
179
+ input?: ("text" | "image")[];
180
+ thinkingLevelMap?: Record<string, unknown>;
181
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
182
+ compat?: Record<string, unknown>;
183
+ inputLimits?: Record<string, unknown>;
184
+ }
185
+
186
+ const hostOf = (url: unknown): string => {
187
+ try {
188
+ return new URL(String(url)).host.toLowerCase();
189
+ } catch {
190
+ return "";
191
+ }
192
+ };
193
+
194
+ /**
195
+ * 按模型 id 从 pi 已有目录(内置 catalog + 已配置 provider)里借元数据。
196
+ *
197
+ * 背景(来自另一个 pi 的实践):服务的 `/models` 往往只给 id,不给上下文窗/输出上限;
198
+ * 而 pi 内置目录里已经有同一模型(如 glm-5.3 在 `zai` / `zai-coding-cn` 下)的
199
+ * contextWindow / maxTokens / input / thinkingLevelMap / compat,直接拿就行。
200
+ * 优先选 host 相同的那个(同一厂商的另一端点),其次 `openai-completions`。
201
+ */
202
+ export function borrowModelMeta(
203
+ id: string,
204
+ catalog: readonly any[],
205
+ baseUrl?: string,
206
+ ): ModelMeta | null {
207
+ const targetHost = hostOf(normalizeBaseUrl(baseUrl ?? ""));
208
+ const candidates = catalog.filter((model) => model && String(model.id) === id);
209
+ if (candidates.length === 0) return null;
210
+ // 同一 host 打过包的另一端点最可信;其次 OpenAI 兼容;最后随便挑一个
211
+ const family = targetHost ? targetHost.split(".").slice(-2).join(".") : "";
212
+ const ranked = [...candidates].sort((a, b) => {
213
+ const score = (model: any) => {
214
+ const host = hostOf(model.baseUrl);
215
+ let value = 0;
216
+ if (host && targetHost && host === targetHost) value += 4;
217
+ else if (family && host.endsWith(family)) value += 3;
218
+ if (model.api === "openai-completions") value += 1;
219
+ return value;
220
+ };
221
+ return score(b) - score(a);
222
+ });
223
+ const best = ranked[0];
224
+ const meta: ModelMeta = {};
225
+ if (typeof best.contextWindow === "number") meta.contextWindow = best.contextWindow;
226
+ if (typeof best.maxTokens === "number") meta.maxTokens = best.maxTokens;
227
+ if (typeof best.reasoning === "boolean") meta.reasoning = best.reasoning;
228
+ if (Array.isArray(best.input)) meta.input = best.input;
229
+ if (best.thinkingLevelMap) meta.thinkingLevelMap = best.thinkingLevelMap;
230
+ if (best.cost) meta.cost = best.cost;
231
+ if (best.compat) meta.compat = best.compat;
232
+ if (best.inputLimits) meta.inputLimits = best.inputLimits;
233
+ return Object.keys(meta).length > 0 ? meta : null;
234
+ }
235
+
236
+ /** 过滤掉 `/models` 里跟对话无关的 id(embedding / rerank / 语音 / 生图等) */
237
+ const NON_CHAT_ID =
238
+ /(embed|rerank|tts|whisper|moderation|cogview|dall-?e|stable-?diffusion|text-to-image|image-gen|asr|ocr-only)/i;
239
+
240
+ export function filterChatModelIds(ids: readonly string[]): string[] {
241
+ return ids.filter((id) => typeof id === "string" && id.length > 0 && !NON_CHAT_ID.test(id));
242
+ }
243
+
244
+ /** 模型名字后面附上上下文大小,方便用户在 QQ 里一眼看出差别 */
245
+ export function modelLabel(id: string, meta?: ModelMeta | null): string {
246
+ const ctx = meta?.contextWindow;
247
+ if (typeof ctx !== "number" || ctx <= 0) return id;
248
+ const human = ctx >= 1_000_000 ? `${Math.round(ctx / 100_000) / 10}M` : `${Math.round(ctx / 1000)}K`;
249
+ return `${id}(${human} 上下文)`;
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // 2. 用户消息解析(新 key / endpoint + key)
254
+ // ---------------------------------------------------------------------------
255
+
256
+ export type CredentialIntake = (
257
+ | { kind: "key"; key: string }
258
+ | { kind: "endpoint"; baseUrl: string; key: string; models: string[] }
259
+ ) & {
260
+ /** 用户写了显式前缀(`key …` / `endpoint …`);否则是裸 key,只在欠费上下文里认 */
261
+ explicit: boolean;
262
+ };
263
+
264
+ const PREFIX_ENDPOINT = /^(?:\/)?(?:endpoint|baseurl|base[_-]?url|url|接口|端点)\s*[::]?\s+/i;
265
+ const PREFIX_KEY = /^(?:\/)?(?:key|apikey|api[_-]?key|密钥|新key)\s*[::]?\s+/i;
266
+ const PREFIX_MODELS = /^(?:models?|模型|模型id)\s*[::]?\s*/i;
267
+
268
+ /**
269
+ * 去掉用户粘上来时可能带的包裹符号:`"…"` `'…'` `` `…` `` `“…”` `<…>` `【…】`。
270
+ * 手机输入法很常见,不该因此认不出 key。
271
+ */
272
+ export function stripWrapping(value: string): string {
273
+ let text = String(value ?? "").trim();
274
+ const pairs: [string, string][] = [
275
+ ['"', '"'],
276
+ ["'", "'"],
277
+ ["`", "`"],
278
+ ["“", "”"],
279
+ ["‘", "’"],
280
+ ["<", ">"],
281
+ ["【", "】"],
282
+ ];
283
+ let changed = true;
284
+ while (changed && text.length > 1) {
285
+ changed = false;
286
+ for (const [open, close] of pairs) {
287
+ if (text.length > open.length + close.length && text.startsWith(open) && text.endsWith(close)) {
288
+ text = text.slice(open.length, text.length - close.length).trim();
289
+ changed = true;
290
+ }
291
+ }
292
+ }
293
+ return text;
294
+ }
295
+
296
+ /**
297
+ * 像不像一个 API key。
298
+ *
299
+ * **不假设任何前缀**:key 可能是 `sk-…`、`ark-…`、`AIza…`、纯十六进制、带 `+/=` 的 base64、
300
+ * 甚至是 `ollama` 这种本地服务的占位字符串。判据只有三条:非空、可打印 ASCII、无空白。
301
+ *
302
+ * `bare` 模式用于「用户没写任何前缀,整条消息就是一串字符」的情形(欠费时直接贴 key):
303
+ * 这时额外收紧一点(长度 ≥ 12 且不能是纯小写单词),避免把 `configuration.ts`
304
+ * 这类正常正文当成 key 吞掉;带前缀(`key …` / `endpoint … …`)时不做这些限制。
305
+ */
306
+ export function looksLikeSecret(value: string, options: { bare?: boolean } = {}): boolean {
307
+ const text = stripWrapping(value);
308
+ if (text.length < 4 || text.length > 400) return false;
309
+ if (/\s/.test(text)) return false;
310
+ // 可打印 ASCII(不含引号以外的奇怪控制字符);非 ASCII 的 key 不存在,而中文正文必须排除
311
+ if (!/^[\x21-\x7e]+$/.test(text)) return false;
312
+ if (looksLikeEndpoint(text)) return false;
313
+ if (!options.bare) return true;
314
+ if (text.length < 12) return false;
315
+ if (/\//.test(text)) return false; // 裸串里的路径别当 key(`docs/setup` 这类)
316
+ // 需要一点熵:有数字、有大写或分隔符;纯小写单词(config、readme)不算
317
+ return /[0-9]/.test(text) || /[A-Z]/.test(text) || /[-_+=:~]/.test(text);
318
+ }
319
+
320
+ export function looksLikeEndpoint(value: string): boolean {
321
+ return /^https?:\/\/[^\s/]+/i.test(String(value ?? "").trim());
322
+ }
323
+
324
+ /** `sk-abcd1234…` → 只留头尾,给回执与日志用 */
325
+ export function maskSecret(value: string): string {
326
+ const text = String(value ?? "").trim();
327
+ if (text.length <= 10) return text ? `${text.slice(0, 2)}***` : "";
328
+ return `${text.slice(0, 5)}…${text.slice(-4)}`;
329
+ }
330
+
331
+ function parseModelIds(tokens: string[]): string[] {
332
+ if (tokens.length === 0) return [];
333
+ const joined = tokens.join(" ").replace(PREFIX_MODELS, "");
334
+ return joined
335
+ .split(/[,,、\s]+/)
336
+ .map((item) => item.trim())
337
+ .filter(Boolean);
338
+ }
339
+
340
+ /**
341
+ * 认三类写法(其余一律当普通对话,避免误吞用户正文):
342
+ * `<任意一串字符>` → 更新当前 provider 的 key(不要求任何前缀)
343
+ * `key <任意一串字符>` / `key: …` → 同上(显式前缀,欠费之外也认)
344
+ * `https://host/v1 <你的 key> [models a,b]` → 注册新 provider 并拉模型列表
345
+ * `endpoint https://host/v1 <你的 key> ...` → 同上(显式前缀)
346
+ */
347
+ export function parseCredentialMessage(text: string): CredentialIntake | null {
348
+ const raw = String(text ?? "").trim();
349
+ if (!raw || raw.length > 2000) return null;
350
+
351
+ const endpointForced = PREFIX_ENDPOINT.test(raw);
352
+ const keyForced = !endpointForced && PREFIX_KEY.test(raw);
353
+ const body = endpointForced ? raw.replace(PREFIX_ENDPOINT, "") : keyForced ? raw.replace(PREFIX_KEY, "") : raw;
354
+ const tokens = body.split(/\s+/).filter(Boolean);
355
+ if (tokens.length === 0) return null;
356
+
357
+ if (looksLikeEndpoint(tokens[0])) {
358
+ const baseUrl = tokens[0];
359
+ const key = stripWrapping(tokens[1] ?? "");
360
+ // 跟在 URL 后面的 key 已经算显式:不按裸串收紧(允许 `ollama` 这种占位 key)
361
+ if (!looksLikeSecret(key)) return null;
362
+ return { kind: "endpoint", baseUrl, key, models: parseModelIds(tokens.slice(2)), explicit: endpointForced };
363
+ }
364
+
365
+ const candidate = tokens.length === 1 ? stripWrapping(tokens[0]) : keyForced ? stripWrapping(body) : "";
366
+ if (candidate && looksLikeSecret(candidate, { bare: !keyForced })) return { kind: "key", key: candidate, explicit: keyForced };
367
+ return null;
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // 3. endpoint 探测(顺手验证 key)
372
+ // ---------------------------------------------------------------------------
373
+
374
+ export interface ProbeResult {
375
+ ok: boolean;
376
+ /** 从 /models 里取到的模型 id(去重、排序) */
377
+ ids: string[];
378
+ /** 真正命中的地址 */
379
+ url?: string;
380
+ status?: number;
381
+ error?: string;
382
+ }
383
+
384
+ /** 去掉尾斜杠与 `/chat/completions` 结尾,得到 provider 的 baseUrl */
385
+ export function normalizeBaseUrl(value: string): string {
386
+ return String(value ?? "")
387
+ .trim()
388
+ .replace(/\/+$/, "")
389
+ .replace(/\/chat\/completions$/i, "")
390
+ .replace(/\/completions$/i, "");
391
+ }
392
+
393
+ export function extractModelIds(body: unknown): string[] {
394
+ const ids: string[] = [];
395
+ const push = (value: unknown) => {
396
+ if (typeof value === "string" && value) ids.push(value);
397
+ else if (value && typeof value === "object" && typeof (value as any).id === "string") ids.push((value as any).id);
398
+ };
399
+ const root = body as any;
400
+ const data = root?.data ?? root?.models ?? root;
401
+ if (Array.isArray(data)) data.forEach(push);
402
+ else if (data && typeof data === "object") Object.keys(data).forEach(push);
403
+ return [...new Set(ids)].sort();
404
+ }
405
+
406
+ /**
407
+ * `GET <base>/models`(base 不带版本号时再试一次 `/v1`)。
408
+ * 401/403 视为「key 无效」立刻返回;其他失败继续试下一个候选地址。
409
+ */
410
+ export async function probeEndpoint(
411
+ baseUrl: string,
412
+ key: string,
413
+ fetchFn: typeof fetch = fetch,
414
+ timeoutMs = 15000,
415
+ ): Promise<ProbeResult> {
416
+ const base = normalizeBaseUrl(baseUrl);
417
+ const bases = /\/v\d+$/i.test(base) ? [base] : [base, `${base}/v1`];
418
+ let last: ProbeResult = { ok: false, ids: [], error: "没有可用地址" };
419
+ for (const candidate of bases) {
420
+ const url = `${candidate}/models`;
421
+ try {
422
+ const response = await fetchFn(url, {
423
+ method: "GET",
424
+ headers: { authorization: `Bearer ${key}`, accept: "application/json" },
425
+ signal: AbortSignal.timeout(timeoutMs),
426
+ });
427
+ if (response.status === 401 || response.status === 403) {
428
+ return { ok: false, ids: [], url, status: response.status, error: `key 无效或无权访问(HTTP ${response.status})` };
429
+ }
430
+ if (!response.ok) {
431
+ last = { ok: false, ids: [], url, status: response.status, error: `HTTP ${response.status}` };
432
+ continue;
433
+ }
434
+ const body = await response.json().catch(() => null);
435
+ return { ok: true, ids: extractModelIds(body), url, status: response.status };
436
+ } catch (error) {
437
+ last = { ok: false, ids: [], url, error: String((error as any)?.message ?? error).slice(0, 160) };
438
+ }
439
+ }
440
+ return last;
441
+ }
442
+
443
+ /** 由 endpoint 生成稳定的 provider id(`qq-<host>`,撞名时加序号) */
444
+ export function providerIdForEndpoint(baseUrl: string, taken: readonly string[] = []): string {
445
+ let host = "endpoint";
446
+ try {
447
+ host = new URL(normalizeBaseUrl(baseUrl)).host || "endpoint";
448
+ } catch {
449
+ // 非法 URL:交给调用方提前拒绝,这里只保证 id 合法
450
+ }
451
+ const slug =
452
+ host
453
+ .toLowerCase()
454
+ .replace(/[^a-z0-9]+/g, "-")
455
+ .replace(/^-+|-+$/g, "")
456
+ .slice(0, 24) || "endpoint";
457
+ const base = `qq-${slug}`;
458
+ if (!taken.includes(base)) return base;
459
+ for (let index = 2; index < 100; index += 1) {
460
+ const candidate = `${base}-${index}`;
461
+ if (!taken.includes(candidate)) return candidate;
462
+ }
463
+ return `${base}-${Date.now()}`;
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // 4. 落盘:auth.json(换 key)/ models.json(新 endpoint)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ export function agentDir(): string {
471
+ const envDir = process.env.PI_CODING_AGENT_DIR?.trim();
472
+ if (envDir) return envDir.replace(/^~(?=$|[\\/])/, os.homedir());
473
+ return path.join(os.homedir(), ".pi", "agent");
474
+ }
475
+
476
+ export const authFilePath = () => path.join(agentDir(), "auth.json");
477
+ export const modelsFilePath = () => path.join(agentDir(), "models.json");
478
+
479
+ function readJson(file: string): any {
480
+ try {
481
+ const text = fs.readFileSync(file, "utf8");
482
+ if (!text.trim()) return {};
483
+ return JSON.parse(text.replace(/^\uFEFF/, ""));
484
+ } catch {
485
+ return {};
486
+ }
487
+ }
488
+
489
+ /** 原子写:同目录临时文件 + rename,避免 pi 读到半个文件 */
490
+ function writeJsonAtomic(file: string, data: unknown): void {
491
+ fs.mkdirSync(path.dirname(file), { recursive: true });
492
+ const tmp = `${file}.${process.pid}.tmp`;
493
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
494
+ fs.renameSync(tmp, file);
495
+ }
496
+
497
+ /** 与 pi 同款锁(realpath:false),拿不到锁就退化成「原子写」,不让换 key 卡死 */
498
+ async function withLock<T>(file: string, fn: () => Promise<T> | T): Promise<T> {
499
+ if (!fs.existsSync(file)) {
500
+ fs.mkdirSync(path.dirname(file), { recursive: true });
501
+ fs.writeFileSync(file, "{}\n", { encoding: "utf8", mode: 0o600 });
502
+ }
503
+ let release: (() => Promise<void>) | null = null;
504
+ try {
505
+ release = await lockfile.lock(file, { realpath: false, retries: 3, stale: 15000 });
506
+ } catch {
507
+ release = null;
508
+ }
509
+ try {
510
+ return await fn();
511
+ } finally {
512
+ await release?.().catch(() => {});
513
+ }
514
+ }
515
+
516
+ export interface WriteResult {
517
+ ok: boolean;
518
+ path: string;
519
+ error?: string;
520
+ }
521
+
522
+ /** 把 key 写进 auth.json 的 `{ "<provider>": { type: "api_key", key } }` */
523
+ export async function updateProviderKey(provider: string, key: string): Promise<WriteResult> {
524
+ const file = authFilePath();
525
+ try {
526
+ await withLock(file, () => {
527
+ const data = readJson(file);
528
+ const previous = data[provider];
529
+ data[provider] = { ...(previous && typeof previous === "object" ? previous : {}), type: "api_key", key };
530
+ writeJsonAtomic(file, data);
531
+ });
532
+ return { ok: true, path: file };
533
+ } catch (error) {
534
+ return { ok: false, path: file, error: String((error as any)?.message ?? error).slice(0, 200) };
535
+ }
536
+ }
537
+
538
+ export interface EndpointProviderInput {
539
+ providerId: string;
540
+ baseUrl: string;
541
+ apiKey: string;
542
+ api?: string;
543
+ /** 完整的模型配置项(含从内置目录借来的 contextWindow / compat 等) */
544
+ models: ResolvedModelConfig[];
545
+ name?: string;
546
+ }
547
+
548
+ /** 在 models.json 里写入/覆盖一个 provider(models 全量替换,其它 provider 保持不动) */
549
+ export async function upsertEndpointProvider(input: EndpointProviderInput): Promise<WriteResult> {
550
+ const file = modelsFilePath();
551
+ try {
552
+ await withLock(file, () => {
553
+ const data = readJson(file);
554
+ const providers = data.providers && typeof data.providers === "object" ? data.providers : {};
555
+ providers[input.providerId] = {
556
+ name: input.name ?? `QQ 接入 (${input.providerId})`,
557
+ baseUrl: normalizeBaseUrl(input.baseUrl),
558
+ api: input.api ?? "openai-completions",
559
+ apiKey: input.apiKey,
560
+ models: input.models.map((model) => ({ ...model })),
561
+ };
562
+ writeJsonAtomic(file, {
563
+ $schema: "https://pi.dev/schemas/models.schema.json",
564
+ ...data,
565
+ providers,
566
+ });
567
+ });
568
+ return { ok: true, path: file };
569
+ } catch (error) {
570
+ return { ok: false, path: file, error: String((error as any)?.message ?? error).slice(0, 200) };
571
+ }
572
+ }
573
+
574
+ /** models.json 里已经存在的 provider id(用于生成不撞名的 id) */
575
+ export function existingProviderIds(): string[] {
576
+ const data = readJson(modelsFilePath());
577
+ const providers = data?.providers;
578
+ return providers && typeof providers === "object" ? Object.keys(providers) : [];
579
+ }