mslxdff 0.1.234 → 0.1.236

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.
@@ -92,6 +92,7 @@
92
92
  | 空轮留口与终局收场(ADR-0043) | `MSLXDFF_EMPTY_TURN_HOLD_END`(默认开)/`MSLXDFF_EMPTY_TURN_BUDGET_MS`(45000)/`MSLXDFF_EMPTY_TURN_MIN_RAISE_TO`(16384) | 「200 但零正文」的空轮曾先 `res.end()` 再判定,重拉/换候选写的是已终结响应(客户端只看到空白)。现把「可撤销」定义为**尚未提交模型产出**:空 delta 帧与 `[DONE]` 暂扣不写、流结束不封口,交回重拉续写同一条连接;思考已刷出的一类撤不回 → 不留口,改在流尾补 `EMPTY_MODEL_RESPONSE` SSE 错误帧 + `[DONE]`(客户端看到具体原因而非莫名空白)。终局按 `res.headersSent` 分叉(未 flush→502 JSON / 已 flush→SSE 错误帧),单一出口 + `writableEnded` 守卫保证恰好一次;预算=请求级跨候选累计封顶;`MIN_RAISE_TO` 给"客户端没设额度"的主流空轮兜底抬一次(`0`=回旧口径不发明);`HOLD_END=0` 逐字节回退旧行为 |
93
93
  | 首发输出额度托底 | `MSLXDFF_EMPTY_TURN_MIN_TOKENS`(缺省 8192,`0`/负数=关)/`MSLXDFF_EMPTY_TURN_TINY_MAX`(缺省 64) | 客户端额度 < 门槛(算爆,实测 `max_completion_tokens=1` 被上游截成 1 token 碎渣、agent 循环停摆)→ 首发上游前托到托底值;只加不减、没设额度不发明、0/负数(上游默认语义)不托;serial-trial、auto-race 与宽带成员三处 `upstream.chat` 出口都托;事件 `output-floor`(`key/raiseFrom/raiseTo`) |
94
94
  | 免费层形状门禁(ADR-0020) | `MSLXDFF_FREE_LANE`(缺省 1)/`MSLXDFF_FREE_LANE_DEBUG=1` | zen 免费模型必须 `stream:true` + tools 含 bash/edit/glob/grep/read 五名且 UA≥`opencode/1.18.0`(否则 403/426);`src/free-lane.js` 自动补形状、非流式聚合回 JSON;`0`=关(逃生阀),DEBUG 打 `[free-lane]` 日志 |
95
+ | 指定模型 5xx 退避(ADR-0068) | `MSLXDFF_5XX_RETRIES`(缺省 `3`)/`MSLXDFF_5XX_RETRY_DELAY_MS`(缺省 `5000`) | 指定模型(非 auto)撞 5xx 后同模型等 5s 重拉、连续 3 次;仍 5xx 才换 1 个节点(组长优先→延迟升序),该节点仍 5xx 直接收场,不再全节点 fan-out、不走宽带;`RETRIES=0`=整套关闭(回旧路径);429/4xx 与 auto 不适用;事件 `upstream-5xx-retry` |
95
96
  | WorkBuddy 摘除 | `-workbuddy remove <uid> [--keep-file]` / `-wb remove` | 按 `uid`(前缀6位)摘除,删 `keys/auths` 与 `auths/workbuddy-<uid>.json` |
96
97
  | 定号消耗 | `header x-mslxdff-workbuddy-uid: <uid>` 或 `model workbuddy/<uid>:<model>` | 钉死指定账号消耗,`x-mslxdff-workbuddy-uid` 回显实际账号 |
97
98
  | 同步 WB | `-setto workbuddy [modelId]` | 同步到 WorkBuddy(原子写 `~/.workbuddy/models.json`,`127.0.0.1/v1`,多模型累积;picks 非空时摘除失效本地条目) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.234",
3
+ "version": "0.1.236",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@ import { isEmptyTurnError } from "../routes/chat/relay-pipeline.js";
15
15
  import { emptyRetryCfg, emptyRaiseCap, withRaisedMaxTokens, withOutputFloor, emptyNudgeCfg, withEmptyNudge, computeNextDelay, emptyTurnBudgetMs, emptyTurnMinRaiseTo } from "./empty-turn.js";
16
16
  import { classifyTerminalUpstream, readVerdictText, terminalShortcircuitOff } from "./terminal-verdict.js";
17
17
  import { forwardOrGuard } from "./context-forward.js";
18
+ import { chatWith5xxRetry, http5xxRetryCfg } from "./upstream-5xx-retry.js";
18
19
 
19
20
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
21
 
@@ -111,7 +112,14 @@ export async function runSerialTrial(ctx, deps = {}) {
111
112
  const tUp = performance.now();
112
113
  evt("upstream-try", { reqId, model, attempt: idx + 1, emptyRetry: emptyRetried, payload: summarizeRequest(forwarded) });
113
114
  try {
114
- upRes = await upstream.chat(forwarded, chatOptsArg);
115
+ // 指定模型:5xx 先原地等 5s 重试(默认 3 次),仍 5xx 才交给上层换 1 个节点;auto 原样直发。
116
+ // 见 upstream-5xx-retry.js(用户口径 2026-10-23)。
117
+ upRes = await chatWith5xxRetry({
118
+ call: () => upstream.chat(forwarded, chatOptsArg),
119
+ singleModel: !useAuto,
120
+ evt, reqId, model,
121
+ isClosed: () => Boolean(res?.destroyed || res?.writableEnded),
122
+ });
115
123
  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, ...upstreamEcho(upRes) });
116
124
  } catch (err) {
117
125
  if (auto) await auto.recordError(model, { message: errMsg(err) });
@@ -258,16 +266,21 @@ export async function runSerialTrial(ctx, deps = {}) {
258
266
  }
259
267
  // 转发闸门(详见 context-forward.js):被拦即收场,不许换路重试——同一份大上下文换个目标照样撑爆。
260
268
  const gc = { res, body, order, handlerCtx, evt, logCall, logError, mark, perf0, stages, done, requested, useAuto, model, exhaustedLocal };
269
+ // 指定模型 5xx 重试耗尽后:只换 1 个节点(同模型换机器),该节点仍失败就直接收场,不再走宽带。
270
+ // 429 不在此列——额度按出口 IP 计,等待无意义,仍按原逻辑逐个问节点。
271
+ // retries=0 是整套功能的逃生阀:连「只换一个节点」一起关,回到旧的 5xx 全节点路径。
272
+ const pinned5xx = !useAuto && http5xxRetryCfg().retries > 0 && Number(lastErr?.status) >= 500;
273
+ const peerCtx = pinned5xx ? { ...handlerCtx, singlePeer: true } : handlerCtx;
261
274
  if (canForwardPeers) {
262
275
  if (!shouldUseGroupForModel(model)) {
263
276
  evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (peer)` });
264
277
  } else {
265
- const fr = await forwardOrGuard(() => peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, attemptStartMs: performance.now(), stages, startedAt, plugins, res }), gc);
278
+ const fr = await forwardOrGuard(() => peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx: peerCtx, evt, logCall, mark, perf0, attemptStartMs: performance.now(), stages, startedAt, plugins, res }), gc);
266
279
  if (fr.done) return fr;
267
280
  lastErr = fr.lastErr ?? lastErr;
268
281
  }
269
282
  }
270
- if (groups) {
283
+ if (groups && !pinned5xx) {
271
284
  if (!shouldUseGroupForModel(model)) {
272
285
  evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (broadband)` });
273
286
  } else {
@@ -0,0 +1,72 @@
1
+ // 指定模型撞 5xx 的原地重试(用户口径,2026-10-23):
2
+ // 5xx → 等 5s → 同模型重拉,连续重试 3 次;仍 5xx 才交给上层换 1 个节点。
3
+ // 为什么不在 auto 里用:auto 的候选是不同模型池,换模型比等待更划算;指定模型的模型不能换,
4
+ // 只能先等上游缓过来,避免「一失败就换节点」把一次故障放大成整组节点同时打上游。
5
+ // 只处理 HTTP 5xx;429(按出口 IP 的额度)与 4xx 一律不等待,交回上层走原有路径。
6
+ const DEFAULTS = Object.freeze({ retries: 3, delayMs: 5000 });
7
+
8
+ function envIntOrZero(env, name, fallback) {
9
+ const raw = String(env[name] ?? "").trim();
10
+ if (raw === "") return fallback;
11
+ const n = Number(raw);
12
+ return Number.isInteger(n) && n >= 0 ? n : fallback;
13
+ }
14
+
15
+ /** 读配置:次数与间隔都允许 0(0 次 = 关闭;0ms = 不等待)。空串/非法值回落默认。 */
16
+ export function http5xxRetryCfg(env = process.env) {
17
+ return {
18
+ retries: envIntOrZero(env, "MSLXDFF_5XX_RETRIES", DEFAULTS.retries),
19
+ delayMs: envIntOrZero(env, "MSLXDFF_5XX_RETRY_DELAY_MS", DEFAULTS.delayMs),
20
+ };
21
+ }
22
+
23
+ export function isHttp5xx(status) {
24
+ return Number.isInteger(Number(status)) && Number(status) >= 500 && Number(status) <= 599;
25
+ }
26
+
27
+ function sleep(ms) {
28
+ return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
29
+ }
30
+
31
+ // 上一发失败的 5xx 响应体没有被业务消费(判据只读 clone),重试前主动取消,防连接空占。
32
+ function cancelBody(res) {
33
+ try {
34
+ const p = res?.body?.cancel?.();
35
+ if (p && typeof p.catch === "function") p.catch(() => {});
36
+ } catch { /* 取消失败不影响重试 */ }
37
+ }
38
+
39
+ /**
40
+ * 包一层 upstream.chat:指定模型 + 5xx 时原地退避重试。
41
+ * @param {object} p
42
+ * @param {() => Promise<any>} p.call 真正发上游(每次调用都重新发)
43
+ * @param {boolean} p.singleModel 是否指定模型(false=auto,原样返回)
44
+ * @param {object} [p.cfg] http5xxRetryCfg()
45
+ * @param {(name:string, data:object)=>void} [p.evt]
46
+ * @param {string} [p.reqId]
47
+ * @param {string} [p.model]
48
+ * @param {() => boolean} [p.isClosed] 客户端是否已断开;断开即停止重试
49
+ * @returns {Promise<any>} 最后一发响应(可能是 5xx,也可能是成功)
50
+ */
51
+ export async function chatWith5xxRetry({
52
+ call,
53
+ singleModel = false,
54
+ cfg = http5xxRetryCfg(),
55
+ evt,
56
+ reqId,
57
+ model,
58
+ isClosed,
59
+ } = {}) {
60
+ let res = await call();
61
+ if (!singleModel || !cfg.retries) return res;
62
+ let retried = 0;
63
+ while (isHttp5xx(res?.status) && retried < cfg.retries) {
64
+ if (typeof isClosed === "function" && isClosed()) break;
65
+ retried += 1;
66
+ cancelBody(res);
67
+ try { evt?.("upstream-5xx-retry", { reqId, model, retry: retried, max: cfg.retries, delayMs: cfg.delayMs, status: res.status }); } catch {}
68
+ await sleep(cfg.delayMs);
69
+ res = await call();
70
+ }
71
+ return res;
72
+ }
package/src/cli/status.js CHANGED
@@ -229,6 +229,9 @@ export async function printStatus(VERSION) {
229
229
  const s = peers.stat(p.url);
230
230
  if (s?.latencyMs != null) tags.push(`${s.latencyMs}ms`);
231
231
  if (s?.fails) tags.push(`${s.fails} fail(s)`);
232
+ if (s?.wins) tags.push(`${s.wins} 成功`);
233
+ const today = s?.days?.[peers.todayKey()];
234
+ if (today && (today.ok || today.fail)) tags.push(`今天 ${today.ok || 0}成/${today.fail || 0}败`);
232
235
  const tag = tags.length ? ` [${tags.join(", ")}]` : "";
233
236
  console.log(` ${p.name || p.url} ${p.url}${tag}`);
234
237
  }
package/src/peers.js CHANGED
@@ -263,10 +263,39 @@ export function createPeersService({
263
263
  return avail[cursor++];
264
264
  }
265
265
 
266
+ // ——— 累计计数(排障读数,不参与选路) ———
267
+ // 为什么单列而不是复用 fails/okAt:那两个是**瞬时**量(成功把 fails 清零、失败把 okAt 晾着),
268
+ // 用来判"现在好不好用";而"这个节点一共替我扛了多少次"必须只增不减,否则永远读不出分布。
269
+ // day 分桶让「今天是不是某节点突然不干活了」一眼可见,历史桶只留 DAY_KEEP 天。
270
+ const DAY_KEEP = 14;
271
+
272
+ function todayKey() {
273
+ const d = new Date(now());
274
+ const p = (n) => String(n).padStart(2, "0");
275
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
276
+ }
277
+
278
+ // 只在 acc({wins,failTotal,days})上加计数,不碰 stats[url]:
279
+ // recordResult 的成功分支会**整体重建**该节点条目,早先那种"先写进 stats 再重建"
280
+ // 的写法会把刚累加的计数直接抹掉(成功一次 = 归零一次,统计恒为 0)。
281
+ function accOf(prev) {
282
+ return { wins: prev.wins || 0, failTotal: prev.failTotal || 0, days: prev.days || {} };
283
+ }
284
+
285
+ function bumpDayInto(acc, field) {
286
+ const days = { ...acc.days };
287
+ const k = todayKey();
288
+ const cur = days[k] || { ok: 0, fail: 0 };
289
+ days[k] = { ok: cur.ok || 0, fail: cur.fail || 0, [field]: (cur[field] || 0) + 1 };
290
+ const keys = Object.keys(days).sort();
291
+ for (const old of keys.slice(0, Math.max(0, keys.length - DAY_KEEP))) delete days[old];
292
+ acc.days = days;
293
+ }
294
+
266
295
  // Long-lived error memory: a peer keeps its last-error timestamp until a
267
296
  // subsequent success resets it (success clears the failure record) or the
268
297
  // error is no longer in the persist store on next load.
269
- async function recordError(url, { status, message } = {}) {
298
+ async function recordError(url, { status, message } = { }) {
270
299
  if (!url) return;
271
300
  lastErrorAt[url] = now();
272
301
  await persistErrors({ ...lastErrorAt });
@@ -287,7 +316,10 @@ export function createPeersService({
287
316
  // Outcome of a forwarded request: ok updates the hot-cache (EMA latency,
288
317
  // last successful model) and clears the error memory; failures keep the
289
318
  // error timestamp so the retry pass can order by recovery time.
290
- async function recordResult(url, { ok, latencyMs, model } = {}) {
319
+ // 累计计数(用户 2026-10-23):okAt/fails 是**瞬时**状态(成功清 fails、失败清 okAt),
320
+ // 看不出「这个节点一共替我扛了多少次」。故另立只增不减的 wins/failTotal 与今日分桶,
321
+ // 用于回答「各节点成功转发次数是否合理」——它是排障读数,不参与选路打分。
322
+ async function recordResult(url, { ok, latencyMs, model, provisional = false } = {}) {
291
323
  if (!url) return;
292
324
  if (ok) {
293
325
  if (lastErrorAt[url] != null) {
@@ -295,6 +327,9 @@ export function createPeersService({
295
327
  await persistErrors({ ...lastErrorAt });
296
328
  }
297
329
  const prev = stats[url] || {};
330
+ // ⚠ 成功分支用 next **整体重建**该节点条目(清 fails/streak429/deadFails 等瞬时量),
331
+ // 所以累计量 wins/failTotal/days 必须经 acc 显式带回去——早先"先写 stats 再重建"的
332
+ // 写法等于每次成功把累计抹掉(成功一次归零一次,统计恒为 0)。
298
333
  const next = {
299
334
  okAt: now(),
300
335
  latencyMs: prev.latencyMs != null && typeof latencyMs === "number"
@@ -307,11 +342,19 @@ export function createPeersService({
307
342
  };
308
343
  // 探测死亡标记:请求成功 = 节点活着,冷却窗口立即解除。
309
344
  // next 本身不含 probeDeadAt 键,天然清除;这里只兜底确保没有残留。
310
- stats[url] = next;
345
+ const acc = accOf(prev);
346
+ if (!provisional) {
347
+ acc.wins += 1;
348
+ bumpDayInto(acc, "ok");
349
+ }
350
+ stats[url] = { ...next, ...acc };
311
351
  delete stats[url].probeDeadAt;
312
352
  } else {
313
353
  const prev = stats[url] || {};
314
- stats[url] = { ...prev, fails: (prev.fails || 0) + 1 };
354
+ const acc = accOf(prev);
355
+ acc.failTotal += 1;
356
+ bumpDayInto(acc, "fail");
357
+ stats[url] = { ...prev, fails: (prev.fails || 0) + 1, ...acc };
315
358
  }
316
359
  await persistStats({ ...stats });
317
360
  }
@@ -341,5 +384,6 @@ export function createPeersService({
341
384
  }, isHot, stat, ordered, orderedByLastError, coolingByLastError, available, next,
342
385
  standbyChain, nextStandby, isLeaderPeer,
343
386
  recordError, recordResult, recordProbeDead, recordProbeOk, errors: () => ({ ...lastErrorAt }), stats: () => ({ ...stats }),
387
+ todayKey,
344
388
  };
345
389
  }
@@ -74,7 +74,8 @@ export async function handleHedge({
74
74
  if (hedged.winner === "peer" && hedged.peerInfo) {
75
75
  const win = hedged.peerInfo;
76
76
  evt("peer-race-win", { reqId: handlerCtx.reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs, hedged: true, ttfMs: hedged.ttfMs });
77
- await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
77
+ // 同 peer-handler:此处只拿到响应头,先记 provisional;送达后(r.handled)再记真正的成功。
78
+ await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target, provisional: true });
78
79
  const bufferedPeerRes = { ...win.res, body: hedged.bufferedBody, headers: win.res.headers, status: win.res.status, _t: win.res._t };
79
80
  const pipeline = createPipeline({
80
81
  relay,
@@ -107,6 +108,8 @@ export async function handleHedge({
107
108
  startedAt,
108
109
  });
109
110
  if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
111
+ // 真正送达才算这个节点成功转发一次(口径见 peers.js bumpWins 注释)
112
+ await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
110
113
  return { handled: true };
111
114
  }
112
115
  }
@@ -30,10 +30,20 @@ export async function handlePeerRelay({
30
30
  const peerErrors = [];
31
31
  // 三批共享同一 peer 阶段总预算(同 hedge:旧行为是每批各吃满一次等头超时,串行叠加到 90s+)
32
32
  const pctx = peerStageCtx({ ...handlerCtx, peerErrors });
33
- const win =
34
- (await race(peers.ordered(), pctx)) ||
35
- (await race(peers.orderedByLastError(), pctx)) ||
36
- (await race(peers.coolingByLastError(), pctx));
33
+ // singlePeer:指定模型 5xx 重试耗尽后只换 1 个节点,该节点仍失败就交回上层收场。
34
+ // 选人仍按「组长第一 → 延迟升序」,只是取链头一个;三批候选不叠加,也不进 IPv6 阶段。
35
+ const singlePeer = handlerCtx?.singlePeer === true;
36
+ let win = null;
37
+ if (singlePeer) {
38
+ pctx.peerMaxPeers = 1;
39
+ const pick = peers.ordered()[0] || peers.orderedByLastError?.()?.[0] || peers.coolingByLastError?.()?.[0] || null;
40
+ win = pick ? await race([pick], pctx) : null;
41
+ } else {
42
+ win =
43
+ (await race(peers.ordered(), pctx)) ||
44
+ (await race(peers.orderedByLastError(), pctx)) ||
45
+ (await race(peers.coolingByLastError(), pctx));
46
+ }
37
47
  if (!win) {
38
48
  evt("peer-race-lose", { reqId: handlerCtx.reqId, model });
39
49
  // 组员全失败:把每个组员的真实返回汇总给调用者(否则只剩一个无信息的 429/502)
@@ -44,7 +54,9 @@ export async function handlePeerRelay({
44
54
  return { handled: false, lastErr: { model, upstream: null, status: allLimit ? 429 : 502, message: `peers failed: ${detail}` } };
45
55
  }
46
56
  evt("peer-race-win", { reqId: handlerCtx.reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs });
47
- await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
57
+ // 这里只拿到**响应头**,pipeline 后面还可能超时/空轮。故先记 provisional(只暖热缓存、参与选路),
58
+ // 真正的成功计数等 pipeline 报 handled:true 再记——否则"收到 200 但零正文"也被算成转发成功。
59
+ await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target, provisional: true });
48
60
 
49
61
  const pipeline = createPipeline({
50
62
  relay,
@@ -78,5 +90,7 @@ export async function handlePeerRelay({
78
90
  });
79
91
  // Note: 超时/失败必须把 handled:false 交回上层换候选(丢弃 → 响应永不 end)— 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
80
92
  if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
93
+ // 真正送达才算这个节点成功转发一次(口径见 peers.js 的 bumpWins 注释)
94
+ await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
81
95
  return { handled: true };
82
96
  }
@@ -116,8 +116,8 @@ export async function handleViaRoute({
116
116
  // 502/429 等可 fallback 到 direct
117
117
  return { handled: false, lastErr: { model, upstream: upRes, status, message: msg } };
118
118
  }
119
- // 成功:记录并走 pipeline 中继(复用 peer 的 streaming 逻辑)
120
- try { await peers.recordResult(peer.url, { ok: true, latencyMs: 0, model }); } catch {}
119
+ // 成功:只拿到响应头,先记 provisional(只暖热缓存);送达后(r.handled)再记真正的成功
120
+ try { await peers.recordResult(peer.url, { ok: true, latencyMs: 0, model, provisional: true }); } catch {}
121
121
  evt("via-route-win", { reqId: handlerCtx.reqId, peer: peer.url, model });
122
122
  const pipeline = createRelayPipeline({
123
123
  relay,
@@ -152,5 +152,7 @@ export async function handleViaRoute({
152
152
  streamTimeoutMs: LAST_CANDIDATE_TIMEOUT_MS,
153
153
  });
154
154
  if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
155
+ // 真正送达才算这个节点成功转发一次(口径见 peers.js bumpWins 注释)
156
+ try { await peers.recordResult(peer.url, { ok: true, latencyMs: 0, model }); } catch {}
155
157
  return { handled: true };
156
158
  }
@@ -68,8 +68,11 @@ export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried
68
68
  // 两段链:先 IPv4(组长→组员延迟升序),IPv4 全败才进 IPv6(同样组长优先 + hasV6 过滤)。
69
69
  // 只保留本次还没试过的。
70
70
  const fresh = (list) => list.filter((p) => !tried.has(p.url));
71
- const ipv4Chain = filterSlow(fresh(candidates));
72
- const ipv6Chain = filterSlow(fresh(v6Candidates));
71
+ // 指定模型 5xx 场景只要 1 个节点:peerMaxPeers=1 时 IPv4 链截断、IPv6 阶段直接跳过。
72
+ // 缺省 Infinity = 现有行为(整链 + v6 阶段)。
73
+ const maxPeers = Number(ctx.peerMaxPeers) > 0 ? Math.floor(Number(ctx.peerMaxPeers)) : Infinity;
74
+ const ipv4Chain = filterSlow(fresh(candidates)).slice(0, maxPeers);
75
+ const ipv6Chain = Number.isFinite(maxPeers) ? [] : filterSlow(fresh(v6Candidates));
73
76
 
74
77
  // —— 预取 + 探针(用户 2026-10-20 口径)——
75
78
  // 「某个节点当备选的时候打一个 hi,准备轮到它了也打一个 hi;hi 都回不来就当它不行,直接换。」