mslxdff 0.1.65 → 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.
Files changed (65) hide show
  1. package/bin/mslxdff.js +3 -3159
  2. package/package.json +1 -1
  3. package/src/chat/cooling.js +99 -0
  4. package/src/chat/direct.js +57 -0
  5. package/src/chat/gateway.js +148 -0
  6. package/src/chat/orchestrator.js +218 -0
  7. package/src/chat/sse.js +69 -0
  8. package/src/chat/upstream.js +59 -501
  9. package/src/cli/bootstrap.js +1 -0
  10. package/src/cli/commands/daemon.js +151 -0
  11. package/src/cli/commands/group.js +272 -0
  12. package/src/cli/commands/model.js +405 -0
  13. package/src/cli/commands/provider/add.js +106 -0
  14. package/src/cli/commands/provider/allowlist.js +99 -0
  15. package/src/cli/commands/provider/config.js +82 -0
  16. package/src/cli/commands/provider/index.js +193 -0
  17. package/src/cli/commands/provider/keys.js +135 -0
  18. package/src/cli/commands/provider/models.js +99 -0
  19. package/src/cli/commands/provider.js +1 -0
  20. package/src/cli/commands/sync.js +143 -0
  21. package/src/cli/commands/system.js +236 -0
  22. package/src/cli/commands/workbuddy.js +91 -0
  23. package/src/cli/format.js +119 -0
  24. package/src/cli/group-helpers.js +69 -0
  25. package/src/cli/help.js +66 -0
  26. package/src/cli/index.js +59 -0
  27. package/src/cli/interactive.js +84 -0
  28. package/src/cli/policy.js +119 -0
  29. package/src/cli/provider-row.js +73 -0
  30. package/src/cli/status.js +283 -0
  31. package/src/cli/util.js +24 -0
  32. package/src/providers/base.js +159 -0
  33. package/src/providers/dispatcher.js +18 -6
  34. package/src/providers/generic.js +28 -166
  35. package/src/providers/openrouter.js +19 -205
  36. package/src/providers/workbuddy/auth.js +175 -0
  37. package/src/providers/workbuddy/balance.js +84 -0
  38. package/src/providers/workbuddy/chat.js +310 -0
  39. package/src/providers/workbuddy/index.js +263 -0
  40. package/src/providers/workbuddy/models.js +111 -0
  41. package/src/providers/workbuddy/rotation-log.js +54 -0
  42. package/src/providers/workbuddy.js +2 -677
  43. package/src/routes/chat/broadband-handler.js +25 -46
  44. package/src/routes/chat/exhausted-handler.js +2 -2
  45. package/src/routes/chat/gateway.js +301 -0
  46. package/src/routes/chat/hedge-handler.js +65 -83
  47. package/src/routes/chat/index.js +1 -384
  48. package/src/routes/chat/local-handler.js +32 -61
  49. package/src/routes/chat/peer-handler.js +32 -23
  50. package/src/routes/chat/relay-pipeline.js +151 -0
  51. package/src/runtime/bootstrap.js +408 -0
  52. package/src/state/facade.js +57 -0
  53. package/src/state/memory.js +161 -0
  54. package/src/state/merge.js +26 -0
  55. package/src/state/persist.js +42 -0
  56. package/src/state/provider-config.js +143 -0
  57. package/src/state/schemas/allowlist.js +87 -0
  58. package/src/state/schemas/group.js +31 -0
  59. package/src/state/schemas/model.js +53 -0
  60. package/src/state/schemas/peer.js +31 -0
  61. package/src/state/schemas/port.js +10 -0
  62. package/src/state/schemas/provider.js +204 -0
  63. package/src/state/schemas/token.js +43 -0
  64. package/src/state/store.js +176 -0
  65. package/src/state.js +1 -711
@@ -0,0 +1,84 @@
1
+ // 交互式选择器:↑/↓ 移动,Enter 确认,q/Esc 取消;ANSI 原地重绘
2
+ export async function pickInteractive(items, startCursor = 0) {
3
+ const { renderChooser, renderChooserHelp, parseKey } = await import("../chooser.js");
4
+ let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
5
+ const draw = () => {
6
+ const lines = [...renderChooser(items, cursor), ...renderChooserHelp()];
7
+ process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
8
+ };
9
+ draw();
10
+ return new Promise((resolve) => {
11
+ process.stdin.setRawMode(true);
12
+ process.stdin.resume();
13
+ process.stdin.setEncoding("utf8");
14
+ const cleanup = () => {
15
+ process.stdin.removeListener("data", onData);
16
+ process.stdin.setRawMode(false);
17
+ process.stdin.pause();
18
+ process.stdout.write("\n");
19
+ };
20
+ const onData = (chunk) => {
21
+ const key = parseKey(String(chunk));
22
+ if (key === "up") {
23
+ cursor = (cursor - 1 + items.length) % items.length;
24
+ draw();
25
+ } else if (key === "down") {
26
+ cursor = (cursor + 1) % items.length;
27
+ draw();
28
+ } else if (key === "enter") {
29
+ cleanup();
30
+ resolve(items[cursor].id);
31
+ } else if (key === "cancel") {
32
+ cleanup();
33
+ resolve(null);
34
+ }
35
+ };
36
+ process.stdin.on("data", onData);
37
+ });
38
+ }
39
+
40
+ // 多选勾选:↑/↓ 移动,Space 勾选/取消,Enter 保存,q/Esc 取消(返回 Set 或 null)
41
+ export async function pickInteractiveMulti(items, initialPicked = new Set(), startCursor = 0) {
42
+ const { renderChooser, renderChooserHelp, parseKey } = await import("../chooser.js");
43
+ let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
44
+ const picked = new Set(items.filter((it) => initialPicked.has(it.id)).map((it) => it.id));
45
+ const draw = () => {
46
+ const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
47
+ const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
48
+ process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
49
+ };
50
+ draw();
51
+ return new Promise((resolve) => {
52
+ process.stdin.setRawMode(true);
53
+ process.stdin.resume();
54
+ process.stdin.setEncoding("utf8");
55
+ const cleanup = () => {
56
+ process.stdin.removeListener("data", onData);
57
+ process.stdin.setRawMode(false);
58
+ process.stdin.pause();
59
+ process.stdout.write("\n");
60
+ };
61
+ const onData = (chunk) => {
62
+ const key = parseKey(String(chunk));
63
+ if (key === "up") {
64
+ cursor = (cursor - 1 + items.length) % items.length;
65
+ draw();
66
+ } else if (key === "down") {
67
+ cursor = (cursor + 1) % items.length;
68
+ draw();
69
+ } else if (key === "space") {
70
+ const id = items[cursor].id;
71
+ if (picked.has(id)) picked.delete(id);
72
+ else picked.add(id);
73
+ draw();
74
+ } else if (key === "enter") {
75
+ cleanup();
76
+ resolve(new Set(picked));
77
+ } else if (key === "cancel") {
78
+ cleanup();
79
+ resolve(null);
80
+ }
81
+ };
82
+ process.stdin.on("data", onData);
83
+ });
84
+ }
@@ -0,0 +1,119 @@
1
+ import { readPid, readPidVersion, isPidAlive, stopDaemon } from "../daemon.js";
2
+ import { resolvePort } from "../server.js";
3
+ import { getPort, loadGroupsJoined } from "../state.js";
4
+
5
+ export function compareSemver(a, b) {
6
+ const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
7
+ const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
8
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
9
+ const av = pa[i] || 0, bv = pb[i] || 0;
10
+ if (av !== bv) return av - bv;
11
+ }
12
+ return 0;
13
+ }
14
+
15
+ export function argValue(args, ...names) {
16
+ for (let i = 0; i < args.length; i++) {
17
+ if (names.includes(args[i])) return args[i + 1];
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export function effectivePort(args) {
23
+ const arg = argValue(args, "-port", "--port");
24
+ if (arg) return Number(arg);
25
+ return resolvePort();
26
+ }
27
+
28
+ export function effectiveHost() {
29
+ const envHost = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST;
30
+ if (typeof envHost === "string" && envHost.trim()) return envHost.trim();
31
+ try {
32
+ const joined = loadGroupsJoined();
33
+ if (joined.some((g) => g.kind === "broadband")) return "127.0.0.1";
34
+ } catch {}
35
+ return "0.0.0.0";
36
+ }
37
+
38
+ export async function waitForHealth(port, timeoutMs) {
39
+ const start = Date.now();
40
+ while (Date.now() - start < timeoutMs) {
41
+ try {
42
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
43
+ if (res.ok) return;
44
+ } catch {
45
+ // not up yet
46
+ }
47
+ await new Promise((r) => setTimeout(r, 100));
48
+ }
49
+ }
50
+
51
+ // Only upgrade, never downgrade. Keeps a newer daemon running.
52
+ export function stopDaemonIfOutdated(VERSION) {
53
+ const pid = readPid();
54
+ if (!pid) return;
55
+ if (!isPidAlive(pid)) {
56
+ console.log(`daemon pid ${pid} is stale (not running) — starting fresh`);
57
+ return;
58
+ }
59
+ const runningVersion = readPidVersion();
60
+ if (!runningVersion) {
61
+ console.log(`daemon version unknown — restarting with v${VERSION}...`);
62
+ stopDaemon();
63
+ return;
64
+ }
65
+ if (runningVersion === VERSION) return;
66
+ const cmp = compareSemver(VERSION, runningVersion);
67
+ if (cmp <= 0) {
68
+ console.log(`daemon running v${runningVersion} — keeping v${runningVersion} (local v${VERSION} is lower, not downgrading)`);
69
+ return;
70
+ }
71
+ console.log(`daemon running v${runningVersion} — upgrading to v${VERSION}, restarting...`);
72
+ stopDaemon();
73
+ }
74
+
75
+ export function refreshIntervalMs() {
76
+ const n = Number(process.env.MODELS_REFRESH_MS);
77
+ return Number.isInteger(n) && n > 0 ? n : 2 * 60 * 60 * 1000;
78
+ }
79
+ export function modelCooldownMs() {
80
+ const n = Number(process.env.MSLXDFF_MODEL_COOLDOWN_MS);
81
+ return Number.isInteger(n) && n > 0 ? n : 60_000;
82
+ }
83
+ export function slowCooldownMs() {
84
+ const n = Number(process.env.MSLXDFF_SLOW_COOLDOWN_MS);
85
+ return Number.isInteger(n) && n > 0 ? n : 5 * 60_000;
86
+ }
87
+ export function peerCooldownMs() {
88
+ const n = Number(process.env.MSLXDFF_PEER_COOLDOWN_MS);
89
+ return Number.isInteger(n) && n > 0 ? n : 30_000;
90
+ }
91
+ export function peerHeatMs() {
92
+ const n = Number(process.env.MSLXDFF_PEER_HEAT_MS);
93
+ return Number.isInteger(n) && n > 0 ? n : 5 * 60_000;
94
+ }
95
+ export function maxHopsValue() {
96
+ const n = Number(process.env.MSLXDFF_MAX_HOPS);
97
+ return Number.isInteger(n) && n > 0 ? n : 3;
98
+ }
99
+ export function groupSyncIntervalMs() {
100
+ const n = Number(process.env.MSLXDFF_GROUP_SYNC_MS);
101
+ return Number.isInteger(n) && n > 0 ? n : 60_000;
102
+ }
103
+ export function autoUpdateIntervalMs() {
104
+ const raw = process.env.MSLXDFF_AUTO_UPDATE_MS ?? process.env.MSLXDFF_AUTO_UPDATE;
105
+ if (raw === undefined || raw === null || raw === "") return 60 * 60 * 1000;
106
+ const s = String(raw).trim().toLowerCase();
107
+ if (s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled") return 0;
108
+ if (s === "1" || s === "true" || s === "on" || s === "yes" || s === "enable" || s === "enabled") return 60 * 60 * 1000;
109
+ const n = Number(raw);
110
+ return Number.isInteger(n) && n > 0 ? n : 60 * 60 * 1000;
111
+ }
112
+ export function banWindowMs() {
113
+ const n = Number(process.env.MSLXDFF_BAN_WINDOW_MS);
114
+ return Number.isInteger(n) && n > 0 ? n : 48 * 60 * 60 * 1000;
115
+ }
116
+ export function banThreshold() {
117
+ const n = Number(process.env.MSLXDFF_BAN_THRESHOLD);
118
+ return Number.isInteger(n) && n > 0 ? n : 5;
119
+ }
@@ -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
+ }
@@ -0,0 +1,283 @@
1
+ import { readFileSync, existsSync, statSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { readPid, readPidVersion, isPidAlive, pidFile } from "../daemon.js";
5
+ import { defaultStateFile, getPort, loadGroupsJoined, loadModelErrors, loadModelStats, loadModelPicks } from "../state.js";
6
+ import { resolvePort } from "../server.js";
7
+ import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
8
+ import { loadToken } from "../state.js";
9
+ import { logDir } from "../logs.js";
10
+ import { createGroupsService } from "../groups.js";
11
+ import { createPeersService } from "../peers.js";
12
+ import { refreshGroupMembers } from "../groups.js";
13
+ import { fmtShanghaiYMDHM } from "../time.js";
14
+ import { fmtStatus, fmtUptime, fmtTs } from "./format.js";
15
+ import { compareSemver } from "./policy.js";
16
+ import { buildProviderRows, formatProviderRow } from "./provider-row.js";
17
+
18
+ export async function printStatus(VERSION) {
19
+ const daemon = readPid();
20
+ const alive = daemon ? isPidAlive(daemon) : false;
21
+ const port = getPort() || resolvePort();
22
+ const persisted = getPort();
23
+ const portSrc = persisted != null ? "persisted" : (process.env.MSLXDFF_PORT ? "env MSLXDFF_PORT" : "default 8989");
24
+ const stateFile = defaultStateFile();
25
+ const dir = logDir();
26
+ let upStr = "";
27
+ if (daemon && alive) {
28
+ try {
29
+ const st = statSync(pidFile());
30
+ const ms = Date.now() - st.mtimeMs;
31
+ upStr = `, up ${fmtUptime(ms)}`;
32
+ } catch {}
33
+ }
34
+ const verNote = daemon && alive ? (() => { const v = readPidVersion(); if (!v) return ""; if (v === VERSION) return "version ok"; const cmp = compareSemver(VERSION, v); if (cmp > 0) return `version ${v} → ${VERSION} (upgrade pending)`; if (cmp < 0) return `version ${v} (newer than local ${VERSION}, keeping)`; return "version ok"; })() : "";
35
+ console.log(`mslxdff v${VERSION}`);
36
+ console.log(`daemon: ${daemon ? (alive ? `running (pid ${daemon}${upStr})` : `stale pid ${daemon} (not alive)`) : "not running"}${verNote ? ` [${verNote}]` : ""}`);
37
+ let healthLine = "";
38
+ try {
39
+ const t0 = Date.now();
40
+ const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1200) });
41
+ const ms = Date.now() - t0;
42
+ healthLine = r.ok ? `health ok ${ms}ms` : `health HTTP ${r.status} ${ms}ms`;
43
+ } catch (e) {
44
+ healthLine = daemon && alive ? `health fail (${String(e?.message || e).slice(0, 60)})` : "health — (daemon not running)";
45
+ }
46
+ console.log(`endpoint: http://localhost:${port}/v1 · ${healthLine}`);
47
+ console.log(`config: port ${port} (${portSrc}) · state ${stateFile} · log ${dir}`);
48
+ try {
49
+ const host = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST || "0.0.0.0";
50
+ if (host !== "0.0.0.0") console.log(`bind: ${host}`);
51
+ } catch {}
52
+
53
+ try {
54
+ const providerRows = buildProviderRows({});
55
+ const enabledCount = providerRows.filter((r) => r.enabled).length;
56
+ console.log(`\nupstream providers (${providerRows.length}, ${enabledCount} enabled) — mslxdff -providers list 查看详情`);
57
+ for (const p of providerRows) {
58
+ console.log(formatProviderRow(p));
59
+ }
60
+ if (enabledCount === 1 && providerRows.length === 1) {
61
+ console.log(` (仅 opencode 内置免费通道;按需加:mslxdff -provider add bai https://api.b.ai/v1 <key> 或 node workbuddy-token-auto.js)`);
62
+ }
63
+ } catch (e) {
64
+ console.log(`\nupstream providers: (unavailable — ${String(e?.message || e).slice(0, 80)})`);
65
+ }
66
+
67
+ try {
68
+ const statuses = loadModelErrors();
69
+ const stats = loadModelStats();
70
+ const picks = loadModelPicks();
71
+ const { getPreferredModel: gpm } = await import("../auto.js");
72
+ const preferred = gpm();
73
+ const modelsFile = join(dir, "models.json");
74
+ let freeIds = [];
75
+ let cachedAt = null;
76
+ let cacheErr = null;
77
+ if (existsSync(modelsFile)) {
78
+ try {
79
+ const cached = JSON.parse(readFileSync(modelsFile, "utf8"));
80
+ freeIds = (cached.data || []).map((m) => m.id).filter(Boolean);
81
+ cachedAt = cached.cachedAt || null;
82
+ } catch (err) { cacheErr = String(err?.message || err).slice(0, 60); }
83
+ }
84
+ const ageStr = cachedAt ? (() => { const ms = Date.now() - cachedAt; const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); return h ? `${h}h${m}m ago` : `${m}m ago`; })() : "";
85
+ const cacheLine = freeIds.length ? `${freeIds.length} free${cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)} · ${ageStr})` : ""}` : (cacheErr ? `cache unreadable (${cacheErr})` : "not cached yet (daemon 拉取后出现)");
86
+ const prefStat = preferred ? (stats[preferred] || stats[`opencode/${preferred}`] || null) : null;
87
+ const prefErr = preferred ? statuses[preferred] : null;
88
+ const prefStatus = prefErr ? (typeof prefErr === "number" ? "error" : (prefErr?.status || "error")) : (prefStat ? "normal" : "");
89
+ const prefLine = preferred ? `${preferred}${prefStatus ? ` [${prefStatus}]` : ""}` : "(none)";
90
+ const picksLine = picks.length ? `${picks.length} (*${picks.slice(0, 4).join(", *")}${picks.length > 4 ? ` …+${picks.length - 4}` : ""})` : "(空=全量 auto)";
91
+ console.log(`\nmodels: ${cacheLine} — mslxdff -model status 查看详情`);
92
+ const prefTtfbDisp = prefStat ? (() => { const v = prefStat.avgTtfbMs ?? prefStat.emaTtfbMs; return v != null && v >= 10 ? `首字 ${v}ms` : (v != null && v < 10 ? "首字 — (测试数据)" : ""); })() : "";
93
+ const prefTotDisp = prefStat?.avgTotalMs && prefStat.avgTotalMs >= 10 ? `总 ${prefStat.avgTotalMs}ms` : "";
94
+ const prefTpsDisp = prefStat?.avgTps && prefStat.avgTps >= 1 ? `${prefStat.avgTps} tok/s` : "";
95
+ const prefExtra = prefStat ? ` ${[prefTtfbDisp, prefTotDisp, prefTpsDisp].filter(Boolean).join(" ")} ${prefStat.count || 0}次` : "";
96
+ console.log(` preferred: ${prefLine}${prefExtra}`);
97
+ console.log(` picks: ${picksLine} ${picks.length ? "auto 仅在勾选内" : "auto 用全量免费池"}`);
98
+ if (freeIds.length) {
99
+ const ids = freeIds;
100
+ console.log(` free list:`);
101
+ for (const id of ids) {
102
+ const st = fmtStatus(id, statuses);
103
+ const full = id.includes("/") ? id : `opencode/${id}`;
104
+ const ms = stats[full] || stats[id];
105
+ let extra = "";
106
+ if (ms) {
107
+ const v = ms.avgTtfbMs ?? ms.emaTtfbMs;
108
+ const vStr = v != null ? (v >= 10 ? `avg首字 ${v}ms` : (v < 10 ? "avg首字 —" : "")) : "";
109
+ const tpsStr = ms.avgTps && ms.avgTps >= 1 ? ` ${ms.avgTps} tok/s` : "";
110
+ const cntStr = ms.count ? ` ${ms.count}次` : "";
111
+ extra = ` ${vStr}${tpsStr}${cntStr}`;
112
+ }
113
+ console.log(` ${id}${st ? ` [${st}]` : ""}${extra}`);
114
+ }
115
+ }
116
+ const allStatIds = Object.keys(stats);
117
+ if (allStatIds.length) {
118
+ const sorted = allStatIds.map((full) => ({ full, s: stats[full] })).filter((x) => x.s && x.s.count).sort((a, b) => (b.s.count - a.s.count) || ((b.s.avgTps || 0) - (a.s.avgTps || 0))).slice(0, 7);
119
+ if (sorted.length) {
120
+ console.log(` 模型体检 Top${sorted.length}(样本>0, 按次数):`);
121
+ console.log(` ${"模型".padEnd(28)} ${"首字".padEnd(7)} ${"总耗时".padEnd(7)} ${"速度".padEnd(10)} ${"啰嗦".padEnd(7)} ${"样本".padEnd(5)} 状态`);
122
+ for (const { full, s } of sorted) {
123
+ const ttfb = s.avgTtfbMs ?? s.emaTtfbMs;
124
+ const total = s.avgTotalMs ?? s.emaTotalMs;
125
+ const tps = s.avgTps ?? s.emaTps;
126
+ const verbose = s.avgCompTok != null ? `${s.avgCompTok}tok` : "—";
127
+ const st = statuses[full] || statuses[full.split("/").slice(1).join("/")] || null;
128
+ const statusStr = st ? (typeof st === "number" ? "error" : (st.status || "normal")) : "normal";
129
+ const p95 = s.p95Ttfb && s.p95Ttfb >= 10 ? ` p95:${s.p95Ttfb}ms` : "";
130
+ const ttfbStr = ttfb != null ? (ttfb >= 10 ? ttfb + "ms" : "—") : "—";
131
+ const totalStr = total != null ? (total >= 10 ? total + "ms" : "—") : "—";
132
+ const tpsStr = tps != null && tps >= 1 ? tps + " tok/s" : "—";
133
+ console.log(` ${full.padEnd(28)} ${ttfbStr.padEnd(7)} ${totalStr.padEnd(7)} ${tpsStr.padEnd(10)} ${verbose.padEnd(7)} ${String(s.count).padEnd(5)} ${statusStr}${p95}`);
134
+ }
135
+ }
136
+ } else if (!freeIds.length) {
137
+ console.log(` (暂无模型样本 — 发一次 mslxdff -chat 后出现,100次后均值更稳)`);
138
+ }
139
+ } catch (e) {
140
+ console.log(`\nmodels: (unavailable — ${String(e?.message || e).slice(0, 80)})`);
141
+ }
142
+
143
+ try {
144
+ const { getAutostartStatus } = await import("../autostart.js");
145
+ const a = await getAutostartStatus();
146
+ const detail = a.detail || (a.enabled ? "已启用" : "未启用");
147
+ const label = detail.startsWith("已启用") || detail.startsWith("未启用") ? detail : `${a.enabled ? "已启用" : "未启用"} · ${detail}`;
148
+ console.log(`\nautostart: ${label} — mslxdff -autostart status`);
149
+ if (a.taskToRun) console.log(` task: ${a.taskToRun.slice(0, 120)}`);
150
+ if (a.unit) console.log(` unit: ${a.unit}`);
151
+ } catch {}
152
+ try {
153
+ const { resolvePluginDirs, loadPlugins } = await import("../plugins.js");
154
+ const pkgRoot2 = dirname(fileURLToPath(import.meta.url)) + "/../..";
155
+ const dirs = resolvePluginDirs({ pkgRoot: pkgRoot2 });
156
+ const { plugins, errors } = await loadPlugins({ dirs });
157
+ if (plugins.length || errors.length) {
158
+ console.log(`plugins: ${plugins.length} loaded${errors.length ? `, ${errors.length} error(s)` : ""} — mslxdff -plugins`);
159
+ for (const p of plugins) console.log(` ${p.name}${p.version ? `@${p.version}` : ""} [${Object.keys(p.hooks || {}).join(", ") || "no hooks"}]`);
160
+ for (const e of errors) console.log(` ! ${e.file} — ${e.error}`);
161
+ } else {
162
+ console.log(`plugins: (none) — 放 *.mjs 到 ${dirs[dirs.length - 1] || "~/.config/mslxdff/plugins"} 启用`);
163
+ }
164
+ } catch {}
165
+
166
+ const groups = createGroupsService({});
167
+ const joined = loadGroupsJoined();
168
+ if (joined.length) {
169
+ console.log(`\njoined groups (${joined.length}):`);
170
+ const { token } = await loadToken();
171
+ for (const g of joined) {
172
+ const isLeader = !g.leaderUrl;
173
+ console.log(` ${g.name} ${isLeader ? "(this node is the leader)" : `leader ${g.leaderUrl}`}`);
174
+ let members = null;
175
+ if (isLeader) {
176
+ members = groups.list()[g.name]?.members ?? {};
177
+ } else {
178
+ const fetchImpl = (url, opts) => fetch(url, { ...opts, signal: AbortSignal.timeout(1500) });
179
+ try {
180
+ members = await refreshGroupMembers(g.name, {
181
+ leaderUrl: g.leaderUrl,
182
+ memberName: g.memberName,
183
+ url: g.myUrl,
184
+ token,
185
+ fetchImpl,
186
+ });
187
+ } catch {
188
+ members = null;
189
+ }
190
+ }
191
+ if (members === null) {
192
+ console.log(" members: (unavailable — leader unreachable)");
193
+ } else {
194
+ const ids = Object.keys(members);
195
+ if (!ids.length) {
196
+ console.log(" members: (none yet)");
197
+ } else {
198
+ console.log(` members (${ids.length}):`);
199
+ for (const id of ids) {
200
+ const m = members[id];
201
+ const tags = [];
202
+ if (id === "leader" && isLeader) tags.push("leader");
203
+ if (m?.url && m.url === g.myUrl) tags.push("this node");
204
+ if (m?.kind === "broadband") tags.push("broadband");
205
+ if (m?.kind === "broadband" && m?.publicIp) tags.push(`ip=${m.publicIp}`);
206
+ if (m?.kind === "broadband" && m?.lastSeen) {
207
+ const ago = Math.round((Date.now() - m.lastSeen) / 1000);
208
+ tags.push(ago < 90 ? `via leader ${ago}s ago` : "stale");
209
+ }
210
+ const tag = tags.length ? ` [${tags.join(", ")}]` : "";
211
+ console.log(` ${m?.url || id}${tag}`);
212
+ }
213
+ }
214
+ }
215
+ }
216
+ } else {
217
+ console.log("\njoined groups: (none — use -creategroup or -addtogroup)");
218
+ }
219
+
220
+ const peers = createPeersService({});
221
+ const allPeers = peers.all();
222
+ if (allPeers.length) {
223
+ console.log(`\nfailover targets (${allPeers.length}):`);
224
+ for (const p of allPeers) {
225
+ const tags = [];
226
+ if (peers.isCooling(p.url)) tags.push("cooling");
227
+ if (peers.isHot(p.url)) tags.push("hot");
228
+ const s = peers.stat(p.url);
229
+ if (s?.latencyMs != null) tags.push(`${s.latencyMs}ms`);
230
+ if (s?.fails) tags.push(`${s.fails} fail(s)`);
231
+ const tag = tags.length ? ` [${tags.join(", ")}]` : "";
232
+ console.log(` ${p.name || p.url} ${p.url}${tag}`);
233
+ }
234
+ } else {
235
+ console.log(`\nfailover: (none — 加组后自动出现,mslxdff -group list)`);
236
+ }
237
+
238
+ const groupNames = Object.keys(groups.list());
239
+ if (groupNames.length) {
240
+ console.log(`\ngroups on this node (${groupNames.length}):`);
241
+ for (const n of groupNames) console.log(` ${n}`);
242
+ }
243
+
244
+ const { recentCalls, lastError } = await import("../logs.js");
245
+ console.log("\nrecent calls: (gateway 持久化,最近5条,含首字/tok/s)");
246
+ const calls = recentCalls(5);
247
+ if (calls.length) {
248
+ let sumDur = 0, sumTps = 0, tpsN = 0;
249
+ for (const c of calls) {
250
+ if (Number.isFinite(c.durationMs)) sumDur += c.durationMs;
251
+ else if (Number.isFinite(c.totalMs)) sumDur += c.totalMs;
252
+ if (Number.isFinite(c.tps)) { sumTps += c.tps; tpsN++; } else if (Number.isFinite(c.charsPerSec)) { sumTps += c.charsPerSec; tpsN++; }
253
+ }
254
+ const avgDur = calls.length ? Math.round(sumDur / calls.length) : null;
255
+ const avgTps = tpsN ? Math.round(sumTps / tpsN) : null;
256
+ console.log(` avg ${avgDur ? avgDur + "ms" : "—"}${avgTps ? ` · ${avgTps} tok/s` : ""} — mslxdff -log 20 查看详情`);
257
+ for (const c of calls) {
258
+ const dur = c.totalMs ?? c.durationMs;
259
+ const ttfb = c.ttfbMs != null ? ` 首字${c.ttfbMs}ms` : "";
260
+ const tps = c.tps != null ? ` ${c.tps}tok/s` : (c.charsPerSec ? ` ${c.charsPerSec}ch/s` : "");
261
+ const tok = c.usage?.completion_tokens != null ? ` tok${c.usage.completion_tokens}` : (c.chars ? ` ch${c.chars}` : "");
262
+ const tm = fmtTs(c.ts);
263
+ console.log(` ${tm} ${(c.model || "-").padEnd(28)} ${String(c.status || "-").padEnd(4)} ${dur ? dur + "ms" : ""}${ttfb}${tps}${tok}${c.auto ? " auto" : ""}`);
264
+ }
265
+ } else {
266
+ console.log(" (none yet — 发一次请求后出现,mslxdff -chat hi)");
267
+ }
268
+
269
+ console.log("\nlast error:");
270
+ const err = lastError();
271
+ if (err) {
272
+ console.log(` ${fmtTs(err.ts)} ${err.model || "-"} ${err.status} ${err.message || ""}`);
273
+ if (err.stack) console.log(` ${String(err.stack).slice(0, 200)}`);
274
+ } else {
275
+ console.log(" (none — 暂无错误,挺好)");
276
+ }
277
+
278
+ const daemon2 = readPid();
279
+ const alive2 = daemon2 ? isPidAlive(daemon2) : false;
280
+ if (daemon2 && alive2) console.log(`\nauth token: use \`mslxdff -showtoken\` · health: http://127.0.0.1:${port}/health`);
281
+ else console.log(`\nnot running — start with: mslxdff -d · 查看日志 mslxdff -log 20`);
282
+ console.log(`hints: mslxdff -providers list · mslxdff -model status · mslxdff -group list · mslxdff -autostart status`);
283
+ }
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { execFile } from "node:child_process";
3
+
4
+ export function errMsg(err) { return String(err?.message || err); }
5
+
6
+ export function readModelsCache(cacheFile) {
7
+ try {
8
+ return JSON.parse(readFileSync(cacheFile, "utf8"));
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ export function npmCmd() {
15
+ return process.platform === "win32" ? "npm.cmd" : "npm";
16
+ }
17
+
18
+ export function run(cmd, args, opts = {}) {
19
+ const isWin = process.platform === "win32";
20
+ return new Promise((resolve) => {
21
+ const execOpts = isWin ? { shell: true, windowsHide: true } : {};
22
+ execFile(cmd, args, { timeout: 120_000, ...execOpts, ...opts }, (err, stdout, stderr) => resolve({ err, stdout, stderr }));
23
+ });
24
+ }