mslxdff 0.1.26 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
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
- async function relay(res, upRes, body) {
69
- const contentType = upRes.headers.get("content-type") || "";
70
- const isStream = Boolean(body?.stream) || contentType.includes("text/event-stream");
71
- res.statusCode = upRes.status;
72
-
73
- if (isStream) {
74
- res.setHeader("Content-Type", "text/event-stream");
75
- res.setHeader("Cache-Control", "no-cache");
76
- res.setHeader("Connection", "keep-alive");
77
- if (upRes.body) {
78
- for await (const chunk of upRes.body) {
79
- res.write(chunk);
80
- }
81
- }
82
- res.end();
83
- return;
84
- }
85
-
86
- const text = await upRes.text();
87
- try {
88
- json(res, upRes.status, JSON.parse(text));
89
- } catch {
90
- res.statusCode = upRes.status;
91
- res.setHeader("Content-Type", contentType || "text/plain");
92
- res.end(text);
93
- }
94
- }
95
-
96
- const PEER_TIMEOUT_MS = 30_000;
97
- const PEER_STATUS_TIMEOUT_MS = 2_000;
98
-
99
- // Ask a peer which of its models are healthy (status normal or never
100
- // failed). Returns model ids ordered as the peer listed them; empty when the
101
- // peer is unreachable, unauthorized, or has no healthy model.
102
- export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = fetch } = {}) {
103
- try {
104
- const res = await fetchImpl(`${peer.url}/v1/models/status`, {
105
- headers: {
106
- "Authorization": `Bearer ${peer.token || ""}`,
107
- "Accept": "application/json",
108
- },
109
- signal: AbortSignal.timeout(timeoutMs),
110
- });
111
- if (!res.ok) return [];
112
- const json = await res.json().catch(() => ({}));
113
- return (json.data || [])
114
- .filter((m) => m && typeof m.id === "string" && m.status === "normal")
115
- .map((m) => m.id);
116
- } catch {
117
- return [];
118
- }
119
- }
120
-
121
- export const PROMPT_MAX_LEN = 160;
122
-
123
- // Human-debuggable summary of the request body: the last non-empty message
124
- // text (multi-modal parts joined), whitespace-flattened and truncated.
125
- export function summarizePrompt(body) {
126
- const msgs = body?.messages;
127
- if (!Array.isArray(msgs) || !msgs.length) return "";
128
- const msg = msgs[msgs.length - 1];
129
- const c = msg?.content;
130
- let text = "";
131
- if (typeof c === "string") text = c;
132
- else if (Array.isArray(c)) {
133
- text = c
134
- .map((p) => (typeof p === "string" ? p : p && typeof p.text === "string" ? p.text : ""))
135
- .join(" ");
136
- }
137
- text = String(text || "").replace(/\s+/g, " ").trim();
138
- return text.length > PROMPT_MAX_LEN ? text.slice(0, PROMPT_MAX_LEN) + "…" : text;
139
- }
140
-
141
- function parseHops(header) {
142
- const n = Number(header);
143
- return Number.isInteger(n) && n >= 0 ? n : 0;
144
- }
145
-
146
- async function forwardToPeer(peer, body, model, hops) {
147
- const controller = new AbortController();
148
- const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
149
- try {
150
- return await fetch(`${peer.url}/v1/chat/completions`, {
151
- method: "POST",
152
- headers: {
153
- "Content-Type": "application/json",
154
- "Authorization": `Bearer ${peer.token}`,
155
- "x-mslxdff-hops": String(hops + 1),
156
- "x-mslxdff-model-lock": model,
157
- "Accept": "text/event-stream",
158
- },
159
- body: JSON.stringify({ ...body, model }),
160
- signal: controller.signal,
161
- });
162
- } catch (err) {
163
- return err;
164
- } finally {
165
- clearTimeout(timer);
166
- }
167
- }
168
-
169
- // Resolve the model a peer should serve for this request: reuse its hot-cache
170
- // model only when it matches the requested one; otherwise probe /v1/models/status
171
- // and prefer the requested model, falling back to the peer's first healthy one.
172
- // Returns { peer, target } or null when the peer is unusable.
173
- async function resolvePeerTarget(ctx, peer) {
174
- const prevModel = ctx.peers.stat(peer.url)?.model;
175
- const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
176
- if (hot) return { peer, target: prevModel };
177
- const healthy = await peerHealthyModels(peer);
178
- if (!healthy.length) {
179
- // peer unreachable or every model unhealthy mark it and move on
180
- await ctx.peers.recordError(peer.url);
181
- ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
182
- ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
183
- return null;
184
- }
185
- ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
186
- return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
187
- }
188
-
189
- // Race a batch of candidates: up to PEER_RACE_LIMIT at a time, first success
190
- // wins. Uses ctx.model/body/hops/peers to resolve targets and forward. Retries
191
- // remaining candidates in subsequent batches. Returns the winning
192
- // { peer, target, res, latencyMs } or null when everyone failed.
193
- export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
194
- ? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
195
- : 3;
196
-
197
- async function racePeerCandidates(candidates, ctx) {
198
- for (let i = 0; i < candidates.length; i += PEER_RACE_LIMIT) {
199
- const batch = candidates.slice(i, i + PEER_RACE_LIMIT);
200
- // resolve targets for this batch first (may probe), then fire them together
201
- const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
202
- if (!prepared.length) continue;
203
- // fire every peer in the batch concurrently and record completion order —
204
- // the first one to succeed wins the race
205
- const completed = await new Promise((resolve) => {
206
- const order = [];
207
- const total = prepared.length;
208
- for (const { peer, target } of prepared) {
209
- const t0 = performance.now();
210
- forwardToPeer(peer, ctx.body, target, ctx.hops).then((res) => {
211
- const latencyMs = Math.round(performance.now() - t0);
212
- const failed = res instanceof Error || res.status >= 400;
213
- ctx.evt("peer-forward", { peer: peer.url, model: target, hops: ctx.hops + 1 });
214
- if (failed) {
215
- const status = res instanceof Error ? 502 : res.status;
216
- ctx.logError(ctx.model,
217
- status,
218
- res instanceof Error ? errMsg(res) : `peer ${status}`);
219
- ctx.evt("peer-error", {
220
- peer: peer.url,
221
- model: target,
222
- status,
223
- message: res instanceof Error ? errMsg(res) : null,
224
- });
225
- order.push({ ok: false, peer, target, res, status });
226
- } else {
227
- order.push({ ok: true, peer, target, res, latencyMs });
228
- }
229
- if (order.length === total) resolve(order);
230
- });
231
- }
232
- });
233
- const winner = completed.find((o) => o.ok);
234
- if (winner) {
235
- // every other responder also gets its memory cleared and its stats warmed
236
- // so it becomes a candidate next time too
237
- for (const o of completed) {
238
- if (o === winner) continue;
239
- if (!o.ok) {
240
- await ctx.peers.recordError(o.peer.url);
241
- await ctx.peers.recordResult(o.peer.url, { ok: false });
242
- } else {
243
- await ctx.peers.recordResult(o.peer.url, { ok: true, latencyMs: o.latencyMs, model: o.target });
244
- }
245
- }
246
- return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
247
- }
248
- for (const o of completed) {
249
- await ctx.peers.recordError(o.peer.url);
250
- await ctx.peers.recordResult(o.peer.url, { ok: false });
251
- }
252
- }
253
- return null;
254
- }
255
-
256
- const ROUTES = [
257
- {
258
- method: "GET",
259
- path: "/health",
260
- handler: ({ res }) => json(res, 200, { status: "ok" }),
261
- },
262
- {
263
- method: "POST",
264
- path: "/v1/chat/completions",
265
- requiresAuth: true,
266
- handler: async ({ req, res, upstream, auto, logs, peers, maxHops, bus }) => {
267
- let body;
268
- try {
269
- body = await readBody(req);
270
- } catch {
271
- return json(res, 400, { error: "Invalid JSON body" });
272
- }
273
-
274
- const startedAt = Date.now();
275
- const hops = parseHops(req.headers["x-mslxdff-hops"]);
276
- const lockModel = req.headers["x-mslxdff-model-lock"] || "";
277
- const requested = normalizeModel(lockModel || body.model || "");
278
- const useAuto = isAutoModel(requested);
279
-
280
- let order;
281
- if (lockModel) {
282
- order = [requested];
283
- } else if (useAuto) {
284
- order = auto ? await auto.candidates() : [""];
285
- } else {
286
- order = auto ? await auto.candidatesFor(requested) : [requested];
287
- }
288
- if (!order.length) order = [""];
289
- const canFallback = order.length > 1;
290
- const canForwardPeers = Boolean(peers) && hops < maxHops;
291
-
292
- const logCall = (model, status) =>
293
- logs?.appendCall({ model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(body.stream) });
294
- const logError = (model, status, message) =>
295
- logs?.appendError({ model, auto: useAuto, status, message });
296
- const evt = (type, data) => {
297
- const entry = { ts: Date.now(), type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt };
298
- if (bus) bus.emit(entry);
299
- logs?.appendEvent?.(entry);
300
- };
301
- evt("request", { hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body) });
302
-
303
- // Shared context for the peer race helpers below (each model iteration
304
- // reuses it; `model` is bound per iteration call).
305
- const handlerCtx = {
306
- model: null,
307
- body,
308
- hops,
309
- peers,
310
- evt,
311
- logError,
312
- logCall,
313
- };
314
-
315
- let lastErr = null;
316
- for (const model of order) {
317
- handlerCtx.model = model;
318
- let upRes = null;
319
- const forwarded = { ...injectReasoningContent(model, body), model };
320
- try {
321
- upRes = await upstream.chat(forwarded);
322
- } catch (err) {
323
- if (auto) await auto.recordError(model, { message: errMsg(err) });
324
- lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
325
- logError(model, 502, errMsg(err));
326
- evt("upstream-error", { model, status: 502, message: errMsg(err) });
327
- }
328
- if (upRes && upRes.status >= 400) {
329
- if (auto) await auto.recordError(model, { status: upRes.status });
330
- lastErr = { model, upstream: upRes, status: upRes.status, message: null };
331
- logError(model, upRes.status, `upstream ${upRes.status}`);
332
- evt("upstream-error", { model, status: upRes.status, message: null });
333
- upRes = null;
334
- }
335
- if (upRes) {
336
- if (auto) await auto.recordOk(model);
337
- logCall(model, upRes.status);
338
- evt("result", { model, status: upRes.status, via: "local" });
339
- return relay(res, upRes, body);
340
- }
341
-
342
- // local failed for this model: race the peers — send the request to
343
- // up to N of them in parallel, first success wins (the winner's model
344
- // is remembered for the next call). If every candidate fails, retry
345
- // once ordered by recovery time (earliest failure first), which
346
- // favours the peer that has had the longest to come back.
347
- if (canForwardPeers) {
348
- const win =
349
- (await racePeerCandidates(peers.ordered(), handlerCtx)) ||
350
- (await racePeerCandidates(peers.orderedByLastError(), handlerCtx));
351
- if (win) {
352
- await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
353
- logCall(win.target, win.res.status);
354
- evt("result", { model: win.target, status: win.res.status, via: "peer" });
355
- return relay(res, win.res, body);
356
- }
357
- }
358
-
359
- if (canFallback) continue;
360
- logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
361
- if (lastErr?.upstream) {
362
- evt("result", { model: lastErr.model, status: lastErr.status, via: "local" });
363
- return relay(res, lastErr.upstream, body);
364
- }
365
- evt("result", { model, status: lastErr?.status ?? 502, via: "none" });
366
- return json(res, 502, { error: lastErr?.message || "all auto models failed" });
367
- }
368
-
369
- logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
370
- if (lastErr?.upstream) {
371
- evt("result", { model: lastErr.model, status: lastErr.status, via: "local" });
372
- return relay(res, lastErr.upstream, body);
373
- }
374
- evt("result", { model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none" });
375
- return json(res, 502, { error: lastErr?.message || "all auto models failed" });
376
- },
377
- },
378
- {
379
- method: "POST",
380
- path: "/v1/groups/join",
381
- requiresAuth: false,
382
- handler: async ({ req, res, groups, token, bans }) => {
383
- if (!groups) return json(res, 501, { error: "Groups service not configured" });
384
- const ip = clientIp(req);
385
- const banned = bans?.isBanned(ip);
386
- if (banned) {
387
- return json(res, 403, { error: `banned until ${new Date(banned.until).toISOString()}` });
388
- }
389
- let body;
390
- try {
391
- body = await readBody(req);
392
- } catch {
393
- return json(res, 400, { error: "Invalid JSON body" });
394
- }
395
- if (!body?.name) return json(res, 400, { error: "group name is required" });
396
-
397
- const fail = (msg) => {
398
- try {
399
- if (bans) {
400
- const b = bans.recordFailure(ip);
401
- if (b) console.error(`${ip} banned (${bans.threshold} failed joins)`);
402
- }
403
- } catch {
404
- // ban bookkeeping must never break the join endpoint
405
- }
406
- return json(res, 403, { error: msg });
407
- };
408
-
409
- // Already-registered members re-register (sync) using their bearer token;
410
- // new members must present the group name (which IS the password).
411
- if (!body.key) {
412
- const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
413
- const hit = auth && groups.membersForToken(body.name, auth[1]);
414
- if (!hit) return fail("invalid member token");
415
- try {
416
- const refreshed = groups.upsertMember(body.name, {
417
- memberName: body.memberName,
418
- url: body.url,
419
- token: body.token,
420
- });
421
- return json(res, 200, { object: "group", name: body.name, members: refreshed });
422
- } catch (err) {
423
- return json(res, 400, { error: errMsg(err) });
424
- }
425
- }
426
-
427
- try {
428
- const youPort = Number(body.myPort);
429
- const youUrl = Number.isInteger(youPort) && youPort > 0 ? `http://${ip}:${youPort}` : "";
430
- const memberUrl = String(body.url || youUrl);
431
- if (!memberUrl) throw new Error("member url is required");
432
- const members = groups.addMember(body.name, {
433
- key: body.key,
434
- memberName: body.memberName,
435
- url: memberUrl,
436
- token: body.token,
437
- });
438
- // 5 wrong passwords bans the source IP (48h) — see createBansService.
439
- if (bans) bans.clear(ip);
440
- // First join seeds the leader's own entry using the addr the joiner saw,
441
- // so -creategroup needs no address argument.
442
- if (!members.leader) {
443
- const leaderUrl = String(body.leaderUrl || "").replace(/\/+$/, "");
444
- if (leaderUrl) {
445
- groups.upsertMember(body.name, { memberName: "leader", url: leaderUrl, token });
446
- Object.assign(members, { leader: { url: leaderUrl, token } });
447
- }
448
- }
449
- // Tell the joiner the url we registered them under (source IP + their port),
450
- // so they can exclude themselves from their own peer list.
451
- json(res, 200, { object: "group", name: body.name, members, you: { url: memberUrl } });
452
- } catch (err) {
453
- return fail(errMsg(err));
454
- }
455
- },
456
- },
457
- {
458
- method: "POST",
459
- path: "/v1/groups/leave",
460
- requiresAuth: false,
461
- handler: async ({ req, res, groups }) => {
462
- if (!groups) return json(res, 501, { error: "Groups service not configured" });
463
- let body;
464
- try {
465
- body = await readBody(req);
466
- } catch {
467
- return json(res, 400, { error: "Invalid JSON body" });
468
- }
469
- if (!body?.name) return json(res, 400, { error: "group name is required" });
470
- const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
471
- if (!auth) return json(res, 401, { error: "bearer token required" });
472
- const group = groups.list()[body.name];
473
- if (!group) return json(res, 404, { error: `group "${body.name}" not found` });
474
- const hit = groups.membersForToken(body.name, auth[1]);
475
- if (!hit) return json(res, 403, { error: "invalid member token" });
476
- try {
477
- const removed = groups.removeMember(body.name, { url: hit.member.url });
478
- return json(res, 200, {
479
- object: "group",
480
- name: body.name,
481
- removed: removed?.removed ?? null,
482
- members: groups.list()[body.name]?.members ?? {},
483
- });
484
- } catch (err) {
485
- return json(res, 400, { error: errMsg(err) });
486
- }
487
- },
488
- },
489
- {
490
- method: "GET",
491
- path: "/v1/models",
492
- requiresAuth: true,
493
- handler: async ({ res, models }) => {
494
- if (!models) return json(res, 501, { error: "Models service not configured" });
495
- try {
496
- const data = await models.get();
497
- json(res, 200, data);
498
- } catch (err) {
499
- json(res, 502, { error: errMsg(err) });
500
- }
501
- },
502
- },
503
- {
504
- method: "GET",
505
- path: "/v1/models/status",
506
- requiresAuth: true,
507
- handler: async ({ res, models, auto }) => {
508
- const statuses = auto?.statuses?.() || {};
509
- let ids = [];
510
- try {
511
- ids = (await models?.get?.())?.data?.map((m) => m.id) || [];
512
- } catch {
513
- // models list unavailable; fall back to status records only
514
- }
515
- const seen = new Set();
516
- const data = [];
517
- for (const id of [...ids, ...Object.keys(statuses)]) {
518
- if (seen.has(id)) continue;
519
- seen.add(id);
520
- const e = statuses[id];
521
- const entry = typeof e === "number"
522
- ? { id, status: "error", at: e }
523
- : { id, status: e?.status || "normal", at: e?.at ?? null, code: e?.code ?? null };
524
- data.push(entry);
525
- }
526
- json(res, 200, { object: "list", data });
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/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: 2, delayMs: 2000 },
8
- 502: { attempts: 2, delayMs: 2000 },
9
- 503: { attempts: 2, delayMs: 2000 },
10
- 504: { attempts: 2, delayMs: 3000 },
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
  }