dsh-provider-proxy 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,116 +1,135 @@
1
- # dsh-provider-proxy
2
-
3
- 给 **DeepSeek Harness**(`0.2.0-rc.1` 及以上)用的插件:在 **设置** 里为**每个 provider 单独配置**「这个 provider 要不要走代理」,填写代理类型 + IP/端口。只有被你打开开关的 provider 走代理,其余 provider 和 DSH 的其它流量保持原来的直连路径。
4
-
5
- 支持官方桌面版(DSH Desktop)与 `dsh web`。
6
-
7
- ## 安装
8
-
9
- 先确保已构建过产物(`lib/index.js`、`lib/client.js`):
10
-
11
- ```sh
12
- npm install
13
- npm run build
14
- ```
15
-
16
- 桌面版(**必须先完整打开一次桌面版以初始化 profile,然后完全退出**,否则安装命令会拒绝执行):
17
-
18
- ```sh
19
- dsh plugin --profile desktop add D:\gitwork\dsh-provider-proxy
20
- ```
21
-
22
- Web:
23
-
24
- ```sh
25
- dsh plugin --profile web add /absolute/path/to/dsh-provider-proxy
26
- ```
27
-
28
- 安装后重启对应应用。设置项写在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`。
29
-
30
- ## 使用
31
-
32
- 打开 **设置**,左侧导航里会出现一项 **Provider 代理**(英文界面为 *Provider Proxy*)。
33
- 页面里每个 provider 一张卡片,各自独立配置:
34
-
35
- | 字段 | 说明 |
36
- | --- | --- |
37
- | 开关 | 该 provider 是否走代理 |
38
- | 类型 | `HTTP` / `HTTPS`(`SOCKS5` 目前未实现,选它会给出提示) |
39
- | IP / 主机 | 只填主机名或 IP,不要带 `http://` 或路径 |
40
- | 端口 | 1–65535 的整数 |
41
- | 用户名 | 可选 |
42
-
43
- 列表只包含**已激活**的 provider(`llm.listProviders()` 能列出的那些)。仅在配置里声明、
44
- 但没有实际注册的可配置 provider 不显示——它的请求根本不会被发出去。
45
- 已经在 `providers` 里配过代理的条目即使暂时不激活也仍然保留,避免已保存的设置悄悄消失。
46
-
47
- 保存立即对新请求生效,不需要重启;正在进行中的流会继续用它启动时的那条连接。
48
-
49
- > **为什么是独立设置页,而不是内嵌在 Models 的 provider 卡片里?**
50
- > 本插件最早按卡片内嵌(`settings.models.provider-card`)实现,但那个槽位是 keyed 的、
51
- > 按 provider 命名空间派发,第三方注册在 0.2.0-rc.1 上始终匹配不上——不报错、不发请求、
52
- > 界面上什么都不出现。独立分区(`settings.section`)是同一 profile 下
53
- > `dsh-llm-retry-settings` 已被验证可用的机制。
54
-
55
- ## 它是怎么工作的
56
-
57
- 0.2.0 没有「给单个 provider 注入 transport」的官方口子,这是核对过源码的结论:
58
-
59
- - `llm-deepseek` 直接调用全局 `fetch`,请求参数里没有 `dispatcher`;
60
- - `llm-pi-ai` 走 pi-ai SDK 自己的 HTTP,DSH 无法配置;
61
- - `LlmCallControls` 只有 `reasoningEffort` / `temperature` / `maxTokens` / `stop`;
62
- - `llm/stream` 拦截的是**已解码的** `StreamChunk`,发生在 HTTP 之后;
63
- - `ctx.web` 只服务 `web_search` / `web_fetch`,与 provider 流量无关;
64
- - 官方的 `@deepseek-ai/dsh-http-proxy` 是**库**不是插件:`installGlobalProxy` 是模块私有函数,没有 service、没有 Config、只有从环境变量解析出的一份进程级策略。
65
-
66
- 所以请求在唯一还能拿到线索的地方被路由:`llm/stream` waterfall 的 `options.provider`。用一个 `AsyncLocalStorage` 把选中的 dispatcher 带到流上,再由一层很薄的 `globalThis.fetch` 包装把它交给 fetch。
67
-
68
- 两个关键点:
69
-
70
- 1. **每个 provider 各自持有一个 dispatcher**(而不是共用一个),所以即使两个 provider 指向同一个上游主机,也不可能互相串用对方的代理。
71
- 2. **流的惰性消费必须被处理**:adapter 的 HTTP 调用通常发生在后续的 `next()` 里,那时 `als.run()` 早已返回。所以每次迭代器的 `next/return/throw` 都会被重新包进作用域里 —— 这正是旧版实现在这里踩过的坑,仓库里有对应的回归测试。
72
-
73
- 代价是这一个全局 `fetch` 包装:作用域之外的行为与原生完全一致,作用域之内若调用方已经自带 `dispatcher` 也不会被覆盖。
74
-
75
- ## 已知限制
76
-
77
- - **SOCKS5 未实现。** `undici` 的 `ProxyAgent` 只会用 HTTP CONNECT 连 HTTP 代理,对 SOCKS5 需要另写连接器。界面里会明确提示,而不是悄悄按 HTTP 路由。
78
- - **上游若绕过 `globalThis.fetch` 自行建连,则不受影响。** `llm-deepseek` 确认是调用时解析全局 `fetch`;`llm-pi-ai` 走外部 SDK,抓取 `fetch` 的时机未能在源码层面确证。
79
- - **不支持 PAC**,也不读取系统代理设置。
80
- - **不改写 `baseURL`** —— 反向代理/中转请直接在 provider 上配置。
81
- - **代理不可达时**请求照常失败,DSH 既有的重试与错误码语义不变。
82
- - 代理密码不进 `cordis.patch.yml`:配置里只存凭据引用名,密码本身走 credentials 服务。
83
- - **代理用户名可用,密码尚未接入。** `passwordRef` 字段已预留并默认留空,UI 上只提供用户名输入框。
84
-
85
- ## 如何验证代理确实生效
86
-
87
- 插件本身不显示出口 IP。可靠的判断方式是在代理端看日志,或临时把端口改成
88
- 一个没人监听的端口——如果请求失败并报 `ECONNREFUSED 127.0.0.1:<该端口>`,
89
- 就说明请求确实是被代理承载的(直连时报错会指向目标站点)。
90
-
91
- 仓库里的 `tests/proxy-routing-proof.test.ts` 就是用这个思路做的:起一个会记录
92
- 连接的假代理,断言「启用的路由收到 1 次连接、未配置的 provider 收到 0 次」。
93
-
94
- ## 开发
95
-
96
- ```sh
97
- npm run build # 构建 lib/index.js 与 lib/client.js
98
- npm test # 69 个用例,跑的是构建产物
99
- npm run typecheck # 类型检查(仅覆盖 src/)
100
- npm run verify # 上面三件事,按顺序
101
- ```
102
-
103
- `lib/` 是构建产物,不入库;包上的 `prepare` 脚本会在 `npm publish` 以及从 git
104
- 地址安装时自动重建。直接 `dsh plugin add <克隆目录>` 前请先执行
105
- `npm install && npm run build`。
106
-
107
- 浏览器半必须输出 client module system 的 lazy-CJS factory 格式(脚本本身只注册工厂,所有副作用都在工厂闭包里执行),`scripts/build.mjs` 负责这件事,格式与 DSH 自带插件一致。
108
-
109
- ## 发布
110
-
111
- ```sh
112
- npm run verify # 先确认全绿
113
- npm version patch # 0.2.0 -> 0.2.1
114
- npm publish
115
- git push --follow-tags
116
- ```
1
+ # dsh-provider-proxy [![DSH Market](https://raw.githubusercontent.com/2BingLing/dsh-market/master/assets/readme/badge-listed-zh.svg)](https://dsh.market/)
2
+
3
+ 给 **DeepSeek Harness**(`0.2.0-rc.1` 及以上)用的插件:在 **设置** 里为**每个 provider 单独配置**「这个 provider 要不要走代理」,填写代理类型 + IP/端口。只有被你打开开关的 provider 走代理,其余 provider 和 DSH 的其它流量保持原来的直连路径。
4
+
5
+ 支持官方桌面版(DSH Desktop)与 `dsh web`。
6
+
7
+ ## 安装
8
+
9
+ 先确保已构建过产物(`lib/index.js`、`lib/client.js`):
10
+
11
+ ```sh
12
+ npm install
13
+ npm run build
14
+ ```
15
+
16
+ 桌面版(**必须先完整打开一次桌面版以初始化 profile,然后完全退出**,否则安装命令会拒绝执行):
17
+
18
+ ```sh
19
+ dsh plugin --profile desktop add dsh-provider-proxy
20
+ ```
21
+
22
+ Web:
23
+
24
+ ```sh
25
+ dsh plugin --profile web add dsh-provider-proxy
26
+ ```
27
+
28
+ 安装后重启对应应用。设置项写在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`。
29
+
30
+ ## 使用
31
+
32
+ 打开 **设置**,左侧导航里会出现一项 **Provider 代理**(英文界面为 *Provider Proxy*)。
33
+ 页面里每个 provider 一张卡片,各自独立配置:
34
+
35
+ | 字段 | 说明 |
36
+ | --- | --- |
37
+ | 开关 | 该 provider 是否走代理 |
38
+ | 类型 | `HTTP` / `HTTPS` / `SOCKS5` |
39
+ | IP / 主机 | 只填主机名或 IP,不要带 `http://` 或路径 |
40
+ | 端口 | 1–65535 的整数 |
41
+ | 用户名 | 可选 |
42
+ | 密码 | 可选。作为代理认证口令发给代理(`HTTP` / `HTTPS` / `SOCKS5` 均支持) |
43
+
44
+ 列表只包含**已激活**的 provider(`llm.listProviders()` 能列出的那些)。仅在配置里声明、
45
+ 但没有实际注册的可配置 provider 不显示——它的请求根本不会被发出去。
46
+ 已经在 `providers` 里配过代理的条目即使暂时不激活也仍然保留,避免已保存的设置悄悄消失。
47
+
48
+ 保存立即对新请求生效,不需要重启;正在进行中的流会继续用它启动时的那条连接。
49
+
50
+ 每张卡片在**该 provider 已启用代理**时还有一个 **测试** 按钮:点它会让 Host 通过**该 provider 自己的代理**发一次请求,回报延迟与(探测目标返回时)出口 IP。探测目标默认是 `https://api.ipify.org?format=json`,可用顶层设置 `testUrl` 换成自建的回显服务。代理不可达时它会明确失败,而不是假通过。延迟数值按快慢着色——≤500 ms 绿、≤1500 ms 黄、更慢红——但数值始终照常显示,颜色只是辅助。
51
+
52
+ 卡片里还有一个折叠的 **高级:目标域名规则**,按**目标主机**决定是否走代理:
53
+
54
+ | 字段 | 说明 |
55
+ | --- | --- |
56
+ | `仅走代理`(`onlyHosts`) | 逗号或空格分隔。非空时,只有匹配这些主机的请求走代理,其余直连。 |
57
+ | `直连`(`bypassHosts`) | 逗号或空格分隔。匹配这些主机的请求直连,优先于 `onlyHosts`。 |
58
+
59
+ 两者都按后缀匹配(`example.com` 覆盖 `api.example.com`),`*` 表示全部,大小写不敏感。**默认是 fail-closed**:启用了代理的路由默认走代理,只有「`bypassHosts` 命中」与「`onlyHosts` 非空且未命中」两种显式例外才直连;解析不出目标主机时**仍走代理**,避免畸形 URL 把带凭据的请求悄悄直连出去。
60
+
61
+ 当组成里存在 web 服务时,列表还会多出一张 **Web 搜索** 卡片(保留 id `web-search`,字段与其它卡片完全相同,也支持自检、计数与域名规则)。它承载 `web_search` 工具的流量;不启用它,搜索保持直连。
62
+
63
+ > **为什么是独立设置页,而不是内嵌在 Models 的 provider 卡片里?**
64
+ > 本插件最早按卡片内嵌(`settings.models.provider-card`)实现,但那个槽位是 keyed 的、
65
+ > 按 provider 命名空间派发,第三方注册在 0.2.0-rc.1 上始终匹配不上——不报错、不发请求、
66
+ > 界面上什么都不出现。独立分区(`settings.section`)是同一 profile 下
67
+ > `dsh-llm-retry-settings` 已被验证可用的机制。
68
+
69
+ ## 它是怎么工作的
70
+
71
+ 0.2.0 没有「给单个 provider 注入 transport」的官方口子,这是核对过源码的结论:
72
+
73
+ - `llm-deepseek` 直接调用全局 `fetch`,请求参数里没有 `dispatcher`;
74
+ - `llm-pi-ai` 走 pi-ai SDK 自己的 HTTP,DSH 无法配置;
75
+ - `LlmCallControls` 只有 `reasoningEffort` / `temperature` / `maxTokens` / `stop`;
76
+ - `llm/stream` 拦截的是**已解码的** `StreamChunk`,发生在 HTTP 之后;
77
+ - `ctx.web` 只服务 `web_search` / `web_fetch`,与 provider 流量无关;
78
+ - 官方的 `@deepseek-ai/dsh-http-proxy` 是**库**不是插件:`installGlobalProxy` 是模块私有函数,没有 service、没有 Config、只有从环境变量解析出的一份进程级策略。
79
+
80
+ 所以请求在唯一还能拿到线索的地方被路由:`llm/stream` waterfall 的 `options.provider`。用一个 `AsyncLocalStorage` 把选中的 dispatcher 带到流上,再由一层很薄的 `globalThis.fetch` 包装把它交给 fetch。
81
+
82
+ 两个关键点:
83
+
84
+ 1. **每个 provider 各自持有一个 dispatcher**(而不是共用一个),所以即使两个 provider 指向同一个上游主机,也不可能互相串用对方的代理。
85
+ 2. **流的惰性消费必须被处理**:adapter 的 HTTP 调用通常发生在后续的 `next()` 里,那时 `als.run()` 早已返回。所以每次迭代器的 `next/return/throw` 都会被重新包进作用域里 —— 这正是旧版实现在这里踩过的坑,仓库里有对应的回归测试。
86
+
87
+ 代价是这一个全局 `fetch` 包装:作用域之外的行为与原生完全一致,作用域之内若调用方已经自带 `dispatcher` 也不会被覆盖。
88
+
89
+ ## 已知限制
90
+
91
+ - **SOCKS5 由 `undici` 内置的 `Socks5ProxyAgent` 实现**(RFC 1928 / RFC 1929),API 稳定性标记为 *Experimental*;`undici` 若在未来移除,需要同步调整。
92
+ - **代理认证字段的成对规则。** SOCKS5 下填了用户名就必须填密码(`Socks5ProxyAgent` 只在两者齐备时才发起认证;只填用户名会被拒绝并给出提示,而不是静默降级为无认证);HTTP/HTTPS 下填了密码就必须填用户名(基本认证无法只发密码)。不合规则的配置会被拒绝并在「连接诊断」里说明,而不是被静默忽略——否则在强制认证的代理端,报错会指向代理而真正原因是设置缺项。HTTP/HTTPS 下只填用户名仍照常工作。
93
+ - **SOCKS5 不支持 `socks://` 别名与 SOCKS5 over TLS**(`proxyTls`)。
94
+ - **`bypassHosts` 命中时由本机解析并连接目标**(DNS 不经过代理)。SOCKS5 下这一点后果最明显:命中的域名不再由代理端解析,本机 DNS 会暴露该次查询。
95
+ - **`web_search` 可以走代理,`web_fetch` 不行。** 搜索 provider(`dsh-web-search-deepseek`)用全局 `fetch`,插件通过包裹 `ctx.web.search` 把它送进代理作用域;这条成立的前提是:工具每次调用都重新取 `ctx.web.search`,且 provider 的 `search()` 是 await-to-completion(不是惰性消费的流)。`web_fetch` 则用 `undici` 自带的 `dispatcher`(来自官方的进程级 env 策略),没有 per-provider 注入点,本插件覆盖不到——需要覆盖它请改用 `HTTP_PROXY` 之类的环境变量。
96
+ - **保留 id `web-search`。** 它的路由存在 `providers` 字典里;真实 provider 若恰好撞名,会与该路由共用同一份配置,无害。
97
+ - **上游若绕过 `globalThis.fetch` 自行建连,则不受影响。** `llm-deepseek` 确认是调用时解析全局 `fetch`;`llm-pi-ai` 走外部 SDK,抓取 `fetch` 的时机未能在源码层面确证。
98
+ - **不支持 PAC**,也不读取系统代理设置。
99
+ - **不改写 `baseURL`** —— 反向代理/中转请直接在 provider 上配置。
100
+ - **代理不可达时**请求照常失败,DSH 既有的重试与错误码语义不变。
101
+ - **连接自检依赖 `testUrl` 可达。** 默认的回显主机在部分网络下可能不可达,会让自检误报失败;请通过 `testUrl` 换成自建回显服务。自检只接受 `POST`,同一 provider 的多次点击会共享同一次在飞请求,不会被当成外呼放大器。
102
+ - **代理密码以明文随设置写入 `cordis.patch.yml`。** HTTP/HTTPS 的密码会拼进交给 `undici` 的代理 URL(`ProxyAgent` 由此生成 `Proxy-Authorization`),SOCKS5 的密码只作为选项传给 `Socks5ProxyAgent`;两者都不会出现在日志或诊断里的代理 URL——那里只显示不含凭据的 origin。
103
+
104
+ ## 如何验证代理确实生效
105
+
106
+ 插件本身不显示出口 IP。可靠的判断方式是在代理端看日志,或临时把端口改成
107
+ 一个没人监听的端口——如果请求失败并报 `ECONNREFUSED 127.0.0.1:<该端口>`,
108
+ 就说明请求确实是被代理承载的(直连时报错会指向目标站点)。
109
+
110
+ 仓库里的 `tests/proxy-routing-proof.test.ts` 就是用这个思路做的:起一个会记录
111
+ 连接的假代理,断言「启用的路由收到 1 次连接、未配置的 provider 收到 0 次」。
112
+
113
+ ## 开发
114
+
115
+ ```sh
116
+ npm run build # 构建 lib/index.js 与 lib/client.js
117
+ npm test # 82 个用例,跑的是构建产物
118
+ npm run typecheck # 类型检查(仅覆盖 src/)
119
+ npm run verify # 上面三件事,按顺序
120
+ ```
121
+
122
+ `lib/` 是构建产物,不入库;包上的 `prepare` 脚本会在 `npm publish` 以及从 git
123
+ 地址安装时自动重建。直接 `dsh plugin add <克隆目录>` 前请先执行
124
+ `npm install && npm run build`。
125
+
126
+ 浏览器半必须输出 client module system 的 lazy-CJS factory 格式(脚本本身只注册工厂,所有副作用都在工厂闭包里执行),`scripts/build.mjs` 负责这件事,格式与 DSH 自带插件一致。
127
+
128
+ ## 发布
129
+
130
+ ```sh
131
+ npm run verify # 先确认全绿
132
+ npm version patch # 0.2.0 -> 0.2.1
133
+ npm publish
134
+ git push --follow-tags
135
+ ```
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
- // Loader compatibility shim.
2
- //
3
- // cordis-plugin-loader resolves an entry as "<package>/index.js" by convention
4
- // and does NOT read package.json `main`/`exports`, so a package installed into
5
- // a profile must expose this file. The implementation lives in ./lib/index.js.
1
+ // Loader compatibility shim.
2
+ //
3
+ // cordis-plugin-loader resolves an entry as "<package>/index.js" by convention
4
+ // and does NOT read package.json `main`/`exports`, so a package installed into
5
+ // a profile must expose this file. The implementation lives in ./lib/index.js.
6
6
  export * from './lib/index.js'
package/lib/client.js CHANGED
@@ -159,18 +159,45 @@ function knownProviders(configured, published) {
159
159
  return [...rows.values()].sort(byName);
160
160
  }
161
161
  var CATALOG_PATH = "/dsh-provider-proxy/providers";
162
+ var TEST_PATH = "/dsh-provider-proxy/test";
163
+ var LATENCY_FAST_MS = 500;
164
+ var LATENCY_SLOW_MS = 1500;
165
+ function latencyColor(ms) {
166
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return "var(--dsw-alias-label-secondary)";
167
+ if (ms <= LATENCY_FAST_MS) return "var(--dsw-alias-state-success-primary, #2ea043)";
168
+ if (ms <= LATENCY_SLOW_MS) return "var(--dsw-alias-state-warn-primary, #d29922)";
169
+ return "var(--dsw-alias-state-error-primary, #f85149)";
170
+ }
171
+ function testResultFrom(body, strings) {
172
+ const ok = body?.ok === true;
173
+ return {
174
+ pending: false,
175
+ ok,
176
+ ms: typeof body?.ms === "number" && Number.isFinite(body.ms) ? body.ms : void 0,
177
+ ip: typeof body?.exitIp === "string" && body.exitIp.length > 0 ? body.exitIp : void 0,
178
+ error: ok ? void 0 : typeof body?.error === "string" && body.error.length > 0 ? body.error : strings.testFailed
179
+ };
180
+ }
162
181
  var catalogueSetter = () => {
163
182
  };
164
- async function fetchProviders() {
183
+ async function fetchCatalog() {
165
184
  const response = await globalThis.fetch(CATALOG_PATH, { headers: { accept: "application/json" } });
166
185
  if (!response.ok) throw new Error(`Host answered ${response.status}`);
167
186
  const body = await response.json();
168
187
  const list = Array.isArray(body?.providers) ? body.providers : [];
169
- return list.filter((row) => typeof row?.id === "string" && row.id.length > 0).map((row) => ({
188
+ const rows = list.filter((row) => typeof row?.id === "string" && row.id.length > 0).map((row) => ({
170
189
  id: row.id,
171
190
  name: typeof row.name === "string" && row.name.length > 0 ? row.name : row.id,
172
191
  live: row.live === true
173
192
  }));
193
+ const hits = {};
194
+ const published = body?.hits;
195
+ if (published !== null && typeof published === "object") {
196
+ for (const [id, count] of Object.entries(published)) {
197
+ if (typeof count === "number" && Number.isFinite(count)) hits[id] = count;
198
+ }
199
+ }
200
+ return { rows, hits };
174
201
  }
175
202
  var byName = (a, b) => a.name.localeCompare(b.name);
176
203
  function view(route) {
@@ -180,7 +207,9 @@ function view(route) {
180
207
  host: route?.host ?? "",
181
208
  port: route?.port ?? 0,
182
209
  username: route?.username ?? "",
183
- passwordRef: route?.passwordRef ?? ""
210
+ password: route?.password ?? "",
211
+ onlyHosts: route?.onlyHosts ?? "",
212
+ bypassHosts: route?.bypassHosts ?? ""
184
213
  };
185
214
  }
186
215
  var diagnostics = [];
@@ -232,6 +261,42 @@ var S = {
232
261
  fontSize: "11px",
233
262
  color: "var(--dsw-alias-label-secondary)"
234
263
  },
264
+ hits: {
265
+ flex: "none",
266
+ fontSize: "11px",
267
+ color: "var(--dsw-alias-label-secondary)",
268
+ whiteSpace: "nowrap"
269
+ },
270
+ testRow: {
271
+ display: "flex",
272
+ alignItems: "center",
273
+ gap: "10px",
274
+ flexWrap: "wrap"
275
+ },
276
+ testButton: {
277
+ padding: "5px 12px",
278
+ border: "1px solid var(--dsw-alias-border-l1)",
279
+ borderRadius: "6px",
280
+ background: "var(--dsw-alias-bg-layer-1)",
281
+ color: "var(--dsw-alias-label-primary)",
282
+ font: "inherit",
283
+ fontSize: "12px",
284
+ cursor: "pointer"
285
+ },
286
+ testResult: {
287
+ fontSize: "11px",
288
+ color: "var(--dsw-alias-label-secondary)",
289
+ wordBreak: "break-all"
290
+ },
291
+ advanced: {
292
+ marginTop: "4px",
293
+ fontSize: "12px"
294
+ },
295
+ advancedSummary: {
296
+ cursor: "pointer",
297
+ fontSize: "11px",
298
+ color: "var(--dsw-alias-label-secondary)"
299
+ },
235
300
  pill: {
236
301
  flex: "none",
237
302
  padding: "2px 10px",
@@ -296,20 +361,46 @@ function renderPage(ctx) {
296
361
  );
297
362
  const [state, setState] = (React.useState ?? ((fn) => [fn(), () => {
298
363
  }]))(() => {
299
- void fetchProviders().then((rows2) => catalogueSetter?.(rows2)).catch((error) => {
364
+ void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch((error) => {
300
365
  note(`provider \u5217\u8868\u83B7\u53D6\u5931\u8D25\uFF1A${error?.message ?? error}`);
301
- catalogueSetter?.([]);
366
+ catalogueSetter?.({ rows: [], hits: {} });
302
367
  });
303
- return { rows: void 0, pending: true };
368
+ return { rows: void 0, hits: {}, pending: true };
304
369
  });
305
- catalogueSetter = (rows2) => setState({ rows: rows2, pending: false });
370
+ catalogueSetter = (catalog) => setState({ rows: catalog.rows, hits: catalog.hits, pending: false });
306
371
  const published = state.rows ?? [];
372
+ const hits = state.hits ?? {};
307
373
  note(
308
374
  `provider \u5217\u8868\uFF1A\u6765\u81EA Host \u5171 ${published.length} \u4E2A\uFF08${published.map((r) => r.id).join("\u3001") || "\u65E0"}\uFF09`
309
375
  );
310
376
  const [draft, setDraft] = (React.useState ?? ((v) => [v, () => {
311
377
  }]))({});
312
378
  const edit = (id, field2, value) => setDraft((current) => ({ ...current, [id]: { ...current[id], [field2]: value } }));
379
+ const [tests, setTests] = (React.useState ?? ((v) => [v, () => {
380
+ }]))({});
381
+ const runTest = (id) => {
382
+ setTests((current) => ({ ...current, [id]: { pending: true } }));
383
+ void (async () => {
384
+ try {
385
+ const response = await globalThis.fetch(TEST_PATH, {
386
+ method: "POST",
387
+ headers: { "content-type": "application/json", accept: "application/json" },
388
+ body: JSON.stringify({ provider: id })
389
+ });
390
+ const body2 = await response.json();
391
+ setTests((current) => ({ ...current, [id]: testResultFrom(body2, t) }));
392
+ if (body2?.ok === true) {
393
+ void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch(() => {
394
+ });
395
+ }
396
+ } catch (error) {
397
+ setTests((current) => ({
398
+ ...current,
399
+ [id]: { pending: false, ok: false, error: String(error?.message ?? error) }
400
+ }));
401
+ }
402
+ })();
403
+ };
313
404
  const rows = knownProviders({ ...configured, ...draft }, published);
314
405
  const save = (id, field2, value) => {
315
406
  void writeConfig(ctx, [{ op: "set", path: ["providers", id, field2], value }]).then(() => {
@@ -359,6 +450,7 @@ function renderPage(ctx) {
359
450
  ] : rows.map((row) => {
360
451
  const route = view({ ...configured[row.id], ...draft[row.id] });
361
452
  const disabled = !route.enabled;
453
+ const result = tests[row.id];
362
454
  return h(
363
455
  "section",
364
456
  {
@@ -373,7 +465,14 @@ function renderPage(ctx) {
373
465
  "div",
374
466
  { className: import_client.default.cardTitles, style: S.titles },
375
467
  h("span", { className: import_client.default.name, style: S.name }, row.name),
376
- h("span", { className: import_client.default.id, style: S.id }, row.id)
468
+ h("span", { className: import_client.default.id, style: S.id }, row.id),
469
+ // A disabled route can never have hits, so "0" there would only add
470
+ // noise; the count shows once the route is on (or has counted).
471
+ route.enabled || (hits[row.id] ?? 0) > 0 ? h(
472
+ "span",
473
+ { className: import_client.default.hits, style: S.hits },
474
+ `${t.hitCountPrefix}${hits[row.id] ?? 0}${t.hitCountSuffix}`
475
+ ) : null
377
476
  ),
378
477
  h(
379
478
  "span",
@@ -412,7 +511,7 @@ function renderPage(ctx) {
412
511
  (protocol) => h("option", { key: protocol, value: protocol }, protocol.toUpperCase())
413
512
  )
414
513
  ),
415
- route.protocol === "socks5" ? t.socks5Unsupported : void 0
514
+ route.protocol === "socks5" ? t.socks5Hint : void 0
416
515
  ),
417
516
  field(
418
517
  t.host,
@@ -453,6 +552,85 @@ function renderPage(ctx) {
453
552
  value: route.username,
454
553
  onChange: (event) => bind(row.id, "username")(event.target.value)
455
554
  })
555
+ ),
556
+ // Every transport accepts a password now: SOCKS5 hands it to
557
+ // Socks5ProxyAgent as an option, HTTP/HTTPS embed it in the proxy URL
558
+ // userinfo (undici turns that into Proxy-Authorization). It is stored on
559
+ // the route, so it lands in the settings file with the rest of the
560
+ // config; the diagnostics only ever surface a credential-free origin.
561
+ field(
562
+ t.password,
563
+ h("input", {
564
+ className: import_client.default.input,
565
+ style: S.input,
566
+ disabled,
567
+ type: "password",
568
+ autocomplete: "off",
569
+ value: route.password,
570
+ onChange: (event) => bind(row.id, "password")(event.target.value)
571
+ }),
572
+ route.protocol === "socks5" ? t.passwordHint : t.passwordHintProxy
573
+ ),
574
+ // The self-check only makes sense for a route that is ON; a disabled
575
+ // route is not in the Host's table, so there is nothing to test.
576
+ route.enabled ? h(
577
+ "div",
578
+ { className: import_client.default.testRow, style: S.testRow, key: `${row.id}:test` },
579
+ h(
580
+ "button",
581
+ {
582
+ className: import_client.default.testButton,
583
+ style: S.testButton,
584
+ type: "button",
585
+ disabled: result?.pending === true,
586
+ onClick: () => runTest(row.id)
587
+ },
588
+ result?.pending === true ? t.testRunning : t.testButton
589
+ ),
590
+ result?.pending === false ? h(
591
+ "span",
592
+ { className: import_client.default.testResult, style: S.testResult },
593
+ result.ok === true ? [
594
+ // Only the latency carries the band colour; the exit IP
595
+ // stays in the secondary label colour.
596
+ h(
597
+ "span",
598
+ { key: "lat", style: { color: latencyColor(result.ms) } },
599
+ `\u2713 ${typeof result.ms === "number" ? result.ms : "\u2014"} ms`
600
+ ),
601
+ result.ip === void 0 ? null : ` \xB7 ${t.exitIpLabel}${result.ip}`
602
+ ] : `\u2717 ${result.error}`
603
+ ) : null
604
+ ) : null,
605
+ // Target-host rules, collapsed so the common case stays one short card.
606
+ h(
607
+ "details",
608
+ { className: import_client.default.advanced, style: S.advanced, key: `${row.id}:rules` },
609
+ h("summary", { className: import_client.default.advancedSummary, style: S.advancedSummary }, t.advanced),
610
+ field(
611
+ t.onlyHosts,
612
+ h("input", {
613
+ className: import_client.default.input,
614
+ style: S.input,
615
+ disabled,
616
+ placeholder: "api.example.com",
617
+ value: route.onlyHosts,
618
+ onChange: (event) => bind(row.id, "onlyHosts")(event.target.value)
619
+ }),
620
+ t.onlyHostsHint
621
+ ),
622
+ field(
623
+ t.bypassHosts,
624
+ h("input", {
625
+ className: import_client.default.input,
626
+ style: S.input,
627
+ disabled,
628
+ placeholder: "localhost",
629
+ value: route.bypassHosts,
630
+ onChange: (event) => bind(row.id, "bypassHosts")(event.target.value)
631
+ }),
632
+ t.bypassHostsHint
633
+ )
456
634
  )
457
635
  );
458
636
  });
@@ -492,8 +670,22 @@ var EN = {
492
670
  host: "IP / host",
493
671
  port: "Port",
494
672
  username: "Username",
673
+ password: "Password",
495
674
  hostHint: "Host or IP only, without scheme or path.",
496
- socks5Unsupported: "SOCKS5 is not implemented yet; use HTTP or HTTPS.",
675
+ passwordHint: "Sent to the SOCKS5 proxy for authentication; stored with this setting.",
676
+ passwordHintProxy: "Sent to the proxy as an authentication password; stored with this setting.",
677
+ hitCountPrefix: "",
678
+ hitCountSuffix: " proxied",
679
+ testButton: "Test",
680
+ testRunning: "Testing\u2026",
681
+ testFailed: "Test failed",
682
+ exitIpLabel: "exit IP ",
683
+ advanced: "Advanced: target host rules",
684
+ onlyHosts: "Proxy only",
685
+ onlyHostsHint: "Comma/space separated; when set, only requests to these hosts are proxied (suffix match, * for all).",
686
+ bypassHosts: "Bypass",
687
+ bypassHostsHint: "Comma/space separated; requests to these hosts go direct, taking priority.",
688
+ socks5Hint: "SOCKS5 tunnels the connection; the proxy resolves the target host name.",
497
689
  on: "proxied",
498
690
  live: "active",
499
691
  off: "direct",
@@ -508,8 +700,22 @@ var ZH = {
508
700
  host: "IP / \u4E3B\u673A",
509
701
  port: "\u7AEF\u53E3",
510
702
  username: "\u7528\u6237\u540D",
703
+ password: "\u5BC6\u7801",
511
704
  hostHint: "\u53EA\u586B\u4E3B\u673A\u540D\u6216 IP\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\u3002",
512
- socks5Unsupported: "SOCKS5 \u5C1A\u672A\u5B9E\u73B0\uFF0C\u8BF7\u6539\u7528 HTTP \u6216 HTTPS\u3002",
705
+ passwordHint: "\u4EC5\u4F5C\u4E3A SOCKS5 \u8BA4\u8BC1\u53E3\u4EE4\u53D1\u9001\u7ED9\u4EE3\u7406\uFF1B\u968F\u8BE5\u8BBE\u7F6E\u4E00\u8D77\u4FDD\u5B58\u3002",
706
+ passwordHintProxy: "\u4F5C\u4E3A\u4EE3\u7406\u8BA4\u8BC1\u53E3\u4EE4\u53D1\u9001\u7ED9\u4EE3\u7406\uFF1B\u968F\u8BE5\u8BBE\u7F6E\u4E00\u8D77\u4FDD\u5B58\u3002",
707
+ hitCountPrefix: "\u5DF2\u4EE3\u7406 ",
708
+ hitCountSuffix: " \u6B21",
709
+ testButton: "\u6D4B\u8BD5",
710
+ testRunning: "\u6D4B\u8BD5\u4E2D\u2026",
711
+ testFailed: "\u6D4B\u8BD5\u5931\u8D25",
712
+ exitIpLabel: "\u51FA\u53E3 IP ",
713
+ advanced: "\u9AD8\u7EA7\uFF1A\u76EE\u6807\u57DF\u540D\u89C4\u5219",
714
+ onlyHosts: "\u4EC5\u8D70\u4EE3\u7406",
715
+ onlyHostsHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u975E\u7A7A\u65F6\u53EA\u6709\u5339\u914D\u8FD9\u4E9B\u4E3B\u673A\u7684\u8BF7\u6C42\u8D70\u4EE3\u7406\uFF08\u540E\u7F00\u5339\u914D\uFF0C* \u8868\u793A\u5168\u90E8\uFF09\u3002",
716
+ bypassHosts: "\u76F4\u8FDE",
717
+ bypassHostsHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u5339\u914D\u8FD9\u4E9B\u4E3B\u673A\u7684\u8BF7\u6C42\u76F4\u8FDE\uFF0C\u4F18\u5148\u4E8E\u4E0A\u4E00\u9879\u3002",
718
+ socks5Hint: "SOCKS5 \u4F1A\u5EFA\u7ACB\u96A7\u9053\uFF0C\u76EE\u6807\u57DF\u540D\u7531\u4EE3\u7406\u7AEF\u89E3\u6790\u3002",
513
719
  on: "\u8D70\u4EE3\u7406",
514
720
  live: "\u542F\u7528\u4E2D",
515
721
  off: "\u76F4\u8FDE",