夜雨聆风学习资料网

ARTICLE · 1177866

DSH 额度监控插件 — 开发调试全记录

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
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
0e87b55llmProviders
:跟随在用模型自动聚焦对应供应商行
625880e
侧栏只显示最短(最近重置)的窗口
5d72d7c
全量代码评审加固(10 项修复)
180cd2c
修"装完必须手动刷新":启动首刷引导

注意:仓库是项目中期才公开的,首个提交已包含早期安装/格式/绑定类调试的最终状态;下文第 3 节的排障过程横跨发布前后,按问题类别而非提交顺序组织。

1. 立项:先把范围钉死

初始需求只有一句话:"监控大模型供应商额度使用情况"。动手前先做了三个决定,事后看都省了返工:

  1. 只做供应商配额 API,不做本地 token 计量。本地计量需要包装 ctx.llm.stream 拦截每次对话,与"查余额"是两套数据源、两套精度语义;混在一起会让首版永远发不出去。
  2. 数据模型一开始就分两类:预付费余额(balances[])和订阅套餐滚动窗口(limits[])。智谱 GLM Coding Plan、Kimi、MiniMax 的套餐都有 5 小时/7 天窗口限额,如果首版只建余额模型,后面加窗口就是破坏性改动。
  3. 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 <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 空白页面三连

设置页打开只显示"尚未配置供应商",换过三个根因,每个都足以独立致空:

  1. 模块缓存的旧 stub bundle:ModuleLoader 按 rev 缓存,一次构建失败留下的残缺 bundle 被缓存,之后无论如何重建页面都不变。教训:构建失败后要确认缓存里没有半成品。
  2. 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 上也能工作的韧性路径。
  3. 嵌套 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 项修复:

#
问题
修复
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 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
验证 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 插件开发清单

  1. cordis 4 没有 ready 事件。启动引导用 timer 插件的 ctx.setTimeout(fiber dispose 自动取消);等可选服务就绪用轮询 ctx.get(name),设截止时间,超时按缺席处理。
  2. static inject 只收字符串数组。可选依赖声明成对象形式会静默阻止 fiber 激活;运行时 ctx.get(name) 软解析。
  3. 客户端拿到的是 RemoteResult 信封,不是裸值。{ok:true,value}|{ok:false,error},务必解包;失败信封要转成错误状态而不是让组件崩掉。
  4. 可选 RPC 参数要 acceptsUndefined: true,否则空参调用被校验拒收。
  5. 客户端 bundle 必须是 ModuleLoader IIFE(window.__ModuleLoader__.load({id, factory}),esbuild banner/footer 实现);react/react/jsx-runtime/@deepseek-ai/cordis 保持 external。
  6. 不要 inject 自己挂载的命名空间(死锁);ctx.get() try/catch 探测 + HTTP wire 兜底。
  7. schemastery ≠ zod:数组没有 .optional(),无 .default() 即可选。
  8. typescript 固定为 devDependency,别依赖 npx 解析(有拉到占位假包的先例)。
  9. 上游字段语义要人工核对:字段名像"用量"不代表是窗口用量(neo 的 usage.total.cost 是累计历史消耗)。
  10. 必须上真实 E2E:slot 入口的组件崩溃会钉死整个页面且刷新不恢复;单测/模拟测试都覆盖不到这类"渲染期"故障。
  11. 首刷/引导逻辑必须独立测:单测里自己驱动 refresh() 会把引导时序 bug 完全藏住——这正是第 6 节 bug 活了三个提交的原因。

9. 复盘

做对的:

  • 范围先钉死(API-only、双数据模型、可插拔 fetcher),首版之后没有破坏性返工;
  • HTTP wire 兜底从排障事故里留下来,成了跨版本韧性路径;
  • E2E harness 一次搭建、反复回本(白屏、credentials 竞态都是它抓的);
  • 每个 bug 修复都补了对应回归测试。

走弯的:

  • 嵌套 fiber 方案做了再放弃,浪费一轮——应该先读 cordis 的 fiber/事件模型再动手;
  • on('ready') 写的时候没验证事件真的存在,靠"代码看起来对"过了很久——对框架 API 的存在性要保持和对自己代码同等的怀疑;
  • 早期调试没有即时提交,发布时历史被压扁,事后复盘只能靠记忆(本文即补记录)。

相关学习资料