dsh-llm-net-router
给 DeepSeek Harness 补一层它自己表达不了的出网策略:按 Provider 选代理,按 Provider 选“我是谁”。
设置页位置:设置 → 网络路由(settings.section,order 72)。
零运行时依赖 · 只包装
globalThis.fetch· 卸载即恢复 · 规则文件原子落盘
目录
它解决的两件事
1. 代理策略是进程级的,但中转站不是一个一个来的
Harness 官方的 @deepseek-ai/dsh-http-proxy 从启动环境与 $DSH_HOME/.env 解析一份策略,
装成 undici 的全局 dispatcher(它自己的文档写明「不读取操作系统代理设置」,也说明策略只有一份)。
于是"中转站 A 必须走代理、中转站 B 必须直连、本地网关 C 必须别碰代理"这件事,
只能靠 NO_PROXY 打补丁——补丁是全局的、按地址的,不是按 Provider 的,
而且改一次要重启。
本插件按 request 的目标 host 决策,运行中随时改:
| 模式 | 含义 |
|---|---|
| 跟随全局 | 交回 Harness 原生策略(保持既有行为,最保守的默认值) |
| 强制直连 | 这条 host 不走任何代理 |
| 走代理 | 这条 host 走指定的 地址:端口(HTTP / HTTPS / SOCKS5 都支持) |
2. user-agent 在配置层改不掉,而中转站看的正是它
很多中转站(New API / one-api 系)在网关层挑客户端,请求头里既没有
originator: codex_cli_rs 也没有官方 CLI 的 UA 时直接回:
{"error":{"message":"unauthorized client detected, ..."},"type":"unauthorized_client_error"}
Harness 侧有两处天然做不到:
user-agent由dsh-llm的attributionHeaders()写死(deepseek-harness/<版本>), provider profile 里的同名字段会在requestHeaders()合并时被当作冲突过滤掉—— 配置里写headers: { user-agent: ... }是不生效的。originator之类的头虽然能通过 profileheaders加上,但要改 YAML、要重启, 而且没法按站点分别试。
本插件在传输边界解决:包装 globalThis.fetch(pi-ai 提供方栈与模型发现都在调用点解析它),
在"最终请求头已经拼完"之后改写它。不碰 provider 的 baseURL,不起本地中继端口,卸载即恢复。
界面
设置 → 网络路由
- 默认策略:没被任何 Provider 规则命中的地址怎么走。
- 代理池:本地代理地址 + 端口,可逐个"探测"(TCP 通不通 / 隧道能不能真的把请求送出去)。
- Provider 规则:每个已配置的 provider 一行—— 通路(跟随全局 / 强制直连 / 走代理)、代理地址、客户端指纹(不伪装 / Codex CLI / Claude Code / 自定义)、 「矩阵探测」按钮。 指纹选 Claude Code 时旁边会多出一个 1M 勾选项(只有 Claude Code 有,见下文「1M 上下文」)。
- 用真实推理端点校验(复选框):默认只探
GET /models; 勾上后改探真实的POST /chat/completions(max_tokens: 1)。 很多中转站的客户端校验只挂在这里,只探目录接口会得到"这个站不需要伪装"的假结论。 - 最近路由决策:只记录真正被本插件影响过的请求,含命中规则、通路、指纹、1M 标记、结果与耗时。
矩阵探测一次回答两个问题:这条地址在「直连 / 每个代理」×「不伪装 / Codex / Claude Code(/ Claude Code + 1M)」 里哪一种能过,以及过不去到底是被客户端校验拦下、被 1M 闸门拦下还是链路/密钥问题。结论可以一键"采纳并写入规则"(1M 勾选项会一起落盘)。探完会对选中的姿势复测一次,复测不过的推荐会被降级。
安装
要求:DeepSeek Harness 0.2.0-rc.2(dshTarget)、Node.js >= 18。插件零运行时依赖,不需要 npm install 装依赖。
方式 A:从 GitHub clone(推荐,便于更新)
$dshHome = $env:DSH_HOME; if (-not $dshHome) { $dshHome = "$env:USERPROFILE\.dsh" }
# 1) clone 到插件目录
git clone https://github.com/dyh886/dsh-llm-net-router.git "$dshHome\plugins\dsh-llm-net-router"
# 2) 挂到 profile 的 node_modules(junction,不需要管理员权限)
New-Item -ItemType Junction `
-Path "$dshHome\profiles\desktop\node_modules\dsh-llm-net-router" `
-Target "$dshHome\plugins\dsh-llm-net-router"
# 3) 在 profiles\desktop\package.json 里:
# - dependencies 加 "dsh-llm-net-router": "file:../../plugins/dsh-llm-net-router"
# - dsh.profile.bundles 加 "dsh-llm-net-router"
# 4) 重启 Harness(桌面端:托盘退出 → 重新启动)
方式 B:交给官方插件入口
dsh plugin --profile desktop add file:$dshHome/plugins/dsh-llm-net-router
# 或直接从 GitHub 安装
dsh plugin --profile desktop add github:dyh/dsh-llm-net-router
更新
git -C "$dshHome\plugins\dsh-llm-net-router" pull --ff-only
# 重启 Harness
卸载
从 dsh.profile.bundles 与 dependencies 里删掉那两行、删掉 junction、重启。规则文件($DSH_HOME/net-router/rules.json)可以留着,也可以直接删。
配置
profile 的 cordis.patch.yml 里,与其它插件行同级:
- id: llm-net-router
config:
enabled: true
defaultMode: inherit # inherit | direct | proxy
defaultProxy: '' # defaultMode=proxy 时用
proxyPool: # 界面下拉与"探测全部"的候选
- http://127.0.0.1:7890
- socks5://127.0.0.1:1080
probeTimeoutMs: 8000 # 目录接口探测的单行超时
inferenceTimeoutMs: 30000 # 推理端点探测的单行超时(真实推理要排队,8 秒必然误判)
probePath: '' # 覆盖探测路径
decisionLogSize: 60
rulesFile: 'net-router/rules.json'
providers: {} # 可选的声明式规则(见下)
界面里保存的规则写在 $DSH_HOME/net-router/rules.json
(先写临时文件再 rename 的原子替换;容忍 BOM,记事本手改过也还能读):
{
"version": 1,
"defaults": { "mode": "inherit", "proxy": "" },
"proxies": ["http://127.0.0.1:7890"],
"providers": {
"agentrouter": {
"enabled": true,
"match": ["agentrouter.org"],
"mode": "proxy",
"proxy": "http://127.0.0.1:7890",
"fingerprint": "codex",
"codexVersion": "",
"claudeVersion": "",
"headers": { "x-app": "cli" },
"note": ""
}
}
}
match 支持 example.com、.example.com、*.example.com、host:port、*;
命中优先级是"匹配串更长者优先",同长度按 provider id 字典序(结果稳定可复现)。
patch 里的 providers: 与文件里的规则会合并,文件优先——所以既能在 YAML 里做声明式种子,
也能在界面上随手改而不动 YAML。
指纹
| id | 发出去的头 |
|---|---|
none |
不改写(Harness 原生 attribution UA) |
codex |
originator: codex_cli_rs、user-agent: codex_cli_rs/<版本> (Windows 11; x86_64) |
claude-code |
user-agent: claude-cli/<版本> (external, cli)、x-app: cli、anthropic-dangerous-direct-browser-access: true;勾了 1M 再追加 anthropic-beta: context-1m-2025-08-07 |
custom |
界面里手写的任意头(JSON) |
codexVersion / claudeVersion 可以单独覆盖版本号;headers 里的键会覆盖内置值——
比如只想要 originator、不想换 UA,就把指纹设为 codex 再用 headers 把 user-agent 改回 Harness 自己的值。
1M 上下文(context1m)
症状:请求 1M 上下文的模型时上游回
{"error":"1m 上下文已经全量可用,请启用 1m 上下文后重试","type":"error"}
原因:这是中转站(anyrouter 系)在网关层做的闸门,判据是 anthropic-beta 里有没有
context-1m-2025-08-07。它和"客户端校验"(401 unauthorized client)是两回事:
| 现象 | 判据 | 修法 |
|---|---|---|
401 unauthorized client detected |
请求头里没有官方 CLI 的 UA / originator |
换指纹(Codex / Claude Code) |
| 400「请启用 1m 上下文」 | anthropic-beta 缺 context-1m-2025-08-07 |
勾上 Claude Code 旁边的 1M |
两个实测结论(anyrouter,2026-10):
- 改模型名没用。
claude --model 'xxx[1m]'里的[1m]是官方 CLI 自己剥掉的语法糖, CLI 看到后缀才会去加那个 beta;直接把[1m]写进请求的model字段,网关照样回 400。 - 只影响 1M 模型。
claude-haiku-4-5这类非 1M 模型带不带这个 beta 都是 200;sonnet-4-5/opus-4-7/fable-5-1-reversed不带就是 400。
实现上 context1m 是规则里的一个布尔字段,只在 fingerprint: claude-code 时生效
(其它指纹下这个 beta 没有可写的位置,界面也不会显示那个勾选项):
{
"anyrouter": {
"enabled": true,
"match": ["anyrouter.top"],
"mode": "proxy",
"proxy": "http://127.0.0.1:7890",
"fingerprint": "claude-code",
"context1m": true,
"headers": {},
"note": "1M 模型必须带 context-1m beta"
}
}
anthropic-beta 是多值累加的头:插件把「请求上原有的 beta」+「headers 里显式写的 beta」+
context-1m-2025-08-07 去重合并,而不是整个覆盖——否则打开 1M 会把 pi-ai 自己带的
thinking / tool-streaming beta 静默关掉,表现成"1M 能用了,别的功能莫名退化"。
命令行
# 驱动运行中的插件(复用界面里那份规则、凭据与代理池)
node scripts/probe-cli.mjs --api http://127.0.0.1:19387 --provider primerelay
node scripts/probe-cli.mjs --api http://127.0.0.1:19387 --provider primerelay --endpoint inference
node scripts/probe-cli.mjs --api http://127.0.0.1:19387 --state
# 独立探测(Harness 没跑着也行)
node scripts/probe-cli.mjs https://relay.example.com/v1 `
--api-key-ref RELAY_API_KEY --endpoint inference --model gpt-6-sol `
--proxies 127.0.0.1:7890,socks5://127.0.0.1:1080
--api-key-ref NAME 会去 $DSH_HOME/.credentials.yaml 的 refs: 段取值,不必把 key 贴进 shell 历史。
探测结果的读法
| 结论 | 含义 | 下一步 |
|---|---|---|
ok |
2xx | — |
client-gate |
响应体里有 unauthorized client 等标识 |
换指纹(Codex / Claude Code) |
context-gate |
400「请启用 1m 上下文」/ context-1m |
勾上 Claude Code 旁边的 1M |
auth |
401/403 但不是客户端校验 | 检查 apiKeyEnv 指向的 key |
model-error |
说模型不存在 | 换模型(命令行的 --model / 界面里的 provider 第一个模型) |
timeout |
未知,不等于被拦 | 调大 inferenceTimeoutMs 后复测 |
network-error |
连接层失败 | 检查代理地址、端口,或该地址是否该走另一个代理 |
http-error |
其它状态码 | 看细节里的上游响应体 |
两条刻意的判定原则(都是实测踩出来的):
- 超时不等于被拦。 慢站上 8 秒超时会把"不伪装"和"Codex"两行判成失败,
只剩第三行在 7.5 秒通过,于是得出"必须用 Claude Code 指纹"的假结论。
现在:只有观测到明确的
client-gate才把"必须伪装"说成结论,超时行只作为 caveat 提示。 - 同一通路上"有的行成功、有的行直接断连"会单独提示。 有些中转对特定指纹不返回 401,而是直接掐连接——那看起来像网络抖动,其实是拒绝。
- "指纹生效了、问题在上游"是一种独立结论。 不伪装被
unauthorized client拦下, 而带指纹的姿势拿到了另一种错误(400 协议不对、402 额度用完),说明客户端校验已经过了, 剩下的问题与伪装无关。工具会把上游原文带出来,而不是笼统地说"三种指纹都没放行"。 - 1M 闸门回的是 400,不是 401。 它看起来像"请求本身不合法",所以必须单独归一类
(
context-gate);否则会得出"三种指纹都没放行"的假结论,然后去改指纹——改多久都没用。
配置体检与匹配漂移
两个"探测全绿、调用照错"的坑,插件现在会主动说出来:
1. baseURL 里的空白(真实事故)
baseURL = "https://host/v1␠"(尾部多了个空格)时:
- pi-ai 用字符串拼接得到
https://host/v1␠/chat/completions,WHATWG 解析把空格编码成%20, 网关收到/v1 /chat/completions→404 {"message":"Invalid URL (POST /v1 /chat/completions)"}。 - 探测路径曾经是
baseURL.trim() + '/models',于是探测全绿、真实调用全炸。
现在两边共用同一条归一化(normalizeRequestUrl):把"紧贴 / 或位于路径末尾"的空白抹掉
(段内部的空白不动,那可能是真文件名),探测与真实请求走的是同一段代码。
同时界面会给出红字告警,指出配置本身该修——兜底是兜底,脏值仍然是脏值。
2. 规则与 host 的漂移
规则里的 match 是按当时的 host 生成的。用户把 baseURL 从
http://127.0.0.1:17890/v1 改成 https://agentrouter.org/v1 之后,旧规则会静默失效:
请求既没走代理也没带指纹,而界面上看起来"规则已经配好了"(症状特征:决策记录里 matched 一直是 0)。
现在 state 会为每个 provider 计算 rule.matchesCurrentHost / rule.stale,
界面显示"匹配已失效"并给一个同步匹配按钮(rules.syncHost);
apply.recommend 也会把当前 host 并进 match,所以重跑一次探测+采纳也能修好。
顺带说明一个设计边界:规则按 host 匹配,所以同一个 host 上的两个 provider 路由共享同一套 通路与指纹(同一站点本来就该用同一套身份)。需要不同协议时,用
api字段区分路由,而不是规则。
实现要点
| 文件 | 职责 |
|---|---|
lib/index.js |
插件入口:配置收敛、规则装载、安装拦截、注册 /llm-net-router/api |
lib/router.js |
globalThis.fetch 包装:按 host 匹配规则 → 改写头 + 选通路;决策环与计数 |
lib/httpclient.js |
无依赖传输层:直连 / HTTP(S) 代理(绝对 URI 与 CONNECT)/ SOCKS5,返回标准 Response |
lib/probe.js |
探测引擎:代理可用性 + 「通路 × 指纹」矩阵 + 推荐(含复测) |
lib/store.js |
规则归一化、匹配、原子持久化 |
lib/fingerprints.js |
指纹表与响应分类 |
lib/api.js |
宿主 API(界面唯一通道)+ provider 发现 + 凭据软解析 |
client.js |
浏览器半侧:window.__ModuleLoader__ bundle,注册 settings.section |
几个刻意的取舍:
- 不改全局 dispatcher,而是自带传输层。 插件包从 profile 的
node_modules解析, Harness 自身的undici在app.asar里,裸名 import 是ERR_MODULE_NOT_FOUND; 而"同一个进程里 A 站走代理 B 站直连"用全局 dispatcher 也表达不了。自己拿 socket,才能按 host 决策。 代价是这里要自己实现代理握手(已覆盖 HTTP(S) 与 SOCKS5,含认证、TLS、重定向、AbortSignal)。 - 只实现 HTTP/1.1。 模型 API 都是 HTTP/1.1 上的 SSE,不需要 h2 多路复用。
- 失败回退不丢伪装。 自有通路失败时回退原生 fetch,但改写后的头仍然保留。
- 决策日志不记 query。 只记
origin+path,避免把?api_key=之类的写进环形缓冲。 mode: inherit+ 指纹 = 与其它传输插件共存的方式。 这个组合下插件只改头、不接管传输, 因此可以和别的出网插件叠加使用(见下)。
已知边界
- FormData / Blob 请求体不承载(自有通路会回退原生 fetch);模型 API 用不到这两种形状。
- 不实现 HTTP/2、不实现 PAC、不实现 SOCKS4。 代理地址给
socks4://会报错而不是静默直连。 - 只管
globalThis.fetch。node:http/node:https直连的流量(例如遥测)不受影响。 - 界面 API 走同源校验:本机回环放行,非回环要求
Origin与Host同源。 - 同一进程里不要同时挂多个接管传输的插件。 本插件的安装/卸载只在自己仍是最外层包装时还原,
不会踩掉后来者;但两个都"接管 fetch"的插件叠加时,谁在谁外面会决定谁说了算。
与其它出网插件共存时,把本插件的规则设成
mode: inherit+ 需要的那套指纹。
开发与测试
npm run check # 所有 .js/.mjs 过一遍 node --check(零依赖)
npm run test:unit # 离线用例
npm test # 加上联网集成用例(没有代理时自动 skip)
# 联网集成:验证 HTTPS 经 HTTP 代理 CONNECT 再 TLS 这条真通路
$env:DSH_NET_ROUTER_IT = "1"
$env:DSH_NET_ROUTER_PROXY = "http://127.0.0.1:7890"
npm run test:integration
CI 覆盖 Node 18 / 20 / 22 × Ubuntu / Windows,并跑 npm pack --dry-run 检查发布内容。
离线用例覆盖:规则归一化/匹配/原子落盘(含 BOM 容忍)、直连与三种代理通路的真 socket 往返、
AbortSignal/重定向/认证、拦截器的改写与回退与幂等安装、探测分类与推荐的两条判定原则、
以及客户端 bundle 的加载契约与真实渲染(自带一个迷你 React,见 test/fixtures/mini-react.mjs)。
项目结构
dsh-llm-net-router/
├── lib/
│ ├── index.js # 插件入口:配置、规则合并、安装拦截、回环 API
│ ├── router.js # globalThis.fetch 包装与决策环
│ ├── httpclient.js # 零依赖传输层(直连 / HTTP(S) / SOCKS5)
│ ├── probe.js # 矩阵探测、推理端点、分类与推荐
│ ├── store.js # 规则归一化、host 匹配、原子持久化
│ ├── fingerprints.js # 指纹表与响应分类
│ └── api.js # 宿主 API + provider 发现 + 凭据软解析
├── client.js # 浏览器半侧:注册「设置 → 网络路由」
├── scripts/
│ ├── probe-cli.mjs # 命令行探测
│ └── check.mjs # 语法自检
├── test/ # node:test,零依赖;含迷你 React 渲染夹具
├── docs/ARCHITECTURE.md # 数据流、决策细节、探测分类、安全边界
├── examples/ # patch 与 rules.json 示例
├── cordis.patch.yml # 插件 bundle 声明与默认配置
└── package.json
更细的模块职责与请求生命周期见 docs/ARCHITECTURE.md。
贡献
见 CONTRIBUTING.md。安全问题的报告方式见 SECURITY.md。变更记录见 CHANGELOG.md。
许可证
MIT © 2026 dyh
No comments yet. Be the first to write one.