dsh-account-quota
DeepSeek Harness(DSH)Web 插件:在输入框下方的常驻行显示账户真实余额与用量,
与同一行的会话统计胶囊(2 轮 72 步 · 269 tok/s)保持一致的排版和配色。
[2 轮 72 步 · 269 tok/s] [6M tok · 缓存命中 98%] ← 会话统计(另一插件)
[🐋 ¥12.34 · 今日 -¥0.56] [⬛ 5 小时 42% · 重置于 12分] ← 本插件
两个胶囊各自可点,各开各的明细面板(DeepSeek:总余额、今日消耗(后面跟着统计起点 与采样次数)、充值/赠送; OpenCode Go:滚动、每周、每月用量与各自的重置倒计时)。胶囊之间没有分隔点 —— 用整行的 12px 间距分开,与统计行的两个胶囊一致;分隔点放在胶囊内部, 用来分开它自己的两个数字。
只显示上游真实数据
卡片上每个数字都直接来自提供方 API,没有任何本地推算。 两个适配器:
| 提供方 | kind | 数据源 | 凭据 |
|---|---|---|---|
deepseek |
tokens |
https://api.deepseek.com/user/balance |
DEEPSEEK_API_KEY |
deepseek-go |
plan |
https://opencode.ai/zen/go/v1/usage |
OPENCODEGO_API_KEY |
两个接口发布的全部字段就是这些:
// GET api.deepseek.com/user/balance
{ "is_available": true,
"balance_infos": [ { "currency": "CNY", "total_balance": "12.34",
"granted_balance": "0.00", "topped_up_balance": "12.34" } ] }
// GET opencode.ai/zen/go/v1/usage
{ "usage": {
"rolling": { "status": "ok", "percent": 42, "resetsAt": "2026-03-04T09:30:00Z" },
"weekly": { "status": "ok", "percent": 17, "resetsAt": "2026-03-08T00:00:00Z" },
"monthly": { "status": "ok", "percent": 65, "resetsAt": "2026-03-15T11:45:00Z" } } }
两个端点都不发布 token 数或消费金额。 探测过的其他路径
(/user/usage、/dashboard/billing/usage、/token-usage、/stats、/quota、/credits)
全部 404。
v0.5.0 因此删除了早期版本的本地统计:它折叠本机会话日志里的
assistant/message.usage,再乘一张定价表。那个数字只覆盖本机、且只在日志还在时有效,
却和覆盖全账号的服务端百分比并排显示,容易引出错误对比。
「今日消耗」从哪来
余额接口是唯一能反映账号级消费的数据源,而且只以差值的形式反映:一分钟前 12.90,
现在 12.34。所以 Host 半保存一条余额序列,把它折叠成 balance.today。
记账规则 —— 当天的样本按时间排序后:
今日消耗 = Σ max(0, vᵢ₋₁ − vᵢ) 今日充值 = Σ max(0, vᵢ − vᵢ₋₁)
- 用逐段正差值累加,而不是「首个余额 − 当前余额」:没有充值时两者结果完全相同, 有充值时前者才不会把一笔充值读成负消费。
- 序列落盘,所以刷新页面、第二个标签页、另一个会话、重启主机读到的都是同一条序列。 Client 半不持有任何记账状态,它只是渲染 Host 折叠好的结果。 两个 Host 进程同时写仍有极窄的「后写覆盖先写」窗口(读盘与改名之间),丢掉的 只是中间一个样本:折叠按相邻样本的差值累加,少一个样本只是把两段并成一段, 当天的消耗不变(除非丢的那段里正好有一笔充值)。
- 余额是账号级的:别的机器、脚本、官方 App 花掉的钱一样会让余额下降, 因此都会被计入 —— 不需要任何按会话的记账。
- 跨样本区间的消耗按该区间测得的实际差值记,不做插值。跨本机午夜的差值直接丢弃, 不拆分给任何一天。
total_balance只有两位小数,所以显示的数字以 ¥0.01 为步进。这是接口的分辨率, 插件不补零、也不编造更细的小数。
覆盖范围与它限定的那一行同排:balance.today.since 是当天第一个样本的时刻,
跟在「今日消耗」这个标签后面 —— 今日消耗 (09:12 · 288 次采样),弱化色,
因为它是那一行的限定条件而不是另一个数字;金额那一列仍然只有金额。
09:12 开始的序列没有看见早晨,面板就照实说,而不是把一个残缺的合计当成全天。
显示内容
常驻行:
| 位置 | 内容 | 来源字段 |
|---|---|---|
| 第一项 | ¥12.34 · 今日 -¥0.56 |
balance_infos[].total_balance + 当日余额序列的折叠 |
| 第二项 | 5 小时 42% · 重置于 1小时29分 |
usage.rolling.percent + usage.rolling.resetsAt |
今日消耗为 0 时显示 今日 ¥0.00(不写 -¥0.00);当天还没有样本、或余额读取失败时,
这一段整个不出现,只留余额本身。
重置倒计时由 resetsAt - now 实时计算,挂在常驻行上 —— 窗口还剩多久,是那个
百分比一半的含义。倒计时每 30 秒重算一次(显示精度为分钟)。
展开面板每个提供方一张,列出它在快照里的全部字段:
- DeepSeek 官方:总余额 / 今日消耗(
今日消耗 (09:12 · 288 次采样))/ 今日充值(>0 时)/ 充值 / 赠送 - OpenCode Go:滚动窗口、每周、每月百分比 + 各自的重置倒计时
面板用的是会话统计弹窗同一套皮肤(ui-chat 的 stat-dialog):菜单底色、
12px 圆角、elevation-prominent 阴影、标题下的一条细分割线、以及
minmax(76px, auto) minmax(0, 1fr) 的标签/数值网格,字号 12px、行高 18px。
标题只有图标 + 提供方名 —— 每一个数字都在行里带着自己的标签,
标题右侧不放一个没有标签的裸数字。
位置:定位用共享的 useAnchoredPosition,它的 effect 依赖里包含 anchor ref 对象
本身,所以每个胶囊各有自己的 ref 对象 —— 若共用一个对象、只移动它的 current,
直接从一个胶囊点到另一个时 effect 不会重跑,面板会停在上一次打开的胶囊那里。
这条是实测出来的(jsdom 点击两个胶囊,断言两次拿到的是不同对象)。细节见下。
宽度和位置也一致:width: max-content 配合
min-width: min(300px, calc(100vw - 24px))、max-width: min(440px, calc(100vw - 24px))
—— 上下限都会给窄视口让路,所以 300px 的下限不会把面板顶出屏幕。定位用共享的
useAnchoredPosition(顶部对齐、间距 8px、四周留 12px 边距,滚动/缩放/面板自身
高度变化时重算),外部点击关闭用 useDismissOnOutsidePointer,面板经
createPortal 挂到 document.body,层级 z-index: 1100(在模态遮罩 1000 之上)。
这两个 hook 来自 @deepseek-ai/dsh-client-ui-primitives,和 react-dom 一样是
Web shell 的 seed 模块 —— 模块表直接应答这两个 specifier,因此本包依然不需要
任何 import,也不需要构建。
percent 是整数:上游只返回整数,插件的校验器也据此拒绝小数
(usage.rolling.percent must be an integer between 0 and 100)。
整行的可用宽度来自 align-self: stretch 拉满的输入框区域
(比 --dsh-chat-content-width 的 680–920px 更宽)。窗口窄到放不下时,
flex-wrap 让本行换到下一行并居中,而不是挤压相邻的统计行。
为何与统计行同排
conversation.composer.dock 是 list 槽位:每个注册项渲染成一个片段兄弟节点,
而输入框区域把它们纵向堆叠。新增一项因此默认会另起一行落在 ui-chat 的 stats 行下面。
本插件不去改输入框区域的方向 —— 那样会连带重排输入框卡片:卡片是
width: 100% 且带 max-width,一旦区域变成横向 flex,卡片就成为可收缩的 flex 项,
而它远窄于区域宽度,于是 stats 行会挤到卡片旁边、卡片被压窄。改为把
dock 锚点本身变成一行:
[data-slot='conversation.composer.dock']:has(> .dsh-aq-root) {
display: flex !important;
flex-wrap: wrap;
align-self: stretch; /* 区域是纵向 flex,stretch 让本行占满宽度 */
justify-content: center;
align-items: center;
column-gap: 12px;
row-gap: 0;
}
[data-slot='conversation.composer.dock']:has(> .dsh-aq-root)
> :is([data-composer-stats], .dsh-aq-root) {
width: auto;
margin: 0;
padding-left: 0;
padding-right: 0;
}
为什么需要 !important。 每个槽位渲染点外面都套了一层 [data-slot="…"] 锚点,
harness 给它的是内联 style="display: contents"。内联样式在层叠中高于任何
普通作者规则,因此把它变成真实盒子的这一条声明必须带 !important。
这一步正是让修复保持局部的原因:输入框区域与它里面的卡片完全不受影响, 只有 dock 这一行改变布局。
为什么还要第二条规则。 锚点成为 flex 行后,stats 行在里面仍是「独占一行」的写法:
| stats 行的声明 | 后果 |
|---|---|
width: 100% |
想占满整行,把本插件的行挤到剩余宽度里 |
margin: 0 auto |
flex 项上的 auto 外边距会先吞掉全部剩余空间,justify-content 再也看不到这些空间 —— 这才是把本行推到最右端的主因 |
padding: 0 32px |
两行各自的 32px 侧内边距叠加,中间凭空多出 64px |
所以共享一行必须把这三条在两行上都中和掉,只留锚点自己的 12px 间距。
stats 行用 [data-composer-stats] 定位 —— 那是 ui-chat 自己样式表已经在用的接缝,
因此无需引用任何哈希类名。
选择器锚定在本插件自己的类名上,因此:
- 只有本插件挂载时规则才生效;卸载后锚点与 stats 行完全恢复宿主原本的样式。
- 不引用其他包的 class,也不依赖哈希类名。
- 卡片与输入框区域不参与这两条规则。
- 不支持
:has()的浏览器忽略这两条规则,退回未修改的堆叠布局 —— 降级而非损坏。
已用 jsdom 实测:
- 挂载时:锚点
display为flex(!important覆盖内联的contents),align-self: stretch、column-gap: 12px;stats 行与本行的width为auto、margin与侧内边距为0px。 - 输入框区域仍为
column,卡片仍为column/width: 100%/max-width: 952px—— 即两者完全未被本插件影响。 - 卸载时:锚点回到
contents,stats 行回到width: 100%/margin: auto/padding: 32px,与其出厂值一致;规则匹配数为 0。 - 特异性:第二条规则
0,3,0覆盖 stats 行的0,1,0,与注入顺序无关。
安装
pnpm dsh plugin --profile web add link:~/dsh-account-quota
安装后重启 dsh web:浏览器半在启动时组装进 boot graph。
配置
顶层字段:
| 字段 | 默认值 | 说明 |
|---|---|---|
requestTimeoutMs |
10000 |
单次上游请求超时(覆盖响应体读取,不只是建立连接) |
maxResponseBytes |
65536 |
上游响应体上限;边读边判,超限即中止传输 |
cacheMs |
60000 |
快照新鲜度下限,同时是 Host 半的采样周期(见下) |
statePath |
$DSH_HOME/account-quota/state.json |
余额序列落盘位置;$DSH_HOME 未设置时回退 ~/.dsh |
cacheMs 同时承担两个角色,因为它们问的是同一个问题 —— 「账户的画面可以有多旧」:
定时采样按这个周期把缓存填上,而周期内的 HTTP GET 直接从内存应答,不再打上游。
代价是与浏览器是否打开无关的固定请求量:默认 60000 即每个适配器每天约 1440 次
(一天两次请求合计约 2880 次)。它换来的是当天起点能落在零点附近,而不是「用户早上
第一次打开界面的时刻」。另外,未知的配置键会直接报错而不是被忽略。
提供方适配器:
- id: account-quota
config:
cacheMs: 60000
providers:
deepseek:
kind: tokens
displayName: DeepSeek 官方
apiKeyEnv: DEEPSEEK_API_KEY
balanceUrl: https://api.deepseek.com/user/balance
currency: CNY
deepseek-go:
kind: plan
displayName: OpenCode Go
apiKeyEnv: OPENCODEGO_API_KEY
usageUrl: https://opencode.ai/zen/go/v1/usage
rollingLabel: 5 小时 # 上游窗口策略,非本插件常量
kind: plan要求usageUrl返回usage.rolling|weekly|monthly。kind: tokens要求balanceUrl返回{ is_available, balance_infos: [...] }。rollingLabel是滚动窗口的显示名。窗口长度属于上游策略,故可配置而非硬编码。- 写
providers会替换内置表而不是追加:本部署的两个路由就是内置表。 合并会把没配置的默认项留成真实适配器,而它要的凭据恰恰是自带providers的部署不会设置的 —— 那一行只会显示「凭据未配置」,白占位置。 要保留内置的某一项就照抄一遍(patch 本来也必须重写整份config)。
刷新节奏
两个适配器在同一次刷新里一起取,所以 DeepSeek 余额与 OpenCode Go 用量是同一个节奏:
| 环节 | 周期 | 在哪 |
|---|---|---|
| 上游采样(每个适配器一次请求) | cacheMs,默认 60 秒 |
Host |
| 浏览器重读快照(本机 HTTP,不打上游) | POLL_MS,60 秒 |
Client |
| 重置倒计时重算 | 30 秒 | Client |
界面上的数字最多比上游旧约 60 秒(再加一次请求耗时)。采样与浏览器无关:
所有标签页关掉后 Host 仍按 cacheMs 继续采,当天的起点才不会漂到「早上第一次开界面」
的时刻。要省请求量就调大 cacheMs,代价是零点后的第一段消耗会被并进更大的区间。
工作原理
- Host 半(
lib/index.js):注入webServer、credentials。每个适配器在 一次刷新里并行请求,单源失败(缺凭据、超时、401)只让该项显示错误, 不影响其他项。- 采样的时钟归 Host,不归浏览器。 一个按
cacheMs跑的定时器在挂载时就采一次, 之后每周期一次,与是否打开标签页无关。请求驱动的刷新在没人开页面时会完全停下, 而那正是隔夜差值发生的时候 —— 当天的起点必须在那之前采到。 - 每次刷新把余额读数追加进序列:先读盘合并、再原子改名写入,所以第二个 Host 进程是把自己的样本并进来,而不是覆盖掉对方的。
cacheMs内的 GET 直接复用快照(定时器通常刚填过),HEAD 永不触发刷新。 它是定时器被清掉后的兜底路径。- API key 只在本进程解析,
redirect: 'error'拒绝跟随重定向。 - 同一个故障只告警一次,消息变化时才再报 —— 采样器会跑满整个进程生命周期。
- 采样的时钟归 Host,不归浏览器。 一个按
- Client 半(
lib/client.js):注册到conversation.composer.dock(输入框下方的环境条目行,与stats并排,list槽位加法共存)。 样式逐条对齐StatsPills.module.css:13px 次级字号、--dsw-alias-label-tertiary颜色、gap: 12px、tabular-nums、·分隔符。缺数据显示—而非 0,避免被读成"余额为零"。
品牌图标内联自 @lobehub/icons-static-svg
(MIT):每条 fill="currentColor" 的 24 单位 path,因此自动继承本行文字颜色与悬停态,
且不产生任何 import —— link: 安装的包从自身真实路径解析裸标识符,
在此 import 会 ERR_MODULE_NOT_FOUND。
上游错误映射为 MISSING_CREDENTIAL、UNAUTHORIZED、NOT_ENTITLED、
UPSTREAM_RATE_LIMITED、TIMEOUT 等结构化错误。
限制
- 仅适用于 web profile;headless 中该行因缺少
webServer保持 pending。 - 不显示 token 数:上游不发布,见上文。需要这个数只能自行估算,而那正是 v0.5.0 移除的东西。
- 消费金额只有当天、且只是差值的和:上游不发布逐请求消费,只有余额。所以
- 只有当天,没有历史;2) 分辨率是 ¥0.01;3) 当天起点是当天第一次采样, 不是 00:00 —— 主机若在 09:00 才启动,凌晨的消耗不在这个数里,面板会在「今日消耗」 后面标出这个起点。 跨本机午夜的差值被丢弃而不是摊分。
- 需要 Host 在跑:
dsh web停止期间不采样。序列在盘上,重启后接着算, 但停机期间的消耗会落进下一个采样区间,或被零点边界丢弃。 - 百分比只有整数:上游
percent是整数。网页控制台的小数位(如8.8%) 没有对应 API。 - 两个凭据都需存在;缺哪个哪项显示错误,另一项照常。
- 超时覆盖到响应体读完为止:只发头再挂住的提供方会让请求超时(
TIMEOUT), 而不会把刷新永久挂起 —— 采样器的在途标志一旦卡住就再也不会恢复采样。 maxResponseBytes在传输中生效:超限即取消读取,不会先把整个响应体读进内存再判断。- 路由没有鉴权,但
dsh web默认只绑127.0.0.1。用webStartup.host: 0.0.0.0暴露到局域网时,这个路由(余额与用量)和 GUI 其他接口一样对同网段可读。
自测
node selftest.js
用替身 Context、可改的假 fetch 和本文件自己掌控的时钟驱动真实的 apply(),
断言的是插件实际产出的快照与它实际写下的文件。覆盖:逐段累加、中途充值、
跨日重置、双 Host 进程合并同一条序列、损坏文件、不可写路径、缺凭据、HEAD 不触发刷新。
Client 半不在自测范围内 —— 渲染它需要 React,而 link: 安装无法从本目录解析。
它的文案用真实 React 的 renderToStaticMarkup 单独核对过(常驻行的四种状态与两个面板),
其中 react-dom 与 ui-primitives 用替身喂入,只核对结构与文案。
来源与许可
本仓库是 zer0zio-stack/dsh-opencode-go-quota
v0.4.0 的 fork,MIT 许可,原始版权归 zer0zio-stack 所有。v0.5.0 起的上游 API
适配、余额序列、面板与本次改名都在 PATCHES.md 里逐版本记录,方便日后与上游对齐。
No comments yet. Be the first to write one.