mslxdff 0.1.30 → 0.1.32
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 +53 -0
- package/package.json +1 -1
- package/src/auto.js +41 -4
- package/src/routes.js +181 -65
- package/src/state.js +11 -0
package/bin/mslxdff.js
CHANGED
|
@@ -664,6 +664,49 @@ const groupSyncTimer = setInterval(() => {
|
|
|
664
664
|
}, groupSyncIntervalMs());
|
|
665
665
|
groupSyncTimer.unref();
|
|
666
666
|
|
|
667
|
+
// Auto-update: periodically check npm for a newer mslxdff and restart.
|
|
668
|
+
// Enable with MSLXDFF_AUTO_UPDATE=1 (hourly) or MSLXDFF_AUTO_UPDATE_MS=<ms>.
|
|
669
|
+
// Uses the same npm view/install path as `mslxdff -update`, but runs inside
|
|
670
|
+
// the daemon so no manual intervention is needed.
|
|
671
|
+
const autoUpdateMs = autoUpdateIntervalMs();
|
|
672
|
+
if (autoUpdateMs) {
|
|
673
|
+
console.log(`auto-update enabled: checking every ${Math.round(autoUpdateMs / 60000)}m`);
|
|
674
|
+
const autoUpdateTimer = setInterval(() => {
|
|
675
|
+
checkAndAutoUpdate().catch((err) => console.log(`auto-update check failed: ${errMsg(err)}`));
|
|
676
|
+
}, autoUpdateMs);
|
|
677
|
+
autoUpdateTimer.unref();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function checkAndAutoUpdate() {
|
|
681
|
+
const info = await run(npmCmd(), ["view", "mslxdff", "version", "dist-tags.latest"]);
|
|
682
|
+
if (info.err) throw new Error(info.err.message);
|
|
683
|
+
const parts = (info.stdout || "").trim().split(/\s+/).filter(Boolean);
|
|
684
|
+
const latest = parts[parts.length - 1];
|
|
685
|
+
if (!latest || latest === VERSION) return;
|
|
686
|
+
// simple semver compare: skip if latest is not newer
|
|
687
|
+
if (compareSemver(latest, VERSION) <= 0) return;
|
|
688
|
+
console.log(`auto-update: v${VERSION} -> v${latest}, installing...`);
|
|
689
|
+
const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`]);
|
|
690
|
+
if (up.err) throw new Error(up.err.message);
|
|
691
|
+
console.log(`auto-update: installed v${latest}, restarting daemon...`);
|
|
692
|
+
try { stopDaemon(); } catch {}
|
|
693
|
+
// startDaemon re-reads VERSION from the newly installed package on next boot;
|
|
694
|
+
// for the current process we just respawn with the new code.
|
|
695
|
+
const newPid = startDaemon([]);
|
|
696
|
+
await waitForHealth(resolvePort(), 8000);
|
|
697
|
+
console.log(`auto-update: restarted as v${latest} (pid ${newPid})`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function compareSemver(a, b) {
|
|
701
|
+
const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
|
|
702
|
+
const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
|
|
703
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
704
|
+
const av = pa[i] || 0, bv = pb[i] || 0;
|
|
705
|
+
if (av !== bv) return av - bv;
|
|
706
|
+
}
|
|
707
|
+
return 0;
|
|
708
|
+
}
|
|
709
|
+
|
|
667
710
|
function argValue(...names) {
|
|
668
711
|
for (let i = 0; i < args.length; i++) {
|
|
669
712
|
if (names.includes(args[i])) return args[i + 1];
|
|
@@ -712,6 +755,14 @@ function groupSyncIntervalMs() {
|
|
|
712
755
|
return Number.isInteger(n) && n > 0 ? n : 60_000;
|
|
713
756
|
}
|
|
714
757
|
|
|
758
|
+
function autoUpdateIntervalMs() {
|
|
759
|
+
const raw = process.env.MSLXDFF_AUTO_UPDATE_MS ?? process.env.MSLXDFF_AUTO_UPDATE;
|
|
760
|
+
if (raw === undefined || raw === null || raw === "") return 0;
|
|
761
|
+
if (raw === "1" || String(raw).toLowerCase() === "true") return 60 * 60 * 1000;
|
|
762
|
+
const n = Number(raw);
|
|
763
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
715
766
|
function banWindowMs() {
|
|
716
767
|
const n = Number(process.env.MSLXDFF_BAN_WINDOW_MS);
|
|
717
768
|
return Number.isInteger(n) && n > 0 ? n : 48 * 60 * 60 * 1000;
|
|
@@ -778,6 +829,8 @@ Environment:
|
|
|
778
829
|
MSLXDFF_MAX_HOPS max peer-forwarding depth (default 3)
|
|
779
830
|
MSLXDFF_BAN_THRESHOLD failed joins before an ip is banned (default 5)
|
|
780
831
|
MSLXDFF_BAN_WINDOW_MS ban duration after too many failures (default 48h)
|
|
832
|
+
MSLXDFF_AUTO_UPDATE auto-update: 1/true=hourly, or ms interval (0=off)
|
|
833
|
+
MSLXDFF_AUTO_UPDATE_MS same as above, explicit ms (overrides AUTO_UPDATE)
|
|
781
834
|
`);
|
|
782
835
|
}
|
|
783
836
|
|
package/package.json
CHANGED
package/src/auto.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadModelErrors, saveModelErrors } from "./state.js";
|
|
1
|
+
import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies } from "./state.js";
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_AUTO_MODELS = [
|
|
4
4
|
"deepseek-v4-flash-free",
|
|
@@ -50,6 +50,7 @@ export function classifyErrorEvent(evt = {}) {
|
|
|
50
50
|
|
|
51
51
|
export const DEFAULT_COOLDOWN_MS = 60_000;
|
|
52
52
|
export const DEFAULT_SLOW_COOLDOWN_MS = 5 * 60_000;
|
|
53
|
+
export const DEFAULT_LATENCY_ALPHA = 0.3;
|
|
53
54
|
|
|
54
55
|
function effectiveCooldown(entry, slowCooldownMs, cooldownMs) {
|
|
55
56
|
if (entry && entry.slow) return slowCooldownMs || 0;
|
|
@@ -63,7 +64,14 @@ function inCooldown(id, errors, now, cooldownMs, slowCooldownMs) {
|
|
|
63
64
|
return cd > 0 && now - e.at < cd;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
// Latency EMA helpers
|
|
68
|
+
function normLatency(e) {
|
|
69
|
+
if (!e || typeof e !== "object") return null;
|
|
70
|
+
const ema = Number(e.emaMs);
|
|
71
|
+
return Number.isFinite(ema) && ema > 0 ? ema : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0, slowCooldownMs = 0, latencies = {} } = {}) {
|
|
67
75
|
return [...new Set(ids)]
|
|
68
76
|
.filter(Boolean)
|
|
69
77
|
.map((id) => ({
|
|
@@ -72,10 +80,12 @@ export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0,
|
|
|
72
80
|
err: normEntry(errors[id])?.at ?? 0,
|
|
73
81
|
isDeepseek: /deepseek/i.test(id),
|
|
74
82
|
cooling: inCooldown(id, errors, now, cooldownMs, slowCooldownMs),
|
|
83
|
+
latency: normLatency(latencies[id]) ?? Number.MAX_SAFE_INTEGER,
|
|
75
84
|
}))
|
|
76
85
|
.sort(
|
|
77
86
|
(a, b) =>
|
|
78
87
|
(a.cooling ? 1 : 0) - (b.cooling ? 1 : 0) ||
|
|
88
|
+
a.latency - b.latency ||
|
|
79
89
|
a.err - b.err ||
|
|
80
90
|
(b.isDeepseek ? 1 : 0) - (a.isDeepseek ? 1 : 0)
|
|
81
91
|
)
|
|
@@ -88,10 +98,14 @@ export function createAutoSelector({
|
|
|
88
98
|
now = () => Date.now(),
|
|
89
99
|
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
90
100
|
slowCooldownMs = DEFAULT_SLOW_COOLDOWN_MS,
|
|
101
|
+
latencyAlpha = DEFAULT_LATENCY_ALPHA,
|
|
91
102
|
errors: seedErrors,
|
|
103
|
+
latencies: seedLatencies,
|
|
92
104
|
persist = (errors, f = file) => saveModelErrors(errors, f ? { file: f } : {}),
|
|
105
|
+
persistLatencies = (latencies, f = file) => saveModelLatencies(latencies, f ? { file: f } : {}),
|
|
93
106
|
} = {}) {
|
|
94
107
|
const lastErrorAt = { ...(seedErrors ?? loadModelErrors(file ? { file } : {})) };
|
|
108
|
+
const latencies = { ...(seedLatencies ?? loadModelLatencies(file ? { file } : {})) };
|
|
95
109
|
|
|
96
110
|
async function loadList() {
|
|
97
111
|
let list;
|
|
@@ -105,7 +119,7 @@ export function createAutoSelector({
|
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
async function candidates() {
|
|
108
|
-
return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs, slowCooldownMs });
|
|
122
|
+
return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs, slowCooldownMs, latencies });
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
async function candidatesFor(requested) {
|
|
@@ -116,6 +130,7 @@ export function createAutoSelector({
|
|
|
116
130
|
now: now(),
|
|
117
131
|
cooldownMs,
|
|
118
132
|
slowCooldownMs,
|
|
133
|
+
latencies,
|
|
119
134
|
});
|
|
120
135
|
if (inCooldown(requested, lastErrorAt, now(), cooldownMs, slowCooldownMs)) {
|
|
121
136
|
return [...others, requested];
|
|
@@ -138,23 +153,45 @@ export function createAutoSelector({
|
|
|
138
153
|
await persist({ ...lastErrorAt });
|
|
139
154
|
}
|
|
140
155
|
|
|
141
|
-
async function recordOk(id) {
|
|
156
|
+
async function recordOk(id, evt = {}) {
|
|
142
157
|
if (!id) return;
|
|
143
158
|
lastErrorAt[id] = { status: MODEL_STATUS.NORMAL, at: now(), code: 200, slow: false };
|
|
144
159
|
await persist({ ...lastErrorAt });
|
|
160
|
+
const ms = Number(evt.latencyMs ?? evt.totalMs ?? evt.elapsedMs);
|
|
161
|
+
if (Number.isFinite(ms) && ms > 0) {
|
|
162
|
+
const prev = latencies[id]?.emaMs;
|
|
163
|
+
const ema = prev ? Math.round(prev * (1 - latencyAlpha) + ms * latencyAlpha) : Math.round(ms);
|
|
164
|
+
latencies[id] = { emaMs: ema, lastMs: Math.round(ms), at: now(), count: (latencies[id]?.count ?? 0) + 1 };
|
|
165
|
+
await persistLatencies({ ...latencies });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function recordLatency(id, ms) {
|
|
170
|
+
if (!id || !Number.isFinite(ms) || ms <= 0) return;
|
|
171
|
+
const prev = latencies[id]?.emaMs;
|
|
172
|
+
const ema = prev ? Math.round(prev * (1 - latencyAlpha) + ms * latencyAlpha) : Math.round(ms);
|
|
173
|
+
latencies[id] = { emaMs: ema, lastMs: Math.round(ms), at: now(), count: (latencies[id]?.count ?? 0) + 1 };
|
|
174
|
+
await persistLatencies({ ...latencies });
|
|
145
175
|
}
|
|
146
176
|
|
|
147
177
|
function statuses() {
|
|
148
178
|
return { ...lastErrorAt };
|
|
149
179
|
}
|
|
150
180
|
|
|
181
|
+
function latencyStatuses() {
|
|
182
|
+
return { ...latencies };
|
|
183
|
+
}
|
|
184
|
+
|
|
151
185
|
return {
|
|
152
186
|
candidates,
|
|
153
187
|
candidatesFor,
|
|
154
188
|
recordError,
|
|
155
189
|
recordOk,
|
|
190
|
+
recordLatency,
|
|
156
191
|
statuses,
|
|
192
|
+
latencyStatuses,
|
|
157
193
|
isCooling,
|
|
158
194
|
errors: () => ({ ...lastErrorAt }),
|
|
195
|
+
latencies: () => ({ ...latencies }),
|
|
159
196
|
};
|
|
160
197
|
}
|
package/src/routes.js
CHANGED
|
@@ -65,26 +65,44 @@ function readBody(req) {
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
// Relay an upstream response to the client. Returns { status, ttfMs, aborted }
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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 } = {}) {
|
|
68
|
+
// Relay an upstream response to the client. Returns { status, ttfMs, aborted, interrupted, detail }
|
|
69
|
+
// detail carries byte/chunk/sawDone diagnostics so a truncated deep-think
|
|
70
|
+
// stream can be told apart from a clean EOF vs our stall/max vs client abort.
|
|
71
|
+
async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS } = {}) {
|
|
77
72
|
const t0 = performance.now();
|
|
78
73
|
const contentType = upRes.headers.get("content-type") || "";
|
|
79
74
|
const isStream = Boolean(body?.stream) || contentType.includes("text/event-stream");
|
|
80
75
|
res.statusCode = upRes.status;
|
|
81
76
|
|
|
77
|
+
let ttf = null;
|
|
78
|
+
let interrupted = false;
|
|
79
|
+
let finishedNormally = false;
|
|
80
|
+
const detail = {
|
|
81
|
+
receivedChunks: 0,
|
|
82
|
+
receivedBytes: 0,
|
|
83
|
+
wroteChunks: 0,
|
|
84
|
+
wroteBytes: 0,
|
|
85
|
+
sawDone: false,
|
|
86
|
+
sawFinishReason: null,
|
|
87
|
+
lastChunkAtMs: null,
|
|
88
|
+
lastChunkGapMs: null,
|
|
89
|
+
maxGapMs: 0,
|
|
90
|
+
stallHits: 0, // chunks where gap > SCORE_STALL_MS (quality signal, never cuts)
|
|
91
|
+
exitReason: null, // normal | first-timeout | stall | max | upstream-error | downstream-close | empty-body
|
|
92
|
+
upstreamError: null,
|
|
93
|
+
downstreamClosed: false,
|
|
94
|
+
};
|
|
95
|
+
let prevChunkAt = t0;
|
|
96
|
+
const onClose = () => {
|
|
97
|
+
detail.downstreamClosed = true;
|
|
98
|
+
if (!finishedNormally && onDownstreamAbort) onDownstreamAbort();
|
|
99
|
+
};
|
|
100
|
+
res.on("close", onClose);
|
|
101
|
+
|
|
82
102
|
if (isStream) {
|
|
83
103
|
res.setHeader("Content-Type", "text/event-stream");
|
|
84
104
|
res.setHeader("Cache-Control", "no-cache");
|
|
85
105
|
res.setHeader("Connection", "keep-alive");
|
|
86
|
-
let ttf = null;
|
|
87
|
-
let interrupted = false;
|
|
88
106
|
if (upRes.body) {
|
|
89
107
|
let first = true;
|
|
90
108
|
let wroteAny = false;
|
|
@@ -97,34 +115,59 @@ async function relay(res, upRes, body, { onFirstChunk, streamTimeoutMs = STREAM_
|
|
|
97
115
|
stallTimer = STALL_TIMEOUT_MS
|
|
98
116
|
? setTimeout(() => {
|
|
99
117
|
stalled = true;
|
|
118
|
+
detail.exitReason = "stall";
|
|
100
119
|
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
101
120
|
}, STALL_TIMEOUT_MS)
|
|
102
121
|
: null;
|
|
103
122
|
};
|
|
104
|
-
|
|
123
|
+
let firstTimer = setTimeout(() => {
|
|
105
124
|
timedOut = true;
|
|
125
|
+
detail.exitReason = "first-timeout";
|
|
106
126
|
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
107
127
|
}, streamTimeoutMs);
|
|
108
128
|
const maxTimer = MAX_STREAM_MS
|
|
109
129
|
? setTimeout(() => {
|
|
110
130
|
tooLong = true;
|
|
131
|
+
detail.exitReason = "max";
|
|
111
132
|
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
112
133
|
}, MAX_STREAM_MS)
|
|
113
134
|
: null;
|
|
114
135
|
try {
|
|
115
136
|
for await (const chunk of upRes.body) {
|
|
137
|
+
const now = performance.now();
|
|
138
|
+
detail.receivedChunks += 1;
|
|
139
|
+
const len = chunk?.length ?? chunk?.byteLength ?? 0;
|
|
140
|
+
detail.receivedBytes += len;
|
|
141
|
+
const gap = Math.round(now - prevChunkAt);
|
|
142
|
+
detail.lastChunkAtMs = Math.round(now - t0);
|
|
143
|
+
detail.lastChunkGapMs = gap;
|
|
144
|
+
if (gap > detail.maxGapMs) detail.maxGapMs = gap;
|
|
145
|
+
if (gap > SCORE_STALL_MS) detail.stallHits += 1;
|
|
146
|
+
prevChunkAt = now;
|
|
147
|
+
// cheap inspection for diagnostics (no full parse)
|
|
148
|
+
try {
|
|
149
|
+
const txt = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : typeof chunk === "string" ? chunk : "";
|
|
150
|
+
if (txt.includes("[DONE]")) detail.sawDone = true;
|
|
151
|
+
const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
|
|
152
|
+
if (m) detail.sawFinishReason = m[1];
|
|
153
|
+
} catch { /* ignore */ }
|
|
116
154
|
if (timedOut || stalled || tooLong) break;
|
|
117
155
|
if (first) {
|
|
118
156
|
first = false;
|
|
119
|
-
ttf = Math.round(
|
|
157
|
+
ttf = Math.round(now - t0);
|
|
120
158
|
onFirstChunk?.(ttf);
|
|
159
|
+
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
121
160
|
}
|
|
122
161
|
wroteAny = true;
|
|
162
|
+
detail.wroteChunks += 1;
|
|
163
|
+
detail.wroteBytes += len;
|
|
123
164
|
res.write(chunk);
|
|
124
|
-
armStall(); //
|
|
165
|
+
armStall(); // no-op when STALL_TIMEOUT_MS=0; scoring uses SCORE_STALL_MS gap above
|
|
125
166
|
}
|
|
167
|
+
if (!detail.exitReason) detail.exitReason = "normal";
|
|
126
168
|
} catch (err) {
|
|
127
|
-
|
|
169
|
+
detail.upstreamError = String(err?.message || err).slice(0, 300);
|
|
170
|
+
detail.exitReason = "upstream-error";
|
|
128
171
|
if (!wroteAny) timedOut = true;
|
|
129
172
|
else stalled = true;
|
|
130
173
|
} finally {
|
|
@@ -133,25 +176,32 @@ async function relay(res, upRes, body, { onFirstChunk, streamTimeoutMs = STREAM_
|
|
|
133
176
|
if (stallTimer) clearTimeout(stallTimer);
|
|
134
177
|
}
|
|
135
178
|
if (timedOut && !wroteAny) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return { status: STREAM_TIMEOUT_MS, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true };
|
|
179
|
+
res.removeListener("close", onClose);
|
|
180
|
+
return { status: STREAM_TIMEOUT_MS, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, detail };
|
|
139
181
|
}
|
|
140
182
|
if ((stalled || tooLong) && wroteAny) {
|
|
141
|
-
// a response that was flowing either went silent for the stall window
|
|
142
|
-
// or blew the total ceiling — we can't cleanly fail over a half-written
|
|
143
|
-
// body, so end it and let the caller remember this model as slow.
|
|
144
183
|
interrupted = true;
|
|
184
|
+
detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
|
|
185
|
+
res.removeListener("close", onClose);
|
|
145
186
|
try { res.end(); } catch { /* ignore */ }
|
|
146
|
-
return { status: 200, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted };
|
|
187
|
+
return { status: 200, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, detail };
|
|
147
188
|
}
|
|
189
|
+
} else {
|
|
190
|
+
detail.exitReason = "empty-body";
|
|
148
191
|
}
|
|
149
192
|
const totalMs = Math.round(performance.now() - t0);
|
|
193
|
+
if (!detail.exitReason) detail.exitReason = "normal";
|
|
194
|
+
finishedNormally = true;
|
|
195
|
+
res.removeListener("close", onClose);
|
|
150
196
|
try { res.end(); } catch { /* ignore */ }
|
|
151
|
-
return { status: 200, ttfMs: ttf, totalMs, aborted: false };
|
|
197
|
+
return { status: 200, ttfMs: ttf, totalMs, aborted: false, interrupted: false, detail };
|
|
152
198
|
}
|
|
153
199
|
|
|
200
|
+
finishedNormally = true;
|
|
201
|
+
res.removeListener("close", onClose);
|
|
154
202
|
const text = await upRes.text();
|
|
203
|
+
detail.receivedBytes = Buffer.byteLength(text);
|
|
204
|
+
detail.exitReason = "normal-non-stream";
|
|
155
205
|
try {
|
|
156
206
|
json(res, upRes.status, JSON.parse(text));
|
|
157
207
|
} catch {
|
|
@@ -159,7 +209,7 @@ async function relay(res, upRes, body, { onFirstChunk, streamTimeoutMs = STREAM_
|
|
|
159
209
|
res.setHeader("Content-Type", contentType || "text/plain");
|
|
160
210
|
res.end(text);
|
|
161
211
|
}
|
|
162
|
-
return { status: upRes.status, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: false };
|
|
212
|
+
return { status: upRes.status, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted: false, detail };
|
|
163
213
|
}
|
|
164
214
|
|
|
165
215
|
const PEER_TIMEOUT_MS = 30_000;
|
|
@@ -279,21 +329,25 @@ export const STREAM_TIMEOUT_MS = (() => {
|
|
|
279
329
|
return Number.isInteger(n) && n > 0 ? n : 25_000;
|
|
280
330
|
})();
|
|
281
331
|
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
332
|
+
// Stall / max ceilings — disabled by default for relays (we never cut a
|
|
333
|
+
// stream that has already started; different models have different verbosity,
|
|
334
|
+
// that's normal). Stall is kept only as a *quality* signal for ranking.
|
|
335
|
+
// Set MSLXDFF_STALL_TIMEOUT_MS=15000 to re-enable cutting (not recommended),
|
|
336
|
+
// or tune MSLXDFF_SCORE_STALL_MS for scoring.
|
|
286
337
|
export const STALL_TIMEOUT_MS = (() => {
|
|
287
338
|
const n = Number(process.env.MSLXDFF_STALL_TIMEOUT_MS);
|
|
339
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
340
|
+
})();
|
|
341
|
+
|
|
342
|
+
export const SCORE_STALL_MS = (() => {
|
|
343
|
+
const raw = process.env.MSLXDFF_SCORE_STALL_MS ?? process.env.MSLXDFF_STALL_TIMEOUT_MS;
|
|
344
|
+
const n = Number(raw);
|
|
288
345
|
return Number.isInteger(n) && n > 0 ? n : 15_000;
|
|
289
346
|
})();
|
|
290
347
|
|
|
291
|
-
// Loose total ceiling (TTFB + generation) so an unbounded stream can never
|
|
292
|
-
// run forever. Kept much larger than the stall timeout so normally-flowing
|
|
293
|
-
// responses are not truncated. Set to 0 to disable.
|
|
294
348
|
export const MAX_STREAM_MS = (() => {
|
|
295
349
|
const n = Number(process.env.MSLXDFF_MAX_STREAM_MS);
|
|
296
|
-
return Number.isInteger(n) && n > 0 ? n :
|
|
350
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
297
351
|
})();
|
|
298
352
|
|
|
299
353
|
async function racePeerCandidates(candidates, ctx) {
|
|
@@ -374,12 +428,14 @@ const ROUTES = [
|
|
|
374
428
|
}
|
|
375
429
|
|
|
376
430
|
const startedAt = Date.now();
|
|
431
|
+
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
377
432
|
const perf0 = performance.now();
|
|
378
433
|
const stages = [];
|
|
379
434
|
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
380
435
|
const hops = parseHops(req.headers["x-mslxdff-hops"]);
|
|
381
436
|
const lockModel = req.headers["x-mslxdff-model-lock"] || "";
|
|
382
|
-
const
|
|
437
|
+
const rawModel = body.model || "";
|
|
438
|
+
const requested = normalizeModel(lockModel || rawModel || "");
|
|
383
439
|
const useAuto = isAutoModel(requested);
|
|
384
440
|
mark("parsed");
|
|
385
441
|
|
|
@@ -397,15 +453,16 @@ const ROUTES = [
|
|
|
397
453
|
mark("ordered");
|
|
398
454
|
|
|
399
455
|
const logCall = (model, status) =>
|
|
400
|
-
logs?.appendCall({ model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(body.stream), stages });
|
|
456
|
+
logs?.appendCall({ reqId, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(body.stream), stages });
|
|
401
457
|
const logError = (model, status, message) =>
|
|
402
|
-
logs?.appendError({ model, auto: useAuto, status, message, stages });
|
|
458
|
+
logs?.appendError({ reqId, model, auto: useAuto, status, message, stages });
|
|
403
459
|
const evt = (type, data) => {
|
|
404
|
-
const entry = { ts: Date.now(), type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
460
|
+
const entry = { ts: Date.now(), reqId, type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
405
461
|
if (bus) bus.emit(entry);
|
|
406
462
|
logs?.appendEvent?.(entry);
|
|
407
463
|
};
|
|
408
|
-
evt("request", { hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body) });
|
|
464
|
+
evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body), rawModel, requested, lockModel: lockModel || null });
|
|
465
|
+
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
409
466
|
|
|
410
467
|
// Shared context for the peer race helpers below (each model iteration
|
|
411
468
|
// reuses it; `model` is bound per iteration call).
|
|
@@ -420,60 +477,87 @@ const ROUTES = [
|
|
|
420
477
|
};
|
|
421
478
|
|
|
422
479
|
let lastErr = null;
|
|
423
|
-
for (
|
|
480
|
+
for (let idx = 0; idx < order.length; idx++) {
|
|
481
|
+
const model = order[idx];
|
|
424
482
|
handlerCtx.model = model;
|
|
483
|
+
evt("model-try", { reqId, model, idx, remaining: order.length - idx });
|
|
425
484
|
let upRes = null;
|
|
426
485
|
const forwarded = { ...injectReasoningContent(model, body), model };
|
|
427
486
|
const tUp = performance.now();
|
|
487
|
+
evt("upstream-try", { reqId, model, attempt: idx + 1 });
|
|
428
488
|
try {
|
|
429
489
|
upRes = await upstream.chat(forwarded);
|
|
490
|
+
evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
|
|
430
491
|
} catch (err) {
|
|
431
492
|
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
432
493
|
lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
|
|
433
494
|
logError(model, 502, errMsg(err));
|
|
434
|
-
evt("upstream-error", { model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
|
|
495
|
+
evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
|
|
435
496
|
}
|
|
436
497
|
mark(`up-${model}`);
|
|
437
498
|
if (upRes && upRes.status >= 400) {
|
|
438
499
|
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
439
500
|
lastErr = { model, upstream: upRes, status: upRes.status, message: null };
|
|
440
501
|
logError(model, upRes.status, `upstream ${upRes.status}`);
|
|
441
|
-
evt("upstream-error", { model, status: upRes.status, message: null, timing: upRes._t ?? null });
|
|
502
|
+
evt("upstream-error", { reqId, model, status: upRes.status, message: null, timing: upRes._t ?? null });
|
|
442
503
|
upRes = null;
|
|
443
504
|
}
|
|
444
505
|
if (upRes) {
|
|
445
|
-
if (auto) await auto.recordOk(model);
|
|
446
506
|
logCall(model, upRes.status);
|
|
447
|
-
|
|
507
|
+
evt("relay-start", { reqId, model, via: "local", isStream: Boolean(body.stream) });
|
|
508
|
+
const out = await relay(res, upRes, body, {
|
|
509
|
+
onFirstChunk: (delta) => {
|
|
510
|
+
mark(`ttf-${model}`);
|
|
511
|
+
evt("relay-first-chunk", { reqId, model, ttfMs: delta });
|
|
512
|
+
},
|
|
513
|
+
onDownstreamAbort: () => {
|
|
514
|
+
evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] });
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
evt("relay-done", { reqId, model, via: "local", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
448
518
|
if (out.status === STREAM_TIMEOUT_MS) {
|
|
449
|
-
// nothing was written — treat this model as failed and keep walking
|
|
450
|
-
// the failover chain instead of waiting out the slow stream.
|
|
451
519
|
if (auto) await auto.recordError(model, { status: 502, slow: true, note: `stream timeout ${STREAM_TIMEOUT_MS}ms` });
|
|
452
520
|
lastErr = { model, upstream: null, status: 502, message: `stream timed out after ${STREAM_TIMEOUT_MS}ms` };
|
|
453
521
|
logError(model, 502, `stream timeout ${STREAM_TIMEOUT_MS}ms`);
|
|
454
|
-
evt("upstream-error", { model, status: 502, message: "stream timeout", timing: null });
|
|
522
|
+
evt("upstream-error", { reqId, model, status: 502, message: "stream timeout", timing: null });
|
|
523
|
+
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: "stream timeout" });
|
|
455
524
|
upRes = null;
|
|
456
525
|
continue;
|
|
457
526
|
}
|
|
458
527
|
if (out.interrupted) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
evt("slow-model", { model, elapsedMs: out.totalMs ?? (Date.now() - startedAt), threshold: STALL_TIMEOUT_MS, interrupted: true });
|
|
528
|
+
if (auto) {
|
|
529
|
+
await auto.recordError(model, { status: 200, slow: true, note: `stall ${STALL_TIMEOUT_MS}ms` });
|
|
530
|
+
await auto.recordLatency(model, out.totalMs ?? (Date.now() - startedAt));
|
|
531
|
+
}
|
|
532
|
+
evt("slow-model", { model, elapsedMs: out.totalMs ?? (Date.now() - startedAt), threshold: STALL_TIMEOUT_MS, interrupted: true, detail: out.detail ?? null });
|
|
465
533
|
logCall(model, 200);
|
|
466
|
-
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true });
|
|
534
|
+
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, detail: out.detail ?? null });
|
|
467
535
|
return;
|
|
468
536
|
}
|
|
469
|
-
// A model that took a long wall-clock time (TTFB + generation + relay)
|
|
470
|
-
// gets remembered as slow so the next request prefers a faster one.
|
|
471
537
|
const elapsed = Date.now() - startedAt;
|
|
538
|
+
const latencyMs = out.totalMs ?? elapsed;
|
|
539
|
+
let scoredSlow = false;
|
|
472
540
|
if (SLOW_TOTAL_MS && auto && elapsed > SLOW_TOTAL_MS && out.status === 200) {
|
|
473
541
|
void auto.recordError(model, { status: 200, slow: true, note: `slow ${elapsed}ms` });
|
|
474
|
-
|
|
542
|
+
void auto.recordLatency(model, latencyMs);
|
|
543
|
+
evt("slow-model", { model, elapsedMs: elapsed, threshold: SLOW_TOTAL_MS, reason: "total", detail: out.detail ?? null });
|
|
544
|
+
scoredSlow = true;
|
|
475
545
|
}
|
|
476
|
-
|
|
546
|
+
if (out.detail?.stallHits > 0 && auto && out.status === 200) {
|
|
547
|
+
void auto.recordError(model, { status: 200, slow: true, note: `stall ${out.detail.stallHits}x gap>${SCORE_STALL_MS}ms maxGap ${out.detail.maxGapMs}ms` });
|
|
548
|
+
void auto.recordLatency(model, latencyMs);
|
|
549
|
+
evt("slow-model", { model, elapsedMs: elapsed, threshold: SCORE_STALL_MS, reason: "stall", stallHits: out.detail.stallHits, maxGapMs: out.detail.maxGapMs, detail: out.detail ?? null });
|
|
550
|
+
scoredSlow = true;
|
|
551
|
+
}
|
|
552
|
+
if (!scoredSlow && auto && out.status === 200) {
|
|
553
|
+
await auto.recordOk(model, { latencyMs });
|
|
554
|
+
} else if (!scoredSlow && auto) {
|
|
555
|
+
// still update latency for non-200? keep for completeness
|
|
556
|
+
await auto.recordLatency(model, latencyMs);
|
|
557
|
+
} else if (scoredSlow && out.detail) {
|
|
558
|
+
// already recorded slow+latency above, still ensure latency EMA is updated for slow case (done)
|
|
559
|
+
}
|
|
560
|
+
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
477
561
|
return;
|
|
478
562
|
}
|
|
479
563
|
|
|
@@ -483,36 +567,68 @@ const ROUTES = [
|
|
|
483
567
|
// once ordered by recovery time (earliest failure first), which
|
|
484
568
|
// favours the peer that has had the longest to come back.
|
|
485
569
|
if (canForwardPeers) {
|
|
570
|
+
evt("peer-race-start", { reqId, model, peers: peers.ordered().length });
|
|
486
571
|
const win =
|
|
487
572
|
(await racePeerCandidates(peers.ordered(), handlerCtx)) ||
|
|
488
573
|
(await racePeerCandidates(peers.orderedByLastError(), handlerCtx));
|
|
489
574
|
if (win) {
|
|
575
|
+
evt("peer-race-win", { reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs });
|
|
490
576
|
await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
|
|
491
577
|
logCall(win.target, win.res.status);
|
|
492
|
-
|
|
493
|
-
|
|
578
|
+
evt("relay-start", { reqId, model: win.target, via: "peer", isStream: Boolean(body.stream) });
|
|
579
|
+
const out = await relay(res, win.res, body, {
|
|
580
|
+
onFirstChunk: (d) => mark(`ttf-peer-${win.target}`),
|
|
581
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model: win.target, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
582
|
+
});
|
|
583
|
+
evt("relay-done", { reqId, model: win.target, via: "peer", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
584
|
+
if (auto && out.status === 200) {
|
|
585
|
+
const latencyMs = out.totalMs ?? win.latencyMs;
|
|
586
|
+
if (out.detail?.stallHits > 0 || (latencyMs && latencyMs > SLOW_TOTAL_MS)) {
|
|
587
|
+
void auto.recordError(win.target, { status: 200, slow: true, note: `peer slow ${latencyMs}ms` });
|
|
588
|
+
void auto.recordLatency(win.target, latencyMs);
|
|
589
|
+
} else {
|
|
590
|
+
await auto.recordOk(win.target, { latencyMs });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
evt("result", { model: win.target, status: out.status, via: "peer", timing: win.res._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
494
594
|
return;
|
|
495
595
|
}
|
|
596
|
+
evt("peer-race-lose", { reqId, model });
|
|
496
597
|
}
|
|
497
598
|
|
|
498
|
-
if (canFallback)
|
|
599
|
+
if (canFallback) {
|
|
600
|
+
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` });
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
evt("exhausted-local", { reqId, lastModel: lastErr?.model ?? model, lastStatus: lastErr?.status ?? 502, order });
|
|
499
604
|
logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
|
|
500
605
|
if (lastErr?.upstream) {
|
|
501
|
-
|
|
502
|
-
|
|
606
|
+
evt("relay-start", { reqId, model: lastErr.model, via: "local-exhausted", isStream: Boolean(body.stream) });
|
|
607
|
+
const out = await relay(res, lastErr.upstream, body, {
|
|
608
|
+
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
609
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
610
|
+
});
|
|
611
|
+
evt("relay-done", { reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
612
|
+
evt("result", { reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
503
613
|
return;
|
|
504
614
|
}
|
|
505
|
-
evt("result", { model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
615
|
+
evt("result", { reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
506
616
|
return json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
507
617
|
}
|
|
508
618
|
|
|
619
|
+
evt("exhausted-all", { reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
|
|
509
620
|
logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
|
|
510
621
|
if (lastErr?.upstream) {
|
|
511
|
-
|
|
512
|
-
|
|
622
|
+
evt("relay-start", { reqId, model: lastErr.model, via: "local-final", isStream: Boolean(body.stream) });
|
|
623
|
+
const out = await relay(res, lastErr.upstream, body, {
|
|
624
|
+
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
625
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
626
|
+
});
|
|
627
|
+
evt("relay-done", { reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
628
|
+
evt("result", { reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
513
629
|
return;
|
|
514
630
|
}
|
|
515
|
-
evt("result", { model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
631
|
+
evt("result", { reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
516
632
|
return json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
517
633
|
},
|
|
518
634
|
},
|
package/src/state.js
CHANGED
|
@@ -47,6 +47,17 @@ export function saveModelErrors(errors, { file = defaultStateFile() } = {}) {
|
|
|
47
47
|
return errors;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export function loadModelLatencies({ file = defaultStateFile() } = {}) {
|
|
51
|
+
const lat = readState(file).modelLatencies;
|
|
52
|
+
return lat && typeof lat === "object" && !Array.isArray(lat) ? lat : {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function saveModelLatencies(latencies, { file = defaultStateFile() } = {}) {
|
|
56
|
+
const state = readState(file);
|
|
57
|
+
writeState(file, { ...state, modelLatencies: latencies });
|
|
58
|
+
return latencies;
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
export function loadPeers({ file = defaultStateFile() } = {}) {
|
|
51
62
|
const peers = readState(file).peers;
|
|
52
63
|
return Array.isArray(peers) ? peers : [];
|