mslxdff 0.1.244 → 0.1.246

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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/chat-pipeline/terminal-verdict.js +4 -4
  3. package/src/cli/commands/daemon.js +1 -1
  4. package/src/cli/index.js +0 -5
  5. package/src/daemon.js +3 -3
  6. package/src/runtime/bootstrap.js +1 -1
  7. package/src/runtime/proc-info.js +2 -2
  8. package/src/runtime/providers-setup.js +2 -4
  9. package/src/server.js +3 -3
  10. package/src/upstream-engine/index.js +2 -17
  11. package/src/upstream.js +1 -61
  12. package/src/cli/commands/multi-ip-pool-fetch.js +0 -173
  13. package/src/cli/commands/multi-ip-pool-test.js +0 -136
  14. package/src/cli/commands/multi-ip-pool.js +0 -83
  15. package/src/cli/commands/v6-pool.js +0 -123
  16. package/src/multi-ip-pool/config.js +0 -155
  17. package/src/multi-ip-pool/diagnose.js +0 -159
  18. package/src/multi-ip-pool/dial.js +0 -179
  19. package/src/multi-ip-pool/dispatch.js +0 -244
  20. package/src/multi-ip-pool/index.js +0 -174
  21. package/src/multi-ip-pool/nodes-fetch.js +0 -110
  22. package/src/multi-ip-pool/nodes.js +0 -205
  23. package/src/multi-ip-pool/report.js +0 -162
  24. package/src/multi-ip-pool/selftest.js +0 -168
  25. package/src/multi-ip-pool/standby.js +0 -157
  26. package/src/multi-ip-pool/tunnel.js +0 -291
  27. package/src/v6pool/claim.js +0 -157
  28. package/src/v6pool/config.js +0 -65
  29. package/src/v6pool/detect.js +0 -130
  30. package/src/v6pool/index.js +0 -157
  31. package/src/v6pool/prefix.js +0 -140
  32. package/src/v6pool/probe.js +0 -40
  33. package/src/v6pool/report.js +0 -104
  34. package/src/v6pool/rotate.js +0 -263
  35. package/src/v6pool/selftest.js +0 -178
  36. package/src/warp-pool/config.js +0 -74
  37. package/src/warp-pool/health.js +0 -106
  38. package/src/warp-pool/identity.js +0 -141
  39. package/src/warp-pool/index.js +0 -358
  40. package/src/warp-pool/keys.js +0 -29
  41. package/src/warp-pool/nodes.js +0 -95
  42. package/src/warp-pool/ports.js +0 -160
  43. package/src/warp-pool/register.js +0 -113
  44. package/src/warp-pool/report.js +0 -232
  45. package/src/warp-pool/revive.js +0 -168
  46. package/src/warp-pool/rotate.js +0 -214
  47. package/src/warp-pool/strategy.js +0 -43
  48. package/src/warp-pool/verdict.js +0 -69
  49. package/src/warp-pool/wireproxy.js +0 -162
@@ -1,263 +0,0 @@
1
- // v6pool · 槽位 dispatcher:每槽一个源地址 + 一个自带 `localAddress` 的 undici Agent。
2
- //
3
- // 契约与 warp-pool 的轮换 dispatcher 一致:`dispatch(opts, handler)` + `close()`,
4
- // 注入点同为 `transport` 的 extDispatcher(legacy 与 SDK 两条路共用同一个对象,见 MODULES.md §1)。
5
- //
6
- // 与 WARP 池的差别只在「换出口」这一步:
7
- // WARP:重注册设备 + 重启隧道(实测 5~8 秒,还会被 CF 注册限流)
8
- // v6 :从前缀里挑一个新地址(≈0 成本:只多一条 `ip addr add`,bind 模式下连这条都省)
9
- // 判决表照旧复用 warp-pool/verdict.js(403 FreeTierError 与 429 FreeUsageLimitError 同一条件 ⇒ 强制换)。
10
- //
11
- // 两个刻意的设计:
12
- // ① 换址**不重置**旧槽的冷却账,而是把旧地址推进 retiring:先关旧 Agent、延后释放地址,
13
- // 在飞请求(SSE 长连接)不会因为源地址被抽走而断。retiring 有硬上限 RETAIN_MAX。
14
- // ② 每次响应都记账(不只 403/429):`fmtFirstOk` 那行「新地址首个 200」就是
15
- // 「429 换 IP 到底有没有换来新额度」的直接证据——这正是本次要回答的问题。
16
- import { getUndici } from "../compat.js";
17
- import { headerValue, tapHandler } from "../warp-pool/rotate.js";
18
- import { createSelector } from "../warp-pool/strategy.js";
19
- import { SIGNAL_STATUSES, classifyWarpSignal } from "../warp-pool/verdict.js";
20
- import { fmtDirect, fmtDispatch, fmtFirstOk, fmtNoRotate, fmtRetire, fmtSwap, fmtSwapFail, fmtVerdictNo } from "./report.js";
21
-
22
- const SESSION_HEADER = "x-opencode-session";
23
-
24
- /** 最多留几个「已换下但地址还在网卡上」的旧出口:保护在飞请求,同时给地址数量一个硬上限。 */
25
- export const RETAIN_MAX = 8;
26
- /** 挑新地址时最多重试几次(前缀太小/已在用的地址太多时会一直撞)。 */
27
- export const PICK_TRIES = 16;
28
-
29
- /**
30
- * @param {{ addrs?: string[], strategy?: "session"|"request", retireMs?: number, rotate?: boolean, debug?: boolean,
31
- * now?: () => number, log?: (s: string) => void, claimer?: object|null, pickAddress?: () => string,
32
- * agentFactory?: (addr: string) => object, agentOptions?: object, fallbackAgent?: object, selector?: object }} [opts]
33
- */
34
- export function createV6Dispatcher({
35
- addrs = [],
36
- strategy = "session",
37
- retireMs = 60_000,
38
- rotate = true,
39
- debug = false,
40
- now = Date.now,
41
- log = () => {},
42
- claimer = null,
43
- pickAddress = () => "",
44
- agentFactory,
45
- agentOptions = { connections: 4, pipelining: 1, keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000 },
46
- fallbackAgent,
47
- selector = createSelector({ strategy }),
48
- } = {}) {
49
- const { Agent } = getUndici();
50
- if (typeof agentFactory !== "function") {
51
- if (typeof Agent !== "function") throw new Error("undici Agent 不可用:无法建立 v6 出口池");
52
- agentFactory = (addr) => new Agent({ connect: { localAddress: addr }, ...agentOptions });
53
- }
54
- const fallback = fallbackAgent !== undefined ? fallbackAgent : typeof Agent === "function" ? new Agent(agentOptions) : null;
55
-
56
- let list = [];
57
- /** @type {Array<{addr:string, agent:object, at:number, release:null|Function, counts:object, swaps:number}>} */
58
- const retiring = [];
59
- const history = [];
60
- let directWarned = false;
61
-
62
- function buildSlot(index, addr) {
63
- return { index, addr, agent: agentFactory(addr), cooldownUntil: 0, swaps: 0, swapping: false, counts: {}, firstOkAt: 0 };
64
- }
65
-
66
- /** 装上源地址(bootstrap 探活通过后调用;空数组 = 池为空 ⇒ 直连兜底)。 */
67
- function setAddresses(next = []) {
68
- list = (next || []).map((a, i) => buildSlot(i, a));
69
- return list.length;
70
- }
71
- // 构造时就装上地址(与 warp-pool 的轮换 dispatcher 同形状:入参即初始池)。
72
- setAddresses(addrs);
73
-
74
- function availableIndexes() {
75
- const t = now();
76
- return list.filter((e) => e.cooldownUntil <= t && !e.swapping).map((e) => e.index);
77
- }
78
-
79
- /** 退休账结算:只有「已过退休时长」且「超出保留上限」的才真关+真释放(close 时 force 全清)。 */
80
- async function flushRetired({ force = false } = {}) {
81
- const sorted = [...retiring].sort((a, b) => a.at - b.at);
82
- const over = Math.max(0, sorted.length - RETAIN_MAX);
83
- const keep = [];
84
- for (let i = 0; i < sorted.length; i++) {
85
- const r = sorted[i];
86
- const aged = now() - r.at >= retireMs;
87
- if (!force && !(aged && i < over)) {
88
- keep.push(r);
89
- continue;
90
- }
91
- try {
92
- await r.agent?.close?.();
93
- } catch {
94
- /* 关不掉旧 Agent 不影响新出口 */
95
- }
96
- if (r.release) {
97
- try {
98
- await r.release();
99
- } catch {
100
- /* 释放失败已由 claim.js 自己打日志 */
101
- }
102
- }
103
- history.push({ addr: r.addr, swaps: r.swaps, counts: { ...r.counts } });
104
- if (history.length > 32) history.shift();
105
- log(fmtRetire({ addr: r.addr }));
106
- }
107
- retiring.length = 0;
108
- retiring.push(...keep);
109
- }
110
-
111
- /** 换该槽的源地址:挑新地址 → 认领 → 建新 Agent → 旧 Agent 退休(先关 Agent 再延后释放地址)。 */
112
- async function swap(index, verdict = {}) {
113
- const e = list[index];
114
- if (!e) return { skipped: true };
115
- if (e.swapping) return { skipped: true, reason: "该槽已在换址中" };
116
- e.swapping = true;
117
- try {
118
- const taken = new Set(list.map((s) => s.addr));
119
- let addr = "";
120
- for (let i = 0; i < PICK_TRIES && (!addr || taken.has(addr)); i++) addr = pickAddress();
121
- if (!addr || taken.has(addr)) throw new Error(`前缀里连续 ${PICK_TRIES} 次都撞上已在用的地址(前缀太小?)`);
122
- if (claimer) await claimer.ensure(addr);
123
- const oldAddr = e.addr;
124
- const prevCounts = { ...e.counts };
125
- const prevSwaps = e.swaps;
126
- retiring.push({
127
- addr: oldAddr,
128
- agent: e.agent,
129
- at: now(),
130
- release: claimer ? () => claimer.release(oldAddr) : null,
131
- counts: prevCounts,
132
- swaps: prevSwaps,
133
- });
134
- e.addr = addr;
135
- e.agent = agentFactory(addr);
136
- e.cooldownUntil = 0;
137
- e.swaps = prevSwaps + 1;
138
- e.counts = {};
139
- e.firstOkAt = 0;
140
- log(fmtSwap({ from: oldAddr, to: addr, status: verdict.status, kind: verdict.kind, forced: verdict.forced, swaps: e.swaps, prevCounts }));
141
- await flushRetired();
142
- return { ok: true, from: oldAddr, to: addr };
143
- } finally {
144
- e.swapping = false;
145
- }
146
- }
147
-
148
- /** 上游响应旁听:记账 → 换址后首个 200 的取证行 → 配额信号才换址。 */
149
- function onTap(signal, e) {
150
- const status = Number(signal?.status);
151
- e.counts[status] = (e.counts[status] || 0) + 1;
152
- if (status === 200 && !e.firstOkAt) {
153
- e.firstOkAt = now();
154
- log(fmtFirstOk({ addr: e.addr, swaps: e.swaps, counts: e.counts }));
155
- }
156
- if (!SIGNAL_STATUSES.includes(status)) return;
157
- const v = classifyWarpSignal(signal);
158
- if (!rotate) {
159
- log(fmtNoRotate({ addr: e.addr, status, kind: v.kind }));
160
- return;
161
- }
162
- if (!v.rotate) {
163
- log(fmtVerdictNo({ addr: e.addr, status, kind: v.kind, why: v.why }));
164
- return;
165
- }
166
- const addr = e.addr;
167
- void swap(e.index, { ...v, status }).catch((err) => log(fmtSwapFail({ addr, error: String(err?.message || err) })));
168
- }
169
-
170
- return {
171
- /** 每次请求调一次 = 选槽粒度(session 策略下同会话粘同一源地址)。 */
172
- dispatch(opts, handler) {
173
- if (!list.length) {
174
- if (fallback) return fallback.dispatch(opts, handler);
175
- throw new Error("v6pool:源地址池为空且无直连兜底");
176
- }
177
- const avail = availableIndexes();
178
- if (!avail.length) {
179
- // 全池都在冷却/换址中 ⇒ 直连兜底优先:硬派给正在换址的槽 = 把请求挂到超时
180
- if (!directWarned) {
181
- directWarned = true;
182
- log(fmtDirect({ reason: `全部 ${list.length} 个源地址都在冷却/换址中(下一条恢复后自动回到池内)` }));
183
- }
184
- return (fallback || list[0].agent).dispatch(opts, handler);
185
- }
186
- directWarned = false;
187
- const sessionKey = strategy === "session" ? headerValue(opts?.headers, SESSION_HEADER) : "";
188
- const e = list[selector.pick(avail, sessionKey)];
189
- if (debug) log(fmtDispatch({ addr: e.addr, session: sessionKey || "(无)" }));
190
- return e.agent.dispatch(opts, tapHandler(handler, (sig) => onTap(sig, e)));
191
- },
192
-
193
- setAddresses,
194
-
195
- /** 手动换某个槽的源地址(供运维/测试;正常路径由配额信号触发)。 */
196
- swap,
197
-
198
- /**
199
- * G1a(ADR-0061):**请求内**换出口——撞出口池级 429 后由 upstream.js await 这一发,
200
- * 把该会话粘的槽换新源地址,返回**换址完成后**的新 Agent 供同一请求立刻重发。
201
- * 与 onTap 的 fire-and-forget 换址(只救下一个请求)不同,这里救的是**在飞请求**。
202
- * 仅 v6 池提供本方法:换源地址成本≈0(bind/ip addr add);WARP 的换出口=重注册设备
203
- * (6s~600s,会被 CF 限流),绝不允许在请求内做——白等 N 分钟换不来更快收场。
204
- * 空池/全槽换址中/该槽被并发占走 ⇒ 返回 null(调用方正常收场,绝不黑洞)。
205
- * 粘性安全:swap 是**原地**换 e.addr/e.agent(index 不变),session→slot 映射不失效。
206
- * @param {string} sessionKey x-opencode-session 值(无 key 时由轮转游标挑槽)
207
- * @returns {Promise<null | {agent: object, addr: string, from: string}>}
208
- */
209
- async rotateFor(sessionKey = "") {
210
- if (!list.length) return null;
211
- const avail = availableIndexes();
212
- if (!avail.length) return null;
213
- const index = selector.pick(avail, strategy === "session" ? sessionKey : "");
214
- const e = list[index];
215
- if (!e) return null;
216
- const r = await swap(index, { kind: "in-request-rotate", status: 429, forced: true });
217
- if (r.skipped) return null;
218
- const slot = list[index];
219
- return { agent: slot.agent, addr: slot.addr, from: r.from };
220
- },
221
-
222
- /** 只读快照(含已退休出口的最终计数——"换址到底有没有换来新额度"看这里)。 */
223
- stats() {
224
- const t = now();
225
- return {
226
- slots: list.map((e) => ({ addr: e.addr, cooling: e.cooldownUntil > t, swaps: e.swaps, swapping: e.swapping, counts: { ...e.counts }, firstOk: !!e.firstOkAt })),
227
- history: history.map((h) => ({ ...h, counts: { ...h.counts } })),
228
- };
229
- },
230
-
231
- entries() {
232
- return list.map((e) => ({ index: e.index, addr: e.addr, cooldownUntil: e.cooldownUntil, swaps: e.swaps }));
233
- },
234
-
235
- async close() {
236
- const slots = list;
237
- list = [];
238
- for (const e of slots) {
239
- try {
240
- await e.agent?.close?.();
241
- } catch {
242
- /* 收摊时关不掉就算了 */
243
- }
244
- }
245
- await flushRetired({ force: true });
246
- if (fallback) {
247
- try {
248
- await fallback.close?.();
249
- } catch {
250
- /* 同上 */
251
- }
252
- }
253
- },
254
-
255
- async destroy() {
256
- return this.close();
257
- },
258
-
259
- get size() {
260
- return list.length;
261
- },
262
- };
263
- }
@@ -1,178 +0,0 @@
1
- // v6pool · 出口对照测试:回答「这台 VPS 的 IPv4 出口撞 429 时,换成 IPv6 出口还有没有额度」。
2
- //
3
- // 这才是 v6 池的**唯一价值判据**:opencode 免费层按**出口 IP** 计额。若本机 IPv4 已耗尽
4
- // (打上游回 429 FreeUsageLimitError),而某个 IPv6 出口还能 200 ⇒ 池有用(换 IP 换到新额度);
5
- // 若 IPv6 出口也 429 ⇒ 要么上游按别的东西计额(账号/session),要么该段没真路由出去。
6
- //
7
- // 对照纪律:**除"源地址"外一切相同** —— 同 URL、同 UA、同**身份四件套 header**、同模型、同 `stream:true`+五核心工具
8
- // (免费层要流式+五核心工具+身份头,缺任一项都会 403 FreeTierError 而非 429,测出来的不是额度);
9
- // 整轮基线+v6 共用同一个 session。差异只允许来自"哪个源地址出网"。
10
- // 判定复用 `warp-pool/verdict.js` 的 classifyWarpSignal(与生产换址判决同源,不自造一套)。
11
- //
12
- // IO 全部注入(agentFactory/fetch/detect/claimer),零真机依赖,可单测。
13
- import { getUndici, timeoutSignal } from "../compat.js";
14
- import { classifyWarpSignal } from "../warp-pool/verdict.js";
15
- import { opencodeUa, genId } from "../opencode-identity.js";
16
- import { CORE_AGENT_TOOL_NAMES } from "../free-lane.js";
17
- import { detectPool } from "./detect.js";
18
- import { resolveV6Config } from "./config.js";
19
- import { createClaimer } from "./claim.js";
20
- import { randomAddress, parsePrefix, hostBitsOf } from "./prefix.js";
21
-
22
- /** 采样上限:抽查,不扫全段。 */
23
- export const V6TEST_MAX = 64;
24
- export const V6TEST_DEFAULT = 6;
25
-
26
- /** 默认探测模型:免费池里最有代表性、额度最容易耗的一个。 */
27
- export const DEFAULT_PROBE_MODEL = "deepseek-v4.1-flash-free";
28
-
29
- /** 免费层请求体形状(stream:true + 五核心工具名),缺了会 403 而非 429 ⇒ 测不出额度。 */
30
- export function freeLaneBody(model, prompt = "hi") {
31
- return {
32
- model,
33
- stream: true,
34
- max_tokens: 16,
35
- messages: [{ role: "user", content: prompt }],
36
- tools: CORE_AGENT_TOOL_NAMES.map((name) => ({
37
- type: "function",
38
- function: { name, description: `The ${name} tool.`, parameters: { type: "object", properties: {} } },
39
- })),
40
- };
41
- }
42
-
43
- /** 读响应正文(有上界,失败给空串——不读就无法判决 403/429 的具名类名)。 */
44
- async function readBody(res, max = 2048) {
45
- try {
46
- const t = await res.text();
47
- return t.slice(0, max);
48
- } catch {
49
- return "";
50
- }
51
- }
52
-
53
- /** 免费层身份头(与生产 buildHeaders 同源,见 src/upstream.js):**缺了必 403 FreeTierError**。
54
- * 实测 2026-10-19:只发 User-Agent → 403;补上 x-opencode-* 四件套 → 200。
55
- * session 用随机 genId(对照测试测的是"出口 IP"不是"会话亲和",每个探针独立即可)。 */
56
- export function freeLaneHeaders({ session = genId("ses_") } = {}) {
57
- return {
58
- "Content-Type": "application/json",
59
- Accept: "text/event-stream",
60
- "User-Agent": opencodeUa(),
61
- "x-opencode-client": "desktop",
62
- "x-opencode-session": session,
63
- "x-opencode-request": genId("msg_"),
64
- "x-opencode-project": "global",
65
- "HTTP-Referer": "https://hermes-agent.nousresearch.com",
66
- "X-Title": "Hermes Agent",
67
- };
68
- }
69
-
70
- /**
71
- * 从某个源地址打一次免费层 chat,返回判决。
72
- * `addr=""` 表示**直连**(走本机默认路由 = IPv4 出口,或双栈默认)。
73
- * @returns {Promise<{addr, ok, status, kind, rotate, ms, body, error}>}
74
- */
75
- export async function probeUpstreamFrom({ addr = "", model, baseUrl = "https://opencode.ai", agentFactory, fetchImpl, timeoutMs = 20_000, prompt, session } = {}) {
76
- const t0 = Date.now();
77
- let agent = null;
78
- try {
79
- agent = addr ? agentFactory(addr) : null; // 直连:不注入 dispatcher,走系统默认出口
80
- const f = fetchImpl || getUndici().fetch;
81
- const res = await f(`${baseUrl}/zen/v1/chat/completions`, {
82
- method: "POST",
83
- headers: freeLaneHeaders({ session }),
84
- body: JSON.stringify(freeLaneBody(model, prompt)),
85
- ...(agent ? { dispatcher: agent } : {}),
86
- signal: timeoutSignal(timeoutMs),
87
- });
88
- const body = await readBody(res);
89
- const v = classifyWarpSignal({ status: res.status, text: body });
90
- return { addr, ok: res.status >= 200 && res.status < 300, status: res.status, kind: v.kind, rotate: v.rotate, ms: Date.now() - t0, body };
91
- } catch (e) {
92
- return { addr, ok: false, status: 0, kind: "network", rotate: true, ms: Date.now() - t0, body: "", error: String(e?.message || e) };
93
- } finally {
94
- if (agent) { try { await agent.close?.(); } catch { /* 一次性 Agent */ } }
95
- }
96
- }
97
-
98
- /**
99
- * 跑一次出口对照测试。返回结构化结果,由 CLI 渲染(本模块不打印)。
100
- *
101
- * @param {{ env?: object, count?: number, model?: string, baseUrl?: string,
102
- * onProgress?: (ev: object) => void,
103
- * deps?: { detect?, claimer?, agentFactory?, fetchImpl?, probe?, randomAddress? } }} opts
104
- */
105
- export async function runV6PoolCompare({
106
- env = process.env,
107
- count = V6TEST_DEFAULT,
108
- model = DEFAULT_PROBE_MODEL,
109
- baseUrl = "https://opencode.ai",
110
- onProgress,
111
- deps = {},
112
- } = {}) {
113
- const cfg = resolveV6Config(env);
114
- const tick = (ev) => { try { onProgress?.(ev); } catch { /* 渲染出错不打断测试 */ } };
115
-
116
- const detect = deps.detect || detectPool;
117
- const d = detect({ cfg, env });
118
- if (!d.ok) return { status: "skip", reason: d.reason, hint: d.hint };
119
- const prefix = parsePrefix(`${d.prefix}`);
120
- if (!prefix) return { status: "skip", reason: `前缀解析失败:${d.prefix}`, hint: "用 MSLXDFF_V6_PREFIX=<段>/<位数> 显式指定" };
121
-
122
- const { Agent } = getUndici();
123
- const agentFactory = deps.agentFactory || ((addr) => new Agent({ connect: { localAddress: addr }, connections: 1, keepAliveTimeout: 5_000 }));
124
- const probe = deps.probe || probeUpstreamFrom;
125
- const pick = deps.randomAddress || ((p) => randomAddress(p));
126
- // 整轮共用一个 session:对照纪律「除源地址外一切相同」——让差异只可能来自出口 IP,
127
- // 而不是会话随机性(免费层按出口 IP 计额,但共用 session 才能把这一点测干净)。
128
- const session = deps.session || genId("ses_");
129
-
130
- // ① 基准:直连(IPv4 / 系统默认出口),记下它的额度状态作为对照基线
131
- tick({ phase: "baseline-start", prefix: d.prefix, model });
132
- const baseline = await probe({ addr: "", model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
133
- tick({ phase: "baseline-done", ...baseline });
134
-
135
- // ② IPv6 池采样:每个随机源地址打同一条请求
136
- const n = Math.max(1, Math.min(V6TEST_MAX, Number(count) || V6TEST_DEFAULT));
137
- const claimer = deps.claimer ? deps.claimer(d, cfg) : createClaimer({ iface: d.iface, mode: d.mode, dir: cfg.dir });
138
- const mineSet = new Set(d.mine || []);
139
- const v6 = [];
140
- try {
141
- for (let i = 0; i < n; i++) {
142
- let addr = pick(prefix);
143
- for (let k = 0; k < 16 && mineSet.has(addr); k++) addr = pick(prefix);
144
- await claimer.ensure(addr);
145
- tick({ phase: "v6-start", i: i + 1, n, addr });
146
- const r = await probe({ addr, model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
147
- v6.push(r);
148
- tick({ phase: "v6-done", i: i + 1, n, ...r });
149
- }
150
- } finally {
151
- try { await claimer.releaseAll(); } catch { /* 释放失败交给下次 sweepStale */ }
152
- }
153
-
154
- // ③ 判语:对照基线
155
- const baseLimited = baseline.status === 429 || baseline.kind === "free-quota" || baseline.kind === "free-tier" || baseline.kind === "ip-limit";
156
- const v6Ok = v6.filter((r) => r.ok);
157
- // 受限 = 有 HTTP 响应但拿不到额度(403 FreeTierError / 429 额度 / 其余 4xx-5xx);
158
- // 只有网络层失败(status=0)才算"够不着"。旧实现只认 429,403 全 0/4 时会掉进兜底 "mixed"
159
- // 误判成「部分可用」(真机 2026-10-19 实测:4 个 v6 全 403 → 却报 0/4 部分可用)。
160
- const v6Limited = v6.filter((r) => !r.ok && r.status);
161
- const v6Net = v6.filter((r) => !r.status);
162
-
163
- let verdict;
164
- const allOk = v6Ok.length === v6.length && v6.length > 0;
165
- if (allOk) verdict = "v6-works"; // 全部 v6 出口都有额度
166
- else if (v6Limited.length === v6.length) verdict = "v6-also-limited"; // 全部受限 ⇒ 没换到新额度
167
- else if (v6Net.length === v6.length) verdict = "v6-unreachable"; // 全部够不着 ⇒ 段没路由过来
168
- else verdict = "mixed"; // 有的成有的不成
169
-
170
- return {
171
- status: "ok",
172
- prefix: d.prefix, iface: d.iface, mode: d.mode, hostBits: hostBitsOf(prefix.bits), model, baseUrl,
173
- baseline, baseLimited,
174
- v6, requested: n,
175
- v6OkCount: v6Ok.length, v6LimitedCount: v6Limited.length, v6NetCount: v6Net.length,
176
- verdict,
177
- };
178
- }
@@ -1,74 +0,0 @@
1
- // warp-pool · 配置解析:env 优先,缺省值集中在一处;纯函数,便于单测。
2
- import { join } from "node:path";
3
- import { logDir } from "../logs.js";
4
-
5
- /** 缺省开 + 429 时换身份换出口:他项目真机实测「同一身份连发 3 次 429,身份一换同一个请求立刻 200」⇒ 换身份=换桶。 */
6
- export const DEFAULTS = {
7
- enabled: true,
8
- mode: "leader",
9
- count: 4,
10
- basePort: 10800,
11
- strategy: "session",
12
- cooldownMs: 600_000,
13
- autodownload: true,
14
- registerMinGapMs: 6_000,
15
- registerMaxGapMs: 15_000,
16
- probePath: "/zen/v1/models",
17
- rotate: true,
18
- };
19
-
20
- const OFF = ["0", "off", "false", "no", "disable", "disabled"];
21
-
22
- function envBool(env, name, fallback) {
23
- const raw = env[name];
24
- if (raw === undefined || raw === null || raw === "") return fallback;
25
- return !OFF.includes(String(raw).trim().toLowerCase());
26
- }
27
-
28
- function envInt(env, name, fallback) {
29
- const v = Number(env[name]);
30
- return Number.isInteger(v) && v > 0 ? v : fallback;
31
- }
32
-
33
- function envStr(env, name, fallback) {
34
- const raw = env[name];
35
- return typeof raw === "string" && raw.trim() ? raw.trim() : fallback;
36
- }
37
-
38
- /**
39
- * 解析 warp-pool 运行时配置。
40
- * @param {Record<string,string|undefined>} env
41
- * @param {{ logDirFn?: () => string }} [deps] 测试可注入 logDir
42
- */
43
- export function resolveWarpConfig(env = process.env, { logDirFn = logDir } = {}) {
44
- const enabled = envBool(env, "MSLXDFF_WARP_POOL", DEFAULTS.enabled);
45
- const mode = envStr(env, "MSLXDFF_WARP_MODE", DEFAULTS.mode);
46
- const count = envInt(env, "MSLXDFF_WARP_COUNT", DEFAULTS.count);
47
- const basePort = envInt(env, "MSLXDFF_WARP_BASE_PORT", DEFAULTS.basePort);
48
- const strategy = envStr(env, "MSLXDFF_WARP_STRATEGY", DEFAULTS.strategy) === "request" ? "request" : "session";
49
- const cooldownMs = envInt(env, "MSLXDFF_WARP_COOLDOWN_MS", DEFAULTS.cooldownMs);
50
- const dir = envStr(env, "MSLXDFF_WARP_DIR", join(logDirFn(), "warp-pool"));
51
- const bin = envStr(env, "MSLXDFF_WARP_BIN", "");
52
- const autodownload = envBool(env, "MSLXDFF_WARP_AUTODOWNLOAD", DEFAULTS.autodownload);
53
- const probePath = envStr(env, "MSLXDFF_WARP_PROBE_PATH", DEFAULTS.probePath);
54
- const rotate = envBool(env, "MSLXDFF_WARP_ROTATE", DEFAULTS.rotate);
55
-
56
- return {
57
- enabled,
58
- active: enabled && mode === "leader",
59
- mode,
60
- count,
61
- basePort,
62
- strategy,
63
- cooldownMs,
64
- dir,
65
- bin,
66
- autodownload,
67
- probePath,
68
- rotate,
69
- registerMinGapMs: DEFAULTS.registerMinGapMs,
70
- registerMaxGapMs: DEFAULTS.registerMaxGapMs,
71
- /** 节点端口列表:basePort .. basePort+count-1 */
72
- ports: Array.from({ length: count }, (_, i) => basePort + i),
73
- };
74
- }
@@ -1,106 +0,0 @@
1
- // warp-pool · 节点健康:主动探活(经隧道打上游 models,不烧 token)+ 失败冷却 + 连续失败触发重拨换 IP。
2
- import { probeTunnel } from "./wireproxy.js";
3
- import { fmtHealth, fmtHeartbeat, dur, MARK } from "./report.js";
4
-
5
- export function createHealthMonitor({
6
- dispatcher,
7
- baseUrl = "https://opencode.ai",
8
- probePath = "/zen/v1/models",
9
- unhealthyAfter = 3,
10
- intervalMs = 60_000,
11
- heartbeatEvery = 10,
12
- infoOf = null,
13
- cooldownMs = 600_000,
14
- onExhausted = null,
15
- probe = probeTunnel,
16
- headers = {},
17
- now = Date.now,
18
- log = () => {},
19
- } = {}) {
20
- let timer = null;
21
- let rounds = 0;
22
- let ticks = 0;
23
- let lastAllDownAt = "";
24
- /** @type {Map<number, boolean>} 每端口上一轮通不通(用来只在「状态变化」时打日志) */
25
- const lastOk = new Map();
26
- const stamp = () => new Date(now()).toISOString().slice(11, 19);
27
-
28
- /**
29
- * 跑一轮全池探活。日志纪律:**只在「状态有变化」「全池不通」「心跳点」时打**——每分钟一行会把日志淹掉。
30
- * 每个不通的节点都要报全:HTTP 状态、错误原文、耗时、连续第几次、冷却多久(这样才不用靠猜)。
31
- */
32
- async function tick() {
33
- if (!dispatcher) return { checked: 0, healthy: 0 };
34
- const list = dispatcher.entries();
35
- let healthy = 0;
36
- let slowestMs = 0;
37
- const problems = [];
38
- const changes = [];
39
- for (const e of list) {
40
- const r = await probe({ port: e.port, baseUrl, path: probePath, headers }).catch((err) => ({
41
- ok: false,
42
- error: String(err?.message || err),
43
- }));
44
- if (Number(r.ms) > slowestMs) slowestMs = Number(r.ms);
45
- if (r.ok) {
46
- dispatcher.markOk(e.index);
47
- healthy += 1;
48
- if (lastOk.get(e.port) === false) changes.push(`:${e.port} 恢复(HTTP ${r.status},${dur(r.ms)})`);
49
- lastOk.set(e.port, true);
50
- continue;
51
- }
52
- dispatcher.markFailure(e.index);
53
- if (lastOk.get(e.port) !== false) {
54
- changes.push(`:${e.port} 掉线(HTTP ${r.status || 0}${r.error ? ` ${String(r.error).slice(0, 80)}` : ""})`);
55
- }
56
- lastOk.set(e.port, false);
57
- problems.push({ port: e.port, status: r.status, error: r.error, ms: r.ms, fails: Number(e.fails || 0) + 1, cooldownMs });
58
- }
59
- const allDown = list.length > 0 && healthy === 0;
60
- ticks += 1;
61
- if (allDown) {
62
- rounds += 1;
63
- lastAllDownAt = stamp();
64
- } else {
65
- rounds = 0;
66
- }
67
- if (changes.length || allDown || ticks % heartbeatEvery === 0) {
68
- log(fmtHealth({ healthy, total: list.length, slowestMs, problems }));
69
- }
70
- for (const c of changes) log(`[warp-pool] ${MARK.warn} 状态变化:${c}`);
71
- if (ticks % heartbeatEvery === 0) {
72
- const extra = typeof infoOf === "function" ? infoOf() || {} : {};
73
- log(fmtHeartbeat({
74
- alive: extra.alive ?? list.length,
75
- total: list.length,
76
- pooled: extra.pooled ?? list.length,
77
- exits: extra.exits || [],
78
- cooling: list.filter((e) => Number(e.cooldownUntil) > now()).length,
79
- lastAllDown: lastAllDownAt,
80
- }));
81
- }
82
- if (allDown && rounds >= 2 && typeof onExhausted === "function") {
83
- rounds = 0;
84
- await onExhausted().catch((e) => log(`[warp-pool] 重拨失败:${String(e?.message || e)}`));
85
- }
86
- return { checked: list.length, healthy };
87
- }
88
-
89
- return {
90
- tick,
91
- start() {
92
- if (timer || !(intervalMs > 0)) return;
93
- timer = setInterval(() => {
94
- tick().catch(() => {});
95
- }, intervalMs);
96
- if (typeof timer.unref === "function") timer.unref();
97
- },
98
- stop() {
99
- if (timer) clearInterval(timer);
100
- timer = null;
101
- },
102
- get running() {
103
- return !!timer;
104
- },
105
- };
106
- }