mslxdff 0.1.2 → 0.1.3
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/README.md +157 -94
- package/bin/mslxdff.js +466 -4
- package/package.json +26 -26
- package/src/auto.js +99 -0
- package/src/daemon.js +63 -63
- package/src/groups.js +189 -0
- package/src/logs.js +80 -0
- package/src/models.js +127 -91
- package/src/peers.js +83 -0
- package/src/reasoning.js +32 -32
- package/src/routes.js +309 -125
- package/src/server.js +37 -36
- package/src/state.js +119 -53
- package/src/upstream.js +67 -67
package/bin/mslxdff.js
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
2
6
|
import { startServer, resolvePort } from "../src/server.js";
|
|
3
7
|
import { createRouter } from "../src/routes.js";
|
|
4
8
|
import { createUpstreamClient } from "../src/upstream.js";
|
|
5
9
|
import { createModelsService } from "../src/models.js";
|
|
6
|
-
import { loadToken, refreshToken, setPort } from "../src/state.js";
|
|
10
|
+
import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined } from "../src/state.js";
|
|
7
11
|
import { startDaemon, stopDaemon, writePid, pidFile, logFile, readPid } from "../src/daemon.js";
|
|
12
|
+
import { createAutoSelector } from "../src/auto.js";
|
|
13
|
+
import { createPeersService } from "../src/peers.js";
|
|
14
|
+
import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../src/groups.js";
|
|
15
|
+
import { logDir, recentCalls, lastError, appendCall, appendError } from "../src/logs.js";
|
|
16
|
+
const logs = { appendCall, appendError };
|
|
8
17
|
|
|
9
18
|
const args = process.argv.slice(2);
|
|
19
|
+
const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
20
|
+
|
|
21
|
+
if (args.includes("-help") || args.includes("--help") || args.includes("-h")) {
|
|
22
|
+
printHelp();
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (args.includes("-update") || args.includes("--update")) {
|
|
27
|
+
await updateSelf();
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
10
30
|
|
|
11
31
|
if (args.includes("-refresh-token") || args.includes("--refresh-token")) {
|
|
12
32
|
const token = await refreshToken();
|
|
@@ -30,6 +50,169 @@ if (args.includes("-stop") || args.includes("--stop")) {
|
|
|
30
50
|
process.exit(0);
|
|
31
51
|
}
|
|
32
52
|
|
|
53
|
+
if (args.includes("-status") || args.includes("--status") || args.includes("-s")) {
|
|
54
|
+
await printStatus();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// -creategroup <name> | -group create <name> | -group sync | -group leave <name> | -group list |
|
|
59
|
+
// -addtogroup <leader-host> <name> | -resetban [ip]
|
|
60
|
+
const createGroupArg = argValue("-creategroup", "--creategroup") || groupIs("create", args);
|
|
61
|
+
if (createGroupArg) {
|
|
62
|
+
const groups = createGroupsService({});
|
|
63
|
+
const peers = createPeersService({});
|
|
64
|
+
const name = createGroupArg;
|
|
65
|
+
const { created } = groups.create(name); // the group name is the password
|
|
66
|
+
markJoined({ name, leaderUrl: "", myUrl: "", memberName: "leader" });
|
|
67
|
+
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
68
|
+
const s = synced.find((x) => x.name === name);
|
|
69
|
+
console.log(created ? `group created: ${name}` : `group already exists: ${name}`);
|
|
70
|
+
console.log(`members on this node: ${s ? `${s.total} (failover: ${s.added})` : "?"}`);
|
|
71
|
+
console.log(`others join with: mslxdff -addtogroup <this-node-host> ${name}`);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function groupIs(action, argv) {
|
|
76
|
+
const idx = argv.indexOf("-group");
|
|
77
|
+
if (idx < 0) return null;
|
|
78
|
+
return argv[idx + 1] === action ? argv[idx + 2] : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const groupCmd = argValue("-group", "--group");
|
|
82
|
+
if (groupCmd && groupCmd !== "create") {
|
|
83
|
+
const groups = createGroupsService({});
|
|
84
|
+
const peers = createPeersService({});
|
|
85
|
+
const rest = args.slice(args.indexOf("-group") + 1);
|
|
86
|
+
const [action, a] = rest;
|
|
87
|
+
if (action === "sync") {
|
|
88
|
+
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
89
|
+
if (!synced.length) {
|
|
90
|
+
console.log("not joined to any group (use -addtogroup or -creategroup)");
|
|
91
|
+
} else {
|
|
92
|
+
for (const s of synced) {
|
|
93
|
+
if (s.error) console.log(`${s.name}: sync failed — ${s.error}`);
|
|
94
|
+
else console.log(`${s.name}: ${s.total} member(s), ${s.added} failover target(s) configured`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} else if (action === "leave" && a) {
|
|
98
|
+
const before = loadGroupsJoined().filter((g) => g.name === a).length;
|
|
99
|
+
saveGroupsJoined(loadGroupsJoined().filter((g) => g.name !== a));
|
|
100
|
+
const removed = peers.removeByGroup(a);
|
|
101
|
+
console.log(before ? `left group "${a}" (${removed} member(s) removed)` : `not a member of group "${a}"`);
|
|
102
|
+
} else if (action === "list") {
|
|
103
|
+
const all = groups.list();
|
|
104
|
+
const names = Object.keys(all);
|
|
105
|
+
if (names.length) {
|
|
106
|
+
for (const n of names) {
|
|
107
|
+
console.log(`${n} (${Object.keys(all[n].members || {}).length} members)`);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
console.log("no groups on this node");
|
|
111
|
+
}
|
|
112
|
+
const joined = loadGroupsJoined();
|
|
113
|
+
if (joined.length) {
|
|
114
|
+
console.log(`\njoined groups (${joined.length}):`);
|
|
115
|
+
for (const g of joined) console.log(` ${g.name} ${g.leaderUrl || "(this node is the leader)"}`);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
console.error("usage: mslxdff -creategroup <name> | -group sync | -group leave <name> | -group list");
|
|
119
|
+
}
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// -addtogroup <leader-host> <name>
|
|
124
|
+
const addToGroupIdx = args.findIndex((x) => x === "-addtogroup" || x === "--addtogroup");
|
|
125
|
+
if (addToGroupIdx >= 0) {
|
|
126
|
+
const [leaderHost, name] = args.slice(addToGroupIdx + 1);
|
|
127
|
+
if (!leaderHost || !name) {
|
|
128
|
+
console.error("usage: mslxdff -addtogroup <leader-host> <name>");
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
const groups = createGroupsService({});
|
|
132
|
+
const peers = createPeersService({});
|
|
133
|
+
const myToken = (await loadToken()).token;
|
|
134
|
+
const leaderUrl = leaderHost.includes("://")
|
|
135
|
+
? leaderHost.replace(/\/+$/, "")
|
|
136
|
+
: `http://${leaderHost}${leaderHost.includes(":") ? "" : ":8989"}`;
|
|
137
|
+
const myPort = effectivePort();
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${leaderUrl}/v1/groups/join`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ name, key: name, leaderUrl, myPort, token: myToken }),
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const text = await res.text().catch(() => "");
|
|
146
|
+
throw new Error(`join failed (HTTP ${res.status}): ${text}`);
|
|
147
|
+
}
|
|
148
|
+
const data = await res.json();
|
|
149
|
+
const myUrl = data.you?.url || "";
|
|
150
|
+
markJoined({ name, leaderUrl, myUrl, memberName: myUrl });
|
|
151
|
+
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
152
|
+
const s = synced.find((x) => x.name === name);
|
|
153
|
+
console.log(`joined group "${name}" at ${leaderUrl}`);
|
|
154
|
+
if (s?.error) console.log(` local failover setup failed: ${s.error}`);
|
|
155
|
+
else console.log(` ${s?.added ?? 0} failover target(s) configured`);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error(`join failed: ${err.message}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// -resetban [ip]
|
|
164
|
+
const resetBanArg = argValue("-resetban", "--resetban");
|
|
165
|
+
if (resetBanArg !== null || args.includes("-resetban") || args.includes("--resetban")) {
|
|
166
|
+
const bans = createBansService({});
|
|
167
|
+
const ip = resetBanArg || null;
|
|
168
|
+
bans.clear(ip || undefined);
|
|
169
|
+
console.log(ip ? `ban cleared for ${ip}` : "all bans cleared");
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function markJoined(entry) {
|
|
174
|
+
const { name } = entry;
|
|
175
|
+
const list = loadGroupsJoined().filter((g) => g.name !== name);
|
|
176
|
+
saveGroupsJoined([...list, entry]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const errMsg = (err) => String(err?.message || err);
|
|
180
|
+
|
|
181
|
+
// Sync every joined group into the local peer list. Leaders read their local
|
|
182
|
+
// groups state; members re-register with the leader (idempotent) to get the
|
|
183
|
+
// freshest member list. Returns per-group results.
|
|
184
|
+
async function syncAllJoinedGroups({ peers, groups }) {
|
|
185
|
+
const joined = loadGroupsJoined();
|
|
186
|
+
const myToken = (await loadToken()).token;
|
|
187
|
+
const results = [];
|
|
188
|
+
for (const g of joined) {
|
|
189
|
+
try {
|
|
190
|
+
if (g.leaderUrl) {
|
|
191
|
+
const members = await refreshGroupMembers(g.name, {
|
|
192
|
+
leaderUrl: g.leaderUrl,
|
|
193
|
+
memberName: g.memberName,
|
|
194
|
+
url: g.myUrl,
|
|
195
|
+
token: myToken,
|
|
196
|
+
});
|
|
197
|
+
results.push({
|
|
198
|
+
name: g.name,
|
|
199
|
+
...syncPeersFromMembers({ peers, members, myUrl: g.myUrl, group: g.name }),
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
// this node is the leader: […] own entry is always skipped
|
|
203
|
+
const members = groups.list()[g.name]?.members ?? {};
|
|
204
|
+
results.push({
|
|
205
|
+
name: g.name,
|
|
206
|
+
...syncPeersFromMembers({ peers, members, myUrl: g.myUrl, group: g.name, skipIds: ["leader"] }),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
results.push({ name: g.name, error: errMsg(err) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return results;
|
|
214
|
+
}
|
|
215
|
+
|
|
33
216
|
// -port N: persist the port, then restart the daemon on it if one is running.
|
|
34
217
|
// Skip when we ARE the daemon child (it already carries the port via args).
|
|
35
218
|
const portArg = argValue("-port", "--port");
|
|
@@ -67,26 +250,74 @@ if (args.includes("-d") || args.includes("--daemon")) {
|
|
|
67
250
|
// we ARE the daemon; stdout/stderr already point at the log file via startDaemon stdio
|
|
68
251
|
}
|
|
69
252
|
|
|
253
|
+
// Bare run: if a daemon is already running, show status + help instead of starting another.
|
|
254
|
+
if (!process.env.MSLXDFF_DAEMON && readPid()) {
|
|
255
|
+
await printStatus();
|
|
256
|
+
printHelp();
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
|
259
|
+
|
|
70
260
|
const { token, created } = await loadToken();
|
|
71
261
|
const upstream = createUpstreamClient({});
|
|
72
262
|
const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
73
|
-
const models = createModelsService({
|
|
263
|
+
const models = createModelsService({
|
|
264
|
+
baseUrl,
|
|
265
|
+
headers: upstream.headers,
|
|
266
|
+
refreshMs: refreshIntervalMs(),
|
|
267
|
+
cacheFile: join(logDir(), "models.json"),
|
|
268
|
+
});
|
|
269
|
+
const auto = createAutoSelector({
|
|
270
|
+
cooldownMs: modelCooldownMs(),
|
|
271
|
+
loadCandidates: async () => {
|
|
272
|
+
try {
|
|
273
|
+
return (await models.get()).data.map((m) => m.id);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const peers = createPeersService({ cooldownMs: peerCooldownMs() });
|
|
280
|
+
const groups = createGroupsService({});
|
|
281
|
+
const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshold() });
|
|
74
282
|
|
|
75
|
-
const router = createRouter({ token, upstream, models });
|
|
283
|
+
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans });
|
|
76
284
|
const srv = startServer({ router });
|
|
77
285
|
|
|
78
286
|
await srv.ready();
|
|
287
|
+
models.startAutoRefresh();
|
|
79
288
|
if (process.env.MSLXDFF_DAEMON) {
|
|
80
289
|
writePid(process.pid);
|
|
81
290
|
}
|
|
82
291
|
const addr = srv.server.address();
|
|
83
292
|
const host = addr.address === "0.0.0.0" || addr.address === "::" ? "localhost" : addr.address;
|
|
84
|
-
console.log(`mslxdff listening on http://${host}:${addr.port}`);
|
|
293
|
+
console.log(`mslxdff v${VERSION} listening on http://${host}:${addr.port}`);
|
|
85
294
|
if (created) {
|
|
86
295
|
console.log(`auth token: ${token}`);
|
|
87
296
|
}
|
|
88
297
|
console.log(`endpoint: http://${host}:${addr.port}/v1`);
|
|
89
298
|
|
|
299
|
+
// Periodically pull the freshest member lists for every joined group so a
|
|
300
|
+
// new member becomes a failover peer on all nodes without manual re-joining.
|
|
301
|
+
syncAllJoinedGroups({ peers, groups })
|
|
302
|
+
.then((results) => {
|
|
303
|
+
for (const r of results) {
|
|
304
|
+
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
305
|
+
else console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
309
|
+
const groupSyncTimer = setInterval(() => {
|
|
310
|
+
syncAllJoinedGroups({ peers, groups })
|
|
311
|
+
.then((results) => {
|
|
312
|
+
for (const r of results) {
|
|
313
|
+
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
314
|
+
else if (r.added) console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
318
|
+
}, groupSyncIntervalMs());
|
|
319
|
+
groupSyncTimer.unref();
|
|
320
|
+
|
|
90
321
|
function argValue(...names) {
|
|
91
322
|
for (let i = 0; i < args.length; i++) {
|
|
92
323
|
if (names.includes(args[i])) return args[i + 1];
|
|
@@ -100,6 +331,41 @@ function effectivePort() {
|
|
|
100
331
|
return resolvePort();
|
|
101
332
|
}
|
|
102
333
|
|
|
334
|
+
function refreshIntervalMs() {
|
|
335
|
+
const n = Number(process.env.MODELS_REFRESH_MS);
|
|
336
|
+
return Number.isInteger(n) && n > 0 ? n : 2 * 60 * 60 * 1000;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function modelCooldownMs() {
|
|
340
|
+
const n = Number(process.env.MSLXDFF_MODEL_COOLDOWN_MS);
|
|
341
|
+
return Number.isInteger(n) && n > 0 ? n : 60_000;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function peerCooldownMs() {
|
|
345
|
+
const n = Number(process.env.MSLXDFF_PEER_COOLDOWN_MS);
|
|
346
|
+
return Number.isInteger(n) && n > 0 ? n : 30_000;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function maxHopsValue() {
|
|
350
|
+
const n = Number(process.env.MSLXDFF_MAX_HOPS);
|
|
351
|
+
return Number.isInteger(n) && n > 0 ? n : 3;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function groupSyncIntervalMs() {
|
|
355
|
+
const n = Number(process.env.MSLXDFF_GROUP_SYNC_MS);
|
|
356
|
+
return Number.isInteger(n) && n > 0 ? n : 60_000;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function banWindowMs() {
|
|
360
|
+
const n = Number(process.env.MSLXDFF_BAN_WINDOW_MS);
|
|
361
|
+
return Number.isInteger(n) && n > 0 ? n : 48 * 60 * 60 * 1000;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function banThreshold() {
|
|
365
|
+
const n = Number(process.env.MSLXDFF_BAN_THRESHOLD);
|
|
366
|
+
return Number.isInteger(n) && n > 0 ? n : 5;
|
|
367
|
+
}
|
|
368
|
+
|
|
103
369
|
async function waitForHealth(port, timeoutMs) {
|
|
104
370
|
const start = Date.now();
|
|
105
371
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -112,3 +378,199 @@ async function waitForHealth(port, timeoutMs) {
|
|
|
112
378
|
await new Promise((r) => setTimeout(r, 100));
|
|
113
379
|
}
|
|
114
380
|
}
|
|
381
|
+
|
|
382
|
+
function printHelp() {
|
|
383
|
+
console.log(`mslxdff v${VERSION} — OpenCode Free OpenAI-compatible proxy
|
|
384
|
+
|
|
385
|
+
Usage:
|
|
386
|
+
mslxdff start the server (if none running); shows status when a daemon is already up
|
|
387
|
+
mslxdff -d start as a background daemon
|
|
388
|
+
mslxdff -status show current status (daemon, models, recent calls, last error)
|
|
389
|
+
mslxdff -stop stop the running daemon
|
|
390
|
+
mslxdff -port N persist the listen port (restarts the daemon on it if running)
|
|
391
|
+
mslxdff -update update mslxdff to the latest published version
|
|
392
|
+
mslxdff -showtoken print the current auth token
|
|
393
|
+
mslxdff -refresh-token rotate the auth token (prints the new one)
|
|
394
|
+
mslxdff -creategroup <name> create a group on this node (the group name is the password)
|
|
395
|
+
mslxdff -addtogroup <leader-host> <name> join a group via its leader host (default port 8989)
|
|
396
|
+
mslxdff -group sync pull the freshest member list for all joined groups
|
|
397
|
+
mslxdff -group leave <name> leave a group (removes its members from this node)
|
|
398
|
+
mslxdff -group list list groups on this node
|
|
399
|
+
mslxdff -resetban [ip] clear join-failure bans (all, or one ip)
|
|
400
|
+
mslxdff -help show this help
|
|
401
|
+
|
|
402
|
+
Environment:
|
|
403
|
+
PORT listen port (default 8989)
|
|
404
|
+
MSLXDFF_STATE_FILE token/port state file
|
|
405
|
+
MSLXDFF_DAEMON_DIR daemon pid/log/models dir
|
|
406
|
+
UPSTREAM_BASE_URL upstream base (default https://opencode.ai)
|
|
407
|
+
UPSTREAM_AUTH_TOKEN upstream bearer value (default "public")
|
|
408
|
+
UPSTREAM_CONNECT_TIMEOUT_MS upstream connect timeout (default 30000)
|
|
409
|
+
MODELS_REFRESH_MS model-list background refresh interval (default 7200000)
|
|
410
|
+
MSLXDFF_MODEL_COOLDOWN_MS fallback cooldown after a model error (default 60000)
|
|
411
|
+
MSLXDFF_PEER_COOLDOWN_MS peer failover cooldown (default 30000)
|
|
412
|
+
MSLXDFF_GROUP_SYNC_MS group membership sync interval (default 60000)
|
|
413
|
+
MSLXDFF_MAX_HOPS max peer-forwarding depth (default 3)
|
|
414
|
+
MSLXDFF_BAN_THRESHOLD failed joins before an ip is banned (default 5)
|
|
415
|
+
MSLXDFF_BAN_WINDOW_MS ban duration after too many failures (default 48h)
|
|
416
|
+
`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function printStatus() {
|
|
420
|
+
const daemon = readPid();
|
|
421
|
+
const port = getPort() || resolvePort();
|
|
422
|
+
console.log(`mslxdff v${VERSION}`);
|
|
423
|
+
console.log(`daemon: ${daemon ? `running (pid ${daemon})` : "not running"}`);
|
|
424
|
+
console.log(`endpoint: http://localhost:${port}/v1`);
|
|
425
|
+
console.log(`log dir: ${logDir()}`);
|
|
426
|
+
|
|
427
|
+
const groups = createGroupsService({});
|
|
428
|
+
const joined = loadGroupsJoined();
|
|
429
|
+
if (joined.length) {
|
|
430
|
+
console.log(`\njoined groups (${joined.length}):`);
|
|
431
|
+
const { token } = await loadToken();
|
|
432
|
+
for (const g of joined) {
|
|
433
|
+
const isLeader = !g.leaderUrl;
|
|
434
|
+
console.log(` ${g.name} ${isLeader ? "(this node is the leader)" : `leader ${g.leaderUrl}`}`);
|
|
435
|
+
let members = null;
|
|
436
|
+
if (isLeader) {
|
|
437
|
+
members = groups.list()[g.name]?.members ?? {};
|
|
438
|
+
} else {
|
|
439
|
+
// registered members can pull the member map back from the leader
|
|
440
|
+
const fetchImpl = (url, opts) => fetch(url, { ...opts, signal: AbortSignal.timeout(1500) });
|
|
441
|
+
try {
|
|
442
|
+
members = await refreshGroupMembers(g.name, {
|
|
443
|
+
leaderUrl: g.leaderUrl,
|
|
444
|
+
memberName: g.memberName,
|
|
445
|
+
url: g.myUrl,
|
|
446
|
+
token,
|
|
447
|
+
fetchImpl,
|
|
448
|
+
});
|
|
449
|
+
} catch {
|
|
450
|
+
members = null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (members === null) {
|
|
454
|
+
console.log(" members: (unavailable — leader unreachable)");
|
|
455
|
+
} else {
|
|
456
|
+
const ids = Object.keys(members);
|
|
457
|
+
if (!ids.length) {
|
|
458
|
+
console.log(" members: (none yet)");
|
|
459
|
+
} else {
|
|
460
|
+
console.log(` members (${ids.length}):`);
|
|
461
|
+
for (const id of ids) {
|
|
462
|
+
const m = members[id];
|
|
463
|
+
const tags = [];
|
|
464
|
+
if (id === "leader" && isLeader) tags.push("leader");
|
|
465
|
+
if (m?.url && m.url === g.myUrl) tags.push("this node");
|
|
466
|
+
const tag = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
467
|
+
console.log(` ${m?.url || id}${tag}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
console.log("\njoined groups: (none — use -creategroup or -addtogroup)");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const peers = createPeersService({});
|
|
477
|
+
const allPeers = peers.all();
|
|
478
|
+
if (allPeers.length) {
|
|
479
|
+
console.log(`\nfailover targets (${allPeers.length}):`);
|
|
480
|
+
for (const p of allPeers) {
|
|
481
|
+
const cooling = peers.isCooling(p.url) ? " [cooling]" : "";
|
|
482
|
+
console.log(` ${p.name || p.url} ${p.url}${cooling}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const groupNames = Object.keys(groups.list());
|
|
487
|
+
if (groupNames.length) {
|
|
488
|
+
console.log(`\ngroups on this node (${groupNames.length}):`);
|
|
489
|
+
for (const n of groupNames) console.log(` ${n}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const modelsFile = join(logDir(), "models.json");
|
|
493
|
+
if (existsSync(modelsFile)) {
|
|
494
|
+
try {
|
|
495
|
+
const cached = JSON.parse(readFileSync(modelsFile, "utf8"));
|
|
496
|
+
const ids = (cached.data || []).map((m) => m.id).filter(Boolean);
|
|
497
|
+
console.log(`\nmodels (${ids.length} free):`);
|
|
498
|
+
for (const id of ids) console.log(` ${id}`);
|
|
499
|
+
} catch {
|
|
500
|
+
console.log("\nmodels: cache unreadable");
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
console.log("\nmodels: not cached yet (runs once the server has fetched the upstream list)");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
console.log("\nrecent calls:");
|
|
507
|
+
const calls = recentCalls(5);
|
|
508
|
+
if (calls.length) {
|
|
509
|
+
for (const c of calls) {
|
|
510
|
+
console.log(` ${fmtTs(c.ts)} ${c.model || "-"} ${c.status} ${c.durationMs ?? "?"}ms${c.auto ? " auto" : ""}`);
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
console.log(" (none yet)");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
console.log("\nlast error:");
|
|
517
|
+
const err = lastError();
|
|
518
|
+
if (err) {
|
|
519
|
+
console.log(` ${fmtTs(err.ts)} ${err.model || "-"} ${err.status} ${err.message || ""}`);
|
|
520
|
+
} else {
|
|
521
|
+
console.log(" (none)");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (daemon) console.log(`\nauth token: use \`mslxdff -showtoken\``);
|
|
525
|
+
else console.log(`\nnot running — start with: mslxdff -d`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function fmtTs(iso) {
|
|
529
|
+
if (!iso) return "-";
|
|
530
|
+
try {
|
|
531
|
+
return new Date(iso).toISOString().replace("T", " ").slice(5, 19);
|
|
532
|
+
} catch {
|
|
533
|
+
return "-";
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function npmCmd() {
|
|
538
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function run(cmd, args, opts = {}) {
|
|
542
|
+
return new Promise((resolve) => {
|
|
543
|
+
execFile(cmd, args, { timeout: 120_000, ...opts }, (err, stdout, stderr) => resolve({ err, stdout, stderr }));
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function updateSelf() {
|
|
548
|
+
console.log(`mslxdff v${VERSION} — checking for updates…`);
|
|
549
|
+
const info = await run(npmCmd(), ["view", "mslxdff", "version", "dist-tags.latest"]);
|
|
550
|
+
if (info.err) {
|
|
551
|
+
console.error(`could not query npm: ${info.err.message}`);
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
const [version, latest] = (info.stdout || "").trim().split(/\s+/);
|
|
555
|
+
console.log(` installed: ${version}`);
|
|
556
|
+
console.log(` latest: ${latest}`);
|
|
557
|
+
if (version === latest) {
|
|
558
|
+
console.log("already up to date");
|
|
559
|
+
process.exit(0);
|
|
560
|
+
}
|
|
561
|
+
console.log(`updating to ${latest}…`);
|
|
562
|
+
const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`], { stdio: "inherit" });
|
|
563
|
+
if (up.err) {
|
|
564
|
+
console.error(`update failed: ${up.err.message}`);
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
console.log(`updated to ${latest}`);
|
|
568
|
+
const daemon = readPid();
|
|
569
|
+
if (daemon) {
|
|
570
|
+
console.log("restarting daemon on the new version…");
|
|
571
|
+
stopDaemon();
|
|
572
|
+
startDaemon([]);
|
|
573
|
+
await waitForHealth(resolvePort(), 4000);
|
|
574
|
+
console.log(`restarted (pid ${readPid()})`);
|
|
575
|
+
}
|
|
576
|
+
}
|
package/package.json
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "mslxdff",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "测试项目,请勿使用。",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"mslxdff": "bin/mslxdff.js"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"start": "node bin/mslxdff.js",
|
|
11
|
-
"test": "node --test test/"
|
|
12
|
-
},
|
|
13
|
-
"engines": {
|
|
14
|
-
"node": ">=20"
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"bin/",
|
|
18
|
-
"src/",
|
|
19
|
-
"README.md"
|
|
20
|
-
],
|
|
21
|
-
"keywords": [
|
|
22
|
-
"test",
|
|
23
|
-
"demo",
|
|
24
|
-
"placeholder"
|
|
25
|
-
],
|
|
26
|
-
"license": "MIT"
|
|
1
|
+
{
|
|
2
|
+
"name": "mslxdff",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "测试项目,请勿使用。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mslxdff": "bin/mslxdff.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/mslxdff.js",
|
|
11
|
+
"test": "node --test test/"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"src/",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"test",
|
|
23
|
+
"demo",
|
|
24
|
+
"placeholder"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT"
|
|
27
27
|
}
|
package/src/auto.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { loadModelErrors, saveModelErrors } from "./state.js";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_AUTO_MODELS = [
|
|
4
|
+
"deepseek-v4-flash-free",
|
|
5
|
+
"mimo-v2.5-free",
|
|
6
|
+
"ling-3.0-flash-free",
|
|
7
|
+
"nemotron-3-ultra-free",
|
|
8
|
+
"north-mini-code-free",
|
|
9
|
+
"laguna-s-2.1-free",
|
|
10
|
+
"big-pickle",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function isAutoModel(model) {
|
|
14
|
+
return !model || model === "auto";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_COOLDOWN_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
function inCooldown(id, errors, now, cooldownMs) {
|
|
20
|
+
if (!cooldownMs) return false;
|
|
21
|
+
const err = errors[id];
|
|
22
|
+
return typeof err === "number" && now - err < cooldownMs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0 } = {}) {
|
|
26
|
+
return [...new Set(ids)]
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map((id) => ({
|
|
29
|
+
id,
|
|
30
|
+
err: typeof errors[id] === "number" ? errors[id] : 0,
|
|
31
|
+
isDeepseek: /deepseek/i.test(id),
|
|
32
|
+
cooling: inCooldown(id, errors, now, cooldownMs),
|
|
33
|
+
}))
|
|
34
|
+
.sort(
|
|
35
|
+
(a, b) =>
|
|
36
|
+
(a.cooling ? 1 : 0) - (b.cooling ? 1 : 0) ||
|
|
37
|
+
a.err - b.err ||
|
|
38
|
+
(b.isDeepseek ? 1 : 0) - (a.isDeepseek ? 1 : 0)
|
|
39
|
+
)
|
|
40
|
+
.map((x) => x.id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createAutoSelector({
|
|
44
|
+
loadCandidates,
|
|
45
|
+
file,
|
|
46
|
+
now = () => Date.now(),
|
|
47
|
+
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
48
|
+
errors: seedErrors,
|
|
49
|
+
persist = (errors, f = file) => saveModelErrors(errors, f ? { file: f } : {}),
|
|
50
|
+
} = {}) {
|
|
51
|
+
const lastErrorAt = { ...(seedErrors ?? loadModelErrors(file ? { file } : {})) };
|
|
52
|
+
|
|
53
|
+
async function loadList() {
|
|
54
|
+
let list;
|
|
55
|
+
try {
|
|
56
|
+
const loaded = await loadCandidates?.();
|
|
57
|
+
list = Array.isArray(loaded) && loaded.length ? loaded : DEFAULT_AUTO_MODELS;
|
|
58
|
+
} catch {
|
|
59
|
+
list = DEFAULT_AUTO_MODELS;
|
|
60
|
+
}
|
|
61
|
+
return [...new Set(list)].filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function candidates() {
|
|
65
|
+
return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function candidatesFor(requested) {
|
|
69
|
+
if (!requested) return candidates();
|
|
70
|
+
const list = await loadList();
|
|
71
|
+
const all = list.includes(requested) ? list : [requested, ...list];
|
|
72
|
+
const others = rankModels(all.filter((id) => id !== requested), lastErrorAt, {
|
|
73
|
+
now: now(),
|
|
74
|
+
cooldownMs,
|
|
75
|
+
});
|
|
76
|
+
if (inCooldown(requested, lastErrorAt, now(), cooldownMs)) {
|
|
77
|
+
return [...others, requested];
|
|
78
|
+
}
|
|
79
|
+
return [requested, ...others];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function recordError(id) {
|
|
83
|
+
if (!id) return;
|
|
84
|
+
lastErrorAt[id] = now();
|
|
85
|
+
await persist({ ...lastErrorAt });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isCooling(id) {
|
|
89
|
+
return inCooldown(id, lastErrorAt, now(), cooldownMs);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
candidates,
|
|
94
|
+
candidatesFor,
|
|
95
|
+
recordError,
|
|
96
|
+
isCooling,
|
|
97
|
+
errors: () => ({ ...lastErrorAt }),
|
|
98
|
+
};
|
|
99
|
+
}
|