mslxdff 0.1.207 → 0.1.209

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.
@@ -104,6 +104,7 @@
104
104
  | 组同步 | `-group sync` | 刷新全组成员 |
105
105
  | 组离开单 | `-group leave <name>` | 离开单组 |
106
106
  | 组列表 | `-group list` | 列组+成员+健康/序号 |
107
+ | 组员链路测试 | `-group test <target\|seq> [--group <名>] [--model <id>] [--say <词>] [--json]` | 验「本机 → 组长 → 某组员(含 relay)→ 上游」整条路,**按阶段指出断在哪一跳**;target 可填 url / `relay://id` / `-group list` 序号 |
107
108
  | 组踢人 | `-group remove <seq>` | 仅 leader 按序号踢人 |
108
109
  | 全部离开 | `-leavegroup` / `--leavegroup` | 离开所有成员组 |
109
110
  | 解散组 | `-delgroup <name>` / `--delgroup` | 仅 leader 解散 |
@@ -111,6 +112,7 @@
111
112
  | 组员开关 | `-use-group [on\|off]` / `--use-group [on\|off]` | opencode 失败时是否走组员(默认 on,off 则所有供应商仅本机;key 供应商默认恒直连,`MSLXDFF_USE_GROUP_KEYS=1` 可开回;cline/workbuddy 恒禁) |
112
113
  | 白嫖雷达 | `-free` / `--free` / `-free-check` / `--free-check` | V2EX 单源白嫖雷达(`latest.json + hot.json` 按白嫖|限免|免费额度过滤) |
113
114
  | 白嫖 watch | `-free-watch` / `--free-watch` | V2EX 白嫖雷达 watch(每 5 分钟轮询) |
115
+ | IPv6 出口对照 | `-v6-pool [test] [--count N] [--model <id>]` | **Linux VPS** 专用:直连(IPv4)打一条 free 层请求取基线,再从 v6 段随机采 N 个源地址各打同一条(只换源地址)→ **基线 429 + v6 有 200 ⇒ 换 IP 能换到新额度**;v6 也 429 ⇒ 换 IP 抬不动。自造 `/128` 用完即撤;非 Linux 如实跳过 |
114
116
  | 自启开 | `-enable-autostart` / `--enable-autostart` | 开机自启(Windows 任务计划 / Linux systemd) |
115
117
  | 自启关 | `-disable-autostart` / `--disable-autostart` | 关闭开机自启 |
116
118
  | 自启状态 | `-autostart status` / `--autostart status` | 查看自启状态 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.207",
3
+ "version": "0.1.209",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,6 +110,23 @@ export async function handleGroupCommand(args) {
110
110
  }
111
111
  console.log(`\njoined groups (${joinedList.length}):`);
112
112
  for (const g of joinedList) console.log(` ${g.name} ${g.leaderUrl || "(this node is the leader)"}`);
113
+ } else if (action === "test") {
114
+ // -group test <target> [--group <名>] [--model <id>] [--say <词>] [--json]
115
+ // target 可以是 url / relay://id / 或 -group list 里的序号(数字)。
116
+ const { resolveTargetArg, renderGroupTest } = await import("../group-test-cli.js");
117
+ const target = await resolveTargetArg(a, rest);
118
+ if (!target) {
119
+ console.error("usage: mslxdff -group test <target|seq> [--group <name>] [--model <id>] [--say <text>]");
120
+ process.exit(1);
121
+ }
122
+ const model = argValue(args, "--model", "-m") || undefined;
123
+ const say = argValue(args, "--say", "-s") || undefined;
124
+ const groupArg = argValue(args, "--group", "-g") || undefined;
125
+ const asJson = args.includes("--json");
126
+ const { runGroupLinkTest } = await import("../group-test.js");
127
+ const r = await runGroupLinkTest({ group: groupArg || "", target, ...(model ? { model } : {}), ...(say ? { prompt: say } : {}) });
128
+ renderGroupTest(r, { json: asJson });
129
+ process.exit(r.status === "ok" ? 0 : 1);
113
130
  } else if (action === "remove" && a) {
114
131
  const seq = Number(a);
115
132
  if (!Number.isInteger(seq) || seq < 1) {
@@ -138,7 +155,7 @@ export async function handleGroupCommand(args) {
138
155
  process.exit(1);
139
156
  }
140
157
  } else {
141
- console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group remove <seq> | -creategroup <name>");
158
+ console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group test <target|seq> | -group remove <seq> | -creategroup <name>");
142
159
  }
143
160
  process.exit(0);
144
161
  }
@@ -0,0 +1,123 @@
1
+ // mslxdff -v6-pool [test|help] —— IPv6 出口额度对照测试(在 VPS 上验证 v6 池值不值得用)。
2
+ //
3
+ // 目的(用户 2026-10-18 明确):**当这台 VPS 的 IPv4 出口撞 429 时,对比测一下 IPv6 出口还有没有额度**。
4
+ // 所以本命令做 A/B:
5
+ // ① 直连(IPv4/系统默认出口)打一条免费层请求,记下额度状态作基线;
6
+ // ② 从 v6 段里随机采 N 个源地址,各打**同一条**请求(同 UA/模型/stream/tools,只换源地址);
7
+ // ③ 对照两者:v6 有 200 ⇒ 池有用(换 IP 换到新额度);v6 全 429 ⇒ 换 IP 抬不动。
8
+ //
9
+ // 纯可逆:自造的 /128 用完即撤(bind 模式不碰网卡)。非 Linux 如实跳过。
10
+ import { runV6PoolCompare, V6TEST_DEFAULT, DEFAULT_PROBE_MODEL } from "../../v6pool/selftest.js";
11
+ import { argValue } from "../policy.js";
12
+
13
+ const FLAG = "-v6-pool";
14
+
15
+ function flagValue(args, ...names) {
16
+ for (const name of names) {
17
+ const v = argValue(args, name);
18
+ if (v !== null && v !== undefined && !String(v).startsWith("-")) return v;
19
+ const eq = args.find((a) => a.startsWith(`${name}=`));
20
+ if (eq) return eq.slice(eq.indexOf("=") + 1);
21
+ }
22
+ return null;
23
+ }
24
+
25
+ const kindZh = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
26
+ const line = (r) => {
27
+ if (r.error) return `✗ 不可达(${String(r.error).slice(0, 50)})`;
28
+ if (r.ok) return `✅ HTTP ${r.status} 有额度`;
29
+ return `⛔ HTTP ${r.status} ${kindZh[r.kind] || r.kind}`;
30
+ };
31
+
32
+ export async function handleV6Pool(args = []) {
33
+ const at = args.findIndex((x) => x === FLAG || x.startsWith(`${FLAG}=`));
34
+ if (at < 0) return false;
35
+ const sub = (args[at].includes("=") ? args[at].split("=")[1] : args[at + 1] || "test").toLowerCase();
36
+ if (["-h", "--help", "help"].includes(sub)) return printUsage();
37
+
38
+ const count = (() => { const v = flagValue(args, "--count", "-n"); return v === null ? V6TEST_DEFAULT : Math.max(1, Math.min(64, Number(v) || V6TEST_DEFAULT)); })();
39
+ const model = flagValue(args, "--model", "-m") || DEFAULT_PROBE_MODEL;
40
+
41
+ console.log("=".repeat(66));
42
+ console.log(" IPv6 出口额度对照 —— 「IPv4 撞 429 时,IPv6 还有额度吗」");
43
+ console.log("=".repeat(66));
44
+ console.log("");
45
+ console.log(` 探测模型 ${model}|采样 ${count} 个 v6 源地址|只换源地址,其余请求完全相同`);
46
+ console.log("");
47
+
48
+ const onProgress = (ev) => {
49
+ if (ev.phase === "baseline-start") console.log(` ▸ 基线:直连(IPv4 / 系统默认出口)打一条免费层请求…`);
50
+ else if (ev.phase === "baseline-done") {
51
+ console.log(` 基线结果:${line(ev)}${ev.body ? `(${String(ev.body).slice(0, 80).replace(/\s+/g, " ")})` : ""}`);
52
+ console.log("");
53
+ } else if (ev.phase === "v6-start") process.stdout.write(` [${String(ev.i).padStart(2)}/${ev.n}] ${ev.addr} … `);
54
+ else if (ev.phase === "v6-done") process.stdout.write(`${line(ev)}\n`);
55
+ };
56
+
57
+ let r;
58
+ try {
59
+ r = await runV6PoolCompare({ count, model, onProgress });
60
+ } catch (e) {
61
+ console.log("");
62
+ console.log(`❌ 测试执行出错:${e?.message ?? e}`);
63
+ console.log(` 重试:mslxdff ${FLAG}`);
64
+ process.exit(1);
65
+ return true;
66
+ }
67
+
68
+ if (r.status === "skip") {
69
+ console.log(`❌ 无法测 v6 出口:${r.reason}`);
70
+ console.log(` 下一步:${r.hint}`);
71
+ process.exit(1);
72
+ return true;
73
+ }
74
+
75
+ console.log("");
76
+ console.log("-".repeat(66));
77
+ console.log(` 基线(IPv4/直连):${line(r.baseline)}`);
78
+ console.log(` v6 采样:有额度 ${r.v6OkCount}|429 ${r.v6LimitedCount}|不可达 ${r.v6NetCount}(共 ${r.requested})`);
79
+ console.log("");
80
+ const verdicts = {
81
+ "v6-works": {
82
+ mark: "✅",
83
+ text: `IPv6 **能换到新额度**:${r.v6OkCount}/${r.requested} 个 v6 出口拿到 200`,
84
+ next: ` 结论:v6 池对这台机器**有用** —— IPv4 耗尽时换 v6 出口能续。daemon 建池时会自动启用。`,
85
+ },
86
+ "v6-also-limited": {
87
+ mark: "⛔",
88
+ text: `IPv6 **也全 429**:换 IP 没换到新额度`,
89
+ next: ` 结论:v6 池对这台机器**无意义**(要么上游不按出口 IP 计额,要么该段没真路由出去)。建议 MSLXDFF_V6_POOL=0。`,
90
+ },
91
+ "v6-unreachable": {
92
+ mark: "❌",
93
+ text: `IPv6 源地址**全都够不着上游**(段没路由到本机)`,
94
+ next: ` 结论:本机这段 v6 不能用作出口。查机房是否把该段 routed 过来,或用 MSLXDFF_V6_PREFIX 指更准的段。`,
95
+ },
96
+ mixed: {
97
+ mark: "△",
98
+ text: `部分可用:${r.v6OkCount}/${r.requested} 个 v6 出口有额度`,
99
+ next: ` 结论:v6 池**部分可用**(可能有些 /128 被沿途丢弃)。够用即可开着。`,
100
+ },
101
+ }[r.verdict] || { mark: "?", text: "未判定", next: "" };
102
+ console.log(` ${verdicts.mark} ${verdicts.text}`);
103
+ if (verdicts.next) console.log(verdicts.next);
104
+ console.log("");
105
+ console.log(` 重试:mslxdff ${FLAG} --count ${r.requested}${model !== DEFAULT_PROBE_MODEL ? ` --model ${model}` : ""}`);
106
+ process.exit(r.verdict === "v6-works" || r.verdict === "mixed" ? 0 : 1);
107
+ }
108
+
109
+ function printUsage() {
110
+ console.log(`用法: mslxdff ${FLAG} [test] [--count N] [--model <id>]`);
111
+ console.log("");
112
+ console.log(" 在 Linux VPS 上做 A/B:直连(IPv4)打一条 free 层请求取基线,");
113
+ console.log(" 再从 v6 段随机采 N 个源地址各打同一条,对照 IPv6 出口还有没有额度。");
114
+ console.log(" 基线 429 + v6 有 200 ⇒ 池有用(换 IP 换到新额度)");
115
+ console.log(" 基线 429 + v6 也 429 ⇒ 换 IP 抬不动(上游不按出口 IP 计额 / 段没路由过来)");
116
+ console.log("");
117
+ console.log("相关 env(与 daemon 建池同源):");
118
+ console.log(" MSLXDFF_V6_PREFIX=<段>/<位数> 显式指定池前缀(缺省取本机全局地址前缀)");
119
+ console.log(" MSLXDFF_V6_IFACE=<网卡> 指定网卡");
120
+ console.log(" MSLXDFF_V6_CLAIM=auto|bind|add 认领方式(bind 需 ip_nonlocal_bind=1;add 需 root)");
121
+ process.exit(0);
122
+ return true;
123
+ }
@@ -0,0 +1,66 @@
1
+ // `-group test` 的 CLI 适配层:把 -group list 的序号解析成 target,并把链路测试结果渲染成四态人话。
2
+ import { loadPeers, loadGroupsJoined, loadToken } from "../state.js";
3
+ import { refreshGroupMembers } from "../groups.js";
4
+ import { compatFetch, timeoutSignal } from "../compat.js";
5
+ import { resolveLeader } from "./group-test.js";
6
+
7
+ /**
8
+ * 把用户给的 target 归一:数字 = -group list 里的序号;否则原样(url / relay://id)。
9
+ * 与 -group list 的编号口径一致(跳过 leader、broadband/static 都算)。
10
+ */
11
+ export async function resolveTargetArg(a, rest = []) {
12
+ const raw = String(a ?? "").trim();
13
+ if (!raw) return "";
14
+ if (!/^\d+$/.test(raw)) return raw; // url 或 relay://id
15
+ const seq = Number(raw);
16
+ const joined = loadGroupsJoined();
17
+ const g = joined.find((x) => x?.leaderUrl) || joined[0];
18
+ if (!g) return "";
19
+ let members;
20
+ if (g.leaderUrl) {
21
+ try {
22
+ const { token } = await loadToken();
23
+ members = await refreshGroupMembers(g.name, {
24
+ leaderUrl: g.leaderUrl, memberName: g.memberName, url: g.myUrl, token,
25
+ fetchImpl: (url, opts) => compatFetch(url, { ...opts, signal: timeoutSignal(4000) }),
26
+ });
27
+ } catch { return ""; }
28
+ } else {
29
+ return ""; // 本机是组长时,序号口径见 -group list(此处不展开)
30
+ }
31
+ const entries = Object.entries(members || {}).filter(([id]) => id !== "leader");
32
+ const hit = entries[seq - 1];
33
+ return hit ? (hit[1]?.url || hit[0]) : "";
34
+ }
35
+
36
+ /** 结果 → 人话。`json` 时直接吐结构化结果(脚本用)。 */
37
+ export function renderGroupTest(r, { json = false } = {}) {
38
+ if (json) { console.log(JSON.stringify(r, null, 2)); return; }
39
+ console.log("=".repeat(62));
40
+ console.log(` 组员链路测试:本机 → 组长 → ${r.target}`);
41
+ console.log("=".repeat(62));
42
+ console.log(` 组长 ${r.leaderUrl || "?"}|组 ${r.group || "?"}${r.ms !== undefined ? `|耗时 ${r.ms}ms` : ""}`);
43
+ console.log("");
44
+ if (r.status === "ok") {
45
+ console.log(` ✅ 通了!经组长转发到该组员,上游返回:`);
46
+ console.log(` "${String(r.content || "").slice(0, 200) || "(空正文)"}"`);
47
+ process.exitCode = 0;
48
+ return;
49
+ }
50
+ const stageZh = {
51
+ usage: "参数错误",
52
+ "resolve-leader": "定位组长失败",
53
+ "resolve-group": "确定组名失败",
54
+ "forward-fetch": "连不上组长",
55
+ auth: "鉴权被拒",
56
+ "target-missing": "目标不在组内",
57
+ "max-hops": "转发跳数超限",
58
+ "relay-timeout": "组长入了队但成员没回",
59
+ "forward-failed": "组长转发失败",
60
+ upstream: "成员打上游失败",
61
+ }[r.stage] || r.stage;
62
+ console.log(` ❌ 没通 — 断在:**${stageZh}**${r.httpStatus ? `(HTTP ${r.httpStatus})` : ""}`);
63
+ if (r.error) console.log(` 原因:${String(r.error).slice(0, 300)}`);
64
+ if (r.hint) console.log(` 下一步:${r.hint}`);
65
+ process.exitCode = 1;
66
+ }
@@ -0,0 +1,118 @@
1
+ // 组员链路测试:验证「本机 → 组长 → 某组员(含 relay 宽带成员)→ 上游 → 回数据」整条路。
2
+ //
3
+ // 为什么需要它:这条链路跨 3 台机器 + 1 道鉴权,坏了只会看到一句 `502 fetch failed`,
4
+ // 完全无法定位是哪一跳断的。本模块**按阶段**记录,把断点直接指出来:
5
+ // ① 定位本机组/组长 → ② 取组长 token(调组长 forward 要**组长的** token,见下)→
6
+ // ③ POST 组长 /v1/groups/relay/forward → ④ 判读回包(200 拿内容 / 401 鉴权 / 404 成员不存在 / 502·504 转发失败)
7
+ //
8
+ // ⚠ 鉴权事实(2026-10-19 实测):`authorized()` 比的是**接收方自己的 token**,
9
+ // 而 `/v1/groups/relay/forward` 在路由层 requiresAuth=true ⇒ 要**组长 token**。
10
+ // 普通组员只有自己的 token ⇒ 调不动组长 forward(这就是"组员经组长"从没通的原因)。
11
+ // 本命令调用时用组长 token;若拿不到组长 token(如你只是组员且未持有),会明确报出这一步。
12
+ import { loadPeers, loadGroupsJoined, loadToken } from "../state.js";
13
+ import { compatFetch, timeoutSignal } from "../compat.js";
14
+
15
+ /** 默认探测模型与提示词。 */
16
+ export const DEFAULT_TEST_MODEL = "deepseek-v4.1-flash-free";
17
+ export const DEFAULT_TEST_PROMPT = "hi";
18
+
19
+ /**
20
+ * 找一个可用的组长 token:优先 state.peers 里名为 leader 的记录,其次 groupsJoined 里非本机 url 的 peer。
21
+ * @returns {{ leaderUrl: string, leaderToken: string, group: string, source: string } | null}
22
+ */
23
+ export function resolveLeader({ peers = [], joined = [] } = {}) {
24
+ const byName = (peers || []).find((p) => String(p?.name || "") === "leader" && p?.url);
25
+ if (byName) {
26
+ const g = joined.find((x) => x?.leaderUrl === byName.url);
27
+ return { leaderUrl: byName.url, leaderToken: String(byName.token || ""), group: g?.name || "", source: "peers[leader]" };
28
+ }
29
+ // 兜底:用 groupsJoined 的 leaderUrl 去 peers 里找同 url 的记录取 token
30
+ const g = (joined || []).find((x) => x?.leaderUrl);
31
+ if (g) {
32
+ const p = (peers || []).find((x) => x?.url === g.leaderUrl);
33
+ return { leaderUrl: g.leaderUrl, leaderToken: String(p?.token || ""), group: g.name, source: "groupsJoined.leaderUrl" };
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * 跑一次链路测试。返回结构化结果(不打印,由 CLI 渲染)。
40
+ *
41
+ * @param {{ group?: string, target: string, model?: string, prompt?: string, timeoutMs?: number,
42
+ * deps?: { peers?, joined?, myToken?, fetchImpl? } }} opts
43
+ * @returns {Promise<{ status: "ok"|"error", stage: string, httpStatus?: number, content?: string,
44
+ * reply?: any, leaderUrl?: string, group?: string, target: string, ms?: number,
45
+ * error?: string, hint?: string }>}
46
+ */
47
+ export async function runGroupLinkTest({
48
+ group = "",
49
+ target = "",
50
+ model = DEFAULT_TEST_MODEL,
51
+ prompt = DEFAULT_TEST_PROMPT,
52
+ timeoutMs = 45_000,
53
+ deps = {},
54
+ } = {}) {
55
+ if (!target) return { status: "error", stage: "usage", target, error: "必须给目标组员(url、relay://id 或 -group list 里的序号对应项)" };
56
+
57
+ const peers = deps.peers ?? loadPeers();
58
+ const joined = deps.joined ?? loadGroupsJoined();
59
+ const myToken = deps.myToken ?? (await loadToken()).token;
60
+ const fetchImpl = deps.fetchImpl ?? compatFetch;
61
+
62
+ const lead = resolveLeader({ peers, joined });
63
+ if (!lead) return { status: "error", stage: "resolve-leader", target, error: "本机没有可用的组长记录(未加入任何组?)", hint: "先 mslxdff -addtogroup <组长host> <组名>" };
64
+ const groupName = group || lead.group;
65
+ if (!groupName) return { status: "error", stage: "resolve-group", target, error: "无法确定组名(state 里 peers/groupsJoined 缺 group)", hint: "用 -group 显式指定组名" };
66
+
67
+ const reqId = `gt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
68
+ const body = { group: groupName, target, reqId, body: { model, messages: [{ role: "user", content: prompt }], stream: false } };
69
+
70
+ const t0 = Date.now();
71
+ let res;
72
+ try {
73
+ res = await fetchImpl(`${lead.leaderUrl}/v1/groups/relay/forward`, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${lead.leaderToken}` },
76
+ body: JSON.stringify(body),
77
+ signal: timeoutSignal(timeoutMs),
78
+ });
79
+ } catch (e) {
80
+ const msg = String(e?.message || e);
81
+ return { status: "error", stage: "forward-fetch", target, leaderUrl: lead.leaderUrl, group: groupName, ms: Date.now() - t0,
82
+ error: `连组长都打不通:${msg}`, hint: "组长地址/网络不通;先 mslxdff -group list 看组长健康" };
83
+ }
84
+ const ms = Date.now() - t0;
85
+ const txt = await res.text().catch(() => "");
86
+
87
+ // 按阶段归类失败 —— 这是本命令存在的意义:一眼看出断在哪一跳
88
+ if (res.status === 401) {
89
+ return { status: "error", stage: "auth", httpStatus: 401, target, leaderUrl: lead.leaderUrl, group: groupName, ms,
90
+ error: "组长拒了鉴权(401):调 /v1/groups/relay/forward 需要**组长的** token,本机没有",
91
+ hint: "这是已知缺陷:组员中继链路的鉴权断了。修复 = 组长侧 forward 应接受成员 token(见 -group test 的 PR)" };
92
+ }
93
+ if (res.status === 404) {
94
+ return { status: "error", stage: "target-missing", httpStatus: 404, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "该目标不在组内(名称/url 对不上);用 -group list 核对" };
95
+ }
96
+ if (res.status === 429) {
97
+ return { status: "error", stage: "max-hops", httpStatus: 429, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "转发跳数超限(有环)" };
98
+ }
99
+ if (res.status === 504) {
100
+ return { status: "error", stage: "relay-timeout", httpStatus: 504, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "组长入了队但目标成员没在 30s 内回结果(成员不在线/不通)" };
101
+ }
102
+ if (res.status !== 200) {
103
+ const isFetchFail = /fetch failed/i.test(txt);
104
+ const isRelay = String(target).startsWith("relay://");
105
+ return { status: "error", stage: "forward-failed", httpStatus: res.status, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 300),
106
+ hint: isFetchFail && isRelay
107
+ ? "对 relay 成员,502 fetch failed 通常是**该成员自己的上游**打不通(组长只负责转发,不碰上游)。判据:同组其他 relay 成员若正常,即证是这一台的网络/上游问题,不是组长。也可能是该成员离线。"
108
+ : isFetchFail ? "组长直连该静态成员失败(它离线或端口不通)" : "" };
109
+ }
110
+
111
+ let reply = null;
112
+ try { reply = JSON.parse(txt); } catch { reply = null; }
113
+ const content = reply?.choices?.[0]?.message?.content ?? reply?.content ?? "";
114
+ if (reply && (reply.error || reply.object === "error")) {
115
+ return { status: "error", stage: "upstream", httpStatus: 200, target, leaderUrl: lead.leaderUrl, group: groupName, ms, reply, error: String(reply.error?.message || reply.error || "").slice(0, 300), hint: "组长转到了成员,但成员打上游失败(该成员自己的上游/额度问题)" };
116
+ }
117
+ return { status: "ok", stage: "done", httpStatus: 200, target, leaderUrl: lead.leaderUrl, group: groupName, ms, content: String(content || ""), reply };
118
+ }
package/src/cli/index.js CHANGED
@@ -41,6 +41,9 @@ export async function run(args = process.argv.slice(2)) {
41
41
  if (await handleAutostart(args)) return;
42
42
  if (await handleFree(args)) return;
43
43
 
44
+ const { handleV6Pool } = await import("./commands/v6-pool.js");
45
+ if (await handleV6Pool(args)) return;
46
+
44
47
  const { handleProviders, handleProvider } = await import("./commands/provider.js");
45
48
  if (await handleProviders(args)) return;
46
49
  if (await handleProvider(args)) return;
@@ -0,0 +1,154 @@
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、同模型、同 `stream:true`+五核心工具
8
+ // (否则会 403 FreeTierError 而非 429,测出来的不是额度);差异只允许来自"哪个源地址出网"。
9
+ // 判定复用 `warp-pool/verdict.js` 的 classifyWarpSignal(与生产换址判决同源,不自造一套)。
10
+ //
11
+ // IO 全部注入(agentFactory/fetch/detect/claimer),零真机依赖,可单测。
12
+ import { getUndici, timeoutSignal } from "../compat.js";
13
+ import { classifyWarpSignal } from "../warp-pool/verdict.js";
14
+ import { opencodeUa } from "../opencode-identity.js";
15
+ import { CORE_AGENT_TOOL_NAMES } from "../free-lane.js";
16
+ import { detectPool } from "./detect.js";
17
+ import { resolveV6Config } from "./config.js";
18
+ import { createClaimer } from "./claim.js";
19
+ import { randomAddress, parsePrefix, hostBitsOf } from "./prefix.js";
20
+
21
+ /** 采样上限:抽查,不扫全段。 */
22
+ export const V6TEST_MAX = 64;
23
+ export const V6TEST_DEFAULT = 6;
24
+
25
+ /** 默认探测模型:免费池里最有代表性、额度最容易耗的一个。 */
26
+ export const DEFAULT_PROBE_MODEL = "deepseek-v4.1-flash-free";
27
+
28
+ /** 免费层请求体形状(stream:true + 五核心工具名),缺了会 403 而非 429 ⇒ 测不出额度。 */
29
+ export function freeLaneBody(model, prompt = "hi") {
30
+ return {
31
+ model,
32
+ stream: true,
33
+ max_tokens: 16,
34
+ messages: [{ role: "user", content: prompt }],
35
+ tools: CORE_AGENT_TOOL_NAMES.map((name) => ({
36
+ type: "function",
37
+ function: { name, description: `The ${name} tool.`, parameters: { type: "object", properties: {} } },
38
+ })),
39
+ };
40
+ }
41
+
42
+ /** 读响应正文(有上界,失败给空串——不读就无法判决 403/429 的具名类名)。 */
43
+ async function readBody(res, max = 2048) {
44
+ try {
45
+ const t = await res.text();
46
+ return t.slice(0, max);
47
+ } catch {
48
+ return "";
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 从某个源地址打一次免费层 chat,返回判决。
54
+ * `addr=""` 表示**直连**(走本机默认路由 = IPv4 出口,或双栈默认)。
55
+ * @returns {Promise<{addr, ok, status, kind, rotate, ms, body, error}>}
56
+ */
57
+ export async function probeUpstreamFrom({ addr = "", model, baseUrl = "https://opencode.ai", agentFactory, fetchImpl, timeoutMs = 20_000, prompt } = {}) {
58
+ const t0 = Date.now();
59
+ let agent = null;
60
+ try {
61
+ agent = addr ? agentFactory(addr) : null; // 直连:不注入 dispatcher,走系统默认出口
62
+ const f = fetchImpl || getUndici().fetch;
63
+ const res = await f(`${baseUrl}/zen/v1/chat/completions`, {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json", "User-Agent": opencodeUa(), Accept: "text/event-stream" },
66
+ body: JSON.stringify(freeLaneBody(model, prompt)),
67
+ ...(agent ? { dispatcher: agent } : {}),
68
+ signal: timeoutSignal(timeoutMs),
69
+ });
70
+ const body = await readBody(res);
71
+ const v = classifyWarpSignal({ status: res.status, text: body });
72
+ return { addr, ok: res.status >= 200 && res.status < 300, status: res.status, kind: v.kind, rotate: v.rotate, ms: Date.now() - t0, body };
73
+ } catch (e) {
74
+ return { addr, ok: false, status: 0, kind: "network", rotate: true, ms: Date.now() - t0, body: "", error: String(e?.message || e) };
75
+ } finally {
76
+ if (agent) { try { await agent.close?.(); } catch { /* 一次性 Agent */ } }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 跑一次出口对照测试。返回结构化结果,由 CLI 渲染(本模块不打印)。
82
+ *
83
+ * @param {{ env?: object, count?: number, model?: string, baseUrl?: string,
84
+ * onProgress?: (ev: object) => void,
85
+ * deps?: { detect?, claimer?, agentFactory?, fetchImpl?, probe?, randomAddress? } }} opts
86
+ */
87
+ export async function runV6PoolCompare({
88
+ env = process.env,
89
+ count = V6TEST_DEFAULT,
90
+ model = DEFAULT_PROBE_MODEL,
91
+ baseUrl = "https://opencode.ai",
92
+ onProgress,
93
+ deps = {},
94
+ } = {}) {
95
+ const cfg = resolveV6Config(env);
96
+ const tick = (ev) => { try { onProgress?.(ev); } catch { /* 渲染出错不打断测试 */ } };
97
+
98
+ const detect = deps.detect || detectPool;
99
+ const d = detect({ cfg, env });
100
+ if (!d.ok) return { status: "skip", reason: d.reason, hint: d.hint };
101
+ const prefix = parsePrefix(`${d.prefix}`);
102
+ if (!prefix) return { status: "skip", reason: `前缀解析失败:${d.prefix}`, hint: "用 MSLXDFF_V6_PREFIX=<段>/<位数> 显式指定" };
103
+
104
+ const { Agent } = getUndici();
105
+ const agentFactory = deps.agentFactory || ((addr) => new Agent({ connect: { localAddress: addr }, connections: 1, keepAliveTimeout: 5_000 }));
106
+ const probe = deps.probe || probeUpstreamFrom;
107
+ const pick = deps.randomAddress || ((p) => randomAddress(p));
108
+
109
+ // ① 基准:直连(IPv4 / 系统默认出口),记下它的额度状态作为对照基线
110
+ tick({ phase: "baseline-start", prefix: d.prefix, model });
111
+ const baseline = await probe({ addr: "", model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl });
112
+ tick({ phase: "baseline-done", ...baseline });
113
+
114
+ // ② IPv6 池采样:每个随机源地址打同一条请求
115
+ const n = Math.max(1, Math.min(V6TEST_MAX, Number(count) || V6TEST_DEFAULT));
116
+ const claimer = deps.claimer ? deps.claimer(d, cfg) : createClaimer({ iface: d.iface, mode: d.mode, dir: cfg.dir });
117
+ const mineSet = new Set(d.mine || []);
118
+ const v6 = [];
119
+ try {
120
+ for (let i = 0; i < n; i++) {
121
+ let addr = pick(prefix);
122
+ for (let k = 0; k < 16 && mineSet.has(addr); k++) addr = pick(prefix);
123
+ await claimer.ensure(addr);
124
+ tick({ phase: "v6-start", i: i + 1, n, addr });
125
+ const r = await probe({ addr, model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl });
126
+ v6.push(r);
127
+ tick({ phase: "v6-done", i: i + 1, n, ...r });
128
+ }
129
+ } finally {
130
+ try { await claimer.releaseAll(); } catch { /* 释放失败交给下次 sweepStale */ }
131
+ }
132
+
133
+ // ③ 判语:对照基线
134
+ const baseLimited = baseline.status === 429 || baseline.kind === "free-quota" || baseline.kind === "free-tier" || baseline.kind === "ip-limit";
135
+ const v6Ok = v6.filter((r) => r.ok);
136
+ const v6Limited = v6.filter((r) => r.status === 429);
137
+ const v6Net = v6.filter((r) => !r.status);
138
+
139
+ let verdict;
140
+ const allOk = v6Ok.length === v6.length && v6.length > 0;
141
+ if (allOk) verdict = "v6-works"; // 全部 v6 出口都有额度
142
+ else if (v6Limited.length === v6.length) verdict = "v6-also-limited"; // 全部 429 ⇒ 没换到新额度
143
+ else if (v6Net.length === v6.length) verdict = "v6-unreachable"; // 全部够不着 ⇒ 段没路由过来
144
+ else verdict = "mixed"; // 有的成有的不成
145
+
146
+ return {
147
+ status: "ok",
148
+ prefix: d.prefix, iface: d.iface, mode: d.mode, hostBits: hostBitsOf(prefix.bits), model, baseUrl,
149
+ baseline, baseLimited,
150
+ v6, requested: n,
151
+ v6OkCount: v6Ok.length, v6LimitedCount: v6Limited.length, v6NetCount: v6Net.length,
152
+ verdict,
153
+ };
154
+ }