@zhushanwen/pi-network-proxy 0.3.0 → 0.3.2
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 +11 -8
- package/package.json +3 -3
- package/skills/network-proxy-ext-config/SKILL.md +27 -8
- package/src/__tests__/fetch-wrapper.test.ts +57 -0
- package/src/__tests__/socks5-guard.test.ts +31 -0
- package/src/config.ts +5 -1
- package/src/dispatcher.ts +48 -12
- package/src/fetch-wrapper.ts +24 -18
- package/src/index.ts +8 -5
package/README.md
CHANGED
|
@@ -5,20 +5,23 @@ LLM 网络代理分流:包装 pi 进程的 `globalThis.fetch`,按主机名
|
|
|
5
5
|
## 行为摘要
|
|
6
6
|
|
|
7
7
|
- **拦截点**:`globalThis.fetch`。pi-ai 的 OpenAI 兼容请求在每次构造 client 时经 `Shims.getDefaultFetch()` 动态取当前全局 fetch,包装它不依赖 pi 与扩展共享 undici 模块实例
|
|
8
|
-
- **安装/卸载**:extension 加载时读配置,`enabled`
|
|
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 匹配(等于或以 `.条目`
|
|
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
|
-
- **降级契约**:socks5
|
|
13
|
+
- **降级契约**:socks5 构造失败(副本缺 `Socks5ProxyAgent`,见下节副本策略 / undici 实验性 API 变化)→ 记错误、恢复原 fetch、全部直连,切回 http 协议恢复;同指纹不重试构造,配置修正(指纹变化)后自然重试
|
|
14
14
|
|
|
15
15
|
## 为什么包装 fetch 而不是 undici 全局 dispatcher
|
|
16
16
|
|
|
17
|
-
undici 的 `setGlobalDispatcher` 槽是模块级变量,只有「pi 与扩展加载同一份 undici
|
|
17
|
+
undici 的 `setGlobalDispatcher` 槽是模块级变量,只有「pi 与扩展加载同一份 undici 模块实例」时才生效。本包的 undici 按宿主运行时解析(见下节),与 pi 自己的 HTTP 栈不保证同实例——扩展安装的全局分流器 pi 可能根本不用(打包版实测:请求静默直连、统计为空)。`globalThis.fetch` 是两套实现之间稳定的接缝(pi-ai / OpenAI SDK 每次构造 client 时动态读取),因此拦截点定在这里。
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
## undici 副本策略(按宿主解析)
|
|
20
|
+
|
|
21
|
+
构建期不内联 undici(bundle external),运行时由宿主解析:Node 宿主(独立 pi / dev)从 `node_modules` 解析 npm 副本;Bun 宿主(打包版 pi)解析二进制内置副本(`builtin://thirdparty/undici`,Bun 团队随 Bun 版本适配)。每个宿主用自己官方维护的网络栈。
|
|
22
|
+
|
|
23
|
+
- **不得回退内联**:npm undici 副本在 Bun 兼容层下 `fetch` 读 chunked 流式响应体永不产块(LLM SSE 请求挂死,2026-10-22 事故实测)
|
|
24
|
+
- **能力面差异**:内置副本无 `Socks5ProxyAgent`——**打包版不支持 socks5 代理**(构造时明确报错并保持全部直连,改用 http/https 恢复);`Agent` / `ProxyAgent` / `buildConnector` / `fetch` 两副本都有且流式行为实测正常(含 IPv4 锁定与 http 代理分支)
|
|
22
25
|
|
|
23
26
|
## 配置
|
|
24
27
|
|
|
@@ -38,7 +41,7 @@ taiji 桌面端经设置页「网络代理」读写(runtime RMW 写侧);
|
|
|
38
41
|
## 两协议构造契约(防回归锚)
|
|
39
42
|
|
|
40
43
|
- http/https:`ProxyAgent` 调优经 `proxyTls: { autoSelectFamilyAttemptTimeout: 2000 }`(构造内部展开透传到「到代理」连接);禁传 `connect` 选项——主类不消费该字段,传入被静默忽略
|
|
41
|
-
- socks5
|
|
44
|
+
- socks5(仅 Node 宿主,打包版无承载见副本策略节):`Socks5ProxyAgent` 调优经 `connect: buildConnector({ autoSelectFamilyAttemptTimeout: 2000 })` 的 **connector 函数形态**(对象形态首个请求建隧道时 TypeError);`proxyTls` 是「代理连接走 TLS」开关,不可当调优参数袋
|
|
42
45
|
- 两协议 agent 均挂防崩溃 error listener(对齐 pi `withUndiciErrorListener` 形态)
|
|
43
46
|
|
|
44
47
|
上述契约由 `src/__tests__/dispatcher-construction.test.ts` 构造形态冒烟测试锁定;分流/计数/状态机行为由 `src/__tests__/fetch-wrapper.test.ts` 锁定。undici 升级时这两处是第一道回归防线。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-network-proxy",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"undici": "^7.28.0",
|
|
44
|
+
"@zhushanwen/pi-llm-shared": "0.12.0",
|
|
44
45
|
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
45
46
|
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
46
|
-
"@zhushanwen/pi-file-lock": "0.4.1"
|
|
47
|
-
"@zhushanwen/pi-llm-shared": "0.12.0"
|
|
47
|
+
"@zhushanwen/pi-file-lock": "0.4.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
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,socks5 仅 Node 宿主——打包版 Bun 内置 undici 副本无此 API)、suffix 主机名匹配语义、noProxy 优先级、凭据成对约束、默认值、示例、生效时机(turn 粒度热重载)、统计文件、故障恢复。触发词:配置网络代理、network-proxy 配置、代理规则、走代理、socks5、noProxy、network-proxy config、代理不生效、代理报错、直连排除、强制 IPv4、ipv4Only。"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# network-proxy 配置指南
|
|
@@ -20,18 +20,22 @@ description: "配置 @zhushanwen/pi-network-proxy(LLM 网络代理分流)时
|
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
22
|
interface NetworkProxyConfig {
|
|
23
|
-
enabled: boolean; //
|
|
23
|
+
enabled: boolean; // 代理分流总开关,默认 false(关闭 = rules 不参与分流,全部直连/env 代理;
|
|
24
|
+
// 不影响 ipv4Only——见下)
|
|
24
25
|
proxy: {
|
|
25
|
-
protocol: "http" | "https" | "socks5"; // http/https 走 undici ProxyAgent
|
|
26
|
+
protocol: "http" | "https" | "socks5"; // http/https 走 undici ProxyAgent;socks5 走 Socks5ProxyAgent
|
|
27
|
+
// (仅 Node 宿主——打包版 pi 的 Bun 内置 undici 副本无此 API,
|
|
28
|
+
// 构造时明确报错并保持全部直连,见「故障恢复」)
|
|
26
29
|
host: string; // 代理服务器地址,如 127.0.0.1
|
|
27
30
|
port: number; // 1-65535 整数
|
|
28
31
|
username: string; // 认证用户名(可空,但必须与 password 成对)
|
|
29
32
|
password: string; // 认证密码(可空,但必须与 username 成对)
|
|
30
33
|
};
|
|
31
34
|
noProxy: string[]; // 直连排除清单(主机名条目),优先级高于 rules——本地网关地址必须加在这里
|
|
32
|
-
rules: string[]; //
|
|
35
|
+
rules: string[]; // 代理规则(主机名条目),enabled 开启时命中即经代理
|
|
33
36
|
ipv4Only: string[]; // 直连 + 强制 IPv4 清单(主机名条目):命中不走代理,DNS 解析锁定 A 记录直连
|
|
34
|
-
// (URL / Host 头 / TLS SNI 保持原样,只干预「选哪个 IP」);用于「IPv6
|
|
37
|
+
// (URL / Host 头 / TLS SNI 保持原样,只干预「选哪个 IP」);用于「IPv6 可达但握手抖动」的服务商。
|
|
38
|
+
// 独立于 enabled 开关:代理关闭时本清单照常生效(清单非空即安装分流包装层)
|
|
35
39
|
}
|
|
36
40
|
```
|
|
37
41
|
|
|
@@ -51,7 +55,7 @@ interface NetworkProxyConfig {
|
|
|
51
55
|
## 生效时机(turn 粒度热重载)
|
|
52
56
|
|
|
53
57
|
- 每个 turn 开始重读配置,指纹(enabled+proxy+noProxy+rules+ipv4Only)变化才重建包装——**运行中会话自下一轮对话起生效,无需重启**;turn 进行中(工具循环连续请求)保存的配置对该 turn 内后续请求不生效
|
|
54
|
-
- `enabled` 从 true → false
|
|
58
|
+
- `enabled` 从 true → false 且 `ipv4Only` 为空:立即恢复包装前的 fetch(全部直连/env 代理路由);`ipv4Only` 非空时包装保留(IPv4 锁定不随代理开关关闭而失效,仅代理分流停用)
|
|
55
59
|
- 未命中规则的流量委托包装前的 fetch——独立 pi 用户设了 `HTTPS_PROXY` 环境变量时,未命中流量仍走 env 代理,语义不丢失
|
|
56
60
|
- pi 的 `configureHttpDispatcher` 带 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99):检测到 `globalThis.fetch` 已被第三方覆盖即保留不重装,本扩展的包装在同进程内持续生效;槽位被其他第三方整体覆盖时,下一 turn 开始自动以覆盖者为新 original 重新包装(防御路径)
|
|
57
61
|
|
|
@@ -64,17 +68,32 @@ interface NetworkProxyConfig {
|
|
|
64
68
|
| 现象 | 处置 |
|
|
65
69
|
|---|---|
|
|
66
70
|
| 代理不可达(请求报 `fetch failed` / `ECONNREFUSED`) | 启动代理软件;或把 `enabled` 改为 `false` 一键恢复直连 |
|
|
67
|
-
| socks5
|
|
71
|
+
| socks5 不可用(打包版:Bun 内置 undici 副本无 Socks5ProxyAgent;或 undici 实验性 API 变化) | extension error 提示并保持全部直连;切回 `http`/`https` 协议即可恢复代理(打包版不支持 socks5 属能力边界,非配置错误) |
|
|
68
72
|
| 配置校验失败(端口越界 / 凭据单边等) | 按报错文案修正配置文件,或删除该文件恢复默认禁用态 |
|
|
69
73
|
| 想让某服务商不再走代理 | 从 `rules` 删除对应主机名条目,或将其加入 `noProxy` |
|
|
70
74
|
|
|
71
75
|
## 配置示例
|
|
72
76
|
|
|
77
|
+
代理分流 + IPv4 锁定并用:
|
|
78
|
+
|
|
73
79
|
```json
|
|
74
80
|
{
|
|
75
81
|
"enabled": true,
|
|
76
82
|
"proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
|
|
77
83
|
"noProxy": ["localhost", "127.0.0.1"],
|
|
78
|
-
"rules": ["api.anthropic.com"]
|
|
84
|
+
"rules": ["api.anthropic.com"],
|
|
85
|
+
"ipv4Only": ["open-design.ai"]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
仅 IPv4 锁定、不走代理(`enabled=false` 时 `rules` 不参与分流,`ipv4Only` 照常生效):
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"enabled": false,
|
|
94
|
+
"proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
|
|
95
|
+
"noProxy": ["localhost", "127.0.0.1"],
|
|
96
|
+
"rules": [],
|
|
97
|
+
"ipv4Only": ["open-design.ai"]
|
|
79
98
|
}
|
|
80
99
|
```
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* fetch 包装状态机单测(fake 依赖注入,不触真实网络):
|
|
3
3
|
* 命中/未命中分流、计数、noProxy/ipv4Only 优先级、ipv4 与代理构造的差异化降级
|
|
4
4
|
* (ipv4 失败只丢 v4 锁定、proxy 失败锁整层;rules 为空不构造代理)、禁用恢复、
|
|
5
|
+
* ipv4Only 独立于 enabled 代理开关(开关关闭时清单照常生效、rules 不参与分流)、
|
|
5
6
|
* 外部覆盖重装、构造失败锁定、reload 残留摘除、入参形态覆盖。
|
|
6
7
|
* 转移表见 fetch-wrapper.ts 文件头。
|
|
7
8
|
*/
|
|
@@ -335,6 +336,62 @@ describe("applyNetworkProxyConfig 包装状态机", () => {
|
|
|
335
336
|
expect(h.errors).toHaveLength(0);
|
|
336
337
|
});
|
|
337
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
|
+
|
|
338
395
|
it("构造失败:onError + 不包装(全直连),同指纹锁定不重试;指纹变化后重试", () => {
|
|
339
396
|
const h = createHarness();
|
|
340
397
|
h.setBuildProxy(() => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* socks5 能力守卫单测:socks5Supported 判定「当前 undici 副本缺 Socks5ProxyAgent」
|
|
3
|
+
* (打包版 pi 的 Bun 内置副本形态,2026-10-22 真机指纹实测 49 导出无 socks)。
|
|
4
|
+
* buildProxyDispatcher 的 socks5 分支接入该判定(缺即抛可恢复错误,走
|
|
5
|
+
* lockOnBuildFailure:error 上报 + 恢复全直连);「副本有 Socks5 时构造成功」的接入
|
|
6
|
+
* 正路径由 dispatcher-construction.test.ts 的 socks5 构造形态用例覆盖。
|
|
7
|
+
*
|
|
8
|
+
* 不用 vi.mock 造「缺导出的模块」——vitest 的 mock 层对访问 mock 未提供的原模块
|
|
9
|
+
* 导出抛自己的拦截错误(要求 mock 与原模块导出面对齐),与真实 ESM namespace
|
|
10
|
+
* 「缺失导出属性访问 = undefined」语义不一致。
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import * as undici from "undici";
|
|
14
|
+
|
|
15
|
+
import { socks5Supported } from "../dispatcher.ts";
|
|
16
|
+
|
|
17
|
+
describe("socks5Supported(副本能力判定)", () => {
|
|
18
|
+
it("缺 Socks5ProxyAgent 的副本(打包版 Bun 内置副本形态)→ false", () => {
|
|
19
|
+
expect(socks5Supported({})).toBe(false);
|
|
20
|
+
expect(socks5Supported({ Socks5ProxyAgent: undefined })).toBe(false);
|
|
21
|
+
expect(socks5Supported({ Socks5ProxyAgent: "not-a-class" })).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("提供函数形态 Socks5ProxyAgent 的副本(npm undici 形态)→ true", () => {
|
|
25
|
+
expect(socks5Supported({ Socks5ProxyAgent: class {} })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("真实 npm undici 副本(Node 测试环境解析)→ true", () => {
|
|
29
|
+
expect(socks5Supported(undici)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -47,7 +47,11 @@ export interface NetworkProxyConfig { // oe-exempt:20261019:framework:ext-config
|
|
|
47
47
|
* (autoSelectFamilyAttemptTimeout=2000)反复试 IPv6,把 connect 预算耗在坏路径上,
|
|
48
48
|
* 最终表现为 `Request timed out.`——实为连接层超时,不是 provider 故障。
|
|
49
49
|
*
|
|
50
|
-
*
|
|
50
|
+
* 独立于 `enabled` 代理开关:`enabled=false` 时本清单照常生效(IPv4 锁定是直连的
|
|
51
|
+
* 地址族修正,不是代理能力;清单非空即安装包装层)。
|
|
52
|
+
*
|
|
53
|
+
* 优先级:noProxy > ipv4Only > rules(rules 另受 enabled 门控——开关关闭时规则
|
|
54
|
+
* 不参与分流)。
|
|
51
55
|
*/
|
|
52
56
|
ipv4Only: string[];
|
|
53
57
|
}
|
package/src/dispatcher.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 分发器构造(供 fetch-wrapper 按请求注入)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* ## undici 副本策略:按宿主运行时解析,不内联
|
|
5
|
+
*
|
|
6
|
+
* 本包的 undici 依赖在构建期标 external(`scripts/bundle-extensions.mjs` 的 EXTERNAL
|
|
7
|
+
* 含 undici),运行时由宿主解析:Node 宿主(独立 pi / dev)解析 `node_modules` 的
|
|
8
|
+
* npm undici;Bun 宿主(打包版 pi)解析二进制内置副本(`builtin://thirdparty/undici`,
|
|
9
|
+
* Bun 团队随 Bun 版本适配测试)。每个宿主用自己官方维护的网络栈。
|
|
10
|
+
* [HISTORICAL] 内联 npm undici 7.28.0 副本的旧形态在打包版(Bun 1.3.x 兼容层)下
|
|
11
|
+
* `fetch` 读 chunked 流式响应体永不产块(LLM SSE 请求挂死 20 分钟,2026-10-22 事故,
|
|
12
|
+
* 本地 https 分块流探针复现),不得回退内联。
|
|
13
|
+
*
|
|
14
|
+
* 两副本能力面差异(真机指纹实测,2026-10-22):内置副本 49 导出,无
|
|
15
|
+
* `Socks5ProxyAgent`——socks5 代理在打包版无承载,下方构造时按能力探测显式报错
|
|
16
|
+
* (走 lockOnBuildFailure:error 上报 + 恢复全直连,同指纹锁定不重试)。
|
|
17
|
+
* `Agent` / `buildConnector` / `ProxyAgent` / `fetch` 两副本都有且流式行为均实测正常。
|
|
18
|
+
*
|
|
19
|
+
* ## 两协议构造契约(undici 7.28.0 源码读码核实,单测冒烟防回归)
|
|
8
20
|
*
|
|
9
|
-
* 两协议构造契约(undici 7.28.0 源码读码核实,单测冒烟防回归):
|
|
10
21
|
* - http/https 走 ProxyAgent,调优经 `proxyTls: { autoSelectFamilyAttemptTimeout: 2000 }`
|
|
11
22
|
* (构造内部 buildConnector({...opts.proxyTls}) 透传到「到代理」连接);禁传 connect 选项
|
|
12
23
|
* ——ProxyAgent 不消费该字段,传入被静默忽略、调优失效且不报错。
|
|
@@ -21,8 +32,11 @@
|
|
|
21
32
|
|
|
22
33
|
import { EventEmitter } from "node:events";
|
|
23
34
|
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
// namespace 导入而非命名导入:打包版 Bun 内置副本缺 Socks5ProxyAgent 命名导出,
|
|
36
|
+
// 命名导入会在模块链接期整体失败;namespace 属性访问缺失导出为 undefined,
|
|
37
|
+
// 供下方运行时能力探测。
|
|
38
|
+
import * as undici from "undici";
|
|
39
|
+
import type { buildConnector, Dispatcher as IDispatcher } from "undici";
|
|
26
40
|
|
|
27
41
|
import type { NetworkProxyConfig, NetworkProxyServer } from "./config.js";
|
|
28
42
|
import { createIpv4Lookup } from "./ipv4.js";
|
|
@@ -53,6 +67,19 @@ function withUndiciErrorListener<T extends IDispatcher>(dispatcher: T): T {
|
|
|
53
67
|
return dispatcher;
|
|
54
68
|
}
|
|
55
69
|
|
|
70
|
+
/**
|
|
71
|
+
* socks5 副本能力判定:当前解析到的 undici 副本是否提供 Socks5ProxyAgent。
|
|
72
|
+
* 打包版 Bun 内置副本无此导出(2026-10-22 真机指纹实测)。`in` 探测短路 + typeof
|
|
73
|
+
* 双保险——namespace 属性访问缺失导出按 ESM 语义是 undefined,但 `in` 的行为已在
|
|
74
|
+
* 打包版真机验证(false,不抛)。
|
|
75
|
+
*/
|
|
76
|
+
export function socks5Supported(mod: object): boolean {
|
|
77
|
+
if (!("Socks5ProxyAgent" in mod)) return false;
|
|
78
|
+
// in 已保证成员存在,断言到必填字段形态取值
|
|
79
|
+
const ctor = (mod as { Socks5ProxyAgent: unknown }).Socks5ProxyAgent;
|
|
80
|
+
return typeof ctor === "function";
|
|
81
|
+
}
|
|
82
|
+
|
|
56
83
|
/** 代理服务器 URL:凭据非空时 percent-encode 拼入(undici 构造内部 decodeURIComponent 还原)。 */
|
|
57
84
|
export function buildProxyUrl(proxy: NetworkProxyServer): string {
|
|
58
85
|
const auth =
|
|
@@ -66,14 +93,22 @@ export function buildProxyUrl(proxy: NetworkProxyServer): string {
|
|
|
66
93
|
export function buildProxyDispatcher(config: NetworkProxyConfig): IDispatcher {
|
|
67
94
|
const uri = buildProxyUrl(config.proxy);
|
|
68
95
|
if (config.proxy.protocol === "socks5") {
|
|
96
|
+
// 能力探测(非宿主名判断):当前解析到的 undici 副本无 Socks5ProxyAgent 即不可用
|
|
97
|
+
// (打包版 Bun 内置副本)。抛错由 lockOnBuildFailure 收口:error 上报 + 恢复全直连。
|
|
98
|
+
if (!socks5Supported(undici)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"当前 undici 副本(打包版 Bun 内置)不提供 Socks5ProxyAgent,socks5 代理不可用;" +
|
|
101
|
+
"请将代理协议改为 http/https 后重试",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
69
104
|
return withUndiciErrorListener(
|
|
70
|
-
new Socks5ProxyAgent(uri, {
|
|
71
|
-
connect: buildConnector(PROXY_TLS_TUNING),
|
|
105
|
+
new undici.Socks5ProxyAgent(uri, {
|
|
106
|
+
connect: undici.buildConnector(PROXY_TLS_TUNING),
|
|
72
107
|
}),
|
|
73
108
|
);
|
|
74
109
|
}
|
|
75
110
|
return withUndiciErrorListener(
|
|
76
|
-
new ProxyAgent({
|
|
111
|
+
new undici.ProxyAgent({
|
|
77
112
|
uri,
|
|
78
113
|
proxyTls: PROXY_TLS_TUNING,
|
|
79
114
|
}),
|
|
@@ -83,7 +118,8 @@ export function buildProxyDispatcher(config: NetworkProxyConfig): IDispatcher {
|
|
|
83
118
|
/**
|
|
84
119
|
* IPv4 锁定分发器(`ipv4Only` 规则的执行体装配):唯一干预 = `connect.lookup` 把 DNS
|
|
85
120
|
* 解析强制到 A 记录(选型依据见 ipv4.ts 头注——改 URL 会因 SNI/Host 不一致被 Cloudflare
|
|
86
|
-
* 拒为 403,只有改 DNS 族在 Node 与 Bun
|
|
121
|
+
* 拒为 403,只有改 DNS 族在 Node 与 Bun 下都正确,两副本 Agent+lookup 已实测流式正常)。
|
|
122
|
+
* URL / Host 头 / TLS 全部保持原样。
|
|
87
123
|
*
|
|
88
124
|
* 刻意不设 `connect.timeout`:保持 undici 默认语义(pi 原生 dispatcher 亦未设)——锁 v4
|
|
89
125
|
* 后 happy-eyeballs 没有坏路径可耗预算,不需要也不应该改连接预算(调大 timeout 只是把
|
|
@@ -91,6 +127,6 @@ export function buildProxyDispatcher(config: NetworkProxyConfig): IDispatcher {
|
|
|
91
127
|
*/
|
|
92
128
|
export function buildIpv4Dispatcher(): IDispatcher {
|
|
93
129
|
return withUndiciErrorListener(
|
|
94
|
-
new Agent({ connect: { lookup: createIpv4Lookup() } }),
|
|
130
|
+
new undici.Agent({ connect: { lookup: createIpv4Lookup() } }),
|
|
95
131
|
);
|
|
96
132
|
}
|
package/src/fetch-wrapper.ts
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 网络代理请求拦截层:包装 pi 进程的 `globalThis.fetch`(打包版兼容设计)。
|
|
3
3
|
*
|
|
4
|
-
* 为什么不是 undici
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* dev
|
|
10
|
-
*
|
|
4
|
+
* 为什么不是 undici 全局分发器:undici 的 `setGlobalDispatcher` 槽是模块级变量,
|
|
5
|
+
* 只在「pi 与扩展加载同一份 undici 模块实例」时生效。本包的 undici 按宿主运行时
|
|
6
|
+
* 解析(Node → node_modules npm 副本;Bun 打包版 → 二进制内置副本,副本策略与依据
|
|
7
|
+
* 见 dispatcher.ts 头注),与 pi 自己的 HTTP 栈不保证同实例。[HISTORICAL] 2026-10-20
|
|
8
|
+
* 打包版实测:setGlobalDispatcher 方案下扩展改的全局槽 pi 不用,LLM 请求照常直连、
|
|
9
|
+
* 统计文件零行——dev 下两实例恰好相同所以 dev 正常、打包版静默失效,拦截点因此
|
|
10
|
+
* 定为 `globalThis.fetch`(不依赖副本实例是否共享)。
|
|
11
11
|
*
|
|
12
12
|
* 稳定接缝 = `globalThis.fetch`:pi-ai 的 OpenAI 兼容请求在每次构造 client 时经
|
|
13
13
|
* `Shims.getDefaultFetch()` 动态取当前全局 fetch(node_modules/openai/internal/shims.js),
|
|
14
|
-
* 包装它不依赖 pi 与扩展是否共享 undici
|
|
15
|
-
* 分发器发出(代理或 IPv4 锁定,见 dispatcher.ts
|
|
16
|
-
*
|
|
14
|
+
* 包装它不依赖 pi 与扩展是否共享 undici 副本。命中规则的请求用扩展解析到的 undici
|
|
15
|
+
* fetch + 分发器发出(代理或 IPv4 锁定,见 dispatcher.ts);未命中的请求原样调用包装前
|
|
16
|
+
* 的 fetch,保留 pi 原生 EnvHttpProxyAgent / 连接调优 / 错误处理语义。
|
|
17
17
|
*
|
|
18
18
|
* 生命周期与旧安装状态机同构:
|
|
19
|
-
* - 加载 / 每 turn
|
|
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
|
|
@@ -147,8 +148,9 @@ function wrapperMarkOf(fetch: FetchLike): FetchWrapperMark | null {
|
|
|
147
148
|
*
|
|
148
149
|
* 分流优先级(单一源在这里,config.ts 的 hits* 只提供匹配,不重复排序):
|
|
149
150
|
* 1. `noProxy` 命中 → 原样直连(用户显式排除,连 IPv4 锁定也不做);
|
|
150
|
-
* 2. `ipv4Only` 命中 → IPv4
|
|
151
|
-
*
|
|
151
|
+
* 2. `ipv4Only` 命中 → IPv4 锁定直连(**不走代理**,本扩展只做地址族选择;独立于
|
|
152
|
+
* `enabled` 代理开关——开关关闭时本分支照常生效);
|
|
153
|
+
* 3. `enabled` 且 `rules` 命中 → 经代理 fetch(开关关闭时 rules 不参与分流,落入直连);
|
|
152
154
|
* 4. 其余 → 原样直连。
|
|
153
155
|
*
|
|
154
156
|
* 计数时点 = 请求发起时(与旧分流器一致;不等待完成,成败由 pi 原生错误链路呈现)。
|
|
@@ -178,7 +180,7 @@ function createWrappedFetch(
|
|
|
178
180
|
return proxyFetch(input, { ...(init ?? {}), dispatcher: ipv4 });
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
if (hitsRules(host, config.rules)) {
|
|
183
|
+
if (config.enabled && hitsRules(host, config.rules)) {
|
|
182
184
|
recordDispatch(host, true);
|
|
183
185
|
// proxy 非空由 applyNetworkProxyConfig 保证(rules 非空才会构造)。
|
|
184
186
|
if (proxy === null) return originalFetch(input, init);
|
|
@@ -279,7 +281,11 @@ export function applyNetworkProxyConfig(
|
|
|
279
281
|
state: FetchWrapperState | null,
|
|
280
282
|
deps: FetchWrapperDeps,
|
|
281
283
|
): FetchWrapperState | null {
|
|
282
|
-
|
|
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) {
|
|
283
289
|
restoreOriginalIfOwned(state, deps);
|
|
284
290
|
return null;
|
|
285
291
|
}
|
|
@@ -293,12 +299,12 @@ export function applyNetworkProxyConfig(
|
|
|
293
299
|
}
|
|
294
300
|
const originalFetch = installed && state ? state.originalFetch : currentFetch;
|
|
295
301
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
302
|
+
// 代理分发器只在「确实可能用到代理」时构造:开关关闭或 rules 为空 = 无任何主机走
|
|
303
|
+
// 代理,此时不应因代理配置不可用而锁死整个包装(含 ipv4Only 直连改写)。
|
|
298
304
|
// 依据:一次事故(2026-10-22 真机验证)——仅配 ipv4Only、rules=[] 时仍构造
|
|
299
305
|
// ProxyAgent 并失败,lockOnBuildFailure 把包装整层撤下,ipv4Only 静默失效。
|
|
300
306
|
let proxy: Dispatcher | null = null;
|
|
301
|
-
if (config.rules.length > 0) {
|
|
307
|
+
if (config.enabled && config.rules.length > 0) {
|
|
302
308
|
try {
|
|
303
309
|
proxy = deps.buildProxy(config);
|
|
304
310
|
} catch (error) {
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* - 加载时:读配置校验,enabled 或 ipv4Only 非空且校验过 → 包装 globalThis.fetch
|
|
6
|
+
* (ipv4Only 命中锁 IPv4 直连、enabled 且命中规则走代理,未命中委托原 fetch);
|
|
7
|
+
* 校验失败 / 构造失败 → extension error(文案含恢复动作:配置文件路径),不包装
|
|
8
|
+
* 不阻断 pi(全部直连)。
|
|
8
9
|
* - 每 turn 开始:重读配置(llm-shared 读时刷新热重载),指纹变化才重建;enabled true→false
|
|
9
|
-
*
|
|
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)——不设
|
|
@@ -95,7 +97,8 @@ export default function networkProxyExtension(pi: ExtensionAPI): void {
|
|
|
95
97
|
ensureExitFlush();
|
|
96
98
|
refreshFromConfig();
|
|
97
99
|
|
|
98
|
-
// turn 粒度热生效:turn 开始重读配置(mtime+size 未变时零 IO),指纹变化重建 /
|
|
100
|
+
// turn 粒度热生效:turn 开始重读配置(mtime+size 未变时零 IO),指纹变化重建 /
|
|
101
|
+
// 代理关且 ipv4Only 空时恢复直连。
|
|
99
102
|
pi.on("turn_start", () => {
|
|
100
103
|
refreshFromConfig();
|
|
101
104
|
});
|