@zhushanwen/pi-network-proxy 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,9 +5,9 @@ LLM 网络代理分流:包装 pi 进程的 `globalThis.fetch`,按主机名
5
5
  ## 行为摘要
6
6
 
7
7
  - **拦截点**:`globalThis.fetch`。pi-ai 的 OpenAI 兼容请求在每次构造 client 时经 `Shims.getDefaultFetch()` 动态取当前全局 fetch,包装它不依赖 pi 与扩展共享 undici 模块实例
8
- - **安装/卸载**:extension 加载时读配置,`enabled` 且校验通过 → 包装并构造代理 dispatcher;`enabled` false → 恢复原 fetch;校验/构造失败 → extension error(含配置文件恢复指引),不包装不阻断 pi(全部直连)
8
+ - **安装/卸载**:extension 加载时读配置,`enabled` 开启或 `ipv4Only` 非空且校验通过 → 包装(`enabled` 且命中规则走代理 dispatcher,`ipv4Only` 命中锁 IPv4 直连,后者不受开关影响);`enabled` false 且 `ipv4Only` 空 → 恢复原 fetch;校验/构造失败 → extension error(含配置文件恢复指引),不包装不阻断 pi(全部直连)
9
9
  - **热生效**:turn 粒度——每 turn 开始重读配置(mtime+size 读时刷新),指纹变化才重建;pi 的 `configureHttpDispatcher` 带 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99:检测到 `globalThis.fetch` 已被第三方覆盖即保留不重装),本扩展的包装在同进程内持续生效;槽位被其他第三方覆盖时下一 turn 自动以覆盖者为新 original 重新包装(防御路径)
10
- - **匹配语义**:主机名归一化(小写、去末尾点)后 suffix 匹配(等于或以 `.条目` 结尾,点边界对齐);noProxy 优先于 rules
10
+ - **匹配语义**:主机名归一化(小写、去末尾点)后 suffix 匹配(等于或以 `.条目` 结尾,点边界对齐);优先级 noProxy > ipv4Only > rules(rules 另受 `enabled` 门控)
11
11
  - **覆盖面**:经 `globalThis.fetch` 发出的请求(pi LLM 调用与 OAuth/目录请求均走 fetch);直接使用 `undici.request` / `Client` 的调用与 WebSocket 升级请求不经过本包装
12
12
  - **计数**:请求发起时 +1(内存按主机名聚合),turn 结束 / 进程退出把增量追加写入 `<agentDir>/network-proxy-stats.jsonl`(每行完整 JSON `{ts, host, proxied, direct}`,单次 write 单行——多进程并发追加不交错);写失败 log 不阻断
13
13
  - **降级契约**:socks5 构造失败(undici 实验性 API)→ 记错误、恢复原 fetch、全部直连,切回 http 协议恢复;同指纹不重试构造,配置修正(指纹变化)后自然重试
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-network-proxy",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "LLM network proxy: rule-based per-hostname traffic splitting via a globalThis.fetch wrapper (http/https/socks5 proxy), with request counters.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -22,7 +22,8 @@
22
22
  "localhost",
23
23
  "127.0.0.1"
24
24
  ],
25
- "rules": []
25
+ "rules": [],
26
+ "ipv4Only": []
26
27
  }
27
28
  }
28
29
  ]
@@ -40,10 +41,10 @@
40
41
  ],
41
42
  "dependencies": {
42
43
  "undici": "^7.28.0",
44
+ "@zhushanwen/pi-llm-shared": "0.12.0",
43
45
  "@zhushanwen/pi-ext-guards": "0.5.0",
44
46
  "@zhushanwen/pi-extension-logger": "0.6.2",
45
- "@zhushanwen/pi-file-lock": "0.4.1",
46
- "@zhushanwen/pi-llm-shared": "0.12.0"
47
+ "@zhushanwen/pi-file-lock": "0.4.1"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@vitest/coverage-v8": "^4.1.9",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: network-proxy-ext-config
3
- description: "配置 @zhushanwen/pi-network-proxy(LLM 网络代理分流)时加载。含配置文件路径、NetworkProxyConfig schema(enabled/proxy/noProxy/rules)、协议三选一(http/https/socks5)、suffix 主机名匹配语义、noProxy 优先级、凭据成对约束、默认值、示例、生效时机(turn 粒度热重载)、统计文件、故障恢复。触发词:配置网络代理、network-proxy 配置、代理规则、走代理、socks5、noProxy、network-proxy config、代理不生效、代理报错、直连排除。"
3
+ description: "配置 @zhushanwen/pi-network-proxy(LLM 网络代理分流)时加载。含配置文件路径、NetworkProxyConfig schema(enabled/proxy/noProxy/rules/ipv4Only)、协议三选一(http/https/socks5)、suffix 主机名匹配语义、noProxy 优先级、凭据成对约束、默认值、示例、生效时机(turn 粒度热重载)、统计文件、故障恢复。触发词:配置网络代理、network-proxy 配置、代理规则、走代理、socks5、noProxy、network-proxy config、代理不生效、代理报错、直连排除、强制 IPv4、ipv4Only。"
4
4
  ---
5
5
 
6
6
  # network-proxy 配置指南
@@ -20,7 +20,8 @@ description: "配置 @zhushanwen/pi-network-proxy(LLM 网络代理分流)时
20
20
 
21
21
  ```ts
22
22
  interface NetworkProxyConfig {
23
- enabled: boolean; // 总开关,默认 false(禁用 = 不包装 fetch,全部按 pi 原生行为出站)
23
+ enabled: boolean; // 代理分流总开关,默认 false(关闭 = rules 不参与分流,全部直连/env 代理;
24
+ // 不影响 ipv4Only——见下)
24
25
  proxy: {
25
26
  protocol: "http" | "https" | "socks5"; // http/https 走 undici ProxyAgent,socks5 走 Socks5ProxyAgent
26
27
  host: string; // 代理服务器地址,如 127.0.0.1
@@ -29,11 +30,14 @@ interface NetworkProxyConfig {
29
30
  password: string; // 认证密码(可空,但必须与 username 成对)
30
31
  };
31
32
  noProxy: string[]; // 直连排除清单(主机名条目),优先级高于 rules——本地网关地址必须加在这里
32
- rules: string[]; // 代理规则(主机名条目),命中即经代理
33
+ rules: string[]; // 代理规则(主机名条目),enabled 开启时命中即经代理
34
+ ipv4Only: string[]; // 直连 + 强制 IPv4 清单(主机名条目):命中不走代理,DNS 解析锁定 A 记录直连
35
+ // (URL / Host 头 / TLS SNI 保持原样,只干预「选哪个 IP」);用于「IPv6 可达但握手抖动」的服务商。
36
+ // 独立于 enabled 开关:代理关闭时本清单照常生效(清单非空即安装分流包装层)
33
37
  }
34
38
  ```
35
39
 
36
- 校验规则(违反即回落禁用态并报错,错误文案含修复动作):协议白名单、端口 1-65535 整数、凭据成对(同空或同非空)、noProxy/rules 每条非空且不含空白字符(含空白的条目对真实主机名恒不匹配,校验层显式拒绝,错误文案提示删除空白)。
40
+ 校验规则(违反即回落禁用态并报错,错误文案含修复动作):协议白名单、端口 1-65535 整数、凭据成对(同空或同非空)、noProxy/rules/ipv4Only 每条非空且不含空白字符(含空白的条目对真实主机名恒不匹配,校验层显式拒绝,错误文案提示删除空白)。`ipv4Only` 是后加字段,旧配置文件缺省 = 空清单。
37
41
 
38
42
  ## 匹配语义
39
43
 
@@ -41,13 +45,15 @@ interface NetworkProxyConfig {
41
45
  - 条目 `api.anthropic.com` 命中 `api.anthropic.com` 与 `eu.api.anthropic.com`
42
46
  - `xapi.anthropic.com` 不命中(无点前缀拼接);`api-anthropic.com.evil.com` 不命中(恶意后缀)
43
47
  - 匹配对象 = 请求目标 URL 的主机名(服务商 baseURL 的主机名;端口不参与匹配)
44
- - noProxy 先于 rules 判定(先排除再匹配);`localhost` 条目同时覆盖 `localhost` 与 `*.localhost`
48
+ - 分流优先级:**noProxy > ipv4Only > rules**(noProxy 命中即原样直连,连 IPv4 锁定都不做)
49
+ - ipv4Only 命中:不走代理,请求 URL 原样保留,经 `connect.lookup` 把 DNS 解析强制到 A 记录(family:4)后直连——URL / Host 头 / TLS SNI 全部不动(把 URL 换成 IP 字面量的做法会被 Cloudflare 按 SNI/Host 不一致拒 403,实测证伪);无 A 记录时解析报错上抛,**不回退 IPv6**(避免「要求锁 v4」的意图被静默丢弃)
50
+ - `localhost` 条目同时覆盖 `localhost` 与 `*.localhost`
45
51
  - 不支持 `*` 通配(无已发生需求);分流覆盖经 `globalThis.fetch` 发出的请求(pi LLM 调用与 OAuth/目录请求);直接使用 `undici.request` / `Client` 的调用不经过本包装
46
52
 
47
53
  ## 生效时机(turn 粒度热重载)
48
54
 
49
- - 每个 turn 开始重读配置,指纹(enabled+proxy+noProxy+rules)变化才重建包装——**运行中会话自下一轮对话起生效,无需重启**;turn 进行中(工具循环连续请求)保存的配置对该 turn 内后续请求不生效
50
- - `enabled` 从 true → false:立即恢复包装前的 fetch(全部直连/env 代理路由)
55
+ - 每个 turn 开始重读配置,指纹(enabled+proxy+noProxy+rules+ipv4Only)变化才重建包装——**运行中会话自下一轮对话起生效,无需重启**;turn 进行中(工具循环连续请求)保存的配置对该 turn 内后续请求不生效
56
+ - `enabled` 从 true → false 且 `ipv4Only` 为空:立即恢复包装前的 fetch(全部直连/env 代理路由);`ipv4Only` 非空时包装保留(IPv4 锁定不随代理开关关闭而失效,仅代理分流停用)
51
57
  - 未命中规则的流量委托包装前的 fetch——独立 pi 用户设了 `HTTPS_PROXY` 环境变量时,未命中流量仍走 env 代理,语义不丢失
52
58
  - pi 的 `configureHttpDispatcher` 带 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99):检测到 `globalThis.fetch` 已被第三方覆盖即保留不重装,本扩展的包装在同进程内持续生效;槽位被其他第三方整体覆盖时,下一 turn 开始自动以覆盖者为新 original 重新包装(防御路径)
53
59
 
@@ -66,11 +72,26 @@ interface NetworkProxyConfig {
66
72
 
67
73
  ## 配置示例
68
74
 
75
+ 代理分流 + IPv4 锁定并用:
76
+
69
77
  ```json
70
78
  {
71
79
  "enabled": true,
72
80
  "proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
73
81
  "noProxy": ["localhost", "127.0.0.1"],
74
- "rules": ["api.anthropic.com"]
82
+ "rules": ["api.anthropic.com"],
83
+ "ipv4Only": ["open-design.ai"]
84
+ }
85
+ ```
86
+
87
+ 仅 IPv4 锁定、不走代理(`enabled=false` 时 `rules` 不参与分流,`ipv4Only` 照常生效):
88
+
89
+ ```json
90
+ {
91
+ "enabled": false,
92
+ "proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
93
+ "noProxy": ["localhost", "127.0.0.1"],
94
+ "rules": [],
95
+ "ipv4Only": ["open-design.ai"]
75
96
  }
76
97
  ```
@@ -58,6 +58,7 @@ function validConfig(overrides?: Partial<NetworkProxyConfig>): NetworkProxyConfi
58
58
  proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
59
59
  noProxy: ["localhost"],
60
60
  rules: ["api.anthropic.com"],
61
+ ipv4Only: [],
61
62
  ...overrides,
62
63
  };
63
64
  }
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * fetch 包装状态机单测(fake 依赖注入,不触真实网络):
3
- * 命中/未命中分流、计数、noProxy 优先、禁用恢复、外部覆盖重装、构造失败锁定、
4
- * reload 残留摘除、入参形态覆盖。转移表见 fetch-wrapper.ts 文件头。
3
+ * 命中/未命中分流、计数、noProxy/ipv4Only 优先级、ipv4 与代理构造的差异化降级
4
+ * (ipv4 失败只丢 v4 锁定、proxy 失败锁整层;rules 为空不构造代理)、禁用恢复、
5
+ * ipv4Only 独立于 enabled 代理开关(开关关闭时清单照常生效、rules 不参与分流)、
6
+ * 外部覆盖重装、构造失败锁定、reload 残留摘除、入参形态覆盖。
7
+ * 转移表见 fetch-wrapper.ts 文件头。
5
8
  */
6
- import { beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { beforeEach, describe, expect, it } from "vitest";
7
10
  import { Dispatcher } from "undici";
8
11
 
9
12
  import type { NetworkProxyConfig } from "../config.ts";
@@ -34,6 +37,7 @@ function config(overrides?: Partial<NetworkProxyConfig>): NetworkProxyConfig {
34
37
  proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
35
38
  noProxy: ["localhost"],
36
39
  rules: ["api.anthropic.com"],
40
+ ipv4Only: [],
37
41
  ...overrides,
38
42
  };
39
43
  }
@@ -42,7 +46,7 @@ function withProxy(patch: Partial<NetworkProxyConfig["proxy"]>): Partial<Network
42
46
  return { proxy: { ...config().proxy, ...patch } };
43
47
  }
44
48
 
45
- /** fake 依赖装配:original fetch / 代理 fetch / 构造器全部可观测、可注入失败。 */
49
+ /** fake 依赖装配:original fetch / 代理 fetch / 两个构造器全部可观测、可注入失败。 */
46
50
  function createHarness() {
47
51
  const originalCalls: FetchCall[] = [];
48
52
  const originalFetch: FetchLike = async (input, init) => {
@@ -53,13 +57,16 @@ function createHarness() {
53
57
 
54
58
  const proxyCalls: ProxyCall[] = [];
55
59
  const proxy = new FakeDispatcher();
60
+ const ipv4 = new FakeDispatcher();
56
61
  const proxyFetch = async (input: string | URL | Request, init: ProxyFetchInit): Promise<Response> => {
57
62
  proxyCalls.push({ input, init });
58
63
  return new Response("proxy");
59
64
  };
60
65
 
61
66
  let buildProxy = (_config: NetworkProxyConfig): Dispatcher => proxy;
67
+ let buildIpv4 = (): Dispatcher => ipv4;
62
68
  const buildCalls: NetworkProxyConfig[] = [];
69
+ const buildIpv4Calls: number[] = [];
63
70
  const errors: string[] = [];
64
71
 
65
72
  const deps: FetchWrapperDeps = {
@@ -71,6 +78,10 @@ function createHarness() {
71
78
  buildCalls.push(next);
72
79
  return buildProxy(next);
73
80
  },
81
+ buildIpv4: () => {
82
+ buildIpv4Calls.push(1);
83
+ return buildIpv4();
84
+ },
74
85
  proxyFetch,
75
86
  onError: (message) => errors.push(message),
76
87
  };
@@ -81,7 +92,9 @@ function createHarness() {
81
92
  originalCalls,
82
93
  proxyCalls,
83
94
  proxy,
95
+ ipv4,
84
96
  buildCalls,
97
+ buildIpv4Calls,
85
98
  errors,
86
99
  getCurrent: () => currentFetch,
87
100
  setCurrent: (fetch: FetchLike) => {
@@ -90,6 +103,9 @@ function createHarness() {
90
103
  setBuildProxy: (next: (config: NetworkProxyConfig) => Dispatcher) => {
91
104
  buildProxy = next;
92
105
  },
106
+ setBuildIpv4: (next: () => Dispatcher) => {
107
+ buildIpv4 = next;
108
+ },
93
109
  };
94
110
  }
95
111
 
@@ -150,6 +166,91 @@ describe("applyNetworkProxyConfig 包装状态机", () => {
150
166
  expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 0, direct: 1 });
151
167
  });
152
168
 
169
+ it("ipv4Only 命中:URL 原样、经 ipv4 分发器直连(不走代理,DNS 锁定交给 connect.lookup)", async () => {
170
+ const h = createHarness();
171
+ applyNetworkProxyConfig(
172
+ config({ ipv4Only: ["open-design.ai"], rules: ["open-design.ai"] }),
173
+ null,
174
+ h.deps,
175
+ );
176
+ const response = await h.getCurrent()("https://amr-link.open-design.ai/v1/chat", {
177
+ method: "POST",
178
+ headers: { "x-test": "1" },
179
+ });
180
+ expect(await response.text()).toBe("proxy"); // 经 proxyFetch 通道(注入 ipv4 分发器)
181
+ expect(h.originalCalls).toHaveLength(0);
182
+ expect(h.proxyCalls).toHaveLength(1);
183
+ const call = h.proxyCalls[0];
184
+ // URL 不改写:域名保持原样(改 URL 会被 Cloudflare 按 SNI/Host 不一致拒 403,见 ipv4.ts 选型)
185
+ expect(call.input).toBe("https://amr-link.open-design.ai/v1/chat");
186
+ expect(call.init.dispatcher).toBe(h.ipv4); // 注入 ipv4 分发器而非代理
187
+ expect(call.init.method).toBe("POST");
188
+ expect(call.init.headers).toEqual({ "x-test": "1" });
189
+ expect(snapshotCounters().get("amr-link.open-design.ai")).toEqual({ proxied: 0, direct: 1 });
190
+ });
191
+
192
+ it("noProxy 优先于 ipv4Only:同时命中时不做任何干预(原样直连)", async () => {
193
+ const h = createHarness();
194
+ applyNetworkProxyConfig(
195
+ config({ noProxy: ["open-design.ai"], ipv4Only: ["open-design.ai"] }),
196
+ null,
197
+ h.deps,
198
+ );
199
+ await h.getCurrent()("https://amr-link.open-design.ai/v1/chat");
200
+ expect(h.proxyCalls).toHaveLength(0);
201
+ expect(h.originalCalls).toHaveLength(1);
202
+ expect(h.originalCalls[0].input).toBe("https://amr-link.open-design.ai/v1/chat");
203
+ });
204
+
205
+ it("rules 为空:不构造代理分发器(代理配置不可用不锁死整层,ipv4Only 照常生效)", () => {
206
+ const h = createHarness();
207
+ h.setBuildProxy(() => {
208
+ throw new Error("rules 为空不应构造代理分发器");
209
+ });
210
+ const state = applyNetworkProxyConfig(
211
+ config({ rules: [], ipv4Only: ["open-design.ai"] }),
212
+ null,
213
+ h.deps,
214
+ );
215
+ expect(state?.wrapper).not.toBeNull();
216
+ expect(h.buildCalls).toHaveLength(0);
217
+ expect(h.buildIpv4Calls).toHaveLength(1);
218
+ expect(h.errors).toHaveLength(0);
219
+ });
220
+
221
+ it("ipv4Only 为空:不构造 IPv4 分发器", () => {
222
+ const h = createHarness();
223
+ h.setBuildIpv4(() => {
224
+ throw new Error("ipv4Only 为空不应构造 IPv4 分发器");
225
+ });
226
+ applyNetworkProxyConfig(config(), null, h.deps);
227
+ expect(h.buildIpv4Calls).toHaveLength(0);
228
+ expect(h.errors).toHaveLength(0);
229
+ });
230
+
231
+ it("ipv4 构造失败:仅降级 v4 锁定为普通直连,不撤整层包装(代理分流不受影响)", async () => {
232
+ const h = createHarness();
233
+ h.setBuildIpv4(() => {
234
+ throw new Error("ipv4 boom");
235
+ });
236
+ const state = applyNetworkProxyConfig(
237
+ config({ ipv4Only: ["open-design.ai"] }),
238
+ null,
239
+ h.deps,
240
+ );
241
+ expect(state?.wrapper).not.toBeNull();
242
+ expect(h.errors).toHaveLength(1);
243
+ expect(h.errors[0]).toContain("IPv4 锁定分发器构造失败");
244
+
245
+ await h.getCurrent()("https://amr-link.open-design.ai/v1/chat");
246
+ expect(h.originalCalls).toHaveLength(1); // 降级为普通直连(原样 URL)
247
+ expect(h.proxyCalls).toHaveLength(0);
248
+
249
+ // 代理分流不受 ipv4 失败影响(同一包装下 rules 命中仍走代理)
250
+ await h.getCurrent()("https://api.anthropic.com/v1/messages");
251
+ expect(h.proxyCalls).toHaveLength(1);
252
+ });
253
+
153
254
  it("子域命中 + 点边界:子域走代理,前缀拼接不命中", async () => {
154
255
  const h = createHarness();
155
256
  applyNetworkProxyConfig(config({ noProxy: [], rules: ["api.anthropic.com"] }), null, h.deps);
@@ -235,6 +336,62 @@ describe("applyNetworkProxyConfig 包装状态机", () => {
235
336
  expect(h.errors).toHaveLength(0);
236
337
  });
237
338
 
339
+ it("enabled=false 且 ipv4Only 非空:包装保留,ipv4Only 命中照常锁 v4 直连", async () => {
340
+ const h = createHarness();
341
+ h.setBuildProxy(() => {
342
+ throw new Error("开关关闭不应构造代理分发器");
343
+ });
344
+ const state = applyNetworkProxyConfig(
345
+ config({ enabled: false, rules: [], ipv4Only: ["open-design.ai"] }),
346
+ null,
347
+ h.deps,
348
+ );
349
+ expect(state?.wrapper).not.toBeNull();
350
+ expect(h.getCurrent()).toBe(state?.wrapper);
351
+ expect(h.buildCalls).toHaveLength(0); // 代理分流不构建
352
+ expect(h.buildIpv4Calls).toHaveLength(1);
353
+
354
+ const response = await h.getCurrent()("https://amr-link.open-design.ai/v1/chat");
355
+ expect(h.proxyCalls).toHaveLength(1);
356
+ expect(h.proxyCalls[0]?.init.dispatcher).toBe(h.ipv4);
357
+ expect(await response.text()).toBe("proxy");
358
+ expect(snapshotCounters().get("amr-link.open-design.ai")).toEqual({ proxied: 0, direct: 1 });
359
+ });
360
+
361
+ it("enabled=false 时 rules 不参与分流:规则主机直连计数,不走代理", async () => {
362
+ const h = createHarness();
363
+ applyNetworkProxyConfig(
364
+ config({ enabled: false, rules: ["api.anthropic.com"], ipv4Only: ["open-design.ai"] }),
365
+ null,
366
+ h.deps,
367
+ );
368
+ await h.getCurrent()("https://api.anthropic.com/v1/messages");
369
+ expect(h.proxyCalls).toHaveLength(0);
370
+ expect(h.originalCalls).toHaveLength(1);
371
+ expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 0, direct: 1 });
372
+ });
373
+
374
+ it("enabled true→false 且 ipv4Only 非空:包装保持(重建不摘除),ipv4 分流继续生效", async () => {
375
+ const h = createHarness();
376
+ const first = applyNetworkProxyConfig(
377
+ config({ rules: [], ipv4Only: ["open-design.ai"] }),
378
+ null,
379
+ h.deps,
380
+ );
381
+ const after = applyNetworkProxyConfig(
382
+ config({ enabled: false, rules: [], ipv4Only: ["open-design.ai"] }),
383
+ first,
384
+ h.deps,
385
+ );
386
+ expect(after).not.toBeNull();
387
+ expect(h.getCurrent()).toBe(after?.wrapper);
388
+ expect(h.getCurrent()).not.toBe(h.originalFetch);
389
+
390
+ await h.getCurrent()("https://amr-link.open-design.ai/v1/chat");
391
+ expect(h.proxyCalls).toHaveLength(1);
392
+ expect(h.proxyCalls[0]?.init.dispatcher).toBe(h.ipv4);
393
+ });
394
+
238
395
  it("构造失败:onError + 不包装(全直连),同指纹锁定不重试;指纹变化后重试", () => {
239
396
  const h = createHarness();
240
397
  h.setBuildProxy(() => {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ipv4Only 真机行为(真实 DNS + 真实网络 + 真实 dispatcher,标记为集成用例)。
3
+ *
4
+ * 默认跳过(需要外网 + 真实域名);显式开:
5
+ * NETWORK_PROXY_LIVE_IPV4=1 npx vitest run src/__tests__/ipv4-live.test.ts
6
+ *
7
+ * 验证点(connect.lookup 方案,与旧「URL 改写」方案的区别见 ipv4.ts 头注选型表):
8
+ * - 请求成功(200/401):TLS SNI / Host / 证书校验全按原域名,未被 IP 形态破坏;
9
+ * - 输入 URL 原样未被改写(域名保持原域名,只干预「选哪个 IP」);
10
+ * - 请求确实经本扩展构造的 IPv4 锁定分发器发出;
11
+ * - net.connect 层实测:用该 lookup 建连,remoteAddress 是 IPv4 点分形态(锁 v4 生效)。
12
+ */
13
+
14
+ import dns from "node:dns";
15
+ import net from "node:net";
16
+ import { fetch as undiciFetch } from "undici";
17
+
18
+ import { describe, expect, it } from "vitest";
19
+
20
+ import type { NetworkProxyConfig } from "../config.js";
21
+ import { buildIpv4Dispatcher } from "../dispatcher.js";
22
+ import {
23
+ applyNetworkProxyConfig,
24
+ type FetchLike,
25
+ type FetchWrapperDeps,
26
+ type ProxyFetchInit,
27
+ } from "../fetch-wrapper.js";
28
+ import { createIpv4Lookup } from "../ipv4.js";
29
+
30
+ const LIVE = process.env.NETWORK_PROXY_LIVE_IPV4 === "1";
31
+ const HOST = "amr-link.open-design.ai";
32
+
33
+ function config(overrides: Partial<NetworkProxyConfig> = {}): NetworkProxyConfig {
34
+ return {
35
+ enabled: true,
36
+ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
37
+ noProxy: ["localhost"],
38
+ rules: [],
39
+ ipv4Only: [HOST],
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe.skipIf(!LIVE)("ipv4Only 真机行为", () => {
45
+ it("锁 v4 后请求成功且 URL 未被改写(经本扩展 ipv4 分发器发出)", async () => {
46
+ // 前提:该域名确有 A 记录(锁 v4 才有地址可用,纯 IPv6 域名是配置错误)
47
+ const aRecords = await new Promise<dns.LookupAddress[]>((resolve, reject) =>
48
+ dns.lookup(HOST, { family: 4, all: true }, (e, a) => (e ? reject(e) : resolve(a))),
49
+ );
50
+ expect(aRecords.length).toBeGreaterThan(0);
51
+
52
+ const ipv4Dispatcher = buildIpv4Dispatcher();
53
+ let currentFetch: FetchLike = async () => new Response("unset");
54
+ const captured: { input?: string | URL | Request; dispatcher?: unknown } = {};
55
+ const deps: FetchWrapperDeps = {
56
+ getFetch: () => currentFetch,
57
+ setFetch: (f) => {
58
+ currentFetch = f;
59
+ },
60
+ buildProxy: () => {
61
+ throw new Error("rules 为空不应构造代理分发器");
62
+ },
63
+ buildIpv4: () => ipv4Dispatcher,
64
+ proxyFetch: (input, init: ProxyFetchInit) => {
65
+ captured.input = input;
66
+ captured.dispatcher = init.dispatcher;
67
+ return undiciFetch(input as never, init as never);
68
+ },
69
+ onError: () => {},
70
+ };
71
+
72
+ applyNetworkProxyConfig(config(), null, deps);
73
+
74
+ const res = await currentFetch(`https://${HOST}/v1/models`);
75
+ // 无 bearer token → 401 是预期(证明 TLS + Host 正确到达服务端,而非连接/证书失败)
76
+ expect([200, 401]).toContain(res.status);
77
+ await res.body?.cancel();
78
+
79
+ expect(captured.input).toBe(`https://${HOST}/v1/models`); // URL 不改写
80
+ expect(captured.dispatcher).toBe(ipv4Dispatcher); // 经本扩展的 ipv4 分发器
81
+ }, 30_000);
82
+
83
+ it("createIpv4Lookup 在 net.connect 层锁住地址族(remoteAddress 为 IPv4 点分形态)", async () => {
84
+ const socket = await new Promise<net.Socket>((resolve, reject) => {
85
+ const s = net.connect({ host: HOST, port: 443, lookup: createIpv4Lookup() }, () => resolve(s));
86
+ s.once("error", reject);
87
+ });
88
+ // 点分十进制 = IPv4;IPv6 会是冒号十六进制(含或不含 ::ffff: 前缀)
89
+ expect(socket.remoteAddress ?? "").toMatch(/^\d+\.\d+\.\d+\.\d+$/);
90
+ socket.destroy();
91
+ }, 15_000);
92
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * createIpv4Lookup 单测:DNS 解析族强制(锁 v4,非偏好)、回调双形态、options 透传、
3
+ * 错误上抛不回退 IPv6。用 fake dns.lookup 注入(不碰真实解析器;fake 按 dns.lookup 真实
4
+ * 语义回调——all:true 给地址数组,all:false 给首个地址字符串)。
5
+ *
6
+ * lookup 是纯地址族选择层——URL / Host / TLS 不在本层(connect.lookup 方案的设计边界,
7
+ * 见 ipv4.ts 头注),端到端行为由 fetch-wrapper.test.ts(分流)与 ipv4-live.test.ts
8
+ * (真机)覆盖。
9
+ */
10
+
11
+ import dns from "node:dns";
12
+
13
+ import { afterEach, describe, expect, it, vi } from "vitest";
14
+
15
+ import { createIpv4Lookup } from "../ipv4.js";
16
+
17
+ /** fake dns.lookup 收到的调用参数(host, options)。 */
18
+ const resolverCalls: Array<{ host: string; opts: Record<string, unknown> }> = [];
19
+
20
+ /**
21
+ * 注入 fake 解析器:记录调用参数,按 dns.lookup 真实回调语义回应
22
+ * (all:true → (null, addrs[]);all:false → (null, 首地址, family);err 非空 → (err, "", 4))。
23
+ */
24
+ function mockResolver(
25
+ respond: (host: string, opts: Record<string, unknown>) => {
26
+ err?: NodeJS.ErrnoException;
27
+ addrs: dns.LookupAddress[];
28
+ },
29
+ ): void {
30
+ vi.spyOn(dns, "lookup").mockImplementation(((
31
+ host: string,
32
+ opts: unknown,
33
+ cb: unknown,
34
+ ) => {
35
+ const options = (opts ?? {}) as Record<string, unknown>;
36
+ resolverCalls.push({ host, opts: options });
37
+ const callback = cb as (
38
+ e: NodeJS.ErrnoException | null,
39
+ a: string | dns.LookupAddress[],
40
+ f?: number,
41
+ ) => void;
42
+ const { err, addrs } = respond(host, options);
43
+ if (err !== undefined) {
44
+ callback(err, "", 4);
45
+ return;
46
+ }
47
+ if (options.all === true) {
48
+ callback(null, addrs);
49
+ return;
50
+ }
51
+ const first = addrs[0];
52
+ callback(null, first?.address ?? "", first?.family ?? 4);
53
+ }) as unknown as typeof dns.lookup);
54
+ }
55
+
56
+ afterEach(() => {
57
+ vi.restoreAllMocks();
58
+ resolverCalls.length = 0;
59
+ });
60
+
61
+ describe("createIpv4Lookup", () => {
62
+ it("all:false 形态:回调 (err, address, family),解析族强制为 4", async () => {
63
+ mockResolver(() => ({ addrs: [{ address: "162.159.129.1", family: 4 }] }));
64
+ const lookup = createIpv4Lookup();
65
+ const result = await new Promise<{ address: string; family: number }>((resolve, reject) => {
66
+ lookup("api.example.com", { all: false }, (err, address, family) => {
67
+ if (err !== null || typeof address !== "string") {
68
+ reject(err ?? new Error("expected single-address form"));
69
+ return;
70
+ }
71
+ resolve({ address, family: family ?? 0 });
72
+ });
73
+ });
74
+ expect(result.address).toBe("162.159.129.1");
75
+ expect(result.family).toBe(4);
76
+ });
77
+
78
+ it("all:true 形态:回调 (err, addresses[]),条目 family 均为 4", async () => {
79
+ mockResolver(() => ({
80
+ addrs: [
81
+ { address: "162.159.129.1", family: 4 },
82
+ { address: "162.159.129.2", family: 4 },
83
+ ],
84
+ }));
85
+ const lookup = createIpv4Lookup();
86
+ const addrs = await new Promise<dns.LookupAddress[]>((resolve, reject) => {
87
+ lookup("api.example.com", { all: true }, (err, address) => {
88
+ if (err !== null || !Array.isArray(address)) {
89
+ reject(err ?? new Error("expected all-addresses form"));
90
+ return;
91
+ }
92
+ resolve(address);
93
+ });
94
+ });
95
+ expect(addrs).toEqual([
96
+ { address: "162.159.129.1", family: 4 },
97
+ { address: "162.159.129.2", family: 4 },
98
+ ]);
99
+ });
100
+
101
+ it("调用方传 family:6 仍被覆写为 4(锁 v4 而非偏好 v4)", async () => {
102
+ mockResolver(() => ({ addrs: [{ address: "10.0.0.1", family: 4 }] }));
103
+ const lookup = createIpv4Lookup();
104
+ await new Promise<void>((resolve, reject) => {
105
+ lookup("forced.example.com", { family: 6 }, (err) => {
106
+ if (err !== null) reject(err);
107
+ else resolve();
108
+ });
109
+ });
110
+ expect(resolverCalls[0]?.opts.family).toBe(4);
111
+ });
112
+
113
+ it("委托系统 dns.lookup(hostname 与其余 options 透传,保留 /etc/hosts 等语义)", async () => {
114
+ mockResolver(() => ({ addrs: [{ address: "10.0.0.2", family: 4 }] }));
115
+ const lookup = createIpv4Lookup();
116
+ await new Promise<void>((resolve, reject) => {
117
+ lookup("passthrough.example.com", { all: false, hints: 1, verbatim: true }, (err) => {
118
+ if (err !== null) reject(err);
119
+ else resolve();
120
+ });
121
+ });
122
+ expect(resolverCalls[0]?.host).toBe("passthrough.example.com");
123
+ expect(resolverCalls[0]?.opts.hints).toBe(1);
124
+ expect(resolverCalls[0]?.opts.verbatim).toBe(true);
125
+ });
126
+
127
+ it("解析器报错:err 原样透传(不回退 IPv6)", async () => {
128
+ mockResolver(() => {
129
+ const err: NodeJS.ErrnoException = new Error("getaddrinfo ENOTFOUND no-a.example.com");
130
+ err.code = "ENOTFOUND";
131
+ return { err, addrs: [] };
132
+ });
133
+ const lookup = createIpv4Lookup();
134
+ const err = await new Promise<NodeJS.ErrnoException | null>((resolve) => {
135
+ lookup("no-a.example.com", { all: false }, (e) => resolve(e));
136
+ });
137
+ expect(err?.code).toBe("ENOTFOUND");
138
+ });
139
+ });
package/src/config.ts CHANGED
@@ -38,6 +38,22 @@ export interface NetworkProxyConfig { // oe-exempt:20261019:framework:ext-config
38
38
  noProxy: string[];
39
39
  /** 代理规则(主机名条目),命中即经代理 */
40
40
  rules: string[];
41
+ /**
42
+ * 直连 + 强制 IPv4 清单(主机名条目):命中即不走代理,DNS 解析锁定 A 记录后直连
43
+ * (`connect.lookup` 强制 family:4;URL / Host 头 / TLS SNI 保持原样——选型依据见
44
+ * ipv4.ts 头注)。
45
+ *
46
+ * 存在理由:双栈域名在「IPv6 可达但握手抖动」时,pi 的 fetch 走 happy-eyeballs
47
+ * (autoSelectFamilyAttemptTimeout=2000)反复试 IPv6,把 connect 预算耗在坏路径上,
48
+ * 最终表现为 `Request timed out.`——实为连接层超时,不是 provider 故障。
49
+ *
50
+ * 独立于 `enabled` 代理开关:`enabled=false` 时本清单照常生效(IPv4 锁定是直连的
51
+ * 地址族修正,不是代理能力;清单非空即安装包装层)。
52
+ *
53
+ * 优先级:noProxy > ipv4Only > rules(rules 另受 enabled 门控——开关关闭时规则
54
+ * 不参与分流)。
55
+ */
56
+ ipv4Only: string[];
41
57
  }
42
58
 
43
59
  /** 默认配置:禁用态 + 常见本地代理地址占位 + 保护本地回环(与 shared DEFAULT 深相等,镜像锁定测试锁两侧)。 */
@@ -46,6 +62,7 @@ export const DEFAULT_NETWORK_PROXY_CONFIG: NetworkProxyConfig = {
46
62
  proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
47
63
  noProxy: ["localhost", "127.0.0.1"],
48
64
  rules: [],
65
+ ipv4Only: [],
49
66
  };
50
67
 
51
68
  /** llm-shared loadConfig 的包名(决定文件名 network-proxy-ext-config.json)。 */
@@ -114,7 +131,7 @@ export function validateNetworkProxyConfig(config: unknown): NetworkProxyValidat
114
131
  return { ok: false, errors: [`网络代理配置必须是对象: ${String(config)}`] };
115
132
  }
116
133
  const errors: string[] = [];
117
- const { enabled, proxy, noProxy, rules } = config as Record<string, unknown>;
134
+ const { enabled, proxy, noProxy, rules, ipv4Only } = config as Record<string, unknown>;
118
135
  if (typeof enabled !== "boolean") {
119
136
  errors.push(
120
137
  `网络代理开关必须是开或关: ${JSON.stringify(enabled) ?? String(enabled)},请重新切换一次开关后保存`,
@@ -123,6 +140,11 @@ export function validateNetworkProxyConfig(config: unknown): NetworkProxyValidat
123
140
  errors.push(...collectProxyServerErrors(proxy));
124
141
  errors.push(...collectHostEntryErrors(noProxy, "直连排除清单(noProxy)"));
125
142
  errors.push(...collectHostEntryErrors(rules, "代理规则(rules)"));
143
+ // ipv4Only 是后加字段:缺省(undefined)= 空清单,向后兼容旧配置文件;
144
+ // 只有显式声明了非数组形态才报错。
145
+ if (ipv4Only !== undefined) {
146
+ errors.push(...collectHostEntryErrors(ipv4Only, "强制 IPv4 直连清单(ipv4Only)"));
147
+ }
126
148
  return errors.length > 0 ? { ok: false, errors } : { ok: true };
127
149
  }
128
150
 
@@ -131,13 +153,17 @@ export function validateNetworkProxyConfig(config: unknown): NetworkProxyValidat
131
153
  * 非法配置 throw(含逐项错误清单)→ loadConfig 回落 defaults 并把消息交给 onWarning——
132
154
  * 非法配置不静默修正(设计验收场景 10:坏配置不装分流器且 error 可归因),回落 defaults
133
155
  * 即「全部直连」的安全态。
156
+ *
157
+ * `ipv4Only` 向后兼容:旧配置文件无此键时补空数组(校验层已容忍 undefined,此处补全
158
+ * 让下游 `hitsIpv4Only(config.ipv4Only)` 无需再判 undefined)。
134
159
  */
135
160
  export function normalizeNetworkProxyConfig(raw: unknown): NetworkProxyConfig {
136
161
  const result = validateNetworkProxyConfig(raw);
137
162
  if (!result.ok) {
138
163
  throw new Error(result.errors.join(";"));
139
164
  }
140
- return raw as NetworkProxyConfig;
165
+ const config = raw as NetworkProxyConfig;
166
+ return { ...config, ipv4Only: config.ipv4Only ?? [] };
141
167
  }
142
168
 
143
169
  /** 条目去重 + 归一化视图(分流匹配用:匹配函数自带归一化,这里只去重保序)。 */
@@ -162,8 +188,13 @@ export function hitsRules(host: string, rules: string[]): boolean {
162
188
  return dedupeEntries(rules).some((pattern) => matchesHostPattern(host, pattern));
163
189
  }
164
190
 
191
+ /** 强制 IPv4 直连命中判定(去重视图 + suffix 匹配,与 rules / noProxy 同一匹配语义)。 */
192
+ export function hitsIpv4Only(host: string, ipv4Only: string[]): boolean {
193
+ return dedupeEntries(ipv4Only).some((pattern) => matchesHostPattern(host, pattern));
194
+ }
195
+
165
196
  /**
166
- * 配置指纹:enabled+proxy+noProxy+rules 序列化比对(行为序列 2——指纹变化才重建分发器)。
197
+ * 配置指纹:enabled+proxy+noProxy+rules+ipv4Only 序列化比对(行为序列 2——指纹变化才重建分发器)。
167
198
  * normalize 后结构固定,JSON.stringify 字段序稳定(字面量构造序)。
168
199
  */
169
200
  export function networkProxyFingerprint(config: NetworkProxyConfig): string {
@@ -172,5 +203,6 @@ export function networkProxyFingerprint(config: NetworkProxyConfig): string {
172
203
  proxy: config.proxy,
173
204
  noProxy: dedupeEntries(config.noProxy),
174
205
  rules: dedupeEntries(config.rules),
206
+ ipv4Only: dedupeEntries(config.ipv4Only),
175
207
  });
176
208
  }
package/src/dispatcher.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * 代理分发器构造(供 fetch-wrapper 在规则命中时按请求注入)。
2
+ * 分发器构造(供 fetch-wrapper 按请求注入)。
3
3
  *
4
4
  * 拦截点已从 undici 全局分发器改为 `globalThis.fetch` 包装(见 fetch-wrapper.ts 头注:
5
5
  * 打包版 pi 内置 undici 与扩展 bundle 内联的 undici 是两套副本,全局槽互不相通)。本模块
6
- * 只负责按协议构造代理分发器,规则判定 / 计数 / 直连委托都在 fetch-wrapper 完成。
6
+ * 负责构造两类分发器——代理分发器(rules 命中时)与 IPv4 锁定分发器(ipv4Only 命中时),
7
+ * 规则判定 / 计数 / 直连委托都在 fetch-wrapper 完成。
7
8
  *
8
9
  * 两协议构造契约(undici 7.28.0 源码读码核实,单测冒烟防回归):
9
10
  * - http/https 走 ProxyAgent,调优经 `proxyTls: { autoSelectFamilyAttemptTimeout: 2000 }`
@@ -20,10 +21,11 @@
20
21
 
21
22
  import { EventEmitter } from "node:events";
22
23
 
23
- import { buildConnector, ProxyAgent, Socks5ProxyAgent } from "undici";
24
+ import { Agent, buildConnector, ProxyAgent, Socks5ProxyAgent } from "undici";
24
25
  import type { Dispatcher as IDispatcher } from "undici";
25
26
 
26
27
  import type { NetworkProxyConfig, NetworkProxyServer } from "./config.js";
28
+ import { createIpv4Lookup } from "./ipv4.js";
27
29
 
28
30
  /** 到代理服务器连接的 autoSelectFamilyAttemptTimeout(对齐 pi 原生调优同值)。 */
29
31
  export const PROXY_CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
@@ -77,3 +79,18 @@ export function buildProxyDispatcher(config: NetworkProxyConfig): IDispatcher {
77
79
  }),
78
80
  );
79
81
  }
82
+
83
+ /**
84
+ * IPv4 锁定分发器(`ipv4Only` 规则的执行体装配):唯一干预 = `connect.lookup` 把 DNS
85
+ * 解析强制到 A 记录(选型依据见 ipv4.ts 头注——改 URL 会因 SNI/Host 不一致被 Cloudflare
86
+ * 拒为 403,只有改 DNS 族在 Node 与 Bun 下都正确)。URL / Host 头 / TLS 全部保持原样。
87
+ *
88
+ * 刻意不设 `connect.timeout`:保持 undici 默认语义(pi 原生 dispatcher 亦未设)——锁 v4
89
+ * 后 happy-eyeballs 没有坏路径可耗预算,不需要也不应该改连接预算(调大 timeout 只是把
90
+ * 失败推迟,2026-10-22 用户裁决)。
91
+ */
92
+ export function buildIpv4Dispatcher(): IDispatcher {
93
+ return withUndiciErrorListener(
94
+ new Agent({ connect: { lookup: createIpv4Lookup() } }),
95
+ );
96
+ }
@@ -12,11 +12,12 @@
12
12
  * 稳定接缝 = `globalThis.fetch`:pi-ai 的 OpenAI 兼容请求在每次构造 client 时经
13
13
  * `Shims.getDefaultFetch()` 动态取当前全局 fetch(node_modules/openai/internal/shims.js),
14
14
  * 包装它不依赖 pi 与扩展是否共享 undici 副本。命中规则的请求用扩展自己的 undici fetch +
15
- * 代理分发器发出;未命中的请求原样调用包装前的 fetch,保留 pi 原生 EnvHttpProxyAgent /
16
- * 连接调优 / 错误处理语义。
15
+ * 分发器发出(代理或 IPv4 锁定,见 dispatcher.ts);未命中的请求原样调用包装前的 fetch,
16
+ * 保留 pi 原生 EnvHttpProxyAgent / 连接调优 / 错误处理语义。
17
17
  *
18
18
  * 生命周期与旧安装状态机同构:
19
- * - 加载 / 每 turn 开始:重读配置推进状态(禁用 = 摘除包装恢复原 fetch);
19
+ * - 加载 / 每 turn 开始:重读配置推进状态(`enabled` 关且 `ipv4Only` 空 = 摘除包装恢复原
20
+ * fetch;`ipv4Only` 非空时包装保留——IPv4 锁定是直连地址族修正,独立于代理开关);
20
21
  * - 指纹未变且包装仍在进程槽位:零动作(不重建);
21
22
  * - 槽位被第三方整体覆盖(其他 extension 包装 / 手动改写;pi 的 configureHttpDispatcher
22
23
  * 有 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99)——检测到 fetch
@@ -31,6 +32,7 @@
31
32
  import type { Dispatcher } from "undici";
32
33
 
33
34
  import {
35
+ hitsIpv4Only,
34
36
  hitsNoProxy,
35
37
  hitsRules,
36
38
  networkProxyFingerprint,
@@ -43,10 +45,9 @@ import { recordDispatch } from "./stats.js";
43
45
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
44
46
 
45
47
  type FetchInput = Parameters<FetchLike>[0];
46
- type FetchInit = Parameters<FetchLike>[1];
47
48
 
48
49
  /**
49
- * 经代理发出时的 init:保留调用方全部选项,附上本次请求的代理分发器。
50
+ * 经分发器发出时的 init:保留调用方全部选项,附上本次请求的分发器(代理或 IPv4 锁定)。
50
51
  * Omit 掉 Node 全局 RequestInit 自带的 dispatcher(undici-types 类型身份),避免与 npm
51
52
  * undici 的 Dispatcher 形成 `Dispatcher & Dispatcher` 冲突;运行时由本字段整体覆盖。
52
53
  * 签名自包含(不引用文件内私有别名 FetchInit)——导出签名的被引用类型必须可命名。
@@ -84,6 +85,11 @@ export interface FetchWrapperDeps { // oe-exempt:20261020:framework:状态机依
84
85
  setFetch: (fetch: FetchLike) => void;
85
86
  /** 代理分发器构造(可注入抛错——「构造失败 → 不包装 → 全直连」单测入口)。 */
86
87
  buildProxy: (config: NetworkProxyConfig) => Dispatcher;
88
+ /**
89
+ * IPv4 锁定分发器构造(`connect.lookup` 强制 family:4)。
90
+ * 只在 `ipv4Only` 非空时调用;可注入抛错(回退到「不做 v4 锁定」)。
91
+ */
92
+ buildIpv4: () => Dispatcher;
87
93
  proxyFetch: ProxyFetch;
88
94
  /** extension error 呈现(logger.error;文案含恢复动作)。 */
89
95
  onError: (message: string) => void;
@@ -138,34 +144,77 @@ function wrapperMarkOf(fetch: FetchLike): FetchWrapperMark | null {
138
144
  }
139
145
 
140
146
  /**
141
- * 构造包装函数:每次调用现算 host → 计数 → 命中规则走代理 fetch,未命中委托原 fetch。
147
+ * 构造包装函数:每次调用现算 host → 计数 → 三态分流,全部未命中则委托原 fetch。
148
+ *
149
+ * 分流优先级(单一源在这里,config.ts 的 hits* 只提供匹配,不重复排序):
150
+ * 1. `noProxy` 命中 → 原样直连(用户显式排除,连 IPv4 锁定也不做);
151
+ * 2. `ipv4Only` 命中 → IPv4 锁定直连(**不走代理**,本扩展只做地址族选择;独立于
152
+ * `enabled` 代理开关——开关关闭时本分支照常生效);
153
+ * 3. `enabled` 且 `rules` 命中 → 经代理 fetch(开关关闭时 rules 不参与分流,落入直连);
154
+ * 4. 其余 → 原样直连。
155
+ *
142
156
  * 计数时点 = 请求发起时(与旧分流器一致;不等待完成,成败由 pi 原生错误链路呈现)。
157
+ * ipv4 锁定计入 `direct`(语义上确实是直连,与经代理区分)。
143
158
  */
144
159
  function createWrappedFetch(
145
160
  originalFetch: FetchLike,
146
161
  config: NetworkProxyConfig,
147
- proxy: Dispatcher,
162
+ proxy: Dispatcher | null,
163
+ ipv4: Dispatcher | null,
148
164
  proxyFetch: ProxyFetch,
149
165
  ): FetchLike {
150
166
  const wrapped: FetchLike = async (input, init) => {
151
167
  const host = hostnameOfInput(input);
152
168
  if (host === "") return originalFetch(input, init);
153
- const proxied = !hitsNoProxy(host, config.noProxy) && hitsRules(host, config.rules);
154
- recordDispatch(host, proxied);
155
- if (!proxied) return originalFetch(input, init);
156
- const rest: Omit<NonNullable<FetchInit>, "dispatcher"> = { ...(init ?? {}) };
157
- return proxyFetch(input, { ...rest, dispatcher: proxy });
169
+
170
+ if (hitsNoProxy(host, config.noProxy)) {
171
+ recordDispatch(host, false);
172
+ return originalFetch(input, init);
173
+ }
174
+
175
+ if (hitsIpv4Only(host, config.ipv4Only)) {
176
+ recordDispatch(host, false);
177
+ // URL / Host 头 / TLS 全部保持原样,只把 DNS 族锁到 IPv4(connect.lookup)。
178
+ // 构造失败(ipv4 === null)= 降级为普通直连(不阻断请求)。
179
+ if (ipv4 === null) return originalFetch(input, init);
180
+ return proxyFetch(input, { ...(init ?? {}), dispatcher: ipv4 });
181
+ }
182
+
183
+ if (config.enabled && hitsRules(host, config.rules)) {
184
+ recordDispatch(host, true);
185
+ // proxy 非空由 applyNetworkProxyConfig 保证(rules 非空才会构造)。
186
+ if (proxy === null) return originalFetch(input, init);
187
+ return proxyFetch(input, { ...(init ?? {}), dispatcher: proxy });
188
+ }
189
+
190
+ recordDispatch(host, false);
191
+ return originalFetch(input, init);
158
192
  };
159
193
  markWrapper(wrapped, originalFetch);
160
194
  return wrapped;
161
195
  }
162
196
 
197
+ /**
198
+ * 槽位归属判定:state 记录的包装当前是否仍挂在 globalThis.fetch 上。
199
+ * 三处共用(禁用恢复 / 构造失败恢复 / 重建前 original 推导)——槽位被第三方
200
+ * 整体覆盖时不再改写别人的实现,也不把覆盖者误当自己的 original。
201
+ */
202
+ function wrapperStillInstalled(
203
+ state: FetchWrapperState | null,
204
+ currentFetch: FetchLike,
205
+ ): boolean {
206
+ return (
207
+ state?.wrapper !== null && state?.wrapper !== undefined && currentFetch === state.wrapper
208
+ );
209
+ }
210
+
163
211
  /** 禁用态收口:仅当槽位仍归本扩展时恢复原 fetch(槽位被外部覆盖时不改写别人的实现)。 */
164
212
  function restoreOriginalIfOwned(
165
213
  state: FetchWrapperState | null,
166
214
  deps: Pick<FetchWrapperDeps, "getFetch" | "setFetch">,
167
215
  ): void {
168
- if (state?.wrapper !== null && state?.wrapper !== undefined && deps.getFetch() === state.wrapper) {
216
+ // state !== null 前置(谓词函数不向调用方传播 narrowing;state 为 null 时谓词恒 false,语义等价)
217
+ if (state !== null && wrapperStillInstalled(state, deps.getFetch())) {
169
218
  deps.setFetch(state.originalFetch);
170
219
  }
171
220
  }
@@ -198,12 +247,31 @@ function lockOnBuildFailure(
198
247
  `网络代理转发器构造失败(协议 ${config.proxy.protocol}),已保持全部直连,不影响 pi 运行;` +
199
248
  `可核对代理配置后保存重试,或关闭网络代理开关。构造错误: ${error instanceof Error ? error.message : String(error)}`,
200
249
  );
201
- if (state?.wrapper !== null && state?.wrapper !== undefined && deps.getFetch() === state.wrapper) {
202
- deps.setFetch(state.originalFetch);
203
- }
250
+ restoreOriginalIfOwned(state, deps);
204
251
  return { originalFetch, fingerprint, wrapper: null };
205
252
  }
206
253
 
254
+ /**
255
+ * IPv4 锁定分发器构造(失败只降级「不锁 v4」,不撤整层包装——代理分流仍应生效)。
256
+ * 独立成函数:构造失败是设计内降级路径(error 上报后返回 null),不与主状态机转移混排。
257
+ */
258
+ function buildIpv4Dispatcher(
259
+ config: NetworkProxyConfig,
260
+ deps: FetchWrapperDeps,
261
+ ): Dispatcher | null {
262
+ if (config.ipv4Only.length === 0) return null;
263
+ try {
264
+ return deps.buildIpv4();
265
+ } catch (error) {
266
+ deps.onError(
267
+ `IPv4 锁定分发器构造失败,ipv4Only 规则降级为普通直连(其余分流不受影响):${
268
+ error instanceof Error ? error.message : String(error)
269
+ }`,
270
+ );
271
+ return null;
272
+ }
273
+ }
274
+
207
275
  /**
208
276
  * 按新配置推进包装状态机(转移表见模块头)。config 契约 = loadNetworkProxyConfig 产物
209
277
  * (normalize 已校验合法——非法配置在读取层回落 defaults+error,构造性到达本函数恒合法)。
@@ -213,33 +281,39 @@ export function applyNetworkProxyConfig(
213
281
  state: FetchWrapperState | null,
214
282
  deps: FetchWrapperDeps,
215
283
  ): FetchWrapperState | null {
216
- if (!config.enabled) {
284
+ // 包装层的存在条件 = 代理开关开(rules 分流可用)或 ipv4Only 非空(IPv4 锁定直连)。
285
+ // 两者全无才摘除包装——ipv4Only 是直连地址族修正,语义上不依赖代理开关;
286
+ // 依据:2026-10-22 真机事故,用户仅配 ipv4Only(enabled=false)时整层被摘,
287
+ // subagent 的 LLM 请求全部 Connection error(双栈域名的 IPv6 路径抖动)。
288
+ if (!config.enabled && config.ipv4Only.length === 0) {
217
289
  restoreOriginalIfOwned(state, deps);
218
290
  return null;
219
291
  }
220
292
 
221
293
  const fingerprint = networkProxyFingerprint(config);
222
- if (state && state.fingerprint === fingerprint && state.wrapper === null) {
223
- return state; // 锁定态:同指纹不重试构造(首败即停)
224
- }
225
294
  const currentFetch = deps.getFetch();
226
- const owned =
227
- state?.wrapper !== null &&
228
- state?.wrapper !== undefined &&
229
- currentFetch === state.wrapper;
230
- if (state && state.fingerprint === fingerprint && owned) {
231
- return state; // 同指纹且包装仍在槽位:零动作
295
+ const installed = wrapperStillInstalled(state, currentFetch);
296
+ // 同指纹短路:锁定态不重试构造(首败即停);包装仍在槽位零动作。
297
+ if (state && state.fingerprint === fingerprint && (state.wrapper === null || installed)) {
298
+ return state;
232
299
  }
233
- const originalFetch = owned && state ? state.originalFetch : currentFetch;
300
+ const originalFetch = installed && state ? state.originalFetch : currentFetch;
234
301
 
235
- let proxy: Dispatcher;
236
- try {
237
- proxy = deps.buildProxy(config);
238
- } catch (error) {
239
- return lockOnBuildFailure(config, state, originalFetch, fingerprint, deps, error);
302
+ // 代理分发器只在「确实可能用到代理」时构造:开关关闭或 rules 为空 = 无任何主机走
303
+ // 代理,此时不应因代理配置不可用而锁死整个包装(含 ipv4Only 直连改写)。
304
+ // 依据:一次事故(2026-10-22 真机验证)——仅配 ipv4Only、rules=[] 时仍构造
305
+ // ProxyAgent 并失败,lockOnBuildFailure 把包装整层撤下,ipv4Only 静默失效。
306
+ let proxy: Dispatcher | null = null;
307
+ if (config.enabled && config.rules.length > 0) {
308
+ try {
309
+ proxy = deps.buildProxy(config);
310
+ } catch (error) {
311
+ return lockOnBuildFailure(config, state, originalFetch, fingerprint, deps, error);
312
+ }
240
313
  }
241
314
 
242
- const wrapper = createWrappedFetch(originalFetch, config, proxy, deps.proxyFetch);
315
+ const ipv4 = buildIpv4Dispatcher(config, deps);
316
+ const wrapper = createWrappedFetch(originalFetch, config, proxy, ipv4, deps.proxyFetch);
243
317
  deps.setFetch(wrapper);
244
318
  return { originalFetch, fingerprint, wrapper };
245
319
  }
package/src/index.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * @zhushanwen/pi-network-proxy 入口:配置驱动的 globalThis.fetch 包装与事件接线。
3
3
  *
4
4
  * 行为序列(fetch-wrapper.ts 头注有完整设计依据):
5
- * - 加载时:读配置校验,enabled 且校验过 → 包装 globalThis.fetch(命中规则走代理,
6
- * 未命中委托原 fetch);校验失败 / 构造失败 → extension error(文案含恢复动作:
7
- * 配置文件路径),不包装不阻断 pi(全部直连)。
5
+ * - 加载时:读配置校验,enabled 或 ipv4Only 非空且校验过 → 包装 globalThis.fetch
6
+ * (ipv4Only 命中锁 IPv4 直连、enabled 且命中规则走代理,未命中委托原 fetch);
7
+ * 校验失败 / 构造失败 → extension error(文案含恢复动作:配置文件路径),不包装
8
+ * 不阻断 pi(全部直连)。
8
9
  * - 每 turn 开始:重读配置(llm-shared 读时刷新热重载),指纹变化才重建;enabled true→false
9
- * 恢复原 fetch(fetch-wrapper.ts 状态机)。
10
+ * 且 ipv4Only 空时恢复原 fetch(fetch-wrapper.ts 状态机;ipv4Only 非空则包装保留,
11
+ * IPv4 锁定不随代理开关关闭而失效)。
10
12
  * - 每 turn 结束:计数增量 flush 追加写入 stats JSONL(stats.ts);进程退出兜底 flush。
11
13
  *
12
14
  * 覆盖面含 subagent 的 pi 进程(subagent 的 LLM 请求同样分流计数,设计前提 P4)——不设
@@ -21,7 +23,7 @@ import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
21
23
  import { getConfigPath } from "@zhushanwen/pi-llm-shared";
22
24
 
23
25
  import { loadNetworkProxyConfig } from "./config.js";
24
- import { buildProxyDispatcher } from "./dispatcher.js";
26
+ import { buildIpv4Dispatcher, buildProxyDispatcher } from "./dispatcher.js";
25
27
  import {
26
28
  applyNetworkProxyConfig,
27
29
  detachResidualFetchWrapper,
@@ -57,6 +59,7 @@ const deps: FetchWrapperDeps = {
57
59
  globalThis.fetch = fetch;
58
60
  },
59
61
  buildProxy: buildProxyDispatcher,
62
+ buildIpv4: buildIpv4Dispatcher,
60
63
  proxyFetch,
61
64
  onError: (message) => logger.error(`${message} ${recoveryHint()}`),
62
65
  };
@@ -94,7 +97,8 @@ export default function networkProxyExtension(pi: ExtensionAPI): void {
94
97
  ensureExitFlush();
95
98
  refreshFromConfig();
96
99
 
97
- // turn 粒度热生效:turn 开始重读配置(mtime+size 未变时零 IO),指纹变化重建 / 禁用恢复。
100
+ // turn 粒度热生效:turn 开始重读配置(mtime+size 未变时零 IO),指纹变化重建 /
101
+ // 代理关且 ipv4Only 空时恢复直连。
98
102
  pi.on("turn_start", () => {
99
103
  refreshFromConfig();
100
104
  });
package/src/ipv4.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 直连 + 强制 IPv4 执行层(`ipv4Only` 规则的执行体)。
3
+ *
4
+ * 存在理由(实测定因,非推测):
5
+ * 双栈域名(如 `amr-link.open-design.ai`)在「IPv6 连通但 TLS 握手抖动」时,
6
+ * pi 的 fetch 走 happy-eyeballs——`autoSelectFamilyAttemptTimeout=2000` 会在 2s 后
7
+ * 换下一个地址,而整个 socket 的 connect 预算由 undici dispatcher 决定(未显式配置时
8
+ * 为 undici 默认 10s,实测复现 10.5s 报 `Request timed out.`)。结果是连接层把预算
9
+ * 耗在坏路径上,最终报错文案看起来像 provider 故障。
10
+ *
11
+ * ## 实现选型:改 DNS 解析,不改 URL
12
+ *
13
+ * 早期实现(参考已废弃的外挂扩展)是「把 URL 主机名替换成 IPv4 字面量 + 保留 Host 头 +
14
+ * 显式 TLS serverName」。**实测证伪**(2026-10-22,同一 provider / 同一 IP):
15
+ *
16
+ * | 方式 | curl/node-undici 结果 |
17
+ * |---|---|
18
+ * | 域名 URL + `--resolve` 到 IPv4(SNI = 域名) | 200 |
19
+ * | IP URL + `Host: 域名` 头(SNI = IP) | TLS 握手失败 / 经 `connect.servername` 补 SNI 后 403 |
20
+ * | 域名 URL + `connect.lookup` 强制 family:4 | 200 |
21
+ *
22
+ * 根因:Cloudflare 侧按 **SNI + Host 一致**路由;IP 字面量形态即便补了 SNI 也进不了
23
+ * 正确的 host 路由(403)。而「URL 保持域名 + 只改 DNS family」让 SNI / Host / 证书
24
+ * 校验 / 路由全部自然正确——我们唯一要干预的就是「选哪个 IP」。
25
+ *
26
+ * 因此本层的产出 = 一个 `lookup` 实现(Node `net.connect` / `tls.connect` 的 `lookup`
27
+ * 选项签名,见 dispatcher.ts 的接线),直接委托系统 `dns.lookup` 并覆写 `family: 4`——
28
+ * /etc/hosts、resolver 配置、OS 级 DNS 缓存等语义全部保留,连接池化后每个 origin 的
29
+ * 解析频次本就低,不另建应用层缓存。
30
+ *
31
+ * ## 边界(刻意不做的事)
32
+ *
33
+ * - 不缓存 Response、不重试、不改超时——只做地址族选择;
34
+ * - 不回退 IPv6:`family: 4` 下系统解析器无 A 记录即报错(ENOTFOUND / ENODATA),
35
+ * 错误沿连接层上抛——命中 ipv4Only 却解不到 A 记录是显式失败,「我要求锁 v4」的
36
+ * 意图静默失效比失败更难归因;
37
+ * - 纯 IPv6 服务命中 ipv4Only 会连接失败——这是配置错误的显式暴露,不是本层的兜底责任。
38
+ */
39
+
40
+ import dns from "node:dns";
41
+ import type { LookupFunction } from "node:net";
42
+
43
+ /** 地址族标识:IPv4(Node dns/net 的 family 数字语义)。 */
44
+ const IPV4_FAMILY = 4;
45
+
46
+ /**
47
+ * 构造 `connect.lookup` 实现:把任意 host 的解析强制到 IPv4(family: 4)。
48
+ *
49
+ * 两种回调形态都要支持(Node net / tls 的不同调用路径):
50
+ * - `all: true` → `(err, [{address, family}])`
51
+ * - `all: false`(缺省)→ `(err, address, family)`
52
+ *
53
+ * 调用方传入的 `family`(含 6)一律覆写为 4——本函数的语义是「锁 v4」,不是「偏好 v4」。
54
+ */
55
+ export function createIpv4Lookup(): LookupFunction {
56
+ return (hostname, options, callback) => {
57
+ const wantsAll = options?.all === true;
58
+ dns.lookup(
59
+ hostname,
60
+ { ...options, family: IPV4_FAMILY, all: wantsAll },
61
+ (err, address, family) => {
62
+ if (err) {
63
+ callback(err, "", IPV4_FAMILY);
64
+ return;
65
+ }
66
+ if (wantsAll) {
67
+ callback(
68
+ null,
69
+ Array.isArray(address) ? address : [{ address, family: IPV4_FAMILY }],
70
+ );
71
+ return;
72
+ }
73
+ callback(null, address, family);
74
+ },
75
+ );
76
+ };
77
+ }