mslxdff 0.1.27 → 0.1.29
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 +6 -0
- package/package.json +1 -1
- package/src/auto.js +26 -11
- package/src/routes.js +43 -11
package/bin/mslxdff.js
CHANGED
|
@@ -587,6 +587,7 @@ const models = createModelsService({
|
|
|
587
587
|
});
|
|
588
588
|
const auto = createAutoSelector({
|
|
589
589
|
cooldownMs: modelCooldownMs(),
|
|
590
|
+
slowCooldownMs: slowCooldownMs(),
|
|
590
591
|
loadCandidates: async () => {
|
|
591
592
|
try {
|
|
592
593
|
return (await models.get()).data.map((m) => m.id);
|
|
@@ -686,6 +687,11 @@ function modelCooldownMs() {
|
|
|
686
687
|
return Number.isInteger(n) && n > 0 ? n : 60_000;
|
|
687
688
|
}
|
|
688
689
|
|
|
690
|
+
function slowCooldownMs() {
|
|
691
|
+
const n = Number(process.env.MSLXDFF_SLOW_COOLDOWN_MS);
|
|
692
|
+
return Number.isInteger(n) && n > 0 ? n : 5 * 60_000;
|
|
693
|
+
}
|
|
694
|
+
|
|
689
695
|
function peerCooldownMs() {
|
|
690
696
|
const n = Number(process.env.MSLXDFF_PEER_COOLDOWN_MS);
|
|
691
697
|
return Number.isInteger(n) && n > 0 ? n : 30_000;
|
package/package.json
CHANGED
package/src/auto.js
CHANGED
|
@@ -22,19 +22,23 @@ export const MODEL_STATUS = Object.freeze({
|
|
|
22
22
|
|
|
23
23
|
// Legacy modelErrors entries are bare timestamps ({id: ts}); newer ones are
|
|
24
24
|
// objects ({id: {status, at, code}}). Normalize both to an entry object.
|
|
25
|
+
// `slow` flags a model whose last request was slow (over the wall-clock
|
|
26
|
+
// threshold) — those get a longer cooldown so they lie low until they recover.
|
|
25
27
|
function normEntry(e) {
|
|
26
|
-
if (typeof e === "number") return { status: MODEL_STATUS.ERROR, at: e, code: null };
|
|
28
|
+
if (typeof e === "number") return { status: MODEL_STATUS.ERROR, at: e, code: null, slow: false };
|
|
27
29
|
if (e && typeof e === "object") {
|
|
28
30
|
return {
|
|
29
31
|
status: e.status || MODEL_STATUS.ERROR,
|
|
30
32
|
at: typeof e.at === "number" ? e.at : 0,
|
|
31
33
|
code: e.code ?? null,
|
|
34
|
+
slow: Boolean(e.slow),
|
|
32
35
|
};
|
|
33
36
|
}
|
|
34
37
|
return null;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function classifyErrorEvent(evt = {}) {
|
|
41
|
+
if (evt.slow) return MODEL_STATUS.ERROR;
|
|
38
42
|
const code = Number(evt.status);
|
|
39
43
|
if (code === 429) return MODEL_STATUS.LIMIT;
|
|
40
44
|
const msg = String(evt.message || evt.note || "").toLowerCase();
|
|
@@ -45,21 +49,29 @@ export function classifyErrorEvent(evt = {}) {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
export const DEFAULT_COOLDOWN_MS = 60_000;
|
|
52
|
+
export const DEFAULT_SLOW_COOLDOWN_MS = 5 * 60_000;
|
|
48
53
|
|
|
49
|
-
function
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
return at > 0 && now - at < cooldownMs;
|
|
54
|
+
function effectiveCooldown(entry, slowCooldownMs, cooldownMs) {
|
|
55
|
+
if (entry && entry.slow) return slowCooldownMs || 0;
|
|
56
|
+
return cooldownMs || 0;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
function inCooldown(id, errors, now, cooldownMs, slowCooldownMs) {
|
|
60
|
+
const e = normEntry(errors[id]);
|
|
61
|
+
if (!e || !(e.at > 0)) return false;
|
|
62
|
+
const cd = effectiveCooldown(e, slowCooldownMs, cooldownMs);
|
|
63
|
+
return cd > 0 && now - e.at < cd;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0, slowCooldownMs = 0 } = {}) {
|
|
56
67
|
return [...new Set(ids)]
|
|
57
68
|
.filter(Boolean)
|
|
58
69
|
.map((id) => ({
|
|
59
70
|
id,
|
|
71
|
+
e: normEntry(errors[id]),
|
|
60
72
|
err: normEntry(errors[id])?.at ?? 0,
|
|
61
73
|
isDeepseek: /deepseek/i.test(id),
|
|
62
|
-
cooling: inCooldown(id, errors, now, cooldownMs),
|
|
74
|
+
cooling: inCooldown(id, errors, now, cooldownMs, slowCooldownMs),
|
|
63
75
|
}))
|
|
64
76
|
.sort(
|
|
65
77
|
(a, b) =>
|
|
@@ -75,6 +87,7 @@ export function createAutoSelector({
|
|
|
75
87
|
file,
|
|
76
88
|
now = () => Date.now(),
|
|
77
89
|
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
90
|
+
slowCooldownMs = DEFAULT_SLOW_COOLDOWN_MS,
|
|
78
91
|
errors: seedErrors,
|
|
79
92
|
persist = (errors, f = file) => saveModelErrors(errors, f ? { file: f } : {}),
|
|
80
93
|
} = {}) {
|
|
@@ -92,7 +105,7 @@ export function createAutoSelector({
|
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
async function candidates() {
|
|
95
|
-
return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs });
|
|
108
|
+
return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs, slowCooldownMs });
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
async function candidatesFor(requested) {
|
|
@@ -102,15 +115,16 @@ export function createAutoSelector({
|
|
|
102
115
|
const others = rankModels(all.filter((id) => id !== requested), lastErrorAt, {
|
|
103
116
|
now: now(),
|
|
104
117
|
cooldownMs,
|
|
118
|
+
slowCooldownMs,
|
|
105
119
|
});
|
|
106
|
-
if (inCooldown(requested, lastErrorAt, now(), cooldownMs)) {
|
|
120
|
+
if (inCooldown(requested, lastErrorAt, now(), cooldownMs, slowCooldownMs)) {
|
|
107
121
|
return [...others, requested];
|
|
108
122
|
}
|
|
109
123
|
return [requested, ...others];
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
function isCooling(id) {
|
|
113
|
-
return inCooldown(id, lastErrorAt, now(), cooldownMs);
|
|
127
|
+
return inCooldown(id, lastErrorAt, now(), cooldownMs, slowCooldownMs);
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
async function recordError(id, evt = {}) {
|
|
@@ -119,13 +133,14 @@ export function createAutoSelector({
|
|
|
119
133
|
status: classifyErrorEvent(evt),
|
|
120
134
|
at: now(),
|
|
121
135
|
code: Number.isInteger(Number(evt.status)) ? Number(evt.status) : null,
|
|
136
|
+
slow: Boolean(evt.slow),
|
|
122
137
|
};
|
|
123
138
|
await persist({ ...lastErrorAt });
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
async function recordOk(id) {
|
|
127
142
|
if (!id) return;
|
|
128
|
-
lastErrorAt[id] = { status: MODEL_STATUS.NORMAL, at: now(), code: 200 };
|
|
143
|
+
lastErrorAt[id] = { status: MODEL_STATUS.NORMAL, at: now(), code: 200, slow: false };
|
|
129
144
|
await persist({ ...lastErrorAt });
|
|
130
145
|
}
|
|
131
146
|
|
package/src/routes.js
CHANGED
|
@@ -84,19 +84,27 @@ async function relay(res, upRes, body, { onFirstChunk, streamTimeoutMs = STREAM_
|
|
|
84
84
|
res.setHeader("Cache-Control", "no-cache");
|
|
85
85
|
res.setHeader("Connection", "keep-alive");
|
|
86
86
|
let ttf = null;
|
|
87
|
+
let interrupted = false;
|
|
87
88
|
if (upRes.body) {
|
|
88
89
|
let first = true;
|
|
89
90
|
let wroteAny = false;
|
|
90
91
|
let timedOut = false;
|
|
91
|
-
|
|
92
|
+
// whole-stream ceiling (TTFB + generation): cancels the upstream body so
|
|
93
|
+
// the loop exits and we proactively end the stream instead of hanging.
|
|
94
|
+
let genTooLong = false;
|
|
95
|
+
const genTimer = GEN_TIMEOUT_MS
|
|
96
|
+
? setTimeout(() => {
|
|
97
|
+
genTooLong = true;
|
|
98
|
+
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
99
|
+
}, GEN_TIMEOUT_MS)
|
|
100
|
+
: null;
|
|
101
|
+
const firstTimer = setTimeout(() => {
|
|
92
102
|
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
103
|
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
96
104
|
}, streamTimeoutMs);
|
|
97
105
|
try {
|
|
98
106
|
for await (const chunk of upRes.body) {
|
|
99
|
-
if (timedOut) break;
|
|
107
|
+
if (timedOut || genTooLong) break;
|
|
100
108
|
if (first) {
|
|
101
109
|
first = false;
|
|
102
110
|
ttf = Math.round(performance.now() - t0);
|
|
@@ -106,20 +114,25 @@ async function relay(res, upRes, body, { onFirstChunk, streamTimeoutMs = STREAM_
|
|
|
106
114
|
res.write(chunk);
|
|
107
115
|
}
|
|
108
116
|
} catch (err) {
|
|
109
|
-
|
|
117
|
+
// body threw — if we already started, treat as a mid-stream interrupt
|
|
118
|
+
if (!wroteAny) { timedOut = true; genTooLong = false; }
|
|
110
119
|
} finally {
|
|
111
|
-
clearTimeout(
|
|
120
|
+
if (firstTimer) clearTimeout(firstTimer);
|
|
121
|
+
if (genTimer) clearTimeout(genTimer);
|
|
112
122
|
}
|
|
113
123
|
if (timedOut && !wroteAny) {
|
|
114
124
|
// nothing written to res yet — safe to drop this model and let the
|
|
115
125
|
// caller fail over to the next one. Do NOT write/end res here.
|
|
116
126
|
return { status: STREAM_TIMEOUT_MS, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true };
|
|
117
127
|
}
|
|
118
|
-
if (timedOut && wroteAny) {
|
|
119
|
-
// we'd already started streaming when it
|
|
120
|
-
//
|
|
128
|
+
if (genTooLong || (timedOut && wroteAny)) {
|
|
129
|
+
// we'd already started streaming when it exceeded the ceiling — can't
|
|
130
|
+
// cleanly fail over a half-written response, just end it so the client
|
|
131
|
+
// sees a clean EOF rather than hanging. interrupted signals the caller
|
|
132
|
+
// to remember this model as slow.
|
|
133
|
+
interrupted = true;
|
|
121
134
|
try { res.end(); } catch { /* ignore */ }
|
|
122
|
-
return { status: 200, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false };
|
|
135
|
+
return { status: 200, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted };
|
|
123
136
|
}
|
|
124
137
|
}
|
|
125
138
|
const totalMs = Math.round(performance.now() - t0);
|
|
@@ -244,7 +257,7 @@ export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
|
|
|
244
257
|
// Set MSLXDFF_SLOW_TOTAL_MS=0 to disable.
|
|
245
258
|
export const SLOW_TOTAL_MS = (() => {
|
|
246
259
|
const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
|
|
247
|
-
return Number.isInteger(n) && n > 0 ? n :
|
|
260
|
+
return Number.isInteger(n) && n > 0 ? n : 20_000;
|
|
248
261
|
})();
|
|
249
262
|
|
|
250
263
|
// How long to wait for the first chunk of a streamed response before giving up
|
|
@@ -255,6 +268,15 @@ export const STREAM_TIMEOUT_MS = (() => {
|
|
|
255
268
|
return Number.isInteger(n) && n > 0 ? n : 25_000;
|
|
256
269
|
})();
|
|
257
270
|
|
|
271
|
+
// Ceiling on the total wall-clock a single streamed response may run (TTFB +
|
|
272
|
+
// generation + relay). Once exceeded we proactively end the stream so the
|
|
273
|
+
// client gets a clean EOF instead of hanging on a very slow model; the model
|
|
274
|
+
// is then flagged slow and demoted for the next request. Set to 0 to disable.
|
|
275
|
+
export const GEN_TIMEOUT_MS = (() => {
|
|
276
|
+
const n = Number(process.env.MSLXDFF_GEN_TIMEOUT_MS);
|
|
277
|
+
return Number.isInteger(n) && n > 0 ? n : 20_000;
|
|
278
|
+
})();
|
|
279
|
+
|
|
258
280
|
async function racePeerCandidates(candidates, ctx) {
|
|
259
281
|
for (let i = 0; i < candidates.length; i += PEER_RACE_LIMIT) {
|
|
260
282
|
const batch = candidates.slice(i, i + PEER_RACE_LIMIT);
|
|
@@ -414,6 +436,16 @@ const ROUTES = [
|
|
|
414
436
|
upRes = null;
|
|
415
437
|
continue;
|
|
416
438
|
}
|
|
439
|
+
if (out.interrupted) {
|
|
440
|
+
// the whole stream ran past GEN_TIMEOUT_MS — we ended it proactively
|
|
441
|
+
// so the client got a clean EOF instead of hanging. Remember this
|
|
442
|
+
// model as slow so the next request prefers a faster one.
|
|
443
|
+
if (auto) await auto.recordError(model, { status: 200, slow: true, note: `gen timeout ${GEN_TIMEOUT_MS}ms` });
|
|
444
|
+
evt("slow-model", { model, elapsedMs: out.totalMs ?? (Date.now() - startedAt), threshold: GEN_TIMEOUT_MS, interrupted: true });
|
|
445
|
+
logCall(model, 200);
|
|
446
|
+
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
417
449
|
// A model that took a long wall-clock time (TTFB + generation + relay)
|
|
418
450
|
// gets remembered as slow so the next request prefers a faster one.
|
|
419
451
|
const elapsed = Date.now() - startedAt;
|