dsh-multi2api 0.1.4

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/lib/index.js ADDED
@@ -0,0 +1,1678 @@
1
+ // dsh-multi2api —— 宿主侧插件。
2
+ //
3
+ // 它做三件事:
4
+ // 1. 托管本机 multi2api(Go)进程:挑端口、写配置、启动、健康检查、崩溃拉起、重启。
5
+ // 2. 把账号池里的每个 WorkBuddy 账号注册成 DSH 里一个独立模型(一个账号一个模型、不串号),
6
+ // 另外提供一个 auto 模型走"积分最高的健康账号"。
7
+ // 3. 在 Web UI 里提供管理面板用的本地接口:状态 / 签到 / 登录加号 / 删号 / 导号 / 日志 / 重启。
8
+ //
9
+ // 不改动 multi2api 源码,也不依赖它的任何私有接口:只用它公开的 4 条路由
10
+ // (POST /v1/chat/completions、GET /v1/models、GET /status、GET /healthz)。
11
+ // 账号文件的增删需要重启被托管进程才会生效(Go 只在启动时读 auths/)。
12
+
13
+ import z from "@deepseek-ai/schemastery";
14
+ import { createProvider } from "@earendil-works/pi-ai";
15
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
16
+ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
17
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
18
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
19
+ import { spawn } from "node:child_process";
20
+ import { randomBytes } from "node:crypto";
21
+ import { createServer } from "node:net";
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { chmod, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
24
+ import { join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ /* ------------------------------------------------------------------ *
28
+ * 常量
29
+ * ------------------------------------------------------------------ */
30
+
31
+ const PROVIDER = "multi2api";
32
+ const PLUGIN_NAME = "dsh-multi2api";
33
+ const ROUTE_PREFIX = "/plugins/dsh-multi2api";
34
+ const SETTINGS_NS_FALLBACK = "multi2api";
35
+
36
+ const UNIFIED_MODEL = "deepseek-v4.1-flash"; // 上游基础模型名(Go 侧 mapModel 认这个名字)
37
+ const DEFAULT_CONTEXT_WINDOW = 976000;
38
+ const DEFAULT_MAX_TOKENS = 125000;
39
+ const AUTO_MODEL_ID = "auto";
40
+
41
+ const MODELS_BODY_LIMIT = 65536;
42
+ // 批量导入账号时一个请求里可能塞很多份凭证,单独给一个更宽的上限。
43
+ const IMPORT_BODY_LIMIT = 8 * 1024 * 1024;
44
+ const MAX_LOG_CHARS = 256 * 1024;
45
+
46
+ // 每个平台的二进制;key = process.platform + "-" + process.arch
47
+ const BIN_BY_PLATFORM = {
48
+ "linux-x64": "bin/multi2api-linux-amd64",
49
+ "linux-arm64": "bin/multi2api-linux-arm64",
50
+ "darwin-x64": "bin/multi2api-darwin-amd64",
51
+ "darwin-arm64": "bin/multi2api-darwin-arm64",
52
+ "win32-x64": "bin/multi2api-win32-x64.exe",
53
+ };
54
+
55
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
56
+
57
+ // WorkBuddy(腾讯 CodeBuddy)登录 / 账单相关常量,来源:multi2api cmd/login/workbuddy.go 与
58
+ // internal/provider/workbuddy/headers.go。
59
+ const WB = {
60
+ chatBaseCN: "https://copilot.tencent.com",
61
+ billingBaseCN: "https://www.codebuddy.cn",
62
+ chatBaseGlobal: "https://www.workbuddy.ai",
63
+ billingBaseGlobal: "https://www.workbuddy.ai",
64
+ originCN: "https://www.codebuddy.cn",
65
+ originGlobal: "https://www.workbuddy.ai",
66
+ ua: "CLI/2.63.2 CodeBuddy/2.63.2",
67
+ pollAttempts: 15,
68
+ pollIntervalMs: 2000,
69
+ };
70
+
71
+ const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
72
+
73
+ const COMPAT = {
74
+ thinkingFormat: "deepseek",
75
+ supportsReasoningEffort: true,
76
+ requiresReasoningContentOnAssistantMessages: true,
77
+ // 上游对 developer 角色返回 11128 风控,必须退回 system。
78
+ supportsDeveloperRole: false,
79
+ supportsStore: false,
80
+ };
81
+
82
+ const THINKING_LEVEL_MAP = {
83
+ off: null,
84
+ minimal: null,
85
+ low: "low",
86
+ medium: null,
87
+ high: "high",
88
+ xhigh: null,
89
+ max: "max",
90
+ };
91
+
92
+ // pi-ai 要求 PiAiAdapterOptions.auth 必填;本插件的鉴权完全走 resolveApiKey,
93
+ // 这里给一份"空壳"凭证实现即可(照抄 dsh-codebuddy-cli 的写法)。
94
+ const INERT_AUTH = {
95
+ credentials: {
96
+ async read() {},
97
+ async list() {
98
+ return [];
99
+ },
100
+ async modify() {
101
+ throw new Error("dsh-multi2api: multi2api 路由没有 pi-ai 凭证生命周期");
102
+ },
103
+ async delete() {},
104
+ },
105
+ authContext: {
106
+ async env() {},
107
+ async fileExists() {
108
+ return false;
109
+ },
110
+ },
111
+ };
112
+
113
+ /* ------------------------------------------------------------------ *
114
+ * 小工具
115
+ * ------------------------------------------------------------------ */
116
+
117
+ function messageOf(error) {
118
+ return error instanceof Error ? error.message : String(error);
119
+ }
120
+
121
+ // 脱敏:JWT、查询串里的 token、以及超长内容。用于回给面板的错误文本。
122
+ function safeMessage(error) {
123
+ return String(error?.message ?? error)
124
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]")
125
+ .replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]")
126
+ .slice(0, 500);
127
+ }
128
+
129
+ function binPathForThisMachine() {
130
+ const key = `${process.platform}-${process.arch}`;
131
+ const rel = BIN_BY_PLATFORM[key];
132
+ if (rel === undefined) {
133
+ throw new Error(`dsh-multi2api: 当前机器(${key})没有内置的 multi2api 程序,暂时只支持 linux/macOS/Windows 的 x64 与 arm64`);
134
+ }
135
+ return fileURLToPath(new URL(`../${rel}`, import.meta.url));
136
+ }
137
+
138
+ function hostnameOfHost(host) {
139
+ if (typeof host !== "string" || host.length === 0) return "";
140
+ if (host.startsWith("[")) {
141
+ const end = host.indexOf("]");
142
+ return end === -1 ? host : host.slice(0, end + 1);
143
+ }
144
+ const idx = host.lastIndexOf(":");
145
+ if (idx === -1) return host;
146
+ const suffix = host.slice(idx + 1);
147
+ if (host.slice(0, idx).includes(":") || !/^\d+$/.test(suffix)) return host;
148
+ return host.slice(0, idx);
149
+ }
150
+
151
+ function hostIsLoopback(host) {
152
+ return LOOPBACK_HOSTS.has(hostnameOfHost(host));
153
+ }
154
+
155
+ function originIsLoopback(origin) {
156
+ if (typeof origin !== "string" || origin.length === 0) return true;
157
+ try {
158
+ const hostname = new URL(origin).hostname;
159
+ return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ function loopbackRequest(req) {
166
+ return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
167
+ }
168
+
169
+ function json(res, status, body) {
170
+ const payload = JSON.stringify(body);
171
+ res.statusCode = status;
172
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
173
+ res.setHeader("Content-Length", Buffer.byteLength(payload));
174
+ res.setHeader("Cache-Control", "no-store");
175
+ res.end(payload);
176
+ }
177
+
178
+ async function readBody(req, limit = MODELS_BODY_LIMIT) {
179
+ const chunks = [];
180
+ let size = 0;
181
+ for await (const chunk of req) {
182
+ size += chunk.length;
183
+ if (size > limit) {
184
+ req.destroy();
185
+ throw new Error("request body too large");
186
+ }
187
+ chunks.push(chunk);
188
+ }
189
+ if (chunks.length === 0) return {};
190
+ const text = Buffer.concat(chunks).toString("utf8");
191
+ if (text.trim().length === 0) return {};
192
+ return JSON.parse(text);
193
+ }
194
+
195
+ // 写操作必须来自本机页面且带 Origin、Content-Type 为 JSON(防 DNS rebinding / 跨站表单)。
196
+ function checkLoopbackPost(req, res) {
197
+ if (!loopbackRequest(req)) {
198
+ json(res, 403, { error: "request-not-trusted" });
199
+ return false;
200
+ }
201
+ if (typeof req.headers.origin !== "string") {
202
+ json(res, 403, { error: "origin-required" });
203
+ return false;
204
+ }
205
+ const contentType = String(req.headers["content-type"] ?? "");
206
+ if (!contentType.toLowerCase().startsWith("application/json")) {
207
+ json(res, 415, { error: "content-type must be application/json" });
208
+ return false;
209
+ }
210
+ return true;
211
+ }
212
+
213
+ function isPortFree(port) {
214
+ return new Promise((resolve) => {
215
+ const server = createServer();
216
+ server.once("error", () => resolve(false));
217
+ server.once("listening", () => server.close(() => resolve(true)));
218
+ server.listen(port, "127.0.0.1");
219
+ });
220
+ }
221
+
222
+ async function findFreePort(preferred) {
223
+ for (let port = preferred; port < preferred + 50; port += 1) {
224
+ if (await isPortFree(port)) return port;
225
+ }
226
+ return preferred;
227
+ }
228
+
229
+ function timeoutSignal(ms) {
230
+ if (typeof AbortSignal?.timeout === "function") return AbortSignal.timeout(ms);
231
+ return undefined;
232
+ }
233
+
234
+ async function fetchJson(url, init = {}, timeoutMs = 15000) {
235
+ const response = await fetch(url, { ...init, signal: init.signal ?? timeoutSignal(timeoutMs) });
236
+ const text = await response.text();
237
+ let body;
238
+ try {
239
+ body = text.length > 0 ? JSON.parse(text) : undefined;
240
+ } catch {
241
+ body = undefined;
242
+ }
243
+ return { response, body, text };
244
+ }
245
+
246
+ /* ------------------------------------------------------------------ *
247
+ * WorkBuddy 账号:登录、签到、积分
248
+ * ------------------------------------------------------------------ */
249
+
250
+ function regionOf(domain) {
251
+ const value = String(domain ?? "").trim().toLowerCase();
252
+ if (value === "workbuddy.ai" || value.endsWith(".workbuddy.ai")) return "global";
253
+ return "cn";
254
+ }
255
+
256
+ function chatBaseFor(account) {
257
+ return regionOf(account.domain) === "global" ? WB.chatBaseGlobal : WB.chatBaseCN;
258
+ }
259
+
260
+ function billingBaseFor(account) {
261
+ return regionOf(account.domain) === "global" ? WB.billingBaseGlobal : WB.billingBaseCN;
262
+ }
263
+
264
+ function originRefererFor(account) {
265
+ return regionOf(account.domain) === "global" ? WB.originGlobal : WB.originCN;
266
+ }
267
+
268
+ function commonHeaders(account) {
269
+ const origin = originRefererFor(account);
270
+ return {
271
+ "Content-Type": "application/json",
272
+ Accept: "application/json, text/plain, */*",
273
+ "X-Requested-With": "XMLHttpRequest",
274
+ Origin: origin,
275
+ Referer: `${origin}/`,
276
+ "User-Agent": WB.ua,
277
+ };
278
+ }
279
+
280
+ function billingHeaders(account) {
281
+ const headers = {
282
+ Authorization: `Bearer ${account.accessToken}`,
283
+ Accept: "application/json",
284
+ "Content-Type": "application/json",
285
+ };
286
+ if (account.uid) headers["X-User-Id"] = account.uid;
287
+ if (account.enterpriseId) {
288
+ headers["X-Enterprise-Id"] = account.enterpriseId;
289
+ headers["X-Tenant-Id"] = account.enterpriseId;
290
+ }
291
+ if (account.domain) headers["X-Domain"] = account.domain;
292
+ return headers;
293
+ }
294
+
295
+ // Go 侧 wbDoJSON:HTTP >= 400 报 http_error;envelope.code != 0 报 code/msg;返回 data。
296
+ async function wbDoJson(account, method, url, body, headers) {
297
+ const init = {
298
+ method,
299
+ headers: headers ?? commonHeaders(account),
300
+ signal: timeoutSignal(30000),
301
+ };
302
+ if (body !== undefined) init.body = JSON.stringify(body);
303
+ const response = await fetch(url, init);
304
+ const text = await response.text();
305
+ if (response.status >= 400) {
306
+ throw new Error(`http_error: upstream ${response.status}: ${text.slice(0, 200)}`);
307
+ }
308
+ let envelope;
309
+ try {
310
+ envelope = text.length > 0 ? JSON.parse(text) : {};
311
+ } catch {
312
+ throw new Error("invalid upstream response");
313
+ }
314
+ if (envelope?.code !== undefined && Number(envelope.code) !== 0) {
315
+ throw new Error(`code=${envelope.code} msg=${envelope.msg ?? ""}`);
316
+ }
317
+ return envelope?.data;
318
+ }
319
+
320
+ // 第 1 步:拿 state 与登录地址。
321
+ async function workbuddyLoginStart() {
322
+ const account = { domain: "www.codebuddy.cn", accessToken: "", uid: "", enterpriseId: "" };
323
+ const url = `${WB.chatBaseCN}/v2/plugin/auth/state?platform=CLI`;
324
+ const data = await wbDoJson(account, "POST", url, {});
325
+ const state = String(data?.state ?? "");
326
+ const authUrl = String(data?.authUrl ?? "");
327
+ if (state.length === 0 || authUrl.length === 0) throw new Error("auth state: missing state or authUrl");
328
+ return { state, authUrl };
329
+ }
330
+
331
+ // 第 2 步:轮询换 token。返回 {accessToken, refreshToken, expiresIn, domain},未完成返回 undefined。
332
+ async function workbuddyLoginPoll(state) {
333
+ const account = { domain: "www.codebuddy.cn", accessToken: "", uid: "", enterpriseId: "" };
334
+ const url = `${WB.chatBaseCN}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`;
335
+ const response = await fetch(url, { headers: commonHeaders(account), signal: timeoutSignal(30000) });
336
+ const text = await response.text();
337
+ if (response.status === 0 || response.status >= 500) {
338
+ throw new Error(`token endpoint error: upstream ${response.status}`);
339
+ }
340
+ if (response.status >= 400) {
341
+ throw new Error(`http_error: upstream ${response.status}: ${text.slice(0, 200)}`);
342
+ }
343
+ let envelope;
344
+ try {
345
+ envelope = text.length > 0 ? JSON.parse(text) : {};
346
+ } catch {
347
+ throw new Error("invalid upstream response");
348
+ }
349
+ if (envelope?.code !== undefined && Number(envelope.code) !== 0) {
350
+ throw new Error(`code=${envelope.code} msg=${envelope.msg ?? ""}`);
351
+ }
352
+ const accessToken = String(envelope?.data?.accessToken ?? "");
353
+ if (accessToken.length === 0) return undefined;
354
+ return {
355
+ accessToken,
356
+ refreshToken: String(envelope?.data?.refreshToken ?? ""),
357
+ expiresIn: Number(envelope?.data?.expiresIn ?? 0),
358
+ domain: String(envelope?.data?.domain ?? ""),
359
+ };
360
+ }
361
+
362
+ // 第 3 步:用 token 换账号资料。
363
+ async function workbuddyFetchAccount(state, accessToken) {
364
+ const account = { domain: "www.codebuddy.cn", accessToken, uid: "", enterpriseId: "" };
365
+ const url = `${WB.chatBaseCN}/v2/plugin/login/account?state=${encodeURIComponent(state)}`;
366
+ const headers = { ...commonHeaders(account), Authorization: `Bearer ${accessToken}` };
367
+ const data = await wbDoJson(account, "GET", url, undefined, headers);
368
+ return {
369
+ uid: String(data?.uid ?? ""),
370
+ enterpriseId: String(data?.enterpriseId ?? ""),
371
+ nickname: String(data?.nickname ?? ""),
372
+ };
373
+ }
374
+
375
+ function isAlreadyCheckedIn(message) {
376
+ const text = String(message ?? "").toLowerCase();
377
+ return message.includes("已签到") || text.includes("already checked") || text.includes("already signed");
378
+ }
379
+
380
+ async function workbuddyCheckin(account) {
381
+ const url = `${billingBaseFor(account)}/v2/billing/meter/daily-checkin`;
382
+ try {
383
+ await wbDoJson(account, "POST", url, {}, billingHeaders(account));
384
+ return { ok: true };
385
+ } catch (error) {
386
+ if (isAlreadyCheckedIn(messageOf(error))) return { ok: true, already: true };
387
+ throw error;
388
+ }
389
+ }
390
+
391
+ async function workbuddyCredit(account) {
392
+ const now = new Date();
393
+ const pad = (n) => String(n).padStart(2, "0");
394
+ const format = (d) =>
395
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
396
+ const end = new Date(now.getTime() + 365 * 101 * 24 * 3600 * 1000);
397
+ const body = {
398
+ PageNumber: 1,
399
+ PageSize: 100,
400
+ ProductCode: "p_tcaca",
401
+ Status: [0, 3],
402
+ PackageEndTimeRangeBegin: format(now),
403
+ PackageEndTimeRangeEnd: format(end),
404
+ };
405
+ const url = `${billingBaseFor(account)}/v2/billing/meter/get-user-resource`;
406
+ const data = await wbDoJson(account, "POST", url, body, billingHeaders(account));
407
+ const accounts = data?.Response?.Data?.Accounts ?? data?.Accounts ?? [];
408
+ let total = 0;
409
+ for (const item of Array.isArray(accounts) ? accounts : []) {
410
+ const cycleSize = Number(item?.CycleCapacitySize ?? 0);
411
+ const cycleRemain = Number(item?.CycleCapacityRemain ?? 0);
412
+ const cycleUsed = Number(item?.CycleCapacityUsed ?? 0);
413
+ const capacityRemain = Number(item?.CapacityRemain ?? 0);
414
+ let remain;
415
+ if (cycleSize > 0) remain = cycleRemain;
416
+ else if (cycleRemain > 0 || cycleUsed > 0) remain = cycleRemain;
417
+ else remain = capacityRemain;
418
+ if (!Number.isFinite(remain) || remain < 0) remain = 0;
419
+ total += remain;
420
+ }
421
+ return total;
422
+ }
423
+
424
+ /* ------------------------------------------------------------------ *
425
+ * 账号文件读写(形状必须与 Go internal/auth/auth.go saveAtomicLocked 一致)
426
+ * ------------------------------------------------------------------ */
427
+
428
+ function parseAccountDoc(raw) {
429
+ let doc;
430
+ try {
431
+ doc = typeof raw === "string" ? JSON.parse(raw) : raw;
432
+ } catch {
433
+ throw new Error("不是合法的 JSON");
434
+ }
435
+ if (doc === null || typeof doc !== "object") throw new Error("账号内容必须是一个 JSON 对象");
436
+ const nested = doc.auth !== undefined || doc.account !== undefined;
437
+ const auth = nested ? (doc.auth ?? {}) : doc;
438
+ const account = nested ? (doc.account ?? {}) : doc;
439
+ const accessToken = String(auth.accessToken ?? auth.access_token ?? "");
440
+ if (accessToken.length === 0) throw new Error("parse_error: missing accessToken");
441
+ return {
442
+ platform: String(doc.platform ?? "workbuddy"),
443
+ accessToken,
444
+ refreshToken: String(auth.refreshToken ?? auth.refresh_token ?? ""),
445
+ expiresAt: Number(auth.expiresAt ?? auth.expires_at ?? 0),
446
+ domain: String(auth.domain ?? ""),
447
+ apiHost: String(auth.apiHost ?? ""),
448
+ machineId: String(auth.machineId ?? ""),
449
+ deviceId: String(auth.deviceId ?? ""),
450
+ uid: String(account.uid ?? ""),
451
+ enterpriseId: String(account.enterpriseId ?? ""),
452
+ nickname: String(account.nickname ?? ""),
453
+ };
454
+ }
455
+
456
+ function buildAccountDoc(account) {
457
+ return {
458
+ platform: account.platform ?? "workbuddy",
459
+ auth: {
460
+ accessToken: account.accessToken,
461
+ refreshToken: account.refreshToken,
462
+ expiresAt: account.expiresAt,
463
+ domain: account.domain,
464
+ apiHost: account.apiHost ?? "",
465
+ machineId: account.machineId ?? "",
466
+ deviceId: account.deviceId ?? "",
467
+ },
468
+ account: {
469
+ uid: account.uid,
470
+ enterpriseId: account.enterpriseId,
471
+ nickname: account.nickname,
472
+ },
473
+ };
474
+ }
475
+
476
+ async function writeAccountFile(authsDir, account) {
477
+ const doc = buildAccountDoc(account);
478
+ const uid = String(account.uid ?? "").trim();
479
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(uid)) throw new Error("uid 不合法,无法写入账号文件");
480
+ const target = join(authsDir, `workbuddy-${uid}.json`);
481
+ const tmp = `${target}.tmp-${randomBytes(4).toString("hex")}`;
482
+ await writeFile(tmp, `${JSON.stringify(doc, null, 2)}\n`, { mode: 0o600 });
483
+ await chmod(tmp, 0o600);
484
+ await rename(tmp, target);
485
+ return target;
486
+ }
487
+
488
+ async function readAccountFile(file) {
489
+ const raw = await readFile(file, "utf8");
490
+ const account = parseAccountDoc(raw);
491
+ if (!account.uid) {
492
+ const match = /^workbuddy-(.+)\.json$/.exec(String(file).split(/[\\/]/).pop() ?? "");
493
+ if (match) account.uid = match[1];
494
+ }
495
+ return account;
496
+ }
497
+
498
+ async function listAccountFiles(authsDir) {
499
+ let names = [];
500
+ try {
501
+ names = await readdir(authsDir);
502
+ } catch {
503
+ return [];
504
+ }
505
+ const out = [];
506
+ for (const name of names) {
507
+ if (!/^workbuddy-.+\.json$/.test(name)) continue;
508
+ const file = join(authsDir, name);
509
+ try {
510
+ out.push({ file, account: await readAccountFile(file) });
511
+ } catch {
512
+ // 坏文件跳过,避免一个文件坏了整个池子起不来。
513
+ }
514
+ }
515
+ return out;
516
+ }
517
+
518
+ // 昵称(手机号)必须是纯数字,Go 侧 splitModelNick 才会把模型名后缀当账号别名。
519
+ // 非纯数字或重名的账号,改写成由 uid 派生的纯数字别名,保证"一账号一模型"始终可用。
520
+ function aliasFor(account, used) {
521
+ const nick = String(account.nickname ?? "").trim();
522
+ if (/^\d+$/.test(nick) && !used.has(nick)) return { alias: nick, rewrite: false };
523
+ const digits = String(account.uid ?? "").replace(/\D+/g, "");
524
+ let seed = digits.length >= 6 ? digits.slice(-12) : String(account.uid ?? "").replace(/[^A-Za-z0-9]/g, "");
525
+ let alias = seed.replace(/\D+/g, "");
526
+ if (alias.length < 6) alias = `${alias}${String(Math.abs(hashCode(String(account.uid ?? ""))))}`.slice(0, 12);
527
+ let suffix = 0;
528
+ while (used.has(alias)) {
529
+ suffix += 1;
530
+ alias = `${seed.slice(0, 10)}${suffix}`;
531
+ }
532
+ return { alias, rewrite: true };
533
+ }
534
+
535
+ function hashCode(text) {
536
+ let hash = 0;
537
+ for (let i = 0; i < text.length; i += 1) {
538
+ hash = (hash << 5) - hash + text.charCodeAt(i);
539
+ hash |= 0;
540
+ }
541
+ return hash;
542
+ }
543
+
544
+ /* ------------------------------------------------------------------ *
545
+ * 子进程托管 + 账号/模型缓存
546
+ * ------------------------------------------------------------------ */
547
+
548
+ function createRuntime(ctx, config) {
549
+ const dataDir = config.dataDir;
550
+ const authsDir = join(dataDir, "auths");
551
+ const logsDir = join(dataDir, "logs");
552
+ const keyFile = join(dataDir, "api_key");
553
+ const autoCheckinFile = join(dataDir, "auto_checkin.json");
554
+ const selectionFile = join(dataDir, "selection.json");
555
+
556
+ const runtime = {
557
+ config,
558
+ dataDir,
559
+ authsDir,
560
+ logsDir,
561
+ settingsNs: resolveSettingsNamespace(ctx),
562
+ apiKey: "",
563
+ port: 0,
564
+ child: null,
565
+ pid: 0,
566
+ stopped: false,
567
+ starting: false,
568
+ lastError: "",
569
+ startedAt: 0,
570
+ restartCount: 0,
571
+ logs: [],
572
+ accounts: [],
573
+ models: [],
574
+ catalog: [],
575
+ catalogAt: 0,
576
+ catalogError: "",
577
+ checkinLog: new Map(),
578
+ pendingLogins: new Map(),
579
+ statusAt: 0,
580
+ statusError: "",
581
+ accountsFingerprint: "",
582
+ modelsFingerprint: "",
583
+ selection: { seeded: false, enabledModels: [], disabledAccounts: [], exists: false },
584
+ };
585
+
586
+ runtime.profiles = new Map();
587
+ runtime.invalidate = () => {
588
+ runtime.profiles = new Map([[PROVIDER, runtime.profile]]);
589
+ };
590
+
591
+ runtime.baseUrl = () => `http://127.0.0.1:${runtime.port}`;
592
+ runtime.modelsBaseUrl = () => `${runtime.baseUrl()}/v1`;
593
+
594
+ runtime.appendLog = (chunk) => {
595
+ const text = String(chunk);
596
+ runtime.logs.push(text);
597
+ let total = runtime.logs.reduce((sum, item) => sum + item.length, 0);
598
+ while (total > MAX_LOG_CHARS && runtime.logs.length > 1) {
599
+ total -= runtime.logs.shift().length;
600
+ }
601
+ };
602
+
603
+ runtime.authHeaders = () => ({ Authorization: `Bearer ${runtime.apiKey}` });
604
+
605
+ // 自动签到开关:面板点一下会写进这个文件,重启 DSH 也不会丢。
606
+ // 没有这个文件时用插件配置里的默认值(默认开)。
607
+ runtime.readAutoCheckin = () => {
608
+ try {
609
+ const parsed = JSON.parse(readFileSync(autoCheckinFile, "utf8"));
610
+ if (typeof parsed?.enabled === "boolean") return parsed.enabled;
611
+ } catch {
612
+ // 文件不存在或坏了:退回配置默认值。
613
+ }
614
+ return runtime.config.autoCheckin === true;
615
+ };
616
+
617
+ runtime.writeAutoCheckin = (enabled) => {
618
+ const next = enabled === true;
619
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
620
+ writeFileSync(autoCheckinFile, `${JSON.stringify({ enabled: next }, null, 2)}\n`);
621
+ runtime.config.autoCheckin = next;
622
+ return next;
623
+ };
624
+
625
+ // 启用清单:决定哪些「模型 × 账号」注册进 DSH 的模型列表。
626
+ // 模型是白名单(没勾就不注册),账号是黑名单(只记被关掉的),这样新加的号默认能用。
627
+ runtime.readSelection = () => {
628
+ let parsed;
629
+ try {
630
+ parsed = JSON.parse(readFileSync(selectionFile, "utf8"));
631
+ } catch {
632
+ parsed = undefined;
633
+ }
634
+ const enabledModels = Array.isArray(parsed?.enabledModels)
635
+ ? parsed.enabledModels.map((item) => String(item)).filter((item) => item.length > 0)
636
+ : [];
637
+ const disabledAccounts = Array.isArray(parsed?.disabledAccounts)
638
+ ? parsed.disabledAccounts.map((item) => String(item)).filter((item) => item.length > 0)
639
+ : [];
640
+ return { seeded: parsed?.seeded === true, enabledModels, disabledAccounts, exists: parsed !== undefined };
641
+ };
642
+
643
+ runtime.writeSelection = (selection) => {
644
+ const next = {
645
+ seeded: selection.seeded === true,
646
+ enabledModels: Array.isArray(selection.enabledModels) ? selection.enabledModels.map(String) : [],
647
+ disabledAccounts: Array.isArray(selection.disabledAccounts) ? selection.disabledAccounts.map(String) : [],
648
+ };
649
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
650
+ writeFileSync(selectionFile, `${JSON.stringify(next, null, 2)}\n`);
651
+ return next;
652
+ };
653
+
654
+ // 模型是否启用:白名单。auto 不在这里判断(它永远注册)。
655
+ runtime.modelEnabled = (modelId, selection) =>
656
+ selection.enabledModels.includes(String(modelId));
657
+
658
+ runtime.accountEnabled = (uid, selection) =>
659
+ !selection.disabledAccounts.includes(String(uid));
660
+
661
+ runtime.ensureApiKey = () => {
662
+ if (existsSync(keyFile)) {
663
+ const existing = readFileSync(keyFile, "utf8").trim();
664
+ if (existing.length > 0) return existing;
665
+ }
666
+ // 首次运行时 dataDir 可能还不存在,写文件前必须先建目录。
667
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
668
+ const generated = randomBytes(24).toString("hex");
669
+ writeFileSync(keyFile, `${generated}\n`, { mode: 0o600 });
670
+ return generated;
671
+ };
672
+
673
+ runtime.goConfig = () => ({
674
+ listen: `127.0.0.1:${runtime.port}`,
675
+ api_key: runtime.apiKey,
676
+ auth_dir: "./auths",
677
+ state_file: "./data/state.json",
678
+ providers: {
679
+ workbuddy: {
680
+ region: "cn",
681
+ chat_enabled: true,
682
+ // err_threshold 放大:一账号一模型时,偶发错误不该把账号冷却掉,否则对应模型会 503。
683
+ cooldown: { hard_credit: "12h", soft_rate: "60s", err_threshold: 999999, err_cooldown: "1s" },
684
+ // auto_checkin 关掉后,Go 侧既不注册整点签到任务,也不做启动补签。
685
+ schedule: {
686
+ checkin_hours: [9, 21],
687
+ keepalive_hours: [22],
688
+ auto_checkin: runtime.readAutoCheckin(),
689
+ },
690
+ },
691
+ },
692
+ });
693
+
694
+ runtime.prepare = async () => {
695
+ runtime.apiKey = runtime.ensureApiKey();
696
+ await mkdir(authsDir, { recursive: true });
697
+ await mkdir(logsDir, { recursive: true });
698
+ await mkdir(join(dataDir, "data"), { recursive: true });
699
+ };
700
+
701
+ runtime.fetchStatus = async (force) => {
702
+ const ttl = Math.max(2, runtime.config.statusRefreshSeconds) * 1000;
703
+ if (!force && runtime.accounts.length > 0 && Date.now() - runtime.statusAt < ttl) return runtime.accounts;
704
+ if (runtime.port === 0) return runtime.accounts;
705
+ try {
706
+ const { response, body } = await fetchJson(`${runtime.baseUrl()}/status`, { headers: runtime.authHeaders() }, 8000);
707
+ if (!response.ok) throw new Error(`status ${response.status}`);
708
+ runtime.accounts = Array.isArray(body?.accounts) ? body.accounts : [];
709
+ runtime.statusError = "";
710
+ } catch (error) {
711
+ runtime.statusError = safeMessage(error);
712
+ }
713
+ runtime.statusAt = Date.now();
714
+ return runtime.accounts;
715
+ };
716
+
717
+ // 上游可用模型清单:直接问被托管的 Go 进程要(它去向上游拉,带积分倍率与能力位)。
718
+ // 拉不到就保留上一次的结果,不影响已经注册好的模型。
719
+ runtime.fetchCatalog = async (force) => {
720
+ const ttl = 10 * 60 * 1000;
721
+ if (!force && runtime.catalog.length > 0 && Date.now() - runtime.catalogAt < ttl) return runtime.catalog;
722
+ if (runtime.port === 0) return runtime.catalog;
723
+ try {
724
+ const url = `${runtime.modelsBaseUrl()}/models${force ? "?refresh=1" : ""}`;
725
+ const { response, body } = await fetchJson(url, { headers: runtime.authHeaders() }, 20000);
726
+ if (!response.ok) throw new Error(`models ${response.status}`);
727
+ const rows = Array.isArray(body?.data) ? body.data : [];
728
+ const list = [];
729
+ for (const row of rows) {
730
+ const id = String(row?.id ?? "").trim();
731
+ if (id.length === 0) continue;
732
+ list.push({
733
+ id,
734
+ name: String(row?.name ?? "").trim() || id,
735
+ credits: String(row?.credits ?? "").trim(),
736
+ contextWindow: Number(row?.context_window ?? 0) || 0,
737
+ maxTokens: Number(row?.max_tokens ?? 0) || 0,
738
+ reasoning: row?.supports_reasoning === true,
739
+ images: row?.supports_images === true,
740
+ toolCall: row?.supports_tool_call === true,
741
+ });
742
+ }
743
+ if (list.length > 0) {
744
+ runtime.catalog = list;
745
+ runtime.catalogAt = Date.now();
746
+ }
747
+ runtime.catalogError = String(body?.error ?? "");
748
+ } catch (error) {
749
+ runtime.catalogError = safeMessage(error);
750
+ }
751
+ return runtime.catalog;
752
+ };
753
+
754
+ // 账号 × 上游模型 全拼接:每个组合一个 DSH 模型,名字是"模型名 · 账号名"。
755
+ // 模型 id 形如 glm-5.3-13800138000,Go 侧 splitModelNick 会把最后的纯数字后缀当账号别名,
756
+ // 剩下的 glm-5.3 才是真正发给上游的模型名。
757
+ runtime.refreshModels = async (force) => {
758
+ const accounts = await runtime.fetchStatus(force);
759
+ await runtime.fetchCatalog(force);
760
+ const catalog = runtime.catalog;
761
+ const files = await listAccountFiles(authsDir);
762
+ const byUid = new Map(files.map((item) => [item.account.uid, item.account]));
763
+ const used = new Set();
764
+ const perAccount = [];
765
+ const normalized = [];
766
+ const seen = new Set();
767
+ for (const account of accounts) {
768
+ if (String(account.platform ?? "") !== "workbuddy") continue;
769
+ const uid = String(account.uid ?? "");
770
+ if (uid.length === 0 || seen.has(uid)) continue;
771
+ seen.add(uid);
772
+ const { alias, rewrite } = aliasFor({ uid, nickname: account.nickname }, used);
773
+ used.add(alias);
774
+ const fileAccount = byUid.get(uid);
775
+ if (rewrite && fileAccount !== undefined && String(fileAccount.nickname ?? "") !== alias) {
776
+ try {
777
+ await writeAccountFile(authsDir, { ...fileAccount, nickname: alias });
778
+ normalized.push(alias);
779
+ } catch {
780
+ // 写不进去也不影响路由,只是这个账号不会被单独注册成模型。
781
+ }
782
+ }
783
+ perAccount.push({
784
+ alias,
785
+ uid,
786
+ accountName: String(account.nickname ?? "").trim() || alias,
787
+ accountKey: String(account.key ?? ""),
788
+ });
789
+ }
790
+
791
+ // 上游模型清单拉不到时,退回一个基础模型,至少保证面板里有东西可选。
792
+ const models_source =
793
+ catalog.length > 0 ? catalog : [{ id: UNIFIED_MODEL, name: UNIFIED_MODEL, credits: "" }];
794
+
795
+ // 首次拿到上游清单时,默认只开 deepseek-v4.1-flash:
796
+ // 一个是 DSH 里现成的默认模型就指着它,另一个是不至于一上来就塞几十个模型。
797
+ let selection = runtime.readSelection();
798
+ if (selection.seeded !== true && catalog.length > 0) {
799
+ if (!selection.enabledModels.includes(UNIFIED_MODEL)) {
800
+ selection.enabledModels.push(UNIFIED_MODEL);
801
+ }
802
+ selection = runtime.writeSelection({ ...selection, seeded: true });
803
+ }
804
+ runtime.selection = selection;
805
+ // 上游清单没拉到时不套白名单,否则会一个模型都不剩、连默认模型都用不了。
806
+ const filtering = catalog.length > 0;
807
+
808
+ const models = [];
809
+ for (const account of perAccount) {
810
+ if (!runtime.accountEnabled(account.uid, selection)) continue;
811
+ for (const item of models_source) {
812
+ if (filtering && !runtime.modelEnabled(item.id, selection)) continue;
813
+ models.push({
814
+ id: `${item.id}-${account.alias}`,
815
+ name: `${item.name} · ${account.accountName}`,
816
+ modelId: item.id,
817
+ modelName: item.name,
818
+ credits: String(item.credits ?? ""),
819
+ contextWindow: Number(item.contextWindow ?? 0) || runtime.config.contextWindow || DEFAULT_CONTEXT_WINDOW,
820
+ maxTokens: Number(item.maxTokens ?? 0) || runtime.config.maxTokens || DEFAULT_MAX_TOKENS,
821
+ reasoning: item.reasoning !== false,
822
+ images: item.images !== false,
823
+ alias: account.alias,
824
+ uid: account.uid,
825
+ accountName: account.accountName,
826
+ accountKey: account.accountKey,
827
+ });
828
+ }
829
+ }
830
+
831
+ runtime.models = models;
832
+ // 账号别名跟「启不启用」无关,面板上始终要显示这行的别名。
833
+ runtime.accountAliases = new Map(perAccount.map((item) => [item.uid, item.alias]));
834
+ runtime.accountsFingerprint = models.map((m) => `${m.id}:${m.uid}`).join(",");
835
+ const aliases = perAccount.map((m) => m.alias).sort().join(",");
836
+ if (normalized.length > 0 && runtime.aliases !== aliases) {
837
+ // 昵称被规范化过 → 需要重启才能让 Go 重新加载这些账号。
838
+ runtime.pendingAliasRestart = true;
839
+ }
840
+ runtime.aliases = aliases;
841
+ // 模型清单变了必须换一个新 Map:pi-ai 的模型快照是按引用比较的。
842
+ if (runtime.modelsFingerprint !== runtime.accountsFingerprint) {
843
+ runtime.modelsFingerprint = runtime.accountsFingerprint;
844
+ runtime.invalidate();
845
+ }
846
+ return models;
847
+ };
848
+
849
+ const accountByUid = async (uid) => {
850
+ const files = await listAccountFiles(authsDir);
851
+ const hit = files.find((item) => item.account.uid === uid);
852
+ return hit?.account;
853
+ };
854
+
855
+ runtime.accountByUid = accountByUid;
856
+
857
+ /* ------------------------------ 子进程 ------------------------------ */
858
+
859
+ runtime.env = () => {
860
+ const env = {};
861
+ for (const name of ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "SystemRoot", "windir", "USERPROFILE"]) {
862
+ const value = process.env[name];
863
+ if (typeof value === "string" && value.length > 0) env[name] = value;
864
+ }
865
+ env.MULTI2API_AUTH_DIR = authsDir;
866
+ env.MULTI2API_STATE_FILE = join(dataDir, "data", "state.json");
867
+ return env;
868
+ };
869
+
870
+ runtime.start = async () => {
871
+ if (runtime.stopped || runtime.starting) return;
872
+ runtime.starting = true;
873
+ try {
874
+ await runtime.prepare();
875
+ runtime.port = await findFreePort(runtime.config.port);
876
+ const configPath = join(dataDir, "config.json");
877
+ await writeFile(configPath, `${JSON.stringify(runtime.goConfig(), null, 2)}\n`, "utf8");
878
+ const exe = binPathForThisMachine();
879
+ const child = spawn(exe, ["-config", configPath], {
880
+ cwd: dataDir,
881
+ env: runtime.env(),
882
+ stdio: ["ignore", "pipe", "pipe"],
883
+ windowsHide: true,
884
+ });
885
+ runtime.child = child;
886
+ runtime.pid = child.pid ?? 0;
887
+ runtime.startedAt = Date.now();
888
+ runtime.lastError = "";
889
+ child.stdout?.on("data", (chunk) => runtime.appendLog(chunk));
890
+ child.stderr?.on("data", (chunk) => runtime.appendLog(chunk));
891
+ child.on("error", (error) => {
892
+ runtime.lastError = safeMessage(error);
893
+ runtime.appendLog(`spawn error: ${messageOf(error)}\n`);
894
+ });
895
+ child.on("exit", (code, signal) => {
896
+ runtime.appendLog(`multi2api exited code=${code} signal=${signal}\n`);
897
+ runtime.child = null;
898
+ runtime.pid = 0;
899
+ if (!runtime.stopped) {
900
+ runtime.restartCount += 1;
901
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(runtime.restartCount, 5));
902
+ const timer = setTimeout(() => {
903
+ if (!runtime.stopped) void runtime.start();
904
+ }, delay);
905
+ if (typeof timer.unref === "function") timer.unref();
906
+ }
907
+ });
908
+ await runtime.waitForHealth();
909
+ await runtime.refreshModels(true);
910
+ } catch (error) {
911
+ runtime.lastError = safeMessage(error);
912
+ ctx.logger.error("dsh-multi2api: 启动 multi2api 进程失败", error);
913
+ } finally {
914
+ runtime.starting = false;
915
+ }
916
+ };
917
+
918
+ runtime.waitForHealth = async () => {
919
+ for (let i = 0; i < 40; i += 1) {
920
+ if (runtime.stopped) return false;
921
+ try {
922
+ const response = await fetch(`${runtime.baseUrl()}/healthz`, { signal: timeoutSignal(2000) });
923
+ if (response.ok) return true;
924
+ } catch {
925
+ // 还没起来,继续等。
926
+ }
927
+ await new Promise((resolve) => setTimeout(resolve, 500));
928
+ }
929
+ runtime.lastError = "multi2api 启动后健康检查超时";
930
+ return false;
931
+ };
932
+
933
+ runtime.terminateChild = async () => {
934
+ const child = runtime.child;
935
+ if (child === null || child === undefined) return;
936
+ await new Promise((resolve) => {
937
+ const done = () => resolve();
938
+ child.once("exit", done);
939
+ try {
940
+ child.kill("SIGTERM");
941
+ } catch {
942
+ resolve();
943
+ return;
944
+ }
945
+ const timer = setTimeout(() => {
946
+ try {
947
+ child.kill("SIGKILL");
948
+ } catch {
949
+ // 已经退出。
950
+ }
951
+ resolve();
952
+ }, 5000);
953
+ if (typeof timer.unref === "function") timer.unref();
954
+ });
955
+ };
956
+
957
+ runtime.restart = async (reason) => {
958
+ runtime.appendLog(`restart requested: ${reason ?? "manual"}\n`);
959
+ // 先置 stopped,避免子进程退出回调把它当成崩溃又拉起一个。
960
+ runtime.stopped = true;
961
+ runtime.restartCount = 0;
962
+ await runtime.terminateChild();
963
+ runtime.stopped = false;
964
+ await runtime.start();
965
+ return { ok: true, pid: runtime.pid, port: runtime.port };
966
+ };
967
+
968
+ runtime.stop = async () => {
969
+ runtime.stopped = true;
970
+ await runtime.terminateChild();
971
+ };
972
+
973
+ runtime.checkinAll = async (uid) => {
974
+ const files = await listAccountFiles(runtime.authsDir);
975
+ const results = [];
976
+ for (const item of files) {
977
+ if (uid !== undefined && item.account.uid !== uid) continue;
978
+ const entry = { uid: item.account.uid, nickname: item.account.nickname };
979
+ try {
980
+ const checkin = await workbuddyCheckin(item.account);
981
+ entry.ok = true;
982
+ entry.already = checkin.already === true;
983
+ try {
984
+ entry.credits = await workbuddyCredit(item.account);
985
+ } catch (error) {
986
+ entry.creditsError = safeMessage(error);
987
+ }
988
+ } catch (error) {
989
+ entry.ok = false;
990
+ entry.error = safeMessage(error);
991
+ }
992
+ const today = new Date().toISOString().slice(0, 10);
993
+ runtime.checkinLog.set(item.account.uid, { date: today, ok: entry.ok === true, at: Date.now() });
994
+ results.push(entry);
995
+ }
996
+ await runtime.refreshModels(true);
997
+ return results;
998
+ };
999
+
1000
+ return runtime;
1001
+ }
1002
+
1003
+ /* ------------------------------------------------------------------ *
1004
+ * pi-ai 适配器
1005
+ * ------------------------------------------------------------------ */
1006
+
1007
+ function toPiModel(info, baseUrl, contextWindow, maxTokens) {
1008
+ const model = {
1009
+ id: info.id,
1010
+ name: info.name,
1011
+ api: "openai-completions",
1012
+ provider: PROVIDER,
1013
+ baseUrl,
1014
+ input: info.images === false ? ["text"] : ["text", "image"],
1015
+ reasoning: info.reasoning !== false,
1016
+ thinkingLevelMap: { ...THINKING_LEVEL_MAP },
1017
+ cost: { ...NO_COST },
1018
+ contextWindow: Number(info.contextWindow ?? 0) || contextWindow,
1019
+ maxTokens: Number(info.maxTokens ?? 0) || maxTokens,
1020
+ compat: { ...COMPAT },
1021
+ };
1022
+ // 账号级隔离:Go 侧优先认模型名后缀(reqNick),X-Account-Uid 作为第二保险。
1023
+ if (typeof info.uid === "string" && info.uid.length > 0) {
1024
+ model.headers = { "X-Account-Uid": info.uid };
1025
+ }
1026
+ return model;
1027
+ }
1028
+
1029
+ function createAdapter(runtime) {
1030
+ const buildModels = () => {
1031
+ const base = runtime.modelsBaseUrl();
1032
+ const contextWindow = runtime.config.contextWindow || DEFAULT_CONTEXT_WINDOW;
1033
+ const maxTokens = runtime.config.maxTokens || DEFAULT_MAX_TOKENS;
1034
+ const models = [
1035
+ toPiModel({ id: AUTO_MODEL_ID, name: "自动(积分最高的健康账号)", uid: "" }, base, contextWindow, maxTokens),
1036
+ ];
1037
+ for (const item of runtime.models) {
1038
+ models.push(toPiModel(item, base, contextWindow, maxTokens));
1039
+ }
1040
+ return models;
1041
+ };
1042
+
1043
+ const provider = {
1044
+ ...createProvider({
1045
+ id: PROVIDER,
1046
+ name: "Multi2API",
1047
+ auth: {
1048
+ apiKey: {
1049
+ name: "Multi2API",
1050
+ async resolve({ credential }) {
1051
+ const apiKey = credential?.key ?? runtime.apiKey;
1052
+ if (apiKey === undefined || apiKey.length === 0) return undefined;
1053
+ return { auth: { apiKey }, source: "Multi2API" };
1054
+ },
1055
+ },
1056
+ },
1057
+ models: buildModels(),
1058
+ api: openAICompletionsApi(),
1059
+ }),
1060
+ getModels: () => buildModels(),
1061
+ };
1062
+
1063
+ const profile = {
1064
+ provider,
1065
+ displayName: "Multi2API",
1066
+ streamIdleTimeoutMs: 300000,
1067
+ retryPolicy: resolveRetryPolicy(undefined, "Multi2API retryPolicy"),
1068
+ configuredMaxTokens: new Map(),
1069
+ modelErrors: new Map(),
1070
+ maxRequestImageBytes: 20971520,
1071
+ requestImagePixelBudget: 4194304,
1072
+ requestImageMaxBytes: 1048576,
1073
+ piProvider: provider,
1074
+ };
1075
+
1076
+ runtime.profile = profile;
1077
+ runtime.invalidate();
1078
+
1079
+ const adapter = new PiAiAdapter({
1080
+ profiles: () => runtime.profiles,
1081
+ resolveApiKey: async () => runtime.apiKey,
1082
+ auth: INERT_AUTH,
1083
+ });
1084
+
1085
+ return { adapter, provider, profile };
1086
+ }
1087
+
1088
+ /* ------------------------------------------------------------------ *
1089
+ * settings:拿到本插件在 profile 里的条目 id 当命名空间
1090
+ * ------------------------------------------------------------------ */
1091
+
1092
+ function entryNamespaceOf(ctx) {
1093
+ const fiber = ctx?.fiber;
1094
+ const candidates = [
1095
+ fiber?.entry?.options?.id,
1096
+ fiber?.entry?.id,
1097
+ typeof ctx?.loader?.locate === "function" ? ctx.loader.locate(fiber) : undefined,
1098
+ ];
1099
+ for (const candidate of candidates) {
1100
+ if (typeof candidate === "string" && candidate.length > 0) return candidate;
1101
+ }
1102
+ return undefined;
1103
+ }
1104
+
1105
+ function resolveSettingsNamespace(ctx) {
1106
+ return entryNamespaceOf(ctx) ?? SETTINGS_NS_FALLBACK;
1107
+ }
1108
+
1109
+ function installSettings(ctx, runtime) {
1110
+ try {
1111
+ ctx.inject(["settings"], (settingsCtx) => {
1112
+ const settings = settingsCtx.get("settings");
1113
+ if (settings === undefined) return;
1114
+ try {
1115
+ if (typeof settings.configure === "function") {
1116
+ settingsCtx.effect(() => {
1117
+ const dispose = settings.configure({ auto: false }, ctx.fiber);
1118
+ return typeof dispose === "function" ? dispose : () => {};
1119
+ }, "dsh-multi2api: settings section");
1120
+ } else if (typeof settings.installSection === "function") {
1121
+ settingsCtx.effect(
1122
+ () => settings.installSection(runtime.settingsNs, Config, {}),
1123
+ "dsh-multi2api: settings section",
1124
+ );
1125
+ }
1126
+ } catch (error) {
1127
+ ctx.logger.warn("dsh-multi2api: 注册设置项失败(不影响使用)", error);
1128
+ }
1129
+ });
1130
+ } catch (error) {
1131
+ ctx.logger.warn("dsh-multi2api: settings 服务不可用(不影响使用)", error);
1132
+ }
1133
+ }
1134
+
1135
+ /* ------------------------------------------------------------------ *
1136
+ * Web 路由
1137
+ * ------------------------------------------------------------------ */
1138
+
1139
+ function registerRoutes(ctx, runtime) {
1140
+ const routes = [];
1141
+ const add = (path, handler) => {
1142
+ routes.push(ctx.webServer.register({ kind: "exact", path: `${ROUTE_PREFIX}${path}`, handler }));
1143
+ };
1144
+
1145
+ const guardGet = (req, res) => {
1146
+ if (req.method !== "GET") {
1147
+ json(res, 405, { error: "method not allowed" });
1148
+ return false;
1149
+ }
1150
+ if (!loopbackRequest(req)) {
1151
+ json(res, 403, { error: "request-not-trusted" });
1152
+ return false;
1153
+ }
1154
+ return true;
1155
+ };
1156
+
1157
+ const summarizeAccounts = () =>
1158
+ runtime.accounts.map((account) => {
1159
+ const uid = String(account.uid ?? "");
1160
+ const model = runtime.models.find((item) => item.uid === uid);
1161
+ const alias = model?.alias ?? runtime.accountAliases?.get(uid) ?? "";
1162
+ const checkin = runtime.checkinLog.get(uid);
1163
+ const today = new Date().toISOString().slice(0, 10);
1164
+ return {
1165
+ uid,
1166
+ nickname: String(account.nickname ?? ""),
1167
+ displayName: String(account.nickname ?? "").trim() || alias || uid.slice(0, 8),
1168
+ alias,
1169
+ modelId: model?.id ?? "",
1170
+ credits: Number(account.credits ?? 0),
1171
+ cooling: account.cooling === true,
1172
+ until: String(account.until ?? ""),
1173
+ reason: String(account.reason ?? ""),
1174
+ disabled: account.disabled === true,
1175
+ enabled: runtime.accountEnabled(uid, runtime.selection),
1176
+ errCount: Number(account.err_count ?? 0),
1177
+ checkedInToday: checkin !== undefined && checkin.date === today,
1178
+ };
1179
+ });
1180
+
1181
+ // GET /status
1182
+ add("/status", async (req, res) => {
1183
+ if (!guardGet(req, res)) return;
1184
+ try {
1185
+ const force = String(req.url ?? "").includes("refresh=1");
1186
+ await runtime.refreshModels(force);
1187
+ json(res, 200, {
1188
+ running: runtime.pid > 0,
1189
+ pid: runtime.pid,
1190
+ port: runtime.port,
1191
+ baseUrl: runtime.port > 0 ? runtime.baseUrl() : "",
1192
+ dataDir: runtime.dataDir,
1193
+ startedAt: runtime.startedAt,
1194
+ error: runtime.lastError,
1195
+ statusError: runtime.statusError,
1196
+ catalogError: runtime.catalogError,
1197
+ accounts: summarizeAccounts(),
1198
+ catalog: runtime.catalog.map((item) => ({
1199
+ id: item.id,
1200
+ name: item.name,
1201
+ credits: item.credits,
1202
+ contextWindow: item.contextWindow,
1203
+ maxTokens: item.maxTokens,
1204
+ reasoning: item.reasoning,
1205
+ images: item.images,
1206
+ toolCall: item.toolCall,
1207
+ enabled: runtime.modelEnabled(item.id, runtime.selection),
1208
+ })),
1209
+ models: runtime.models.map((item) => ({
1210
+ id: item.id,
1211
+ name: item.name,
1212
+ modelId: item.modelId,
1213
+ modelName: item.modelName,
1214
+ credits: item.credits,
1215
+ uid: item.uid,
1216
+ alias: item.alias,
1217
+ accountName: item.accountName,
1218
+ })),
1219
+ autoModelId: AUTO_MODEL_ID,
1220
+ });
1221
+ } catch (error) {
1222
+ json(res, 500, { error: safeMessage(error) });
1223
+ }
1224
+ });
1225
+
1226
+ // GET /logs
1227
+ add("/logs", (req, res) => {
1228
+ if (!guardGet(req, res)) return;
1229
+ const url = new URL(String(req.url ?? "/"), "http://127.0.0.1");
1230
+ const lines = Math.max(10, Math.min(2000, Number(url.searchParams.get("lines") ?? runtime.config.logLines) || 300));
1231
+ const text = runtime.logs.join("");
1232
+ const all = text.split("\n");
1233
+ json(res, 200, { text: all.slice(-lines).join("\n") });
1234
+ });
1235
+
1236
+ // POST /check-in 只给内部用(启动自动签到);面板不再暴露单个或全部签到入口。
1237
+ add("/check-in", async (req, res) => {
1238
+ if (req.method !== "POST") {
1239
+ json(res, 405, { error: "method not allowed" });
1240
+ return;
1241
+ }
1242
+ if (!checkLoopbackPost(req, res)) return;
1243
+ try {
1244
+ const body = await readBody(req);
1245
+ const uid = typeof body?.uid === "string" && body.uid.length > 0 ? body.uid : undefined;
1246
+ const results = await runtime.checkinAll(uid);
1247
+ json(res, 200, { ok: true, results });
1248
+ } catch (error) {
1249
+ json(res, 200, { ok: false, error: safeMessage(error) });
1250
+ }
1251
+ });
1252
+
1253
+ // GET /settings —— 面板读开关状态。
1254
+ add("/settings", (req, res) => {
1255
+ if (!guardGet(req, res)) return;
1256
+ const selection = runtime.selection ?? runtime.readSelection();
1257
+ json(res, 200, {
1258
+ ok: true,
1259
+ autoCheckin: runtime.readAutoCheckin(),
1260
+ selection: {
1261
+ seeded: selection.seeded === true,
1262
+ enabledModels: selection.enabledModels,
1263
+ disabledAccounts: selection.disabledAccounts,
1264
+ },
1265
+ });
1266
+ });
1267
+
1268
+ // POST /selection/save —— 面板勾选「启用哪些模型 / 关掉哪些账号」。
1269
+ // body: {kind:"model", id, enabled} 或 {kind:"account", uid, enabled}
1270
+ add("/selection/save", async (req, res) => {
1271
+ if (req.method !== "POST") {
1272
+ json(res, 405, { error: "method not allowed" });
1273
+ return;
1274
+ }
1275
+ if (!checkLoopbackPost(req, res)) return;
1276
+ try {
1277
+ const body = await readBody(req);
1278
+ const kind = String(body?.kind ?? "");
1279
+ const enabled = body?.enabled === true;
1280
+ const current = runtime.selection ?? runtime.readSelection();
1281
+ const enabledModels = new Set(current.enabledModels);
1282
+ const disabledAccounts = new Set(current.disabledAccounts);
1283
+
1284
+ if (kind === "model") {
1285
+ const id = String(body?.id ?? "");
1286
+ if (id.length === 0) {
1287
+ json(res, 400, { ok: false, error: "缺少模型 id" });
1288
+ return;
1289
+ }
1290
+ if (enabled) enabledModels.add(id);
1291
+ else enabledModels.delete(id);
1292
+ } else if (kind === "account") {
1293
+ const uid = String(body?.uid ?? "");
1294
+ if (uid.length === 0) {
1295
+ json(res, 400, { ok: false, error: "缺少账号 uid" });
1296
+ return;
1297
+ }
1298
+ if (enabled) disabledAccounts.delete(uid);
1299
+ else disabledAccounts.add(uid);
1300
+ } else {
1301
+ json(res, 400, { ok: false, error: "kind 只能是 model 或 account" });
1302
+ return;
1303
+ }
1304
+
1305
+ runtime.selection = runtime.writeSelection({
1306
+ seeded: true,
1307
+ enabledModels: [...enabledModels],
1308
+ disabledAccounts: [...disabledAccounts],
1309
+ });
1310
+ // 清单变了,立刻重建模型列表并换掉 pi-ai 的快照(它按引用比较)。
1311
+ await runtime.refreshModels(true);
1312
+ runtime.invalidate();
1313
+ json(res, 200, {
1314
+ ok: true,
1315
+ selection: {
1316
+ seeded: true,
1317
+ enabledModels: runtime.selection.enabledModels,
1318
+ disabledAccounts: runtime.selection.disabledAccounts,
1319
+ },
1320
+ modelCount: runtime.models.length,
1321
+ });
1322
+ } catch (error) {
1323
+ json(res, 200, { ok: false, error: safeMessage(error) });
1324
+ }
1325
+ });
1326
+
1327
+ // POST /settings —— 面板拨开关。只写一个小文件,不重启进程。
1328
+ add("/settings/save", async (req, res) => {
1329
+ if (req.method !== "POST") {
1330
+ json(res, 405, { error: "method not allowed" });
1331
+ return;
1332
+ }
1333
+ if (!checkLoopbackPost(req, res)) return;
1334
+ try {
1335
+ const body = await readBody(req);
1336
+ const enabled = runtime.writeAutoCheckin(body?.autoCheckin === true);
1337
+ // 开关落盘后重启被托管进程,让 Go 侧按新配置重建(开=重启后补签一次,关=不再注册签到任务)。
1338
+ void runtime.restart("auto checkin changed");
1339
+ json(res, 200, { ok: true, autoCheckin: enabled });
1340
+ } catch (error) {
1341
+ json(res, 200, { ok: false, error: safeMessage(error) });
1342
+ }
1343
+ });
1344
+
1345
+ // POST /restart
1346
+ add("/restart", async (req, res) => {
1347
+ if (req.method !== "POST") {
1348
+ json(res, 405, { error: "method not allowed" });
1349
+ return;
1350
+ }
1351
+ if (!checkLoopbackPost(req, res)) return;
1352
+ try {
1353
+ const result = await runtime.restart("panel");
1354
+ json(res, 200, { ok: true, ...result });
1355
+ } catch (error) {
1356
+ json(res, 200, { ok: false, error: safeMessage(error) });
1357
+ }
1358
+ });
1359
+
1360
+ // POST /accounts/delete body: {uid}
1361
+ add("/accounts/delete", async (req, res) => {
1362
+ if (req.method !== "POST") {
1363
+ json(res, 405, { error: "method not allowed" });
1364
+ return;
1365
+ }
1366
+ if (!checkLoopbackPost(req, res)) return;
1367
+ try {
1368
+ const body = await readBody(req);
1369
+ const uid = String(body?.uid ?? "");
1370
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(uid)) {
1371
+ json(res, 400, { ok: false, error: "uid 不合法" });
1372
+ return;
1373
+ }
1374
+ await rm(join(runtime.authsDir, `workbuddy-${uid}.json`), { force: true });
1375
+ const result = await runtime.restart("account deleted");
1376
+ json(res, 200, { ok: true, ...result });
1377
+ } catch (error) {
1378
+ json(res, 200, { ok: false, error: safeMessage(error) });
1379
+ }
1380
+ });
1381
+
1382
+ // POST /accounts/import body: {doc} | {text} | {path}
1383
+ add("/accounts/import", async (req, res) => {
1384
+ if (req.method !== "POST") {
1385
+ json(res, 405, { error: "method not allowed" });
1386
+ return;
1387
+ }
1388
+ if (!checkLoopbackPost(req, res)) return;
1389
+ try {
1390
+ const body = await readBody(req, IMPORT_BODY_LIMIT);
1391
+ const imported = [];
1392
+ const failed = [];
1393
+ const rawList = [];
1394
+ if (typeof body?.path === "string" && body.path.length > 0) {
1395
+ const target = body.path;
1396
+ const statModule = await import("node:fs/promises");
1397
+ const info = await statModule.stat(target);
1398
+ if (info.isDirectory()) {
1399
+ const names = await readdir(target);
1400
+ for (const name of names) {
1401
+ if (!name.endsWith(".json")) continue;
1402
+ rawList.push(await readFile(join(target, name), "utf8"));
1403
+ }
1404
+ } else {
1405
+ rawList.push(await readFile(target, "utf8"));
1406
+ }
1407
+ } else if (Array.isArray(body?.docs)) {
1408
+ for (const doc of body.docs) rawList.push(doc);
1409
+ } else if (body?.doc !== undefined) {
1410
+ rawList.push(body.doc);
1411
+ } else if (typeof body?.text === "string") {
1412
+ rawList.push(body.text);
1413
+ }
1414
+ if (rawList.length === 0) {
1415
+ json(res, 400, { ok: false, error: "没有可导入的内容" });
1416
+ return;
1417
+ }
1418
+ for (const raw of rawList) {
1419
+ try {
1420
+ const account = parseAccountDoc(raw);
1421
+ if (account.uid.length === 0) {
1422
+ // 没有 uid 时无法定位账号文件,跳过(Go 侧也需要 uid 做挑号)。
1423
+ failed.push({ error: "缺少 uid,无法导入" });
1424
+ continue;
1425
+ }
1426
+ await writeAccountFile(runtime.authsDir, account);
1427
+ imported.push({ uid: account.uid, nickname: account.nickname });
1428
+ } catch (error) {
1429
+ failed.push({ error: safeMessage(error) });
1430
+ }
1431
+ }
1432
+ if (imported.length > 0) await runtime.restart("accounts imported");
1433
+ json(res, 200, { ok: imported.length > 0, imported, failed });
1434
+ } catch (error) {
1435
+ json(res, 200, { ok: false, error: safeMessage(error) });
1436
+ }
1437
+ });
1438
+
1439
+ // POST /login/start
1440
+ add("/login/start", async (req, res) => {
1441
+ if (req.method !== "POST") {
1442
+ json(res, 405, { error: "method not allowed" });
1443
+ return;
1444
+ }
1445
+ if (!checkLoopbackPost(req, res)) return;
1446
+ try {
1447
+ const started = await workbuddyLoginStart();
1448
+ runtime.pendingLogins.set(started.state, Date.now());
1449
+ // 清理超过 10 分钟还没完成的登录。
1450
+ for (const [state, at] of runtime.pendingLogins) {
1451
+ if (Date.now() - at > 10 * 60 * 1000) runtime.pendingLogins.delete(state);
1452
+ }
1453
+ json(res, 200, { ok: true, authUrl: started.authUrl, state: started.state });
1454
+ } catch (error) {
1455
+ json(res, 200, { ok: false, error: safeMessage(error) });
1456
+ }
1457
+ });
1458
+
1459
+ // POST /login/poll body: {state}
1460
+ add("/login/poll", async (req, res) => {
1461
+ if (req.method !== "POST") {
1462
+ json(res, 405, { error: "method not allowed" });
1463
+ return;
1464
+ }
1465
+ if (!checkLoopbackPost(req, res)) return;
1466
+ try {
1467
+ const body = await readBody(req);
1468
+ const state = String(body?.state ?? "");
1469
+ if (state.length === 0) {
1470
+ json(res, 400, { ok: false, error: 'expected {"state": string}' });
1471
+ return;
1472
+ }
1473
+ const token = await workbuddyLoginPoll(state);
1474
+ if (token === undefined) {
1475
+ json(res, 200, { ok: true, done: false });
1476
+ return;
1477
+ }
1478
+ let account = { uid: "", enterpriseId: "", nickname: "" };
1479
+ try {
1480
+ account = await workbuddyFetchAccount(state, token.accessToken);
1481
+ } catch {
1482
+ // 拿不到资料也要把号存下来(与 Go 登录命令行为一致)。
1483
+ }
1484
+ if (account.uid.length === 0) {
1485
+ json(res, 200, { ok: false, done: true, error: "拿到了 token 但读不到 uid,请重试登录" });
1486
+ return;
1487
+ }
1488
+ const expiresAt = token.expiresIn > 0 ? Math.floor(Date.now() / 1000) + token.expiresIn : 0;
1489
+ const saved = {
1490
+ platform: "workbuddy",
1491
+ accessToken: token.accessToken,
1492
+ refreshToken: token.refreshToken,
1493
+ expiresAt,
1494
+ domain: token.domain,
1495
+ uid: account.uid,
1496
+ enterpriseId: account.enterpriseId,
1497
+ nickname: account.nickname,
1498
+ };
1499
+ await writeAccountFile(runtime.authsDir, saved);
1500
+ runtime.pendingLogins.delete(state);
1501
+ let credits;
1502
+ try {
1503
+ credits = await workbuddyCredit(saved);
1504
+ } catch {
1505
+ credits = undefined;
1506
+ }
1507
+ await runtime.restart("account added");
1508
+ json(res, 200, {
1509
+ ok: true,
1510
+ done: true,
1511
+ account: { uid: saved.uid, nickname: saved.nickname, credits },
1512
+ });
1513
+ } catch (error) {
1514
+ json(res, 200, { ok: false, done: true, error: safeMessage(error) });
1515
+ }
1516
+ });
1517
+
1518
+ // POST /refresh-models —— 面板点「刷新模型」时强制重拉上游清单。
1519
+ add("/refresh-models", async (req, res) => {
1520
+ if (req.method !== "POST") {
1521
+ json(res, 405, { error: "method not allowed" });
1522
+ return;
1523
+ }
1524
+ if (!checkLoopbackPost(req, res)) return;
1525
+ try {
1526
+ await runtime.fetchCatalog(true);
1527
+ await runtime.refreshModels(true);
1528
+ json(res, 200, {
1529
+ ok: true,
1530
+ catalog: runtime.catalog,
1531
+ models: runtime.models.map((item) => ({ id: item.id, name: item.name, credits: item.credits })),
1532
+ });
1533
+ } catch (error) {
1534
+ json(res, 200, { ok: false, error: safeMessage(error) });
1535
+ }
1536
+ });
1537
+
1538
+ return () => {
1539
+ for (const dispose of routes) {
1540
+ try {
1541
+ dispose();
1542
+ } catch {
1543
+ // 已经被释放。
1544
+ }
1545
+ }
1546
+ };
1547
+ }
1548
+
1549
+ /* ------------------------------------------------------------------ *
1550
+ * 配置
1551
+ * ------------------------------------------------------------------ */
1552
+
1553
+ export const Config = z.object({
1554
+ dataDir: z
1555
+ .string()
1556
+ .description("数据目录(账号池、配置、日志都放这里)。留空 = ~/.dsh/multi2api")
1557
+ .default("")
1558
+ .volatile(),
1559
+ port: z.natural().description("本地监听端口(被占用时自动往后找空闲端口)").default(7864).volatile(),
1560
+ contextWindow: z.natural().description("模型上下文窗口").default(DEFAULT_CONTEXT_WINDOW).volatile(),
1561
+ maxTokens: z.natural().description("单次回复最大 token 数").default(DEFAULT_MAX_TOKENS).volatile(),
1562
+ statusRefreshSeconds: z.natural().description("账号状态刷新间隔(秒)").default(30).volatile(),
1563
+ logLines: z.natural().description("日志面板默认显示行数").default(300).volatile(),
1564
+ autoStart: z.boolean().description("插件加载时自动启动 multi2api 进程").default(true).volatile(),
1565
+ autoCheckin: z
1566
+ .boolean()
1567
+ .description("自动签到:开启后每次启动 DSH 都会给所有账号签到一次")
1568
+ .default(true)
1569
+ .volatile(),
1570
+ });
1571
+
1572
+ function unwrapValue(value, fallback) {
1573
+ const raw = value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
1574
+ return raw === undefined || raw === null ? fallback : raw;
1575
+ }
1576
+
1577
+ function readConfig(raw) {
1578
+ const source = raw ?? {};
1579
+ const configuredDir = String(unwrapValue(source.dataDir, "")).trim();
1580
+ const defaultDir = join(resolveDshHome(), "multi2api");
1581
+ const port = Number(unwrapValue(source.port, 7864));
1582
+ const contextWindow = Number(unwrapValue(source.contextWindow, DEFAULT_CONTEXT_WINDOW));
1583
+ const maxTokens = Number(unwrapValue(source.maxTokens, DEFAULT_MAX_TOKENS));
1584
+ const statusRefreshSeconds = Number(unwrapValue(source.statusRefreshSeconds, 30));
1585
+ const logLines = Number(unwrapValue(source.logLines, 300));
1586
+ const autoStart = Boolean(unwrapValue(source.autoStart, true));
1587
+ const autoCheckin = Boolean(unwrapValue(source.autoCheckin, true));
1588
+ return {
1589
+ dataDir: configuredDir.length > 0 ? configuredDir : defaultDir,
1590
+ port: Number.isFinite(port) && port > 0 ? Math.floor(port) : 7864,
1591
+ contextWindow: Number.isFinite(contextWindow) && contextWindow > 0 ? Math.floor(contextWindow) : DEFAULT_CONTEXT_WINDOW,
1592
+ maxTokens: Number.isFinite(maxTokens) && maxTokens > 0 ? Math.floor(maxTokens) : DEFAULT_MAX_TOKENS,
1593
+ statusRefreshSeconds:
1594
+ Number.isFinite(statusRefreshSeconds) && statusRefreshSeconds > 0 ? Math.floor(statusRefreshSeconds) : 30,
1595
+ logLines: Number.isFinite(logLines) && logLines > 0 ? Math.floor(logLines) : 300,
1596
+ autoStart,
1597
+ autoCheckin,
1598
+ };
1599
+ }
1600
+
1601
+ /* ------------------------------------------------------------------ *
1602
+ * 入口
1603
+ * ------------------------------------------------------------------ */
1604
+
1605
+ export const name = PLUGIN_NAME;
1606
+ export const inject = ["llm"];
1607
+
1608
+ export function apply(ctx, rawConfig) {
1609
+ const config = readConfig(rawConfig);
1610
+ const runtime = createRuntime(ctx, config);
1611
+
1612
+ installSettings(ctx, runtime);
1613
+
1614
+ // 动态模型列表:后台定时刷新账号 → 模型映射;getModels() 每次读当前快照。
1615
+ ctx.effect(() => {
1616
+ const timer = setInterval(() => {
1617
+ void runtime.refreshModels(false);
1618
+ }, Math.max(5, config.statusRefreshSeconds) * 1000);
1619
+ if (typeof timer.unref === "function") timer.unref();
1620
+ return () => clearInterval(timer);
1621
+ }, "dsh-multi2api: 模型列表定时刷新");
1622
+
1623
+ // webServer 惰性注入:拿不到就不注册面板接口,不影响模型转发。
1624
+ ctx.inject(["webServer"], (webCtx) => {
1625
+ const dispose = registerRoutes(webCtx, runtime);
1626
+ webCtx.effect(() => dispose, "dsh-multi2api: Web 管理接口");
1627
+ });
1628
+
1629
+ // 进程生命周期跟着插件走。
1630
+ ctx.effect(
1631
+ () => () => {
1632
+ void runtime.stop();
1633
+ },
1634
+ "dsh-multi2api: 停止被托管的进程",
1635
+ );
1636
+
1637
+ if (config.autoStart) {
1638
+ // 启动流程:先把被托管进程拉起来(内部会等健康检查通过),再按开关给所有账号签到一次。
1639
+ void (async () => {
1640
+ try {
1641
+ await runtime.start();
1642
+ if (runtime.readAutoCheckin()) await runtime.checkinAll();
1643
+ } catch (error) {
1644
+ ctx.logger.warn("dsh-multi2api: 启动自动签到失败", error);
1645
+ }
1646
+ })();
1647
+ }
1648
+
1649
+ // 注册 pi-ai 供应商路由。
1650
+ try {
1651
+ const { adapter } = createAdapter(runtime);
1652
+ const releaseAdapter = ctx.llm.registerAdapter([PROVIDER], adapter);
1653
+ const releaseDirectory = ctx.llm.registerConfigurableProviders([
1654
+ {
1655
+ provider: PROVIDER,
1656
+ displayName: "Multi2API",
1657
+ settingsNs: runtime.settingsNs,
1658
+ settingsPath: [],
1659
+ declared: false,
1660
+ },
1661
+ ]);
1662
+ try {
1663
+ ctx.effect(
1664
+ () => () => {
1665
+ releaseAdapter();
1666
+ releaseDirectory();
1667
+ },
1668
+ "dsh-multi2api: 供应商注册",
1669
+ );
1670
+ } catch (error) {
1671
+ releaseAdapter();
1672
+ releaseDirectory();
1673
+ throw error;
1674
+ }
1675
+ } catch (error) {
1676
+ ctx.logger.error("dsh-multi2api: 注册供应商失败", error);
1677
+ }
1678
+ }