mslxdff 0.1.236 → 0.1.237

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.
Files changed (102) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/package.json +1 -1
  3. package/src/auto.js +264 -264
  4. package/src/autostart.js +62 -15
  5. package/src/bench/cline-bench.js +42 -42
  6. package/src/bench/probe.js +70 -70
  7. package/src/bench/report.js +162 -162
  8. package/src/bench/runner.js +77 -77
  9. package/src/bench/via-probe.js +124 -124
  10. package/src/bench/via-routes.js +87 -87
  11. package/src/bench/workbuddy-bench.js +54 -54
  12. package/src/chat/engine.js +160 -160
  13. package/src/chat/gateway.js +163 -163
  14. package/src/chat/orchestrator.js +234 -234
  15. package/src/chat/prompt.js +70 -70
  16. package/src/chat/repl.js +88 -88
  17. package/src/chat/terminal.js +135 -135
  18. package/src/chat/tools.js +306 -306
  19. package/src/chat-pipeline/index.js +155 -155
  20. package/src/chat-pipeline/policy.js +84 -84
  21. package/src/cli/commands/group.js +266 -266
  22. package/src/cli/commands/model/picks.js +50 -50
  23. package/src/cli/commands/provider/bench-via.js +247 -247
  24. package/src/cli/commands/provider/bench.js +141 -141
  25. package/src/cli/commands/provider/index.js +139 -139
  26. package/src/cli/commands/provider/models.js +149 -149
  27. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  28. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  29. package/src/cli/commands/sync.js +310 -310
  30. package/src/cli/status.js +282 -282
  31. package/src/daemon.js +246 -246
  32. package/src/model-capabilities/enrich.js +86 -86
  33. package/src/model-capabilities/index.js +183 -183
  34. package/src/model-capabilities/parse.js +70 -70
  35. package/src/models.js +293 -293
  36. package/src/peers.js +389 -389
  37. package/src/providers/agentrouter.js +238 -238
  38. package/src/providers/base.js +164 -164
  39. package/src/providers/cline/auth.js +228 -228
  40. package/src/providers/cline/chat.js +307 -307
  41. package/src/providers/cline.js +2 -2
  42. package/src/providers/codearts/chat.js +207 -207
  43. package/src/providers/dispatcher.js +147 -147
  44. package/src/providers/globalqwenwork/cosy.js +123 -123
  45. package/src/providers/globalqwenwork/crypto.js +218 -218
  46. package/src/providers/globalqwenwork/http.js +20 -20
  47. package/src/providers/globalqwenwork/rsa.js +54 -54
  48. package/src/providers/globalqwenwork/sse.js +268 -268
  49. package/src/providers/globalqwenwork/stream.js +130 -130
  50. package/src/providers/globalqwenwork/upstream.js +122 -122
  51. package/src/providers/keyring.js +60 -60
  52. package/src/providers/qoder/chat.js +292 -292
  53. package/src/providers/qoder/index.js +311 -311
  54. package/src/providers/qoder/sse.js +233 -233
  55. package/src/providers/qwenwork/account-store.js +133 -133
  56. package/src/providers/qwenwork/constants.js +67 -67
  57. package/src/providers/qwenwork/cosy.js +120 -120
  58. package/src/providers/qwenwork/crypto.js +218 -218
  59. package/src/providers/qwenwork/http.js +20 -20
  60. package/src/providers/qwenwork/index.js +337 -337
  61. package/src/providers/qwenwork/payload.js +144 -144
  62. package/src/providers/qwenwork/rsa.js +54 -54
  63. package/src/providers/qwenwork/sse.js +268 -268
  64. package/src/providers/qwenwork/stream.js +130 -130
  65. package/src/providers/qwenwork/upstream.js +120 -120
  66. package/src/providers/qwenwork.js +1 -1
  67. package/src/providers/raccoon/const.js +219 -219
  68. package/src/providers/raccoon/credits.js +285 -285
  69. package/src/providers/registry.js +89 -89
  70. package/src/providers/workbuddy/chat.js +298 -298
  71. package/src/providers/workbuddy/reshape.js +152 -152
  72. package/src/providers/workbuddy.js +2 -2
  73. package/src/reasoning.js +32 -32
  74. package/src/routes/chat/broadband-handler.js +80 -80
  75. package/src/routes/chat/gateway.js +46 -46
  76. package/src/routes/chat/relay-pipeline.js +282 -282
  77. package/src/routes/chat/via-route-handler.js +158 -158
  78. package/src/routes/hedge.js +260 -260
  79. package/src/routes/models-route.js +167 -167
  80. package/src/routes/peers.js +373 -373
  81. package/src/routes/stream.js +364 -364
  82. package/src/runtime/bootstrap.js +75 -75
  83. package/src/runtime/keep-warm.js +135 -135
  84. package/src/runtime/provider-gate.js +75 -75
  85. package/src/runtime/providers-setup.js +187 -187
  86. package/src/runtime/raccoon-checkin.js +175 -175
  87. package/src/runtime/server-lifecycle.js +34 -10
  88. package/src/server.js +85 -85
  89. package/src/state/schemas/allowlist.js +92 -92
  90. package/src/sync-opencode.js +280 -280
  91. package/src/transport/index.js +244 -244
  92. package/src/transport/pool.js +56 -56
  93. package/src/transport/retry.js +24 -24
  94. package/src/transport/sse.js +93 -93
  95. package/src/upstream-probe/display.js +52 -52
  96. package/src/upstream-probe/probe.js +49 -49
  97. package/src/upstream-probe/rotate.js +110 -110
  98. package/src/upstream-probe/start.js +45 -45
  99. package/src/upstream.js +355 -355
  100. package/docs/cli_help_mini.md +0 -149
  101. package/src/providers/AGENTS.md +0 -49
  102. package/src/routes/AGENTS.md +0 -37
package/src/peers.js CHANGED
@@ -1,389 +1,389 @@
1
- import { loadPeers, savePeers, loadPeerErrors, savePeerErrors, loadPeerStats, savePeerStats } from "./state.js";
2
-
3
- // 组员失败冷却分级(2026-10-18 用户拍板):
4
- // 非额度失败(502/5xx/超时,可能几秒就恢复)→ 短冷却 10s,到点自动重新纳入候选、继续挨个重试;
5
- // 额度类(连续 2 次 429)→ 长冷却 10min(额度当天不会因快重试恢复,反复撞只会白烧配额)。
6
- export const DEFAULT_PEER_COOLDOWN_MS = 10_000;
7
- export const DEFAULT_PEER_LIMIT_COOLDOWN_MS = 10 * 60_000;
8
- export const DEFAULT_PEER_HEAT_MS = 5 * 60_000;
9
- export const DEFAULT_MAX_HOPS = 3;
10
- export const DEFAULT_BROADBAND_STALE_MS = 90_000;
11
- // 连续几次连接级失败即视为"这个地址没服务",进长冷却(不再靠 30s 短冷却反复捞回)。
12
- export const DEAD_FAIL_THRESHOLD = 2;
13
- // 探测失败(GET /health 连不上)视为"这个节点当前不可用",10 分钟内不再当候选。
14
- // 与请求失败账分开:health 探测是**主动、零额度**的可用性判断,比"这一发不顺"更可信——
15
- // 现网 2026-10-20 13:27 req mvg8r7cz-b8qn:152.67.222.233:62028 早已 health timeout,
16
- // CLI/保热都探到了,但选路不知道,仍把它排进候选并吃满 20s 单次上限(总 27s)。
17
- export const DEFAULT_PEER_PROBE_DEAD_MS = 10 * 60_000;
18
-
19
- export function peerProbeDeadMs() {
20
- const n = Number(process.env.MSLXDFF_PEER_PROBE_DEAD_MS);
21
- return Number.isInteger(n) && n >= 0 ? n : DEFAULT_PEER_PROBE_DEAD_MS;
22
- }
23
-
24
- const EMA_ALPHA = 0.3;
25
-
26
- export function broadbandStaleMs() {
27
- const n = Number(process.env.MSLXDFF_BROADBAND_STALE_MS);
28
- return Number.isInteger(n) && n > 0 ? n : DEFAULT_BROADBAND_STALE_MS;
29
- }
30
-
31
- export function isBroadbandStale(peer, nowMs = Date.now(), staleMs = broadbandStaleMs()) {
32
- const kind = peer?.kind || (String(peer?.url || "").startsWith("relay://") ? "broadband" : "static");
33
- if (kind !== "broadband") return false;
34
- const lastSeen = peer?.lastSeen;
35
- if (typeof lastSeen !== "number") return true; // no heartbeat yet => stale
36
- return nowMs - lastSeen > staleMs;
37
- }
38
-
39
- export function normalizePeerUrl(url) {
40
- return String(url || "").trim().replace(/\/+$/, "");
41
- }
42
-
43
- export function createPeersService({
44
- file,
45
- now = () => Date.now(),
46
- cooldownMs = DEFAULT_PEER_COOLDOWN_MS,
47
- limitCooldownMs = DEFAULT_PEER_LIMIT_COOLDOWN_MS,
48
- heatMs = DEFAULT_PEER_HEAT_MS,
49
- peers: seedPeers,
50
- errors: seedErrors,
51
- stats: seedStats,
52
- persistPeers = (list, f = file) => savePeers(list, f ? { file: f } : {}),
53
- persistErrors = (errors, f = file) => savePeerErrors(errors, f ? { file: f } : {}),
54
- persistStats = (stats, f = file) => savePeerStats(stats, f ? { file: f } : {}),
55
- } = {}) {
56
- const list = (seedPeers ?? loadPeers(file ? { file } : {}))
57
- .map((p) => ({ ...p, url: normalizePeerUrl(p.url) }))
58
- .filter((p) => p && p.url);
59
- const lastErrorAt = { ...(seedErrors ?? loadPeerErrors(file ? { file } : {})) };
60
- const stats = { ...(seedStats ?? loadPeerStats(file ? { file } : {})) };
61
-
62
- function all() {
63
- return [...list];
64
- }
65
-
66
- function add(peer) {
67
- const url = normalizePeerUrl(peer?.url);
68
- if (!url) return false;
69
- const existing = list.find((p) => p.url === url);
70
- const entry = { ...peer, url };
71
- if (existing) Object.assign(existing, entry);
72
- else list.push(entry);
73
- persistPeers([...list]);
74
- return true;
75
- }
76
-
77
- function remove(url) {
78
- const target = normalizePeerUrl(url);
79
- const idx = list.findIndex((p) => p.url === target);
80
- if (idx < 0) return false;
81
- list.splice(idx, 1);
82
- persistPeers([...list]);
83
- return true;
84
- }
85
-
86
- function removeByGroup(group) {
87
- const before = list.length;
88
- for (let i = list.length - 1; i >= 0; i--) {
89
- if (list[i].group === group) list.splice(i, 1);
90
- }
91
- if (list.length !== before) persistPeers([...list]);
92
- return before - list.length;
93
- }
94
-
95
- // 分级冷却:连续 429(streak >= 2)→ limitCooldownMs(限流恢复慢,5min 内不再考虑);
96
- // 连接级永久失败(ECONNREFUSED/ENOTFOUND,deadFails >= 2)同样进长冷却;
97
- // 其他失败(网络抖动、5xx)→ cooldownMs(可能几秒恢复,给快速重试机会)。
98
- function coolingWindowMs(url) {
99
- // 探测死亡(health 连不上):10min 内不再考虑——比请求失败更可信的"没服务"证据
100
- if (stats[url]?.probeDeadAt !== undefined && now() - stats[url].probeDeadAt < peerProbeDeadMs()) return limitCooldownMs;
101
- if ((stats[url]?.deadFails ?? 0) >= DEAD_FAIL_THRESHOLD) return limitCooldownMs;
102
- const streak = stats[url]?.streak429 ?? 0;
103
- return streak >= 2 ? limitCooldownMs : cooldownMs;
104
- }
105
-
106
- // 连接级失败(端口没服务/DNS 解析不了)与"这一发不顺"是两回事:前者换多少次都一样,
107
- // 现网 node-b(127.0.0.1:3500,本机根本没服务)靠 30s 短冷却 + 兜底,被反复竞速 1569 次。
108
- function isConnLevelFailure(message) {
109
- const s = String(message || "");
110
- return /ECONNREFUSED|ENOTFOUND|ENETUNREACH|EHOSTUNREACH|ECONNRESET|fetch failed|connect (?:timeout|ETIMEDOUT)|socket hang up/i.test(s);
111
- }
112
-
113
- function isCooling(url) {
114
- // 探测死亡(health 探不通):10min 内不再当候选——比请求失败更可信的"没服务"证据
115
- const pDead = stats[url]?.probeDeadAt;
116
- if (pDead !== undefined && now() - pDead < peerProbeDeadMs()) return true;
117
- const win = coolingWindowMs(url);
118
- if (!win) return false;
119
- const err = lastErrorAt[url];
120
- if (typeof err === "number" && now() - err < win) return true;
121
- const peer = list.find((p) => p.url === url);
122
- if (peer && isBroadbandStale(peer, now(), broadbandStaleMs())) return true;
123
- return false;
124
- }
125
-
126
- function isBroadbandCooling(url) {
127
- const peer = list.find((p) => p.url === url);
128
- if (!peer) return false;
129
- return isBroadbandStale(peer, now(), broadbandStaleMs());
130
- }
131
-
132
- function available() {
133
- return list.filter((p) => !isCooling(p.url));
134
- }
135
-
136
- // A peer counts as "hot" when it succeeded recently: reuse its last model
137
- // without a fresh health probe to keep the fast path fast.
138
- function isHot(url, t = now()) {
139
- if (isCooling(url)) return false;
140
- const s = stats[url];
141
- return Boolean(s?.okAt) && t - s.okAt < heatMs;
142
- }
143
-
144
- function stat(url) {
145
- return stats[url] ? { ...stats[url] } : null;
146
- }
147
-
148
- function rankScore(p, t) {
149
- const s = stats[p.url];
150
- const hot = s?.okAt && t - s.okAt < heatMs ? 0 : 1;
151
- const latency = s?.latencyMs != null ? s.latencyMs : 1_000_000;
152
- const fails = s?.fails ?? 0;
153
- return hot * 1_000_000_000 + latency * 1_000 + fails;
154
- }
155
-
156
- // 可用组员排序(用户 2026-10-19 决定):**延迟最小优先**,不再「热优先」。
157
- // 语义 = 「组员的额度用完后,用跟组长延迟最小的组员」——延迟是首要判据。
158
- // 延迟来源(优先级从高到低):
159
- // ① stats 里的真实请求 EMA(recordResult 记的 latencyMs)——最可信;
160
- // ② peer.heartbeatLatencyMs —— 组员**定时心跳**上报的 rtt(没跑过真实请求时也能排序);
161
- // 未测过延迟的排在有数据之后;同延迟按「最早失败优先」;都没失败过的排最后。
162
- // 组长识别:本机是组员时,peers 里有 name==="leader" 的记录(cli/group-test.js 同口径)。
163
- // 组长自己节点视角没有这条记录 → isLeaderPeer 恒 false,退化为纯延迟排序(正是想要的行为)。
164
- function isLeaderPeer(p) {
165
- return String(p?.name || "").toLowerCase() === "leader";
166
- }
167
-
168
- function peerLatency(p) {
169
- const s = stats[p.url]?.latencyMs;
170
- if (s != null) return s;
171
- const hb = list.find((x) => x.url === p.url)?.heartbeatLatencyMs;
172
- return Number.isFinite(Number(hb)) ? Number(hb) : null;
173
- }
174
-
175
- // 组员延迟升序(组长不参与),供两处复用:ordered 的第二档 + standbyChain。
176
- function byLatencyAsc(a, b) {
177
- const la = peerLatency(a);
178
- const lb = peerLatency(b);
179
- const ha = la != null, hb = lb != null;
180
- if (ha !== hb) return ha ? -1 : 1; // 有延迟数据的优先
181
- if (ha && hb && la !== lb) return la - lb; // 延迟小的先上
182
- const ea = lastErrorAt[a.url];
183
- const eb = lastErrorAt[b.url];
184
- if (ea == null && eb == null) return 0;
185
- if (ea == null) return 1;
186
- if (eb == null) return -1;
187
- return ea - eb;
188
- }
189
-
190
- // 用户 2026-10-20 约定:**组长恒定第一**(先榨干组长额度),组长用完再按延迟升序上组员。
191
- // 之前是纯延迟排序,「9/9 走组长」只是组长恰好延迟最低的巧合(AGENTS.md 已记差距)。
192
- function ordered(t = now()) {
193
- return available().sort((a, b) => {
194
- const la = isLeaderPeer(a), lb = isLeaderPeer(b);
195
- if (la !== lb) return la ? -1 : 1; // 组长恒排第一
196
- return byLatencyAsc(a, b);
197
- });
198
- }
199
-
200
- /**
201
- * 常驻接力链(用户 2026-10-20:永远准备一个备选,429 立即切)。
202
- * IPv4 阶段:组长 → 组员(延迟升序);IPv4 全用完才进 IPv6 阶段:先本机/组长 v6,
203
- * 再按延迟升序找 hasV6 的组员。
204
- * @returns {{ipv4: object[], ipv6: object[]}} 两段链,各自已排序
205
- */
206
- function standbyChain(t = now()) {
207
- const ipv4 = available().sort((a, b) => {
208
- const la = isLeaderPeer(a), lb = isLeaderPeer(b);
209
- if (la !== lb) return la ? -1 : 1;
210
- return byLatencyAsc(a, b);
211
- });
212
- // IPv6 阶段:只保留心跳自报 hasV6 的节点,同样组长优先、其余延迟升序
213
- const ipv6 = ipv4.filter((p) => p.hasV6 === true);
214
- return { ipv4, ipv6 };
215
- }
216
-
217
- /** 下一个备选:从链里取第一个还没试过的。这就是「永远准备好的那个」。 */
218
- function nextStandby(tried, t = now()) {
219
- const { ipv4, ipv6 } = standbyChain(t);
220
- const skip = tried instanceof Set ? tried : new Set(tried || []);
221
- return {
222
- ipv4: ipv4.find((p) => !skip.has(p.url)) || null,
223
- ipv6: ipv6.find((p) => !skip.has(p.url)) || null,
224
- };
225
- }
226
-
227
- // Available peers ordered specifically for recovery-time retry: earliest
228
- // failure first (it has had the longest to come back), never-failed last.
229
- function orderedByLastError(t = now()) {
230
- return available().sort((a, b) => {
231
- const ea = lastErrorAt[a.url];
232
- const eb = lastErrorAt[b.url];
233
- if (ea == null && eb == null) return 0;
234
- if (ea == null) return 1; // never-failed peers go after any that failed
235
- if (eb == null) return -1;
236
- return ea - eb; // earliest failure first
237
- });
238
- }
239
-
240
- let cursor = 0;
241
-
242
- // 兜底重试集合:仅"因错误冷却中"的 peer(按最早失败优先)。
243
- // 场景:唯一可用节点偶发失败被冷却锁死 → 主链零候选/全败时,兜底给它一次机会;
244
- // 成功会被 recordResult 清除错误记录、回归热路径,避免"明明能用的节点被 30s 冷却全灭"。
245
- function coolingByLastError() {
246
- const t = now();
247
- return list
248
- .filter((p) => {
249
- const err = lastErrorAt[p.url];
250
- if (typeof err !== "number") return false;
251
- if (stats[p.url]?.probeDeadAt !== undefined && now() - stats[p.url].probeDeadAt < peerProbeDeadMs()) return false; // 探测死亡:兜底也不考虑(否则每次白等一次拒连)
252
- if ((stats[p.url]?.streak429 ?? 0) >= 2) return false; // 429 长冷却:兜底也不考虑
253
- if ((stats[p.url]?.deadFails ?? 0) >= DEAD_FAIL_THRESHOLD) return false; // 连接级永久失败:兜底也不考虑(否则每次白等一次拒连)
254
- return t - err < cooldownMs;
255
- })
256
- .sort((a, b) => (lastErrorAt[a.url] ?? 0) - (lastErrorAt[b.url] ?? 0));
257
- }
258
-
259
- function next() {
260
- const avail = available();
261
- if (!avail.length) return null;
262
- cursor = cursor % avail.length;
263
- return avail[cursor++];
264
- }
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
-
295
- // Long-lived error memory: a peer keeps its last-error timestamp until a
296
- // subsequent success resets it (success clears the failure record) or the
297
- // error is no longer in the persist store on next load.
298
- async function recordError(url, { status, message } = { }) {
299
- if (!url) return;
300
- lastErrorAt[url] = now();
301
- await persistErrors({ ...lastErrorAt });
302
- // 连续 429 计数(仅成功清零):第 2 次起进入长冷却——该 peer 大概率真被限流,不再频繁试它
303
- if (Number(status) === 429) {
304
- const prev = stats[url] || {};
305
- stats[url] = { ...prev, streak429: (prev.streak429 || 0) + 1 };
306
- await persistStats({ ...stats });
307
- }
308
- // 连接级失败计数(仅成功清零):第 2 次起进长冷却,且不再被 coolingByLastError 兜底捞回
309
- if (isConnLevelFailure(message)) {
310
- const prev = stats[url] || {};
311
- stats[url] = { ...prev, deadFails: (prev.deadFails || 0) + 1 };
312
- await persistStats({ ...stats });
313
- }
314
- }
315
-
316
- // Outcome of a forwarded request: ok updates the hot-cache (EMA latency,
317
- // last successful model) and clears the error memory; failures keep the
318
- // error timestamp so the retry pass can order by recovery time.
319
- // 累计计数(用户 2026-10-23):okAt/fails 是**瞬时**状态(成功清 fails、失败清 okAt),
320
- // 看不出「这个节点一共替我扛了多少次」。故另立只增不减的 wins/failTotal 与今日分桶,
321
- // 用于回答「各节点成功转发次数是否合理」——它是排障读数,不参与选路打分。
322
- async function recordResult(url, { ok, latencyMs, model, provisional = false } = {}) {
323
- if (!url) return;
324
- if (ok) {
325
- if (lastErrorAt[url] != null) {
326
- delete lastErrorAt[url];
327
- await persistErrors({ ...lastErrorAt });
328
- }
329
- const prev = stats[url] || {};
330
- // ⚠ 成功分支用 next **整体重建**该节点条目(清 fails/streak429/deadFails 等瞬时量),
331
- // 所以累计量 wins/failTotal/days 必须经 acc 显式带回去——早先"先写 stats 再重建"的
332
- // 写法等于每次成功把累计抹掉(成功一次归零一次,统计恒为 0)。
333
- const next = {
334
- okAt: now(),
335
- latencyMs: prev.latencyMs != null && typeof latencyMs === "number"
336
- ? Math.round(prev.latencyMs * (1 - EMA_ALPHA) + latencyMs * EMA_ALPHA)
337
- : (typeof latencyMs === "number" ? latencyMs : prev.latencyMs ?? 0),
338
- fails: 0,
339
- model: model || prev.model || "",
340
- streak429: 0,
341
- deadFails: 0,
342
- };
343
- // 探测死亡标记:请求成功 = 节点活着,冷却窗口立即解除。
344
- // next 本身不含 probeDeadAt 键,天然清除;这里只兜底确保没有残留。
345
- const acc = accOf(prev);
346
- if (!provisional) {
347
- acc.wins += 1;
348
- bumpDayInto(acc, "ok");
349
- }
350
- stats[url] = { ...next, ...acc };
351
- delete stats[url].probeDeadAt;
352
- } else {
353
- const prev = stats[url] || {};
354
- const acc = accOf(prev);
355
- acc.failTotal += 1;
356
- bumpDayInto(acc, "fail");
357
- stats[url] = { ...prev, fails: (prev.fails || 0) + 1, ...acc };
358
- }
359
- await persistStats({ ...stats });
360
- }
361
-
362
- // 探测死亡标记(health 连不上):probeDeadAt 置为此刻,10min 内冷却。不反复探。
363
- // recordProbeOk 在探测成功时清标记(节点回来了,冷却窗口立刻结束)。
364
- async function recordProbeDead(url) {
365
- if (!url) return;
366
- const prev = stats[url] || {};
367
- stats[url] = { ...prev, probeDeadAt: now() };
368
- await persistStats({ ...stats });
369
- }
370
-
371
- async function recordProbeOk(url) {
372
- if (!url) return;
373
- const prev = stats[url] || {};
374
- if (!("probeDeadAt" in prev)) return;
375
- delete prev.probeDeadAt;
376
- stats[url] = prev;
377
- await persistStats({ ...stats });
378
- }
379
-
380
- return {
381
- all, add, remove, removeByGroup, isCooling, isBroadbandCooling, isBroadbandStale: (url) => {
382
- const peer = list.find((p) => p.url === url);
383
- return peer ? isBroadbandStale(peer, now(), broadbandStaleMs()) : false;
384
- }, isHot, stat, ordered, orderedByLastError, coolingByLastError, available, next,
385
- standbyChain, nextStandby, isLeaderPeer,
386
- recordError, recordResult, recordProbeDead, recordProbeOk, errors: () => ({ ...lastErrorAt }), stats: () => ({ ...stats }),
387
- todayKey,
388
- };
389
- }
1
+ import { loadPeers, savePeers, loadPeerErrors, savePeerErrors, loadPeerStats, savePeerStats } from "./state.js";
2
+
3
+ // 组员失败冷却分级(2026-10-18 用户拍板):
4
+ // 非额度失败(502/5xx/超时,可能几秒就恢复)→ 短冷却 10s,到点自动重新纳入候选、继续挨个重试;
5
+ // 额度类(连续 2 次 429)→ 长冷却 10min(额度当天不会因快重试恢复,反复撞只会白烧配额)。
6
+ export const DEFAULT_PEER_COOLDOWN_MS = 10_000;
7
+ export const DEFAULT_PEER_LIMIT_COOLDOWN_MS = 10 * 60_000;
8
+ export const DEFAULT_PEER_HEAT_MS = 5 * 60_000;
9
+ export const DEFAULT_MAX_HOPS = 3;
10
+ export const DEFAULT_BROADBAND_STALE_MS = 90_000;
11
+ // 连续几次连接级失败即视为"这个地址没服务",进长冷却(不再靠 30s 短冷却反复捞回)。
12
+ export const DEAD_FAIL_THRESHOLD = 2;
13
+ // 探测失败(GET /health 连不上)视为"这个节点当前不可用",10 分钟内不再当候选。
14
+ // 与请求失败账分开:health 探测是**主动、零额度**的可用性判断,比"这一发不顺"更可信——
15
+ // 现网 2026-10-20 13:27 req mvg8r7cz-b8qn:152.67.222.233:62028 早已 health timeout,
16
+ // CLI/保热都探到了,但选路不知道,仍把它排进候选并吃满 20s 单次上限(总 27s)。
17
+ export const DEFAULT_PEER_PROBE_DEAD_MS = 10 * 60_000;
18
+
19
+ export function peerProbeDeadMs() {
20
+ const n = Number(process.env.MSLXDFF_PEER_PROBE_DEAD_MS);
21
+ return Number.isInteger(n) && n >= 0 ? n : DEFAULT_PEER_PROBE_DEAD_MS;
22
+ }
23
+
24
+ const EMA_ALPHA = 0.3;
25
+
26
+ export function broadbandStaleMs() {
27
+ const n = Number(process.env.MSLXDFF_BROADBAND_STALE_MS);
28
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_BROADBAND_STALE_MS;
29
+ }
30
+
31
+ export function isBroadbandStale(peer, nowMs = Date.now(), staleMs = broadbandStaleMs()) {
32
+ const kind = peer?.kind || (String(peer?.url || "").startsWith("relay://") ? "broadband" : "static");
33
+ if (kind !== "broadband") return false;
34
+ const lastSeen = peer?.lastSeen;
35
+ if (typeof lastSeen !== "number") return true; // no heartbeat yet => stale
36
+ return nowMs - lastSeen > staleMs;
37
+ }
38
+
39
+ export function normalizePeerUrl(url) {
40
+ return String(url || "").trim().replace(/\/+$/, "");
41
+ }
42
+
43
+ export function createPeersService({
44
+ file,
45
+ now = () => Date.now(),
46
+ cooldownMs = DEFAULT_PEER_COOLDOWN_MS,
47
+ limitCooldownMs = DEFAULT_PEER_LIMIT_COOLDOWN_MS,
48
+ heatMs = DEFAULT_PEER_HEAT_MS,
49
+ peers: seedPeers,
50
+ errors: seedErrors,
51
+ stats: seedStats,
52
+ persistPeers = (list, f = file) => savePeers(list, f ? { file: f } : {}),
53
+ persistErrors = (errors, f = file) => savePeerErrors(errors, f ? { file: f } : {}),
54
+ persistStats = (stats, f = file) => savePeerStats(stats, f ? { file: f } : {}),
55
+ } = {}) {
56
+ const list = (seedPeers ?? loadPeers(file ? { file } : {}))
57
+ .map((p) => ({ ...p, url: normalizePeerUrl(p.url) }))
58
+ .filter((p) => p && p.url);
59
+ const lastErrorAt = { ...(seedErrors ?? loadPeerErrors(file ? { file } : {})) };
60
+ const stats = { ...(seedStats ?? loadPeerStats(file ? { file } : {})) };
61
+
62
+ function all() {
63
+ return [...list];
64
+ }
65
+
66
+ function add(peer) {
67
+ const url = normalizePeerUrl(peer?.url);
68
+ if (!url) return false;
69
+ const existing = list.find((p) => p.url === url);
70
+ const entry = { ...peer, url };
71
+ if (existing) Object.assign(existing, entry);
72
+ else list.push(entry);
73
+ persistPeers([...list]);
74
+ return true;
75
+ }
76
+
77
+ function remove(url) {
78
+ const target = normalizePeerUrl(url);
79
+ const idx = list.findIndex((p) => p.url === target);
80
+ if (idx < 0) return false;
81
+ list.splice(idx, 1);
82
+ persistPeers([...list]);
83
+ return true;
84
+ }
85
+
86
+ function removeByGroup(group) {
87
+ const before = list.length;
88
+ for (let i = list.length - 1; i >= 0; i--) {
89
+ if (list[i].group === group) list.splice(i, 1);
90
+ }
91
+ if (list.length !== before) persistPeers([...list]);
92
+ return before - list.length;
93
+ }
94
+
95
+ // 分级冷却:连续 429(streak >= 2)→ limitCooldownMs(限流恢复慢,5min 内不再考虑);
96
+ // 连接级永久失败(ECONNREFUSED/ENOTFOUND,deadFails >= 2)同样进长冷却;
97
+ // 其他失败(网络抖动、5xx)→ cooldownMs(可能几秒恢复,给快速重试机会)。
98
+ function coolingWindowMs(url) {
99
+ // 探测死亡(health 连不上):10min 内不再考虑——比请求失败更可信的"没服务"证据
100
+ if (stats[url]?.probeDeadAt !== undefined && now() - stats[url].probeDeadAt < peerProbeDeadMs()) return limitCooldownMs;
101
+ if ((stats[url]?.deadFails ?? 0) >= DEAD_FAIL_THRESHOLD) return limitCooldownMs;
102
+ const streak = stats[url]?.streak429 ?? 0;
103
+ return streak >= 2 ? limitCooldownMs : cooldownMs;
104
+ }
105
+
106
+ // 连接级失败(端口没服务/DNS 解析不了)与"这一发不顺"是两回事:前者换多少次都一样,
107
+ // 现网 node-b(127.0.0.1:3500,本机根本没服务)靠 30s 短冷却 + 兜底,被反复竞速 1569 次。
108
+ function isConnLevelFailure(message) {
109
+ const s = String(message || "");
110
+ return /ECONNREFUSED|ENOTFOUND|ENETUNREACH|EHOSTUNREACH|ECONNRESET|fetch failed|connect (?:timeout|ETIMEDOUT)|socket hang up/i.test(s);
111
+ }
112
+
113
+ function isCooling(url) {
114
+ // 探测死亡(health 探不通):10min 内不再当候选——比请求失败更可信的"没服务"证据
115
+ const pDead = stats[url]?.probeDeadAt;
116
+ if (pDead !== undefined && now() - pDead < peerProbeDeadMs()) return true;
117
+ const win = coolingWindowMs(url);
118
+ if (!win) return false;
119
+ const err = lastErrorAt[url];
120
+ if (typeof err === "number" && now() - err < win) return true;
121
+ const peer = list.find((p) => p.url === url);
122
+ if (peer && isBroadbandStale(peer, now(), broadbandStaleMs())) return true;
123
+ return false;
124
+ }
125
+
126
+ function isBroadbandCooling(url) {
127
+ const peer = list.find((p) => p.url === url);
128
+ if (!peer) return false;
129
+ return isBroadbandStale(peer, now(), broadbandStaleMs());
130
+ }
131
+
132
+ function available() {
133
+ return list.filter((p) => !isCooling(p.url));
134
+ }
135
+
136
+ // A peer counts as "hot" when it succeeded recently: reuse its last model
137
+ // without a fresh health probe to keep the fast path fast.
138
+ function isHot(url, t = now()) {
139
+ if (isCooling(url)) return false;
140
+ const s = stats[url];
141
+ return Boolean(s?.okAt) && t - s.okAt < heatMs;
142
+ }
143
+
144
+ function stat(url) {
145
+ return stats[url] ? { ...stats[url] } : null;
146
+ }
147
+
148
+ function rankScore(p, t) {
149
+ const s = stats[p.url];
150
+ const hot = s?.okAt && t - s.okAt < heatMs ? 0 : 1;
151
+ const latency = s?.latencyMs != null ? s.latencyMs : 1_000_000;
152
+ const fails = s?.fails ?? 0;
153
+ return hot * 1_000_000_000 + latency * 1_000 + fails;
154
+ }
155
+
156
+ // 可用组员排序(用户 2026-10-19 决定):**延迟最小优先**,不再「热优先」。
157
+ // 语义 = 「组员的额度用完后,用跟组长延迟最小的组员」——延迟是首要判据。
158
+ // 延迟来源(优先级从高到低):
159
+ // ① stats 里的真实请求 EMA(recordResult 记的 latencyMs)——最可信;
160
+ // ② peer.heartbeatLatencyMs —— 组员**定时心跳**上报的 rtt(没跑过真实请求时也能排序);
161
+ // 未测过延迟的排在有数据之后;同延迟按「最早失败优先」;都没失败过的排最后。
162
+ // 组长识别:本机是组员时,peers 里有 name==="leader" 的记录(cli/group-test.js 同口径)。
163
+ // 组长自己节点视角没有这条记录 → isLeaderPeer 恒 false,退化为纯延迟排序(正是想要的行为)。
164
+ function isLeaderPeer(p) {
165
+ return String(p?.name || "").toLowerCase() === "leader";
166
+ }
167
+
168
+ function peerLatency(p) {
169
+ const s = stats[p.url]?.latencyMs;
170
+ if (s != null) return s;
171
+ const hb = list.find((x) => x.url === p.url)?.heartbeatLatencyMs;
172
+ return Number.isFinite(Number(hb)) ? Number(hb) : null;
173
+ }
174
+
175
+ // 组员延迟升序(组长不参与),供两处复用:ordered 的第二档 + standbyChain。
176
+ function byLatencyAsc(a, b) {
177
+ const la = peerLatency(a);
178
+ const lb = peerLatency(b);
179
+ const ha = la != null, hb = lb != null;
180
+ if (ha !== hb) return ha ? -1 : 1; // 有延迟数据的优先
181
+ if (ha && hb && la !== lb) return la - lb; // 延迟小的先上
182
+ const ea = lastErrorAt[a.url];
183
+ const eb = lastErrorAt[b.url];
184
+ if (ea == null && eb == null) return 0;
185
+ if (ea == null) return 1;
186
+ if (eb == null) return -1;
187
+ return ea - eb;
188
+ }
189
+
190
+ // 用户 2026-10-20 约定:**组长恒定第一**(先榨干组长额度),组长用完再按延迟升序上组员。
191
+ // 之前是纯延迟排序,「9/9 走组长」只是组长恰好延迟最低的巧合(AGENTS.md 已记差距)。
192
+ function ordered(t = now()) {
193
+ return available().sort((a, b) => {
194
+ const la = isLeaderPeer(a), lb = isLeaderPeer(b);
195
+ if (la !== lb) return la ? -1 : 1; // 组长恒排第一
196
+ return byLatencyAsc(a, b);
197
+ });
198
+ }
199
+
200
+ /**
201
+ * 常驻接力链(用户 2026-10-20:永远准备一个备选,429 立即切)。
202
+ * IPv4 阶段:组长 → 组员(延迟升序);IPv4 全用完才进 IPv6 阶段:先本机/组长 v6,
203
+ * 再按延迟升序找 hasV6 的组员。
204
+ * @returns {{ipv4: object[], ipv6: object[]}} 两段链,各自已排序
205
+ */
206
+ function standbyChain(t = now()) {
207
+ const ipv4 = available().sort((a, b) => {
208
+ const la = isLeaderPeer(a), lb = isLeaderPeer(b);
209
+ if (la !== lb) return la ? -1 : 1;
210
+ return byLatencyAsc(a, b);
211
+ });
212
+ // IPv6 阶段:只保留心跳自报 hasV6 的节点,同样组长优先、其余延迟升序
213
+ const ipv6 = ipv4.filter((p) => p.hasV6 === true);
214
+ return { ipv4, ipv6 };
215
+ }
216
+
217
+ /** 下一个备选:从链里取第一个还没试过的。这就是「永远准备好的那个」。 */
218
+ function nextStandby(tried, t = now()) {
219
+ const { ipv4, ipv6 } = standbyChain(t);
220
+ const skip = tried instanceof Set ? tried : new Set(tried || []);
221
+ return {
222
+ ipv4: ipv4.find((p) => !skip.has(p.url)) || null,
223
+ ipv6: ipv6.find((p) => !skip.has(p.url)) || null,
224
+ };
225
+ }
226
+
227
+ // Available peers ordered specifically for recovery-time retry: earliest
228
+ // failure first (it has had the longest to come back), never-failed last.
229
+ function orderedByLastError(t = now()) {
230
+ return available().sort((a, b) => {
231
+ const ea = lastErrorAt[a.url];
232
+ const eb = lastErrorAt[b.url];
233
+ if (ea == null && eb == null) return 0;
234
+ if (ea == null) return 1; // never-failed peers go after any that failed
235
+ if (eb == null) return -1;
236
+ return ea - eb; // earliest failure first
237
+ });
238
+ }
239
+
240
+ let cursor = 0;
241
+
242
+ // 兜底重试集合:仅"因错误冷却中"的 peer(按最早失败优先)。
243
+ // 场景:唯一可用节点偶发失败被冷却锁死 → 主链零候选/全败时,兜底给它一次机会;
244
+ // 成功会被 recordResult 清除错误记录、回归热路径,避免"明明能用的节点被 30s 冷却全灭"。
245
+ function coolingByLastError() {
246
+ const t = now();
247
+ return list
248
+ .filter((p) => {
249
+ const err = lastErrorAt[p.url];
250
+ if (typeof err !== "number") return false;
251
+ if (stats[p.url]?.probeDeadAt !== undefined && now() - stats[p.url].probeDeadAt < peerProbeDeadMs()) return false; // 探测死亡:兜底也不考虑(否则每次白等一次拒连)
252
+ if ((stats[p.url]?.streak429 ?? 0) >= 2) return false; // 429 长冷却:兜底也不考虑
253
+ if ((stats[p.url]?.deadFails ?? 0) >= DEAD_FAIL_THRESHOLD) return false; // 连接级永久失败:兜底也不考虑(否则每次白等一次拒连)
254
+ return t - err < cooldownMs;
255
+ })
256
+ .sort((a, b) => (lastErrorAt[a.url] ?? 0) - (lastErrorAt[b.url] ?? 0));
257
+ }
258
+
259
+ function next() {
260
+ const avail = available();
261
+ if (!avail.length) return null;
262
+ cursor = cursor % avail.length;
263
+ return avail[cursor++];
264
+ }
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
+
295
+ // Long-lived error memory: a peer keeps its last-error timestamp until a
296
+ // subsequent success resets it (success clears the failure record) or the
297
+ // error is no longer in the persist store on next load.
298
+ async function recordError(url, { status, message } = { }) {
299
+ if (!url) return;
300
+ lastErrorAt[url] = now();
301
+ await persistErrors({ ...lastErrorAt });
302
+ // 连续 429 计数(仅成功清零):第 2 次起进入长冷却——该 peer 大概率真被限流,不再频繁试它
303
+ if (Number(status) === 429) {
304
+ const prev = stats[url] || {};
305
+ stats[url] = { ...prev, streak429: (prev.streak429 || 0) + 1 };
306
+ await persistStats({ ...stats });
307
+ }
308
+ // 连接级失败计数(仅成功清零):第 2 次起进长冷却,且不再被 coolingByLastError 兜底捞回
309
+ if (isConnLevelFailure(message)) {
310
+ const prev = stats[url] || {};
311
+ stats[url] = { ...prev, deadFails: (prev.deadFails || 0) + 1 };
312
+ await persistStats({ ...stats });
313
+ }
314
+ }
315
+
316
+ // Outcome of a forwarded request: ok updates the hot-cache (EMA latency,
317
+ // last successful model) and clears the error memory; failures keep the
318
+ // error timestamp so the retry pass can order by recovery time.
319
+ // 累计计数(用户 2026-10-23):okAt/fails 是**瞬时**状态(成功清 fails、失败清 okAt),
320
+ // 看不出「这个节点一共替我扛了多少次」。故另立只增不减的 wins/failTotal 与今日分桶,
321
+ // 用于回答「各节点成功转发次数是否合理」——它是排障读数,不参与选路打分。
322
+ async function recordResult(url, { ok, latencyMs, model, provisional = false } = {}) {
323
+ if (!url) return;
324
+ if (ok) {
325
+ if (lastErrorAt[url] != null) {
326
+ delete lastErrorAt[url];
327
+ await persistErrors({ ...lastErrorAt });
328
+ }
329
+ const prev = stats[url] || {};
330
+ // ⚠ 成功分支用 next **整体重建**该节点条目(清 fails/streak429/deadFails 等瞬时量),
331
+ // 所以累计量 wins/failTotal/days 必须经 acc 显式带回去——早先"先写 stats 再重建"的
332
+ // 写法等于每次成功把累计抹掉(成功一次归零一次,统计恒为 0)。
333
+ const next = {
334
+ okAt: now(),
335
+ latencyMs: prev.latencyMs != null && typeof latencyMs === "number"
336
+ ? Math.round(prev.latencyMs * (1 - EMA_ALPHA) + latencyMs * EMA_ALPHA)
337
+ : (typeof latencyMs === "number" ? latencyMs : prev.latencyMs ?? 0),
338
+ fails: 0,
339
+ model: model || prev.model || "",
340
+ streak429: 0,
341
+ deadFails: 0,
342
+ };
343
+ // 探测死亡标记:请求成功 = 节点活着,冷却窗口立即解除。
344
+ // next 本身不含 probeDeadAt 键,天然清除;这里只兜底确保没有残留。
345
+ const acc = accOf(prev);
346
+ if (!provisional) {
347
+ acc.wins += 1;
348
+ bumpDayInto(acc, "ok");
349
+ }
350
+ stats[url] = { ...next, ...acc };
351
+ delete stats[url].probeDeadAt;
352
+ } else {
353
+ const prev = stats[url] || {};
354
+ const acc = accOf(prev);
355
+ acc.failTotal += 1;
356
+ bumpDayInto(acc, "fail");
357
+ stats[url] = { ...prev, fails: (prev.fails || 0) + 1, ...acc };
358
+ }
359
+ await persistStats({ ...stats });
360
+ }
361
+
362
+ // 探测死亡标记(health 连不上):probeDeadAt 置为此刻,10min 内冷却。不反复探。
363
+ // recordProbeOk 在探测成功时清标记(节点回来了,冷却窗口立刻结束)。
364
+ async function recordProbeDead(url) {
365
+ if (!url) return;
366
+ const prev = stats[url] || {};
367
+ stats[url] = { ...prev, probeDeadAt: now() };
368
+ await persistStats({ ...stats });
369
+ }
370
+
371
+ async function recordProbeOk(url) {
372
+ if (!url) return;
373
+ const prev = stats[url] || {};
374
+ if (!("probeDeadAt" in prev)) return;
375
+ delete prev.probeDeadAt;
376
+ stats[url] = prev;
377
+ await persistStats({ ...stats });
378
+ }
379
+
380
+ return {
381
+ all, add, remove, removeByGroup, isCooling, isBroadbandCooling, isBroadbandStale: (url) => {
382
+ const peer = list.find((p) => p.url === url);
383
+ return peer ? isBroadbandStale(peer, now(), broadbandStaleMs()) : false;
384
+ }, isHot, stat, ordered, orderedByLastError, coolingByLastError, available, next,
385
+ standbyChain, nextStandby, isLeaderPeer,
386
+ recordError, recordResult, recordProbeDead, recordProbeOk, errors: () => ({ ...lastErrorAt }), stats: () => ({ ...stats }),
387
+ todayKey,
388
+ };
389
+ }