@zhushanwen/pi-network-proxy 0.3.0 → 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 +2 -2
- package/package.json +3 -3
- package/skills/network-proxy-ext-config/SKILL.md +23 -6
- package/src/__tests__/fetch-wrapper.test.ts +57 -0
- package/src/config.ts +5 -1
- package/src/fetch-wrapper.ts +14 -8
- package/src/index.ts +8 -5
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`
|
|
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
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.3.
|
|
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",
|
|
@@ -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)、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; //
|
|
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,9 +30,10 @@ interface NetworkProxyConfig {
|
|
|
29
30
|
password: string; // 认证密码(可空,但必须与 username 成对)
|
|
30
31
|
};
|
|
31
32
|
noProxy: string[]; // 直连排除清单(主机名条目),优先级高于 rules——本地网关地址必须加在这里
|
|
32
|
-
rules: string[]; //
|
|
33
|
+
rules: string[]; // 代理规则(主机名条目),enabled 开启时命中即经代理
|
|
33
34
|
ipv4Only: string[]; // 直连 + 强制 IPv4 清单(主机名条目):命中不走代理,DNS 解析锁定 A 记录直连
|
|
34
|
-
// (URL / Host 头 / TLS SNI 保持原样,只干预「选哪个 IP」);用于「IPv6
|
|
35
|
+
// (URL / Host 头 / TLS SNI 保持原样,只干预「选哪个 IP」);用于「IPv6 可达但握手抖动」的服务商。
|
|
36
|
+
// 独立于 enabled 开关:代理关闭时本清单照常生效(清单非空即安装分流包装层)
|
|
35
37
|
}
|
|
36
38
|
```
|
|
37
39
|
|
|
@@ -51,7 +53,7 @@ interface NetworkProxyConfig {
|
|
|
51
53
|
## 生效时机(turn 粒度热重载)
|
|
52
54
|
|
|
53
55
|
- 每个 turn 开始重读配置,指纹(enabled+proxy+noProxy+rules+ipv4Only)变化才重建包装——**运行中会话自下一轮对话起生效,无需重启**;turn 进行中(工具循环连续请求)保存的配置对该 turn 内后续请求不生效
|
|
54
|
-
- `enabled` 从 true → false
|
|
56
|
+
- `enabled` 从 true → false 且 `ipv4Only` 为空:立即恢复包装前的 fetch(全部直连/env 代理路由);`ipv4Only` 非空时包装保留(IPv4 锁定不随代理开关关闭而失效,仅代理分流停用)
|
|
55
57
|
- 未命中规则的流量委托包装前的 fetch——独立 pi 用户设了 `HTTPS_PROXY` 环境变量时,未命中流量仍走 env 代理,语义不丢失
|
|
56
58
|
- pi 的 `configureHttpDispatcher` 带 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99):检测到 `globalThis.fetch` 已被第三方覆盖即保留不重装,本扩展的包装在同进程内持续生效;槽位被其他第三方整体覆盖时,下一 turn 开始自动以覆盖者为新 original 重新包装(防御路径)
|
|
57
59
|
|
|
@@ -70,11 +72,26 @@ interface NetworkProxyConfig {
|
|
|
70
72
|
|
|
71
73
|
## 配置示例
|
|
72
74
|
|
|
75
|
+
代理分流 + IPv4 锁定并用:
|
|
76
|
+
|
|
73
77
|
```json
|
|
74
78
|
{
|
|
75
79
|
"enabled": true,
|
|
76
80
|
"proxy": { "protocol": "http", "host": "127.0.0.1", "port": 7890, "username": "", "password": "" },
|
|
77
81
|
"noProxy": ["localhost", "127.0.0.1"],
|
|
78
|
-
"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"]
|
|
79
96
|
}
|
|
80
97
|
```
|
|
@@ -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(() => {
|
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/fetch-wrapper.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* 保留 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
|
});
|