dsh-zhixiaohang-guard
Manifest valid★ 1Zhixiaohang's Channel Protection
dsh-zhixiaohang-guard
一个宿主插件,承担两件事:
- A. 智小航专属节流:只对
provider = zhixiaohang(兜底按主机token.nuaa.edu.cn)做 10 次/分钟滑动窗口排队;其它 provider(如deepseek-account)零延迟直通。 - B. 校外乱码入路屏蔽:没连校园网/VPN 时网关返回的整页 HTML(含巨型 base64)在落盘之前被换成一句 <200 字符短提示;原文一个字节都不进会话记录、模型上下文与日志。
纯 JS、零依赖、单文件 ESM(index.mjs),不需要 python、不需要构建。
可观测性(v1.1.0:怎么确认插件真的激活了)
宿主主进程日志不落盘(%APPDATA%\@deepseek-ai\dsh-desktop\logs 只有崩溃日志),
dsh --profile desktop --dump-config 也会被 profile "desktop" is managed exclusively by the Electron application 拒绝——
所以从外面看不到任何加载记录。为此插件自带信号:
- 状态文件:挂载时写
<DSH_HOME>/zhixiaohang-guard/status.json(默认C:\Users\<你的用户名>\.dsh\zhixiaohang-guard\status.json),含mountedAt/pid/module(实际加载的文件路径)/listeners(两个钩子是否注册)/selfTest(自检结果)/stats/lastEvents。 - 只读状态页:http://127.0.0.1:19387/zhixiaohang-guard(JSON 在
/zhixiaohang-guard/api/status)。
重启后判断"到底装上了没有",只看两条:
- 状态文件存在,且
selfTest.ok === true、listeners['llm/stream'] === true、listeners['agent/request-error'] === true; module指向C:/Users/<你的用户名>/.dsh/plugins/zhixiaohang-guard/index.mjs(而不是工作区里的那份)。
selfTest 是插件在自己进程内拿一段"校外乱码"样本跑一遍真实净化路径:detectedOffCampus: true、
noticeChars: 97、noticeLeakFree: true —— 说明净化链路在该进程内确实工作,而不只是"文件被读进去了"。
计数器(stats / lastEvents)在命中时刷新:paced(节流排队次数)、sanitized(乱码屏蔽次数)、
claimed(认领次数)、emptyStreams / emptyStreamRetries、retryAfterEnriched。
为什么挂在 llm/stream(实测依据,不是猜的)
宿主 0.2.0-rc.2 的请求路径(源码位置均已核对):
agent loop step()
└─ preparedCall.stream(request) dsh-agent-loop/lib/index.js:1072
└─ LlmRuntime.streamWithRegistration()
└─ ctx.waterfall(this, 'llm/stream', …) dsh-llm/lib/index.js:2371 ← 本插件在这里
└─ adapterStream() → openai SDK → 网关
└─ live.push(chunk) → assistant/attempt 落盘 dsh-agent-loop/lib/index.js:1119-1123
└─ dispatch.waterfall('agent/request-error', …) dsh-agent-loop/lib/index.js:1124 ← 已经太晚
关键事实:
llm/stream是官方公开的 waterfall,签名(options, next) => AsyncIterable<StreamChunk>,官方 invariant 自己就这么注册(带{ global: true, prepend: true })。- 失败先落盘再进
agent/request-error:finish分片被assistant-stream原样记入紧凑流(dsh-llm/lib/types/assistant-stream.js:105)。所以只改request-error里的消息拦不住落盘——必须在llm/stream处就改。 - 跨 fiber 监听服务事件必须
global: true,否则被 context filter 丢掉(cordis/src/events.ts:116,173)。
校外乱码的两条真实形态
| 网关响应 | 走到哪里 | 表现 |
|---|---|---|
| 非 2xx + HTML | openai SDK 异常消息里装着整页 HTML | code = SERVER(5xx)或 AUTH(401/403);pi-ai 的 4000 字符上限不生效(它只对"body 未被 SDK 折进 message"的错误生效,见 pi-ai/dist/utils/error-body.js:23) |
| 200 + HTML | SSE 解析不出任何事件 | pi-ai 抛 Stream ended without finish_reason(34 字节)→ code = TRANSPORT;不带乱码,但用户只看到看不懂的英文 |
本机 4 个会话日志全量扫描(2026-10-10)实证:
| 失败 | 次数 | 消息长度 | 是否携乱码 |
|---|---|---|---|
SERVER | 50 | 最长 316,850 字节 | 是(PNG 魔数 + base64 + HTML + "仅限校内访问") |
TRANSPORT Stream ended without finish_reason | 60 | 34 字节 | 否 |
RATE_LIMIT 429 upstream_capacity_exhausted | 264 | 197 字节 | 否(含 retry_after_seconds) |
行为
节流(A)
- 滑动窗口(默认
rpm: 10, windowMs: 60000):窗口内满额时排队等待到有名额再发,不报错。 - 等待可取消:
signal中止时不再发起请求,改为产出协议里规范的abortedfinish。 - 队列打满(默认 512)时直接放行——宁可少节流,也不卡死会话。
- 非目标 provider 完全不进这个分支,不建定时器,延迟恒为 0。
屏蔽(B)
- 改写终止
finish.reason.failure:message→ 短提示,code→ZHIXIAOHANG_OFF_CAMPUS,其余字段(如status)保留。 - 提示 = 默认文案 +(能从原文清理出"仅限校内访问"整句时)附上该句,总长截到
maxNoticeChars。 - 同时在
agent/request-error用{ global: true, prepend: true }抢先认领该失败且不调用next(): 判定为"需要用户操作、不可重试",避免dsh-llm-error-retry按 429 规则做 60s×N 空转。 - 防御性兜底:若乱码以
text-delta/reasoning-delta正文形式出现(实测未出现),也会被换成短提示。
空白流(真机 60 次的那种)
- 默认先自动重发 1 次(仅当这一轮还没吐出任何内容时;已有正文则不重发,避免重复输出)。
- 重发成功 → 用户完全看不到错误;仍失败 → 换成中性短提示,保留
TRANSPORTcode。 - 与已装的
dsh-llm-finish-reason-tolerance互补不冲突:那个插件只把"已吐内容"的空白流提升为成功,一个内容都没吐时它明确放行错误——正是本插件处理的部分。
空流其实是"网关上游容量不足"的另一副面孔(v1.2.0,真机复现)
2026-10-10 直接对网关做流式复现(tools/probe-stream.mjs,4 次全中):
#1 429 {"message":"All upstream providers are cooling down. Please retry after 44 seconds.",
"type":"upstream_capacity_exhausted","retry_after_seconds":44,"code":"circuit.upstream_capacity_exhausted"}
#2 429 retry_after_seconds: 43 #3 429 :42 #4 429 :42
同一时刻 GET /v1/models(带 key)返回 200 + 23,877 字节模型表 —— 链路与鉴权都正常,是网关自己的上游在冷却。
也就是说:校外的 200+HTML 与上游容量的 200+空流是两种不同的东西,旧文案把后者说成"没连 VPN"是误导,v1.2.0 已改。
因此 v1.2.0 增加 emptyStreamAsRateLimit(默认开):只要本插件最近在 429 失败消息里解析到过 retry_after_seconds(窗口 capacityHintTtlMs,默认 180s),
就把空流改写成
code: RATE_LIMIT, providerRetryAfterMs: <服务端提示>, message: "429: 网关上游容量不足(由空白流识别,服务端提示约 42s)…"
于是这次失败会流回 dsh-llm-error-retry(本插件只在"校外乱码"时认领失败,RATE_LIMIT 一律放行),按冷却自动重试,而不是丢一句死胡同提示让人干等。
没有任何容量证据时(例如纯粹的流被掐断),才走"重发 1 次 → 中性提示"的老路。
429 的 retry_after_seconds 修复
网关把 retry_after_seconds 写在消息 JSON 里,宿主却没填进 failure.providerRetryAfterMs。本插件(honorRetryAfterSeconds: true,默认开)从消息里解析并补上该字段,让重试插件尊重服务端提示(封顶 1 小时)。
配置
写在 cordis.patch.yml 那一行的 config 里,全部有默认值:
- insert:
- id: zhixiaohang-guard
name: 'file:///C:/Users/<你的用户名>/.dsh/plugins/zhixiaohang-guard/index.mjs'
config:
pace: true
rpm: 10
windowMs: 60000
providers: [zhixiaohang]
hosts: [token.nuaa.edu.cn]
sanitize: true
notice: '⚠️ 智小航:当前不在校园网/VPN,校内系统拒绝访问。请连接校园 VPN 后重试。服务热线 (025)84890123'
reminderKeys: [仅限校内访问, 校内访问, 综合服务门户, 校园网]
weakReminderKeys: [VPN, vpn, 校外]
weakKeysRequireCorroboration: true
maxNoticeChars: 200
offCampusCode: ZHIXIAOHANG_OFF_CAMPUS
maxWaiters: 512
emptyStreamRetries: 1
emptyStreamBackoffMs: 1500
emptyStreamNotice: '⚠️ 智小航:网关流式响应被中断(未返回结束原因),已自动重试仍未恢复。若持续如此请稍后重试;若在校外请先连接校园 VPN。'
emptyStreamAsRateLimit: true
capacityHintTtlMs: 180000
honorRetryAfterSeconds: true
precheck: false
precheckTtlMs: 60000
forceIpv4: true
forceIpv4Hosts: [token.nuaa.edu.cn]
forceIpv4Mode: auto
forceIpv4Probe: true
强制 IPv4(v1.3.0)
需求是"调用智小航时让 DSH 强制走 IPv4"。踩点结论与实测:
- 请求通道:pi-ai 的
profileOptions()不注入自定义 fetch,openai SDK 用的是 Node 内置fetch→ 而宿主自带的dsh-http-proxy正是靠"替换 undici 全局派发器"来接管它(dsh-http-proxy/lib/index.js:403-450), 因为 Node 内置 fetch 读的是同一个Symbol.for('undici.globalDispatcher.1')。 - 因此本插件用同一套路:包一层派发器,只把
forceIpv4Hosts里的主机换成connect.family = 4的连接池, 其余主机原样dispatch给原派发器 —— 代理链路、其它 provider 全不受影响(dsh-http-proxy用自己的 Agent 时也照旧)。 - 有代理时(
HTTPS_PROXY等且未命中NO_PROXY)改走ProxyAgent,并让到代理的连接也走 IPv4,保留代理。 undici从 profile 的node_modules解析(插件目录里没有 node_modules);解析或安装失败时按forceIpv4Mode: auto退回dns.setDefaultResultOrder('ipv4first')+net.setDefaultAutoSelectFamily(false)(这一步是进程级、影响面更大,日志会写明)。
实测(node tools/test-ipv4.mjs,同端口同时监听 127.0.0.1 与 ::1):
| 观测 | 结果 |
|---|---|
安装前 http://localhost | 落在 IPv6(本机确实优先 IPv6) |
| 安装后同一请求 | 落在 IPv4 ✅ |
未列入名单的主机 / [::1] 直连 | 不受影响(IPv6 仍可用)✅ |
| 卸载后 | 恢复原派发器 ✅ |
token.nuaa.edu.cn 的 A / AAAA | 10.0.241.133 / 2001:da8:1006:1001::101(有 AAAA) |
token.nuaa.edu.cn 的 getaddrinfo(fetch 真正用的那条) | 只返回 IPv4 |
⚠️ 诚实的结论:机制已验证有效,但在当前网络状态下 getaddrinfo 对智小航只返回 IPv4,
所以对"今天的空流问题"它是无害的空操作(不是解药);当某天解析器同时给出 AAAA 时它才会真正起作用。
空流的根因仍是网关上游容量(见上一节),这条只是把 IPv6 这条潜在不稳定路径提前堵上。
状态页里会记录 ipv4.mode / previousDispatcher / dnsRecords / connectProbe,可直接核对当时是否真的需要它。
几个需要解释的偏离字面需求的设计判断:
weakReminderKeys(VPN/vpn/校外)默认必须与页面特征(HTML/base64/data URI)同时出现才算命中(weakKeysRequireCorroboration: true)。否则模型正常回答里提到"VPN"就会被整段替换掉。要退回"任一关键词命中即判定"的字面规则,把这一项设为false。precheck默认关(按你的要求);打开后,识别到校外会在 TTL 内直接快速失败,不再白跑请求。- 空白流默认重发 1 次。若你更希望"校外就不重发",设
emptyStreamRetries: 0(仍会换成中文短提示)。
验证(可复跑)
$node = 'C:\Users\<你的用户名>\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe'
$py = 'C:\Users\<你的用户名>\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\python\python.exe'
# 1) 假时钟节流单测:第 11 次必须等待、取消可中断、配置回落
& $node tools\test-pacer.mjs
# 2) 入路实测:本地假网关用真实校外 HTML,真 openai SDK 取真实失败消息,喂进插件监听器
& $node tools\test-ingress.mjs
# 3) 与 python clean_zxh.py 的逐行等价性
& $py clean_zxh.py tests\sample_full.txt -o verify\py_full_reminder.txt --reminder
& $node tools\gen-js-clean.mjs tests\sample_full.txt --out verify\js_full_reminder.txt --reminder
& $node tools\compare-clean.mjs verify\py_full_reminder.txt verify\js_full_reminder.txt
结果(本轮实测):节流单测与入路实测全绿;等价性 10/10 组(5 个样例 × 默认/--reminder)逐行完全一致。
安装(desktop profile)
-
把本目录整个拷到稳定位置:
C:\Users\<你的用户名>\.dsh\plugins\zhixiaohang-guard\(不要放进node_modules,任何重装/更新都会覆盖它。) -
在
C:\Users\<你的用户名>\.dsh\profiles\desktop\cordis.patch.yml末尾追加:- insert: - id: zhixiaohang-guard name: 'file:///C:/Users/<你的用户名>/.dsh/plugins/zhixiaohang-guard/index.mjs' config: {}name必须写成file:///URL(或相对补丁文件目录的../../plugins/zhixiaohang-guard/index.mjs)。 实测(node tools/test-specifier.mjs,复刻加载器tree.ts:112-128的解析分支, baseUrl = 补丁文件所在目录file:///C:/Users/<你的用户名>/.dsh/profiles/desktop/):name写法结果 C:/Users/<你的用户名>/.dsh/plugins/.../index.mjs❌ ERR_UNSUPPORTED_ESM_URL_SCHEME(被当成c:协议)C:\Users\<你的用户名>\.dsh\plugins\...\index.mjs❌ 同上 file:///C:/Users/<你的用户名>/.dsh/plugins/.../index.mjs✅ 通过(两种 baseUrl 假设都通过) ../../plugins/zhixiaohang-guard/index.mjs✅ 通过(依赖 baseUrl = profile 目录) ./plugins/zhixiaohang-guard/index.mjs❌ ERR_MODULE_NOT_FOUND(那是~/.dsh/下的相对写法) -
重启桌面应用(单文件宿主插件在启动时加载)。
-
确认加载:日志里应出现
zhixiaohang-guard: mounted(pace=10/60000ms,providers=[zhixiaohang],…)且没有did not activate警告。
若发现该行被应用保存设置时抹掉(profile 文件由 Electron 应用托管),把这行改放到
C:\Users\<你的用户名>\.dsh\cordis.patch.yml(根补丁层,dsh-llm-error-retry 的 README 用的就是这个位置)。
不做什么
- 不改、不删已装的
dsh-llm-error-retry、dsh-throttle、dsh-llm-finish-reason-tolerance等插件,只做协作。 - 不碰
dsh-throttle的call_api令牌桶——那条路径管不到模型请求。 - 不依赖 python,日志里不打印任何原文(只打长度与机器码)。
Comments
Loading…
From the same category
by awesome-dsh-plugin
A curated list of plugins for DeepSeek Harness (dsh) · DeepSeek Harness 插件精选列表
★ 18.3k
CC0-1.0
Python
Oct 10, 2026
by 0xsline
DeepSeek Harness (DSH) ecosystem: curated plugins, tools, and infrastructure from dsh-external/hub and the public dsh-plugin topic.
★ 1.2k
CC0-1.0
Python
Oct 10, 2026
by pax-beehive
Open-source CLI, schemas, resolver, and DSH agent tools for DSH Plugin Hub
★ 450
MIT
TypeScript
Oct 6, 2026
by xiajiajun516
DeepSeek Harness (DSH) backup & restore plugin — export, import, migrate and sync your complete DSH configuration, plugins, MCP servers, skills and workspace. One-click migration to another machine.
★ 176
MIT
TypeScript
Oct 8, 2026
dsh plugin --profile web add dsh-config-managerby yjh051108
推荐组件(非必须):DeepSeek Harness 运行时注入器;已随 dsh-routing-suite 单仓库化保留,本仓库继续维护/发布。
★ 164
TypeScript
Sep 18, 2026
dsh plugin --profile web add @dsh-external/dsh-super-injectorby jigjoy-ai
A CLI that turns a goal into a pull request - and a sandbox for testing concurrent AI coding agents on the Mozaik runtime.
★ 124
MIT
TypeScript
Oct 2, 2026