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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.29",
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,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
- // 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;
91
109
  let timedOut = false;
92
- // whole-stream ceiling (TTFB + generation): cancels the upstream body so
93
- // the loop exits and we proactively end the stream instead of hanging.
94
- let genTooLong = false;
95
- const genTimer = GEN_TIMEOUT_MS
96
- ? setTimeout(() => {
97
- genTooLong = true;
98
- if (typeof upRes.body.cancel === "function") upRes.body.cancel().catch(() => {});
99
- }, GEN_TIMEOUT_MS)
100
- : null;
101
- const firstTimer = setTimeout(() => {
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
- if (timedOut || genTooLong) break;
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(performance.now() - t0);
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
- // body threw if we already started, treat as a mid-stream interrupt
118
- if (!wroteAny) { timedOut = true; genTooLong = false; }
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 (genTimer) clearTimeout(genTimer);
175
+ if (maxTimer) clearTimeout(maxTimer);
176
+ if (stallTimer) clearTimeout(stallTimer);
122
177
  }
123
178
  if (timedOut && !wroteAny) {
124
- // nothing written to res yet — safe to drop this model and let the
125
- // caller fail over to the next one. Do NOT write/end res here.
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 (genTooLong || (timedOut && wroteAny)) {
129
- // we'd already started streaming when it exceeded the ceiling — can't
130
- // cleanly fail over a half-written response, just end it so the client
131
- // sees a clean EOF rather than hanging. interrupted signals the caller
132
- // to remember this model as slow.
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
- // Ceiling on the total wall-clock a single streamed response may run (TTFB +
272
- // generation + relay). Once exceeded we proactively end the stream so the
273
- // client gets a clean EOF instead of hanging on a very slow model; the model
274
- // is then flagged slow and demoted for the next request. Set to 0 to disable.
275
- export const GEN_TIMEOUT_MS = (() => {
276
- const n = Number(process.env.MSLXDFF_GEN_TIMEOUT_MS);
277
- return Number.isInteger(n) && n > 0 ? n : 20_000;
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 requested = normalizeModel(lockModel || body.model || "");
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 (const model of order) {
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
- 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 });
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
- // the whole stream ran past GEN_TIMEOUT_MS — we ended it proactively
441
- // so the client got a clean EOF instead of hanging. Remember this
442
- // model as slow so the next request prefers a faster one.
443
- if (auto) await auto.recordError(model, { status: 200, slow: true, note: `gen timeout ${GEN_TIMEOUT_MS}ms` });
444
- evt("slow-model", { model, elapsedMs: out.totalMs ?? (Date.now() - startedAt), threshold: GEN_TIMEOUT_MS, interrupted: true });
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
- 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;
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
- evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs });
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
- const out = await relay(res, win.res, body, { onFirstChunk: (d) => mark(`ttf-peer-${win.target}`) });
473
- 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 });
474
594
  return;
475
595
  }
596
+ evt("peer-race-lose", { reqId, model });
476
597
  }
477
598
 
478
- 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 });
479
604
  logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
480
605
  if (lastErr?.upstream) {
481
- const out = await relay(res, lastErr.upstream, body, { onFirstChunk: (d) => mark(`ttf-${lastErr.model}`) });
482
- 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 });
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
- const out = await relay(res, lastErr.upstream, body, { onFirstChunk: (d) => mark(`ttf-${lastErr.model}`) });
492
- 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 });
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 : [];