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.
- package/bin/mslxdff.js +3 -3159
- package/package.json +1 -1
- package/src/chat/cooling.js +99 -0
- package/src/chat/direct.js +57 -0
- package/src/chat/gateway.js +148 -0
- package/src/chat/orchestrator.js +218 -0
- package/src/chat/sse.js +69 -0
- package/src/chat/upstream.js +59 -501
- package/src/cli/bootstrap.js +1 -0
- package/src/cli/commands/daemon.js +151 -0
- package/src/cli/commands/group.js +272 -0
- package/src/cli/commands/model.js +405 -0
- package/src/cli/commands/provider/add.js +106 -0
- package/src/cli/commands/provider/allowlist.js +99 -0
- package/src/cli/commands/provider/config.js +82 -0
- package/src/cli/commands/provider/index.js +193 -0
- package/src/cli/commands/provider/keys.js +135 -0
- package/src/cli/commands/provider/models.js +99 -0
- package/src/cli/commands/provider.js +1 -0
- package/src/cli/commands/sync.js +143 -0
- package/src/cli/commands/system.js +236 -0
- package/src/cli/commands/workbuddy.js +91 -0
- package/src/cli/format.js +119 -0
- package/src/cli/group-helpers.js +69 -0
- package/src/cli/help.js +66 -0
- package/src/cli/index.js +59 -0
- package/src/cli/interactive.js +84 -0
- package/src/cli/policy.js +119 -0
- package/src/cli/provider-row.js +73 -0
- package/src/cli/status.js +283 -0
- package/src/cli/util.js +24 -0
- package/src/providers/base.js +159 -0
- package/src/providers/dispatcher.js +18 -6
- package/src/providers/generic.js +28 -166
- package/src/providers/openrouter.js +19 -205
- package/src/providers/workbuddy/auth.js +175 -0
- package/src/providers/workbuddy/balance.js +84 -0
- package/src/providers/workbuddy/chat.js +310 -0
- package/src/providers/workbuddy/index.js +263 -0
- package/src/providers/workbuddy/models.js +111 -0
- package/src/providers/workbuddy/rotation-log.js +54 -0
- package/src/providers/workbuddy.js +2 -677
- package/src/routes/chat/broadband-handler.js +25 -46
- package/src/routes/chat/exhausted-handler.js +2 -2
- package/src/routes/chat/gateway.js +301 -0
- package/src/routes/chat/hedge-handler.js +65 -83
- package/src/routes/chat/index.js +1 -384
- package/src/routes/chat/local-handler.js +32 -61
- package/src/routes/chat/peer-handler.js +32 -23
- package/src/routes/chat/relay-pipeline.js +151 -0
- package/src/runtime/bootstrap.js +408 -0
- package/src/state/facade.js +57 -0
- package/src/state/memory.js +161 -0
- package/src/state/merge.js +26 -0
- package/src/state/persist.js +42 -0
- package/src/state/provider-config.js +143 -0
- package/src/state/schemas/allowlist.js +87 -0
- package/src/state/schemas/group.js +31 -0
- package/src/state/schemas/model.js +53 -0
- package/src/state/schemas/peer.js +31 -0
- package/src/state/schemas/port.js +10 -0
- package/src/state/schemas/provider.js +204 -0
- package/src/state/schemas/token.js +43 -0
- package/src/state/store.js +176 -0
- package/src/state.js +1 -711
package/bin/mslxdff.js
CHANGED
|
@@ -1,3160 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { dirname, join, basename } from "node:path";
|
|
6
|
-
import { startServer, resolvePort } from "../src/server.js";
|
|
7
|
-
import { DEFAULT_PORT, defaultStateFile } from "../src/state.js";
|
|
8
|
-
import { createRouter } from "../src/routes.js";
|
|
9
|
-
import { createUpstreamClient } from "../src/upstream.js";
|
|
10
|
-
import { createModelsService } from "../src/models.js";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors, savePreferredModel, loadPreferredModel, loadModelPicks, saveModelPicks, loadProviderKey, loadProviderKeys, loadProviderAuths, loadProviderConfigs as loadProviderConfigsState, loadModelStats, loadModelLatencies, loadProviderConfigs, loadProviderAllowedModels, loadProviderShareKeys, loadProviderBaseUrl } from "../src/state.js";
|
|
13
|
-
import { getPreferredModel } from "../src/auto.js";
|
|
14
|
-
import { normalizeModel } from "../src/reasoning.js";
|
|
15
|
-
import { syncToWorkbuddy, workbuddyModelsPath } from "../src/sync-workbuddy.js";
|
|
16
|
-
import { syncToOpencode, opencodeConfigPath, toExternalAlias, toInternalId } from "../src/sync-opencode.js";
|
|
17
|
-
import { startDaemon, stopDaemon, writePid, pidFile, logFile, readPid, readPidVersion, isPidAlive } from "../src/daemon.js";
|
|
18
|
-
import { createAutoSelector } from "../src/auto.js";
|
|
19
|
-
import { createPeersService } from "../src/peers.js";
|
|
20
|
-
import { createEventBus } from "../src/events.js";
|
|
21
|
-
import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../src/groups.js";
|
|
22
|
-
import { logDir, recentCalls, lastError, appendCall, appendError, appendEvent, recentEvents, eventsFile, callsFile, errorsFile } from "../src/logs.js";
|
|
23
|
-
import { loadPlugins, runHook, pluginsDir, resolvePluginDirs } from "../src/plugins.js";
|
|
24
|
-
import { createOpenCodeProvider } from "../src/providers/opencode.js";
|
|
25
|
-
import { fmtShanghai, fmtShanghaiYMDHM, fmtShanghaiHMS } from "../src/time.js";
|
|
26
|
-
|
|
27
|
-
const logs = { appendCall, appendError, appendEvent };
|
|
28
|
-
|
|
29
|
-
const args = process.argv.slice(2);
|
|
30
|
-
const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
31
|
-
const HEALTH_TIMEOUT_MS = 4000;
|
|
32
|
-
|
|
33
|
-
if (args.includes("-help") || args.includes("--help") || args.includes("-h")) {
|
|
34
|
-
printHelp();
|
|
35
|
-
process.exit(0);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (args.includes("-update") || args.includes("--update")) {
|
|
39
|
-
await updateSelf();
|
|
40
|
-
process.exit(0);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (args.includes("-refresh-token") || args.includes("--refresh-token")) {
|
|
44
|
-
const token = await refreshToken();
|
|
45
|
-
console.log(token);
|
|
46
|
-
process.exit(0);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (args.includes("-showtoken") || args.includes("--showtoken")) {
|
|
50
|
-
const { token } = await loadToken();
|
|
51
|
-
console.log(token);
|
|
52
|
-
process.exit(0);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (args.includes("-stop") || args.includes("--stop")) {
|
|
56
|
-
const { stopped, pid, reason } = stopDaemon();
|
|
57
|
-
if (stopped) {
|
|
58
|
-
console.log(`mslxdff daemon stopped (pid ${pid})`);
|
|
59
|
-
} else {
|
|
60
|
-
console.log(`mslxdff daemon not running${reason ? ` (${reason})` : ""}`);
|
|
61
|
-
}
|
|
62
|
-
process.exit(0);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (args.includes("-uninstall") || args.includes("--uninstall")) {
|
|
66
|
-
const { stopped, pid } = stopDaemon();
|
|
67
|
-
if (stopped) console.log(`mslxdff daemon stopped (pid ${pid})`);
|
|
68
|
-
else console.log("mslxdff daemon not running");
|
|
69
|
-
|
|
70
|
-
const stateFile = defaultStateFile();
|
|
71
|
-
const dir = dirname(stateFile);
|
|
72
|
-
const removed = [];
|
|
73
|
-
for (const f of [
|
|
74
|
-
stateFile,
|
|
75
|
-
pidFile(),
|
|
76
|
-
logFile(),
|
|
77
|
-
join(dir, "calls.log"),
|
|
78
|
-
join(dir, "errors.log"),
|
|
79
|
-
join(dir, "events.log"),
|
|
80
|
-
]) {
|
|
81
|
-
try {
|
|
82
|
-
rmSync(f, { force: true });
|
|
83
|
-
removed.push(f);
|
|
84
|
-
} catch {
|
|
85
|
-
// already gone, fine
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
if (removed.length) console.log(`removed ${removed.length} file(s):\n ${removed.join("\n ")}`);
|
|
89
|
-
else console.log("no state/log files to remove");
|
|
90
|
-
|
|
91
|
-
console.log("\npackage still installed — finish with:");
|
|
92
|
-
console.log(" npm uninstall -g mslxdff");
|
|
93
|
-
process.exit(0);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (args.includes("-log") || args.includes("--log") || args.includes("-logs") || args.includes("--logs")) {
|
|
97
|
-
const idx = args.findIndex((x) => x === "-log" || x === "--log" || x === "-logs" || x === "--logs");
|
|
98
|
-
const raw = args[idx + 1];
|
|
99
|
-
const n = Number(raw);
|
|
100
|
-
const count = Number.isInteger(n) && n > 0 ? n : 10;
|
|
101
|
-
const file = eventsFile();
|
|
102
|
-
const dir = logDir();
|
|
103
|
-
console.log(`log dir: ${dir}`);
|
|
104
|
-
console.log(`events: ${file}`);
|
|
105
|
-
const evts = recentEvents(count);
|
|
106
|
-
if (!evts.length) {
|
|
107
|
-
console.log(`(no events yet — file empty or not found)`);
|
|
108
|
-
} else {
|
|
109
|
-
console.log(`--- last ${evts.length} event(s) ---`);
|
|
110
|
-
for (const e of evts) console.log(fmtEvent(e));
|
|
111
|
-
}
|
|
112
|
-
// also hint for other logs
|
|
113
|
-
if (count <= 10) {
|
|
114
|
-
console.log(`\nhint: mslxdff -log 100 | calls: ${callsFile()} errors: ${errorsFile()} daemon: ${logFile()}`);
|
|
115
|
-
}
|
|
116
|
-
process.exit(0);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// -plugins: list plugins in the plugins dirs (and their hooks) without starting the daemon
|
|
120
|
-
if (args.includes("-plugins") || args.includes("--plugins")) {
|
|
121
|
-
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
122
|
-
const dirs = resolvePluginDirs({ pkgRoot });
|
|
123
|
-
const labels = ["official (bundled)", "user"];
|
|
124
|
-
if (dirs.length === 1) labels[0] = "dir";
|
|
125
|
-
console.log(`plugin dirs:`);
|
|
126
|
-
dirs.forEach((d, i) => console.log(` [${labels[i] || `dir${i + 1}`}] ${d}${existsSync(d) ? "" : " (not created yet)"}`));
|
|
127
|
-
const { plugins, errors } = await loadPlugins({ dirs });
|
|
128
|
-
if (!plugins.length && !errors.length) {
|
|
129
|
-
console.log("(no plugins — drop *.mjs files into a dir above, see docs/plugins.md)");
|
|
130
|
-
}
|
|
131
|
-
for (const p of plugins) {
|
|
132
|
-
const hooks = Object.keys(p.hooks || {});
|
|
133
|
-
const src = p.file.startsWith(pkgRoot) ? "official" : "user";
|
|
134
|
-
console.log(` ${p.name}${p.version ? `@${p.version}` : ""} [${hooks.join(", ") || "no hooks"}] (${src})`);
|
|
135
|
-
if (p.description) console.log(` ${p.description}`);
|
|
136
|
-
}
|
|
137
|
-
for (const e of errors) console.log(` load error: ${e.file} — ${e.error}`);
|
|
138
|
-
process.exit(0);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (args.includes("-chat") || args.includes("--chat")) {
|
|
142
|
-
const idx = args.findIndex((x) => x === "-chat" || x === "--chat");
|
|
143
|
-
const rest = args.slice(idx + 1);
|
|
144
|
-
// rest 中若全是空白或以 - 开头的“选项”,仍视作本次对话的输入(单次模式);
|
|
145
|
-
// 无 rest 则进入常驻 REPL。REPL 本身就是前台独立进程,与 daemon 无父子关系,daemon 重启不影响它。
|
|
146
|
-
const singleShot = rest.length ? rest.join(" ").trim() : null;
|
|
147
|
-
const { startChat } = await import("../src/chat/index.js");
|
|
148
|
-
await startChat({ singleShot: singleShot || undefined });
|
|
149
|
-
process.exit(0);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (args.includes("-status") || args.includes("--status") || args.includes("-s")) {
|
|
153
|
-
await printStatus();
|
|
154
|
-
process.exit(0);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// -model list | -models : show the free models this proxy serves (cache-first)
|
|
158
|
-
// -model refresh : force a fresh fetch from the upstream and update the cache
|
|
159
|
-
if (args.includes("-model") || args.includes("-models")) {
|
|
160
|
-
const idx = args.findIndex((x) => x === "-model" || x === "-models");
|
|
161
|
-
const sub = args[idx + 1];
|
|
162
|
-
if (sub === "refresh") {
|
|
163
|
-
const models = createModelsService({
|
|
164
|
-
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
165
|
-
headers: createUpstreamClient({}).headers,
|
|
166
|
-
refreshMs: 0,
|
|
167
|
-
cacheFile: join(logDir(), "models.json"),
|
|
168
|
-
});
|
|
169
|
-
try {
|
|
170
|
-
const list = await models.get();
|
|
171
|
-
const ids = (list.data || []).map((m) => m.id).filter(Boolean);
|
|
172
|
-
console.log(`refreshed: ${ids.length} free model(s)`);
|
|
173
|
-
for (const id of ids) console.log(` ${id}`);
|
|
174
|
-
} catch (err) {
|
|
175
|
-
console.error(`could not refresh models: ${String(err?.message || err)}`);
|
|
176
|
-
process.exit(1);
|
|
177
|
-
}
|
|
178
|
-
process.exit(0);
|
|
179
|
-
}
|
|
180
|
-
if (sub === "status") {
|
|
181
|
-
const statuses = loadModelErrors();
|
|
182
|
-
const cacheFile = join(logDir(), "models.json");
|
|
183
|
-
const cached = readModelsCache(cacheFile);
|
|
184
|
-
const ids = new Set([
|
|
185
|
-
...(cached?.data || []).map((m) => m.id),
|
|
186
|
-
...Object.keys(statuses),
|
|
187
|
-
]);
|
|
188
|
-
for (const id of ids) {
|
|
189
|
-
const e = statuses[id];
|
|
190
|
-
const st = typeof e === "number" ? "error" : e?.status || "normal";
|
|
191
|
-
const at = typeof e === "number" ? e : e?.at;
|
|
192
|
-
const when = at ? ` (${fmtShanghai(at)})` : "";
|
|
193
|
-
const extra = e?.code ? ` HTTP ${e.code}` : "";
|
|
194
|
-
console.log(` ${id} ${st}${when}${extra}`);
|
|
195
|
-
}
|
|
196
|
-
process.exit(0);
|
|
197
|
-
}
|
|
198
|
-
if (sub === "set" && args[idx + 2]) {
|
|
199
|
-
const id = args[idx + 2];
|
|
200
|
-
savePreferredModel(id);
|
|
201
|
-
const picks = [...new Set([...loadModelPicks(), id])];
|
|
202
|
-
saveModelPicks(picks);
|
|
203
|
-
console.log(`default model set to: ${id} (daemon hot-reloads on next request)`);
|
|
204
|
-
console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
|
|
205
|
-
process.exit(0);
|
|
206
|
-
}
|
|
207
|
-
// -model pick <id> | -model unpick <id> | -model picks : 常用模型勾选集管理(非 TTY 用)
|
|
208
|
-
if (sub === "pick" && args[idx + 2] && args[idx + 2] !== "clear") {
|
|
209
|
-
const picks = [...new Set([...loadModelPicks(), args[idx + 2]])];
|
|
210
|
-
saveModelPicks(picks);
|
|
211
|
-
console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
|
|
212
|
-
process.exit(0);
|
|
213
|
-
}
|
|
214
|
-
if (sub === "pick" && args[idx + 2] === "clear") {
|
|
215
|
-
saveModelPicks([]);
|
|
216
|
-
console.log("picks cleared — auto uses the full model list again");
|
|
217
|
-
process.exit(0);
|
|
218
|
-
}
|
|
219
|
-
if (sub === "unpick" && args[idx + 2]) {
|
|
220
|
-
const picks = loadModelPicks().filter((x) => x !== args[idx + 2]);
|
|
221
|
-
saveModelPicks(picks);
|
|
222
|
-
console.log(`picked: ${picks.join(", ") || "(none)"}${picks.length === 0 ? " (auto uses full list)" : ""}`);
|
|
223
|
-
process.exit(0);
|
|
224
|
-
}
|
|
225
|
-
if (sub === "picks") {
|
|
226
|
-
const picks = loadModelPicks();
|
|
227
|
-
if (!picks.length) {
|
|
228
|
-
console.log("no picks — auto uses the full model list");
|
|
229
|
-
} else {
|
|
230
|
-
console.log(`${picks.length} picked model(s), auto only selects within these:`);
|
|
231
|
-
}
|
|
232
|
-
for (const id of picks) console.log(` ${id}`);
|
|
233
|
-
process.exit(0);
|
|
234
|
-
}
|
|
235
|
-
// -model list supports optional provider filter: -model list --provider <id> | -model list <id> | --json
|
|
236
|
-
let modelListProvider = null;
|
|
237
|
-
let modelListJson = false;
|
|
238
|
-
if (sub === "list") {
|
|
239
|
-
const restArgs = args.slice(idx + 2);
|
|
240
|
-
for (let i = 0; i < restArgs.length; i++) {
|
|
241
|
-
const a = String(restArgs[i] || "");
|
|
242
|
-
if (a === "--json" || a === "-json") modelListJson = true;
|
|
243
|
-
else if (a === "--provider" || a === "-provider" || a === "--providerId") { modelListProvider = String(restArgs[i + 1] || "").trim() || null; i++; }
|
|
244
|
-
else if (!a.startsWith("-") && !modelListProvider) modelListProvider = a;
|
|
245
|
-
}
|
|
246
|
-
if (modelListProvider) {
|
|
247
|
-
const { normalizeProviderId } = await import("../src/providers/model-id.js");
|
|
248
|
-
const nid = normalizeProviderId(modelListProvider);
|
|
249
|
-
modelListProvider = nid || modelListProvider.toLowerCase();
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
if (sub !== undefined && sub !== "list") {
|
|
253
|
-
console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list [--provider <id>] [--json] | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
|
|
254
|
-
process.exit(1);
|
|
255
|
-
}
|
|
256
|
-
const cacheFile = join(logDir(), "models.json");
|
|
257
|
-
// 主动刷新:每次执行都尝试拉取上游最新列表(4s 超时),成功则更新缓存,失败则回退到 stale
|
|
258
|
-
async function tryRefreshModels() {
|
|
259
|
-
try {
|
|
260
|
-
const models = createModelsService({
|
|
261
|
-
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
262
|
-
headers: createUpstreamClient({}).headers,
|
|
263
|
-
refreshMs: 0,
|
|
264
|
-
cacheFile,
|
|
265
|
-
});
|
|
266
|
-
// 4s 超时,避免阻塞
|
|
267
|
-
const list = await Promise.race([
|
|
268
|
-
models.get(),
|
|
269
|
-
new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
|
|
270
|
-
]);
|
|
271
|
-
return list;
|
|
272
|
-
} catch {
|
|
273
|
-
return null;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
try {
|
|
277
|
-
let ids = [];
|
|
278
|
-
let cachedAt = null;
|
|
279
|
-
let refreshed = null;
|
|
280
|
-
// 每次都主动尝试刷新
|
|
281
|
-
refreshed = await tryRefreshModels();
|
|
282
|
-
if (refreshed?.data) {
|
|
283
|
-
ids = (refreshed.data || []).map((m) => m.id).filter(Boolean);
|
|
284
|
-
cachedAt = refreshed.cachedAt || Date.now();
|
|
285
|
-
} else {
|
|
286
|
-
const cached = readModelsCache(cacheFile);
|
|
287
|
-
if (cached) {
|
|
288
|
-
ids = (cached.data || []).map((m) => m.id).filter(Boolean);
|
|
289
|
-
cachedAt = cached.cachedAt || null;
|
|
290
|
-
} else {
|
|
291
|
-
// 无缓存且刷新失败,尝试一次兜底 fetch(已在 tryRefreshModels 中尝试过,此处直接报错)
|
|
292
|
-
throw new Error("no cached models and refresh failed");
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
// provider filter: bare ids are opencode, prefixed are <provider>/...
|
|
296
|
-
if (modelListProvider) {
|
|
297
|
-
const prov = String(modelListProvider).toLowerCase();
|
|
298
|
-
const filtered = ids.filter((id) => {
|
|
299
|
-
const slash = String(id).indexOf("/");
|
|
300
|
-
const p = slash > 0 ? String(id).slice(0, slash).toLowerCase() : "opencode";
|
|
301
|
-
return p === prov;
|
|
302
|
-
});
|
|
303
|
-
// 非 opencode 供应商的模型不在 opencode 缓存中,改从 allowlist 展示(原名+别名)
|
|
304
|
-
if (prov !== "opencode" && filtered.length === 0) {
|
|
305
|
-
try {
|
|
306
|
-
const { loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../src/state.js");
|
|
307
|
-
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
308
|
-
try { loadModelAliases(); } catch {}
|
|
309
|
-
const allowed = loadProviderAllowedModels(prov);
|
|
310
|
-
const allowAny = loadProviderAllowAnyModels(prov);
|
|
311
|
-
const baseUrl = loadProviderBaseUrl(prov);
|
|
312
|
-
if (modelListJson) {
|
|
313
|
-
const data = allowed.length
|
|
314
|
-
? allowed.map((raw) => ({ id: `${prov}/${raw}`, object: "model" }))
|
|
315
|
-
: [];
|
|
316
|
-
console.log(JSON.stringify({ object: "list", data }, null, 2));
|
|
317
|
-
process.exit(0);
|
|
318
|
-
}
|
|
319
|
-
if (!allowed.length) {
|
|
320
|
-
if (allowAny) {
|
|
321
|
-
console.log(`provider "${prov}" allowAny ON (allowlist 空=放行全部)${baseUrl ? ` baseUrl=${baseUrl}` : ""}`);
|
|
322
|
-
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${prov} models`);
|
|
323
|
-
} else {
|
|
324
|
-
console.log(`no models for provider "${prov}" — allowlist 空 + allowAny OFF = 阻塞`);
|
|
325
|
-
console.log(` 设白名单: mslxdff -provider ${prov} allowlist set <model1> <model2> 或 mslxdff -provider ${prov} allowAny on`);
|
|
326
|
-
console.log(` live 查看: mslxdff -provider ${prov} models`);
|
|
327
|
-
}
|
|
328
|
-
process.exit(0);
|
|
329
|
-
}
|
|
330
|
-
const at2 = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
331
|
-
console.log(`${allowed.length} model(s) for ${prov}${at2} (allowlist,原名 + 别名):`);
|
|
332
|
-
const pickedIds2 = loadModelPicks();
|
|
333
|
-
for (const raw of allowed) {
|
|
334
|
-
const canonical = `${prov}/${raw}`;
|
|
335
|
-
let alias = null;
|
|
336
|
-
try { alias = getAliasForModel(canonical); } catch {}
|
|
337
|
-
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
338
|
-
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
339
|
-
const mark2 = pickedIds2.includes(canonical) || (alias && pickedIds2.includes(alias)) ? "*" : " ";
|
|
340
|
-
console.log(` ${mark2} ${canonical}${aliasStr}`);
|
|
341
|
-
}
|
|
342
|
-
process.exit(0);
|
|
343
|
-
} catch {}
|
|
344
|
-
}
|
|
345
|
-
ids = filtered;
|
|
346
|
-
if (modelListJson) {
|
|
347
|
-
console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
|
|
348
|
-
process.exit(0);
|
|
349
|
-
}
|
|
350
|
-
if (!ids.length) {
|
|
351
|
-
console.log(`no models for provider "${prov}" — try: mslxdff -provider ${prov} models or mslxdff -model refresh`);
|
|
352
|
-
process.exit(0);
|
|
353
|
-
}
|
|
354
|
-
} else if (modelListJson) {
|
|
355
|
-
console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
|
|
356
|
-
process.exit(0);
|
|
357
|
-
}
|
|
358
|
-
if (!ids.length) {
|
|
359
|
-
console.log("no models available — try: mslxdff -model refresh");
|
|
360
|
-
process.exit(0);
|
|
361
|
-
}
|
|
362
|
-
// TTY 交互式:`mslxdff -models`(无 list)走交互;` -model list` 默认列表,但交互入口统一为 -models
|
|
363
|
-
// 为支持“opencode + 其他供应商 allowlist 原名/别名”一起勾选,交互池 = opencode 免费池 + 各供应商 allowlist + 已勾选的遗留 picks
|
|
364
|
-
if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
365
|
-
const statuses = loadModelErrors();
|
|
366
|
-
const current = getPreferredModel();
|
|
367
|
-
const pickedIds = loadModelPicks();
|
|
368
|
-
// 基础:opencode 免费池
|
|
369
|
-
const combinedIds = [...ids];
|
|
370
|
-
const seen = new Set(combinedIds);
|
|
371
|
-
try {
|
|
372
|
-
const { loadProviderConfigs, loadProviderAllowedModels } = await import("../src/state.js");
|
|
373
|
-
const configs = loadProviderConfigs();
|
|
374
|
-
for (const pid of Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode")) {
|
|
375
|
-
const allowed = loadProviderAllowedModels(pid);
|
|
376
|
-
for (const raw of allowed) {
|
|
377
|
-
const canonical = `${pid}/${raw}`;
|
|
378
|
-
if (!seen.has(canonical)) {
|
|
379
|
-
seen.add(canonical);
|
|
380
|
-
combinedIds.push(canonical);
|
|
381
|
-
}
|
|
382
|
-
// 别名(dash 版)若已被 picks 选中,也确保可见(便于取消)
|
|
383
|
-
const aliasDash = String(canonical).replace(/\//g, "-");
|
|
384
|
-
if (aliasDash !== canonical && pickedIds.includes(aliasDash) && !seen.has(aliasDash)) {
|
|
385
|
-
// 不直接加入 alias 作为独立选项,保留 canonical 即可(canonical 与 alias 视为同一模型,勾选 canonical)
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
} catch {}
|
|
390
|
-
// 已勾选但不在上述池中的(例如 workbuddy/* 当 allowAny ON 时无 allowlist,或历史 picks),补齐以便取消
|
|
391
|
-
for (const pid of pickedIds) {
|
|
392
|
-
if (!seen.has(pid)) {
|
|
393
|
-
seen.add(pid);
|
|
394
|
-
combinedIds.push(pid);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
const items = combinedIds.map((id) => {
|
|
398
|
-
const e = statuses[id];
|
|
399
|
-
return {
|
|
400
|
-
id,
|
|
401
|
-
status: typeof e === "number" ? "error" : e?.status || "normal",
|
|
402
|
-
current: id === current,
|
|
403
|
-
picked: pickedIds.includes(id),
|
|
404
|
-
};
|
|
405
|
-
});
|
|
406
|
-
const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
|
|
407
|
-
if (!result) {
|
|
408
|
-
console.log("cancelled — picks unchanged");
|
|
409
|
-
process.exit(0);
|
|
410
|
-
}
|
|
411
|
-
saveModelPicks([...result]);
|
|
412
|
-
console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
|
|
413
|
-
process.exit(0);
|
|
414
|
-
}
|
|
415
|
-
const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
416
|
-
const pickedIds = loadModelPicks();
|
|
417
|
-
const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
|
|
418
|
-
// 分组展示:按供应商前缀分组,opencode 裸 id 归为 opencode
|
|
419
|
-
const groups = {};
|
|
420
|
-
for (const id of ids) {
|
|
421
|
-
const prov = String(id).includes("/") ? String(id).split("/")[0] : "opencode";
|
|
422
|
-
if (!groups[prov]) groups[prov] = [];
|
|
423
|
-
groups[prov].push(id);
|
|
424
|
-
}
|
|
425
|
-
const order = ["opencode", "workbuddy", "clinebot", "openrouter"];
|
|
426
|
-
const sortedProvs = Object.keys(groups).sort((a, b) => {
|
|
427
|
-
const ia = order.indexOf(a), ib = order.indexOf(b);
|
|
428
|
-
if (ia !== -1 || ib !== -1) {
|
|
429
|
-
if (ia === -1) return 1;
|
|
430
|
-
if (ib === -1) return -1;
|
|
431
|
-
return ia - ib;
|
|
432
|
-
}
|
|
433
|
-
return a.localeCompare(b);
|
|
434
|
-
});
|
|
435
|
-
if (modelListProvider) {
|
|
436
|
-
console.log(`${ids.length} model(s) for ${modelListProvider}${at} (${pickedIds.length} picked, * = picked):`);
|
|
437
|
-
// 加载别名映射,显示原始名+别名
|
|
438
|
-
let aliasMap = {};
|
|
439
|
-
try {
|
|
440
|
-
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
441
|
-
loadModelAliases();
|
|
442
|
-
for (const id of ids) {
|
|
443
|
-
const alias = getAliasForModel(id);
|
|
444
|
-
if (alias) aliasMap[id] = alias;
|
|
445
|
-
else if (String(id).includes("/")) {
|
|
446
|
-
const dashAlias = String(id).replace(/\//g, "-");
|
|
447
|
-
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
} catch {}
|
|
451
|
-
for (const prov of sortedProvs) {
|
|
452
|
-
const list = groups[prov];
|
|
453
|
-
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
454
|
-
for (const id of list) {
|
|
455
|
-
const alias = aliasMap[id];
|
|
456
|
-
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
457
|
-
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
461
|
-
} else {
|
|
462
|
-
console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
|
|
463
|
-
// 加载别名映射,显示原始名+别名
|
|
464
|
-
let aliasMap = {};
|
|
465
|
-
let fullAliases = {};
|
|
466
|
-
try {
|
|
467
|
-
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
468
|
-
loadModelAliases();
|
|
469
|
-
for (const id of ids) {
|
|
470
|
-
const alias = getAliasForModel(id);
|
|
471
|
-
if (alias) aliasMap[id] = alias;
|
|
472
|
-
else if (String(id).includes("/")) {
|
|
473
|
-
const dashAlias = String(id).replace(/\//g, "-");
|
|
474
|
-
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
try {
|
|
478
|
-
const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
|
|
479
|
-
const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
|
|
480
|
-
if (raw && typeof raw === "object") fullAliases = raw;
|
|
481
|
-
} catch {}
|
|
482
|
-
} catch {}
|
|
483
|
-
for (const prov of sortedProvs) {
|
|
484
|
-
const list = groups[prov];
|
|
485
|
-
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
486
|
-
for (const id of list) {
|
|
487
|
-
const alias = aliasMap[id];
|
|
488
|
-
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
489
|
-
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
// ── 分隔 + 其他供应商 allowlist(原名 + 别名) ──
|
|
493
|
-
// 其余供应商的可用模型由 allowlist 决定(空 allowlist + allowAny OFF = 阻塞),此处直接展示 allowlist 条目与别名,不做网络拉取,速度快
|
|
494
|
-
try {
|
|
495
|
-
const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../src/state.js");
|
|
496
|
-
const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../src/providers/model-id.js");
|
|
497
|
-
try { _la2(); } catch {}
|
|
498
|
-
const configs = loadProviderConfigs();
|
|
499
|
-
const otherIds = Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode");
|
|
500
|
-
// 仅展示已配置过的供应商(有 baseUrl/keys/allowlist 任一),按固定顺序 + 字典序
|
|
501
|
-
const order2 = ["workbuddy", "clinebot", "openrouter", "bai"];
|
|
502
|
-
otherIds.sort((a, b) => {
|
|
503
|
-
const ia = order2.indexOf(a), ib = order2.indexOf(b);
|
|
504
|
-
if (ia !== -1 || ib !== -1) {
|
|
505
|
-
if (ia === -1) return 1;
|
|
506
|
-
if (ib === -1) return -1;
|
|
507
|
-
return ia - ib;
|
|
508
|
-
}
|
|
509
|
-
return a.localeCompare(b);
|
|
510
|
-
});
|
|
511
|
-
if (otherIds.length) {
|
|
512
|
-
console.log(`\n────────────────────────────────────────`);
|
|
513
|
-
console.log(`其他供应商 (allowlist,原名 + 别名) (${otherIds.length} providers):`);
|
|
514
|
-
for (const pid of otherIds) {
|
|
515
|
-
const allowed = loadProviderAllowedModels(pid);
|
|
516
|
-
const allowAny = loadProviderAllowAnyModels(pid);
|
|
517
|
-
const baseUrl = loadProviderBaseUrl(pid) || configs[pid]?.baseUrl || "";
|
|
518
|
-
const header = allowAny
|
|
519
|
-
? (allowed.length ? `allowlist ${allowed.length} (allowAny ON)` : `allowAny ON (allowlist 空=放行全部)`)
|
|
520
|
-
: (allowed.length ? `allowlist ${allowed.length} (allowAny OFF)` : `allowlist 空 + allowAny OFF = 阻塞`);
|
|
521
|
-
console.log(`\n ── ${pid} (${header})${baseUrl ? ` baseUrl=${baseUrl}` : ""} ──`);
|
|
522
|
-
if (!allowed.length) {
|
|
523
|
-
if (allowAny) {
|
|
524
|
-
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${pid} models`);
|
|
525
|
-
console.log(` 限制可用模型: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
526
|
-
} else {
|
|
527
|
-
console.log(` 阻塞中:无可用模型 — 设白名单: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
528
|
-
console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
|
|
529
|
-
}
|
|
530
|
-
} else {
|
|
531
|
-
for (const raw of allowed) {
|
|
532
|
-
const canonical = `${pid}/${raw}`;
|
|
533
|
-
let alias = null;
|
|
534
|
-
try { alias = _gaf(canonical); } catch {}
|
|
535
|
-
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
536
|
-
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
537
|
-
const pickedMark = pickedIds.includes(canonical) || pickedIds.includes(alias || "") ? "*" : " ";
|
|
538
|
-
console.log(` ${pickedMark} ${canonical}${aliasStr}`);
|
|
539
|
-
}
|
|
540
|
-
console.log(` 管理: mslxdff -provider ${pid} allowlist [list|add|remove|clear] | allowAny on|off`);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
} else {
|
|
544
|
-
console.log(`\n────────────────────────────────────────`);
|
|
545
|
-
console.log(`其他供应商 (allowlist,原名 + 别名): (none — 尚未配置)`);
|
|
546
|
-
console.log(` 添加示例: mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models`);
|
|
547
|
-
}
|
|
548
|
-
// 额外兜底:本地别名中不在任何 allowlist 里的,单独提示
|
|
549
|
-
const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => {
|
|
550
|
-
if (ids.includes(canonical)) return false;
|
|
551
|
-
// 已在 allowlist 中展示过的别名不重复
|
|
552
|
-
for (const pid of otherIds) {
|
|
553
|
-
const allowed = loadProviderAllowedModels(pid);
|
|
554
|
-
for (const raw of allowed) {
|
|
555
|
-
const can = `${pid}/${raw}`;
|
|
556
|
-
if (can === canonical) return false;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return true;
|
|
560
|
-
});
|
|
561
|
-
if (aliasEntries.length) {
|
|
562
|
-
console.log(`\n 本地别名 (不在 allowlist 里的遗留映射 ${aliasEntries.length}):`);
|
|
563
|
-
for (const [alias, canonical] of aliasEntries) {
|
|
564
|
-
console.log(` ${canonical} => ${alias}`);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
} catch {}
|
|
568
|
-
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
569
|
-
}
|
|
570
|
-
} catch (err) {
|
|
571
|
-
console.error(`could not fetch models: ${String(err?.message || err)}`);
|
|
572
|
-
process.exit(1);
|
|
573
|
-
}
|
|
574
|
-
process.exit(0);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// -setto workbuddy|opencode [modelId]: sync to WorkBuddy / opencode
|
|
578
|
-
if (args.includes("-setto") || args.includes("--setto")) {
|
|
579
|
-
const idx = args.findIndex((x) => x === "-setto" || x === "--setto");
|
|
580
|
-
const target = args[idx + 1];
|
|
581
|
-
if (!["workbuddy", "opencode"].includes(target)) {
|
|
582
|
-
console.error("usage: mslxdff -setto workbuddy [modelId] | mslxdff -setto opencode [modelId]");
|
|
583
|
-
process.exit(1);
|
|
584
|
-
}
|
|
585
|
-
if (target === "opencode") {
|
|
586
|
-
const raw = args[idx + 2] && !String(args[idx + 2]).startsWith("-") ? String(args[idx + 2]).trim() : null;
|
|
587
|
-
let id;
|
|
588
|
-
let internal;
|
|
589
|
-
if (raw) {
|
|
590
|
-
if (raw === "auto" || !raw) {
|
|
591
|
-
console.error("modelId 不能为 auto 或空");
|
|
592
|
-
process.exit(1);
|
|
593
|
-
}
|
|
594
|
-
const norm = normalizeModel(raw);
|
|
595
|
-
if (!norm) {
|
|
596
|
-
console.error("modelId 不能为空");
|
|
597
|
-
process.exit(1);
|
|
598
|
-
}
|
|
599
|
-
savePreferredModel(norm);
|
|
600
|
-
console.log(`default model set to: ${norm} (daemon hot-reloads on next request)`);
|
|
601
|
-
internal = toInternalId(norm);
|
|
602
|
-
id = toExternalAlias(internal);
|
|
603
|
-
} else {
|
|
604
|
-
const pref = loadPreferredModel() || getPreferredModel();
|
|
605
|
-
if (!pref) {
|
|
606
|
-
console.error("no preferred model set; use: mslxdff -setto opencode <modelId>");
|
|
607
|
-
process.exit(1);
|
|
608
|
-
}
|
|
609
|
-
const norm = normalizeModel(pref);
|
|
610
|
-
if (!norm) {
|
|
611
|
-
console.error("modelId 不能为空");
|
|
612
|
-
process.exit(1);
|
|
613
|
-
}
|
|
614
|
-
internal = toInternalId(norm);
|
|
615
|
-
id = toExternalAlias(internal);
|
|
616
|
-
}
|
|
617
|
-
try {
|
|
618
|
-
const cacheFile = join(logDir(), "models.json");
|
|
619
|
-
const models = createModelsService({
|
|
620
|
-
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
621
|
-
headers: createUpstreamClient({}).headers,
|
|
622
|
-
refreshMs: 0,
|
|
623
|
-
cacheFile,
|
|
624
|
-
});
|
|
625
|
-
const fresh = await Promise.race([
|
|
626
|
-
models.get(),
|
|
627
|
-
new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
|
|
628
|
-
]);
|
|
629
|
-
if (fresh?.data?.length) {
|
|
630
|
-
const ids = fresh.data.map((m) => m.id);
|
|
631
|
-
if (!ids.includes(internal) && !ids.includes(id)) {
|
|
632
|
-
console.log(`warn: "${internal}" not in current free list (${ids.length} models), still syncing to opencode (alias ${id})`);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
} catch {
|
|
636
|
-
// refresh failed, still proceed
|
|
637
|
-
}
|
|
638
|
-
try {
|
|
639
|
-
const { token } = await loadToken();
|
|
640
|
-
const persisted = getPort();
|
|
641
|
-
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
642
|
-
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
643
|
-
const file = opencodeConfigPath();
|
|
644
|
-
const r = await syncToOpencode({ id, token, port, file });
|
|
645
|
-
const aliasLabel = r.alias && r.alias !== r.id ? ` (alias ${r.alias} 对应内部 ${r.internal})` : (r.id !== r.internal ? ` (alias for "${r.internal}", 原名仍兼容)` : ` (原名兼容)`);
|
|
646
|
-
console.log(`synced to opencode: ${r.action} "${r.id}"${aliasLabel} @ ${file}`);
|
|
647
|
-
console.log(` url: http://127.0.0.1:${port}/v1`);
|
|
648
|
-
if (r.id !== r.internal) console.log(` alias: ${r.id} -> ${r.internal} (opencode 选 mslxdff/${r.id} 直达本地 ${r.internal})`);
|
|
649
|
-
else console.log(` alias: ${r.internal} (原名直用,opencode 选 mslxdff/${r.id} 直达本地 ${r.internal})`);
|
|
650
|
-
} catch (err) {
|
|
651
|
-
console.error(`failed to sync to opencode: ${String(err?.message || err)}`);
|
|
652
|
-
process.exit(1);
|
|
653
|
-
}
|
|
654
|
-
process.exit(0);
|
|
655
|
-
}
|
|
656
|
-
const raw = args[idx + 2] && !String(args[idx + 2]).startsWith("-") ? String(args[idx + 2]).trim() : null;
|
|
657
|
-
let id;
|
|
658
|
-
if (raw) {
|
|
659
|
-
if (raw === "auto" || !raw) {
|
|
660
|
-
console.error("modelId 不能为 auto 或空");
|
|
661
|
-
process.exit(1);
|
|
662
|
-
}
|
|
663
|
-
const norm = normalizeModel(raw);
|
|
664
|
-
if (!norm) {
|
|
665
|
-
console.error("modelId 不能为空");
|
|
666
|
-
process.exit(1);
|
|
667
|
-
}
|
|
668
|
-
savePreferredModel(norm);
|
|
669
|
-
console.log(`default model set to: ${norm} (daemon hot-reloads on next request)`);
|
|
670
|
-
id = norm;
|
|
671
|
-
} else {
|
|
672
|
-
id = loadPreferredModel() || getPreferredModel();
|
|
673
|
-
if (!id) {
|
|
674
|
-
console.error("no preferred model set; use: mslxdff -setto workbuddy <modelId>");
|
|
675
|
-
process.exit(1);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
// 主动刷新模型列表(每次 -setto 都尝试,保证 deepseek 等最新模型可见;失败不阻断同步)
|
|
679
|
-
try {
|
|
680
|
-
const cacheFile = join(logDir(), "models.json");
|
|
681
|
-
const models = createModelsService({
|
|
682
|
-
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
683
|
-
headers: createUpstreamClient({}).headers,
|
|
684
|
-
refreshMs: 0,
|
|
685
|
-
cacheFile,
|
|
686
|
-
});
|
|
687
|
-
const fresh = await Promise.race([
|
|
688
|
-
models.get(),
|
|
689
|
-
new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
|
|
690
|
-
]);
|
|
691
|
-
if (fresh?.data?.length) {
|
|
692
|
-
const ids = fresh.data.map((m) => m.id);
|
|
693
|
-
if (!ids.includes(id)) {
|
|
694
|
-
console.log(`warn: "${id}" not in current free list (${ids.length} models), still syncing to WorkBuddy`);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
} catch {
|
|
698
|
-
// refresh failed, still proceed with sync using stale/cached list
|
|
699
|
-
}
|
|
700
|
-
try {
|
|
701
|
-
const { token } = await loadToken();
|
|
702
|
-
const persisted = getPort();
|
|
703
|
-
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
704
|
-
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
705
|
-
const file = workbuddyModelsPath();
|
|
706
|
-
const r = await syncToWorkbuddy({ id, token, port, file });
|
|
707
|
-
console.log(`synced to WorkBuddy: ${r.action} "${id}" @ ${file}`);
|
|
708
|
-
console.log(` url: http://127.0.0.1:${port}/v1/chat/completions`);
|
|
709
|
-
} catch (err) {
|
|
710
|
-
console.error(`failed to sync to WorkBuddy: ${String(err?.message || err)}`);
|
|
711
|
-
process.exit(1);
|
|
712
|
-
}
|
|
713
|
-
process.exit(0);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// -workbuddy checkin/balance/list/remove : WorkBuddy 多号管理
|
|
717
|
-
if (args.includes("-workbuddy") || args.includes("--workbuddy") || args.includes("-wb")) {
|
|
718
|
-
const idx = args.findIndex((x) => x === "-workbuddy" || x === "--workbuddy" || x === "-wb");
|
|
719
|
-
const sub = args[idx + 1];
|
|
720
|
-
if (!sub || sub === "checkin" || sub === "daily-checkin" || sub === "check-in") {
|
|
721
|
-
const { spawn } = await import("node:child_process");
|
|
722
|
-
const script = join(dirname(fileURLToPath(import.meta.url)), "..", "workbuddy-checkin.js");
|
|
723
|
-
const child = spawn(process.execPath, [script, ...args.slice(idx + 2)], { stdio: "inherit" });
|
|
724
|
-
child.on("close", (code) => process.exit(code ?? 0));
|
|
725
|
-
child.on("error", (err) => { console.error(`workbuddy checkin failed: ${err.message}`); process.exit(1); });
|
|
726
|
-
await new Promise(() => {});
|
|
727
|
-
} else if (sub === "balance" || sub === "balances" || sub === "credit" || sub === "credits") {
|
|
728
|
-
const asJson = args.includes("--json") || args.includes("-json");
|
|
729
|
-
const { loadProviderConfigs } = await import("../src/state.js");
|
|
730
|
-
const { fetchBalance } = await import("../src/providers/workbuddy-balance.js");
|
|
731
|
-
const cfg = loadProviderConfigs().workbuddy || {};
|
|
732
|
-
const auths = Array.isArray(cfg.auths) ? cfg.auths : [];
|
|
733
|
-
const keys = Array.isArray(cfg.keys) ? cfg.keys : [];
|
|
734
|
-
// fallback scan auths dir if state empty
|
|
735
|
-
let items = auths.map((a,i)=> ({ uid:a.uid, domain:a.domain, key: keys[i]||"" , auth:a }));
|
|
736
|
-
if (!items.length) {
|
|
737
|
-
try {
|
|
738
|
-
const { readdirSync, readFileSync, existsSync } = await import("node:fs");
|
|
739
|
-
const { join } = await import("node:path");
|
|
740
|
-
const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
|
|
741
|
-
if (existsSync(dir)) {
|
|
742
|
-
for (const f of readdirSync(dir).filter(x=>x.startsWith("workbuddy-")&&x.endsWith(".json"))) {
|
|
743
|
-
try { const j=JSON.parse(readFileSync(join(dir,f),"utf8")); if(j?.account?.uid) items.push({uid:j.account.uid, domain:j.auth.domain||"www.codebuddy.cn", key:j.auth.accessToken, auth:{uid:j.account.uid, domain:j.auth.domain||"www.codebuddy.cn", enterpriseId:j.account.enterpriseId||""}}); } catch {}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
} catch {}
|
|
747
|
-
}
|
|
748
|
-
if (!items.length) { console.log("no workbuddy accounts — run node workbuddy-token-auto.js"); process.exit(0); }
|
|
749
|
-
const results=[];
|
|
750
|
-
for (const it of items) {
|
|
751
|
-
const b = await fetchBalance({ uid: it.uid, key: it.key, auth: it.auth }).catch(()=>null);
|
|
752
|
-
results.push({ uid: it.uid, domain: it.domain, balance: b });
|
|
753
|
-
}
|
|
754
|
-
if (asJson) { console.log(JSON.stringify({ results }, null, 2)); process.exit(0); }
|
|
755
|
-
console.log(`workbuddy balances (${results.length}):`);
|
|
756
|
-
for (const r of results) {
|
|
757
|
-
const b=r.balance;
|
|
758
|
-
if (!b) console.log(` ${r.uid} (balance unavailable) domain=${r.domain}`);
|
|
759
|
-
else console.log(` ${r.uid} total=${b.totalStr||b.total} dailyPacks=${b.dailyPacks} active=${b.activeCount} nextExpire=${b.nextExpire||"-"} domain=${r.domain}`);
|
|
760
|
-
}
|
|
761
|
-
process.exit(0);
|
|
762
|
-
} else if (sub === "list" || sub === "ls" || sub === "status") {
|
|
763
|
-
const { loadProviderConfigs } = await import("../src/state.js");
|
|
764
|
-
const cfg = loadProviderConfigs().workbuddy || {};
|
|
765
|
-
const auths = Array.isArray(cfg.auths) ? cfg.auths : [];
|
|
766
|
-
const keys = Array.isArray(cfg.keys) ? cfg.keys : [];
|
|
767
|
-
if (!auths.length) { console.log("no workbuddy accounts in state — check auths/workbuddy-*.json"); process.exit(0); }
|
|
768
|
-
console.log(`workbuddy accounts (${auths.length}):`);
|
|
769
|
-
auths.forEach((a,i)=> {
|
|
770
|
-
const k=(keys[i]||"").slice(0,4);
|
|
771
|
-
console.log(` [${i+1}] uid=${a.uid} domain=${a.domain||"www.codebuddy.cn"} enterprise=${a.enterpriseId||"-"} key=${k?k+"…":"(none)"} refresh=${a.refreshToken?"yes":"no"}`);
|
|
772
|
-
});
|
|
773
|
-
process.exit(0);
|
|
774
|
-
} else if (sub === "remove" || sub === "rm" || sub === "del" || sub === "delete") {
|
|
775
|
-
const target = args[idx+2];
|
|
776
|
-
if (!target) { console.error("usage: mslxdff -workbuddy remove <uid> [--keep-file]"); process.exit(1); }
|
|
777
|
-
const keep = args.includes("--keep-file");
|
|
778
|
-
const { loadProviderConfigs, saveProviderConfig } = await import("../src/state.js");
|
|
779
|
-
const cfg = loadProviderConfigs().workbuddy || {};
|
|
780
|
-
let auths = Array.isArray(cfg.auths)? [...cfg.auths]:[];
|
|
781
|
-
let keys = Array.isArray(cfg.keys)? [...cfg.keys]:[];
|
|
782
|
-
// support prefix match 6 chars
|
|
783
|
-
let rmIdx = auths.findIndex(a=> a.uid===target || a.uid.startsWith(target));
|
|
784
|
-
if (rmIdx<0) { console.error(`uid not found: ${target}`); process.exit(1); }
|
|
785
|
-
const uid = auths[rmIdx].uid;
|
|
786
|
-
auths.splice(rmIdx,1); keys.splice(rmIdx,1);
|
|
787
|
-
saveProviderConfig("workbuddy", { baseUrl: cfg.baseUrl||"https://copilot.tencent.com", keys, auths });
|
|
788
|
-
if (!keep) {
|
|
789
|
-
try {
|
|
790
|
-
const { existsSync, unlinkSync } = await import("node:fs");
|
|
791
|
-
const { join } = await import("node:path");
|
|
792
|
-
const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
|
|
793
|
-
const fp = join(dir, `workbuddy-${uid}.json`);
|
|
794
|
-
if (existsSync(fp)) { unlinkSync(fp); console.log(`removed file ${fp}`); }
|
|
795
|
-
} catch {}
|
|
796
|
-
}
|
|
797
|
-
try { const { getBalanceCache } = await import("../src/providers/workbuddy-balance.js"); getBalanceCache().delete(uid); } catch {}
|
|
798
|
-
console.log(`removed workbuddy ${uid} (now ${auths.length} account(s))`);
|
|
799
|
-
process.exit(0);
|
|
800
|
-
} else {
|
|
801
|
-
console.error("usage: mslxdff -workbuddy checkin | balance [--json] | list | remove <uid>");
|
|
802
|
-
process.exit(1);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// -enable-autostart / -disable-autostart / -autostart status : 开机自启(Windows schtasks / Linux systemd user)
|
|
807
|
-
if (args.includes("-enable-autostart") || args.includes("--enable-autostart") || args.includes("-disable-autostart") || args.includes("--disable-autostart") || args.includes("-autostart") || args.includes("--autostart")) {
|
|
808
|
-
const { enableAutostart, disableAutostart, getAutostartStatus, autostartHelp } = await import("../src/autostart.js");
|
|
809
|
-
if (args.includes("-enable-autostart") || args.includes("--enable-autostart")) {
|
|
810
|
-
const r = await enableAutostart();
|
|
811
|
-
if (r.ok) {
|
|
812
|
-
console.log(`autostart 已启用 · ${r.method || autostartHelp()}`);
|
|
813
|
-
console.log(`验证: mslxdff -autostart status`);
|
|
814
|
-
} else {
|
|
815
|
-
console.error(`启用自启失败: ${r.error || "unknown"}`);
|
|
816
|
-
console.error(`提示: Windows 需允许任务计划,Linux 需 systemd --user`);
|
|
817
|
-
process.exit(1);
|
|
818
|
-
}
|
|
819
|
-
process.exit(0);
|
|
820
|
-
}
|
|
821
|
-
if (args.includes("-disable-autostart") || args.includes("--disable-autostart")) {
|
|
822
|
-
const r = await disableAutostart();
|
|
823
|
-
if (r.ok) console.log(`autostart 已禁用`);
|
|
824
|
-
else { console.error(`禁用失败: ${r.error}`); process.exit(1); }
|
|
825
|
-
process.exit(0);
|
|
826
|
-
}
|
|
827
|
-
// -autostart / -autostart status
|
|
828
|
-
const idx = args.findIndex((x) => x === "-autostart" || x === "--autostart");
|
|
829
|
-
const sub = args[idx + 1];
|
|
830
|
-
if (!sub || sub === "status" || sub === "list") {
|
|
831
|
-
const s = await getAutostartStatus();
|
|
832
|
-
console.log(`autostart: ${s.enabled ? "已启用" : "未启用"} · ${s.detail || autostartHelp()}`);
|
|
833
|
-
if (s.taskToRun) console.log(` task: ${s.taskToRun}`);
|
|
834
|
-
if (s.unit) console.log(` unit: ${s.unit}`);
|
|
835
|
-
process.exit(0);
|
|
836
|
-
}
|
|
837
|
-
console.error("usage: mslxdff -enable-autostart | mslxdff -disable-autostart | mslxdff -autostart status");
|
|
838
|
-
process.exit(1);
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// -free / -free-check / --free / -free-watch : V2EX 限免白嫖雷达(仅 V2EX 单源)
|
|
842
|
-
if (args.includes("-free") || args.includes("--free") || args.includes("-free-check") || args.includes("--free-check") || args.includes("-free-watch") || args.includes("--free-watch")) {
|
|
843
|
-
const isWatch = args.includes("-free-watch") || args.includes("--free-watch");
|
|
844
|
-
const { fetchV2exFree } = await import("../src/free-watcher.js");
|
|
845
|
-
const show = async () => {
|
|
846
|
-
const hits = await fetchV2exFree({ timeoutMs: 6000 });
|
|
847
|
-
const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
848
|
-
console.log(`[V2EX] free check @ ${ts} — ${hits.length} hit(s)`);
|
|
849
|
-
if (!hits.length) {
|
|
850
|
-
console.log("(暂无命中 — 关键词:白嫖|限免|免费额度|注册送|羊毛,来源:/api/topics/latest.json + hot.json)");
|
|
851
|
-
} else {
|
|
852
|
-
for (const h of hits) console.log(` ${h.title} | ${h.url} | ${h.node} ${h.replies}回复`);
|
|
853
|
-
}
|
|
854
|
-
};
|
|
855
|
-
if (!isWatch) {
|
|
856
|
-
try { await show(); } catch (err) { console.error(`V2EX 拉取失败: ${err?.message || err}`); process.exit(1); }
|
|
857
|
-
process.exit(0);
|
|
858
|
-
}
|
|
859
|
-
console.log("V2EX 白嫖雷达 watch 模式 — 每 5 分钟拉一次 Ctrl+C 退出");
|
|
860
|
-
const run = async () => {
|
|
861
|
-
try { await show(); } catch (err) { console.error(`[${new Date().toISOString().slice(11,19)}] 拉取失败: ${err?.message || err}`); }
|
|
862
|
-
console.log("---");
|
|
863
|
-
};
|
|
864
|
-
await run();
|
|
865
|
-
const timer = setInterval(run, 5 * 60 * 1000);
|
|
866
|
-
timer.unref?.();
|
|
867
|
-
// 保持前台常驻
|
|
868
|
-
await new Promise(() => {});
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// -providers list : list all deployed upstream providers (opencode + openrouter + generic)
|
|
872
|
-
if (args.includes("-providers") || args.includes("--providers")) {
|
|
873
|
-
const idx = args.findIndex((x) => x === "-providers" || x === "--providers");
|
|
874
|
-
const sub = args[idx + 1];
|
|
875
|
-
if (!sub || sub === "list" || sub === "status") {
|
|
876
|
-
const { loadProviderConfigs, loadProviderKeys, loadProviderShareKeys, loadProviderBaseUrl, loadProviderAllowedModels } = await import("../src/state.js");
|
|
877
|
-
const configs = loadProviderConfigs();
|
|
878
|
-
const opencodeEnabled = true;
|
|
879
|
-
const opencodeBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
880
|
-
const orKeys = loadProviderKeys("openrouter");
|
|
881
|
-
const orBase = "https://openrouter.ai/api/v1";
|
|
882
|
-
const orShare = loadProviderShareKeys("openrouter");
|
|
883
|
-
const orAllowed = loadProviderAllowedModels("openrouter");
|
|
884
|
-
// collect generic ids from configs + legacy providerKeys
|
|
885
|
-
const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode" && id !== "openrouter"));
|
|
886
|
-
try {
|
|
887
|
-
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
888
|
-
const pk = raw.providerKeys || {};
|
|
889
|
-
for (const id of Object.keys(pk)) if (id !== "opencode" && id !== "openrouter") genericIds.add(id);
|
|
890
|
-
const cfgRaw = raw.providerConfigs || {};
|
|
891
|
-
for (const id of Object.keys(cfgRaw)) if (id !== "opencode" && id !== "openrouter") genericIds.add(id);
|
|
892
|
-
} catch {}
|
|
893
|
-
// also include env-only generic ids (scan env for MSLXDFF_*_KEY)
|
|
894
|
-
for (const k of Object.keys(process.env)) {
|
|
895
|
-
const m = k.match(/^MSLXDFF_(.+)_KEY$/);
|
|
896
|
-
if (m) {
|
|
897
|
-
const id = m[1].toLowerCase().replace(/__/g, "-");
|
|
898
|
-
if (id !== "openrouter" && id !== "opencode") genericIds.add(id);
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
// include providers that only have allowlist (no keys yet) — exclude built-ins already listed
|
|
902
|
-
try {
|
|
903
|
-
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
904
|
-
const cfgs = raw.providerConfigs || {};
|
|
905
|
-
for (const id of Object.keys(cfgs)) {
|
|
906
|
-
if (id === "opencode" || id === "openrouter") continue;
|
|
907
|
-
const am = cfgs[id]?.allowedModels;
|
|
908
|
-
if (Array.isArray(am) && am.length) genericIds.add(id);
|
|
909
|
-
}
|
|
910
|
-
} catch {}
|
|
911
|
-
const list = [];
|
|
912
|
-
const { loadProviderAllowAnyModels: _la0 } = await import("../src/state.js");
|
|
913
|
-
const opAllowAny = _la0("opencode");
|
|
914
|
-
const orAllowAny = _la0("openrouter");
|
|
915
|
-
const opAllowed = loadProviderAllowedModels("opencode");
|
|
916
|
-
list.push({ id: "opencode", enabled: opencodeEnabled, baseUrl: opencodeBase, keys: [], share: false, allowed: opAllowed, allowAny: opAllowAny, note: "built-in, no key, cannot share" });
|
|
917
|
-
list.push({ id: "openrouter", enabled: orKeys.length > 0, baseUrl: orBase, keys: orKeys, share: orShare, allowed: orAllowed, allowAny: orAllowAny, note: orKeys.length ? "" : "no keys" });
|
|
918
|
-
for (const gid of [...genericIds].sort()) {
|
|
919
|
-
const cfg = configs[gid];
|
|
920
|
-
const keys = loadProviderKeys(gid);
|
|
921
|
-
const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || "";
|
|
922
|
-
const share = loadProviderShareKeys(gid);
|
|
923
|
-
const allowed = loadProviderAllowedModels(gid);
|
|
924
|
-
const allowAny = _la0(gid);
|
|
925
|
-
const enabled = Boolean(baseUrl && keys.length);
|
|
926
|
-
let note = "";
|
|
927
|
-
if (!baseUrl && !keys.length && !allowed.length && allowAny === false) note = "no baseUrl, no keys, BLOCKED (allowAny OFF)";
|
|
928
|
-
else if (!baseUrl && !keys.length && !allowed.length) note = "no baseUrl, no keys";
|
|
929
|
-
else if (!baseUrl && !allowed.length && !allowAny) note = "missing baseUrl, BLOCKED";
|
|
930
|
-
else if (!keys.length && !allowed.length && !allowAny) note = "no keys, BLOCKED";
|
|
931
|
-
else if (!baseUrl) note = "missing baseUrl";
|
|
932
|
-
else if (!keys.length) note = "no keys";
|
|
933
|
-
list.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, share, allowed, allowAny, note });
|
|
934
|
-
}
|
|
935
|
-
console.log(`providers (${list.length}):`);
|
|
936
|
-
for (const p of list) {
|
|
937
|
-
const state = p.enabled ? "enabled " : "disabled";
|
|
938
|
-
const keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}` : "0 keys";
|
|
939
|
-
const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
|
|
940
|
-
const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 3).join(",")}${p.allowed.length > 3 ? "..." : ""})` : (p.allowAny ? "allow=all" : "allow=none(BLOCKED)");
|
|
941
|
-
const note = p.note ? ` (${p.note})` : "";
|
|
942
|
-
console.log(` ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(28)} ${allowInfo.padEnd(22)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
|
|
943
|
-
}
|
|
944
|
-
console.log(`\nuse: mslxdff -provider <id> list to inspect one, mslxdff -provider <id> allowlist set <model...> to restrict`);
|
|
945
|
-
console.log(` mslxdff -provider <id> allowAny on|off (empty allowlist = block or allow all)`);
|
|
946
|
-
process.exit(0);
|
|
947
|
-
}
|
|
948
|
-
console.error("usage: mslxdff -providers list");
|
|
949
|
-
process.exit(1);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// -provider <id> [key...|add|remove|list|clear]: 配置需鉴权的供应商 API key(如 openrouter)。
|
|
953
|
-
// 行为:无参数且 TTY → 交互式隐藏输入(多行直到空行,逐一追加);非 TTY → 打印用法。key 持久化到 state。
|
|
954
|
-
if (args.includes("-provider") || args.includes("--provider")) {
|
|
955
|
-
const idx = args.findIndex((x) => x === "-provider" || x === "--provider");
|
|
956
|
-
const id = args[idx + 1];
|
|
957
|
-
const sub = args[idx + 2];
|
|
958
|
-
const rest = args.slice(idx + 2);
|
|
959
|
-
if (!id) {
|
|
960
|
-
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|share|set-url|allowlist|allowAny]");
|
|
961
|
-
console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
|
|
962
|
-
console.error(" mslxdff -provider openrouter add sk-4 append one key");
|
|
963
|
-
console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
|
|
964
|
-
console.error(" mslxdff -provider openrouter list list all keys (masked)");
|
|
965
|
-
console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
|
|
966
|
-
console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
|
|
967
|
-
console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
|
|
968
|
-
console.error(" mslxdff -provider openrouter set-models-path /v1/models");
|
|
969
|
-
console.error(" mslxdff -provider openrouter set-chat-path /v1/chat/completions");
|
|
970
|
-
console.error(" mslxdff -provider openrouter allowlist set gpt-4 gpt-3.5 manage allowed models (empty=block unless allowAny ON)");
|
|
971
|
-
console.error(" mslxdff -provider openrouter allowAny on|off empty allowlist = allow all or block all (default OFF, secure)");
|
|
972
|
-
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx add generic OpenAI-compatible provider");
|
|
973
|
-
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 add with allowlist");
|
|
974
|
-
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models --chat-path /v1/chat/completions");
|
|
975
|
-
console.error(" mslxdff -provider openrouter interactive hidden input (append)");
|
|
976
|
-
console.error(" mslxdff -provider openrouter clear remove all keys");
|
|
977
|
-
process.exit(1);
|
|
978
|
-
}
|
|
979
|
-
// alias: mslxdff -provider list (same as -providers list) — list all providers
|
|
980
|
-
if (id === "list" || id === "status") {
|
|
981
|
-
const { loadProviderConfigs, loadProviderKeys, loadProviderShareKeys, loadProviderBaseUrl } = await import("../src/state.js");
|
|
982
|
-
const configs = loadProviderConfigs();
|
|
983
|
-
const opencodeBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
984
|
-
const orKeys = loadProviderKeys("openrouter");
|
|
985
|
-
const orBase = "https://openrouter.ai/api/v1";
|
|
986
|
-
const orShare = loadProviderShareKeys("openrouter");
|
|
987
|
-
const genericIds = new Set(Object.keys(configs).filter((x) => x !== "opencode" && x !== "openrouter"));
|
|
988
|
-
try {
|
|
989
|
-
const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
|
|
990
|
-
const pk = raw.providerKeys || {};
|
|
991
|
-
for (const k of Object.keys(pk)) if (k !== "opencode" && k !== "openrouter") genericIds.add(k);
|
|
992
|
-
} catch {}
|
|
993
|
-
for (const k of Object.keys(process.env)) {
|
|
994
|
-
const m = k.match(/^MSLXDFF_(.+)_KEY$/);
|
|
995
|
-
if (m) {
|
|
996
|
-
const gid = m[1].toLowerCase().replace(/__/g, "-");
|
|
997
|
-
if (gid !== "openrouter" && gid !== "opencode") genericIds.add(gid);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
const list = [];
|
|
1001
|
-
list.push({ id: "opencode", enabled: true, baseUrl: opencodeBase, keys: [], share: false, note: "built-in, no key, cannot share" });
|
|
1002
|
-
list.push({ id: "openrouter", enabled: orKeys.length > 0, baseUrl: orBase, keys: orKeys, share: orShare, note: orKeys.length ? "" : "no keys" });
|
|
1003
|
-
for (const gid of [...genericIds].sort()) {
|
|
1004
|
-
const cfg = configs[gid];
|
|
1005
|
-
const keys = loadProviderKeys(gid);
|
|
1006
|
-
const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || "";
|
|
1007
|
-
const share = loadProviderShareKeys(gid);
|
|
1008
|
-
const enabled = Boolean(baseUrl && keys.length);
|
|
1009
|
-
let note = "";
|
|
1010
|
-
if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
|
|
1011
|
-
else if (!baseUrl) note = "missing baseUrl";
|
|
1012
|
-
else if (!keys.length) note = "no keys";
|
|
1013
|
-
list.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, share, note });
|
|
1014
|
-
}
|
|
1015
|
-
console.log(`providers (${list.length}):`);
|
|
1016
|
-
for (const p of list) {
|
|
1017
|
-
const state = p.enabled ? "enabled " : "disabled";
|
|
1018
|
-
const keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}` : "0 keys";
|
|
1019
|
-
const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
|
|
1020
|
-
const note = p.note ? ` (${p.note})` : "";
|
|
1021
|
-
console.log(` ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(28)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
|
|
1022
|
-
}
|
|
1023
|
-
console.log(`\nuse: mslxdff -provider <id> list to inspect one, mslxdff -provider add <id> <baseUrl> <key> to add generic`);
|
|
1024
|
-
process.exit(0);
|
|
1025
|
-
}
|
|
1026
|
-
// 通用供应商快捷添加:mslxdff -provider add <id> <baseUrl> <key> [allowedModel...]
|
|
1027
|
-
if (id === "add") {
|
|
1028
|
-
const gid = sub;
|
|
1029
|
-
const gBase = rest[1];
|
|
1030
|
-
const gKey = rest[2];
|
|
1031
|
-
if (!gid || !gBase || !gKey) {
|
|
1032
|
-
console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...] [--models-path <path>] [--chat-path <path>]");
|
|
1033
|
-
console.error(" e.g. mslxdff -provider add myapi https://api.example.com/v1 sk-xxx");
|
|
1034
|
-
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 gpt-3.5");
|
|
1035
|
-
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models --chat-path /v1/chat/completions");
|
|
1036
|
-
process.exit(1);
|
|
1037
|
-
}
|
|
1038
|
-
if (gid === "opencode" || gid === "oc" || gid === "openrouter") {
|
|
1039
|
-
console.error(`provider "${gid}" is built-in — use: mslxdff -provider ${gid} add <key> or mslxdff -provider ${gid} set-url <url>`);
|
|
1040
|
-
process.exit(1);
|
|
1041
|
-
}
|
|
1042
|
-
const { saveProviderConfig, loadProviderConfig, loadProviderShareKeys, loadProviderAllowedModels } = await import("../src/state.js");
|
|
1043
|
-
const { normalizeProviderId } = await import("../src/providers/model-id.js");
|
|
1044
|
-
const nid = normalizeProviderId(gid);
|
|
1045
|
-
if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
|
|
1046
|
-
if (!/^https?:\/\/.+/.test(String(gBase).trim())) { console.error(`invalid baseUrl: ${gBase} (must start with http:// or https://)`); process.exit(1); }
|
|
1047
|
-
const cur = loadProviderConfig(nid) || { baseUrl: "", keys: [], allowedModels: [], auths: [], modelsPath: "", chatPath: "" };
|
|
1048
|
-
let keys, auths, baseUrl;
|
|
1049
|
-
baseUrl = String(gBase).trim();
|
|
1050
|
-
// parse --models-path / --chat-path from tail
|
|
1051
|
-
let parsedModelsPath = null;
|
|
1052
|
-
let parsedChatPath = null;
|
|
1053
|
-
const extraTokens = [];
|
|
1054
|
-
for (let _i = 3; _i < rest.length; _i++) {
|
|
1055
|
-
const tok = String(rest[_i] || "");
|
|
1056
|
-
if (tok === "--models-path" || tok === "--modelsPath" || tok === "--models_path") { parsedModelsPath = String(rest[_i + 1] || "").trim() || null; _i++; }
|
|
1057
|
-
else if (tok.startsWith("--models-path=")) { parsedModelsPath = tok.slice("--models-path=".length).trim() || null; }
|
|
1058
|
-
else if (tok === "--chat-path" || tok === "--chatPath" || tok === "--chat_path") { parsedChatPath = String(rest[_i + 1] || "").trim() || null; _i++; }
|
|
1059
|
-
else if (tok.startsWith("--chat-path=")) { parsedChatPath = tok.slice("--chat-path=".length).trim() || null; }
|
|
1060
|
-
else extraTokens.push(tok);
|
|
1061
|
-
}
|
|
1062
|
-
if (parsedModelsPath && !String(parsedModelsPath).startsWith("/")) { console.error(`invalid --models-path: ${parsedModelsPath} (must start with /)`); process.exit(1); }
|
|
1063
|
-
if (parsedChatPath && !String(parsedChatPath).startsWith("/")) { console.error(`invalid --chat-path: ${parsedChatPath} (must start with /)`); process.exit(1); }
|
|
1064
|
-
const extraModels = extraTokens.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
|
|
1065
|
-
const allowedModels = extraModels.length ? [...new Set([...(cur.allowedModels || []), ...extraModels])] : (cur.allowedModels || []);
|
|
1066
|
-
if (nid === "workbuddy") {
|
|
1067
|
-
// workbuddy: keys/auths 一一对应,需解析 uid
|
|
1068
|
-
const token = String(gKey).trim();
|
|
1069
|
-
let uid = "";
|
|
1070
|
-
try { const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString()); uid = payload.uid || payload.sub || payload.userId || payload.user_id || ""; } catch {}
|
|
1071
|
-
if (!uid) uid = "manual-" + token.slice(-8);
|
|
1072
|
-
const curKeys = Array.isArray(cur.keys) ? [...cur.keys] : [];
|
|
1073
|
-
const curAuths = Array.isArray(cur.auths) ? [...cur.auths] : [];
|
|
1074
|
-
let idx = curAuths.findIndex(a => a.uid === uid);
|
|
1075
|
-
if (idx < 0) idx = curKeys.findIndex(k => k === token);
|
|
1076
|
-
let newKeys, newAuths;
|
|
1077
|
-
if (idx >= 0) {
|
|
1078
|
-
newKeys = [...curKeys]; newKeys[idx] = token;
|
|
1079
|
-
newAuths = [...curAuths]; newAuths[idx] = { uid, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
|
|
1080
|
-
// if auths shorter than keys, pad
|
|
1081
|
-
while (newAuths.length < newKeys.length) newAuths.push({ uid: `manual-${newKeys[newAuths.length].slice(-8)}`, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" });
|
|
1082
|
-
} else {
|
|
1083
|
-
newKeys = [...new Set([...curKeys, token].filter(Boolean))];
|
|
1084
|
-
newAuths = [...curAuths, { uid, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" }];
|
|
1085
|
-
// align lengths
|
|
1086
|
-
while (newAuths.length < newKeys.length) newAuths.push({ uid: `manual-${newKeys[newAuths.length].slice(-8)}`, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" });
|
|
1087
|
-
while (newKeys.length < newAuths.length) newKeys.push(token);
|
|
1088
|
-
}
|
|
1089
|
-
keys = newKeys; auths = newAuths;
|
|
1090
|
-
const cfgToSave = { baseUrl, keys, auths, allowedModels };
|
|
1091
|
-
if (parsedModelsPath) cfgToSave.modelsPath = parsedModelsPath;
|
|
1092
|
-
else if (cur.modelsPath) cfgToSave.modelsPath = cur.modelsPath;
|
|
1093
|
-
if (parsedChatPath) cfgToSave.chatPath = parsedChatPath;
|
|
1094
|
-
else if (cur.chatPath) cfgToSave.chatPath = cur.chatPath;
|
|
1095
|
-
saveProviderConfig(nid, cfgToSave);
|
|
1096
|
-
// 同步写 auths/workbuddy-<uid>.json 供 checkin 使用
|
|
1097
|
-
try {
|
|
1098
|
-
const { writeFileSync, mkdirSync } = await import("node:fs");
|
|
1099
|
-
const { join } = await import("node:path");
|
|
1100
|
-
const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
|
|
1101
|
-
mkdirSync(dir, { recursive: true });
|
|
1102
|
-
const fp = join(dir, `workbuddy-${uid}.json`);
|
|
1103
|
-
const doc = { account: { uid, enterpriseId: "", nickname: "" }, auth: { accessToken: token, refreshToken: "", expiresAt: Math.floor(Date.now()/1000)+3600, domain: "www.codebuddy.cn" } };
|
|
1104
|
-
writeFileSync(fp + ".tmp", JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
1105
|
-
try { const { renameSync, unlinkSync, existsSync } = await import("node:fs"); if (existsSync(fp)) unlinkSync(fp); renameSync(fp + ".tmp", fp); } catch { writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 }); }
|
|
1106
|
-
} catch {}
|
|
1107
|
-
} else {
|
|
1108
|
-
const trimmed = String(gKey).trim();
|
|
1109
|
-
const already = (cur.keys || []).some((k) => String(k).trim() === trimmed);
|
|
1110
|
-
if (already) {
|
|
1111
|
-
console.log(`key already exists for ${nid} (${trimmed.slice(0, 4)}…${trimmed.slice(-4)}), skipped — still ${cur.keys.length} key(s)`);
|
|
1112
|
-
console.log(` use: mslxdff -provider ${nid} list to see keys`);
|
|
1113
|
-
process.exit(0);
|
|
1114
|
-
}
|
|
1115
|
-
keys = [...new Set([...(cur.keys || []), trimmed].filter(Boolean))];
|
|
1116
|
-
auths = undefined;
|
|
1117
|
-
const cfgToSave2 = { baseUrl, keys, allowedModels };
|
|
1118
|
-
if (parsedModelsPath) cfgToSave2.modelsPath = parsedModelsPath;
|
|
1119
|
-
else if (cur.modelsPath) cfgToSave2.modelsPath = cur.modelsPath;
|
|
1120
|
-
if (parsedChatPath) cfgToSave2.chatPath = parsedChatPath;
|
|
1121
|
-
else if (cur.chatPath) cfgToSave2.chatPath = cur.chatPath;
|
|
1122
|
-
saveProviderConfig(nid, cfgToSave2);
|
|
1123
|
-
}
|
|
1124
|
-
console.log(`added generic provider: ${nid}`);
|
|
1125
|
-
console.log(` baseUrl: ${String(gBase).trim().replace(/\/+$/, "")}`);
|
|
1126
|
-
console.log(` keys: ${keys.length} (${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")})`);
|
|
1127
|
-
if (allowedModels.length) console.log(` allowedModels: ${allowedModels.length} (${allowedModels.join(", ")})`);
|
|
1128
|
-
else console.log(` allowedModels: (none — BLOCKED, otherwise unusable) → mslxdff -provider ${nid} allowlist set <model...> OR mslxdff -provider ${nid} allowAny on (allow all)`);
|
|
1129
|
-
console.log(` share: ${loadProviderShareKeys(nid) ? "ON" : "off"} (mslxdff -provider ${nid} share on|off)`);
|
|
1130
|
-
console.log(` allowAny: OFF (secure, empty allowlist = 403 block before upstream) — enable via: mslxdff -provider ${nid} allowAny on`);
|
|
1131
|
-
console.log(` use as: ${nid}/<model-id> — restart daemon to activate`);
|
|
1132
|
-
console.log(` NOTE: empty allowlist = 403 before upstream, no cost — must set allowlist to use`);
|
|
1133
|
-
process.exit(0);
|
|
1134
|
-
}
|
|
1135
|
-
if ((id === "opencode" || id === "oc") && sub !== "allowlist" && sub !== "allow" && sub !== "allowed" && sub !== "whitelist" && sub !== "list" && sub !== "status" && sub !== "allowAny" && sub !== "allow-any" && sub !== "allow_any" && sub !== "allowany") {
|
|
1136
|
-
console.log("opencode is the default (bare) provider — it needs no API key and can never be shared with peers");
|
|
1137
|
-
console.log("(its IP-based rate limit is spread by peer forwarding itself)");
|
|
1138
|
-
console.log(` allowlist: mslxdff -provider opencode allowlist [list|set|add|remove|clear] — restrict models (empty=allow all)`);
|
|
1139
|
-
process.exit(0);
|
|
1140
|
-
}
|
|
1141
|
-
const { loadProviderKeys, saveProviderKeys, addProviderKey, removeProviderKeys, loadProviderShareKeys, saveProviderShareKeys, loadProviderConfig, saveProviderConfig, saveProviderBaseUrl, loadProviderConfigs } = await import("../src/state.js");
|
|
1142
|
-
if (sub === "set-models-path" || sub === "setModelsPath" || sub === "models-path") {
|
|
1143
|
-
const p = rest[1];
|
|
1144
|
-
if (!p) {
|
|
1145
|
-
console.error(`usage: mslxdff -provider ${id} set-models-path <path> (e.g. /v1/models)`);
|
|
1146
|
-
process.exit(1);
|
|
1147
|
-
}
|
|
1148
|
-
if (!String(p).trim().startsWith("/")) {
|
|
1149
|
-
console.error(`invalid modelsPath: ${p} (must start with /)`);
|
|
1150
|
-
process.exit(1);
|
|
1151
|
-
}
|
|
1152
|
-
const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
|
|
1153
|
-
const { normalizeProviderId } = await import("../src/providers/model-id.js");
|
|
1154
|
-
const nid = normalizeProviderId(id);
|
|
1155
|
-
saveProviderConfig(nid || id, { baseUrl: cur.baseUrl || "", keys: cur.keys || [], modelsPath: String(p).trim() });
|
|
1156
|
-
console.log(`set ${nid || id} modelsPath: ${String(p).trim()} — restart daemon to activate`);
|
|
1157
|
-
process.exit(0);
|
|
1158
|
-
}
|
|
1159
|
-
if (sub === "set-chat-path" || sub === "setChatPath" || sub === "chat-path") {
|
|
1160
|
-
const p = rest[1];
|
|
1161
|
-
if (!p) {
|
|
1162
|
-
console.error(`usage: mslxdff -provider ${id} set-chat-path <path> (e.g. /v1/chat/completions)`);
|
|
1163
|
-
process.exit(1);
|
|
1164
|
-
}
|
|
1165
|
-
if (!String(p).trim().startsWith("/")) {
|
|
1166
|
-
console.error(`invalid chatPath: ${p} (must start with /)`);
|
|
1167
|
-
process.exit(1);
|
|
1168
|
-
}
|
|
1169
|
-
const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
|
|
1170
|
-
const { normalizeProviderId } = await import("../src/providers/model-id.js");
|
|
1171
|
-
const nid = normalizeProviderId(id);
|
|
1172
|
-
saveProviderConfig(nid || id, { baseUrl: cur.baseUrl || "", keys: cur.keys || [], chatPath: String(p).trim() });
|
|
1173
|
-
console.log(`set ${nid || id} chatPath: ${String(p).trim()} — restart daemon to activate`);
|
|
1174
|
-
process.exit(0);
|
|
1175
|
-
}
|
|
1176
|
-
if (sub === "clear") {
|
|
1177
|
-
const configs = loadProviderConfigs();
|
|
1178
|
-
if (configs[id]) {
|
|
1179
|
-
saveProviderConfig(id, { baseUrl: "", keys: [] });
|
|
1180
|
-
} else {
|
|
1181
|
-
saveProviderKeys(id, []);
|
|
1182
|
-
}
|
|
1183
|
-
console.log(`cleared ${id} API keys (provider disabled on next daemon start)`);
|
|
1184
|
-
process.exit(0);
|
|
1185
|
-
}
|
|
1186
|
-
if (sub === "set-url" || sub === "setUrl" || sub === "url") {
|
|
1187
|
-
const url = rest[1];
|
|
1188
|
-
if (!url) {
|
|
1189
|
-
console.error(`usage: mslxdff -provider ${id} set-url <baseUrl>`);
|
|
1190
|
-
process.exit(1);
|
|
1191
|
-
}
|
|
1192
|
-
if (!/^https?:\/\/.+/.test(String(url).trim())) {
|
|
1193
|
-
console.error(`invalid baseUrl: ${url} (must start with http:// or https://)`);
|
|
1194
|
-
process.exit(1);
|
|
1195
|
-
}
|
|
1196
|
-
const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
|
|
1197
|
-
saveProviderConfig(id, { baseUrl: String(url).trim(), keys: cur.keys || [] });
|
|
1198
|
-
console.log(`set ${id} baseUrl: ${String(url).trim().replace(/\/+$/, "")} — restart daemon to activate`);
|
|
1199
|
-
process.exit(0);
|
|
1200
|
-
}
|
|
1201
|
-
if (sub === "share") {
|
|
1202
|
-
const on = rest[1];
|
|
1203
|
-
if (!on) {
|
|
1204
|
-
console.log(`share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"}`);
|
|
1205
|
-
process.exit(0);
|
|
1206
|
-
}
|
|
1207
|
-
if (!["on", "off", "1", "0", "true", "false"].includes(String(on).toLowerCase())) {
|
|
1208
|
-
console.error("usage: mslxdff -provider openrouter share on|off");
|
|
1209
|
-
process.exit(1);
|
|
1210
|
-
}
|
|
1211
|
-
const state = ["on", "1", "true"].includes(String(on).toLowerCase());
|
|
1212
|
-
saveProviderShareKeys(id, state);
|
|
1213
|
-
console.log(`share keys to peers: ${state ? "ON" : "off"} — restart daemon to activate`);
|
|
1214
|
-
process.exit(0);
|
|
1215
|
-
}
|
|
1216
|
-
// allowAnyModels 开关:默认关闭(安全),空 allowlist 时直接 403(防扣费)
|
|
1217
|
-
if (sub === "allowAny" || sub === "allow-any" || sub === "allow_any" || sub === "allowany") {
|
|
1218
|
-
const { loadProviderAllowAnyModels, saveProviderAllowAnyModels } = await import("../src/state.js");
|
|
1219
|
-
const on = rest[1];
|
|
1220
|
-
if (!on) {
|
|
1221
|
-
const cur = loadProviderAllowAnyModels(id);
|
|
1222
|
-
console.log(`allowAnyModels: ${cur ? "ON (allow all when allowlist empty)" : "OFF (empty allowlist = block all)"}`);
|
|
1223
|
-
console.log(` set: mslxdff -provider ${id} allowAny on|off`);
|
|
1224
|
-
process.exit(0);
|
|
1225
|
-
}
|
|
1226
|
-
if (!["on", "off", "1", "0", "true", "false"].includes(String(on).toLowerCase())) {
|
|
1227
|
-
console.error(`usage: mslxdff -provider ${id} allowAny on|off`);
|
|
1228
|
-
process.exit(1);
|
|
1229
|
-
}
|
|
1230
|
-
const state = ["on", "1", "true"].includes(String(on).toLowerCase());
|
|
1231
|
-
saveProviderAllowAnyModels(id, state);
|
|
1232
|
-
console.log(`allowAnyModels: ${state ? "ON (empty allowlist = allow all)" : "OFF (empty allowlist = block all)"} — takes effect immediately (hot-reloaded)`);
|
|
1233
|
-
process.exit(0);
|
|
1234
|
-
}
|
|
1235
|
-
// allowlist 管理:默认关闭,白名单为空 = 禁止(需显式 allowAny:true 才放行);非空 = 仅名单内可用(防昂贵模型)
|
|
1236
|
-
if (sub === "allowlist" || sub === "allow" || sub === "allowed" || sub === "whitelist") {
|
|
1237
|
-
const { loadProviderAllowedModels, saveProviderAllowedModels, loadProviderConfig, loadProviderAllowAnyModels } = await import("../src/state.js");
|
|
1238
|
-
const action = rest[1];
|
|
1239
|
-
const rawTargets = rest.slice(2);
|
|
1240
|
-
// 兼容:mslxdff -provider <id> allowlist (无 action)→ list
|
|
1241
|
-
if (!action || action === "list" || action === "status") {
|
|
1242
|
-
const list = loadProviderAllowedModels(id);
|
|
1243
|
-
const cfg = loadProviderConfig(id);
|
|
1244
|
-
const baseUrl = cfg?.baseUrl || "";
|
|
1245
|
-
const allowAny = loadProviderAllowAnyModels(id);
|
|
1246
|
-
console.log(`provider: ${id}${baseUrl ? ` baseUrl: ${baseUrl}` : ""}`);
|
|
1247
|
-
if (!list.length) {
|
|
1248
|
-
if (allowAny) {
|
|
1249
|
-
console.log(` allowedModels: (none — allow all, because allowAny=ON)`);
|
|
1250
|
-
console.log(` to secure: mslxdff -provider ${id} allowAny off or mslxdff -provider ${id} allowlist set <model1> <model2> ...`);
|
|
1251
|
-
} else {
|
|
1252
|
-
console.log(` allowedModels: (none — BLOCK ALL, provider disabled until allowlist set or allowAny ON)`);
|
|
1253
|
-
console.log(` set via: mslxdff -provider ${id} allowlist set <model1> <model2> ...`);
|
|
1254
|
-
console.log(` or: mslxdff -provider ${id} allowAny on (allow all when allowlist empty)`);
|
|
1255
|
-
}
|
|
1256
|
-
} else {
|
|
1257
|
-
console.log(` allowedModels: ${list.length} model${list.length > 1 ? "s" : ""} (only these can be used)`);
|
|
1258
|
-
list.forEach((m, i) => console.log(` [${i + 1}] ${m}`));
|
|
1259
|
-
console.log(` manage: mslxdff -provider ${id} allowlist add <model> | remove <model> | set <m1> <m2> ... | clear`);
|
|
1260
|
-
console.log(` allowAny: ${allowAny ? "ON" : "OFF"} (empty list behavior) — mslxdff -provider ${id} allowAny on|off`);
|
|
1261
|
-
}
|
|
1262
|
-
console.log(` NOTE: empty allowlist + allowAny OFF = 403 block (hot-reloaded, no restart needed)`);
|
|
1263
|
-
process.exit(0);
|
|
1264
|
-
}
|
|
1265
|
-
if (action === "clear") {
|
|
1266
|
-
saveProviderAllowedModels(id, []);
|
|
1267
|
-
const allowAny = loadProviderAllowAnyModels(id);
|
|
1268
|
-
console.log(`cleared ${id} allowlist (now ${allowAny ? "allow all (allowAny ON)" : "BLOCK ALL (allowAny OFF)"}) — takes effect immediately (hot-reloaded)`);
|
|
1269
|
-
process.exit(0);
|
|
1270
|
-
}
|
|
1271
|
-
if (action === "set") {
|
|
1272
|
-
const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
|
|
1273
|
-
// 支持逗号分隔的一串:model1,model2
|
|
1274
|
-
const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
|
|
1275
|
-
if (!flat.length) {
|
|
1276
|
-
console.error(`usage: mslxdff -provider ${id} allowlist set <model1> <model2> ...`);
|
|
1277
|
-
process.exit(1);
|
|
1278
|
-
}
|
|
1279
|
-
const saved = saveProviderAllowedModels(id, flat);
|
|
1280
|
-
console.log(`set ${id} allowlist: ${saved.length} model${saved.length > 1 ? "s" : ""} (${saved.join(", ")}) — takes effect immediately (hot-reloaded)`);
|
|
1281
|
-
process.exit(0);
|
|
1282
|
-
}
|
|
1283
|
-
if (action === "add") {
|
|
1284
|
-
const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
|
|
1285
|
-
const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
|
|
1286
|
-
if (!flat.length) {
|
|
1287
|
-
console.error(`usage: mslxdff -provider ${id} allowlist add <model> [model2 ...]`);
|
|
1288
|
-
process.exit(1);
|
|
1289
|
-
}
|
|
1290
|
-
const cur = loadProviderAllowedModels(id);
|
|
1291
|
-
const next = [...new Set([...cur, ...flat])];
|
|
1292
|
-
saveProviderAllowedModels(id, next);
|
|
1293
|
-
console.log(`added ${flat.length} model${flat.length > 1 ? "s" : ""} to ${id} allowlist (now ${next.length}: ${next.join(", ")}) — takes effect immediately (hot-reloaded)`);
|
|
1294
|
-
process.exit(0);
|
|
1295
|
-
}
|
|
1296
|
-
if (action === "remove" || action === "rm" || action === "del") {
|
|
1297
|
-
const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
|
|
1298
|
-
const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
|
|
1299
|
-
if (!flat.length) {
|
|
1300
|
-
console.error(`usage: mslxdff -provider ${id} allowlist remove <model> [model2 ...]`);
|
|
1301
|
-
process.exit(1);
|
|
1302
|
-
}
|
|
1303
|
-
const cur = loadProviderAllowedModels(id);
|
|
1304
|
-
const set = new Set(flat);
|
|
1305
|
-
const next = cur.filter((m) => !set.has(m));
|
|
1306
|
-
saveProviderAllowedModels(id, next);
|
|
1307
|
-
const { loadProviderAllowAnyModels: _la } = await import("../src/state.js");
|
|
1308
|
-
const _allowAny = _la(id);
|
|
1309
|
-
console.log(`removed ${cur.length - next.length} model${cur.length - next.length !== 1 ? "s" : ""} from ${id} allowlist (now ${next.length ? next.join(", ") : (_allowAny ? "(allow all)" : "(BLOCK ALL)")}) — takes effect immediately (hot-reloaded)`);
|
|
1310
|
-
process.exit(0);
|
|
1311
|
-
}
|
|
1312
|
-
console.error(`usage: mslxdff -provider ${id} allowlist [list|set|add|remove|clear] [models...]`);
|
|
1313
|
-
console.error(` mslxdff -provider ${id} allowAny on|off (empty allowlist = block or allow all)`);
|
|
1314
|
-
process.exit(1);
|
|
1315
|
-
}
|
|
1316
|
-
if (sub === "models" || sub === "show-models" || sub === "list-models" || sub === "ls") {
|
|
1317
|
-
const wantsJson = args.includes("--json") || args.includes("-json");
|
|
1318
|
-
const cfg = loadProviderConfig(id);
|
|
1319
|
-
// opencode: use aggregated cache + provider-specific? For opencode, show bare ids from cache
|
|
1320
|
-
if (id === "opencode" || id === "oc") {
|
|
1321
|
-
try {
|
|
1322
|
-
const cacheFile = join(logDir(), "models.json");
|
|
1323
|
-
const { readFileSync } = await import("node:fs");
|
|
1324
|
-
const raw = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
1325
|
-
const ids = (raw.data || []).map((m) => m.id).filter((x) => !String(x).includes("/"));
|
|
1326
|
-
if (wantsJson) {
|
|
1327
|
-
console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
|
|
1328
|
-
} else {
|
|
1329
|
-
console.log(`opencode models (${ids.length}):`);
|
|
1330
|
-
for (const mid of ids) console.log(` ${mid}`);
|
|
1331
|
-
}
|
|
1332
|
-
} catch (e) {
|
|
1333
|
-
console.error(`could not read models cache: ${String(e?.message || e)}`);
|
|
1334
|
-
process.exit(1);
|
|
1335
|
-
}
|
|
1336
|
-
process.exit(0);
|
|
1337
|
-
}
|
|
1338
|
-
// generic/workbuddy: live fetch via provider listModels
|
|
1339
|
-
try {
|
|
1340
|
-
const { createGenericProvider } = await import("../src/providers/generic.js");
|
|
1341
|
-
const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
|
|
1342
|
-
const { isModelAllowed } = await import("../src/state.js");
|
|
1343
|
-
const baseUrl = cfg?.baseUrl || (id === "workbuddy" ? "https://copilot.tencent.com" : "");
|
|
1344
|
-
const keys = loadProviderKeys(id);
|
|
1345
|
-
const auths = cfg?.auths || [];
|
|
1346
|
-
let provider;
|
|
1347
|
-
if (id === "workbuddy") {
|
|
1348
|
-
provider = createWorkbuddyProvider({ baseUrl, apiKeys: keys, auths, file: defaultStateFile() });
|
|
1349
|
-
} else {
|
|
1350
|
-
if (!baseUrl) {
|
|
1351
|
-
console.error(`provider ${id}: missing baseUrl — set via: mslxdff -provider ${id} set-url <baseUrl>`);
|
|
1352
|
-
process.exit(1);
|
|
1353
|
-
}
|
|
1354
|
-
provider = createGenericProvider({ id, baseUrl, apiKeys: keys, file: defaultStateFile() });
|
|
1355
|
-
}
|
|
1356
|
-
const all = await provider.listModels();
|
|
1357
|
-
// show 全部上游模型,仅标注是否被 allowlist 放行(不拦截展示)
|
|
1358
|
-
const markAllowed = (mid) => {
|
|
1359
|
-
const raw = String(mid || "").includes("/") ? String(mid).split("/").slice(1).join("/") : String(mid);
|
|
1360
|
-
const checkRaw = mid.startsWith(`${id}/`) ? mid.slice(id.length + 1) : raw;
|
|
1361
|
-
return isModelAllowed(id, checkRaw);
|
|
1362
|
-
};
|
|
1363
|
-
if (wantsJson) {
|
|
1364
|
-
// --json 仍按 allowlist 过滤(给脚本消费可用模型)
|
|
1365
|
-
const filtered = all.filter((m) => markAllowed(m.id));
|
|
1366
|
-
console.log(JSON.stringify({ object: "list", data: filtered }, null, 2));
|
|
1367
|
-
} else {
|
|
1368
|
-
const allowedCount = all.filter((m) => markAllowed(m.id)).length;
|
|
1369
|
-
console.log(`${id} models (${all.length} total, ${allowedCount} ✓ allowed${allowedCount !== all.length ? `, ${all.length - allowedCount} x blocked by allowlist` : ""}):`);
|
|
1370
|
-
const fmtPrice = (m) => {
|
|
1371
|
-
const c = String(m.credits || "").trim();
|
|
1372
|
-
if (c) {
|
|
1373
|
-
// "x0.00 credits" / "x0.00" → 统一成 "x0.00"
|
|
1374
|
-
const m0 = c.match(/x\s*([\d.]+)/i);
|
|
1375
|
-
if (m0) return `x${m0[1]}`;
|
|
1376
|
-
return c.replace(/\s*credits\s*/gi, "").trim().replace(/\s+/g, " ");
|
|
1377
|
-
}
|
|
1378
|
-
if (m.pricing && typeof m.pricing === "object") {
|
|
1379
|
-
const p = m.pricing.prompt ?? m.pricing.input ?? m.pricing.completion ?? "";
|
|
1380
|
-
if (p) return String(p);
|
|
1381
|
-
}
|
|
1382
|
-
if (m.price != null && String(m.price).trim()) return String(m.price).trim();
|
|
1383
|
-
if (String(m.id).endsWith("/auto")) return "浮动";
|
|
1384
|
-
return "—";
|
|
1385
|
-
};
|
|
1386
|
-
const fmtBadge = (m) => {
|
|
1387
|
-
const tags = Array.isArray(m.tags) ? m.tags : [];
|
|
1388
|
-
const b = tags.find((t) => String(t).includes("限时免费") || String(t).toLowerCase().includes("free"));
|
|
1389
|
-
if (!b) return "";
|
|
1390
|
-
const part = String(b).split(":")[1];
|
|
1391
|
-
return part ? ` [${part}]` : ` [${b}]`;
|
|
1392
|
-
};
|
|
1393
|
-
// 按 credits 升序已在 provider 排好序,展示时对齐价格列便于分辨
|
|
1394
|
-
const idW = Math.max(22, ...all.map((m) => String(m.id).length)) + 2;
|
|
1395
|
-
const priceW = Math.max(6, ...all.map((m) => fmtPrice(m).length)) + 2;
|
|
1396
|
-
for (const m of all) {
|
|
1397
|
-
const ok = markAllowed(m.id);
|
|
1398
|
-
const price = fmtPrice(m);
|
|
1399
|
-
const badge = fmtBadge(m);
|
|
1400
|
-
const name = m.name ? ` ${m.name}` : "";
|
|
1401
|
-
const blocked = ok ? "" : " [blocked — allowlist]";
|
|
1402
|
-
const line = ` ${ok ? "✓" : "x"} ${String(m.id).padEnd(idW)}${String(price).padEnd(priceW)}${name}${badge}${blocked}`;
|
|
1403
|
-
console.log(line);
|
|
1404
|
-
}
|
|
1405
|
-
if (!all.length) console.log(` (no models — check baseUrl/keys or try: curl ${baseUrl}/models)`);
|
|
1406
|
-
else if (allowedCount === 0) console.log(` tip: all blocked — mslxdff -provider ${id} allowAny on 或 allowlist set <model...>`);
|
|
1407
|
-
else if (allowedCount !== all.length) console.log(` tip: blocked 仅影响 /v1/chat 调用,展示已全量列出`);
|
|
1408
|
-
}
|
|
1409
|
-
try { await provider.close?.(); } catch {}
|
|
1410
|
-
} catch (e) {
|
|
1411
|
-
console.error(`could not list ${id} models: ${String(e?.message || e)}`);
|
|
1412
|
-
process.exit(1);
|
|
1413
|
-
}
|
|
1414
|
-
process.exit(0);
|
|
1415
|
-
}
|
|
1416
|
-
if (sub === "list" || sub === "status") {
|
|
1417
|
-
const keys = loadProviderKeys(id);
|
|
1418
|
-
const cfg = loadProviderConfig(id);
|
|
1419
|
-
const { loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("../src/state.js");
|
|
1420
|
-
const allowed = loadProviderAllowedModels(id);
|
|
1421
|
-
const allowAny = loadProviderAllowAnyModels(id);
|
|
1422
|
-
const baseUrl = cfg?.baseUrl || "";
|
|
1423
|
-
if (baseUrl) console.log(`provider: ${id} baseUrl: ${baseUrl}`);
|
|
1424
|
-
else console.log(`provider: ${id}${id === "openrouter" ? " (built-in baseUrl: https://openrouter.ai/api/v1)" : ""}`);
|
|
1425
|
-
if (keys.length) {
|
|
1426
|
-
console.log(` keys: ${keys.length} key${keys.length > 1 ? "s" : ""}`);
|
|
1427
|
-
keys.forEach((k, i) => console.log(` [${i + 1}] ${k.slice(0, 4)}…${k.slice(-4)} (${k.length} chars)`));
|
|
1428
|
-
console.log(` remove by: mslxdff -provider ${id} remove <seq> [seq...] | <key-value>`);
|
|
1429
|
-
} else {
|
|
1430
|
-
console.log(` keys: (no keys configured)`);
|
|
1431
|
-
}
|
|
1432
|
-
console.log(` share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"} (mslxdff -provider ${id} share on|off)`);
|
|
1433
|
-
console.log(` allowAnyModels: ${allowAny ? "ON (empty allowlist = allow all)" : "OFF (empty allowlist = BLOCK ALL)"} (mslxdff -provider ${id} allowAny on|off)`);
|
|
1434
|
-
if (allowed.length) console.log(` allowedModels: ${allowed.length} (${allowed.join(", ")}) — only these can be used`);
|
|
1435
|
-
else console.log(` allowedModels: (none — ${allowAny ? "allow all" : "BLOCK ALL"}) (mslxdff -provider ${id} allowlist set <model...> or allowAny on)`);
|
|
1436
|
-
if (baseUrl) console.log(` set url: mslxdff -provider ${id} set-url <baseUrl>`);
|
|
1437
|
-
console.log(` NOTE: opencode is the default provider and can never be shared`);
|
|
1438
|
-
process.exit(0);
|
|
1439
|
-
}
|
|
1440
|
-
if (sub === "add") {
|
|
1441
|
-
const key = rest[1];
|
|
1442
|
-
if (!key) {
|
|
1443
|
-
console.error("usage: mslxdff -provider openrouter add <key>");
|
|
1444
|
-
process.exit(1);
|
|
1445
|
-
}
|
|
1446
|
-
const trimmed = String(key).trim();
|
|
1447
|
-
const curKeys = loadProviderKeys(id);
|
|
1448
|
-
if (curKeys.some((k) => String(k).trim() === trimmed)) {
|
|
1449
|
-
console.log(`key already exists for ${id} (${trimmed.slice(0, 4)}…${trimmed.slice(-4)}), skipped — still ${curKeys.length} key(s)`);
|
|
1450
|
-
console.log(` use: mslxdff -provider ${id} list to see keys`);
|
|
1451
|
-
process.exit(0);
|
|
1452
|
-
}
|
|
1453
|
-
// 通用供应商:keys 存 providerConfigs,需保留 baseUrl
|
|
1454
|
-
const configs = loadProviderConfigs();
|
|
1455
|
-
if (configs[id]) {
|
|
1456
|
-
const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
|
|
1457
|
-
const keys = [...new Set([...(cur.keys || []), trimmed].filter(Boolean))];
|
|
1458
|
-
saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys });
|
|
1459
|
-
console.log(`added ${id} API key (now ${keys.length} total) — restart daemon to activate`);
|
|
1460
|
-
} else {
|
|
1461
|
-
const added = addProviderKey(id, key);
|
|
1462
|
-
console.log(`added ${id} API key (now ${added.length} total) — restart daemon to activate`);
|
|
1463
|
-
}
|
|
1464
|
-
process.exit(0);
|
|
1465
|
-
}
|
|
1466
|
-
if (sub === "remove") {
|
|
1467
|
-
const targets = rest.slice(1).filter((k) => !k.startsWith("-"));
|
|
1468
|
-
if (!targets.length) {
|
|
1469
|
-
console.error("usage: mslxdff -provider openrouter remove <seq|key> [seq|key ...] (seq = index shown by 'list')");
|
|
1470
|
-
process.exit(1);
|
|
1471
|
-
}
|
|
1472
|
-
const current = loadProviderKeys(id);
|
|
1473
|
-
const toRemove = [];
|
|
1474
|
-
for (const raw of targets.flatMap((t) => String(t).split(","))) {
|
|
1475
|
-
const t = raw.trim();
|
|
1476
|
-
if (!t) continue;
|
|
1477
|
-
if (/^\d+$/.test(t)) {
|
|
1478
|
-
const seq = Number(t);
|
|
1479
|
-
const idx = seq - 1;
|
|
1480
|
-
if (Number.isInteger(seq) && idx >= 0 && idx < current.length) toRemove.push(current[idx]);
|
|
1481
|
-
else console.log(` ! no key at sequence ${seq} (provider has ${current.length}) — skipped`);
|
|
1482
|
-
} else {
|
|
1483
|
-
toRemove.push(t);
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
if (!toRemove.length) {
|
|
1487
|
-
console.log("nothing to remove");
|
|
1488
|
-
process.exit(0);
|
|
1489
|
-
}
|
|
1490
|
-
const configs = loadProviderConfigs();
|
|
1491
|
-
let remaining;
|
|
1492
|
-
if (configs[id]) {
|
|
1493
|
-
const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
|
|
1494
|
-
const set = new Set([...new Set(toRemove)].map((k) => String(k).trim()));
|
|
1495
|
-
const keys = (cur.keys || []).filter((k) => !set.has(k));
|
|
1496
|
-
saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys });
|
|
1497
|
-
remaining = keys;
|
|
1498
|
-
} else {
|
|
1499
|
-
remaining = removeProviderKeys(id, [...new Set(toRemove)]);
|
|
1500
|
-
}
|
|
1501
|
-
console.log(`removed ${current.length - remaining.length} ${id} API key(s) (now ${remaining.length} total) — restart daemon to activate`);
|
|
1502
|
-
process.exit(0);
|
|
1503
|
-
}
|
|
1504
|
-
if (sub && !sub.startsWith("-")) {
|
|
1505
|
-
const keys = rest.filter((k) => !k.startsWith("-"));
|
|
1506
|
-
if (!keys.length) {
|
|
1507
|
-
console.error("no key given");
|
|
1508
|
-
process.exit(1);
|
|
1509
|
-
}
|
|
1510
|
-
const configs = loadProviderConfigs();
|
|
1511
|
-
if (configs[id]) {
|
|
1512
|
-
const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
|
|
1513
|
-
const clean = [...new Set(keys.map((k) => String(k).trim()).filter(Boolean))];
|
|
1514
|
-
saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys: clean });
|
|
1515
|
-
console.log(`set ${id} API keys (${clean.length}: ${clean.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}) — restart daemon to activate`);
|
|
1516
|
-
} else {
|
|
1517
|
-
saveProviderKeys(id, keys);
|
|
1518
|
-
console.log(`set ${id} API keys (${keys.length}: ${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}) — restart daemon to activate`);
|
|
1519
|
-
}
|
|
1520
|
-
process.exit(0);
|
|
1521
|
-
}
|
|
1522
|
-
// 交互式隐藏输入:多行直到空行,逐一追加到现有 keys
|
|
1523
|
-
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
1524
|
-
const readline = await import("node:readline/promises");
|
|
1525
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
1526
|
-
console.log(`Enter ${id} API keys, one per line (input hidden). Blank line to finish:`);
|
|
1527
|
-
const existing = loadProviderKeys(id);
|
|
1528
|
-
const collected = [];
|
|
1529
|
-
for (;;) {
|
|
1530
|
-
const key = await rl.question(existing.length || collected.length ? "" : "");
|
|
1531
|
-
const clean = String(key || "").trim();
|
|
1532
|
-
if (!clean) break;
|
|
1533
|
-
collected.push(clean);
|
|
1534
|
-
}
|
|
1535
|
-
rl.close();
|
|
1536
|
-
if (!collected.length) {
|
|
1537
|
-
console.log("empty input — nothing changed");
|
|
1538
|
-
process.exit(0);
|
|
1539
|
-
}
|
|
1540
|
-
for (const k of collected) addProviderKey(id, k);
|
|
1541
|
-
const total = (await import("../src/state.js")).loadProviderKeys(id).length;
|
|
1542
|
-
console.log(`added ${collected.length} ${id} API key(s) (now ${total} total) — restart daemon to activate`);
|
|
1543
|
-
process.exit(0);
|
|
1544
|
-
}
|
|
1545
|
-
console.error("provide keys inline (non-TTY): mslxdff -provider openrouter <key1> [key2 ...]");
|
|
1546
|
-
process.exit(1);
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
// 交互式选择器:↑/↓ 移动,Enter 确认,q/Esc 取消;ANSI 原地重绘
|
|
1550
|
-
async function pickInteractive(items, startCursor = 0) {
|
|
1551
|
-
const { renderChooser, renderChooserHelp, parseKey } = await import("../src/chooser.js");
|
|
1552
|
-
let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
|
|
1553
|
-
const draw = () => {
|
|
1554
|
-
const lines = [...renderChooser(items, cursor), ...renderChooserHelp()];
|
|
1555
|
-
// PowerShell 兼容:\x1b[2J 清屏 + \x1b[H 回到左上,比 \x1b[G\x1b[J 更可靠
|
|
1556
|
-
process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
|
|
1557
|
-
};
|
|
1558
|
-
draw();
|
|
1559
|
-
return new Promise((resolve) => {
|
|
1560
|
-
const wasRaw = process.stdin.isRaw;
|
|
1561
|
-
process.stdin.setRawMode(true);
|
|
1562
|
-
process.stdin.resume();
|
|
1563
|
-
process.stdin.setEncoding("utf8");
|
|
1564
|
-
const cleanup = () => {
|
|
1565
|
-
process.stdin.removeListener("data", onData);
|
|
1566
|
-
process.stdin.setRawMode(false);
|
|
1567
|
-
process.stdin.pause();
|
|
1568
|
-
process.stdout.write("\n");
|
|
1569
|
-
};
|
|
1570
|
-
const onData = (chunk) => {
|
|
1571
|
-
const key = parseKey(String(chunk));
|
|
1572
|
-
if (key === "up") {
|
|
1573
|
-
cursor = (cursor - 1 + items.length) % items.length;
|
|
1574
|
-
draw();
|
|
1575
|
-
} else if (key === "down") {
|
|
1576
|
-
cursor = (cursor + 1) % items.length;
|
|
1577
|
-
draw();
|
|
1578
|
-
} else if (key === "enter") {
|
|
1579
|
-
cleanup();
|
|
1580
|
-
resolve(items[cursor].id);
|
|
1581
|
-
} else if (key === "cancel") {
|
|
1582
|
-
cleanup();
|
|
1583
|
-
resolve(null);
|
|
1584
|
-
}
|
|
1585
|
-
};
|
|
1586
|
-
process.stdin.on("data", onData);
|
|
1587
|
-
});
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
// 多选勾选:↑/↓ 移动,Space 勾选/取消,Enter 保存,q/Esc 取消(返回 Set 或 null)
|
|
1591
|
-
async function pickInteractiveMulti(items, initialPicked = new Set(), startCursor = 0) {
|
|
1592
|
-
const { renderChooser, renderChooserHelp, parseKey } = await import("../src/chooser.js");
|
|
1593
|
-
let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
|
|
1594
|
-
const picked = new Set(items.filter((it) => initialPicked.has(it.id)).map((it) => it.id));
|
|
1595
|
-
const draw = () => {
|
|
1596
|
-
const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
|
|
1597
|
-
const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
|
|
1598
|
-
process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
|
|
1599
|
-
};
|
|
1600
|
-
draw();
|
|
1601
|
-
return new Promise((resolve) => {
|
|
1602
|
-
const wasRaw = process.stdin.isRaw;
|
|
1603
|
-
process.stdin.setRawMode(true);
|
|
1604
|
-
process.stdin.resume();
|
|
1605
|
-
process.stdin.setEncoding("utf8");
|
|
1606
|
-
const cleanup = () => {
|
|
1607
|
-
process.stdin.removeListener("data", onData);
|
|
1608
|
-
process.stdin.setRawMode(false);
|
|
1609
|
-
process.stdin.pause();
|
|
1610
|
-
process.stdout.write("\n");
|
|
1611
|
-
};
|
|
1612
|
-
const onData = (chunk) => {
|
|
1613
|
-
const key = parseKey(String(chunk));
|
|
1614
|
-
if (key === "up") {
|
|
1615
|
-
cursor = (cursor - 1 + items.length) % items.length;
|
|
1616
|
-
draw();
|
|
1617
|
-
} else if (key === "down") {
|
|
1618
|
-
cursor = (cursor + 1) % items.length;
|
|
1619
|
-
draw();
|
|
1620
|
-
} else if (key === "space") {
|
|
1621
|
-
const id = items[cursor].id;
|
|
1622
|
-
if (picked.has(id)) picked.delete(id);
|
|
1623
|
-
else picked.add(id);
|
|
1624
|
-
draw();
|
|
1625
|
-
} else if (key === "enter") {
|
|
1626
|
-
cleanup();
|
|
1627
|
-
resolve(new Set(picked));
|
|
1628
|
-
} else if (key === "cancel") {
|
|
1629
|
-
cleanup();
|
|
1630
|
-
resolve(null);
|
|
1631
|
-
}
|
|
1632
|
-
};
|
|
1633
|
-
process.stdin.on("data", onData);
|
|
1634
|
-
});
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
// -debug: stop the background daemon and run the server in THIS terminal
|
|
1638
|
-
// (foreground), printing every event to stdout in real time via the in-memory
|
|
1639
|
-
// event bus — no filesystem polling. Ctrl+C / SIGTERM restarts the daemon in
|
|
1640
|
-
// the background, then exits. See the daemon body below for the stream wiring.
|
|
1641
|
-
if (args.includes("-debug") || args.includes("--debug")) {
|
|
1642
|
-
// 先停旧 daemon,再清日志,保持 debug 输出干净(仅本次会话)
|
|
1643
|
-
const { stopped, pid } = stopDaemon();
|
|
1644
|
-
if (stopped) console.log(`[debug] stopped background daemon (pid ${pid})`);
|
|
1645
|
-
try {
|
|
1646
|
-
const dir = logDir();
|
|
1647
|
-
const toClear = [eventsFile(), callsFile(), errorsFile(), logFile()];
|
|
1648
|
-
let cleared = 0;
|
|
1649
|
-
for (const f of toClear) {
|
|
1650
|
-
try {
|
|
1651
|
-
if (existsSync(f)) {
|
|
1652
|
-
writeFileSync(f, "");
|
|
1653
|
-
cleared++;
|
|
1654
|
-
}
|
|
1655
|
-
} catch {}
|
|
1656
|
-
}
|
|
1657
|
-
console.log(`[debug] 已清理旧日志 ${cleared} 个文件 (${dir}),本次会话干净输出`);
|
|
1658
|
-
} catch {}
|
|
1659
|
-
console.log("--- live (Ctrl+C: stop debugging and restore background daemon) ---");
|
|
1660
|
-
process.env.MSLXDFF_DEBUG = "1";
|
|
1661
|
-
process.env.MSLXDFF_DAEMON = "1";
|
|
1662
|
-
// fall through to the daemon body below — no process.exit() here
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
// -creategroup <name> | -group create <name> | -group sync | -group leave <name> | -group list |
|
|
1666
|
-
// -addtogroup <leader-host> <name> | -resetban [ip]
|
|
1667
|
-
const createGroupArg = argValue("-creategroup", "--creategroup") || groupIs("create", args);
|
|
1668
|
-
if (createGroupArg) {
|
|
1669
|
-
const groups = createGroupsService({});
|
|
1670
|
-
const peers = createPeersService({});
|
|
1671
|
-
const name = createGroupArg;
|
|
1672
|
-
const { created } = groups.create(name); // the group name is the password
|
|
1673
|
-
markJoined({ name, leaderUrl: "", myUrl: "", memberName: "leader" });
|
|
1674
|
-
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
1675
|
-
const s = synced.find((x) => x.name === name);
|
|
1676
|
-
console.log(created ? `group created: ${name}` : `group already exists: ${name}`);
|
|
1677
|
-
console.log(`members on this node: ${s ? `${s.total} (failover: ${s.added})` : "?"}`);
|
|
1678
|
-
console.log(`others join with: mslxdff -addtogroup <this-node-host> ${name}`);
|
|
1679
|
-
process.exit(0);
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
function groupIs(action, argv) {
|
|
1683
|
-
const idx = argv.indexOf("-group");
|
|
1684
|
-
if (idx < 0) return null;
|
|
1685
|
-
return argv[idx + 1] === action ? argv[idx + 2] : null;
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
const groupCmd = argValue("-group", "--group");
|
|
1689
|
-
if (groupCmd && groupCmd !== "create") {
|
|
1690
|
-
const groups = createGroupsService({});
|
|
1691
|
-
const peers = createPeersService({});
|
|
1692
|
-
const rest = args.slice(args.indexOf("-group") + 1);
|
|
1693
|
-
const [action, a] = rest;
|
|
1694
|
-
if (action === "sync") {
|
|
1695
|
-
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
1696
|
-
if (!synced.length) {
|
|
1697
|
-
console.log("not joined to any group (use -addtogroup or -creategroup)");
|
|
1698
|
-
} else {
|
|
1699
|
-
for (const s of synced) {
|
|
1700
|
-
if (s.error) console.log(`${s.name}: sync failed — ${s.error}`);
|
|
1701
|
-
else console.log(`${s.name}: ${s.total} member(s), ${s.added} failover target(s) configured`);
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
} else if (action === "leave" && a) {
|
|
1705
|
-
const before = loadGroupsJoined().filter((g) => g.name === a).length;
|
|
1706
|
-
saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== a));
|
|
1707
|
-
const removed = peers.removeByGroup(a);
|
|
1708
|
-
console.log(before ? `left group "${a}" (${removed} member(s) removed)` : `not a member of group "${a}"`);
|
|
1709
|
-
} else if (action === "list") {
|
|
1710
|
-
const joinedList = loadGroupsJoined();
|
|
1711
|
-
if (!joinedList.length) {
|
|
1712
|
-
console.log("no groups on this node");
|
|
1713
|
-
process.exit(0);
|
|
1714
|
-
}
|
|
1715
|
-
const { token } = await loadToken();
|
|
1716
|
-
const fetchImpl = (url, opts) => fetch(url, { ...opts, signal: AbortSignal.timeout(1500) });
|
|
1717
|
-
for (const g of joinedList) {
|
|
1718
|
-
const isLeader = !g.leaderUrl;
|
|
1719
|
-
let members;
|
|
1720
|
-
if (isLeader) {
|
|
1721
|
-
members = groups.list()[g.name]?.members || {};
|
|
1722
|
-
} else {
|
|
1723
|
-
try {
|
|
1724
|
-
members = await refreshGroupMembers(g.name, {
|
|
1725
|
-
leaderUrl: g.leaderUrl,
|
|
1726
|
-
memberName: g.memberName,
|
|
1727
|
-
url: g.myUrl,
|
|
1728
|
-
token,
|
|
1729
|
-
fetchImpl,
|
|
1730
|
-
});
|
|
1731
|
-
} catch (err) {
|
|
1732
|
-
console.log(`${g.name} (members unavailable — leader unreachable: ${errMsg(err)})`);
|
|
1733
|
-
continue;
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
const entries = Object.entries(members).filter(([id]) => id !== "leader");
|
|
1737
|
-
console.log(`${g.name} (${entries.length} member${entries.length === 1 ? "" : "s"})`);
|
|
1738
|
-
// probe every member endpoint concurrently for reachability + latency;
|
|
1739
|
-
// display order = state order so the sequence numbers stay stable for -group remove
|
|
1740
|
-
// broadband members (relay://) are not probed — they are reachable via leader
|
|
1741
|
-
const probes = await Promise.all(
|
|
1742
|
-
entries.map(([id, m]) => probeHealth({ id, url: m?.url || id, kind: m?.kind, lastSeen: m?.lastSeen, publicIp: m?.publicIp }))
|
|
1743
|
-
);
|
|
1744
|
-
const leaderEntry = members.leader ? Object.entries(members).find(([id]) => id === "leader") : null;
|
|
1745
|
-
const leaderProbe = leaderEntry ? await probeHealth({ id: "leader", url: leaderEntry[1].url }) : null;
|
|
1746
|
-
const display = leaderProbe ? [...probes, leaderProbe] : probes;
|
|
1747
|
-
let seq = 0;
|
|
1748
|
-
for (const r of display) {
|
|
1749
|
-
const m = entries.find(([eid]) => eid === r.id)?.[1] || (r.id === "leader" ? leaderEntry?.[1] : null);
|
|
1750
|
-
const isBb = r.kind === "broadband" || String(r.url || "").startsWith("relay://") || m?.kind === "broadband";
|
|
1751
|
-
if (isBb) {
|
|
1752
|
-
const ago = r.lastSeen ? `${Math.round((Date.now() - r.lastSeen) / 1000)}s ago` : "no heartbeat yet";
|
|
1753
|
-
const ip = r.publicIp || m?.publicIp || "?";
|
|
1754
|
-
const via = r.stale ? "stale" : `via leader ${ago}`;
|
|
1755
|
-
const stateBb = `${via} ip=${ip}`;
|
|
1756
|
-
if (r.id === "leader") {
|
|
1757
|
-
console.log(` leader ${r.url} ${stateBb}`);
|
|
1758
|
-
continue;
|
|
1759
|
-
}
|
|
1760
|
-
seq += 1;
|
|
1761
|
-
const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
|
|
1762
|
-
console.log(` ${seq}. ${r.url}${label} [broadband] ${stateBb}`);
|
|
1763
|
-
continue;
|
|
1764
|
-
}
|
|
1765
|
-
const state = r.fail ? `fail ${r.fail}` : `ok ${r.ms}ms`;
|
|
1766
|
-
if (r.id === "leader") {
|
|
1767
|
-
console.log(` leader ${r.url} ${state}`);
|
|
1768
|
-
continue;
|
|
1769
|
-
}
|
|
1770
|
-
seq += 1;
|
|
1771
|
-
const label = r.id && r.id !== r.url ? ` [${r.id}]` : "";
|
|
1772
|
-
console.log(` ${seq}. ${r.url}${label} ${state}`);
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
console.log(`\njoined groups (${joinedList.length}):`);
|
|
1776
|
-
for (const g of joinedList) console.log(` ${g.name} ${g.leaderUrl || "(this node is the leader)"}`);
|
|
1777
|
-
} else if (action === "remove" && a) {
|
|
1778
|
-
// leader-only: kick a member by its list sequence number (1-based, leader excluded)
|
|
1779
|
-
const seq = Number(a);
|
|
1780
|
-
if (!Number.isInteger(seq) || seq < 1) {
|
|
1781
|
-
console.error(`usage: mslxdff -group remove <seq>`);
|
|
1782
|
-
process.exit(1);
|
|
1783
|
-
}
|
|
1784
|
-
const joined = loadGroupsJoined().find((g) => !g.leaderUrl);
|
|
1785
|
-
if (!joined) {
|
|
1786
|
-
console.error("group remove requires being the leader — this node leads no group");
|
|
1787
|
-
process.exit(1);
|
|
1788
|
-
}
|
|
1789
|
-
const members = groups.list()[joined.name]?.members || {};
|
|
1790
|
-
const entries = Object.entries(members).filter(([id]) => id !== "leader");
|
|
1791
|
-
const target = entries[seq - 1];
|
|
1792
|
-
if (!target) {
|
|
1793
|
-
console.error(`member #${seq} not found — group "${joined.name}" has ${entries.length} member(s)`);
|
|
1794
|
-
process.exit(1);
|
|
1795
|
-
}
|
|
1796
|
-
const [id, m] = target;
|
|
1797
|
-
try {
|
|
1798
|
-
const removed = groups.removeMember(joined.name, { url: m.url });
|
|
1799
|
-
if (removed) console.log(`removed ${m.url} from "${joined.name}"`);
|
|
1800
|
-
else console.log(`member ${m.url} already gone from "${joined.name}"`);
|
|
1801
|
-
} catch (err) {
|
|
1802
|
-
console.error(`remove failed: ${errMsg(err)}`);
|
|
1803
|
-
process.exit(1);
|
|
1804
|
-
}
|
|
1805
|
-
} else {
|
|
1806
|
-
console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group remove <seq> | -creategroup <name>");
|
|
1807
|
-
}
|
|
1808
|
-
process.exit(0);
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
// -addtogroup <leader-host> <name> [--broadband]
|
|
1812
|
-
const addToGroupIdx = args.findIndex((x) => x === "-addtogroup" || x === "--addtogroup");
|
|
1813
|
-
if (addToGroupIdx >= 0) {
|
|
1814
|
-
const rawArgs = args.slice(addToGroupIdx + 1);
|
|
1815
|
-
const isBroadband = rawArgs.includes("--broadband");
|
|
1816
|
-
const filtered = rawArgs.filter((a) => a !== "--broadband");
|
|
1817
|
-
const [leaderHost, name] = filtered;
|
|
1818
|
-
if (!leaderHost || !name || filtered.length > 2) {
|
|
1819
|
-
console.error("usage: mslxdff -addtogroup <leader-host> <name> [--broadband]");
|
|
1820
|
-
process.exit(1);
|
|
1821
|
-
}
|
|
1822
|
-
const groups = createGroupsService({});
|
|
1823
|
-
const peers = createPeersService({});
|
|
1824
|
-
const myToken = (await loadToken()).token;
|
|
1825
|
-
const leaderUrl = leaderHost.includes("://")
|
|
1826
|
-
? leaderHost.replace(/\/+$/, "")
|
|
1827
|
-
: `http://${leaderHost}${leaderHost.includes(":") ? "" : ":8989"}`;
|
|
1828
|
-
const kind = isBroadband ? "broadband" : "static";
|
|
1829
|
-
let joinBody;
|
|
1830
|
-
if (isBroadband) {
|
|
1831
|
-
const relayId = `relay://${myToken.slice(0, 8)}`;
|
|
1832
|
-
joinBody = { name, key: name, leaderUrl, url: relayId, token: myToken, kind: "broadband" };
|
|
1833
|
-
} else {
|
|
1834
|
-
const myPort = effectivePort();
|
|
1835
|
-
joinBody = { name, key: name, leaderUrl, myPort, token: myToken, kind: "static" };
|
|
1836
|
-
}
|
|
1837
|
-
try {
|
|
1838
|
-
const res = await fetch(`${leaderUrl}/v1/groups/join`, {
|
|
1839
|
-
method: "POST",
|
|
1840
|
-
headers: { "Content-Type": "application/json" },
|
|
1841
|
-
body: JSON.stringify(joinBody),
|
|
1842
|
-
});
|
|
1843
|
-
if (!res.ok) {
|
|
1844
|
-
const text = await res.text().catch(() => "");
|
|
1845
|
-
throw new Error(`join failed (HTTP ${res.status}): ${text}`);
|
|
1846
|
-
}
|
|
1847
|
-
const data = await res.json();
|
|
1848
|
-
const myUrl = data.you?.url || joinBody.url || "";
|
|
1849
|
-
const memberName = isBroadband ? myUrl : myUrl;
|
|
1850
|
-
markJoined({ name, leaderUrl, myUrl, memberName, kind });
|
|
1851
|
-
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
1852
|
-
const s = synced.find((x) => x.name === name);
|
|
1853
|
-
console.log(`joined group "${name}" at ${leaderUrl}${isBroadband ? " [broadband]" : ""}`);
|
|
1854
|
-
if (s?.error) console.log(` local failover setup failed: ${s.error}`);
|
|
1855
|
-
else console.log(` ${s?.added ?? 0} failover target(s) configured${isBroadband ? " (broadband via leader, local 127.0.0.1)" : ""}`);
|
|
1856
|
-
} catch (err) {
|
|
1857
|
-
console.error(`join failed: ${err.message}`);
|
|
1858
|
-
process.exit(1);
|
|
1859
|
-
}
|
|
1860
|
-
process.exit(0);
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
// -resetban [ip]
|
|
1864
|
-
const resetBanArg = argValue("-resetban", "--resetban");
|
|
1865
|
-
if (resetBanArg !== null || args.includes("-resetban") || args.includes("--resetban")) {
|
|
1866
|
-
const bans = createBansService({});
|
|
1867
|
-
const ip = resetBanArg || null;
|
|
1868
|
-
bans.clear(ip || undefined);
|
|
1869
|
-
console.log(ip ? `ban cleared for ${ip}` : "all bans cleared");
|
|
1870
|
-
process.exit(0);
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
function markJoined(entry) {
|
|
1874
|
-
const { name } = entry;
|
|
1875
|
-
if (!entry.kind) entry.kind = "static";
|
|
1876
|
-
const list = loadGroupsJoined().filter((g) => g.name !== name);
|
|
1877
|
-
saveGroupsJoined([...list, entry]);
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
// -leavegroup | -leave-groups: leave every joined group as a member.
|
|
1881
|
-
// Leaders cannot "leave" — they must disband with -delgroup <name>.
|
|
1882
|
-
const leaveAll = args.includes("-leavegroup") || args.includes("--leavegroup") || args.includes("-leave-groups");
|
|
1883
|
-
if (leaveAll) {
|
|
1884
|
-
const peers = createPeersService({});
|
|
1885
|
-
const joined = loadGroupsJoined();
|
|
1886
|
-
if (!joined.length) {
|
|
1887
|
-
console.log("not joined to any group");
|
|
1888
|
-
process.exit(0);
|
|
1889
|
-
}
|
|
1890
|
-
const myToken = (await loadToken()).token;
|
|
1891
|
-
const leaders = [];
|
|
1892
|
-
for (const g of joined) {
|
|
1893
|
-
if (g.leaderUrl) {
|
|
1894
|
-
// member: deregister from the leader so we stop showing up in -group list
|
|
1895
|
-
const peersRemoved = peers.removeByGroup(g.name);
|
|
1896
|
-
try {
|
|
1897
|
-
const res = await fetch(`${g.leaderUrl}/v1/groups/leave`, {
|
|
1898
|
-
method: "POST",
|
|
1899
|
-
headers: {
|
|
1900
|
-
"Content-Type": "application/json",
|
|
1901
|
-
"Authorization": `Bearer ${myToken}`,
|
|
1902
|
-
},
|
|
1903
|
-
body: JSON.stringify({ name: g.name }),
|
|
1904
|
-
});
|
|
1905
|
-
if (res.ok) console.log(`${g.name}: left (deregistered from ${g.leaderUrl})`);
|
|
1906
|
-
else console.log(`${g.name}: left locally (leader said: ${await res.text().catch(() => `HTTP ${res.status}`)})`);
|
|
1907
|
-
} catch (err) {
|
|
1908
|
-
console.log(`${g.name}: left locally (leader unreachable: ${errMsg(err)})`);
|
|
1909
|
-
}
|
|
1910
|
-
} else {
|
|
1911
|
-
leaders.push(g.name);
|
|
1912
|
-
}
|
|
1913
|
-
}
|
|
1914
|
-
const left = joined.filter((g) => g.leaderUrl).map((g) => g.name);
|
|
1915
|
-
saveGroupsJoined(loadGroupsJoined().filter((g) => !left.includes(g.name)));
|
|
1916
|
-
console.log(`left ${left.length} group(s)`);
|
|
1917
|
-
if (leaders.length) {
|
|
1918
|
-
console.log(`\nskipped ${leaders.length} group(s) where this node is the leader:`);
|
|
1919
|
-
for (const n of leaders) console.log(` ${n} — leaders can't leave; disband it with: mslxdff -delgroup ${n}`);
|
|
1920
|
-
}
|
|
1921
|
-
process.exit(0);
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
// -delgroup <name>: a leader disbands one of its groups (local groups state +
|
|
1925
|
-
// any member records). Fails if this node is not the leader of that group.
|
|
1926
|
-
const delGroupName = argValue("-delgroup", "--delgroup");
|
|
1927
|
-
if (delGroupName) {
|
|
1928
|
-
const groups = createGroupsService({});
|
|
1929
|
-
const peers = createPeersService({});
|
|
1930
|
-
const local = groups.list()[delGroupName];
|
|
1931
|
-
if (!local) {
|
|
1932
|
-
const joined0 = loadGroupsJoined().find((g) => g.name === delGroupName);
|
|
1933
|
-
if (joined0?.leaderUrl) {
|
|
1934
|
-
console.log(`"${delGroupName}" is led by ${joined0.leaderUrl} — you are a member, use -leavegroup to leave it`);
|
|
1935
|
-
} else if (joined0) {
|
|
1936
|
-
console.log(`"${delGroupName}" exists in local state but has no group definition — nothing to delete`);
|
|
1937
|
-
} else {
|
|
1938
|
-
console.log(`group "${delGroupName}" not found on this node`);
|
|
1939
|
-
}
|
|
1940
|
-
process.exit(1);
|
|
1941
|
-
}
|
|
1942
|
-
const disbanded = groups.delete(delGroupName);
|
|
1943
|
-
const members = Object.values(disbanded?.members || {});
|
|
1944
|
-
peers.removeByGroup(delGroupName);
|
|
1945
|
-
saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== delGroupName));
|
|
1946
|
-
console.log(`group "${delGroupName}" disbanded (${members.length} member${members.length === 1 ? "" : "s"} removed)`);
|
|
1947
|
-
process.exit(0);
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
function errMsg(err) { return String(err?.message || err); }
|
|
1951
|
-
|
|
1952
|
-
// Probe a member's health endpoint concurrently (v1/health preferred,
|
|
1953
|
-
// falling back to /health). Returns { id, url, ms, rank } or { id, url, fail }.
|
|
1954
|
-
// broadband relay:// members are not probed — they are reachable via leader relay.
|
|
1955
|
-
async function probeHealth({ id, url, kind, lastSeen, publicIp } = {}) {
|
|
1956
|
-
if (!url) return { id, url, fail: "no url", rank: 3 };
|
|
1957
|
-
const isBb = kind === "broadband" || String(url).startsWith("relay://");
|
|
1958
|
-
if (isBb) {
|
|
1959
|
-
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
1960
|
-
const stale = typeof lastSeen === "number" ? Date.now() - lastSeen > staleMs : true;
|
|
1961
|
-
return { id, url, kind: "broadband", lastSeen, publicIp, stale, rank: stale ? 2 : 0 };
|
|
1962
|
-
}
|
|
1963
|
-
const base = String(url).replace(/\/+$/, "");
|
|
1964
|
-
const startedAt = Date.now();
|
|
1965
|
-
try {
|
|
1966
|
-
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) });
|
|
1967
|
-
if (!res.ok) return { id, url: base, fail: `HTTP ${res.status}`, rank: 2 };
|
|
1968
|
-
return { id, url: base, ms: Date.now() - startedAt, rank: 0 };
|
|
1969
|
-
} catch (err) {
|
|
1970
|
-
return { id, url: base, fail: errMsg(err), rank: 2 };
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
function readModelsCache(cacheFile) {
|
|
1975
|
-
try {
|
|
1976
|
-
return JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
1977
|
-
} catch {
|
|
1978
|
-
return null;
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
// Sync every joined group into the local peer list. Leaders read their local
|
|
1983
|
-
// groups state; members re-register with the leader (idempotent) to get the
|
|
1984
|
-
// freshest member list. Returns per-group results.
|
|
1985
|
-
async function syncAllJoinedGroups({ peers, groups }) {
|
|
1986
|
-
const joined = loadGroupsJoined();
|
|
1987
|
-
const myToken = (await loadToken()).token;
|
|
1988
|
-
const results = [];
|
|
1989
|
-
for (const g of joined) {
|
|
1990
|
-
try {
|
|
1991
|
-
if (g.leaderUrl) {
|
|
1992
|
-
const members = await refreshGroupMembers(g.name, {
|
|
1993
|
-
leaderUrl: g.leaderUrl,
|
|
1994
|
-
memberName: g.memberName,
|
|
1995
|
-
url: g.myUrl,
|
|
1996
|
-
token: myToken,
|
|
1997
|
-
kind: g.kind,
|
|
1998
|
-
});
|
|
1999
|
-
results.push({
|
|
2000
|
-
name: g.name,
|
|
2001
|
-
...syncPeersFromMembers({ peers, members, myUrl: g.myUrl, group: g.name }),
|
|
2002
|
-
});
|
|
2003
|
-
} else {
|
|
2004
|
-
// this node is the leader: […] own entry is always skipped
|
|
2005
|
-
const members = groups.list()[g.name]?.members ?? {};
|
|
2006
|
-
results.push({
|
|
2007
|
-
name: g.name,
|
|
2008
|
-
...syncPeersFromMembers({ peers, members, myUrl: g.myUrl, group: g.name, skipIds: ["leader"] }),
|
|
2009
|
-
});
|
|
2010
|
-
}
|
|
2011
|
-
} catch (err) {
|
|
2012
|
-
results.push({ name: g.name, error: errMsg(err) });
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
return results;
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
// Upgrade-aware handling of a running daemon: keep it when it already runs
|
|
2019
|
-
// our version, otherwise stop it so the spawn below takes over. Cleans up
|
|
2020
|
-
// stale pid files (daemon died without -stop).
|
|
2021
|
-
function stopDaemonIfOutdated() {
|
|
2022
|
-
const pid = readPid();
|
|
2023
|
-
if (!pid) return;
|
|
2024
|
-
if (!isPidAlive(pid)) {
|
|
2025
|
-
console.log(`daemon pid ${pid} is stale (not running) — starting fresh`);
|
|
2026
|
-
return;
|
|
2027
|
-
}
|
|
2028
|
-
const runningVersion = readPidVersion();
|
|
2029
|
-
if (runningVersion === VERSION) return;
|
|
2030
|
-
console.log(
|
|
2031
|
-
runningVersion
|
|
2032
|
-
? `daemon running v${runningVersion} — upgrading to v${VERSION}, restarting...`
|
|
2033
|
-
: `daemon version unknown — restarting with v${VERSION}...`
|
|
2034
|
-
);
|
|
2035
|
-
stopDaemon();
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
// -port N: persist the port, then restart the daemon on it if one is running.
|
|
2039
|
-
// Skip when we ARE the daemon child (it already carries the port via args).
|
|
2040
|
-
const portArg = argValue("-port", "--port");
|
|
2041
|
-
if (portArg && !process.env.MSLXDFF_DAEMON) {
|
|
2042
|
-
const port = Number(portArg);
|
|
2043
|
-
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
2044
|
-
console.error(`invalid port: ${portArg}`);
|
|
2045
|
-
process.exit(1);
|
|
2046
|
-
}
|
|
2047
|
-
setPort(port);
|
|
2048
|
-
const daemon = readPid();
|
|
2049
|
-
if (daemon) {
|
|
2050
|
-
stopDaemon();
|
|
2051
|
-
startDaemon(["-port", String(port)]);
|
|
2052
|
-
await waitForHealth(port, 4000);
|
|
2053
|
-
console.log(`mslxdff restarted on port ${port} (pid ${readPid()})`);
|
|
2054
|
-
console.log(`endpoint: http://localhost:${port}/v1`);
|
|
2055
|
-
} else {
|
|
2056
|
-
console.log(`port saved: ${port} (daemon not running; takes effect on next start)`);
|
|
2057
|
-
}
|
|
2058
|
-
process.exit(0);
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
|
-
if (args.includes("-d") || args.includes("--daemon")) {
|
|
2062
|
-
if (!process.env.MSLXDFF_DAEMON) {
|
|
2063
|
-
// foreground: spawn the detached background instance, then wait for health
|
|
2064
|
-
const port = effectivePort();
|
|
2065
|
-
stopDaemonIfOutdated();
|
|
2066
|
-
const spawnedPid = startDaemon(args.filter((a) => a !== "-d" && a !== "--daemon"));
|
|
2067
|
-
await waitForHealth(port, 4000);
|
|
2068
|
-
console.log(`mslxdff daemon started (pid ${spawnedPid})`);
|
|
2069
|
-
console.log(`log: ${logFile()}`);
|
|
2070
|
-
console.log(`pid: ${pidFile()}`);
|
|
2071
|
-
process.exit(0);
|
|
2072
|
-
}
|
|
2073
|
-
// we ARE the daemon; stdout/stderr already point at the log file via startDaemon stdio
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
// Bare run: show status + help when the daemon is already up; otherwise spawn it
|
|
2077
|
-
// as a background daemon and exit — never holds the terminal (npx-friendly).
|
|
2078
|
-
if (!process.env.MSLXDFF_DAEMON) {
|
|
2079
|
-
const pid = readPid();
|
|
2080
|
-
if (pid && isPidAlive(pid) && readPidVersion() === VERSION) {
|
|
2081
|
-
await printStatus();
|
|
2082
|
-
printHelp();
|
|
2083
|
-
process.exit(0);
|
|
2084
|
-
}
|
|
2085
|
-
const port = effectivePort();
|
|
2086
|
-
stopDaemonIfOutdated();
|
|
2087
|
-
const spawnedPid = startDaemon([]);
|
|
2088
|
-
await waitForHealth(port, 4000);
|
|
2089
|
-
console.log(`mslxdff v${VERSION} started as a background daemon (pid ${spawnedPid})`);
|
|
2090
|
-
console.log(`endpoint: http://localhost:${port}/v1`);
|
|
2091
|
-
console.log(`log: ${logFile()}`);
|
|
2092
|
-
console.log(`pid: ${pidFile()}`);
|
|
2093
|
-
console.log(`status: run \`mslxdff\` again (or \`mslxdff -status\`)`);
|
|
2094
|
-
process.exit(0);
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
const { token, created } = await loadToken();
|
|
2098
|
-
// 插件系统:加载(在 upstream 创建前,插件可整体替换上游实现)
|
|
2099
|
-
// 双目录:<安装目录>/plugins/(官方插件,随包分发)+ ~/.config/mslxdff/plugins/(用户自定义,升级不丢)
|
|
2100
|
-
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
2101
|
-
const pluginDirs = resolvePluginDirs({ pkgRoot });
|
|
2102
|
-
const { plugins: loadedPlugins, errors: pluginErrors } = await loadPlugins({ dirs: pluginDirs });
|
|
2103
|
-
for (const e of pluginErrors) {
|
|
2104
|
-
console.log(`plugin load failed: ${e.file} — ${e.error}`);
|
|
2105
|
-
appendEvent({ ts: Date.now(), type: "plugin-load-error", file: e.file, error: e.error });
|
|
2106
|
-
}
|
|
2107
|
-
if (loadedPlugins.length) {
|
|
2108
|
-
console.log(`plugins loaded (${loadedPlugins.length}): ${loadedPlugins.map((p) => `${p.name}${p.version ? `@${p.version}` : ""}`).join(", ")}`);
|
|
2109
|
-
appendEvent({ ts: Date.now(), type: "plugins-loaded", plugins: loadedPlugins.map((p) => ({ name: p.name, version: p.version })) });
|
|
2110
|
-
}
|
|
2111
|
-
// 多供应商通道:默认 opencode + 可选 openrouter(MSLXDFF_OPENROUTER_KEY 启用)
|
|
2112
|
-
// 插件 createUpstream 仍是"整体替换式"外部 provider(单通道,保留原语义)
|
|
2113
|
-
const upstreamHooks = loadedPlugins.length
|
|
2114
|
-
? (name, ctx) => runHook(loadedPlugins, name, ctx)
|
|
2115
|
-
: null;
|
|
2116
|
-
const providerPlugin = loadedPlugins.find((p) => typeof p.createUpstream === "function");
|
|
2117
|
-
let upstream;
|
|
2118
|
-
const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
2119
|
-
let providers = [];
|
|
2120
|
-
if (providerPlugin) {
|
|
2121
|
-
// 插件整体替换:沿用旧单通道路径
|
|
2122
|
-
if (!upstream) {
|
|
2123
|
-
try {
|
|
2124
|
-
upstream = await providerPlugin.createUpstream({ baseUrl, authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
|
|
2125
|
-
console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
|
|
2126
|
-
appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
|
|
2127
|
-
} catch (err) {
|
|
2128
|
-
console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} — falling back to default`);
|
|
2129
|
-
appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
|
-
if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
|
|
2133
|
-
} else {
|
|
2134
|
-
// 内置多 Provider:opencode 恒启用,openrouter 有 key 才启用
|
|
2135
|
-
const opencodeClient = createUpstreamClient({ hooks: upstreamHooks });
|
|
2136
|
-
const opencodeModels = createModelsService({
|
|
2137
|
-
baseUrl,
|
|
2138
|
-
headers: opencodeClient.headers,
|
|
2139
|
-
refreshMs: refreshIntervalMs(),
|
|
2140
|
-
cacheFile: join(logDir(), "models.json"),
|
|
2141
|
-
});
|
|
2142
|
-
providers.push(createOpenCodeProvider({ upstream: opencodeClient, modelsService: opencodeModels }));
|
|
2143
|
-
const orKeys = loadProviderKeys("openrouter");
|
|
2144
|
-
if (orKeys.length) {
|
|
2145
|
-
const { createOpenRouterProvider } = await import("../src/providers/openrouter.js");
|
|
2146
|
-
providers.push(createOpenRouterProvider({ apiKeys: orKeys }));
|
|
2147
|
-
console.log(`provider enabled: openrouter (${orKeys.length} key${orKeys.length > 1 ? "s" : ""})`);
|
|
2148
|
-
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "openrouter", keys: orKeys.length });
|
|
2149
|
-
}
|
|
2150
|
-
// 通用 OpenAI 兼容供应商:读 providerConfigs,逐个创建 generic provider
|
|
2151
|
-
const { loadProviderConfigs } = await import("../src/state.js");
|
|
2152
|
-
const genericConfigs = loadProviderConfigs();
|
|
2153
|
-
for (const [gid, cfg] of Object.entries(genericConfigs)) {
|
|
2154
|
-
if (gid === "opencode" || gid === "openrouter") continue;
|
|
2155
|
-
if (gid === "workbuddy") {
|
|
2156
|
-
const base = String(cfg?.baseUrl || "").trim() || "https://copilot.tencent.com";
|
|
2157
|
-
const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
|
|
2158
|
-
const auths = Array.isArray(cfg?.auths) ? cfg.auths : [];
|
|
2159
|
-
if (!keys.length) continue;
|
|
2160
|
-
try {
|
|
2161
|
-
const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
|
|
2162
|
-
providers.push(createWorkbuddyProvider({ baseUrl: base, apiKeys: keys, auths }));
|
|
2163
|
-
console.log(`provider enabled: workbuddy (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
|
|
2164
|
-
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
|
|
2165
|
-
} catch (err) {
|
|
2166
|
-
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
2167
|
-
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
2168
|
-
}
|
|
2169
|
-
continue;
|
|
2170
|
-
}
|
|
2171
|
-
const base = String(cfg?.baseUrl || "").trim();
|
|
2172
|
-
const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
|
|
2173
|
-
if (!base || !keys.length) continue;
|
|
2174
|
-
try {
|
|
2175
|
-
const { createGenericProvider } = await import("../src/providers/generic.js");
|
|
2176
|
-
providers.push(createGenericProvider({ id: gid, baseUrl: base, apiKeys: keys }));
|
|
2177
|
-
console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
|
|
2178
|
-
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
|
|
2179
|
-
} catch (err) {
|
|
2180
|
-
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
2181
|
-
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
// workbuddy via env-only (no providerConfigs entry but MSLXDFF_WORKBUDDY_KEY set)
|
|
2185
|
-
if (!genericConfigs["workbuddy"]) {
|
|
2186
|
-
const wbKeys = loadProviderKeys("workbuddy");
|
|
2187
|
-
if (wbKeys.length) {
|
|
2188
|
-
try {
|
|
2189
|
-
const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
|
|
2190
|
-
const wbAuths = loadProviderAuths("workbuddy");
|
|
2191
|
-
providers.push(createWorkbuddyProvider({ apiKeys: wbKeys, auths: wbAuths }));
|
|
2192
|
-
console.log(`provider enabled: workbuddy (${wbKeys.length} key${wbKeys.length > 1 ? "s" : ""}) baseUrl=https://copilot.tencent.com (env)`);
|
|
2193
|
-
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "workbuddy", keys: wbKeys.length, baseUrl: "https://copilot.tencent.com" });
|
|
2194
|
-
} catch (err) {
|
|
2195
|
-
console.log(`provider workbuddy failed: ${err?.message || err}`);
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
const { createProviderDispatcher } = await import("../src/providers/dispatcher.js");
|
|
2200
|
-
upstream = createProviderDispatcher(providers);
|
|
2201
|
-
appendEvent({ ts: Date.now(), type: "providers", providers: providers.map((p) => p.id) });
|
|
2202
|
-
}
|
|
2203
|
-
const opencodeProvider = providers.find((p) => p.id === "opencode");
|
|
2204
|
-
const models = createModelsService({
|
|
2205
|
-
providers: providers.length > 1 ? providers : undefined,
|
|
2206
|
-
baseUrl,
|
|
2207
|
-
headers: upstream.headers || opencodeProvider?.upstream?.headers,
|
|
2208
|
-
refreshMs: refreshIntervalMs(),
|
|
2209
|
-
cacheFile: join(logDir(), "models.json"),
|
|
2210
|
-
});
|
|
2211
|
-
const auto = createAutoSelector({
|
|
2212
|
-
cooldownMs: modelCooldownMs(),
|
|
2213
|
-
slowCooldownMs: slowCooldownMs(),
|
|
2214
|
-
file: defaultStateFile(),
|
|
2215
|
-
loadCandidates: async () => {
|
|
2216
|
-
try {
|
|
2217
|
-
return (await models.get()).data.map((m) => m.id);
|
|
2218
|
-
} catch {
|
|
2219
|
-
return null;
|
|
2220
|
-
}
|
|
2221
|
-
},
|
|
2222
|
-
});
|
|
2223
|
-
const peers = createPeersService({ cooldownMs: peerCooldownMs(), heatMs: peerHeatMs() });
|
|
2224
|
-
const groups = createGroupsService({});
|
|
2225
|
-
const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshold() });
|
|
2226
|
-
|
|
2227
|
-
const isDebug = process.env.MSLXDFF_DEBUG === "1";
|
|
2228
|
-
const bus = createEventBus();
|
|
2229
|
-
|
|
2230
|
-
// 加载 WorkBuddy 别名映射(clinebot-z-ai → clinebot/z-ai)
|
|
2231
|
-
try {
|
|
2232
|
-
const { loadModelAliases } = await import("../src/providers/model-id.js");
|
|
2233
|
-
loadModelAliases();
|
|
2234
|
-
} catch {}
|
|
2235
|
-
|
|
2236
|
-
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus, plugins: loadedPlugins });
|
|
2237
|
-
const listenHost = effectiveHost();
|
|
2238
|
-
const srv = startServer({
|
|
2239
|
-
router,
|
|
2240
|
-
signals: !isDebug,
|
|
2241
|
-
host: listenHost,
|
|
2242
|
-
onBeforeClose: loadedPlugins.length
|
|
2243
|
-
? () => runHook(loadedPlugins, "server:stop", { version: VERSION }).then(() => {})
|
|
2244
|
-
: undefined,
|
|
2245
|
-
});
|
|
2246
|
-
|
|
2247
|
-
// -debug: push every event straight to this terminal.
|
|
2248
|
-
if (isDebug) {
|
|
2249
|
-
bus.subscribe((e) => {
|
|
2250
|
-
try {
|
|
2251
|
-
console.log(fmtEvent(e));
|
|
2252
|
-
} catch {
|
|
2253
|
-
// malformed event — skip
|
|
2254
|
-
}
|
|
2255
|
-
});
|
|
2256
|
-
// Ctrl+C / SIGTERM: restore the background daemon, then exit.
|
|
2257
|
-
const restore = () => {
|
|
2258
|
-
console.log("\n[debug] restoring background daemon...");
|
|
2259
|
-
try {
|
|
2260
|
-
const restoredPid = startDaemon([]);
|
|
2261
|
-
console.log(`[debug] daemon restored (pid ${restoredPid})`);
|
|
2262
|
-
} catch (err) {
|
|
2263
|
-
console.error(`[debug] could not restore daemon: ${err.message}`);
|
|
2264
|
-
}
|
|
2265
|
-
setTimeout(() => process.exit(0), 300);
|
|
2266
|
-
};
|
|
2267
|
-
process.on("SIGINT", restore);
|
|
2268
|
-
process.on("SIGTERM", restore);
|
|
2269
|
-
}
|
|
2270
|
-
|
|
2271
|
-
await srv.ready();
|
|
2272
|
-
|
|
2273
|
-
// 插件 hook:server:start — 服务就绪后触发(只观察)
|
|
2274
|
-
if (loadedPlugins.length) {
|
|
2275
|
-
runHook(loadedPlugins, "server:start", { port: srv.server.address()?.port, host: listenHost, version: VERSION }).catch(() => {});
|
|
2276
|
-
// 插件 onEvent(evt) — 订阅全部事件流(fire-and-forget,错误隔离)
|
|
2277
|
-
const eventPlugins = loadedPlugins.filter((p) => typeof p.onEvent === "function");
|
|
2278
|
-
if (eventPlugins.length) {
|
|
2279
|
-
bus.subscribe((e) => {
|
|
2280
|
-
for (const p of eventPlugins) {
|
|
2281
|
-
try { p.onEvent(e); } catch {}
|
|
2282
|
-
}
|
|
2283
|
-
});
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
2286
|
-
|
|
2287
|
-
// 上游 Keep-Alive 预热:首条 TCP+TLS 暖好,100ms 后异步触发,不阻塞 ready
|
|
2288
|
-
setTimeout(() => {
|
|
2289
|
-
upstream.preheat().then((r) => {
|
|
2290
|
-
const entry = { ts: Date.now(), type: "upstream-preheat", ...r, baseUrl };
|
|
2291
|
-
try { bus.emit(entry); } catch {}
|
|
2292
|
-
try { logs.appendEvent(entry); } catch {}
|
|
2293
|
-
if (r.skipped) console.log(`[preheat] skipped (MSLXDFF_PREHEAT disabled)`);
|
|
2294
|
-
else if (r.ok) console.log(`[preheat] opencode models ok ${r.status} ${r.ms}ms`);
|
|
2295
|
-
else console.log(`[preheat] opencode models failed ${r.error || r.status || ""} ${r.ms || 0}ms`);
|
|
2296
|
-
}).catch(() => {});
|
|
2297
|
-
}, 100).unref?.();
|
|
2298
|
-
|
|
2299
|
-
models.startAutoRefresh();
|
|
2300
|
-
if (process.env.MSLXDFF_DAEMON) {
|
|
2301
|
-
writePid(process.pid, VERSION);
|
|
2302
|
-
}
|
|
2303
|
-
const addr = srv.server.address();
|
|
2304
|
-
const host = addr.address === "0.0.0.0" || addr.address === "::" ? "localhost" : addr.address;
|
|
2305
|
-
console.log(`mslxdff v${VERSION} listening on http://${host}:${addr.port}`);
|
|
2306
|
-
if (created) {
|
|
2307
|
-
console.log(`auth token: ${token}`);
|
|
2308
|
-
}
|
|
2309
|
-
console.log(`endpoint: http://${host}:${addr.port}/v1`);
|
|
2310
|
-
try {
|
|
2311
|
-
const { hedgeDelayMs } = await import("../src/routes/hedge.js");
|
|
2312
|
-
const hd = hedgeDelayMs();
|
|
2313
|
-
console.log(`hedge: ${hd ? `${hd}ms` : "off"} (MSLXDFF_HEDGE_DELAY_MS)`);
|
|
2314
|
-
} catch {}
|
|
2315
|
-
|
|
2316
|
-
// Periodically pull the freshest member lists for every joined group so a
|
|
2317
|
-
// new member becomes a failover peer on all nodes without manual re-joining.
|
|
2318
|
-
syncAllJoinedGroups({ peers, groups })
|
|
2319
|
-
.then((results) => {
|
|
2320
|
-
for (const r of results) {
|
|
2321
|
-
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
2322
|
-
else console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
2323
|
-
}
|
|
2324
|
-
})
|
|
2325
|
-
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
2326
|
-
const groupSyncTimer = setInterval(() => {
|
|
2327
|
-
syncAllJoinedGroups({ peers, groups })
|
|
2328
|
-
.then((results) => {
|
|
2329
|
-
for (const r of results) {
|
|
2330
|
-
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
2331
|
-
else if (r.added) console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
2332
|
-
}
|
|
2333
|
-
})
|
|
2334
|
-
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
2335
|
-
}, groupSyncIntervalMs());
|
|
2336
|
-
groupSyncTimer.unref();
|
|
2337
|
-
|
|
2338
|
-
// Broadband heartbeat + relay poll (only for broadband members)
|
|
2339
|
-
const broadbandGroups = () => loadGroupsJoined().filter((g) => g.kind === "broadband" && g.leaderUrl);
|
|
2340
|
-
if (broadbandGroups().length) {
|
|
2341
|
-
const doHeartbeat = async () => {
|
|
2342
|
-
for (const g of broadbandGroups()) {
|
|
2343
|
-
try {
|
|
2344
|
-
const res = await fetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
|
|
2345
|
-
method: "POST",
|
|
2346
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
2347
|
-
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
2348
|
-
signal: AbortSignal.timeout(5000),
|
|
2349
|
-
});
|
|
2350
|
-
if (!res.ok) {
|
|
2351
|
-
const txt = await res.text().catch(() => "");
|
|
2352
|
-
console.log(`broadband heartbeat ${g.name}: ${res.status} ${txt.slice(0, 100)}`);
|
|
2353
|
-
}
|
|
2354
|
-
} catch (err) {
|
|
2355
|
-
console.log(`broadband heartbeat ${g.name}: failed — ${errMsg(err)}`);
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
};
|
|
2359
|
-
const doPoll = async () => {
|
|
2360
|
-
for (const g of broadbandGroups()) {
|
|
2361
|
-
try {
|
|
2362
|
-
const pollRes = await fetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
|
|
2363
|
-
method: "POST",
|
|
2364
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
2365
|
-
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
2366
|
-
signal: AbortSignal.timeout(8000),
|
|
2367
|
-
});
|
|
2368
|
-
if (!pollRes.ok) continue;
|
|
2369
|
-
const data = await pollRes.json().catch(() => ({}));
|
|
2370
|
-
const items = data.data || [];
|
|
2371
|
-
for (const item of items) {
|
|
2372
|
-
const { reqId, body } = item;
|
|
2373
|
-
let result;
|
|
2374
|
-
try {
|
|
2375
|
-
const upRes = await upstream.chat(body);
|
|
2376
|
-
const ct = upRes.headers.get("content-type") || "";
|
|
2377
|
-
const isStream = Boolean(body?.stream) || ct.includes("text/event-stream");
|
|
2378
|
-
if (isStream && upRes.body) {
|
|
2379
|
-
// collect stream chunks for relay (simplified: buffer then forward as SSE)
|
|
2380
|
-
let collected = "";
|
|
2381
|
-
for await (const chunk of upRes.body) {
|
|
2382
|
-
collected += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
2383
|
-
}
|
|
2384
|
-
result = { status: upRes.status, headers: { "Content-Type": "text/event-stream" }, body: collected };
|
|
2385
|
-
} else {
|
|
2386
|
-
const txt = await upRes.text();
|
|
2387
|
-
let parsed;
|
|
2388
|
-
try { parsed = JSON.parse(txt); } catch { parsed = txt; }
|
|
2389
|
-
result = { status: upRes.status, headers: { "Content-Type": upRes.headers.get("content-type") || "application/json" }, body: typeof parsed === "string" ? parsed : JSON.stringify(parsed) };
|
|
2390
|
-
}
|
|
2391
|
-
} catch (err) {
|
|
2392
|
-
result = { status: 502, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: errMsg(err) }) };
|
|
2393
|
-
}
|
|
2394
|
-
try {
|
|
2395
|
-
await fetch(`${g.leaderUrl}/v1/groups/relay/result`, {
|
|
2396
|
-
method: "POST",
|
|
2397
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
2398
|
-
body: JSON.stringify({ name: g.name, group: g.name, reqId, result }),
|
|
2399
|
-
signal: AbortSignal.timeout(5000),
|
|
2400
|
-
});
|
|
2401
|
-
} catch {}
|
|
2402
|
-
}
|
|
2403
|
-
} catch {}
|
|
2404
|
-
}
|
|
2405
|
-
};
|
|
2406
|
-
// initial heartbeat
|
|
2407
|
-
doHeartbeat().catch(() => {});
|
|
2408
|
-
const hbTimer = setInterval(doHeartbeat, 30_000);
|
|
2409
|
-
hbTimer.unref();
|
|
2410
|
-
const pollTimer = setInterval(doPoll, 1000);
|
|
2411
|
-
pollTimer.unref();
|
|
2412
|
-
console.log(`broadband relay: heartbeat 30s + poll 1s for ${broadbandGroups().length} group(s)`);
|
|
2413
|
-
}
|
|
2414
|
-
|
|
2415
|
-
// Auto-update: periodically check npm for a newer mslxdff and restart.
|
|
2416
|
-
// Default: hourly (no env needed). Disable with MSLXDFF_AUTO_UPDATE=0/off/false.
|
|
2417
|
-
// Tuning: MSLXDFF_AUTO_UPDATE=1/true → hourly, or MSLXDFF_AUTO_UPDATE_MS=<ms>.
|
|
2418
|
-
const autoUpdateMs = autoUpdateIntervalMs();
|
|
2419
|
-
function emitAutoUpdate(type, data = {}) {
|
|
2420
|
-
const entry = { ts: Date.now(), type, ...data };
|
|
2421
|
-
try { bus?.emit(entry); } catch {}
|
|
2422
|
-
try { logs?.appendEvent?.(entry); } catch {}
|
|
2423
|
-
// also to daemon.log for tail
|
|
2424
|
-
const line = `[auto-update] ${type} ${JSON.stringify(data)}`;
|
|
2425
|
-
console.log(line);
|
|
2426
|
-
}
|
|
2427
|
-
if (autoUpdateMs) {
|
|
2428
|
-
console.log(`auto-update enabled: checking every ${Math.round(autoUpdateMs / 60000)}m`);
|
|
2429
|
-
emitAutoUpdate("auto-update-enabled", { intervalMs: autoUpdateMs, current: VERSION });
|
|
2430
|
-
// run once shortly after start (30s) so a newly deployed fix is picked up quickly,
|
|
2431
|
-
// then on the regular interval
|
|
2432
|
-
setTimeout(() => {
|
|
2433
|
-
emitAutoUpdate("auto-update-check", { current: VERSION });
|
|
2434
|
-
checkAndAutoUpdate().catch((err) => {
|
|
2435
|
-
console.log(`auto-update check failed: ${errMsg(err)}`);
|
|
2436
|
-
emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
|
|
2437
|
-
});
|
|
2438
|
-
}, 30_000).unref?.();
|
|
2439
|
-
const autoUpdateTimer = setInterval(() => {
|
|
2440
|
-
emitAutoUpdate("auto-update-check", { current: VERSION });
|
|
2441
|
-
checkAndAutoUpdate().catch((err) => {
|
|
2442
|
-
console.log(`auto-update check failed: ${errMsg(err)}`);
|
|
2443
|
-
emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
|
|
2444
|
-
});
|
|
2445
|
-
}, autoUpdateMs);
|
|
2446
|
-
autoUpdateTimer.unref();
|
|
2447
|
-
} else {
|
|
2448
|
-
console.log(`auto-update disabled (set MSLXDFF_AUTO_UPDATE=1 to enable hourly)`);
|
|
2449
|
-
emitAutoUpdate("auto-update-disabled", { current: VERSION });
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
async function checkAndAutoUpdate() {
|
|
2453
|
-
emitAutoUpdate("auto-update-query", { current: VERSION });
|
|
2454
|
-
const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
|
|
2455
|
-
if (info.err) {
|
|
2456
|
-
emitAutoUpdate("auto-update-query-failed", { error: info.err.message || String(info.stderr || "").slice(0, 500) });
|
|
2457
|
-
throw new Error(info.err.message || String(info.stderr || "").slice(0, 500));
|
|
2458
|
-
}
|
|
2459
|
-
let latest = "";
|
|
2460
|
-
try {
|
|
2461
|
-
latest = JSON.parse(String(info.stdout || "").trim());
|
|
2462
|
-
if (Array.isArray(latest)) latest = latest[latest.length - 1];
|
|
2463
|
-
latest = String(latest || "").replace(/^v/, "").trim();
|
|
2464
|
-
} catch {
|
|
2465
|
-
const raw = String(info.stdout || "").trim();
|
|
2466
|
-
const m = raw.match(/(\d+\.\d+\.\d+[^\s'"]*)/);
|
|
2467
|
-
latest = m ? m[1] : raw.split(/\s+/).pop()?.replace(/['"]/g, "") || "";
|
|
2468
|
-
}
|
|
2469
|
-
latest = latest.replace(/['"]/g, "").trim();
|
|
2470
|
-
emitAutoUpdate("auto-update-queried", { current: VERSION, latest, stdout: String(info.stdout || "").trim().slice(0, 200) });
|
|
2471
|
-
if (!latest || latest === VERSION) {
|
|
2472
|
-
emitAutoUpdate("auto-update-noop", { current: VERSION, latest });
|
|
2473
|
-
return;
|
|
2474
|
-
}
|
|
2475
|
-
if (compareSemver(latest, VERSION) <= 0) {
|
|
2476
|
-
emitAutoUpdate("auto-update-noop", { current: VERSION, latest, reason: "not newer" });
|
|
2477
|
-
return;
|
|
2478
|
-
}
|
|
2479
|
-
emitAutoUpdate("auto-update-found", { current: VERSION, latest });
|
|
2480
|
-
console.log(`auto-update: v${VERSION} -> v${latest}, installing...`);
|
|
2481
|
-
emitAutoUpdate("auto-update-installing", { current: VERSION, latest });
|
|
2482
|
-
const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`]);
|
|
2483
|
-
if (up.err) {
|
|
2484
|
-
emitAutoUpdate("auto-update-install-failed", { current: VERSION, latest, error: up.err.message || String(up.stderr || "").slice(0, 500) });
|
|
2485
|
-
throw new Error(up.err.message || String(up.stderr || "").slice(0, 500));
|
|
2486
|
-
}
|
|
2487
|
-
emitAutoUpdate("auto-update-installed", { current: VERSION, latest, stdout: String(up.stdout || "").slice(0, 500) });
|
|
2488
|
-
console.log(`auto-update: installed v${latest}, restarting daemon...`);
|
|
2489
|
-
emitAutoUpdate("auto-update-restarting", { current: VERSION, latest });
|
|
2490
|
-
try { stopDaemon(); } catch (e) { emitAutoUpdate("auto-update-stop-failed", { error: errMsg(e) }); }
|
|
2491
|
-
const newPid = startDaemon([]);
|
|
2492
|
-
await waitForHealth(resolvePort(), 8000);
|
|
2493
|
-
console.log(`auto-update: restarted as v${latest} (pid ${newPid})`);
|
|
2494
|
-
emitAutoUpdate("auto-update-restarted", { current: VERSION, latest, newPid });
|
|
2495
|
-
}
|
|
2496
|
-
|
|
2497
|
-
function compareSemver(a, b) {
|
|
2498
|
-
const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
|
|
2499
|
-
const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
|
|
2500
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
2501
|
-
const av = pa[i] || 0, bv = pb[i] || 0;
|
|
2502
|
-
if (av !== bv) return av - bv;
|
|
2503
|
-
}
|
|
2504
|
-
return 0;
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
function argValue(...names) {
|
|
2508
|
-
for (let i = 0; i < args.length; i++) {
|
|
2509
|
-
if (names.includes(args[i])) return args[i + 1];
|
|
2510
|
-
}
|
|
2511
|
-
return null;
|
|
2512
|
-
}
|
|
2513
|
-
|
|
2514
|
-
function effectivePort() {
|
|
2515
|
-
const arg = argValue("-port", "--port");
|
|
2516
|
-
if (arg) return Number(arg);
|
|
2517
|
-
return resolvePort();
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
|
-
function effectiveHost() {
|
|
2521
|
-
const envHost = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST;
|
|
2522
|
-
if (typeof envHost === "string" && envHost.trim()) return envHost.trim();
|
|
2523
|
-
try {
|
|
2524
|
-
const joined = loadGroupsJoined();
|
|
2525
|
-
if (joined.some((g) => g.kind === "broadband")) return "127.0.0.1";
|
|
2526
|
-
} catch {}
|
|
2527
|
-
return "0.0.0.0";
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
function refreshIntervalMs() {
|
|
2531
|
-
const n = Number(process.env.MODELS_REFRESH_MS);
|
|
2532
|
-
return Number.isInteger(n) && n > 0 ? n : 2 * 60 * 60 * 1000;
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
function modelCooldownMs() {
|
|
2536
|
-
const n = Number(process.env.MSLXDFF_MODEL_COOLDOWN_MS);
|
|
2537
|
-
return Number.isInteger(n) && n > 0 ? n : 60_000;
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
function slowCooldownMs() {
|
|
2541
|
-
const n = Number(process.env.MSLXDFF_SLOW_COOLDOWN_MS);
|
|
2542
|
-
return Number.isInteger(n) && n > 0 ? n : 5 * 60_000;
|
|
2543
|
-
}
|
|
2544
|
-
|
|
2545
|
-
function peerCooldownMs() {
|
|
2546
|
-
const n = Number(process.env.MSLXDFF_PEER_COOLDOWN_MS);
|
|
2547
|
-
return Number.isInteger(n) && n > 0 ? n : 30_000;
|
|
2548
|
-
}
|
|
2549
|
-
|
|
2550
|
-
function peerHeatMs() {
|
|
2551
|
-
const n = Number(process.env.MSLXDFF_PEER_HEAT_MS);
|
|
2552
|
-
return Number.isInteger(n) && n > 0 ? n : 5 * 60_000;
|
|
2553
|
-
}
|
|
2554
|
-
|
|
2555
|
-
function maxHopsValue() {
|
|
2556
|
-
const n = Number(process.env.MSLXDFF_MAX_HOPS);
|
|
2557
|
-
return Number.isInteger(n) && n > 0 ? n : 3;
|
|
2558
|
-
}
|
|
2559
|
-
|
|
2560
|
-
function groupSyncIntervalMs() {
|
|
2561
|
-
const n = Number(process.env.MSLXDFF_GROUP_SYNC_MS);
|
|
2562
|
-
return Number.isInteger(n) && n > 0 ? n : 60_000;
|
|
2563
|
-
}
|
|
2564
|
-
|
|
2565
|
-
function autoUpdateIntervalMs() {
|
|
2566
|
-
const raw = process.env.MSLXDFF_AUTO_UPDATE_MS ?? process.env.MSLXDFF_AUTO_UPDATE;
|
|
2567
|
-
// default: hourly when env not set; explicit 0/off/false disables
|
|
2568
|
-
if (raw === undefined || raw === null || raw === "") return 60 * 60 * 1000;
|
|
2569
|
-
const s = String(raw).trim().toLowerCase();
|
|
2570
|
-
if (s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled") return 0;
|
|
2571
|
-
if (s === "1" || s === "true" || s === "on" || s === "yes" || s === "enable" || s === "enabled") return 60 * 60 * 1000;
|
|
2572
|
-
const n = Number(raw);
|
|
2573
|
-
return Number.isInteger(n) && n > 0 ? n : 60 * 60 * 1000;
|
|
2574
|
-
}
|
|
2575
|
-
|
|
2576
|
-
function banWindowMs() {
|
|
2577
|
-
const n = Number(process.env.MSLXDFF_BAN_WINDOW_MS);
|
|
2578
|
-
return Number.isInteger(n) && n > 0 ? n : 48 * 60 * 60 * 1000;
|
|
2579
|
-
}
|
|
2580
|
-
|
|
2581
|
-
function banThreshold() {
|
|
2582
|
-
const n = Number(process.env.MSLXDFF_BAN_THRESHOLD);
|
|
2583
|
-
return Number.isInteger(n) && n > 0 ? n : 5;
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
|
-
async function waitForHealth(port, timeoutMs) {
|
|
2587
|
-
const start = Date.now();
|
|
2588
|
-
while (Date.now() - start < timeoutMs) {
|
|
2589
|
-
try {
|
|
2590
|
-
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
2591
|
-
if (res.ok) return;
|
|
2592
|
-
} catch {
|
|
2593
|
-
// not up yet
|
|
2594
|
-
}
|
|
2595
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
|
|
2599
|
-
function printHelp() {
|
|
2600
|
-
console.log(`mslxdff v${VERSION} — OpenCode Free OpenAI-compatible proxy
|
|
2601
|
-
|
|
2602
|
-
Usage:
|
|
2603
|
-
mslxdff start as a background daemon and exit (status + help if one is already running)
|
|
2604
|
-
mslxdff -d start as a background daemon
|
|
2605
|
-
mslxdff -status show current status (daemon/health/port/config, upstream providers, models + metrics/体检表, autostart/plugins, groups/peers, recent calls with ttfb/tps, last error)
|
|
2606
|
-
mslxdff -log [N] show last N events (default 10, e.g. -log 100)
|
|
2607
|
-
mslxdff -models interactive picker: ↑/↓ select a model, Enter sets it as the default (non-TTY: plain list)
|
|
2608
|
-
mslxdff -model list list the free models this proxy serves (cached)
|
|
2609
|
-
mslxdff -model set <id> set the default (preferred) model without the interactive picker
|
|
2610
|
-
mslxdff -model status show per-model health status (normal/limit/error)
|
|
2611
|
-
mslxdff -model refresh force-refresh the model cache from the upstream
|
|
2612
|
-
mslxdff -debug live-follow the daemon event stream (requests, errors, peer forwards)
|
|
2613
|
-
mslxdff -stop stop the running daemon
|
|
2614
|
-
mslxdff -uninstall stop the daemon and delete all state/log files
|
|
2615
|
-
mslxdff -port N persist the listen port (restarts the daemon on it if running)
|
|
2616
|
-
mslxdff -update update mslxdff to the latest published version
|
|
2617
|
-
mslxdff -showtoken print the current auth token
|
|
2618
|
-
mslxdff -refresh-token rotate the auth token (prints the new one)
|
|
2619
|
-
mslxdff -setto workbuddy [modelId] set default model and sync to WorkBuddy models.json (insert or update 127.0.0.1/v1 entry)
|
|
2620
|
-
mslxdff -provider add <id> <baseUrl> <key> [allowedModel...] add a generic OpenAI-compatible provider (myapi/gpt-4, baseUrl https://api.example.com/v1; extra models = allowlist)
|
|
2621
|
-
mslxdff -provider add workbuddy https://copilot.tencent.com <key> [allow...] add WorkBuddy专用供应商(workbuddy/hy3 前缀路由,auths 与 keys 一一对应)
|
|
2622
|
-
mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url] configure provider API keys/URL (multiple keys = rotating, set-url for generic)
|
|
2623
|
-
mslxdff -provider <id> allowlist [list|set|add|remove|clear] manage allowed models (empty=allow all, non-empty=only listed)
|
|
2624
|
-
mslxdff -workbuddy checkin daily 100 credits(双域幂等,已签 code 10001 视为成功)
|
|
2625
|
-
mslxdff -providers list list all configured upstream providers (opencode, openrouter, generic, workbuddy)
|
|
2626
|
-
mslxdff -creategroup <name> create a group on this node (the group name is the password)
|
|
2627
|
-
mslxdff -addtogroup <leader-host> <name> [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)
|
|
2628
|
-
mslxdff -group sync pull the freshest member list for all joined groups
|
|
2629
|
-
mslxdff -group leave <name> leave a group (removes its members from this node)
|
|
2630
|
-
mslxdff -group list list groups on this node (numbered members)
|
|
2631
|
-
mslxdff -group remove <seq> leader only: kick a member by its list sequence number
|
|
2632
|
-
mslxdff -leavegroup leave every joined group as a member (leaders: use -delgroup)
|
|
2633
|
-
mslxdff -delgroup <name> disband a group this node leads (deletes it and its members)
|
|
2634
|
-
mslxdff -free V2EX 白嫖雷达(仅 V2EX 单源:latest+hot 按 白嫖|限免|免费额度|注册送|羊毛 过滤)
|
|
2635
|
-
mslxdff -free-watch V2EX 白嫖雷达 watch 模式(每 5 分钟轮询)
|
|
2636
|
-
mslxdff -enable-autostart 开机自启:注册 Windows 任务计划 / Linux systemd user(重启后自动拉起)
|
|
2637
|
-
mslxdff -disable-autostart 关闭开机自启
|
|
2638
|
-
mslxdff -autostart status 查看自启状态
|
|
2639
|
-
mslxdff -chat ["prompt"] chat REPL(mimo-v2.5-free 优先/big-pickle 兜底,自然语言转命令,模糊匹配由模型完成,历史持久化,超长自动压缩,仅拦 -uninstall,daemon 重启不影响)
|
|
2640
|
-
mslxdff -resetban [ip] clear join-failure bans (all, or one ip)
|
|
2641
|
-
mslxdff -help show this help
|
|
2642
|
-
|
|
2643
|
-
Environment:
|
|
2644
|
-
MSLXDFF_PORT listen port (default 8989; use mslxdff -port N to persist)
|
|
2645
|
-
MSLXDFF_STATE_FILE token/port state file
|
|
2646
|
-
MSLXDFF_DAEMON_DIR daemon pid/log/models dir
|
|
2647
|
-
UPSTREAM_BASE_URL upstream base (default https://opencode.ai)
|
|
2648
|
-
UPSTREAM_AUTH_TOKEN upstream bearer value (default "public")
|
|
2649
|
-
MSLXDFF_OPENROUTER_KEY openrouter provider API keys(s) (single env value; multiple use "mslxdff -provider openrouter k1 k2 ..." to persist)
|
|
2650
|
-
UPSTREAM_CONNECT_TIMEOUT_MS upstream connect timeout (default 30000)
|
|
2651
|
-
MODELS_REFRESH_MS model-list background refresh interval (default 7200000)
|
|
2652
|
-
MSLXDFF_MODEL_COOLDOWN_MS fallback cooldown after a model error (default 60000)
|
|
2653
|
-
MSLXDFF_PEER_COOLDOWN_MS peer failover cooldown (default 30000)
|
|
2654
|
-
MSLXDFF_PEER_HEAT_MS how long a peer success stays hot for fast reuse (default 300000)
|
|
2655
|
-
MSLXDFF_GROUP_SYNC_MS group membership sync interval (default 60000)
|
|
2656
|
-
MSLXDFF_MAX_HOPS max peer-forwarding depth (default 3)
|
|
2657
|
-
MSLXDFF_BAN_THRESHOLD failed joins before an ip is banned (default 5)
|
|
2658
|
-
MSLXDFF_BAN_WINDOW_MS ban duration after too many failures (default 48h)
|
|
2659
|
-
MSLXDFF_HEDGE_DELAY_MS hedge peer race when local stream first chunk slow (default 1000, 0/off to disable)
|
|
2660
|
-
MSLXDFF_AUTO_UPDATE auto-update: hourly by default, 0/off/false to disable, 1/true or ms
|
|
2661
|
-
MSLXDFF_AUTO_UPDATE_MS same as above, explicit ms (overrides AUTO_UPDATE)
|
|
2662
|
-
`);
|
|
2663
|
-
}
|
|
2664
|
-
|
|
2665
|
-
async function printStatus() {
|
|
2666
|
-
const daemon = readPid();
|
|
2667
|
-
const alive = daemon ? isPidAlive(daemon) : false;
|
|
2668
|
-
const port = getPort() || resolvePort();
|
|
2669
|
-
const persisted = getPort();
|
|
2670
|
-
const portSrc = persisted != null ? "persisted" : (process.env.MSLXDFF_PORT ? "env MSLXDFF_PORT" : "default 8989");
|
|
2671
|
-
const stateFile = defaultStateFile();
|
|
2672
|
-
const dir = logDir();
|
|
2673
|
-
// daemon uptime from pid file mtime
|
|
2674
|
-
let upStr = "";
|
|
2675
|
-
if (daemon && alive) {
|
|
2676
|
-
try {
|
|
2677
|
-
const st = statSync(pidFile());
|
|
2678
|
-
const ms = Date.now() - st.mtimeMs;
|
|
2679
|
-
upStr = `, up ${fmtUptime(ms)}`;
|
|
2680
|
-
} catch {}
|
|
2681
|
-
}
|
|
2682
|
-
const verNote = daemon && alive ? (() => { const v = readPidVersion(); return v === VERSION ? "version ok" : (v ? `version ${v} → ${VERSION} (restart pending)` : ""); })() : "";
|
|
2683
|
-
console.log(`mslxdff v${VERSION}`);
|
|
2684
|
-
console.log(`daemon: ${daemon ? (alive ? `running (pid ${daemon}${upStr})` : `stale pid ${daemon} (not alive)`) : "not running"}${verNote ? ` [${verNote}]` : ""}`);
|
|
2685
|
-
// local health probe — 1.2s timeout, 显示端到端可用性
|
|
2686
|
-
let healthLine = "";
|
|
2687
|
-
try {
|
|
2688
|
-
const t0 = Date.now();
|
|
2689
|
-
const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1200) });
|
|
2690
|
-
const ms = Date.now() - t0;
|
|
2691
|
-
healthLine = r.ok ? `health ok ${ms}ms` : `health HTTP ${r.status} ${ms}ms`;
|
|
2692
|
-
} catch (e) {
|
|
2693
|
-
healthLine = daemon && alive ? `health fail (${String(e?.message || e).slice(0, 60)})` : "health — (daemon not running)";
|
|
2694
|
-
}
|
|
2695
|
-
console.log(`endpoint: http://localhost:${port}/v1 · ${healthLine}`);
|
|
2696
|
-
console.log(`config: port ${port} (${portSrc}) · state ${stateFile} · log ${dir}`);
|
|
2697
|
-
try {
|
|
2698
|
-
const host = process.env.MSLXDFF_HOST || process.env.MSLXDFF_BIND_HOST || "0.0.0.0";
|
|
2699
|
-
if (host !== "0.0.0.0") console.log(`bind: ${host}`);
|
|
2700
|
-
} catch {}
|
|
2701
|
-
|
|
2702
|
-
// ---- upstream providers(用户最想要的一块)----
|
|
2703
|
-
try {
|
|
2704
|
-
const configs = loadProviderConfigs();
|
|
2705
|
-
const upstreamBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
2706
|
-
const providerRows = [];
|
|
2707
|
-
// opencode 恒 enabled
|
|
2708
|
-
const opAllowed = loadProviderAllowedModels("opencode");
|
|
2709
|
-
providerRows.push({ id: "opencode", enabled: true, baseUrl: upstreamBase, keys: [], allowed: opAllowed, share: false, note: "built-in, no key, cannot share" });
|
|
2710
|
-
// collect generic ids (configs + legacy providerKeys + env)
|
|
2711
|
-
const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode"));
|
|
2712
|
-
try {
|
|
2713
|
-
const raw = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
2714
|
-
const pk = raw.providerKeys || {};
|
|
2715
|
-
for (const id of Object.keys(pk)) if (id !== "opencode") genericIds.add(id);
|
|
2716
|
-
const cfgRaw = raw.providerConfigs || {};
|
|
2717
|
-
for (const id of Object.keys(cfgRaw)) if (id !== "opencode") genericIds.add(id);
|
|
2718
|
-
} catch {}
|
|
2719
|
-
for (const k of Object.keys(process.env)) {
|
|
2720
|
-
const m = k.match(/^MSLXDFF_(.+)_KEY$/);
|
|
2721
|
-
if (m) {
|
|
2722
|
-
const id = m[1].toLowerCase().replace(/__/g, "-");
|
|
2723
|
-
if (id !== "opencode") genericIds.add(id);
|
|
2724
|
-
}
|
|
2725
|
-
}
|
|
2726
|
-
for (const gid of [...genericIds].sort()) {
|
|
2727
|
-
const cfg = configs[gid];
|
|
2728
|
-
const keys = loadProviderKeys(gid);
|
|
2729
|
-
const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : "");
|
|
2730
|
-
const share = loadProviderShareKeys(gid);
|
|
2731
|
-
const allowed = loadProviderAllowedModels(gid);
|
|
2732
|
-
let enabled = Boolean(baseUrl && keys.length) || (gid === "openrouter" && keys.length > 0);
|
|
2733
|
-
const auths = gid === "workbuddy" ? (loadProviderAuths(gid) || []) : [];
|
|
2734
|
-
let note = "";
|
|
2735
|
-
// 检测测试桩:workbuddy 的 k-new / 127.0.0.1 / 短 key 均视为未真实配置
|
|
2736
|
-
const isWorkbuddyStub = gid === "workbuddy" && (keys.includes("k-new") || baseUrl.includes("127.0.0.1") || (keys.length === 1 && keys[0].length < 20));
|
|
2737
|
-
if (isWorkbuddyStub) {
|
|
2738
|
-
enabled = false;
|
|
2739
|
-
note = "测试桩 (key=k-new, baseUrl=127.0.0.1) — 请重跑 node workbuddy-token-auto.js 写入真实 JWT";
|
|
2740
|
-
} else if (!enabled) {
|
|
2741
|
-
if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
|
|
2742
|
-
else if (!baseUrl) note = "missing baseUrl";
|
|
2743
|
-
else if (!keys.length) note = "no keys";
|
|
2744
|
-
} else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
|
|
2745
|
-
note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
|
|
2746
|
-
}
|
|
2747
|
-
providerRows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, share, note, authCount: auths.length });
|
|
2748
|
-
}
|
|
2749
|
-
const enabledCount = providerRows.filter((r) => r.enabled).length;
|
|
2750
|
-
console.log(`\nupstream providers (${providerRows.length}, ${enabledCount} enabled) — mslxdff -providers list 查看详情`);
|
|
2751
|
-
for (const p of providerRows) {
|
|
2752
|
-
const dot = p.enabled ? "●" : "○";
|
|
2753
|
-
const state = p.enabled ? "enabled " : "disabled";
|
|
2754
|
-
let keysInfo;
|
|
2755
|
-
if (p.id === "opencode") keysInfo = "无需 key (内置)";
|
|
2756
|
-
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";
|
|
2757
|
-
const authInfo = p.authCount ? ` ${p.authCount} acc` : "";
|
|
2758
|
-
const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 2).join(",")}${p.allowed.length > 2 ? "…" : ""})` : "allow=all";
|
|
2759
|
-
const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
|
|
2760
|
-
const note = p.note ? ` (${p.note})` : "";
|
|
2761
|
-
console.log(` ${dot} ${p.id.padEnd(12)} ${state} ${keysInfo}${authInfo} ${allowInfo.padEnd(18)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
|
|
2762
|
-
}
|
|
2763
|
-
if (enabledCount === 1 && providerRows.length === 1) {
|
|
2764
|
-
console.log(` (仅 opencode 内置免费通道;按需加:mslxdff -provider add bai https://api.b.ai/v1 <key> 或 node workbuddy-token-auto.js)`);
|
|
2765
|
-
}
|
|
2766
|
-
} catch (e) {
|
|
2767
|
-
console.log(`\nupstream providers: (unavailable — ${String(e?.message || e).slice(0, 80)})`);
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2770
|
-
// ---- models:preferred + picks + 免费缓存 + 体检表 ----
|
|
2771
|
-
try {
|
|
2772
|
-
const statuses = loadModelErrors();
|
|
2773
|
-
const stats = loadModelStats();
|
|
2774
|
-
const picks = loadModelPicks();
|
|
2775
|
-
const preferred = getPreferredModel();
|
|
2776
|
-
const modelsFile = join(dir, "models.json");
|
|
2777
|
-
let freeIds = [];
|
|
2778
|
-
let cachedAt = null;
|
|
2779
|
-
let cacheErr = null;
|
|
2780
|
-
if (existsSync(modelsFile)) {
|
|
2781
|
-
try {
|
|
2782
|
-
const cached = JSON.parse(readFileSync(modelsFile, "utf8"));
|
|
2783
|
-
freeIds = (cached.data || []).map((m) => m.id).filter(Boolean);
|
|
2784
|
-
cachedAt = cached.cachedAt || null;
|
|
2785
|
-
} catch (err) { cacheErr = String(err?.message || err).slice(0, 60); }
|
|
2786
|
-
}
|
|
2787
|
-
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`; })() : "";
|
|
2788
|
-
const cacheLine = freeIds.length ? `${freeIds.length} free${cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)} · ${ageStr})` : ""}` : (cacheErr ? `cache unreadable (${cacheErr})` : "not cached yet (daemon 拉取后出现)");
|
|
2789
|
-
const prefStat = preferred ? (stats[preferred] || stats[`opencode/${preferred}`] || null) : null;
|
|
2790
|
-
const prefErr = preferred ? statuses[preferred] : null;
|
|
2791
|
-
const prefStatus = prefErr ? (typeof prefErr === "number" ? "error" : (prefErr?.status || "error")) : (prefStat ? "normal" : "");
|
|
2792
|
-
const prefLine = preferred ? `${preferred}${prefStatus ? ` [${prefStatus}]` : ""}` : "(none)";
|
|
2793
|
-
const picksLine = picks.length ? `${picks.length} (*${picks.slice(0, 4).join(", *")}${picks.length > 4 ? ` …+${picks.length - 4}` : ""})` : "(空=全量 auto)";
|
|
2794
|
-
console.log(`\nmodels: ${cacheLine} — mslxdff -model status 查看详情`);
|
|
2795
|
-
const prefTtfbDisp = prefStat ? (() => { const v = prefStat.avgTtfbMs ?? prefStat.emaTtfbMs; return v != null && v >= 10 ? `首字 ${v}ms` : (v != null && v < 10 ? "首字 — (测试数据)" : ""); })() : "";
|
|
2796
|
-
const prefTotDisp = prefStat?.avgTotalMs && prefStat.avgTotalMs >= 10 ? `总 ${prefStat.avgTotalMs}ms` : "";
|
|
2797
|
-
const prefTpsDisp = prefStat?.avgTps && prefStat.avgTps >= 1 ? `${prefStat.avgTps} tok/s` : "";
|
|
2798
|
-
const prefExtra = prefStat ? ` ${[prefTtfbDisp, prefTotDisp, prefTpsDisp].filter(Boolean).join(" ")} ${prefStat.count || 0}次` : "";
|
|
2799
|
-
console.log(` preferred: ${prefLine}${prefExtra}`);
|
|
2800
|
-
console.log(` picks: ${picksLine} ${picks.length ? "auto 仅在勾选内" : "auto 用全量免费池"}`);
|
|
2801
|
-
if (freeIds.length) {
|
|
2802
|
-
const ids = freeIds;
|
|
2803
|
-
console.log(` free list:`);
|
|
2804
|
-
for (const id of ids) {
|
|
2805
|
-
const st = fmtStatus(id, statuses);
|
|
2806
|
-
const full = id.includes("/") ? id : `opencode/${id}`;
|
|
2807
|
-
const ms = stats[full] || stats[id];
|
|
2808
|
-
let extra = "";
|
|
2809
|
-
if (ms) {
|
|
2810
|
-
const v = ms.avgTtfbMs ?? ms.emaTtfbMs;
|
|
2811
|
-
const vStr = v != null ? (v >= 10 ? `avg首字 ${v}ms` : (v < 10 ? "avg首字 —" : "")) : "";
|
|
2812
|
-
const tpsStr = ms.avgTps && ms.avgTps >= 1 ? ` ${ms.avgTps} tok/s` : "";
|
|
2813
|
-
const cntStr = ms.count ? ` ${ms.count}次` : "";
|
|
2814
|
-
extra = ` ${vStr}${tpsStr}${cntStr}`;
|
|
2815
|
-
}
|
|
2816
|
-
console.log(` ${id}${st ? ` [${st}]` : ""}${extra}`);
|
|
2817
|
-
}
|
|
2818
|
-
}
|
|
2819
|
-
// 体检表 Top(按样本数)—— 来自 modelStats,更能反映“跑得快不快/啰不啰嗦”
|
|
2820
|
-
const allStatIds = Object.keys(stats);
|
|
2821
|
-
if (allStatIds.length) {
|
|
2822
|
-
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);
|
|
2823
|
-
if (sorted.length) {
|
|
2824
|
-
console.log(` 模型体检 Top${sorted.length}(样本>0, 按次数):`);
|
|
2825
|
-
console.log(` ${"模型".padEnd(28)} ${"首字".padEnd(7)} ${"总耗时".padEnd(7)} ${"速度".padEnd(10)} ${"啰嗦".padEnd(7)} ${"样本".padEnd(5)} 状态`);
|
|
2826
|
-
for (const { full, s } of sorted) {
|
|
2827
|
-
const ttfb = s.avgTtfbMs ?? s.emaTtfbMs;
|
|
2828
|
-
const total = s.avgTotalMs ?? s.emaTotalMs;
|
|
2829
|
-
const tps = s.avgTps ?? s.emaTps;
|
|
2830
|
-
const verbose = s.avgCompTok != null ? `${s.avgCompTok}tok` : "—";
|
|
2831
|
-
const st = statuses[full] || statuses[full.split("/").slice(1).join("/")] || null;
|
|
2832
|
-
const statusStr = st ? (typeof st === "number" ? "error" : (st.status || "normal")) : "normal";
|
|
2833
|
-
const p95 = s.p95Ttfb && s.p95Ttfb >= 10 ? ` p95:${s.p95Ttfb}ms` : "";
|
|
2834
|
-
const ttfbStr = ttfb != null ? (ttfb >= 10 ? ttfb + "ms" : "—") : "—";
|
|
2835
|
-
const totalStr = total != null ? (total >= 10 ? total + "ms" : "—") : "—";
|
|
2836
|
-
const tpsStr = tps != null && tps >= 1 ? tps + " tok/s" : "—";
|
|
2837
|
-
console.log(` ${full.padEnd(28)} ${ttfbStr.padEnd(7)} ${totalStr.padEnd(7)} ${tpsStr.padEnd(10)} ${verbose.padEnd(7)} ${String(s.count).padEnd(5)} ${statusStr}${p95}`);
|
|
2838
|
-
}
|
|
2839
|
-
}
|
|
2840
|
-
} else if (!freeIds.length) {
|
|
2841
|
-
console.log(` (暂无模型样本 — 发一次 mslxdff -chat 后出现,100次后均值更稳)`);
|
|
2842
|
-
}
|
|
2843
|
-
} catch (e) {
|
|
2844
|
-
console.log(`\nmodels: (unavailable — ${String(e?.message || e).slice(0, 80)})`);
|
|
2845
|
-
}
|
|
2846
|
-
|
|
2847
|
-
// ---- autostart + plugins(运维一眼可见)----
|
|
2848
|
-
try {
|
|
2849
|
-
const { getAutostartStatus } = await import("../src/autostart.js");
|
|
2850
|
-
const a = await getAutostartStatus();
|
|
2851
|
-
const detail = a.detail || (a.enabled ? "已启用" : "未启用");
|
|
2852
|
-
// detail 已含“已启用/未启用”,避免重复
|
|
2853
|
-
const label = detail.startsWith("已启用") || detail.startsWith("未启用") ? detail : `${a.enabled ? "已启用" : "未启用"} · ${detail}`;
|
|
2854
|
-
console.log(`\nautostart: ${label} — mslxdff -autostart status`);
|
|
2855
|
-
if (a.taskToRun) console.log(` task: ${a.taskToRun.slice(0, 120)}`);
|
|
2856
|
-
if (a.unit) console.log(` unit: ${a.unit}`);
|
|
2857
|
-
} catch {}
|
|
2858
|
-
try {
|
|
2859
|
-
const { resolvePluginDirs, loadPlugins } = await import("../src/plugins.js");
|
|
2860
|
-
const pkgRoot = dirname(fileURLToPath(import.meta.url)) + "/..";
|
|
2861
|
-
const dirs = resolvePluginDirs({ pkgRoot });
|
|
2862
|
-
const { plugins, errors } = await loadPlugins({ dirs });
|
|
2863
|
-
if (plugins.length || errors.length) {
|
|
2864
|
-
console.log(`plugins: ${plugins.length} loaded${errors.length ? `, ${errors.length} error(s)` : ""} — mslxdff -plugins`);
|
|
2865
|
-
for (const p of plugins) console.log(` ${p.name}${p.version ? `@${p.version}` : ""} [${Object.keys(p.hooks || {}).join(", ") || "no hooks"}]`);
|
|
2866
|
-
for (const e of errors) console.log(` ! ${e.file} — ${e.error}`);
|
|
2867
|
-
} else {
|
|
2868
|
-
console.log(`plugins: (none) — 放 *.mjs 到 ${dirs[dirs.length - 1] || "~/.config/mslxdff/plugins"} 启用`);
|
|
2869
|
-
}
|
|
2870
|
-
} catch {}
|
|
2871
|
-
|
|
2872
|
-
const groups = createGroupsService({});
|
|
2873
|
-
const joined = loadGroupsJoined();
|
|
2874
|
-
if (joined.length) {
|
|
2875
|
-
console.log(`\njoined groups (${joined.length}):`);
|
|
2876
|
-
const { token } = await loadToken();
|
|
2877
|
-
for (const g of joined) {
|
|
2878
|
-
const isLeader = !g.leaderUrl;
|
|
2879
|
-
console.log(` ${g.name} ${isLeader ? "(this node is the leader)" : `leader ${g.leaderUrl}`}`);
|
|
2880
|
-
let members = null;
|
|
2881
|
-
if (isLeader) {
|
|
2882
|
-
members = groups.list()[g.name]?.members ?? {};
|
|
2883
|
-
} else {
|
|
2884
|
-
// registered members can pull the member map back from the leader
|
|
2885
|
-
const fetchImpl = (url, opts) => fetch(url, { ...opts, signal: AbortSignal.timeout(1500) });
|
|
2886
|
-
try {
|
|
2887
|
-
members = await refreshGroupMembers(g.name, {
|
|
2888
|
-
leaderUrl: g.leaderUrl,
|
|
2889
|
-
memberName: g.memberName,
|
|
2890
|
-
url: g.myUrl,
|
|
2891
|
-
token,
|
|
2892
|
-
fetchImpl,
|
|
2893
|
-
});
|
|
2894
|
-
} catch {
|
|
2895
|
-
members = null;
|
|
2896
|
-
}
|
|
2897
|
-
}
|
|
2898
|
-
if (members === null) {
|
|
2899
|
-
console.log(" members: (unavailable — leader unreachable)");
|
|
2900
|
-
} else {
|
|
2901
|
-
const ids = Object.keys(members);
|
|
2902
|
-
if (!ids.length) {
|
|
2903
|
-
console.log(" members: (none yet)");
|
|
2904
|
-
} else {
|
|
2905
|
-
console.log(` members (${ids.length}):`);
|
|
2906
|
-
for (const id of ids) {
|
|
2907
|
-
const m = members[id];
|
|
2908
|
-
const tags = [];
|
|
2909
|
-
if (id === "leader" && isLeader) tags.push("leader");
|
|
2910
|
-
if (m?.url && m.url === g.myUrl) tags.push("this node");
|
|
2911
|
-
if (m?.kind === "broadband") tags.push("broadband");
|
|
2912
|
-
if (m?.kind === "broadband" && m?.publicIp) tags.push(`ip=${m.publicIp}`);
|
|
2913
|
-
if (m?.kind === "broadband" && m?.lastSeen) {
|
|
2914
|
-
const ago = Math.round((Date.now() - m.lastSeen) / 1000);
|
|
2915
|
-
tags.push(ago < 90 ? `via leader ${ago}s ago` : "stale");
|
|
2916
|
-
}
|
|
2917
|
-
const tag = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
2918
|
-
console.log(` ${m?.url || id}${tag}`);
|
|
2919
|
-
}
|
|
2920
|
-
}
|
|
2921
|
-
}
|
|
2922
|
-
}
|
|
2923
|
-
} else {
|
|
2924
|
-
console.log("\njoined groups: (none — use -creategroup or -addtogroup)");
|
|
2925
|
-
}
|
|
2926
|
-
|
|
2927
|
-
const peers = createPeersService({});
|
|
2928
|
-
const allPeers = peers.all();
|
|
2929
|
-
if (allPeers.length) {
|
|
2930
|
-
console.log(`\nfailover targets (${allPeers.length}):`);
|
|
2931
|
-
for (const p of allPeers) {
|
|
2932
|
-
const tags = [];
|
|
2933
|
-
if (peers.isCooling(p.url)) tags.push("cooling");
|
|
2934
|
-
if (peers.isHot(p.url)) tags.push("hot");
|
|
2935
|
-
const s = peers.stat(p.url);
|
|
2936
|
-
if (s?.latencyMs != null) tags.push(`${s.latencyMs}ms`);
|
|
2937
|
-
if (s?.fails) tags.push(`${s.fails} fail(s)`);
|
|
2938
|
-
const tag = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
2939
|
-
console.log(` ${p.name || p.url} ${p.url}${tag}`);
|
|
2940
|
-
}
|
|
2941
|
-
} else {
|
|
2942
|
-
console.log(`\nfailover: (none — 加组后自动出现,mslxdff -group list)`);
|
|
2943
|
-
}
|
|
2944
|
-
|
|
2945
|
-
const groupNames = Object.keys(groups.list());
|
|
2946
|
-
if (groupNames.length) {
|
|
2947
|
-
console.log(`\ngroups on this node (${groupNames.length}):`);
|
|
2948
|
-
for (const n of groupNames) console.log(` ${n}`);
|
|
2949
|
-
}
|
|
2950
|
-
|
|
2951
|
-
// recent calls — 补首字/tok/s 信息,原来只看 duration
|
|
2952
|
-
console.log("\nrecent calls: (gateway 持久化,最近5条,含首字/tok/s)");
|
|
2953
|
-
const calls = recentCalls(5);
|
|
2954
|
-
if (calls.length) {
|
|
2955
|
-
let sumDur = 0, sumTps = 0, tpsN = 0;
|
|
2956
|
-
for (const c of calls) {
|
|
2957
|
-
if (Number.isFinite(c.durationMs)) sumDur += c.durationMs;
|
|
2958
|
-
else if (Number.isFinite(c.totalMs)) sumDur += c.totalMs;
|
|
2959
|
-
if (Number.isFinite(c.tps)) { sumTps += c.tps; tpsN++; } else if (Number.isFinite(c.charsPerSec)) { sumTps += c.charsPerSec; tpsN++; }
|
|
2960
|
-
}
|
|
2961
|
-
const avgDur = calls.length ? Math.round(sumDur / calls.length) : null;
|
|
2962
|
-
const avgTps = tpsN ? Math.round(sumTps / tpsN) : null;
|
|
2963
|
-
console.log(` avg ${avgDur ? avgDur + "ms" : "—"}${avgTps ? ` · ${avgTps} tok/s` : ""} — mslxdff -log 20 查看详情`);
|
|
2964
|
-
for (const c of calls) {
|
|
2965
|
-
const dur = c.totalMs ?? c.durationMs;
|
|
2966
|
-
const ttfb = c.ttfbMs != null ? ` 首字${c.ttfbMs}ms` : "";
|
|
2967
|
-
const tps = c.tps != null ? ` ${c.tps}tok/s` : (c.charsPerSec ? ` ${c.charsPerSec}ch/s` : "");
|
|
2968
|
-
const tok = c.usage?.completion_tokens != null ? ` tok${c.usage.completion_tokens}` : (c.chars ? ` ch${c.chars}` : "");
|
|
2969
|
-
const tm = fmtTs(c.ts);
|
|
2970
|
-
console.log(` ${tm} ${(c.model || "-").padEnd(28)} ${String(c.status || "-").padEnd(4)} ${dur ? dur + "ms" : ""}${ttfb}${tps}${tok}${c.auto ? " auto" : ""}`);
|
|
2971
|
-
}
|
|
2972
|
-
} else {
|
|
2973
|
-
console.log(" (none yet — 发一次请求后出现,mslxdff -chat hi)");
|
|
2974
|
-
}
|
|
2975
|
-
|
|
2976
|
-
console.log("\nlast error:");
|
|
2977
|
-
const err = lastError();
|
|
2978
|
-
if (err) {
|
|
2979
|
-
console.log(` ${fmtTs(err.ts)} ${err.model || "-"} ${err.status} ${err.message || ""}`);
|
|
2980
|
-
if (err.stack) console.log(` ${String(err.stack).slice(0, 200)}`);
|
|
2981
|
-
} else {
|
|
2982
|
-
console.log(" (none — 暂无错误,挺好)");
|
|
2983
|
-
}
|
|
2984
|
-
|
|
2985
|
-
if (daemon && alive) console.log(`\nauth token: use \`mslxdff -showtoken\` · health: http://127.0.0.1:${port}/health`);
|
|
2986
|
-
else console.log(`\nnot running — start with: mslxdff -d · 查看日志 mslxdff -log 20`);
|
|
2987
|
-
console.log(`hints: mslxdff -providers list · mslxdff -model status · mslxdff -group list · mslxdff -autostart status`);
|
|
2988
|
-
}
|
|
2989
|
-
|
|
2990
|
-
function fmtStatus(id, statuses) {
|
|
2991
|
-
const e = statuses[id];
|
|
2992
|
-
if (typeof e === "number") return `error ${fmtTs(e)}`;
|
|
2993
|
-
if (!e?.status || e.status === "normal") return "";
|
|
2994
|
-
const when = e.at ? ` ${fmtTs(e.at)}` : "";
|
|
2995
|
-
const code = e.code ? ` HTTP ${e.code}` : "";
|
|
2996
|
-
return `${e.status}${when}${code}`;
|
|
2997
|
-
}
|
|
2998
|
-
|
|
2999
|
-
function fmtDur(ms) {
|
|
3000
|
-
if (!Number.isFinite(ms)) return "?";
|
|
3001
|
-
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
function fmtUptime(ms) {
|
|
3005
|
-
if (!Number.isFinite(ms) || ms < 0) return "?";
|
|
3006
|
-
const s = Math.floor(ms / 1000);
|
|
3007
|
-
if (s < 60) return `${s}s`;
|
|
3008
|
-
const m = Math.floor(s / 60);
|
|
3009
|
-
const h = Math.floor(m / 60);
|
|
3010
|
-
const d = Math.floor(h / 24);
|
|
3011
|
-
if (d) return `${d}d${h % 24}h`;
|
|
3012
|
-
if (h) return `${h}h${m % 60}m`;
|
|
3013
|
-
return `${m}m${s % 60}s`;
|
|
3014
|
-
}
|
|
3015
|
-
|
|
3016
|
-
function fmtEvent(e) {
|
|
3017
|
-
const t = e?.ts ? fmtShanghaiHMS(e.ts) : "--:--:--";
|
|
3018
|
-
const head = `[${t}]`;
|
|
3019
|
-
const m = (x) => x || "-";
|
|
3020
|
-
const fallbackTag = e.fallback?.fallback ? ` fallback=${e.fallback.requested_model || e.requested}->${e.fallback.actual_model || e.actual}(${e.fallback.reason || e.reason})` : "";
|
|
3021
|
-
switch (e?.type) {
|
|
3022
|
-
case "request":
|
|
3023
|
-
return `${head} request client-> ${m(e.requested || e.model)}${e.auto ? " (auto)" : ""} raw=${m(e.rawModel)} lock=${m(e.lockModel)} hops=${e.hops} from ${e.ip || "?"}${e.stream ? " stream" : ""}${e.prompt ? ` content="${e.prompt}"` : ""} reqId=${e.reqId || ""}`;
|
|
3024
|
-
case "client-request":
|
|
3025
|
-
return `${head} client req 客户端请求 model=${m(e.requested)} raw=${m(e.rawModel)} lock=${m(e.lockModel)} ip=${e.ip || "?"}${e.stream ? " stream" : ""}`;
|
|
3026
|
-
case "ordered":
|
|
3027
|
-
return `${head} ordered 尝试顺序 [${(e.order||[]).join(" -> ")}] canFallback=${e.canFallback} useAuto=${e.useAuto}`;
|
|
3028
|
-
case "model-try":
|
|
3029
|
-
return `${head} model-try 尝试本地 model=${m(e.model)} idx=${e.idx} reqId=${e.reqId || ""}`;
|
|
3030
|
-
case "upstream-try":
|
|
3031
|
-
return `${head} upstream try 上游请求 model=${m(e.model)} attempt=${e.attempt || 1}`;
|
|
3032
|
-
case "upstream-done":
|
|
3033
|
-
return `${head} upstream ok ${m(e.model)} HTTP ${e.status}${e.timing ? ` total=${e.timing.totalMs}ms` : ""}`;
|
|
3034
|
-
case "upstream-error":
|
|
3035
|
-
return `${head} upstream err ${m(e.model)} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)}`;
|
|
3036
|
-
case "upstream-preheat":
|
|
3037
|
-
if (e.skipped) return `${head} preheat 跳过 (MSLXDFF_PREHEAT disabled)`;
|
|
3038
|
-
return `${head} preheat 预热 opencode models ${e.ok ? "ok" : "fail"} ${e.status ? `HTTP ${e.status}` : e.error || ""} ${e.ms ? `${e.ms}ms` : ""}`;
|
|
3039
|
-
case "peer-race-start":
|
|
3040
|
-
return `${head} peer race 开始并发给组员 model=${m(e.model)} peers=${e.peers}`;
|
|
3041
|
-
case "peer-health":
|
|
3042
|
-
return `${head} peer check ${e.peer} -> ${e.count ? e.healthy.join(", ") : "no healthy models"}${e.strict ? " (strict)" : ""}`;
|
|
3043
|
-
case "peer-request":
|
|
3044
|
-
return `${head} peer req 并发发出 peer=${e.peer} model=${m(e.model)} hops=${e.hops}`;
|
|
3045
|
-
case "peer-forward":
|
|
3046
|
-
return `${head} peer resp 收到响应 peer=${e.peer} model=${m(e.model)} ${e.ok ? "ok" : "fail"} ${e.latencyMs ? `${e.latencyMs}ms` : ""} hops=${e.hops}${e.retry ? " (retry)" : ""}`;
|
|
3047
|
-
case "peer-error":
|
|
3048
|
-
return `${head} peer err ${e.peer} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)} model=${m(e.model)}`;
|
|
3049
|
-
case "peer-race-win":
|
|
3050
|
-
return `${head} peer win 选中 peer=${e.winPeer} model=${m(e.winTarget)} latency=${e.latencyMs}ms`;
|
|
3051
|
-
case "peer-race-lose":
|
|
3052
|
-
return `${head} peer lose 组员全部失败 model=${m(e.model)}`;
|
|
3053
|
-
case "fallback":
|
|
3054
|
-
return `${head} fallback ${m(e.from)} -> ${m(e.to)} reason=${m(e.reason)}`;
|
|
3055
|
-
case "fallback-notice":
|
|
3056
|
-
return `${head} fallback! 客户端请求 ${m(e.requested)} 实际返回 ${m(e.actual)} 原因=${m(e.reason)} via=${m(e.via)} notice=${m(e.notice)}`;
|
|
3057
|
-
case "relay-try":
|
|
3058
|
-
return `${head} relay try 给宽带中继请求 target=${e.target} model=${m(e.model)} via=${e.via}`;
|
|
3059
|
-
case "relay-fail":
|
|
3060
|
-
return `${head} relay fail ${e.target} ${e.status ? `HTTP ${e.status}` : ""} ${m(e.message)}`;
|
|
3061
|
-
case "relay-ip-change":
|
|
3062
|
-
return `${head} relay ip ${e.member || e.id || "?"} ${e.oldIp || "?"} -> ${e.newIp || e.publicIp || "?"} via leader`;
|
|
3063
|
-
case "relay-forward":
|
|
3064
|
-
return `${head} relay fwd ${e.target || e.peer || "?"} via leader model=${m(e.model)} hops=${e.hops || 0}`;
|
|
3065
|
-
case "relay-heartbeat":
|
|
3066
|
-
return `${head} relay hb ${e.member || "?"} ip=${e.ip || "?"} lastSeen=${e.lastSeen ? fmtShanghaiHMS(e.lastSeen) : "?"}`;
|
|
3067
|
-
case "client-abort":
|
|
3068
|
-
return `${head} client abort ${m(e.model)} total=${fmtDur(e.totalMs)}`;
|
|
3069
|
-
case "result":
|
|
3070
|
-
return `${head} result 返回客户端 status=${e.status} model=${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}${fallbackTag}`;
|
|
3071
|
-
case "client-response":
|
|
3072
|
-
return `${head} client res 返回客户端 客户端请求 ${m(e.requested)} 实际返回 ${m(e.actual)} via=${m(e.via)}${e.fallback?.fallback ? ` fallback=${e.fallback.requested_model}->${e.fallback.actual_model}(${e.fallback.reason})` : " 无fallback"} status=${e.status}`;
|
|
3073
|
-
case "auto-update-enabled":
|
|
3074
|
-
return `${head} auto-update enabled every ${Math.round((e.intervalMs||0)/60000)}m current=${e.current}`;
|
|
3075
|
-
case "auto-update-disabled":
|
|
3076
|
-
return `${head} auto-update disabled current=${e.current}`;
|
|
3077
|
-
case "auto-update-check":
|
|
3078
|
-
return `${head} auto-update checking current=${e.current}`;
|
|
3079
|
-
case "auto-update-query":
|
|
3080
|
-
return `${head} auto-update querying npm current=${e.current}`;
|
|
3081
|
-
case "auto-update-queried":
|
|
3082
|
-
return `${head} auto-update queried current=${e.current} latest=${e.latest} raw=${(e.stdout||"").slice(0,80)}`;
|
|
3083
|
-
case "auto-update-noop":
|
|
3084
|
-
return `${head} auto-update noop current=${e.current} latest=${e.latest}${e.reason?` reason=${e.reason}`:""}`;
|
|
3085
|
-
case "auto-update-found":
|
|
3086
|
-
return `${head} auto-update NEW v${e.current} -> v${e.latest} 发现新版本`;
|
|
3087
|
-
case "auto-update-installing":
|
|
3088
|
-
return `${head} auto-update installing v${e.latest}...`;
|
|
3089
|
-
case "auto-update-installed":
|
|
3090
|
-
return `${head} auto-update installed v${e.latest}`;
|
|
3091
|
-
case "auto-update-restarting":
|
|
3092
|
-
return `${head} auto-update restarting daemon to v${e.latest}...`;
|
|
3093
|
-
case "auto-update-restarted":
|
|
3094
|
-
return `${head} auto-update restarted pid=${e.newPid} v${e.current} -> v${e.latest} 升级完成`;
|
|
3095
|
-
case "auto-update-failed":
|
|
3096
|
-
case "auto-update-query-failed":
|
|
3097
|
-
case "auto-update-install-failed":
|
|
3098
|
-
return `${head} auto-update failed ${e.type} error=${e.error||""}`;
|
|
3099
|
-
default:
|
|
3100
|
-
return `${head} ${e?.type || "?"} ${JSON.stringify(e || {})}`;
|
|
3101
|
-
}
|
|
3102
|
-
}
|
|
3103
|
-
|
|
3104
|
-
function fmtTs(iso) {
|
|
3105
|
-
return fmtShanghai(iso);
|
|
3106
|
-
}
|
|
3107
|
-
|
|
3108
|
-
function npmCmd() {
|
|
3109
|
-
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
|
-
function run(cmd, args, opts = {}) {
|
|
3113
|
-
const isWin = process.platform === "win32";
|
|
3114
|
-
return new Promise((resolve) => {
|
|
3115
|
-
// npm.cmd is a batch file on Windows — execFile needs shell:true to find it via PATHEXT
|
|
3116
|
-
const execOpts = isWin ? { shell: true, windowsHide: true } : {};
|
|
3117
|
-
execFile(cmd, args, { timeout: 120_000, ...execOpts, ...opts }, (err, stdout, stderr) => resolve({ err, stdout, stderr }));
|
|
3118
|
-
});
|
|
3119
|
-
}
|
|
3120
|
-
|
|
3121
|
-
async function updateSelf() {
|
|
3122
|
-
console.log(`mslxdff v${VERSION} — checking for updates…`);
|
|
3123
|
-
const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
|
|
3124
|
-
if (info.err) {
|
|
3125
|
-
console.error(`could not query npm: ${info.err.message || String(info.stderr || "").slice(0, 500)}`);
|
|
3126
|
-
process.exit(1);
|
|
3127
|
-
}
|
|
3128
|
-
let latest = "";
|
|
3129
|
-
try {
|
|
3130
|
-
latest = JSON.parse(String(info.stdout || "").trim());
|
|
3131
|
-
if (Array.isArray(latest)) latest = latest[latest.length - 1];
|
|
3132
|
-
latest = String(latest || "").trim();
|
|
3133
|
-
} catch {
|
|
3134
|
-
const m = String(info.stdout || "").trim().match(/(\d+\.\d+\.\d+[^\s'"]*)/);
|
|
3135
|
-
latest = m ? m[1] : "";
|
|
3136
|
-
}
|
|
3137
|
-
latest = String(latest || "").replace(/['"]/g, "").trim();
|
|
3138
|
-
const version = VERSION;
|
|
3139
|
-
console.log(` installed: ${version}`);
|
|
3140
|
-
console.log(` latest: ${latest || "unknown"}`);
|
|
3141
|
-
if (!latest || version === latest || compareSemver(latest, version) <= 0) {
|
|
3142
|
-
console.log("already up to date");
|
|
3143
|
-
process.exit(0);
|
|
3144
|
-
}
|
|
3145
|
-
console.log(`updating to ${latest}…`);
|
|
3146
|
-
const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`], { stdio: "inherit" });
|
|
3147
|
-
if (up.err) {
|
|
3148
|
-
console.error(`update failed: ${up.err.message}`);
|
|
3149
|
-
process.exit(1);
|
|
3150
|
-
}
|
|
3151
|
-
console.log(`updated to ${latest}`);
|
|
3152
|
-
const daemon = readPid();
|
|
3153
|
-
if (daemon) {
|
|
3154
|
-
console.log("restarting daemon on the new version…");
|
|
3155
|
-
stopDaemon();
|
|
3156
|
-
startDaemon([]);
|
|
3157
|
-
await waitForHealth(resolvePort(), 4000);
|
|
3158
|
-
console.log(`restarted (pid ${readPid()})`);
|
|
3159
|
-
}
|
|
3160
|
-
}
|
|
2
|
+
// thin adapter — all logic lives in src/cli (deep module, single inlet run)
|
|
3
|
+
import { run } from "../src/cli/index.js";
|
|
4
|
+
await run(process.argv.slice(2));
|