mslxdff 0.1.29 → 0.1.31
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 +1 -1
- package/src/auto.js +41 -4
- package/src/routes.js +212 -76
- package/src/state.js +11 -0
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,82 +65,143 @@ 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;
|
|
91
109
|
let timedOut = false;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
let stalled = false;
|
|
111
|
+
let tooLong = false;
|
|
112
|
+
let stallTimer = null;
|
|
113
|
+
const armStall = () => {
|
|
114
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
115
|
+
stallTimer = STALL_TIMEOUT_MS
|
|
116
|
+
? setTimeout(() => {
|
|
117
|
+
stalled = true;
|
|
118
|
+
detail.exitReason = "stall";
|
|
119
|
+
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
120
|
+
}, STALL_TIMEOUT_MS)
|
|
121
|
+
: null;
|
|
122
|
+
};
|
|
123
|
+
let firstTimer = setTimeout(() => {
|
|
102
124
|
timedOut = true;
|
|
125
|
+
detail.exitReason = "first-timeout";
|
|
103
126
|
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
104
127
|
}, streamTimeoutMs);
|
|
128
|
+
const maxTimer = MAX_STREAM_MS
|
|
129
|
+
? setTimeout(() => {
|
|
130
|
+
tooLong = true;
|
|
131
|
+
detail.exitReason = "max";
|
|
132
|
+
if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
|
|
133
|
+
}, MAX_STREAM_MS)
|
|
134
|
+
: null;
|
|
105
135
|
try {
|
|
106
136
|
for await (const chunk of upRes.body) {
|
|
107
|
-
|
|
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 */ }
|
|
154
|
+
if (timedOut || stalled || tooLong) break;
|
|
108
155
|
if (first) {
|
|
109
156
|
first = false;
|
|
110
|
-
ttf = Math.round(
|
|
157
|
+
ttf = Math.round(now - t0);
|
|
111
158
|
onFirstChunk?.(ttf);
|
|
159
|
+
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
112
160
|
}
|
|
113
161
|
wroteAny = true;
|
|
162
|
+
detail.wroteChunks += 1;
|
|
163
|
+
detail.wroteBytes += len;
|
|
114
164
|
res.write(chunk);
|
|
165
|
+
armStall(); // no-op when STALL_TIMEOUT_MS=0; scoring uses SCORE_STALL_MS gap above
|
|
115
166
|
}
|
|
167
|
+
if (!detail.exitReason) detail.exitReason = "normal";
|
|
116
168
|
} catch (err) {
|
|
117
|
-
|
|
118
|
-
|
|
169
|
+
detail.upstreamError = String(err?.message || err).slice(0, 300);
|
|
170
|
+
detail.exitReason = "upstream-error";
|
|
171
|
+
if (!wroteAny) timedOut = true;
|
|
172
|
+
else stalled = true;
|
|
119
173
|
} finally {
|
|
120
174
|
if (firstTimer) clearTimeout(firstTimer);
|
|
121
|
-
if (
|
|
175
|
+
if (maxTimer) clearTimeout(maxTimer);
|
|
176
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
122
177
|
}
|
|
123
178
|
if (timedOut && !wroteAny) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
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 };
|
|
127
181
|
}
|
|
128
|
-
if (
|
|
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.
|
|
182
|
+
if ((stalled || tooLong) && wroteAny) {
|
|
133
183
|
interrupted = true;
|
|
184
|
+
detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
|
|
185
|
+
res.removeListener("close", onClose);
|
|
134
186
|
try { res.end(); } catch { /* ignore */ }
|
|
135
|
-
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 };
|
|
136
188
|
}
|
|
189
|
+
} else {
|
|
190
|
+
detail.exitReason = "empty-body";
|
|
137
191
|
}
|
|
138
192
|
const totalMs = Math.round(performance.now() - t0);
|
|
193
|
+
if (!detail.exitReason) detail.exitReason = "normal";
|
|
194
|
+
finishedNormally = true;
|
|
195
|
+
res.removeListener("close", onClose);
|
|
139
196
|
try { res.end(); } catch { /* ignore */ }
|
|
140
|
-
return { status: 200, ttfMs: ttf, totalMs, aborted: false };
|
|
197
|
+
return { status: 200, ttfMs: ttf, totalMs, aborted: false, interrupted: false, detail };
|
|
141
198
|
}
|
|
142
199
|
|
|
200
|
+
finishedNormally = true;
|
|
201
|
+
res.removeListener("close", onClose);
|
|
143
202
|
const text = await upRes.text();
|
|
203
|
+
detail.receivedBytes = Buffer.byteLength(text);
|
|
204
|
+
detail.exitReason = "normal-non-stream";
|
|
144
205
|
try {
|
|
145
206
|
json(res, upRes.status, JSON.parse(text));
|
|
146
207
|
} catch {
|
|
@@ -148,7 +209,7 @@ async function relay(res, upRes, body, { onFirstChunk, streamTimeoutMs = STREAM_
|
|
|
148
209
|
res.setHeader("Content-Type", contentType || "text/plain");
|
|
149
210
|
res.end(text);
|
|
150
211
|
}
|
|
151
|
-
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 };
|
|
152
213
|
}
|
|
153
214
|
|
|
154
215
|
const PEER_TIMEOUT_MS = 30_000;
|
|
@@ -268,13 +329,25 @@ export const STREAM_TIMEOUT_MS = (() => {
|
|
|
268
329
|
return Number.isInteger(n) && n > 0 ? n : 25_000;
|
|
269
330
|
})();
|
|
270
331
|
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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.
|
|
337
|
+
export const STALL_TIMEOUT_MS = (() => {
|
|
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);
|
|
345
|
+
return Number.isInteger(n) && n > 0 ? n : 15_000;
|
|
346
|
+
})();
|
|
347
|
+
|
|
348
|
+
export const MAX_STREAM_MS = (() => {
|
|
349
|
+
const n = Number(process.env.MSLXDFF_MAX_STREAM_MS);
|
|
350
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
278
351
|
})();
|
|
279
352
|
|
|
280
353
|
async function racePeerCandidates(candidates, ctx) {
|
|
@@ -355,12 +428,14 @@ const ROUTES = [
|
|
|
355
428
|
}
|
|
356
429
|
|
|
357
430
|
const startedAt = Date.now();
|
|
431
|
+
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
358
432
|
const perf0 = performance.now();
|
|
359
433
|
const stages = [];
|
|
360
434
|
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
361
435
|
const hops = parseHops(req.headers["x-mslxdff-hops"]);
|
|
362
436
|
const lockModel = req.headers["x-mslxdff-model-lock"] || "";
|
|
363
|
-
const
|
|
437
|
+
const rawModel = body.model || "";
|
|
438
|
+
const requested = normalizeModel(lockModel || rawModel || "");
|
|
364
439
|
const useAuto = isAutoModel(requested);
|
|
365
440
|
mark("parsed");
|
|
366
441
|
|
|
@@ -378,15 +453,16 @@ const ROUTES = [
|
|
|
378
453
|
mark("ordered");
|
|
379
454
|
|
|
380
455
|
const logCall = (model, status) =>
|
|
381
|
-
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 });
|
|
382
457
|
const logError = (model, status, message) =>
|
|
383
|
-
logs?.appendError({ model, auto: useAuto, status, message, stages });
|
|
458
|
+
logs?.appendError({ reqId, model, auto: useAuto, status, message, stages });
|
|
384
459
|
const evt = (type, data) => {
|
|
385
|
-
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] };
|
|
386
461
|
if (bus) bus.emit(entry);
|
|
387
462
|
logs?.appendEvent?.(entry);
|
|
388
463
|
};
|
|
389
|
-
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 });
|
|
390
466
|
|
|
391
467
|
// Shared context for the peer race helpers below (each model iteration
|
|
392
468
|
// reuses it; `model` is bound per iteration call).
|
|
@@ -401,59 +477,87 @@ const ROUTES = [
|
|
|
401
477
|
};
|
|
402
478
|
|
|
403
479
|
let lastErr = null;
|
|
404
|
-
for (
|
|
480
|
+
for (let idx = 0; idx < order.length; idx++) {
|
|
481
|
+
const model = order[idx];
|
|
405
482
|
handlerCtx.model = model;
|
|
483
|
+
evt("model-try", { reqId, model, idx, remaining: order.length - idx });
|
|
406
484
|
let upRes = null;
|
|
407
485
|
const forwarded = { ...injectReasoningContent(model, body), model };
|
|
408
486
|
const tUp = performance.now();
|
|
487
|
+
evt("upstream-try", { reqId, model, attempt: idx + 1 });
|
|
409
488
|
try {
|
|
410
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 });
|
|
411
491
|
} catch (err) {
|
|
412
492
|
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
413
493
|
lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
|
|
414
494
|
logError(model, 502, errMsg(err));
|
|
415
|
-
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) } });
|
|
416
496
|
}
|
|
417
497
|
mark(`up-${model}`);
|
|
418
498
|
if (upRes && upRes.status >= 400) {
|
|
419
499
|
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
420
500
|
lastErr = { model, upstream: upRes, status: upRes.status, message: null };
|
|
421
501
|
logError(model, upRes.status, `upstream ${upRes.status}`);
|
|
422
|
-
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 });
|
|
423
503
|
upRes = null;
|
|
424
504
|
}
|
|
425
505
|
if (upRes) {
|
|
426
|
-
if (auto) await auto.recordOk(model);
|
|
427
506
|
logCall(model, upRes.status);
|
|
428
|
-
|
|
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 });
|
|
429
518
|
if (out.status === STREAM_TIMEOUT_MS) {
|
|
430
|
-
// nothing was written — treat this model as failed and keep walking
|
|
431
|
-
// the failover chain instead of waiting out the slow stream.
|
|
432
519
|
if (auto) await auto.recordError(model, { status: 502, slow: true, note: `stream timeout ${STREAM_TIMEOUT_MS}ms` });
|
|
433
520
|
lastErr = { model, upstream: null, status: 502, message: `stream timed out after ${STREAM_TIMEOUT_MS}ms` };
|
|
434
521
|
logError(model, 502, `stream timeout ${STREAM_TIMEOUT_MS}ms`);
|
|
435
|
-
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" });
|
|
436
524
|
upRes = null;
|
|
437
525
|
continue;
|
|
438
526
|
}
|
|
439
527
|
if (out.interrupted) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
evt("slow-model", { model, elapsedMs: out.totalMs ?? (Date.now() - startedAt), threshold:
|
|
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 });
|
|
445
533
|
logCall(model, 200);
|
|
446
|
-
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 });
|
|
447
535
|
return;
|
|
448
536
|
}
|
|
449
|
-
// A model that took a long wall-clock time (TTFB + generation + relay)
|
|
450
|
-
// gets remembered as slow so the next request prefers a faster one.
|
|
451
537
|
const elapsed = Date.now() - startedAt;
|
|
538
|
+
const latencyMs = out.totalMs ?? elapsed;
|
|
539
|
+
let scoredSlow = false;
|
|
452
540
|
if (SLOW_TOTAL_MS && auto && elapsed > SLOW_TOTAL_MS && out.status === 200) {
|
|
453
541
|
void auto.recordError(model, { status: 200, slow: true, note: `slow ${elapsed}ms` });
|
|
454
|
-
|
|
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;
|
|
545
|
+
}
|
|
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;
|
|
455
551
|
}
|
|
456
|
-
|
|
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 });
|
|
457
561
|
return;
|
|
458
562
|
}
|
|
459
563
|
|
|
@@ -463,36 +567,68 @@ const ROUTES = [
|
|
|
463
567
|
// once ordered by recovery time (earliest failure first), which
|
|
464
568
|
// favours the peer that has had the longest to come back.
|
|
465
569
|
if (canForwardPeers) {
|
|
570
|
+
evt("peer-race-start", { reqId, model, peers: peers.ordered().length });
|
|
466
571
|
const win =
|
|
467
572
|
(await racePeerCandidates(peers.ordered(), handlerCtx)) ||
|
|
468
573
|
(await racePeerCandidates(peers.orderedByLastError(), handlerCtx));
|
|
469
574
|
if (win) {
|
|
575
|
+
evt("peer-race-win", { reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs });
|
|
470
576
|
await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
|
|
471
577
|
logCall(win.target, win.res.status);
|
|
472
|
-
|
|
473
|
-
|
|
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 });
|
|
474
594
|
return;
|
|
475
595
|
}
|
|
596
|
+
evt("peer-race-lose", { reqId, model });
|
|
476
597
|
}
|
|
477
598
|
|
|
478
|
-
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 });
|
|
479
604
|
logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
|
|
480
605
|
if (lastErr?.upstream) {
|
|
481
|
-
|
|
482
|
-
|
|
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 });
|
|
483
613
|
return;
|
|
484
614
|
}
|
|
485
|
-
evt("result", { model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
615
|
+
evt("result", { reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
486
616
|
return json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
487
617
|
}
|
|
488
618
|
|
|
619
|
+
evt("exhausted-all", { reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
|
|
489
620
|
logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
|
|
490
621
|
if (lastErr?.upstream) {
|
|
491
|
-
|
|
492
|
-
|
|
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 });
|
|
493
629
|
return;
|
|
494
630
|
}
|
|
495
|
-
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 });
|
|
496
632
|
return json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
497
633
|
},
|
|
498
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 : [];
|