dsh-provider-proxy 0.3.0 → 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
@@ -38,8 +38,8 @@ dsh plugin --profile web add dsh-provider-proxy
38
38
  | 类型 | `HTTP` / `HTTPS` / `SOCKS5` |
39
39
  | IP / 主机 | 只填主机名或 IP,不要带 `http://` 或路径 |
40
40
  | 端口 | 1–65535 的整数 |
41
- | 用户名 | 可选(SOCKS5 用,需与密码同时填写) |
42
- | 密码 | 可选(SOCKS5 用)。作为 SOCKS5 认证口令发给代理 |
41
+ | 用户名 | 可选 |
42
+ | 密码 | 可选。作为代理认证口令发给代理(`HTTP` / `HTTPS` / `SOCKS5` 均支持) |
43
43
 
44
44
  列表只包含**已激活**的 provider(`llm.listProviders()` 能列出的那些)。仅在配置里声明、
45
45
  但没有实际注册的可配置 provider 不显示——它的请求根本不会被发出去。
@@ -47,6 +47,19 @@ dsh plugin --profile web add dsh-provider-proxy
47
47
 
48
48
  保存立即对新请求生效,不需要重启;正在进行中的流会继续用它启动时的那条连接。
49
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
+
50
63
  > **为什么是独立设置页,而不是内嵌在 Models 的 provider 卡片里?**
51
64
  > 本插件最早按卡片内嵌(`settings.models.provider-card`)实现,但那个槽位是 keyed 的、
52
65
  > 按 provider 命名空间派发,第三方注册在 0.2.0-rc.1 上始终匹配不上——不报错、不发请求、
@@ -76,14 +89,17 @@ dsh plugin --profile web add dsh-provider-proxy
76
89
  ## 已知限制
77
90
 
78
91
  - **SOCKS5 由 `undici` 内置的 `Socks5ProxyAgent` 实现**(RFC 1928 / RFC 1929),API 稳定性标记为 *Experimental*;`undici` 若在未来移除,需要同步调整。
79
- - **SOCKS5 的用户名必须与密码同时填写。** 只填用户名会被拒绝并给出提示,而不是静默降级为无认证——否则在强制认证的代理端,报错会指向代理而真正原因是设置缺项。
92
+ - **代理认证字段的成对规则。** SOCKS5 下填了用户名就必须填密码(`Socks5ProxyAgent` 只在两者齐备时才发起认证;只填用户名会被拒绝并给出提示,而不是静默降级为无认证);HTTP/HTTPS 下填了密码就必须填用户名(基本认证无法只发密码)。不合规则的配置会被拒绝并在「连接诊断」里说明,而不是被静默忽略——否则在强制认证的代理端,报错会指向代理而真正原因是设置缺项。HTTP/HTTPS 下只填用户名仍照常工作。
80
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 若恰好撞名,会与该路由共用同一份配置,无害。
81
97
  - **上游若绕过 `globalThis.fetch` 自行建连,则不受影响。** `llm-deepseek` 确认是调用时解析全局 `fetch`;`llm-pi-ai` 走外部 SDK,抓取 `fetch` 的时机未能在源码层面确证。
82
98
  - **不支持 PAC**,也不读取系统代理设置。
83
99
  - **不改写 `baseURL`** —— 反向代理/中转请直接在 provider 上配置。
84
100
  - **代理不可达时**请求照常失败,DSH 既有的重试与错误码语义不变。
85
- - **SOCKS5 代理密码以明文随设置写入 `cordis.patch.yml`。** 它只会作为选项传给 `Socks5ProxyAgent`,不会出现在日志或诊断报告里的代理 URL 中。
86
- - **HTTP/HTTPS 代理仍只支持用户名**,密码只对 SOCKS5 生效。
101
+ - **连接自检依赖 `testUrl` 可达。** 默认的回显主机在部分网络下可能不可达,会让自检误报失败;请通过 `testUrl` 换成自建回显服务。自检只接受 `POST`,同一 provider 的多次点击会共享同一次在飞请求,不会被当成外呼放大器。
102
+ - **代理密码以明文随设置写入 `cordis.patch.yml`。** HTTP/HTTPS 的密码会拼进交给 `undici` 的代理 URL(`ProxyAgent` 由此生成 `Proxy-Authorization`),SOCKS5 的密码只作为选项传给 `Socks5ProxyAgent`;两者都不会出现在日志或诊断里的代理 URL——那里只显示不含凭据的 origin。
87
103
 
88
104
  ## 如何验证代理确实生效
89
105
 
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
- password: route?.password ?? ""
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",
@@ -454,13 +553,12 @@ function renderPage(ctx) {
454
553
  onChange: (event) => bind(row.id, "username")(event.target.value)
455
554
  })
456
555
  ),
457
- // Only SOCKS5 authenticates in-band, so only SOCKS5 shows this field.
458
- //
459
- // The password is stored on the route and therefore written into the
460
- // settings file with the rest of the config. It is handed to
461
- // Socks5ProxyAgent as an option, never embedded in the proxy URL that
462
- // the diagnostics surface.
463
- route.protocol === "socks5" ? field(
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(
464
562
  t.password,
465
563
  h("input", {
466
564
  className: import_client.default.input,
@@ -471,8 +569,69 @@ function renderPage(ctx) {
471
569
  value: route.password,
472
570
  onChange: (event) => bind(row.id, "password")(event.target.value)
473
571
  }),
474
- t.passwordHint
475
- ) : void 0
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
+ )
634
+ )
476
635
  );
477
636
  });
478
637
  return h(
@@ -514,6 +673,18 @@ var EN = {
514
673
  password: "Password",
515
674
  hostHint: "Host or IP only, without scheme or path.",
516
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.",
517
688
  socks5Hint: "SOCKS5 tunnels the connection; the proxy resolves the target host name.",
518
689
  on: "proxied",
519
690
  live: "active",
@@ -532,6 +703,18 @@ var ZH = {
532
703
  password: "\u5BC6\u7801",
533
704
  hostHint: "\u53EA\u586B\u4E3B\u673A\u540D\u6216 IP\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\u3002",
534
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",
535
718
  socks5Hint: "SOCKS5 \u4F1A\u5EFA\u7ACB\u96A7\u9053\uFF0C\u76EE\u6807\u57DF\u540D\u7531\u4EE3\u7406\u7AEF\u89E3\u6790\u3002",
536
719
  on: "\u8D70\u4EE3\u7406",
537
720
  live: "\u542F\u7528\u4E2D",