@zhushanwen/pi-network-proxy 0.2.0 → 0.2.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 +16 -6
- package/package.json +2 -2
- package/skills/network-proxy-ext-config/SKILL.md +7 -6
- package/src/__tests__/dispatcher.test.ts +5 -124
- package/src/__tests__/fetch-wrapper.test.ts +324 -0
- package/src/__tests__/index.test.ts +56 -31
- package/src/dispatcher.ts +10 -100
- package/src/fetch-wrapper.ts +244 -0
- package/src/index.ts +45 -25
- package/src/stats.ts +2 -2
- package/src/__tests__/installer.test.ts +0 -313
- package/src/installer.ts +0 -165
package/README.md
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
# @zhushanwen/pi-network-proxy
|
|
2
2
|
|
|
3
|
-
LLM
|
|
3
|
+
LLM 网络代理分流:包装 pi 进程的 `globalThis.fetch`,按主机名规则把命中请求交给代理 dispatcher(http/https/socks5),未命中的原样委托包装前的 fetch(直连或 pi 原生 env 代理路由),并按主机名计数(经代理 / 直连)。
|
|
4
4
|
|
|
5
5
|
## 行为摘要
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
7
|
+
- **拦截点**:`globalThis.fetch`。pi-ai 的 OpenAI 兼容请求在每次构造 client 时经 `Shims.getDefaultFetch()` 动态取当前全局 fetch,包装它不依赖 pi 与扩展共享 undici 模块实例
|
|
8
|
+
- **安装/卸载**:extension 加载时读配置,`enabled` 且校验通过 → 包装并构造代理 dispatcher;`enabled` false → 恢复原 fetch;校验/构造失败 → extension error(含配置文件恢复指引),不包装不阻断 pi(全部直连)
|
|
9
|
+
- **热生效**:turn 粒度——每 turn 开始重读配置(mtime+size 读时刷新),指纹变化才重建;pi 的 `configureHttpDispatcher` 带 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99:检测到 `globalThis.fetch` 已被第三方覆盖即保留不重装),本扩展的包装在同进程内持续生效;槽位被其他第三方覆盖时下一 turn 自动以覆盖者为新 original 重新包装(防御路径)
|
|
10
|
+
- **匹配语义**:主机名归一化(小写、去末尾点)后 suffix 匹配(等于或以 `.条目` 结尾,点边界对齐);noProxy 优先于 rules
|
|
11
|
+
- **覆盖面**:经 `globalThis.fetch` 发出的请求(pi LLM 调用与 OAuth/目录请求均走 fetch);直接使用 `undici.request` / `Client` 的调用与 WebSocket 升级请求不经过本包装
|
|
10
12
|
- **计数**:请求发起时 +1(内存按主机名聚合),turn 结束 / 进程退出把增量追加写入 `<agentDir>/network-proxy-stats.jsonl`(每行完整 JSON `{ts, host, proxied, direct}`,单次 write 单行——多进程并发追加不交错);写失败 log 不阻断
|
|
11
|
-
- **降级契约**:socks5 构造失败(undici 实验性 API)→
|
|
13
|
+
- **降级契约**:socks5 构造失败(undici 实验性 API)→ 记错误、恢复原 fetch、全部直连,切回 http 协议恢复;同指纹不重试构造,配置修正(指纹变化)后自然重试
|
|
14
|
+
|
|
15
|
+
## 为什么包装 fetch 而不是 undici 全局 dispatcher
|
|
16
|
+
|
|
17
|
+
undici 的 `setGlobalDispatcher` 槽是模块级变量,只有「pi 与扩展加载同一份 undici 模块实例」时才生效:
|
|
18
|
+
|
|
19
|
+
- 独立 pi(`node_modules/.bin/pi`)与扩展源码都从 `node_modules/undici` 解析,实例相同,旧方案可用
|
|
20
|
+
- 打包版 pi 是单文件二进制,HTTP 栈用内置 undici(`builtin://thirdparty/undici`);扩展 bundle 由 esbuild 内联自己的 undici 副本。两套全局槽互不相通——扩展安装的分流器 pi 根本不会使用,LLM 请求静默直连、统计为空
|
|
21
|
+
- `globalThis.fetch` 是两套实现之间稳定的接缝(pi-ai / OpenAI SDK 每次构造 client 时动态读取),因此拦截点定在这里
|
|
12
22
|
|
|
13
23
|
## 配置
|
|
14
24
|
|
|
@@ -31,4 +41,4 @@ taiji 桌面端经设置页「网络代理」读写(runtime RMW 写侧);
|
|
|
31
41
|
- socks5:`Socks5ProxyAgent` 调优经 `connect: buildConnector({ autoSelectFamilyAttemptTimeout: 2000 })` 的 **connector 函数形态**(对象形态首个请求建隧道时 TypeError);`proxyTls` 是「代理连接走 TLS」开关,不可当调优参数袋
|
|
32
42
|
- 两协议 agent 均挂防崩溃 error listener(对齐 pi `withUndiciErrorListener` 形态)
|
|
33
43
|
|
|
34
|
-
上述契约由 `src/__tests__/dispatcher-construction.test.ts`
|
|
44
|
+
上述契约由 `src/__tests__/dispatcher-construction.test.ts` 构造形态冒烟测试锁定;分流/计数/状态机行为由 `src/__tests__/fetch-wrapper.test.ts` 锁定。undici 升级时这两处是第一道回归防线。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-network-proxy",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "LLM network proxy: rule-based per-hostname traffic splitting via
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
7
7
|
"taiji": {
|
|
@@ -5,7 +5,7 @@ description: "配置 @zhushanwen/pi-network-proxy(LLM 网络代理分流)时
|
|
|
5
5
|
|
|
6
6
|
# network-proxy 配置指南
|
|
7
7
|
|
|
8
|
-
> @zhushanwen/pi-network-proxy
|
|
8
|
+
> @zhushanwen/pi-network-proxy:包装 pi 进程的 `globalThis.fetch`,按主机名规则分流——命中规则的请求经代理出站,未命中的保持 pi 原生行为(直连或 env 代理),并按主机名计数(经代理 / 直连)。打包版与独立 pi 都生效(不依赖 pi 与扩展共享 undici 模块实例)。
|
|
9
9
|
|
|
10
10
|
## 配置文件位置
|
|
11
11
|
|
|
@@ -20,7 +20,7 @@ description: "配置 @zhushanwen/pi-network-proxy(LLM 网络代理分流)时
|
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
22
|
interface NetworkProxyConfig {
|
|
23
|
-
enabled: boolean; // 总开关,默认 false(禁用 =
|
|
23
|
+
enabled: boolean; // 总开关,默认 false(禁用 = 不包装 fetch,全部按 pi 原生行为出站)
|
|
24
24
|
proxy: {
|
|
25
25
|
protocol: "http" | "https" | "socks5"; // http/https 走 undici ProxyAgent,socks5 走 Socks5ProxyAgent
|
|
26
26
|
host: string; // 代理服务器地址,如 127.0.0.1
|
|
@@ -42,13 +42,14 @@ interface NetworkProxyConfig {
|
|
|
42
42
|
- `xapi.anthropic.com` 不命中(无点前缀拼接);`api-anthropic.com.evil.com` 不命中(恶意后缀)
|
|
43
43
|
- 匹配对象 = 请求目标 URL 的主机名(服务商 baseURL 的主机名;端口不参与匹配)
|
|
44
44
|
- noProxy 先于 rules 判定(先排除再匹配);`localhost` 条目同时覆盖 `localhost` 与 `*.localhost`
|
|
45
|
-
- 不支持 `*`
|
|
45
|
+
- 不支持 `*` 通配(无已发生需求);分流覆盖经 `globalThis.fetch` 发出的请求(pi LLM 调用与 OAuth/目录请求);直接使用 `undici.request` / `Client` 的调用不经过本包装
|
|
46
46
|
|
|
47
47
|
## 生效时机(turn 粒度热重载)
|
|
48
48
|
|
|
49
|
-
- 每个 turn 开始重读配置,指纹(enabled+proxy+noProxy+rules
|
|
50
|
-
- `enabled` 从 true → false
|
|
51
|
-
-
|
|
49
|
+
- 每个 turn 开始重读配置,指纹(enabled+proxy+noProxy+rules)变化才重建包装——**运行中会话自下一轮对话起生效,无需重启**;turn 进行中(工具循环连续请求)保存的配置对该 turn 内后续请求不生效
|
|
50
|
+
- `enabled` 从 true → false:立即恢复包装前的 fetch(全部直连/env 代理路由)
|
|
51
|
+
- 未命中规则的流量委托包装前的 fetch——独立 pi 用户设了 `HTTPS_PROXY` 环境变量时,未命中流量仍走 env 代理,语义不丢失
|
|
52
|
+
- pi 的 `configureHttpDispatcher` 带 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99):检测到 `globalThis.fetch` 已被第三方覆盖即保留不重装,本扩展的包装在同进程内持续生效;槽位被其他第三方整体覆盖时,下一 turn 开始自动以覆盖者为新 original 重新包装(防御路径)
|
|
52
53
|
|
|
53
54
|
## 统计文件
|
|
54
55
|
|
|
@@ -1,132 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* 代理 URL 构造(凭据 percent-encode 拼入)。
|
|
3
|
+
* 分流行为与计数已随拦截点迁移到 fetch-wrapper.test.ts;两协议构造形态冒烟在
|
|
4
|
+
* dispatcher-construction.test.ts(vi.mock undici 隔离)。
|
|
4
5
|
*/
|
|
5
|
-
import { describe, expect, it
|
|
6
|
-
import { Dispatcher } from "undici";
|
|
7
|
-
import type { Dispatcher as IDispatcher } from "undici";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
8
7
|
|
|
9
|
-
import { buildProxyUrl
|
|
8
|
+
import { buildProxyUrl } from "../dispatcher.ts";
|
|
10
9
|
import type { NetworkProxyServer } from "../config.ts";
|
|
11
10
|
|
|
12
|
-
class RecordingDispatcher extends Dispatcher {
|
|
13
|
-
calls: Array<{ origin: string | URL | undefined }> = [];
|
|
14
|
-
dispatch(opts: IDispatcher.DispatchOptions, _handler: IDispatcher.DispatchHandler): boolean {
|
|
15
|
-
this.calls.push({ origin: opts.origin });
|
|
16
|
-
return true;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function configLists(noProxy: string[], rules: string[]) {
|
|
21
|
-
return { noProxy, rules };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function dispatchVia(
|
|
25
|
-
dispatcher: RuleDispatchDispatcher,
|
|
26
|
-
origin: string | URL | undefined,
|
|
27
|
-
): { toProxy: boolean; toFallback: boolean } {
|
|
28
|
-
proxyDispatcher.calls = [];
|
|
29
|
-
fallbackDispatcher.calls = [];
|
|
30
|
-
dispatcher.dispatch({ origin, path: "/", method: "GET" }, {});
|
|
31
|
-
return {
|
|
32
|
-
toProxy: proxyDispatcher.calls.length > 0,
|
|
33
|
-
toFallback: fallbackDispatcher.calls.length > 0,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
let fallbackDispatcher: RecordingDispatcher;
|
|
38
|
-
let proxyDispatcher: RecordingDispatcher;
|
|
39
|
-
let counted: Array<{ host: string; proxied: boolean }>;
|
|
40
|
-
|
|
41
|
-
function createDispatcher(noProxy: string[], rules: string[]): RuleDispatchDispatcher {
|
|
42
|
-
fallbackDispatcher = new RecordingDispatcher();
|
|
43
|
-
proxyDispatcher = new RecordingDispatcher();
|
|
44
|
-
counted = [];
|
|
45
|
-
return new RuleDispatchDispatcher({
|
|
46
|
-
fallback: fallbackDispatcher,
|
|
47
|
-
proxy: proxyDispatcher,
|
|
48
|
-
...configLists(noProxy, rules),
|
|
49
|
-
onCount: (host, proxied) => counted.push({ host, proxied }),
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe("RuleDispatchDispatcher 分流行为", () => {
|
|
54
|
-
it("规则命中 → 代理 dispatch + proxied 计数(计数时点 = 发起时,同步完成)", () => {
|
|
55
|
-
const dispatcher = createDispatcher(["localhost"], ["api.anthropic.com"]);
|
|
56
|
-
const result = dispatchVia(dispatcher, "https://api.anthropic.com/v1/messages");
|
|
57
|
-
expect(result).toEqual({ toProxy: true, toFallback: false });
|
|
58
|
-
expect(counted).toEqual([{ host: "api.anthropic.com", proxied: true }]);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("规则未命中 → 委托原分发器(fallback)+ direct 计数", () => {
|
|
62
|
-
const dispatcher = createDispatcher(["localhost"], ["api.anthropic.com"]);
|
|
63
|
-
const result = dispatchVia(dispatcher, "https://open.bigmodel.cn/api");
|
|
64
|
-
expect(result).toEqual({ toProxy: false, toFallback: true });
|
|
65
|
-
expect(counted).toEqual([{ host: "open.bigmodel.cn", proxied: false }]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("noProxy 优先于 rules:同主机名两侧都出现时直连(决策 7)", () => {
|
|
69
|
-
const dispatcher = createDispatcher(["api.anthropic.com"], ["api.anthropic.com"]);
|
|
70
|
-
const result = dispatchVia(dispatcher, "https://api.anthropic.com/v1/messages");
|
|
71
|
-
expect(result).toEqual({ toProxy: false, toFallback: true });
|
|
72
|
-
expect(counted).toEqual([{ host: "api.anthropic.com", proxied: false }]);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("子域命中 + 点边界:子域走代理,前缀拼接不命中走 fallback", () => {
|
|
76
|
-
const dispatcher = createDispatcher([], ["api.anthropic.com"]);
|
|
77
|
-
expect(dispatchVia(dispatcher, "https://eu.api.anthropic.com/v1").toProxy).toBe(true);
|
|
78
|
-
expect(dispatchVia(dispatcher, "https://xapi.anthropic.com/v1").toFallback).toBe(true);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("归一化参与分流:大写 / 末尾点 origin 命中规则", () => {
|
|
82
|
-
const dispatcher = createDispatcher([], ["api.anthropic.com"]);
|
|
83
|
-
expect(dispatchVia(dispatcher, "https://API.Anthropic.COM./v1").toProxy).toBe(true);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("URL 对象形态的 origin 同样处理", () => {
|
|
87
|
-
const dispatcher = createDispatcher([], ["api.anthropic.com"]);
|
|
88
|
-
expect(dispatchVia(dispatcher, new URL("https://api.anthropic.com/v1")).toProxy).toBe(true);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("origin 缺失(无已发生证据的异常路径)→ 委托 fallback + direct 计数,不抛", () => {
|
|
92
|
-
const dispatcher = createDispatcher([], ["api.anthropic.com"]);
|
|
93
|
-
expect(() => dispatchVia(dispatcher, undefined)).not.toThrow();
|
|
94
|
-
expect(counted).toEqual([{ host: "", proxied: false }]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("计数按主机名聚合可累计(多次请求同主机名同键累加)", () => {
|
|
98
|
-
const dispatcher = createDispatcher([], ["api.anthropic.com"]);
|
|
99
|
-
dispatchVia(dispatcher, "https://api.anthropic.com/a");
|
|
100
|
-
dispatchVia(dispatcher, "https://eu.api.anthropic.com/b");
|
|
101
|
-
expect(counted).toEqual([
|
|
102
|
-
{ host: "api.anthropic.com", proxied: true },
|
|
103
|
-
{ host: "eu.api.anthropic.com", proxied: true },
|
|
104
|
-
]);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("handler 原样透传给目标分发器(未命中委托语义 = pi 原生行为不变)", () => {
|
|
108
|
-
const dispatcher = createDispatcher([], []);
|
|
109
|
-
const handler = { onHeaders: vi.fn() } as unknown as IDispatcher.DispatchHandler;
|
|
110
|
-
const opts = { origin: "https://any.test", path: "/", method: "GET" } as IDispatcher.DispatchOptions;
|
|
111
|
-
const seen: Array<unknown> = [];
|
|
112
|
-
fallbackDispatcher = new RecordingDispatcher();
|
|
113
|
-
vi.spyOn(fallbackDispatcher, "dispatch").mockImplementation((o, h) => {
|
|
114
|
-
seen.push({ o, h });
|
|
115
|
-
return true;
|
|
116
|
-
});
|
|
117
|
-
const relay = new RuleDispatchDispatcher({
|
|
118
|
-
fallback: fallbackDispatcher,
|
|
119
|
-
proxy: new RecordingDispatcher(),
|
|
120
|
-
noProxy: [],
|
|
121
|
-
rules: [],
|
|
122
|
-
onCount: () => {},
|
|
123
|
-
});
|
|
124
|
-
relay.dispatch(opts, handler);
|
|
125
|
-
expect(seen).toHaveLength(1);
|
|
126
|
-
expect((seen[0] as { h: unknown }).h).toBe(handler);
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
11
|
describe("buildProxyUrl(凭据 percent-encode 拼入)", () => {
|
|
131
12
|
const base: NetworkProxyServer = {
|
|
132
13
|
protocol: "http",
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch 包装状态机单测(fake 依赖注入,不触真实网络):
|
|
3
|
+
* 命中/未命中分流、计数、noProxy 优先、禁用恢复、外部覆盖重装、构造失败锁定、
|
|
4
|
+
* reload 残留摘除、入参形态覆盖。转移表见 fetch-wrapper.ts 文件头。
|
|
5
|
+
*/
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { Dispatcher } from "undici";
|
|
8
|
+
|
|
9
|
+
import type { NetworkProxyConfig } from "../config.ts";
|
|
10
|
+
import {
|
|
11
|
+
applyNetworkProxyConfig,
|
|
12
|
+
detachResidualFetchWrapper,
|
|
13
|
+
type FetchLike,
|
|
14
|
+
type FetchWrapperDeps,
|
|
15
|
+
type ProxyFetchInit,
|
|
16
|
+
} from "../fetch-wrapper.ts";
|
|
17
|
+
import { resetCounters, snapshotCounters } from "../stats.ts";
|
|
18
|
+
|
|
19
|
+
class FakeDispatcher extends Dispatcher {}
|
|
20
|
+
|
|
21
|
+
interface FetchCall { // oe-exempt:20261020:test:测试观测记录形状别名,仅本测试文件消费
|
|
22
|
+
input: string | URL | Request;
|
|
23
|
+
init?: RequestInit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ProxyCall { // oe-exempt:20261020:test:测试观测记录形状别名,仅本测试文件消费
|
|
27
|
+
input: string | URL | Request;
|
|
28
|
+
init: ProxyFetchInit;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function config(overrides?: Partial<NetworkProxyConfig>): NetworkProxyConfig {
|
|
32
|
+
return {
|
|
33
|
+
enabled: true,
|
|
34
|
+
proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
|
|
35
|
+
noProxy: ["localhost"],
|
|
36
|
+
rules: ["api.anthropic.com"],
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withProxy(patch: Partial<NetworkProxyConfig["proxy"]>): Partial<NetworkProxyConfig> {
|
|
42
|
+
return { proxy: { ...config().proxy, ...patch } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** fake 依赖装配:original fetch / 代理 fetch / 构造器全部可观测、可注入失败。 */
|
|
46
|
+
function createHarness() {
|
|
47
|
+
const originalCalls: FetchCall[] = [];
|
|
48
|
+
const originalFetch: FetchLike = async (input, init) => {
|
|
49
|
+
originalCalls.push({ input, init });
|
|
50
|
+
return new Response("original");
|
|
51
|
+
};
|
|
52
|
+
let currentFetch: FetchLike = originalFetch;
|
|
53
|
+
|
|
54
|
+
const proxyCalls: ProxyCall[] = [];
|
|
55
|
+
const proxy = new FakeDispatcher();
|
|
56
|
+
const proxyFetch = async (input: string | URL | Request, init: ProxyFetchInit): Promise<Response> => {
|
|
57
|
+
proxyCalls.push({ input, init });
|
|
58
|
+
return new Response("proxy");
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let buildProxy = (_config: NetworkProxyConfig): Dispatcher => proxy;
|
|
62
|
+
const buildCalls: NetworkProxyConfig[] = [];
|
|
63
|
+
const errors: string[] = [];
|
|
64
|
+
|
|
65
|
+
const deps: FetchWrapperDeps = {
|
|
66
|
+
getFetch: () => currentFetch,
|
|
67
|
+
setFetch: (fetch) => {
|
|
68
|
+
currentFetch = fetch;
|
|
69
|
+
},
|
|
70
|
+
buildProxy: (next) => {
|
|
71
|
+
buildCalls.push(next);
|
|
72
|
+
return buildProxy(next);
|
|
73
|
+
},
|
|
74
|
+
proxyFetch,
|
|
75
|
+
onError: (message) => errors.push(message),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
deps,
|
|
80
|
+
originalFetch,
|
|
81
|
+
originalCalls,
|
|
82
|
+
proxyCalls,
|
|
83
|
+
proxy,
|
|
84
|
+
buildCalls,
|
|
85
|
+
errors,
|
|
86
|
+
getCurrent: () => currentFetch,
|
|
87
|
+
setCurrent: (fetch: FetchLike) => {
|
|
88
|
+
currentFetch = fetch;
|
|
89
|
+
},
|
|
90
|
+
setBuildProxy: (next: (config: NetworkProxyConfig) => Dispatcher) => {
|
|
91
|
+
buildProxy = next;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
resetCounters();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("applyNetworkProxyConfig 包装状态机", () => {
|
|
101
|
+
it("首装 enabled:包装 globalThis.fetch 槽位(fake 槽),返回包装状态", () => {
|
|
102
|
+
const h = createHarness();
|
|
103
|
+
const state = applyNetworkProxyConfig(config(), null, h.deps);
|
|
104
|
+
expect(state).not.toBeNull();
|
|
105
|
+
expect(state?.wrapper).not.toBeNull();
|
|
106
|
+
expect(h.getCurrent()).toBe(state?.wrapper);
|
|
107
|
+
expect(h.errors).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("规则命中:走代理 fetch + proxied 计数,init 保留并注入 dispatcher", async () => {
|
|
111
|
+
const h = createHarness();
|
|
112
|
+
applyNetworkProxyConfig(config(), null, h.deps);
|
|
113
|
+
const response = await h.getCurrent()("https://api.anthropic.com/v1/messages", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "x-test": "1" },
|
|
116
|
+
body: "{}",
|
|
117
|
+
});
|
|
118
|
+
expect(await response.text()).toBe("proxy");
|
|
119
|
+
expect(h.proxyCalls).toHaveLength(1);
|
|
120
|
+
expect(h.originalCalls).toHaveLength(0);
|
|
121
|
+
expect(h.proxyCalls[0]?.init.dispatcher).toBe(h.proxy);
|
|
122
|
+
expect(h.proxyCalls[0]?.init.method).toBe("POST");
|
|
123
|
+
expect(h.proxyCalls[0]?.init.headers).toEqual({ "x-test": "1" });
|
|
124
|
+
expect(h.proxyCalls[0]?.init.body).toBe("{}");
|
|
125
|
+
expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 1, direct: 0 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("规则未命中:原样委托原 fetch + direct 计数", async () => {
|
|
129
|
+
const h = createHarness();
|
|
130
|
+
applyNetworkProxyConfig(config(), null, h.deps);
|
|
131
|
+
const init: RequestInit = { method: "GET" };
|
|
132
|
+
const response = await h.getCurrent()("https://open.bigmodel.cn/api", init);
|
|
133
|
+
expect(await response.text()).toBe("original");
|
|
134
|
+
expect(h.originalCalls).toHaveLength(1);
|
|
135
|
+
expect(h.originalCalls[0]?.init).toBe(init);
|
|
136
|
+
expect(h.proxyCalls).toHaveLength(0);
|
|
137
|
+
expect(snapshotCounters().get("open.bigmodel.cn")).toEqual({ proxied: 0, direct: 1 });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("noProxy 优先于 rules:同主机名两侧都出现时直连", async () => {
|
|
141
|
+
const h = createHarness();
|
|
142
|
+
applyNetworkProxyConfig(
|
|
143
|
+
config({ noProxy: ["api.anthropic.com"], rules: ["api.anthropic.com"] }),
|
|
144
|
+
null,
|
|
145
|
+
h.deps,
|
|
146
|
+
);
|
|
147
|
+
await h.getCurrent()("https://api.anthropic.com/v1/messages");
|
|
148
|
+
expect(h.proxyCalls).toHaveLength(0);
|
|
149
|
+
expect(h.originalCalls).toHaveLength(1);
|
|
150
|
+
expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 0, direct: 1 });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("子域命中 + 点边界:子域走代理,前缀拼接不命中", async () => {
|
|
154
|
+
const h = createHarness();
|
|
155
|
+
applyNetworkProxyConfig(config({ noProxy: [], rules: ["api.anthropic.com"] }), null, h.deps);
|
|
156
|
+
await h.getCurrent()("https://eu.api.anthropic.com/v1");
|
|
157
|
+
await h.getCurrent()("https://xapi.anthropic.com/v1");
|
|
158
|
+
expect(h.proxyCalls).toHaveLength(1);
|
|
159
|
+
expect(h.originalCalls).toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("入参形态:string / URL / Request 都能识别主机名", async () => {
|
|
163
|
+
const h = createHarness();
|
|
164
|
+
applyNetworkProxyConfig(config({ noProxy: [], rules: ["api.anthropic.com"] }), null, h.deps);
|
|
165
|
+
await h.getCurrent()("https://api.anthropic.com/a");
|
|
166
|
+
await h.getCurrent()(new URL("https://api.anthropic.com/b"));
|
|
167
|
+
await h.getCurrent()(new Request("https://api.anthropic.com/c"));
|
|
168
|
+
expect(h.proxyCalls).toHaveLength(3);
|
|
169
|
+
expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 3, direct: 0 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("无法解析目标(相对 URL / 空 host):原样放行且不计数", async () => {
|
|
173
|
+
const h = createHarness();
|
|
174
|
+
applyNetworkProxyConfig(config(), null, h.deps);
|
|
175
|
+
await h.getCurrent()("/relative/path");
|
|
176
|
+
await h.getCurrent()(new URL("file:///tmp/a"));
|
|
177
|
+
expect(h.originalCalls).toHaveLength(2);
|
|
178
|
+
expect(h.proxyCalls).toHaveLength(0);
|
|
179
|
+
expect(snapshotCounters().size).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("指纹未变且包装仍在槽位:零动作(状态对象复用,不重建)", () => {
|
|
183
|
+
const h = createHarness();
|
|
184
|
+
const state = applyNetworkProxyConfig(config(), null, h.deps);
|
|
185
|
+
const again = applyNetworkProxyConfig(config(), state, h.deps);
|
|
186
|
+
expect(again).toBe(state);
|
|
187
|
+
expect(h.buildCalls).toHaveLength(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("指纹变化:重建代理分发器并换新包装", async () => {
|
|
191
|
+
const h = createHarness();
|
|
192
|
+
const first = applyNetworkProxyConfig(config(), null, h.deps);
|
|
193
|
+
const second = applyNetworkProxyConfig(
|
|
194
|
+
config({ rules: ["api.openai.com"] }),
|
|
195
|
+
first,
|
|
196
|
+
h.deps,
|
|
197
|
+
);
|
|
198
|
+
expect(second?.wrapper).not.toBe(first?.wrapper);
|
|
199
|
+
expect(h.buildCalls).toHaveLength(2);
|
|
200
|
+
await h.getCurrent()("https://api.openai.com/v1");
|
|
201
|
+
expect(h.proxyCalls).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("槽位被外部覆盖(pi 重跑 configureHttpDispatcher → undici.install):同指纹也以覆盖者为 original 重新包装", async () => {
|
|
205
|
+
const h = createHarness();
|
|
206
|
+
const first = applyNetworkProxyConfig(config(), null, h.deps);
|
|
207
|
+
// 模拟 pi 设置变更:globalThis.fetch 被 pi 原生实现整体替换,本扩展状态未感知
|
|
208
|
+
const piReinstalled: FetchLike = async () => new Response("pi-native");
|
|
209
|
+
h.setCurrent(piReinstalled);
|
|
210
|
+
|
|
211
|
+
const again = applyNetworkProxyConfig(config(), first, h.deps);
|
|
212
|
+
expect(again).not.toBe(first);
|
|
213
|
+
expect(again?.wrapper).not.toBeNull();
|
|
214
|
+
expect(h.getCurrent()).toBe(again?.wrapper);
|
|
215
|
+
// 未命中流量走覆盖者(pi 当前实现),不是旧 original
|
|
216
|
+
await h.getCurrent()("https://open.bigmodel.cn/api");
|
|
217
|
+
expect(h.originalCalls).toHaveLength(0);
|
|
218
|
+
// 命中流量仍走代理
|
|
219
|
+
await h.getCurrent()("https://api.anthropic.com/v1");
|
|
220
|
+
expect(h.proxyCalls).toHaveLength(1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("enabled true→false:恢复原 fetch + 状态清空", () => {
|
|
224
|
+
const h = createHarness();
|
|
225
|
+
const state = applyNetworkProxyConfig(config(), null, h.deps);
|
|
226
|
+
const after = applyNetworkProxyConfig(config({ enabled: false }), state, h.deps);
|
|
227
|
+
expect(after).toBeNull();
|
|
228
|
+
expect(h.getCurrent()).toBe(h.originalFetch);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("首装即禁用:零动作", () => {
|
|
232
|
+
const h = createHarness();
|
|
233
|
+
expect(applyNetworkProxyConfig(config({ enabled: false }), null, h.deps)).toBeNull();
|
|
234
|
+
expect(h.buildCalls).toHaveLength(0);
|
|
235
|
+
expect(h.errors).toHaveLength(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("构造失败:onError + 不包装(全直连),同指纹锁定不重试;指纹变化后重试", () => {
|
|
239
|
+
const h = createHarness();
|
|
240
|
+
h.setBuildProxy(() => {
|
|
241
|
+
throw new TypeError("this[kConnector] is not a function");
|
|
242
|
+
});
|
|
243
|
+
const locked = applyNetworkProxyConfig(config(), null, h.deps);
|
|
244
|
+
expect(locked?.wrapper).toBeNull();
|
|
245
|
+
expect(h.getCurrent()).toBe(h.originalFetch);
|
|
246
|
+
expect(h.errors).toHaveLength(1);
|
|
247
|
+
expect(h.errors[0]).toContain("构造失败");
|
|
248
|
+
expect(h.errors[0]).toContain("直连");
|
|
249
|
+
|
|
250
|
+
const stillLocked = applyNetworkProxyConfig(config(), locked, h.deps);
|
|
251
|
+
expect(stillLocked?.wrapper).toBeNull();
|
|
252
|
+
expect(h.buildCalls).toHaveLength(1); // 同指纹不重试
|
|
253
|
+
|
|
254
|
+
h.setBuildProxy(() => new FakeDispatcher());
|
|
255
|
+
const recovered = applyNetworkProxyConfig(config(withProxy({ port: 7891 })), stillLocked, h.deps);
|
|
256
|
+
expect(recovered?.wrapper).not.toBeNull();
|
|
257
|
+
expect(h.getCurrent()).toBe(recovered?.wrapper);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("运行中重建遇构造失败:撤下旧包装恢复原 fetch,记指纹锁定", () => {
|
|
261
|
+
const h = createHarness();
|
|
262
|
+
const installed = applyNetworkProxyConfig(config(), null, h.deps);
|
|
263
|
+
h.setBuildProxy(() => {
|
|
264
|
+
throw new Error("socks5 experimental API changed");
|
|
265
|
+
});
|
|
266
|
+
const locked = applyNetworkProxyConfig(
|
|
267
|
+
config(withProxy({ protocol: "socks5" })),
|
|
268
|
+
installed,
|
|
269
|
+
h.deps,
|
|
270
|
+
);
|
|
271
|
+
expect(locked?.wrapper).toBeNull();
|
|
272
|
+
expect(h.getCurrent()).toBe(h.originalFetch);
|
|
273
|
+
expect(h.errors[0]).toContain("socks5");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("锁定态再遇禁用:状态清空、零槽位操作", () => {
|
|
277
|
+
const h = createHarness();
|
|
278
|
+
h.setBuildProxy(() => {
|
|
279
|
+
throw new Error("boom");
|
|
280
|
+
});
|
|
281
|
+
const locked = applyNetworkProxyConfig(config(), null, h.deps);
|
|
282
|
+
const after = applyNetworkProxyConfig(config({ enabled: false }), locked, h.deps);
|
|
283
|
+
expect(after).toBeNull();
|
|
284
|
+
expect(h.getCurrent()).toBe(h.originalFetch);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("detachResidualFetchWrapper(reload 残留摘除)", () => {
|
|
289
|
+
it("installed=null 且槽上是本扩展旧包装:恢复其 original fetch", () => {
|
|
290
|
+
const h = createHarness();
|
|
291
|
+
applyNetworkProxyConfig(config(), null, h.deps);
|
|
292
|
+
expect(h.getCurrent()).not.toBe(h.originalFetch);
|
|
293
|
+
|
|
294
|
+
// 模拟 reload 后新模块环境:installed=null,槽上仍是旧环境包装
|
|
295
|
+
detachResidualFetchWrapper(null, h.deps);
|
|
296
|
+
expect(h.getCurrent()).toBe(h.originalFetch);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("installed=null 且槽上是非本扩展函数:零动作", () => {
|
|
300
|
+
const h = createHarness();
|
|
301
|
+
const foreignFetch: FetchLike = async () => new Response("foreign");
|
|
302
|
+
h.setCurrent(foreignFetch);
|
|
303
|
+
detachResidualFetchWrapper(null, h.deps);
|
|
304
|
+
expect(h.getCurrent()).toBe(foreignFetch);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("installed 非 null(同环境 factory 重跑):早退,不误摘当前包装", () => {
|
|
308
|
+
const h = createHarness();
|
|
309
|
+
const state = applyNetworkProxyConfig(config(), null, h.deps);
|
|
310
|
+
detachResidualFetchWrapper(state, h.deps);
|
|
311
|
+
expect(h.getCurrent()).toBe(state?.wrapper);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("reload 后重新推进:摘除旧包装后再包新层(不嵌套双计)", async () => {
|
|
315
|
+
const h = createHarness();
|
|
316
|
+
applyNetworkProxyConfig(config(), null, h.deps);
|
|
317
|
+
detachResidualFetchWrapper(null, h.deps);
|
|
318
|
+
const state = applyNetworkProxyConfig(config(), null, h.deps);
|
|
319
|
+
expect(state?.originalFetch).toBe(h.originalFetch);
|
|
320
|
+
await h.getCurrent()("https://api.anthropic.com/v1");
|
|
321
|
+
expect(h.proxyCalls).toHaveLength(1);
|
|
322
|
+
expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 1, direct: 0 });
|
|
323
|
+
});
|
|
324
|
+
});
|