DeepSeek Harness 额度监控插件开发调试全记录
DSH 额度监控插件 — 开发调试全记录
DeepSeek Harness(DSH)额度监控插件 dsh-quota-monitor 与 dsh-client-ui-quota 的开发调试记录,覆盖 9 个提交,从双包架构、4 个内置 fetcher 到设置页额度卡片与侧栏指示器。文中逐一还原了 schemastery 与 zod 混用、inject 对象形式导致 fiber 不激活、TYPERT manifest 校验被拒、RemoteResult 信封解包引发刷新白屏等 bug 的症状、定位与修复,并附 10 项代码评审加固。
正文
本文记录 dsh-quota-monitor / dsh-client-ui-quota 两个包从需求界定到当前状态的完整开发与调试过程,重点是每一个 bug 的症状 → 定位 → 根因 → 修复。写给两 类读者:想给 DeepSeek Harness(DSH)写插件的人,以及想接手这个仓库的人。 仓库:mengbin92/deepseek-harness-quota-monitor 设计文档见 quota-monitor-design.md。 0. 项目概览与时间线
| 包 | 运行端 | 职责 | |---|---|---| | dsh-quota-monitor | Host (Node) | 周期抓取各供应商余额/订阅窗口,聚合 QuotaSnapshot,暴露 ctx.quotaMonitor + Typert Remote 命名空间 quota | | dsh-client-ui-quota | Web Client | 设置页"额度"卡片 + 侧栏常驻指示器,60s 轮询快照 |
提交时间线(9 个提交):
| 提交 | 内容 | |---|---| | a9c6669 | 首版:双包架构、4 个内置 fetcher、Remote 命名空间、设置页 + 侧栏 | | cbeb6e1 | 修 neo 钱包误导性"已用"显示 + refresh 可选参数 RPC 拒收 | | 5103fec | 修客户端 RemoteResult 信封解包(刷新白屏) | | 48cc419 | 采纳 cc-switch 显示规范(剩余百分比、数字着色、最短窗口优先) | | 746db71 | 单供应商显示定制:order / pinned / hidden | | 0e87b55 | llmProviders:跟随在用模型自动聚焦对应供应商行 | | 625880e | 侧栏只显示最短(最近重置)的窗口 | | 5d72d7c | 全量代码评审加固(10 项修复) | | 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. 首版实现:语言层面的两个坑
| 坑 | 症状 | 修复 | |---|---|---| | schemastery 不是 zod | 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 警告 "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 必须是:
即 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 死了再点刷新无响应)造成的体感,服务端单个供应商刷新实际 0 识别),命中的行在设置页挂"使用中"徽标、侧栏优先聚焦;无命中时回退 pinned、再回退最差状态行。 侧栏最短窗口(625880e):侧栏一行放不下 neo 的三个窗口,改为只显示 reset 最近的一个(日额度 剩余 98%),完整三窗口仍在设置页和 tooltip。 5. 代码评审加固(5d72d7c)
对全仓库做了一轮评审,10 项修复:
| # | 问题 | 修复 | |---|---|---| | 1 | 并发刷新竞态:定时器与手动刷新并发时,后完成的旧请求覆盖新结果(旧请求失败会把刚刷好的行改写成 error+缓存) | 按供应商 id 的 in-flight Map 去重,并发触发共享同一次抓取 | | 2 | 重复 provider id:配置里两条同 id → 双卡 + React duplicate key + 双倍请求 | 构造时按 id 去重并告警 | | 3 | 空字符串余额解析成 0:Number('') === 0,缺余额字段显示 0 并误告警 | 统一 toNumber(空白串返回 undefined) | | 4 | generic-http 窗口数值不校验:字符串数字直接流入 LimitInfo → 客户端 NaN% 进度条 | 逐字段 toNumber 强转 | | 5 | neo 部分失败不可见:subscription 挂、wallet 成功 → 行显示"正常"但窗口消失 | 失败分支 console.warn 记录 | | 6 | 测试硬编码绝对路径:公开仓库换机即挂 | import.meta.url 相对路径;real-mount 无本机 DSH 时自动跳过 | | 7 | Host 核心逻辑零测试 | 新增 test/monitor.mjs(去重/排序/warn 阈值/error+stale 行/并发去重/hidden+disabled) | | 8 | 死代码:limitLine() 及只被它用的 windowUnit 文案、generic-http 的 void sub、永不执行的 noneFetcher.fetch | 全部删除 | | 9 | remote-client.ts 行号漂移 | 与源码真实行号同步 | | 10 | 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 NEOAPIKEY is not configured 错误。原因:credentials 服务在本 fiber 之后异步激活,250ms 时还没就绪。这验证了 E2E 的价值:单元测试全部通过(测试自己驱动 refresh()),只有真实启动暴露引导时序。 最终方案
用 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 | 验证 bundle/patch 组合是否真的进了 profile | | 服务器日志 | lsof -ti:3080 \| xargs kill -9 清场后 dsh web --no-open > /tmp/dsh-web-X.log &,token 从日志 grep | | Gateway wire 直调 | POST /api/quota/snapshot,body {"type":"client-request","rpcId":"x","method":"quota/snapshot","payload":{"args":{}}}——绕开浏览器单独验证 Host 侧 | | Node 级客户端模拟 | test/simulate.mjs:模拟 window.ModuleLoader + react stub,在 Node 里执行真实 bundle 的 apply(),抓"import failed"/apply 期错误 | | 真实挂载测试 | test/real-mount.mjs:加载本机 DSH 的真实 typert-registry/api-gateway bundle 跑 $mount(无 DSH 环境自动跳过) | | Playwright E2E | playwright-core + 系统 Chrome headless;打开带 token 的页面 → 设置 → 额度 → 点刷新/不点刷新,捕获全部 console 和 pageerror——白屏类崩溃只有这里能抓到 | 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 的存在性要保持和对自己代码同等的怀疑; 早期调试没有即时提交,发布时历史被压扁,事后复盘只能靠记忆(本文即补记录)。