@zhushanwen/pi-network-proxy 0.3.1 → 0.3.3
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 +9 -6
- package/package.json +7 -6
- package/skills/network-proxy-ext-config/SKILL.md +5 -3
- package/src/__tests__/network-proxy-shared-parity.test.ts +218 -0
- package/src/__tests__/socks5-guard.test.ts +31 -0
- package/src/config.ts +2 -1
- package/src/dispatcher.ts +48 -12
- package/src/fetch-wrapper.ts +10 -10
- package/src/match.ts +2 -1
package/README.md
CHANGED
|
@@ -10,15 +10,18 @@ LLM 网络代理分流:包装 pi 进程的 `globalThis.fetch`,按主机名
|
|
|
10
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.3",
|
|
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,14 +41,15 @@
|
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"undici": "^7.28.0",
|
|
44
|
-
"@zhushanwen/pi-
|
|
45
|
-
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
44
|
+
"@zhushanwen/pi-ext-guards": "0.5.1",
|
|
46
45
|
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
47
|
-
"@zhushanwen/pi-file-lock": "0.4.1"
|
|
46
|
+
"@zhushanwen/pi-file-lock": "0.4.1",
|
|
47
|
+
"@zhushanwen/pi-llm-shared": "0.12.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@vitest/coverage-v8": "^4.1.9",
|
|
51
|
-
"vitest": "^4.1.8"
|
|
51
|
+
"vitest": "^4.1.8",
|
|
52
|
+
"@taiji/shared": "0.10.0"
|
|
52
53
|
},
|
|
53
54
|
"files": [
|
|
54
55
|
"index.ts",
|
|
@@ -65,6 +66,6 @@
|
|
|
65
66
|
}
|
|
66
67
|
},
|
|
67
68
|
"scripts": {
|
|
68
|
-
"test": "vitest run"
|
|
69
|
+
"test": "vitest run --pool=threads"
|
|
69
70
|
}
|
|
70
71
|
}
|
|
@@ -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/ipv4Only)、协议三选一(http/https/socks5)、suffix 主机名匹配语义、noProxy 优先级、凭据成对约束、默认值、示例、生效时机(turn 粒度热重载)、统计文件、故障恢复。触发词:配置网络代理、network-proxy 配置、代理规则、走代理、socks5、noProxy、network-proxy config、代理不生效、代理报错、直连排除、强制 IPv4、ipv4Only。"
|
|
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 配置指南
|
|
@@ -23,7 +23,9 @@ interface NetworkProxyConfig {
|
|
|
23
23
|
enabled: boolean; // 代理分流总开关,默认 false(关闭 = rules 不参与分流,全部直连/env 代理;
|
|
24
24
|
// 不影响 ipv4Only——见下)
|
|
25
25
|
proxy: {
|
|
26
|
-
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
|
+
// 构造时明确报错并保持全部直连,见「故障恢复」)
|
|
27
29
|
host: string; // 代理服务器地址,如 127.0.0.1
|
|
28
30
|
port: number; // 1-65535 整数
|
|
29
31
|
username: string; // 认证用户名(可空,但必须与 password 成对)
|
|
@@ -66,7 +68,7 @@ interface NetworkProxyConfig {
|
|
|
66
68
|
| 现象 | 处置 |
|
|
67
69
|
|---|---|
|
|
68
70
|
| 代理不可达(请求报 `fetch failed` / `ECONNREFUSED`) | 启动代理软件;或把 `enabled` 改为 `false` 一键恢复直连 |
|
|
69
|
-
| socks5
|
|
71
|
+
| socks5 不可用(打包版:Bun 内置 undici 副本无 Socks5ProxyAgent;或 undici 实验性 API 变化) | extension error 提示并保持全部直连;切回 `http`/`https` 协议即可恢复代理(打包版不支持 socks5 属能力边界,非配置错误) |
|
|
70
72
|
| 配置校验失败(端口越界 / 凭据单边等) | 按报错文案修正配置文件,或删除该文件恢复默认禁用态 |
|
|
71
73
|
| 想让某服务商不再走代理 | 从 `rules` 删除对应主机名条目,或将其加入 `noProxy` |
|
|
72
74
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* network-proxy 校验/匹配语义跨包对拍测试(架构腐化审计 network-proxy.md 候选 1 的机器锁)。
|
|
3
|
+
*
|
|
4
|
+
* 语义 SSOT = packages/shared/src/network-proxy.ts;本扩展 src/config.ts、src/match.ts
|
|
5
|
+
* 是 bundle 自包含复制体。此前两侧测试各自独立维护、互不 import(config.test.ts /
|
|
6
|
+
* match.test.ts 与 shared 侧 network-proxy.test.ts 各自锁本侧),注释声称的「镜像锁定
|
|
7
|
+
* 测试锁两侧行为一致」实际不存在——shared 侧语义变更不会使本包测试变红。
|
|
8
|
+
*
|
|
9
|
+
* 本测试照 retry-rules-validation-parity.test.ts 先例:import @taiji/shared 真实实现,
|
|
10
|
+
* 同一输入矩阵分别喂两侧,断言双侧同判——校验含错误清单逐字一致(文案面向用户且
|
|
11
|
+
* 含修复动作,两侧分叉 = 一侧的用户指引失效)。shared 侧调整语义 → 本测试红 →
|
|
12
|
+
* 按提示同步本包复制体(以 shared 为准)。
|
|
13
|
+
*
|
|
14
|
+
* 签名说明:shared validateNetworkProxyConfig 参数已统一为 unknown(与扩展侧同款,
|
|
15
|
+
* 运行时守卫本就按任意输入设计,类型级收窄统一、零运行时行为变化)。
|
|
16
|
+
*
|
|
17
|
+
* @taiji/shared 仅测试面消费(devDependencies + workspace 源直引,bundle 自包含
|
|
18
|
+
* 不受影响)。单包环境未安装该 devDependency 时本文件 import 即失败(不 skip)——
|
|
19
|
+
* 先补装 `@taiji/shared: workspace:*` 再跑。
|
|
20
|
+
*/
|
|
21
|
+
import { describe, expect, it } from "vitest";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_NETWORK_PROXY_CONFIG as SHARED_DEFAULT,
|
|
25
|
+
matchesHostPattern as sharedMatchesHostPattern,
|
|
26
|
+
normalizeHostname as sharedNormalizeHostname,
|
|
27
|
+
validateNetworkProxyConfig as sharedValidateNetworkProxyConfig,
|
|
28
|
+
} from "@taiji/shared";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_NETWORK_PROXY_CONFIG,
|
|
32
|
+
validateNetworkProxyConfig,
|
|
33
|
+
} from "../config.ts";
|
|
34
|
+
import { matchesHostPattern, normalizeHostname } from "../match.ts";
|
|
35
|
+
|
|
36
|
+
// ============================================================
|
|
37
|
+
// normalizeHostname:同输入双侧同出
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
const HOSTNAME_MATRIX: string[] = [
|
|
41
|
+
"api.anthropic.com",
|
|
42
|
+
"API.Anthropic.COM",
|
|
43
|
+
"api.anthropic.com.",
|
|
44
|
+
"api.anthropic.com..",
|
|
45
|
+
"LocalHost",
|
|
46
|
+
"localhost",
|
|
47
|
+
"xapi.openai.com",
|
|
48
|
+
"127.0.0.1",
|
|
49
|
+
"",
|
|
50
|
+
".",
|
|
51
|
+
"..",
|
|
52
|
+
" api.anthropic.com",
|
|
53
|
+
"api.anthropic.com ",
|
|
54
|
+
" ",
|
|
55
|
+
"api.xn--wse.academy",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
describe("normalizeHostname 双侧同判(归一化语义跨包对拍)", () => {
|
|
59
|
+
it("矩阵逐条:本侧输出 === shared 侧输出", () => {
|
|
60
|
+
for (const host of HOSTNAME_MATRIX) {
|
|
61
|
+
expect(normalizeHostname(host), `host=${JSON.stringify(host)}`).toBe(sharedNormalizeHostname(host));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("锚定值抽查(防两侧同漂时对拍空转——shared 侧自身测试另有行为锁)", () => {
|
|
66
|
+
expect(normalizeHostname("API.Anthropic.COM.")).toBe("api.anthropic.com");
|
|
67
|
+
expect(normalizeHostname(" api.anthropic.com")).toBe(" api.anthropic.com");
|
|
68
|
+
expect(normalizeHostname("")).toBe("");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ============================================================
|
|
73
|
+
// matchesHostPattern:真值表双侧同判
|
|
74
|
+
// ============================================================
|
|
75
|
+
|
|
76
|
+
const MATCH_MATRIX: { host: string; pattern: string }[] = [
|
|
77
|
+
// 等于 / suffix 命中
|
|
78
|
+
{ host: "api.anthropic.com", pattern: "api.anthropic.com" },
|
|
79
|
+
{ host: "x.api.anthropic.com", pattern: "api.anthropic.com" },
|
|
80
|
+
{ host: "api.anthropic.com", pattern: "anthropic.com" },
|
|
81
|
+
// 点边界:无点前缀拼接不命中
|
|
82
|
+
{ host: "xapi.anthropic.com", pattern: "api.anthropic.com" },
|
|
83
|
+
// 恶意后缀不命中
|
|
84
|
+
{ host: "api-anthropic.com.evil.com", pattern: "api-anthropic.com" },
|
|
85
|
+
// 大小写 / 末尾点归一化后命中
|
|
86
|
+
{ host: "X.API.Anthropic.COM.", pattern: "api.anthropic.com" },
|
|
87
|
+
{ host: "api.anthropic.com", pattern: "API.Anthropic.COM." },
|
|
88
|
+
// 空主机名 / 空 pattern / 纯点 pattern
|
|
89
|
+
{ host: "", pattern: "api.anthropic.com" },
|
|
90
|
+
{ host: "api.anthropic.com", pattern: "" },
|
|
91
|
+
{ host: "api.anthropic.com", pattern: "." },
|
|
92
|
+
{ host: "api.anthropic.com", pattern: ".." },
|
|
93
|
+
// 带空白条目恒不命中(归一化不 trim,校验层显式拒绝)
|
|
94
|
+
{ host: "api.anthropic.com", pattern: " api.anthropic.com" },
|
|
95
|
+
{ host: " api.anthropic.com", pattern: "api.anthropic.com" },
|
|
96
|
+
// pattern 比主机名长(suffix 反向不命中)
|
|
97
|
+
{ host: "anthropic.com", pattern: "api.anthropic.com" },
|
|
98
|
+
// IP 条目(noProxy 默认成员形态)
|
|
99
|
+
{ host: "127.0.0.1", pattern: "127.0.0.1" },
|
|
100
|
+
{ host: "localhost", pattern: "localhost" },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
describe("matchesHostPattern 双侧同判(suffix 点边界真值表跨包对拍)", () => {
|
|
104
|
+
it("矩阵逐条:本侧输出 === shared 侧输出", () => {
|
|
105
|
+
for (const { host, pattern } of MATCH_MATRIX) {
|
|
106
|
+
expect(
|
|
107
|
+
matchesHostPattern(host, pattern),
|
|
108
|
+
`host=${JSON.stringify(host)}, pattern=${JSON.stringify(pattern)}`,
|
|
109
|
+
).toBe(sharedMatchesHostPattern(host, pattern));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("锚定值抽查(点边界与恶意后缀是安全语义,两侧同漂也不许双双失守)", () => {
|
|
114
|
+
expect(matchesHostPattern("xapi.anthropic.com", "api.anthropic.com")).toBe(false);
|
|
115
|
+
expect(matchesHostPattern("api-anthropic.com.evil.com", "api-anthropic.com")).toBe(false);
|
|
116
|
+
expect(matchesHostPattern("x.api.anthropic.com", "api.anthropic.com")).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ============================================================
|
|
121
|
+
// validateNetworkProxyConfig:同输入双侧同判(含错误清单逐字一致)
|
|
122
|
+
// ============================================================
|
|
123
|
+
|
|
124
|
+
/** 合法基座(每次浅拷贝,矩阵条目间无共享可变状态)。 */
|
|
125
|
+
function validConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
126
|
+
return {
|
|
127
|
+
enabled: true,
|
|
128
|
+
proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
|
|
129
|
+
noProxy: ["localhost"],
|
|
130
|
+
rules: ["api.anthropic.com"],
|
|
131
|
+
ipv4Only: [],
|
|
132
|
+
...overrides,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 单输入双侧对拍:同一配置喂两侧 validateNetworkProxyConfig,断言
|
|
138
|
+
* ① ok 同判;② 都拒绝时错误清单逐字一致(含条目顺序——顺序也是用户可见形态)。
|
|
139
|
+
*/
|
|
140
|
+
function expectSameValidation(label: string, config: unknown): void {
|
|
141
|
+
const sharedResult = sharedValidateNetworkProxyConfig(config);
|
|
142
|
+
const extResult = validateNetworkProxyConfig(config);
|
|
143
|
+
expect(extResult.ok, `[${label}] ok 双侧同判`).toBe(sharedResult.ok);
|
|
144
|
+
if (!sharedResult.ok && !extResult.ok) {
|
|
145
|
+
expect(extResult.errors, `[${label}] 错误清单逐字一致`).toEqual(sharedResult.errors);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const VALIDATION_MATRIX: { label: string; config: unknown }[] = [
|
|
150
|
+
// ── 合法形态 ──
|
|
151
|
+
{ label: "全字段合法", config: validConfig() },
|
|
152
|
+
{ label: "socks5 协议 + 成对凭据", config: validConfig({ proxy: { protocol: "socks5", host: "p.example.com", port: 1080, username: "u", password: "p" } }) },
|
|
153
|
+
{ label: "ipv4Only 缺省(旧配置文件向后兼容形态)", config: (() => { const c = validConfig(); delete c.ipv4Only; return c; })() },
|
|
154
|
+
{ label: "空清单 + 禁用态(DEFAULT 形态)", config: { enabled: false, proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" }, noProxy: ["localhost", "127.0.0.1"], rules: [], ipv4Only: [] } },
|
|
155
|
+
{ label: "重复条目不报错(匹配天然幂等)", config: validConfig({ rules: ["a.com", "a.com"] }) },
|
|
156
|
+
// ── 顶层对象性 ──
|
|
157
|
+
{ label: "null", config: null },
|
|
158
|
+
{ label: "数组", config: [validConfig()] },
|
|
159
|
+
{ label: "字符串", config: "{}" },
|
|
160
|
+
{ label: "数字", config: 42 },
|
|
161
|
+
// ── enabled ──
|
|
162
|
+
{ label: "enabled 缺省", config: (() => { const c = validConfig(); delete c.enabled; return c; })() },
|
|
163
|
+
{ label: "enabled 非布尔", config: validConfig({ enabled: "yes" }) },
|
|
164
|
+
// ── proxy ──
|
|
165
|
+
{ label: "proxy 缺省", config: (() => { const c = validConfig(); delete c.proxy; return c; })() },
|
|
166
|
+
{ label: "proxy null", config: validConfig({ proxy: null }) },
|
|
167
|
+
{ label: "proxy 数组", config: validConfig({ proxy: ["http"] }) },
|
|
168
|
+
{ label: "协议词表外", config: validConfig({ proxy: { protocol: "socks4", host: "127.0.0.1", port: 7890, username: "", password: "" } }) },
|
|
169
|
+
{ label: "协议非字符串", config: validConfig({ proxy: { protocol: 1, host: "127.0.0.1", port: 7890, username: "", password: "" } }) },
|
|
170
|
+
{ label: "host 空串", config: validConfig({ proxy: { protocol: "http", host: "", port: 7890, username: "", password: "" } }) },
|
|
171
|
+
{ label: "host 纯空白", config: validConfig({ proxy: { protocol: "http", host: " ", port: 7890, username: "", password: "" } }) },
|
|
172
|
+
{ label: "host 非字符串", config: validConfig({ proxy: { protocol: "http", host: 80, port: 7890, username: "", password: "" } }) },
|
|
173
|
+
{ label: "端口 0", config: validConfig({ proxy: { protocol: "http", host: "127.0.0.1", port: 0, username: "", password: "" } }) },
|
|
174
|
+
{ label: "端口 65536", config: validConfig({ proxy: { protocol: "http", host: "127.0.0.1", port: 65536, username: "", password: "" } }) },
|
|
175
|
+
{ label: "端口小数", config: validConfig({ proxy: { protocol: "http", host: "127.0.0.1", port: 7890.5, username: "", password: "" } }) },
|
|
176
|
+
{ label: "端口字符串", config: validConfig({ proxy: { protocol: "http", host: "127.0.0.1", port: "7890", username: "", password: "" } }) },
|
|
177
|
+
{ label: "凭据非文本", config: validConfig({ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: 1, password: "" } }) },
|
|
178
|
+
{ label: "凭据单边(有用户名无密码)", config: validConfig({ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "u", password: "" } }) },
|
|
179
|
+
{ label: "凭据单边(无用户名有密码)", config: validConfig({ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "p" } }) },
|
|
180
|
+
// ── noProxy / rules 条目 ──
|
|
181
|
+
{ label: "noProxy 非数组", config: validConfig({ noProxy: "localhost" }) },
|
|
182
|
+
{ label: "noProxy 空串条目", config: validConfig({ noProxy: [""] }) },
|
|
183
|
+
{ label: "noProxy 纯空白条目", config: validConfig({ noProxy: [" "] }) },
|
|
184
|
+
{ label: "noProxy 非字符串条目", config: validConfig({ noProxy: [123] }) },
|
|
185
|
+
{ label: "rules 含空白字符条目", config: validConfig({ rules: ["api.anthropic.com "] }) },
|
|
186
|
+
{ label: "rules 非数组", config: validConfig({ rules: {} }) },
|
|
187
|
+
// ── ipv4Only ──
|
|
188
|
+
{ label: "ipv4Only 非数组(显式声明非法形态才报错)", config: validConfig({ ipv4Only: "example.com" }) },
|
|
189
|
+
{ label: "ipv4Only 空串条目", config: validConfig({ ipv4Only: [""] }) },
|
|
190
|
+
{ label: "ipv4Only 含空白条目", config: validConfig({ ipv4Only: [" example.com"] }) },
|
|
191
|
+
// ── 多错误并存(锁收集顺序)──
|
|
192
|
+
{ label: "多字段同时非法(enabled + proxy + noProxy + rules + ipv4Only)", config: { enabled: 1, proxy: null, noProxy: "x", rules: [""], ipv4Only: 3 } },
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
describe("validateNetworkProxyConfig 双侧同判(同输入矩阵跨包对拍)", () => {
|
|
196
|
+
it("矩阵逐条:ok 同判 + 拒绝时错误清单逐字一致", () => {
|
|
197
|
+
for (const { label, config } of VALIDATION_MATRIX) {
|
|
198
|
+
expectSameValidation(label, config);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("锚定值抽查:合法最小形态两侧都 ok、顶层非对象两侧都拒", () => {
|
|
203
|
+
expect(validateNetworkProxyConfig(validConfig())).toEqual({ ok: true });
|
|
204
|
+
expect(sharedValidateNetworkProxyConfig(validConfig())).toEqual({ ok: true });
|
|
205
|
+
expect(validateNetworkProxyConfig(null).ok).toBe(false);
|
|
206
|
+
expect(sharedValidateNetworkProxyConfig(null).ok).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ============================================================
|
|
211
|
+
// DEFAULT_NETWORK_PROXY_CONFIG:两侧深相等
|
|
212
|
+
// ============================================================
|
|
213
|
+
|
|
214
|
+
describe("DEFAULT_NETWORK_PROXY_CONFIG 双侧深相等(默认面起点一致)", () => {
|
|
215
|
+
it("本侧 DEFAULT === shared 侧 DEFAULT", () => {
|
|
216
|
+
expect(DEFAULT_NETWORK_PROXY_CONFIG).toEqual(SHARED_DEFAULT);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -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
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* 类型与校验语义 SSOT = packages/shared/src/network-proxy.ts(u-foundation 契约)——
|
|
5
5
|
* extension bundle 自包含不 import shared 运行时代码(extensions 对 shared 的既有惯例:
|
|
6
6
|
* 结构类型本地声明 + 行为镜像锁定测试),本文件是复制体;漂移时以 shared 为准,
|
|
7
|
-
* __tests__/config.test.ts
|
|
7
|
+
* __tests__/config.test.ts 锁本侧行为,__tests__/network-proxy-shared-parity.test.ts
|
|
8
|
+
* 跨包对拍(同输入矩阵 import @taiji/shared 双侧同判)锁两侧一致。
|
|
8
9
|
*
|
|
9
10
|
* 配置文件 = <agentDir>/config/network-proxy-ext-config.json(llm-shared getConfigPath
|
|
10
11
|
* 推导;runtime 写侧与 settings 页经 RPC 打通,设计 .tmp/tech-design/llm-network-proxy.md
|
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,19 +1,19 @@
|
|
|
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
19
|
* - 加载 / 每 turn 开始:重读配置推进状态(`enabled` 关且 `ipv4Only` 空 = 摘除包装恢复原
|
package/src/match.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* 语义 SSOT = packages/shared/src/network-proxy.ts 的同名函数——extension bundle 自包含
|
|
5
5
|
* 不 import shared 运行时代码(extensions 对 shared 的既有惯例:结构类型本地声明 + 行为
|
|
6
6
|
* 真值表镜像锁定,见 rename-session RENAME_INSTRUCTION_MAX_LENGTH 先例),本文件是复制体;
|
|
7
|
-
* 漂移时以 shared 为准,__tests__/match.test.ts
|
|
7
|
+
* 漂移时以 shared 为准,__tests__/match.test.ts 锁本侧真值表,
|
|
8
|
+
* __tests__/network-proxy-shared-parity.test.ts 跨包对拍锁两侧一致。
|
|
8
9
|
*
|
|
9
10
|
* 匹配语义(设计 .tmp/tech-design/llm-network-proxy.md §3.1 配置 schema):
|
|
10
11
|
* suffix = 主机名等于或以 `.pattern` 结尾;点边界必须对齐——`xapi.anthropic.com` 不命中
|