mslxdff 0.1.25 → 0.1.27
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 +1 -1
- package/package.json +1 -1
- package/src/routes.js +618 -529
- package/src/server.js +5 -1
- package/src/upstream.js +25 -4
package/bin/mslxdff.js
CHANGED
|
@@ -758,7 +758,7 @@ Usage:
|
|
|
758
758
|
mslxdff -help show this help
|
|
759
759
|
|
|
760
760
|
Environment:
|
|
761
|
-
|
|
761
|
+
MSLXDFF_PORT listen port (default 8989; use mslxdff -port N to persist)
|
|
762
762
|
MSLXDFF_STATE_FILE token/port state file
|
|
763
763
|
MSLXDFF_DAEMON_DIR daemon pid/log/models dir
|
|
764
764
|
UPSTREAM_BASE_URL upstream base (default https://opencode.ai)
|
package/package.json
CHANGED
package/src/routes.js
CHANGED
|
@@ -1,529 +1,618 @@
|
|
|
1
|
-
import { timingSafeEqual, createHash } from "node:crypto";
|
|
2
|
-
import { performance } from "node:perf_hooks";
|
|
3
|
-
import { injectReasoningContent, normalizeModel } from "./reasoning.js";
|
|
4
|
-
import { isAutoModel } from "./auto.js";
|
|
5
|
-
import { DEFAULT_MAX_HOPS } from "./peers.js";
|
|
6
|
-
|
|
7
|
-
export const errMsg = (err) => String(err?.message || err);
|
|
8
|
-
|
|
9
|
-
export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus }) {
|
|
10
|
-
return async function router(req, res) {
|
|
11
|
-
const method = req.method || "GET";
|
|
12
|
-
const path = (req.url || "").split("?")[0];
|
|
13
|
-
|
|
14
|
-
const route = ROUTES.find((r) => r.method === method && r.path === path);
|
|
15
|
-
|
|
16
|
-
if (!route) return notFound(res);
|
|
17
|
-
|
|
18
|
-
if (route.requiresAuth && !authorized(req, token)) {
|
|
19
|
-
res.statusCode = 401;
|
|
20
|
-
res.setHeader("WWW-Authenticate", "Bearer");
|
|
21
|
-
return json(res, 401, { error: "Unauthorized" });
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
await route.handler({ req, res, upstream, models, auto, logs, peers, maxHops, groups, bans, token, bus });
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function clientIp(req) {
|
|
29
|
-
const fwd = req.headers["x-forwarded-for"];
|
|
30
|
-
const head = typeof fwd === "string" ? fwd.split(",")[0].trim() : "";
|
|
31
|
-
const raw = String(head || req.socket.remoteAddress || "");
|
|
32
|
-
return raw.replace(/^::ffff:/, "").replace(/^::1$/, "127.0.0.1") || null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function authorized(req, token) {
|
|
36
|
-
const header = req.headers["authorization"] || "";
|
|
37
|
-
const match = /^Bearer (.+)$/.exec(header);
|
|
38
|
-
if (!match) return false;
|
|
39
|
-
const digests = (s) => createHash("sha256").update(s).digest();
|
|
40
|
-
return timingSafeEqual(digests(match[1]), digests(token));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function json(res, status, body) {
|
|
44
|
-
res.statusCode = status;
|
|
45
|
-
res.setHeader("Content-Type", "application/json");
|
|
46
|
-
res.end(JSON.stringify(body));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function notFound(res) {
|
|
50
|
-
return json(res, 404, { error: "Not Found" });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function readBody(req) {
|
|
54
|
-
return new Promise((resolve, reject) => {
|
|
55
|
-
let data = "";
|
|
56
|
-
req.on("data", (c) => (data += c));
|
|
57
|
-
req.on("end", () => {
|
|
58
|
-
try {
|
|
59
|
-
resolve(data ? JSON.parse(data) : {});
|
|
60
|
-
} catch (err) {
|
|
61
|
-
reject(err);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
req.on("error", reject);
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
:
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
1
|
+
import { timingSafeEqual, createHash } from "node:crypto";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { injectReasoningContent, normalizeModel } from "./reasoning.js";
|
|
4
|
+
import { isAutoModel } from "./auto.js";
|
|
5
|
+
import { DEFAULT_MAX_HOPS } from "./peers.js";
|
|
6
|
+
|
|
7
|
+
export const errMsg = (err) => String(err?.message || err);
|
|
8
|
+
|
|
9
|
+
export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus }) {
|
|
10
|
+
return async function router(req, res) {
|
|
11
|
+
const method = req.method || "GET";
|
|
12
|
+
const path = (req.url || "").split("?")[0];
|
|
13
|
+
|
|
14
|
+
const route = ROUTES.find((r) => r.method === method && r.path === path);
|
|
15
|
+
|
|
16
|
+
if (!route) return notFound(res);
|
|
17
|
+
|
|
18
|
+
if (route.requiresAuth && !authorized(req, token)) {
|
|
19
|
+
res.statusCode = 401;
|
|
20
|
+
res.setHeader("WWW-Authenticate", "Bearer");
|
|
21
|
+
return json(res, 401, { error: "Unauthorized" });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await route.handler({ req, res, upstream, models, auto, logs, peers, maxHops, groups, bans, token, bus });
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clientIp(req) {
|
|
29
|
+
const fwd = req.headers["x-forwarded-for"];
|
|
30
|
+
const head = typeof fwd === "string" ? fwd.split(",")[0].trim() : "";
|
|
31
|
+
const raw = String(head || req.socket.remoteAddress || "");
|
|
32
|
+
return raw.replace(/^::ffff:/, "").replace(/^::1$/, "127.0.0.1") || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function authorized(req, token) {
|
|
36
|
+
const header = req.headers["authorization"] || "";
|
|
37
|
+
const match = /^Bearer (.+)$/.exec(header);
|
|
38
|
+
if (!match) return false;
|
|
39
|
+
const digests = (s) => createHash("sha256").update(s).digest();
|
|
40
|
+
return timingSafeEqual(digests(match[1]), digests(token));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function json(res, status, body) {
|
|
44
|
+
res.statusCode = status;
|
|
45
|
+
res.setHeader("Content-Type", "application/json");
|
|
46
|
+
res.end(JSON.stringify(body));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function notFound(res) {
|
|
50
|
+
return json(res, 404, { error: "Not Found" });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readBody(req) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
let data = "";
|
|
56
|
+
req.on("data", (c) => (data += c));
|
|
57
|
+
req.on("end", () => {
|
|
58
|
+
try {
|
|
59
|
+
resolve(data ? JSON.parse(data) : {});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
reject(err);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
req.on("error", reject);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Relay an upstream response to the client. Returns { status, ttfMs, aborted }
|
|
69
|
+
// where:
|
|
70
|
+
// - status 200 = fully relayed; STREAM_TIMEOUT = first chunk never arrived
|
|
71
|
+
// within streamTimeoutMs and nothing was written to res yet (safe to
|
|
72
|
+
// failover); 500 = the response body errored mid-stream.
|
|
73
|
+
// - ttfMs time to first chunk when one arrived.
|
|
74
|
+
// - aborted true when we closed the downstream connection ourselves (only
|
|
75
|
+
// for the STREAM_TIMEOUT case, before anything was written).
|
|
76
|
+
async function relay(res, upRes, body, { onFirstChunk, streamTimeoutMs = STREAM_TIMEOUT_MS } = {}) {
|
|
77
|
+
const t0 = performance.now();
|
|
78
|
+
const contentType = upRes.headers.get("content-type") || "";
|
|
79
|
+
const isStream = Boolean(body?.stream) || contentType.includes("text/event-stream");
|
|
80
|
+
res.statusCode = upRes.status;
|
|
81
|
+
|
|
82
|
+
if (isStream) {
|
|
83
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
84
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
85
|
+
res.setHeader("Connection", "keep-alive");
|
|
86
|
+
let ttf = null;
|
|
87
|
+
if (upRes.body) {
|
|
88
|
+
let first = true;
|
|
89
|
+
let wroteAny = false;
|
|
90
|
+
let timedOut = false;
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
timedOut = true;
|
|
93
|
+
// nothing written yet — cancel the upstream body so the loop can exit
|
|
94
|
+
// and we can fail over to the next model cleanly.
|
|
95
|
+
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
96
|
+
}, streamTimeoutMs);
|
|
97
|
+
try {
|
|
98
|
+
for await (const chunk of upRes.body) {
|
|
99
|
+
if (timedOut) break;
|
|
100
|
+
if (first) {
|
|
101
|
+
first = false;
|
|
102
|
+
ttf = Math.round(performance.now() - t0);
|
|
103
|
+
onFirstChunk?.(ttf);
|
|
104
|
+
}
|
|
105
|
+
wroteAny = true;
|
|
106
|
+
res.write(chunk);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
timedOut = true;
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
if (timedOut && !wroteAny) {
|
|
114
|
+
// nothing written to res yet — safe to drop this model and let the
|
|
115
|
+
// caller fail over to the next one. Do NOT write/end res here.
|
|
116
|
+
return { status: STREAM_TIMEOUT_MS, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true };
|
|
117
|
+
}
|
|
118
|
+
if (timedOut && wroteAny) {
|
|
119
|
+
// we'd already started streaming when it died — can't fail over, just
|
|
120
|
+
// end the response so the client sees a clean EOF.
|
|
121
|
+
try { res.end(); } catch { /* ignore */ }
|
|
122
|
+
return { status: 200, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const totalMs = Math.round(performance.now() - t0);
|
|
126
|
+
try { res.end(); } catch { /* ignore */ }
|
|
127
|
+
return { status: 200, ttfMs: ttf, totalMs, aborted: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const text = await upRes.text();
|
|
131
|
+
try {
|
|
132
|
+
json(res, upRes.status, JSON.parse(text));
|
|
133
|
+
} catch {
|
|
134
|
+
res.statusCode = upRes.status;
|
|
135
|
+
res.setHeader("Content-Type", contentType || "text/plain");
|
|
136
|
+
res.end(text);
|
|
137
|
+
}
|
|
138
|
+
return { status: upRes.status, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const PEER_TIMEOUT_MS = 30_000;
|
|
142
|
+
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
143
|
+
|
|
144
|
+
// Ask a peer which of its models are healthy (status normal or never
|
|
145
|
+
// failed). Returns model ids ordered as the peer listed them; empty when the
|
|
146
|
+
// peer is unreachable, unauthorized, or has no healthy model.
|
|
147
|
+
export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = fetch } = {}) {
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetchImpl(`${peer.url}/v1/models/status`, {
|
|
150
|
+
headers: {
|
|
151
|
+
"Authorization": `Bearer ${peer.token || ""}`,
|
|
152
|
+
"Accept": "application/json",
|
|
153
|
+
},
|
|
154
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok) return [];
|
|
157
|
+
const json = await res.json().catch(() => ({}));
|
|
158
|
+
return (json.data || [])
|
|
159
|
+
.filter((m) => m && typeof m.id === "string" && m.status === "normal")
|
|
160
|
+
.map((m) => m.id);
|
|
161
|
+
} catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const PROMPT_MAX_LEN = 160;
|
|
167
|
+
|
|
168
|
+
// Human-debuggable summary of the request body: the last non-empty message
|
|
169
|
+
// text (multi-modal parts joined), whitespace-flattened and truncated.
|
|
170
|
+
export function summarizePrompt(body) {
|
|
171
|
+
const msgs = body?.messages;
|
|
172
|
+
if (!Array.isArray(msgs) || !msgs.length) return "";
|
|
173
|
+
const msg = msgs[msgs.length - 1];
|
|
174
|
+
const c = msg?.content;
|
|
175
|
+
let text = "";
|
|
176
|
+
if (typeof c === "string") text = c;
|
|
177
|
+
else if (Array.isArray(c)) {
|
|
178
|
+
text = c
|
|
179
|
+
.map((p) => (typeof p === "string" ? p : p && typeof p.text === "string" ? p.text : ""))
|
|
180
|
+
.join(" ");
|
|
181
|
+
}
|
|
182
|
+
text = String(text || "").replace(/\s+/g, " ").trim();
|
|
183
|
+
return text.length > PROMPT_MAX_LEN ? text.slice(0, PROMPT_MAX_LEN) + "…" : text;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseHops(header) {
|
|
187
|
+
const n = Number(header);
|
|
188
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function forwardToPeer(peer, body, model, hops) {
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
|
|
194
|
+
try {
|
|
195
|
+
return await fetch(`${peer.url}/v1/chat/completions`, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
"Authorization": `Bearer ${peer.token}`,
|
|
200
|
+
"x-mslxdff-hops": String(hops + 1),
|
|
201
|
+
"x-mslxdff-model-lock": model,
|
|
202
|
+
"Accept": "text/event-stream",
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify({ ...body, model }),
|
|
205
|
+
signal: controller.signal,
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return err;
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Resolve the model a peer should serve for this request: reuse its hot-cache
|
|
215
|
+
// model only when it matches the requested one; otherwise probe /v1/models/status
|
|
216
|
+
// and prefer the requested model, falling back to the peer's first healthy one.
|
|
217
|
+
// Returns { peer, target } or null when the peer is unusable.
|
|
218
|
+
async function resolvePeerTarget(ctx, peer) {
|
|
219
|
+
const prevModel = ctx.peers.stat(peer.url)?.model;
|
|
220
|
+
const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
|
|
221
|
+
if (hot) return { peer, target: prevModel };
|
|
222
|
+
const healthy = await peerHealthyModels(peer);
|
|
223
|
+
if (!healthy.length) {
|
|
224
|
+
// peer unreachable or every model unhealthy — mark it and move on
|
|
225
|
+
await ctx.peers.recordError(peer.url);
|
|
226
|
+
ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
|
|
227
|
+
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
|
|
231
|
+
return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Race a batch of candidates: up to PEER_RACE_LIMIT at a time, first success
|
|
235
|
+
// wins. Uses ctx.model/body/hops/peers to resolve targets and forward. Retries
|
|
236
|
+
// remaining candidates in subsequent batches. Returns the winning
|
|
237
|
+
// { peer, target, res, latencyMs } or null when everyone failed.
|
|
238
|
+
export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
|
|
239
|
+
? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
|
|
240
|
+
: 3;
|
|
241
|
+
|
|
242
|
+
// A model whose whole request takes longer than this wall-clock duration is
|
|
243
|
+
// remembered as slow and demoted, so a fast model is preferred next request.
|
|
244
|
+
// Set MSLXDFF_SLOW_TOTAL_MS=0 to disable.
|
|
245
|
+
export const SLOW_TOTAL_MS = (() => {
|
|
246
|
+
const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
|
|
247
|
+
return Number.isInteger(n) && n > 0 ? n : 15_000;
|
|
248
|
+
})();
|
|
249
|
+
|
|
250
|
+
// How long to wait for the first chunk of a streamed response before giving up
|
|
251
|
+
// on that model (nothing has been written yet, so we can fail over cleanly).
|
|
252
|
+
// Set MSLXDFF_STREAM_TIMEOUT_MS=0 to disable the circuit breaker.
|
|
253
|
+
export const STREAM_TIMEOUT_MS = (() => {
|
|
254
|
+
const n = Number(process.env.MSLXDFF_STREAM_TIMEOUT_MS);
|
|
255
|
+
return Number.isInteger(n) && n > 0 ? n : 25_000;
|
|
256
|
+
})();
|
|
257
|
+
|
|
258
|
+
async function racePeerCandidates(candidates, ctx) {
|
|
259
|
+
for (let i = 0; i < candidates.length; i += PEER_RACE_LIMIT) {
|
|
260
|
+
const batch = candidates.slice(i, i + PEER_RACE_LIMIT);
|
|
261
|
+
// resolve targets for this batch first (may probe), then fire them together
|
|
262
|
+
const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
|
|
263
|
+
if (!prepared.length) continue;
|
|
264
|
+
// fire every peer in the batch concurrently and record completion order —
|
|
265
|
+
// the first one to succeed wins the race
|
|
266
|
+
const completed = await new Promise((resolve) => {
|
|
267
|
+
const order = [];
|
|
268
|
+
const total = prepared.length;
|
|
269
|
+
for (const { peer, target } of prepared) {
|
|
270
|
+
const t0 = performance.now();
|
|
271
|
+
forwardToPeer(peer, ctx.body, target, ctx.hops).then((res) => {
|
|
272
|
+
const latencyMs = Math.round(performance.now() - t0);
|
|
273
|
+
const failed = res instanceof Error || res.status >= 400;
|
|
274
|
+
ctx.evt("peer-forward", { peer: peer.url, model: target, hops: ctx.hops + 1 });
|
|
275
|
+
if (failed) {
|
|
276
|
+
const status = res instanceof Error ? 502 : res.status;
|
|
277
|
+
ctx.logError(ctx.model,
|
|
278
|
+
status,
|
|
279
|
+
res instanceof Error ? errMsg(res) : `peer ${status}`);
|
|
280
|
+
ctx.evt("peer-error", {
|
|
281
|
+
peer: peer.url,
|
|
282
|
+
model: target,
|
|
283
|
+
status,
|
|
284
|
+
message: res instanceof Error ? errMsg(res) : null,
|
|
285
|
+
});
|
|
286
|
+
order.push({ ok: false, peer, target, res, status });
|
|
287
|
+
} else {
|
|
288
|
+
order.push({ ok: true, peer, target, res, latencyMs });
|
|
289
|
+
}
|
|
290
|
+
if (order.length === total) resolve(order);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
const winner = completed.find((o) => o.ok);
|
|
295
|
+
if (winner) {
|
|
296
|
+
// every other responder also gets its memory cleared and its stats warmed
|
|
297
|
+
// so it becomes a candidate next time too
|
|
298
|
+
for (const o of completed) {
|
|
299
|
+
if (o === winner) continue;
|
|
300
|
+
if (!o.ok) {
|
|
301
|
+
await ctx.peers.recordError(o.peer.url);
|
|
302
|
+
await ctx.peers.recordResult(o.peer.url, { ok: false });
|
|
303
|
+
} else {
|
|
304
|
+
await ctx.peers.recordResult(o.peer.url, { ok: true, latencyMs: o.latencyMs, model: o.target });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
|
|
308
|
+
}
|
|
309
|
+
for (const o of completed) {
|
|
310
|
+
await ctx.peers.recordError(o.peer.url);
|
|
311
|
+
await ctx.peers.recordResult(o.peer.url, { ok: false });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const ROUTES = [
|
|
318
|
+
{
|
|
319
|
+
method: "GET",
|
|
320
|
+
path: "/health",
|
|
321
|
+
handler: ({ res }) => json(res, 200, { status: "ok" }),
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
method: "POST",
|
|
325
|
+
path: "/v1/chat/completions",
|
|
326
|
+
requiresAuth: true,
|
|
327
|
+
handler: async ({ req, res, upstream, auto, logs, peers, maxHops, bus }) => {
|
|
328
|
+
let body;
|
|
329
|
+
try {
|
|
330
|
+
body = await readBody(req);
|
|
331
|
+
} catch {
|
|
332
|
+
return json(res, 400, { error: "Invalid JSON body" });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const startedAt = Date.now();
|
|
336
|
+
const perf0 = performance.now();
|
|
337
|
+
const stages = [];
|
|
338
|
+
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
339
|
+
const hops = parseHops(req.headers["x-mslxdff-hops"]);
|
|
340
|
+
const lockModel = req.headers["x-mslxdff-model-lock"] || "";
|
|
341
|
+
const requested = normalizeModel(lockModel || body.model || "");
|
|
342
|
+
const useAuto = isAutoModel(requested);
|
|
343
|
+
mark("parsed");
|
|
344
|
+
|
|
345
|
+
let order;
|
|
346
|
+
if (lockModel) {
|
|
347
|
+
order = [requested];
|
|
348
|
+
} else if (useAuto) {
|
|
349
|
+
order = auto ? await auto.candidates() : [""];
|
|
350
|
+
} else {
|
|
351
|
+
order = auto ? await auto.candidatesFor(requested) : [requested];
|
|
352
|
+
}
|
|
353
|
+
if (!order.length) order = [""];
|
|
354
|
+
const canFallback = order.length > 1;
|
|
355
|
+
const canForwardPeers = Boolean(peers) && hops < maxHops;
|
|
356
|
+
mark("ordered");
|
|
357
|
+
|
|
358
|
+
const logCall = (model, status) =>
|
|
359
|
+
logs?.appendCall({ model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(body.stream), stages });
|
|
360
|
+
const logError = (model, status, message) =>
|
|
361
|
+
logs?.appendError({ model, auto: useAuto, status, message, stages });
|
|
362
|
+
const evt = (type, data) => {
|
|
363
|
+
const entry = { ts: Date.now(), type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
364
|
+
if (bus) bus.emit(entry);
|
|
365
|
+
logs?.appendEvent?.(entry);
|
|
366
|
+
};
|
|
367
|
+
evt("request", { hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body) });
|
|
368
|
+
|
|
369
|
+
// Shared context for the peer race helpers below (each model iteration
|
|
370
|
+
// reuses it; `model` is bound per iteration call).
|
|
371
|
+
const handlerCtx = {
|
|
372
|
+
model: null,
|
|
373
|
+
body,
|
|
374
|
+
hops,
|
|
375
|
+
peers,
|
|
376
|
+
evt,
|
|
377
|
+
logError,
|
|
378
|
+
logCall,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
let lastErr = null;
|
|
382
|
+
for (const model of order) {
|
|
383
|
+
handlerCtx.model = model;
|
|
384
|
+
let upRes = null;
|
|
385
|
+
const forwarded = { ...injectReasoningContent(model, body), model };
|
|
386
|
+
const tUp = performance.now();
|
|
387
|
+
try {
|
|
388
|
+
upRes = await upstream.chat(forwarded);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
391
|
+
lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
|
|
392
|
+
logError(model, 502, errMsg(err));
|
|
393
|
+
evt("upstream-error", { model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
|
|
394
|
+
}
|
|
395
|
+
mark(`up-${model}`);
|
|
396
|
+
if (upRes && upRes.status >= 400) {
|
|
397
|
+
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
398
|
+
lastErr = { model, upstream: upRes, status: upRes.status, message: null };
|
|
399
|
+
logError(model, upRes.status, `upstream ${upRes.status}`);
|
|
400
|
+
evt("upstream-error", { model, status: upRes.status, message: null, timing: upRes._t ?? null });
|
|
401
|
+
upRes = null;
|
|
402
|
+
}
|
|
403
|
+
if (upRes) {
|
|
404
|
+
if (auto) await auto.recordOk(model);
|
|
405
|
+
logCall(model, upRes.status);
|
|
406
|
+
const out = await relay(res, upRes, body, { onFirstChunk: (delta) => mark(`ttf-${model}`) });
|
|
407
|
+
if (out.status === STREAM_TIMEOUT_MS) {
|
|
408
|
+
// nothing was written — treat this model as failed and keep walking
|
|
409
|
+
// the failover chain instead of waiting out the slow stream.
|
|
410
|
+
if (auto) await auto.recordError(model, { status: 502, slow: true, note: `stream timeout ${STREAM_TIMEOUT_MS}ms` });
|
|
411
|
+
lastErr = { model, upstream: null, status: 502, message: `stream timed out after ${STREAM_TIMEOUT_MS}ms` };
|
|
412
|
+
logError(model, 502, `stream timeout ${STREAM_TIMEOUT_MS}ms`);
|
|
413
|
+
evt("upstream-error", { model, status: 502, message: "stream timeout", timing: null });
|
|
414
|
+
upRes = null;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
// A model that took a long wall-clock time (TTFB + generation + relay)
|
|
418
|
+
// gets remembered as slow so the next request prefers a faster one.
|
|
419
|
+
const elapsed = Date.now() - startedAt;
|
|
420
|
+
if (SLOW_TOTAL_MS && auto && elapsed > SLOW_TOTAL_MS && out.status === 200) {
|
|
421
|
+
void auto.recordError(model, { status: 200, slow: true, note: `slow ${elapsed}ms` });
|
|
422
|
+
evt("slow-model", { model, elapsedMs: elapsed, threshold: SLOW_TOTAL_MS });
|
|
423
|
+
}
|
|
424
|
+
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// local failed for this model: race the peers — send the request to
|
|
429
|
+
// up to N of them in parallel, first success wins (the winner's model
|
|
430
|
+
// is remembered for the next call). If every candidate fails, retry
|
|
431
|
+
// once ordered by recovery time (earliest failure first), which
|
|
432
|
+
// favours the peer that has had the longest to come back.
|
|
433
|
+
if (canForwardPeers) {
|
|
434
|
+
const win =
|
|
435
|
+
(await racePeerCandidates(peers.ordered(), handlerCtx)) ||
|
|
436
|
+
(await racePeerCandidates(peers.orderedByLastError(), handlerCtx));
|
|
437
|
+
if (win) {
|
|
438
|
+
await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
|
|
439
|
+
logCall(win.target, win.res.status);
|
|
440
|
+
const out = await relay(res, win.res, body, { onFirstChunk: (d) => mark(`ttf-peer-${win.target}`) });
|
|
441
|
+
evt("result", { model: win.target, status: out.status, via: "peer", timing: win.res._t ?? null, ttfMs: out.ttfMs });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (canFallback) continue;
|
|
447
|
+
logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
|
|
448
|
+
if (lastErr?.upstream) {
|
|
449
|
+
const out = await relay(res, lastErr.upstream, body, { onFirstChunk: (d) => mark(`ttf-${lastErr.model}`) });
|
|
450
|
+
evt("result", { model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
evt("result", { model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
454
|
+
return json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
|
|
458
|
+
if (lastErr?.upstream) {
|
|
459
|
+
const out = await relay(res, lastErr.upstream, body, { onFirstChunk: (d) => mark(`ttf-${lastErr.model}`) });
|
|
460
|
+
evt("result", { model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
evt("result", { model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
464
|
+
return json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
method: "POST",
|
|
469
|
+
path: "/v1/groups/join",
|
|
470
|
+
requiresAuth: false,
|
|
471
|
+
handler: async ({ req, res, groups, token, bans }) => {
|
|
472
|
+
if (!groups) return json(res, 501, { error: "Groups service not configured" });
|
|
473
|
+
const ip = clientIp(req);
|
|
474
|
+
const banned = bans?.isBanned(ip);
|
|
475
|
+
if (banned) {
|
|
476
|
+
return json(res, 403, { error: `banned until ${new Date(banned.until).toISOString()}` });
|
|
477
|
+
}
|
|
478
|
+
let body;
|
|
479
|
+
try {
|
|
480
|
+
body = await readBody(req);
|
|
481
|
+
} catch {
|
|
482
|
+
return json(res, 400, { error: "Invalid JSON body" });
|
|
483
|
+
}
|
|
484
|
+
if (!body?.name) return json(res, 400, { error: "group name is required" });
|
|
485
|
+
|
|
486
|
+
const fail = (msg) => {
|
|
487
|
+
try {
|
|
488
|
+
if (bans) {
|
|
489
|
+
const b = bans.recordFailure(ip);
|
|
490
|
+
if (b) console.error(`${ip} banned (${bans.threshold} failed joins)`);
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
// ban bookkeeping must never break the join endpoint
|
|
494
|
+
}
|
|
495
|
+
return json(res, 403, { error: msg });
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Already-registered members re-register (sync) using their bearer token;
|
|
499
|
+
// new members must present the group name (which IS the password).
|
|
500
|
+
if (!body.key) {
|
|
501
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
502
|
+
const hit = auth && groups.membersForToken(body.name, auth[1]);
|
|
503
|
+
if (!hit) return fail("invalid member token");
|
|
504
|
+
try {
|
|
505
|
+
const refreshed = groups.upsertMember(body.name, {
|
|
506
|
+
memberName: body.memberName,
|
|
507
|
+
url: body.url,
|
|
508
|
+
token: body.token,
|
|
509
|
+
});
|
|
510
|
+
return json(res, 200, { object: "group", name: body.name, members: refreshed });
|
|
511
|
+
} catch (err) {
|
|
512
|
+
return json(res, 400, { error: errMsg(err) });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const youPort = Number(body.myPort);
|
|
518
|
+
const youUrl = Number.isInteger(youPort) && youPort > 0 ? `http://${ip}:${youPort}` : "";
|
|
519
|
+
const memberUrl = String(body.url || youUrl);
|
|
520
|
+
if (!memberUrl) throw new Error("member url is required");
|
|
521
|
+
const members = groups.addMember(body.name, {
|
|
522
|
+
key: body.key,
|
|
523
|
+
memberName: body.memberName,
|
|
524
|
+
url: memberUrl,
|
|
525
|
+
token: body.token,
|
|
526
|
+
});
|
|
527
|
+
// 5 wrong passwords bans the source IP (48h) — see createBansService.
|
|
528
|
+
if (bans) bans.clear(ip);
|
|
529
|
+
// First join seeds the leader's own entry using the addr the joiner saw,
|
|
530
|
+
// so -creategroup needs no address argument.
|
|
531
|
+
if (!members.leader) {
|
|
532
|
+
const leaderUrl = String(body.leaderUrl || "").replace(/\/+$/, "");
|
|
533
|
+
if (leaderUrl) {
|
|
534
|
+
groups.upsertMember(body.name, { memberName: "leader", url: leaderUrl, token });
|
|
535
|
+
Object.assign(members, { leader: { url: leaderUrl, token } });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Tell the joiner the url we registered them under (source IP + their port),
|
|
539
|
+
// so they can exclude themselves from their own peer list.
|
|
540
|
+
json(res, 200, { object: "group", name: body.name, members, you: { url: memberUrl } });
|
|
541
|
+
} catch (err) {
|
|
542
|
+
return fail(errMsg(err));
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
method: "POST",
|
|
548
|
+
path: "/v1/groups/leave",
|
|
549
|
+
requiresAuth: false,
|
|
550
|
+
handler: async ({ req, res, groups }) => {
|
|
551
|
+
if (!groups) return json(res, 501, { error: "Groups service not configured" });
|
|
552
|
+
let body;
|
|
553
|
+
try {
|
|
554
|
+
body = await readBody(req);
|
|
555
|
+
} catch {
|
|
556
|
+
return json(res, 400, { error: "Invalid JSON body" });
|
|
557
|
+
}
|
|
558
|
+
if (!body?.name) return json(res, 400, { error: "group name is required" });
|
|
559
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
560
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
561
|
+
const group = groups.list()[body.name];
|
|
562
|
+
if (!group) return json(res, 404, { error: `group "${body.name}" not found` });
|
|
563
|
+
const hit = groups.membersForToken(body.name, auth[1]);
|
|
564
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
565
|
+
try {
|
|
566
|
+
const removed = groups.removeMember(body.name, { url: hit.member.url });
|
|
567
|
+
return json(res, 200, {
|
|
568
|
+
object: "group",
|
|
569
|
+
name: body.name,
|
|
570
|
+
removed: removed?.removed ?? null,
|
|
571
|
+
members: groups.list()[body.name]?.members ?? {},
|
|
572
|
+
});
|
|
573
|
+
} catch (err) {
|
|
574
|
+
return json(res, 400, { error: errMsg(err) });
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
method: "GET",
|
|
580
|
+
path: "/v1/models",
|
|
581
|
+
requiresAuth: true,
|
|
582
|
+
handler: async ({ res, models }) => {
|
|
583
|
+
if (!models) return json(res, 501, { error: "Models service not configured" });
|
|
584
|
+
try {
|
|
585
|
+
const data = await models.get();
|
|
586
|
+
json(res, 200, data);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
json(res, 502, { error: errMsg(err) });
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
method: "GET",
|
|
594
|
+
path: "/v1/models/status",
|
|
595
|
+
requiresAuth: true,
|
|
596
|
+
handler: async ({ res, models, auto }) => {
|
|
597
|
+
const statuses = auto?.statuses?.() || {};
|
|
598
|
+
let ids = [];
|
|
599
|
+
try {
|
|
600
|
+
ids = (await models?.get?.())?.data?.map((m) => m.id) || [];
|
|
601
|
+
} catch {
|
|
602
|
+
// models list unavailable; fall back to status records only
|
|
603
|
+
}
|
|
604
|
+
const seen = new Set();
|
|
605
|
+
const data = [];
|
|
606
|
+
for (const id of [...ids, ...Object.keys(statuses)]) {
|
|
607
|
+
if (seen.has(id)) continue;
|
|
608
|
+
seen.add(id);
|
|
609
|
+
const e = statuses[id];
|
|
610
|
+
const entry = typeof e === "number"
|
|
611
|
+
? { id, status: "error", at: e }
|
|
612
|
+
: { id, status: e?.status || "normal", at: e?.at ?? null, code: e?.code ?? null };
|
|
613
|
+
data.push(entry);
|
|
614
|
+
}
|
|
615
|
+
json(res, 200, { object: "list", data });
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
];
|
package/src/server.js
CHANGED
|
@@ -33,9 +33,13 @@ export function startServer({ router, signals = true }, port = resolvePort()) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function resolvePort() {
|
|
36
|
+
// 8989 is the hard default. Port only changes via an explicit, mslxdff-owned
|
|
37
|
+
// override: the persisted `-port N` setting, or the MSLXDFF_PORT env var.
|
|
38
|
+
// Bare `PORT` is deliberately NOT read — an ssh session / wrapper script
|
|
39
|
+
// commonly injects it and would silently override the default.
|
|
36
40
|
const persisted = getPort();
|
|
37
41
|
if (persisted) return persisted;
|
|
38
|
-
const env = Number(process.env.
|
|
42
|
+
const env = Number(process.env.MSLXDFF_PORT);
|
|
39
43
|
// 0 = OS-assigned ephemeral port (valid; used by tests/containers)
|
|
40
44
|
if (Number.isInteger(env) && env >= 0) return env;
|
|
41
45
|
return DEFAULT_PORT;
|
package/src/upstream.js
CHANGED
|
@@ -4,10 +4,10 @@ export function createUpstreamClient({
|
|
|
4
4
|
connectTimeoutMs = Number(process.env.UPSTREAM_CONNECT_TIMEOUT_MS) || 30_000,
|
|
5
5
|
retry = {
|
|
6
6
|
network: { attempts: 2, delayMs: 1000 },
|
|
7
|
-
429: { attempts:
|
|
8
|
-
502: { attempts:
|
|
9
|
-
503: { attempts:
|
|
10
|
-
504: { attempts:
|
|
7
|
+
429: { attempts: 1, delayMs: 500 },
|
|
8
|
+
502: { attempts: 1, delayMs: 500 },
|
|
9
|
+
503: { attempts: 1, delayMs: 500 },
|
|
10
|
+
504: { attempts: 1, delayMs: 500 },
|
|
11
11
|
},
|
|
12
12
|
fetchImpl = fetch,
|
|
13
13
|
} = {}) {
|
|
@@ -20,21 +20,42 @@ export function createUpstreamClient({
|
|
|
20
20
|
|
|
21
21
|
async function chat(body) {
|
|
22
22
|
const url = `${baseUrl}/zen/v1/chat/completions`;
|
|
23
|
+
const t0 = performance.now();
|
|
24
|
+
const attempts = [];
|
|
25
|
+
let waitMs = 0;
|
|
23
26
|
for (let attempt = 0; ; attempt++) {
|
|
27
|
+
const t = performance.now();
|
|
24
28
|
const result = await attemptOnce(url, body);
|
|
29
|
+
attempts.push({
|
|
30
|
+
attempt,
|
|
31
|
+
type: result instanceof Error ? "network" : `http${result.status}`,
|
|
32
|
+
ms: Math.round(performance.now() - t),
|
|
33
|
+
});
|
|
25
34
|
if (result instanceof Error) {
|
|
26
35
|
const entry = retry?.network;
|
|
27
36
|
if (entry && attempt < entry.attempts) {
|
|
28
37
|
await sleep(entry.delayMs);
|
|
38
|
+
waitMs += entry.delayMs;
|
|
29
39
|
continue;
|
|
30
40
|
}
|
|
41
|
+
result._t = {
|
|
42
|
+
attempts,
|
|
43
|
+
waitMs,
|
|
44
|
+
totalMs: Math.round(performance.now() - t0),
|
|
45
|
+
};
|
|
31
46
|
throw result;
|
|
32
47
|
}
|
|
33
48
|
const entry = retry?.[result.status];
|
|
34
49
|
if (entry && attempt < entry.attempts) {
|
|
35
50
|
await sleep(entry.delayMs);
|
|
51
|
+
waitMs += entry.delayMs;
|
|
36
52
|
continue;
|
|
37
53
|
}
|
|
54
|
+
result._t = {
|
|
55
|
+
attempts,
|
|
56
|
+
waitMs,
|
|
57
|
+
totalMs: Math.round(performance.now() - t0),
|
|
58
|
+
};
|
|
38
59
|
return result;
|
|
39
60
|
}
|
|
40
61
|
}
|