mslxdff 0.1.30 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
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
- export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0, slowCooldownMs = 0 } = {}) {
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
- // 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 } = {}) {
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
- const firstTimer = setTimeout(() => {
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(performance.now() - t0);
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(); // any fresh chunk resets the stall clock
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
- // body threw if nothing was written, treat it as a first-block timeout
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
- // nothing written to res yet — safe to drop this model and let the
137
- // caller fail over to the next one. Do NOT write/end res here.
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
- // Ceiling on how long a streamed response may produce no new chunk before we
283
- // treat the model as stalled and proactively end the stream (clean EOF instead
284
- // of hanging on a dead upstream). A model that keeps emitting chunks is never
285
- // cut off only silence triggers it. Set to 0 to disable.
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 : 120_000;
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 requested = normalizeModel(lockModel || body.model || "");
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 (const model of order) {
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
- const out = await relay(res, upRes, body, { onFirstChunk: (delta) => mark(`ttf-${model}`) });
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
- // the model went silent past STALL_TIMEOUT_MS (or blew the total
460
- // ceiling) we ended it so the client got a clean EOF instead of
461
- // hanging. Remember this model as slow so the next request prefers
462
- // a faster one.
463
- if (auto) await auto.recordError(model, { status: 200, slow: true, note: `stall ${STALL_TIMEOUT_MS}ms` });
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
- evt("slow-model", { model, elapsedMs: elapsed, threshold: SLOW_TOTAL_MS });
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
- evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs });
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
- const out = await relay(res, win.res, body, { onFirstChunk: (d) => mark(`ttf-peer-${win.target}`) });
493
- evt("result", { model: win.target, status: out.status, via: "peer", timing: win.res._t ?? null, ttfMs: out.ttfMs });
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) continue;
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
- const out = await relay(res, lastErr.upstream, body, { onFirstChunk: (d) => mark(`ttf-${lastErr.model}`) });
502
- evt("result", { model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs });
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
- const out = await relay(res, lastErr.upstream, body, { onFirstChunk: (d) => mark(`ttf-${lastErr.model}`) });
512
- evt("result", { model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs });
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 : [];