mslxdff 0.1.27 → 0.1.29

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