mslxdff 0.1.66 → 0.1.67

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.
@@ -1,408 +1 @@
1
- import { join, dirname } from "node:path";
2
- import { fileURLToPath } from "node:url";
3
- import { startServer } from "../server.js";
4
- import { defaultStateFile } from "../state.js";
5
- import { createRouter } from "../routes.js";
6
- import { createUpstreamClient } from "../upstream.js";
7
- import { createModelsService } from "../models.js";
8
- import { loadToken } from "../state.js";
9
- import { createAutoSelector } from "../auto.js";
10
- import { createPeersService } from "../peers.js";
11
- import { createEventBus } from "../events.js";
12
- import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../groups.js";
13
- import { logDir, appendCall, appendError, appendEvent } from "../logs.js";
14
- import { loadPlugins, runHook, resolvePluginDirs } from "../plugins.js";
15
- import { createOpenCodeProvider } from "../providers/opencode.js";
16
- import { loadProviderKeys, loadProviderAuths, loadProviderConfigs } from "../state.js";
17
- import { effectiveHost, refreshIntervalMs, modelCooldownMs, slowCooldownMs, peerCooldownMs, peerHeatMs, maxHopsValue, groupSyncIntervalMs, autoUpdateIntervalMs, banWindowMs, banThreshold } from "./policy.js";
18
- import { fmtEvent } from "./format.js";
19
- import { errMsg, npmCmd, run } from "./util.js";
20
- import { loadGroupsJoined } from "../state.js";
21
- import { writePid } from "../daemon.js";
22
- import { resolvePort } from "../server.js";
23
-
24
- const logs = { appendCall, appendError, appendEvent };
25
-
26
- export async function startDaemonMain(VERSION) {
27
- const { token, created } = await loadToken();
28
- const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
29
- // src/cli/bootstrap.js -> 2 levels up to src, then 1 to root
30
- const pkgRoot2 = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
31
- const pluginDirs = resolvePluginDirs({ pkgRoot: pkgRoot2 });
32
- const { plugins: loadedPlugins, errors: pluginErrors } = await loadPlugins({ dirs: pluginDirs });
33
- for (const e of pluginErrors) {
34
- console.log(`plugin load failed: ${e.file} — ${e.error}`);
35
- appendEvent({ ts: Date.now(), type: "plugin-load-error", file: e.file, error: e.error });
36
- }
37
- if (loadedPlugins.length) {
38
- console.log(`plugins loaded (${loadedPlugins.length}): ${loadedPlugins.map((p) => `${p.name}${p.version ? `@${p.version}` : ""}`).join(", ")}`);
39
- appendEvent({ ts: Date.now(), type: "plugins-loaded", plugins: loadedPlugins.map((p) => ({ name: p.name, version: p.version })) });
40
- }
41
- const upstreamHooks = loadedPlugins.length
42
- ? (name, ctx) => runHook(loadedPlugins, name, ctx)
43
- : null;
44
- const providerPlugin = loadedPlugins.find((p) => typeof p.createUpstream === "function");
45
- let upstream;
46
- const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
47
- let providers = [];
48
- if (providerPlugin) {
49
- if (!upstream) {
50
- try {
51
- upstream = await providerPlugin.createUpstream({ baseUrl, authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
52
- console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
53
- appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
54
- } catch (err) {
55
- console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} — falling back to default`);
56
- appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
57
- }
58
- }
59
- if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
60
- } else {
61
- const opencodeClient = createUpstreamClient({ hooks: upstreamHooks });
62
- const opencodeModels = createModelsService({
63
- baseUrl,
64
- headers: opencodeClient.headers,
65
- refreshMs: refreshIntervalMs(),
66
- cacheFile: join(logDir(), "models.json"),
67
- });
68
- providers.push(createOpenCodeProvider({ upstream: opencodeClient, modelsService: opencodeModels }));
69
- const orKeys = loadProviderKeys("openrouter");
70
- if (orKeys.length) {
71
- const { createOpenRouterProvider } = await import("../providers/openrouter.js");
72
- providers.push(createOpenRouterProvider({ apiKeys: orKeys }));
73
- console.log(`provider enabled: openrouter (${orKeys.length} key${orKeys.length > 1 ? "s" : ""})`);
74
- appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "openrouter", keys: orKeys.length });
75
- }
76
- const genericConfigs = loadProviderConfigs();
77
- for (const [gid, cfg] of Object.entries(genericConfigs)) {
78
- if (gid === "opencode" || gid === "openrouter") continue;
79
- if (gid === "workbuddy") {
80
- const base = String(cfg?.baseUrl || "").trim() || "https://copilot.tencent.com";
81
- const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
82
- const auths = Array.isArray(cfg?.auths) ? cfg.auths : [];
83
- if (!keys.length) continue;
84
- try {
85
- const { createWorkbuddyProvider } = await import("../providers/workbuddy.js");
86
- providers.push(createWorkbuddyProvider({ baseUrl: base, apiKeys: keys, auths }));
87
- console.log(`provider enabled: workbuddy (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
88
- appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
89
- } catch (err) {
90
- console.log(`provider ${gid} failed: ${err?.message || err}`);
91
- appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
92
- }
93
- continue;
94
- }
95
- const base = String(cfg?.baseUrl || "").trim();
96
- const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
97
- if (!base || !keys.length) continue;
98
- try {
99
- const { createGenericProvider } = await import("../providers/generic.js");
100
- providers.push(createGenericProvider({ id: gid, baseUrl: base, apiKeys: keys }));
101
- console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
102
- appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
103
- } catch (err) {
104
- console.log(`provider ${gid} failed: ${err?.message || err}`);
105
- appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
106
- }
107
- }
108
- if (!genericConfigs["workbuddy"]) {
109
- const wbKeys = loadProviderKeys("workbuddy");
110
- if (wbKeys.length) {
111
- try {
112
- const { createWorkbuddyProvider } = await import("../providers/workbuddy.js");
113
- const wbAuths = loadProviderAuths("workbuddy");
114
- providers.push(createWorkbuddyProvider({ apiKeys: wbKeys, auths: wbAuths }));
115
- console.log(`provider enabled: workbuddy (${wbKeys.length} key${wbKeys.length > 1 ? "s" : ""}) baseUrl=https://copilot.tencent.com (env)`);
116
- appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "workbuddy", keys: wbKeys.length, baseUrl: "https://copilot.tencent.com" });
117
- } catch (err) {
118
- console.log(`provider workbuddy failed: ${err?.message || err}`);
119
- }
120
- }
121
- }
122
- const { createProviderDispatcher } = await import("../providers/dispatcher.js");
123
- upstream = createProviderDispatcher(providers);
124
- appendEvent({ ts: Date.now(), type: "providers", providers: providers.map((p) => p.id) });
125
- }
126
- const opencodeProvider = providers.find((p) => p.id === "opencode");
127
- const models = createModelsService({
128
- providers: providers.length > 1 ? providers : undefined,
129
- baseUrl,
130
- headers: upstream.headers || opencodeProvider?.upstream?.headers,
131
- refreshMs: refreshIntervalMs(),
132
- cacheFile: join(logDir(), "models.json"),
133
- });
134
- const auto = createAutoSelector({
135
- cooldownMs: modelCooldownMs(),
136
- slowCooldownMs: slowCooldownMs(),
137
- file: defaultStateFile(),
138
- loadCandidates: async () => {
139
- try {
140
- return (await models.get()).data.map((m) => m.id);
141
- } catch {
142
- return null;
143
- }
144
- },
145
- });
146
- const peers = createPeersService({ cooldownMs: peerCooldownMs(), heatMs: peerHeatMs() });
147
- const groups = createGroupsService({});
148
- const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshold() });
149
-
150
- const isDebug = process.env.MSLXDFF_DEBUG === "1";
151
- const bus = createEventBus();
152
-
153
- try {
154
- const { loadModelAliases } = await import("../providers/model-id.js");
155
- loadModelAliases();
156
- } catch {}
157
-
158
- const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus, plugins: loadedPlugins });
159
- const listenHost = effectiveHost();
160
- const srv = startServer({
161
- router,
162
- signals: !isDebug,
163
- host: listenHost,
164
- onBeforeClose: loadedPlugins.length
165
- ? () => runHook(loadedPlugins, "server:stop", { version: VERSION }).then(() => {})
166
- : undefined,
167
- });
168
-
169
- if (isDebug) {
170
- bus.subscribe((e) => {
171
- try {
172
- console.log(fmtEvent(e));
173
- } catch {}
174
- });
175
- const { startDaemon: sd } = await import("../daemon.js");
176
- const restore2 = () => {
177
- console.log("\n[debug] restoring background daemon...");
178
- try {
179
- const restoredPid = sd([]);
180
- console.log(`[debug] daemon restored (pid ${restoredPid})`);
181
- } catch (err) {
182
- console.error(`[debug] could not restore daemon: ${err.message}`);
183
- }
184
- setTimeout(() => process.exit(0), 300);
185
- };
186
- process.on("SIGINT", restore2);
187
- process.on("SIGTERM", restore2);
188
- }
189
-
190
- await srv.ready();
191
-
192
- if (loadedPlugins.length) {
193
- runHook(loadedPlugins, "server:start", { port: srv.server.address()?.port, host: listenHost, version: VERSION }).catch(() => {});
194
- const eventPlugins = loadedPlugins.filter((p) => typeof p.onEvent === "function");
195
- if (eventPlugins.length) {
196
- bus.subscribe((e) => {
197
- for (const p of eventPlugins) {
198
- try { p.onEvent(e); } catch {}
199
- }
200
- });
201
- }
202
- }
203
-
204
- setTimeout(() => {
205
- upstream.preheat().then((r) => {
206
- const entry = { ts: Date.now(), type: "upstream-preheat", ...r, baseUrl };
207
- try { bus.emit(entry); } catch {}
208
- try { logs.appendEvent(entry); } catch {}
209
- if (r.skipped) console.log(`[preheat] skipped (MSLXDFF_PREHEAT disabled)`);
210
- else if (r.ok) console.log(`[preheat] opencode models ok ${r.status} ${r.ms}ms`);
211
- else console.log(`[preheat] opencode models failed ${r.error || r.status || ""} ${r.ms || 0}ms`);
212
- }).catch(() => {});
213
- }, 100).unref?.();
214
-
215
- models.startAutoRefresh();
216
- if (process.env.MSLXDFF_DAEMON) {
217
- writePid(process.pid, VERSION);
218
- }
219
- const addr = srv.server.address();
220
- const host = addr.address === "0.0.0.0" || addr.address === "::" ? "localhost" : addr.address;
221
- console.log(`mslxdff v${VERSION} listening on http://${host}:${addr.port}`);
222
- if (created) {
223
- console.log(`auth token: ${token}`);
224
- }
225
- console.log(`endpoint: http://${host}:${addr.port}/v1`);
226
- try {
227
- const { hedgeDelayMs } = await import("../routes/hedge.js");
228
- const hd = hedgeDelayMs();
229
- console.log(`hedge: ${hd ? `${hd}ms` : "off"} (MSLXDFF_HEDGE_DELAY_MS)`);
230
- } catch {}
231
-
232
- // group sync
233
- const { syncAllJoinedGroups } = await import("./group-helpers.js");
234
- syncAllJoinedGroups({ peers, groups })
235
- .then((results) => {
236
- for (const r of results) {
237
- if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
238
- else console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
239
- }
240
- })
241
- .catch((err) => console.log(`group sync: ${errMsg(err)}`));
242
- const groupSyncTimer = setInterval(() => {
243
- syncAllJoinedGroups({ peers, groups })
244
- .then((results) => {
245
- for (const r of results) {
246
- if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
247
- else if (r.added) console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
248
- }
249
- })
250
- .catch((err) => console.log(`group sync: ${errMsg(err)}`));
251
- }, groupSyncIntervalMs());
252
- groupSyncTimer.unref();
253
-
254
- const broadbandGroups = () => loadGroupsJoined().filter((g) => g.kind === "broadband" && g.leaderUrl);
255
- if (broadbandGroups().length) {
256
- const doHeartbeat = async () => {
257
- for (const g of broadbandGroups()) {
258
- try {
259
- const res = await fetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
260
- method: "POST",
261
- headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
262
- body: JSON.stringify({ name: g.name, group: g.name }),
263
- signal: AbortSignal.timeout(5000),
264
- });
265
- if (!res.ok) {
266
- const txt = await res.text().catch(() => "");
267
- console.log(`broadband heartbeat ${g.name}: ${res.status} ${txt.slice(0, 100)}`);
268
- }
269
- } catch (err) {
270
- console.log(`broadband heartbeat ${g.name}: failed — ${errMsg(err)}`);
271
- }
272
- }
273
- };
274
- const doPoll = async () => {
275
- for (const g of broadbandGroups()) {
276
- try {
277
- const pollRes = await fetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
278
- method: "POST",
279
- headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
280
- body: JSON.stringify({ name: g.name, group: g.name }),
281
- signal: AbortSignal.timeout(8000),
282
- });
283
- if (!pollRes.ok) continue;
284
- const data = await pollRes.json().catch(() => ({}));
285
- const items = data.data || [];
286
- for (const item of items) {
287
- const { reqId, body } = item;
288
- let result;
289
- try {
290
- const upRes = await upstream.chat(body);
291
- const ct = upRes.headers.get("content-type") || "";
292
- const isStream = Boolean(body?.stream) || ct.includes("text/event-stream");
293
- if (isStream && upRes.body) {
294
- let collected = "";
295
- for await (const chunk of upRes.body) {
296
- collected += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
297
- }
298
- result = { status: upRes.status, headers: { "Content-Type": "text/event-stream" }, body: collected };
299
- } else {
300
- const txt = await upRes.text();
301
- let parsed;
302
- try { parsed = JSON.parse(txt); } catch { parsed = txt; }
303
- result = { status: upRes.status, headers: { "Content-Type": upRes.headers.get("content-type") || "application/json" }, body: typeof parsed === "string" ? parsed : JSON.stringify(parsed) };
304
- }
305
- } catch (err) {
306
- result = { status: 502, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: errMsg(err) }) };
307
- }
308
- try {
309
- await fetch(`${g.leaderUrl}/v1/groups/relay/result`, {
310
- method: "POST",
311
- headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
312
- body: JSON.stringify({ name: g.name, group: g.name, reqId, result }),
313
- signal: AbortSignal.timeout(5000),
314
- });
315
- } catch {}
316
- }
317
- } catch {}
318
- }
319
- };
320
- doHeartbeat().catch(() => {});
321
- const hbTimer = setInterval(doHeartbeat, 30_000);
322
- hbTimer.unref();
323
- const pollTimer = setInterval(doPoll, 1000);
324
- pollTimer.unref();
325
- console.log(`broadband relay: heartbeat 30s + poll 1s for ${broadbandGroups().length} group(s)`);
326
- }
327
-
328
- const autoUpdateMs = autoUpdateIntervalMs();
329
- function emitAutoUpdate(type, data = {}) {
330
- const entry = { ts: Date.now(), type, ...data };
331
- try { bus?.emit(entry); } catch {}
332
- try { logs?.appendEvent?.(entry); } catch {}
333
- const line = `[auto-update] ${type} ${JSON.stringify(data)}`;
334
- console.log(line);
335
- }
336
- if (autoUpdateMs) {
337
- console.log(`auto-update enabled: checking every ${Math.round(autoUpdateMs / 60000)}m`);
338
- emitAutoUpdate("auto-update-enabled", { intervalMs: autoUpdateMs, current: VERSION });
339
- setTimeout(() => {
340
- emitAutoUpdate("auto-update-check", { current: VERSION });
341
- checkAndAutoUpdate().catch((err) => {
342
- console.log(`auto-update check failed: ${errMsg(err)}`);
343
- emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
344
- });
345
- }, 30_000).unref?.();
346
- const autoUpdateTimer = setInterval(() => {
347
- emitAutoUpdate("auto-update-check", { current: VERSION });
348
- checkAndAutoUpdate().catch((err) => {
349
- console.log(`auto-update check failed: ${errMsg(err)}`);
350
- emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
351
- });
352
- }, autoUpdateMs);
353
- autoUpdateTimer.unref();
354
- } else {
355
- console.log(`auto-update disabled (set MSLXDFF_AUTO_UPDATE=1 to enable hourly)`);
356
- emitAutoUpdate("auto-update-disabled", { current: VERSION });
357
- }
358
-
359
- async function checkAndAutoUpdate() {
360
- emitAutoUpdate("auto-update-query", { current: VERSION });
361
- const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
362
- if (info.err) {
363
- emitAutoUpdate("auto-update-query-failed", { error: info.err.message || String(info.stderr || "").slice(0, 500) });
364
- throw new Error(info.err.message || String(info.stderr || "").slice(0, 500));
365
- }
366
- let latest = "";
367
- try {
368
- latest = JSON.parse(String(info.stdout || "").trim());
369
- if (Array.isArray(latest)) latest = latest[latest.length - 1];
370
- latest = String(latest || "").replace(/^v/, "").trim();
371
- } catch {
372
- const raw = String(info.stdout || "").trim();
373
- const m = raw.match(/(\d+\.\d+\.\d+[^\s'"]*)/);
374
- latest = m ? m[1] : raw.split(/\s+/).pop()?.replace(/['"]/g, "") || "";
375
- }
376
- latest = latest.replace(/['"]/g, "").trim();
377
- emitAutoUpdate("auto-update-queried", { current: VERSION, latest, stdout: String(info.stdout || "").trim().slice(0, 200) });
378
- if (!latest || latest === VERSION) {
379
- emitAutoUpdate("auto-update-noop", { current: VERSION, latest });
380
- return;
381
- }
382
- const { compareSemver } = await import("./policy.js");
383
- if (compareSemver(latest, VERSION) <= 0) {
384
- emitAutoUpdate("auto-update-noop", { current: VERSION, latest, reason: "not newer" });
385
- return;
386
- }
387
- emitAutoUpdate("auto-update-found", { current: VERSION, latest });
388
- console.log(`auto-update: v${VERSION} -> v${latest}, installing...`);
389
- emitAutoUpdate("auto-update-installing", { current: VERSION, latest });
390
- const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`]);
391
- if (up.err) {
392
- emitAutoUpdate("auto-update-install-failed", { current: VERSION, latest, error: up.err.message || String(up.stderr || "").slice(0, 500) });
393
- throw new Error(up.err.message || String(up.stderr || "").slice(0, 500));
394
- }
395
- emitAutoUpdate("auto-update-installed", { current: VERSION, latest, stdout: String(up.stdout || "").slice(0, 500) });
396
- console.log(`auto-update: installed v${latest}, restarting daemon...`);
397
- emitAutoUpdate("auto-update-restarting", { current: VERSION, latest });
398
- const { stopDaemon, startDaemon } = await import("../daemon.js");
399
- try { stopDaemon(); } catch (e) { emitAutoUpdate("auto-update-stop-failed", { error: errMsg(e) }); }
400
- const { waitForHealth } = await import("./policy.js");
401
- const newPid = startDaemon([]);
402
- await waitForHealth(resolvePort(), 8000);
403
- console.log(`auto-update: restarted as v${latest} (pid ${newPid})`);
404
- emitAutoUpdate("auto-update-restarted", { current: VERSION, latest, newPid });
405
- }
406
- }
407
-
408
-
1
+ export * from "../runtime/bootstrap.js";
@@ -0,0 +1,73 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
3
+
4
+ /**
5
+ * 聚合 genericIds:来自 providerConfigs + providerKeys + env MSLXDFF_*_KEY
6
+ * 纯逻辑抽取,便于单测;默认读真实 stateFile 与 env,测试可注入 stateFile
7
+ */
8
+ export function buildProviderRows({ stateFile, env = process.env } = {}) {
9
+ // 复用 status 的聚合逻辑,但抽为纯函数
10
+ let configs = {};
11
+ try { configs = loadProviderConfigs(stateFile ? { file: stateFile } : undefined); } catch {}
12
+ const upstreamBase = env.UPSTREAM_BASE_URL || "https://opencode.ai";
13
+ const rows = [];
14
+ const opAllowed = (() => { try { return loadProviderAllowedModels("opencode", stateFile ? { file: stateFile } : undefined); } catch { return []; } })();
15
+ rows.push({ id: "opencode", enabled: true, baseUrl: upstreamBase, keys: [], allowed: opAllowed, share: false, note: "built-in, no key, cannot share", authCount: 0 });
16
+
17
+ const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode"));
18
+ try {
19
+ const raw = JSON.parse(readFileSync(stateFile || "", "utf8"));
20
+ const pk = raw.providerKeys || {};
21
+ for (const id of Object.keys(pk)) if (id !== "opencode") genericIds.add(id);
22
+ const cfgRaw = raw.providerConfigs || {};
23
+ for (const id of Object.keys(cfgRaw)) if (id !== "opencode") genericIds.add(id);
24
+ } catch {}
25
+ for (const k of Object.keys(env)) {
26
+ const m = k.match(/^MSLXDFF_(.+)_KEY$/);
27
+ if (m) {
28
+ const id = m[1].toLowerCase().replace(/__/g, "-");
29
+ if (id !== "opencode") genericIds.add(id);
30
+ }
31
+ }
32
+
33
+ for (const gid of [...genericIds].sort()) {
34
+ const cfg = configs[gid];
35
+ let keys = [];
36
+ let baseUrl = "";
37
+ let share = false;
38
+ let allowed = [];
39
+ try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
40
+ try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
41
+ try { share = loadProviderShareKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
42
+ try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
43
+ let enabled = Boolean(baseUrl && keys.length) || (gid === "openrouter" && keys.length > 0);
44
+ const auths = gid === "workbuddy" ? (() => { try { return loadProviderAuths(gid, stateFile ? { file: stateFile } : undefined) || []; } catch { return []; } })() : [];
45
+ let note = "";
46
+ const isWorkbuddyStub = gid === "workbuddy" && (keys.includes("k-new") || String(baseUrl).includes("127.0.0.1") || (keys.length === 1 && keys[0].length < 20));
47
+ if (isWorkbuddyStub) {
48
+ enabled = false;
49
+ note = "测试桩 (key=k-new, baseUrl=127.0.0.1) — 请重跑 node workbuddy-token-auto.js 写入真实 JWT";
50
+ } else if (!enabled) {
51
+ if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
52
+ else if (!baseUrl) note = "missing baseUrl";
53
+ else if (!keys.length) note = "no keys";
54
+ } else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
55
+ note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
56
+ }
57
+ rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, share, note, authCount: auths.length });
58
+ }
59
+ return rows;
60
+ }
61
+
62
+ export function formatProviderRow(p) {
63
+ const dot = p.enabled ? "●" : "○";
64
+ const state = p.enabled ? "enabled " : "disabled";
65
+ let keysInfo;
66
+ if (p.id === "opencode") keysInfo = "无需 key (内置)";
67
+ else keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 3)}…${k.slice(-3)}`).join(", ")}` : "0 keys";
68
+ const authInfo = p.authCount ? ` ${p.authCount} acc` : "";
69
+ const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 2).join(",")}${p.allowed.length > 2 ? "…" : ""})` : "allow=all";
70
+ const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
71
+ const note = p.note ? ` (${p.note})` : "";
72
+ return ` ${dot} ${p.id.padEnd(12)} ${state} ${keysInfo}${authInfo} ${allowInfo.padEnd(18)} baseUrl=${p.baseUrl} ${shareInfo}${note}`;
73
+ }
package/src/cli/status.js CHANGED
@@ -13,6 +13,7 @@ import { refreshGroupMembers } from "../groups.js";
13
13
  import { fmtShanghaiYMDHM } from "../time.js";
14
14
  import { fmtStatus, fmtUptime, fmtTs } from "./format.js";
15
15
  import { compareSemver } from "./policy.js";
16
+ import { buildProviderRows, formatProviderRow } from "./provider-row.js";
16
17
 
17
18
  export async function printStatus(VERSION) {
18
19
  const daemon = readPid();
@@ -50,61 +51,11 @@ export async function printStatus(VERSION) {
50
51
  } catch {}
51
52
 
52
53
  try {
53
- const configs = loadProviderConfigs();
54
- const upstreamBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
55
- const providerRows = [];
56
- const opAllowed = loadProviderAllowedModels("opencode");
57
- providerRows.push({ id: "opencode", enabled: true, baseUrl: upstreamBase, keys: [], allowed: opAllowed, share: false, note: "built-in, no key, cannot share" });
58
- const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode"));
59
- try {
60
- const raw = JSON.parse(readFileSync(stateFile, "utf8"));
61
- const pk = raw.providerKeys || {};
62
- for (const id of Object.keys(pk)) if (id !== "opencode") genericIds.add(id);
63
- const cfgRaw = raw.providerConfigs || {};
64
- for (const id of Object.keys(cfgRaw)) if (id !== "opencode") genericIds.add(id);
65
- } catch {}
66
- for (const k of Object.keys(process.env)) {
67
- const m = k.match(/^MSLXDFF_(.+)_KEY$/);
68
- if (m) {
69
- const id = m[1].toLowerCase().replace(/__/g, "-");
70
- if (id !== "opencode") genericIds.add(id);
71
- }
72
- }
73
- for (const gid of [...genericIds].sort()) {
74
- const cfg = configs[gid];
75
- const keys = loadProviderKeys(gid);
76
- const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : "");
77
- const share = loadProviderShareKeys(gid);
78
- const allowed = loadProviderAllowedModels(gid);
79
- let enabled = Boolean(baseUrl && keys.length) || (gid === "openrouter" && keys.length > 0);
80
- const auths = gid === "workbuddy" ? (loadProviderAuths(gid) || []) : [];
81
- let note = "";
82
- const isWorkbuddyStub = gid === "workbuddy" && (keys.includes("k-new") || baseUrl.includes("127.0.0.1") || (keys.length === 1 && keys[0].length < 20));
83
- if (isWorkbuddyStub) {
84
- enabled = false;
85
- note = "测试桩 (key=k-new, baseUrl=127.0.0.1) — 请重跑 node workbuddy-token-auto.js 写入真实 JWT";
86
- } else if (!enabled) {
87
- if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
88
- else if (!baseUrl) note = "missing baseUrl";
89
- else if (!keys.length) note = "no keys";
90
- } else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
91
- note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
92
- }
93
- providerRows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, share, note, authCount: auths.length });
94
- }
54
+ const providerRows = buildProviderRows({});
95
55
  const enabledCount = providerRows.filter((r) => r.enabled).length;
96
56
  console.log(`\nupstream providers (${providerRows.length}, ${enabledCount} enabled) — mslxdff -providers list 查看详情`);
97
57
  for (const p of providerRows) {
98
- const dot = p.enabled ? "●" : "○";
99
- const state = p.enabled ? "enabled " : "disabled";
100
- let keysInfo;
101
- if (p.id === "opencode") keysInfo = "无需 key (内置)";
102
- else keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 3)}…${k.slice(-3)}`).join(", ")}` : "0 keys";
103
- const authInfo = p.authCount ? ` ${p.authCount} acc` : "";
104
- const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 2).join(",")}${p.allowed.length > 2 ? "…" : ""})` : "allow=all";
105
- const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
106
- const note = p.note ? ` (${p.note})` : "";
107
- console.log(` ${dot} ${p.id.padEnd(12)} ${state} ${keysInfo}${authInfo} ${allowInfo.padEnd(18)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
58
+ console.log(formatProviderRow(p));
108
59
  }
109
60
  if (enabledCount === 1 && providerRows.length === 1) {
110
61
  console.log(` (仅 opencode 内置免费通道;按需加:mslxdff -provider add bai https://api.b.ai/v1 <key> 或 node workbuddy-token-auto.js)`);
@@ -1,10 +1,22 @@
1
1
  import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
2
- import { isModelAllowed, loadProviderAllowedModels, loadProviderAllowAnyModels } from "../state.js";
2
+ import { isModelAllowed as stateIsAllowed, loadProviderAllowedModels as stateLoadAllowed, loadProviderAllowAnyModels as stateLoadAllowAny } from "../state.js";
3
3
 
4
4
  // 多供应商 dispatcher:把多个 Provider 聚合成一个 `upstream` 形状(chat/preheat/close),
5
5
  // 按 body.model 的前缀路由到对应供应商,转发上游前剥掉前缀只发原始 id。
6
- export function createProviderDispatcher(providers = []) {
6
+ // 纯化:第二参可注入纯函数 isAllowed/getAllowed/getAllowAny,便于单测不读盘
7
+ export function createProviderDispatcher(providers = [], opts = {}) {
7
8
  const byId = new Map(providers.map((p) => [p.id, p]));
9
+ // 纯化注入:第二参可为 {isAllowed, getAllowedModels, getAllowAny} 或直接函数 isAllowed
10
+ let isAllowedFn, getAllowedFn, getAllowAnyFn;
11
+ if (typeof opts === "function") {
12
+ isAllowedFn = opts;
13
+ getAllowedFn = stateLoadAllowed;
14
+ getAllowAnyFn = stateLoadAllowAny;
15
+ } else {
16
+ isAllowedFn = opts.isAllowed || stateIsAllowed;
17
+ getAllowedFn = opts.getAllowedModels || stateLoadAllowed;
18
+ getAllowAnyFn = opts.getAllowAny || stateLoadAllowAny;
19
+ }
8
20
 
9
21
  function resolve(model) {
10
22
  const split = splitModelId(model, providers.map((p) => p.id));
@@ -29,8 +41,8 @@ export function createProviderDispatcher(providers = []) {
29
41
  raw = modelPart;
30
42
  }
31
43
  }
32
- if (!isModelAllowed(provider.id, raw)) {
33
- const allowed = loadProviderAllowedModels(provider.id);
44
+ if (!isAllowedFn(provider.id, raw)) {
45
+ const allowed = getAllowedFn(provider.id) || [];
34
46
  const msg = `model not allowed for provider "${provider.id}": "${raw}" — allowed: ${allowed.join(", ") || "(none)"} (use: mslxdff -provider ${provider.id} allowlist add <model>)`;
35
47
  return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-allowlist": "1" } });
36
48
  }
@@ -58,8 +70,8 @@ export function createProviderDispatcher(providers = []) {
58
70
  } catch {
59
71
  list = [];
60
72
  }
61
- const allowed = loadProviderAllowedModels(p.id);
62
- const allowAny = loadProviderAllowAnyModels(p.id);
73
+ const allowed = getAllowedFn(p.id) || [];
74
+ const allowAny = getAllowAnyFn(p.id);
63
75
  const allowedSet = allowed.length ? new Set(allowed) : null;
64
76
  // 空名单且不允许任意模型 => 该供应商不暴露任何模型(安全默认)
65
77
  if (!allowedSet && !allowAny) continue;
@@ -29,6 +29,7 @@ export function createGenericProvider({
29
29
  },
30
30
  fetchImpl,
31
31
  headers: extraHeaders,
32
+ mapModel,
32
33
  noAgent = false,
33
34
  file,
34
35
  } = {}) {
@@ -79,6 +80,7 @@ export function createGenericProvider({
79
80
  const { listModels } = createListModelsRunner({
80
81
  id, ring, dispatcher, fetchImpl,
81
82
  getUrl: () => joinUrl(resolvedBase, resolvedModelsPath),
83
+ mapModel,
82
84
  });
83
85
 
84
86
  const { preheat } = createPreheatRunner({