ARTICLE · 1177866
DSH 额度监控插件 — 开发调试全记录
本文记录
dsh-quota-monitor/dsh-client-ui-quota两个包从需求界定到当前状态的完整开发与调试过程,重点是每一个 bug 的症状 → 定位 → 根因 → 修复。写给两 类读者:想给 DeepSeek Harness(DSH)写插件的人,以及想接手这个仓库的人。
仓库:mengbin92/deepseek-harness-quota-monitor
设计文档见 quota-monitor-design.md。
0. 项目概览与时间线
dsh-quota-monitor | QuotaSnapshot,暴露 ctx.quotaMonitor + Typert Remote 命名空间 quota | |
dsh-client-ui-quota |
提交时间线(9 个提交):
a9c6669 | |
cbeb6e1 | |
5103fec | |
48cc419 | |
746db71 | order / pinned / hidden |
0e87b55 | llmProviders |
625880e | |
5d72d7c | |
180cd2c |
注意:仓库是项目中期才公开的,首个提交已包含早期安装/格式/绑定类调试的最终状态;下文第 3 节的排障过程横跨发布前后,按问题类别而非提交顺序组织。
1. 立项:先把范围钉死
初始需求只有一句话:"监控大模型供应商额度使用情况"。动手前先做了三个决定,事后看都省了返工:
只做供应商配额 API,不做本地 token 计量。本地计量需要包装 ctx.llm.stream拦截每次对话,与"查余额"是两套数据源、两套精度语义;混在一起会让首版永远发不出去。数据模型一开始就分两类:预付费余额( balances[])和订阅套餐滚动窗口(limits[])。智谱 GLM Coding Plan、Kimi、MiniMax 的套餐都有 5 小时/7 天窗口限额,如果首版只建余额模型,后面加窗口就是破坏性改动。fetcher 可插拔。第三方网关的配额接口千奇百怪(neo 网关、new-api、自建 one-api 各不相同),内置适配器 + registerFetcher注册口是唯一能不改核心代码接新供应商的形状。
2. 首版实现:语言层面的两个坑
z.array(z.string()).optional().optional() 不存在——schemastery 的数组没有这个方法 | .default() 的字段本身就是可选的,删掉调用即可;两库心智模型不能混用 | |
npx tsc | npx tsc 从 npm 拉了一个占位 tsc 包,构建行为诡异 | typescript@5.6 固定为 devDependency,脚本直接调 tsc |
3. 接入 DSH 运行时:调试重头戏
这是整个项目最艰难的一段。插件代码本身不难,难的是 DSH 的插件体系(bundle → cordis.patch.yml → profile 组合 → Typert RPC → Web ModuleLoader)没有任何一处文档把全链路讲清楚,每一步都是"跑起来 → 报错 → 读 DSH 源码 → 改"。
3.1 安装声明
dsh plugin --profile web add <path> 警告 "declares no dsh.bundle — installed as a plain dependency":没有 dsh.bundle.patch 声明的包不会被当 bundle 组合进 profile。修复:package.json 加 "dsh": {"bundle": {"patch": "./cordis.patch.yml"}} + 一个 insert patch 文件。
3.2 Host fiber 激活失败:inject 的对象形式
static inject = ['timer', { credentials: { required: false } }]——服务注册了但 fiber 永远不激活,无报错无日志。读 cordis 源码确认:inject 只接受字符串数组形式,可选依赖不能用对象形式声明。修复:static inject = ['timer'],credentials 在调用时用 this.ctx.get('credentials') 软解析(服务缺席时 fetcher 如实报"credential 未配置")。
3.3 手写 TYPERT manifest
Host 服务要暴露成 Remote 命名空间,dsh-typert-loader 探测包的 ./typert 导出,两次被拒:
"module has no TYPERT manifest object"——导出必须精确是 TYPERT = { package, face: 'host', schemas, invocations, model }的形状;"sourceLocation.line must be a positive integer"——invocation 的 sourceLocation.line必须是真实正整数,占位的 0 直接被校验拒绝。
官方包用 dsh-typert-generator 生成这些工件;本包手写并保持与源码行号同步(评审阶段发现行号漂移,已校正,后续改动需同步维护)。
3.4 binding namespace 不匹配
网关报 gateway/binding-invalid:super(ctx, 'quotaMonitor') 的 wire 命名空间默认取服务键,而客户端描述符声明的命名空间是 quota。修复:super(ctx, 'quotaMonitor', { namespace: 'quota' })。
3.5 Web 客户端 bundle 格式
浏览器报 "import failed":tsc 产出的 ESM 在 DSH Web 客户端的模块体系里跑不起来。DSH 客户端用自己的 ModuleLoader,bundle 必须是:
window.__ModuleLoader__.load({
id: "dsh-client-ui-quota",
factory: (require) => { /* CJS factory */ }
})
即 esbuild 打成 CJS IIFE、用 banner/footer 包上 load() 调用;react、react/jsx-runtime、@deepseek-ai/cordis 保持 external(运行时由客户端模块表种子提供),其余依赖(zod 等)打进包里。package.json 还需要 dsh.client 声明(inject 列表 + platform: web)。
3.6 空白页面三连
设置页打开只显示"尚未配置供应商",换过三个根因,每个都足以独立致空:
模块缓存的旧 stub bundle:ModuleLoader 按 rev 缓存,一次构建失败留下的残缺 bundle 被缓存,之后无论如何重建页面都不变。教训:构建失败后要确认缓存里没有半成品。 cordis inject guard:store 里访问 remote.quota抛cannot get property "remote.quota" without inject——cordis 把 Remote 命名空间属性挡在 inject 声明后面。但客户端 fiber自己就是挂载这个命名空间的一方,硬 inject 会死锁。修复:ctx.get('remote.quota')放进 try/catch 探测,拿不到就退回 Gateway 的 HTTP wire 格式(POST /api/quota/snapshot,与类型化命名空间同一个端点)。这个兜底保留至今,是老版本 Host 上也能工作的韧性路径。嵌套 fiber 方案失败:曾尝试 ctx.plugin()起一个inject: ['remote.quota']的子 fiber 来合法拿到命名空间,结果客户端渲染直接坏掉(slot 组件树里多了 fiber 边界)。放弃,回退扁平apply(ctx)。
3.7 刷新即白屏:RemoteResult 信封
页面能打开、点"刷新"整页变白,且刷新页面也救不回来(slot 入口已崩)。控制台:TypeError: Cannot read properties of undefined (reading 'length') at QuotaSection。
根因:类型化命名空间的调用在客户端解析为 RemoteResult 信封{ok:true,value} | {ok:false,error}(见 ClientRemoteService.invoke),不是裸业务值。store 把信封当快照用,providers 是 undefined,组件 .length 崩溃,slot 渲染器不捕获子组件异常,于是一次崩溃钉死整个入口。
修复:store 统一过 unwrapRemote()——ok 取 value,!ok 抛成错误状态。这条用 test/unwrap-remote.mjs 固定成回归测试(成功信封、失败信封两个用例)。
顺带澄清了"刷新要等两分钟"的错觉:那是崩溃残局(slot 死了再点刷新无响应)造成的体感,服务端单个供应商刷新实际 <1s。
3.8 可选参数的 RPC 校验
quota/refresh 不带参数调用被拒 "missing providerId"。Typert 参数描述符默认拒收 undefined,可选参数必须显式 acceptsUndefined: true。
3.9 neo 钱包的误导性"已用"
neo 网关(micro-one-api)钱包接口 /v1/usage 的 usage.total.cost 是累计历史消耗(换算后远大于余额),首版把它映射进了"已用"位,页面显示"已用 4,797.95"极具误导性。修复:删掉该映射——钱包只显示余额,窗口用量由订阅接口的 limits[] 承载。教训:上游字段名和业务语义对不上时,宁可少显示也不能错显示。
3.10 识别不了的端点:如实降级
192.168.110.123:3000 实际是 new-api(不是 micro-one-api),其余额接口 /api/user/balance 要 web access token(≠ relay API key),插件拿不到。这类端点配置 fetcher: none + hidden: true,UI 显示"无配额接口"而不是报错刷屏。
4. 展示层演进
cc-switch 规范( 48cc419):参考 cc-switch 的用量显示约定——永远显示剩余百分比而非已用;只给数字着色(绿正常/黄 <20% 剩余/红耗尽),标签和文案保持灰色;刷新按钮至少转 600ms(太快显得"没反应")。单供应商定制( 746db71):order(卡片排序)、pinned(侧栏优先显示)、hidden(完全隐藏且不抓取)。跟随在用模型( 0e87b55):行声明llmProviders(LLM 供应商键),客户端每 5s 读主视图会话的模型目录(modelDirectories.directoryFor(sessionId).store.getSnapshot().current.provider,主视图会话通过sessions.list里retainedBy.mainView > 0识别),命中的行在设置页挂"使用中"徽标、侧栏优先聚焦;无命中时回退 pinned、再回退最差状态行。侧栏最短窗口( 625880e):侧栏一行放不下 neo 的三个窗口,改为只显示 reset 最近的一个(日额度 剩余 98%),完整三窗口仍在设置页和 tooltip。
5. 代码评审加固(5d72d7c)
对全仓库做了一轮评审,10 项修复:
| 并发刷新竞态 | ||
| 重复 provider id | ||
空字符串余额解析成 0Number('') === 0,缺余额字段显示 0 并误告警 | toNumber(空白串返回 undefined) | |
generic-http 窗口数值不校验LimitInfo → 客户端 NaN% 进度条 | toNumber 强转 | |
| neo 部分失败不可见 | console.warn 记录 | |
| 测试硬编码绝对路径 | import.meta.urlreal-mount 无本机 DSH 时自动跳过 | |
| Host 核心逻辑零测试 | test/monitor.mjs(去重/排序/warn 阈值/error+stale 行/并发去重/hidden+disabled) | |
死代码limitLine() 及只被它用的 windowUnit 文案、generic-http 的 void sub、永不执行的 noneFetcher.fetch | ||
| remote-client.ts 行号漂移 | ||
| snapshot() 只拷贝 balances 不拷 limits |
另删掉了 nested-mount.mjs——它测的是 3.6 里已放弃的嵌套 fiber 方案,必然失败。
6. 首刷引导:一个"装完必须手动刷新"的 bug(180cd2c)
症状
插件装好后不手动点一次刷新,额度行永远是灰色的"查询中…"。
根因
构造函数里用 ctx.on('ready', () => this.refresh()) 做首刷——但 cordis 4 根本没有 ready 事件。两个证据:cordis 源码(@deepseek-ai/cordis/src)里搜不到这个事件名;整个 DSH 官方代码库也没有任何 on('ready' 用法。监听器注册给一个永不触发的事件,首刷从未执行,行只能等 30 分钟定时器或手动刷新。
第一次修复的教训:固定延迟撞上 credentials 竞态
第一版改成"延迟 250ms 首刷",E2E 一跑:行确实自动离开 pending 了——但刷成一排 credential NEO_API_KEY is not configured 错误。原因:credentials 服务在本 fiber 之后异步激活,250ms 时还没就绪。这验证了 E2E 的价值:单元测试全部通过(测试自己驱动 refresh()),只有真实启动暴露引导时序。
最终方案
const boot = (attempt: number): void => {
if (this.ctx.get('credentials') !== undefined || attempt >= 40) {
void this.refresh()
return
}
timerish.setTimeout(() => boot(attempt + 1), 250)
}
timerish.setTimeout(() => boot(0), 250)
用 timer 插件的 ctx.setTimeout链轮询 credentials(250ms × 最多 10s),就绪即刷;超时也照刷——没有 credentials 服务的 profile 应该如实报错,而不是永远 pending; 每一跳都走 timer 插件(fiber dispose 自动取消),不用裸 setTimeout。
配套客户端改动:快照里还有 pending 行时(host 刚启动、首刷在途)5 秒快速重轮询,而不是干等 60 秒整周期。
验证
重启 dsh web、打开页面、零点击:Neo 网关($299.8 + 日/周/月三窗口)和 DeepSeek(¥18.01)在首屏直接就是"正常"状态,70 秒观察零控制台错误。
7. 调试工具箱
这套组合贯穿全程,值得复用:
dsh --profile web --dump-config | |
lsof -ti:3080 | xargs kill -9dsh web --no-open > /tmp/dsh-web-X.log &,token 从日志 grep | |
POST /api/quota/snapshot{"type":"client-request","rpcId":"x","method":"quota/snapshot","payload":{"args":{}}}——绕开浏览器单独验证 Host 侧 | |
test/simulate.mjswindow.__ModuleLoader__ + react stub,在 Node 里执行真实 bundle 的 apply(),抓"import failed"/apply 期错误 | |
test/real-mount.mjs$mount(无 DSH 环境自动跳过) | |
8. 给后来者的 DSH 插件开发清单
cordis 4 没有 ready事件。启动引导用 timer 插件的ctx.setTimeout(fiber dispose 自动取消);等可选服务就绪用轮询ctx.get(name),设截止时间,超时按缺席处理。static inject只收字符串数组。可选依赖声明成对象形式会静默阻止 fiber 激活;运行时ctx.get(name)软解析。客户端拿到的是 RemoteResult 信封,不是裸值。 {ok:true,value}|{ok:false,error},务必解包;失败信封要转成错误状态而不是让组件崩掉。可选 RPC 参数要 acceptsUndefined: true,否则空参调用被校验拒收。客户端 bundle 必须是 ModuleLoader IIFE( window.__ModuleLoader__.load({id, factory}),esbuild banner/footer 实现);react/react/jsx-runtime/@deepseek-ai/cordis保持 external。不要 inject 自己挂载的命名空间(死锁); ctx.get()try/catch 探测 + HTTP wire 兜底。schemastery ≠ zod:数组没有 .optional(),无.default()即可选。typescript 固定为 devDependency,别依赖 npx解析(有拉到占位假包的先例)。上游字段语义要人工核对:字段名像"用量"不代表是窗口用量(neo 的 usage.total.cost是累计历史消耗)。必须上真实 E2E:slot 入口的组件崩溃会钉死整个页面且刷新不恢复;单测/模拟测试都覆盖不到这类"渲染期"故障。 首刷/引导逻辑必须独立测:单测里自己驱动 refresh()会把引导时序 bug 完全藏住——这正是第 6 节 bug 活了三个提交的原因。
9. 复盘
做对的:
范围先钉死(API-only、双数据模型、可插拔 fetcher),首版之后没有破坏性返工; HTTP wire 兜底从排障事故里留下来,成了跨版本韧性路径; E2E harness 一次搭建、反复回本(白屏、credentials 竞态都是它抓的); 每个 bug 修复都补了对应回归测试。
走弯的:
嵌套 fiber 方案做了再放弃,浪费一轮——应该先读 cordis 的 fiber/事件模型再动手; on('ready')写的时候没验证事件真的存在,靠"代码看起来对"过了很久——对框架 API 的存在性要保持和对自己代码同等的怀疑;早期调试没有即时提交,发布时历史被压扁,事后复盘只能靠记忆(本文即补记录)。















































































