dsh-local-usage
已验证dsh-local-usage · v0.6.0 · MIT · Web 界面
Usage statistics panel for DeepSeek Harness: historical token and money aggregates folded from every durable session log, drawn as a rolling-year heatmap.
安装
dsh plugin add dsh-local-usage 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
标签
作者
说明文档
description: "dsh Web 客户端的用量统计面板:从全部持久会话日志折叠出的历史 token 与金额统计,以滚动一年的消费热力图呈现,并自述计价方式与数据来源。" kind: "package-reference"
dsh-local-usage
中文 | English
概述
用量统计回答这台机器花了多少钱、花在了什么时候。Harness 按会话记录了精确的 provider token 计量,但既不提供跨会话聚合,也完全不涉及货币,因此本包把两者都补上:Host 半边枚举全部逻辑会话,把每个持久日志折叠为「每次计费结算一个样本」,将样本归因到实际计费的 provider/model 路由,再按配置的价目表计价;Client 半边贡献一个全局面板——侧栏入口,在主列中以全宽页面打开——其核心是当年的日历。
它刻意不做成设置页:一整年的格子需要主列的宽度才不必横向滚动,而且一个 profile 花了多少钱并不是一项偏好设置。
一个面板给出:
- 滚动一年的日历 —— 以当前周结束的 52 周,按窗口内活跃日的分位数梯度着色,每天悬停都有明细卡片。
- 首次读取会自我交代 —— 折叠日志期间先画出面板自己的空骨架:能说清的地方直接给出「已读会话数 / 总数」,数字落地时从 0 涨一次。
- 折叠结果能跨重启 —— 已折叠的内容按每条日志自身的变更令牌缓存在磁盘上,因此重启 dsh 后是从一个 2.5 MiB 的文件重画这一年,而不是重新解码每一份会话日志。
- 每天一个页面 —— 点击任意格子进入当天详情:当天总量,以及其中各工作目录分别占多少。
- 五档区间 —— 当天、近 7 天、本月、本季度、全年,全部以今天为终点。
- 区间总量 —— 金额、总 token、非缓存输入 / 输出 / 缓存读 / 缓存写四个桶、缓存命中率、计费调用次数、以及贡献用量的会话数。
- 可配置的价目表 —— 按模型的输入、缓存读、缓存写、输出单价,回退费率,以及北京时间高峰时段与倍率。
- 可靠性说明 —— 日历下方给出这个窗口为这些调用付出了什么:按原因归类的 provider 重试、工具错误占工具调用的比例、以及失败的上下文压缩。只在有内容可说时才出现。
- 自述依据 —— 计价公式、实际生效的费率、以及每个数字的来源,写在页面本身,而不是只写在文档里。
- 跟随 harness 的语言,不自带开关 —— 每句文案来自字典,每个数字按当前语言格式化,因此中文面板以 万/亿 计数,英文面板以 M/B 计数。
- 不出网 —— 折叠在 dsh 进程内基于持久会话日志完成,折叠出的内容留在本机
<DSH_HOME>/local-usage/下;不上传任何数据,也不发起外部请求。
目录
使用本包
在侧栏选择用量统计即可打开面板。它是全局面板,属于 profile 而非某个会话,因此在切换会话时依然可用;在已经提供 Session 查询引擎、会话存储、以及带侧栏与主列的布局外壳的 Web 组合中挂载 dsh-local-usage 即可,无需任何配置。
两半之间不需要额外接线:Host 半边自行注册 Fetch 路由,浏览器半边直接调用它,因此本包不会出现在产品的 Remote 装配中。
安装
dsh plugin add [email protected]:webxiaobaiyu-droid/dsh-local-usage.git # 从 GitHub 安装
dsh plugin add /path/to/dsh-local-usage # 从本地目录安装
插件 → 添加插件 对话框接受同样这两种形式。
本仓库把构建好的 lib/ 与 cordis.patch.yml 一并入库,且不声明任何构建脚本,因此安装时不会在你的机器上执行代码,也不需要 git 依赖通常会要求的构建授权:加载的就是已入库的产物本身。dsh plugin add 会把 dsh-local-usage 追加到该 profile 的 bundle 列表,dsh --profile <名称> --dump-config 可以看到它贡献的那一行 local-usage。一行同时挂载两半:它加载 Host 半边,而由于 manifest 声明了 dsh.client.platform: web,同一行也是浏览器加载面板的依据。
一个 profile 的 bundle 列表是在进程启动时组装的:通过插件 → 添加插件安装会作用于正在运行的进程;而在命令行安装时,若已有 dsh 进程在该 profile 上服务,需要重启该进程,入口才会出现。
阅读页面
首次请求是最慢的一次,因为它必须读取并折叠这台机器上全部持久会话日志。这段等待里看到的是页面本身——空的:主数字与那排格子、按真实边长画出的整整一年格子、排行行。月份标签、星期栏与每格的边长都是关于日历而非关于用量的事实,因此如实画出;只有数字是占位。数字落地时不会有任何位移。Host 说得出要折叠多少份日志时,页面就报出这个数——「正在读取会话 12 / 65」——并给出已完成比例的进度条;说不出来时,进度条改为来回扫动:用动效代表一段没人能测量的时间。随后数字在约半秒内从 0 涨到终值,且只涨这一次;切换区间是直接换数字,而不是在读者正在比较的数值上重演一遍。
范围选择器决定观察的镜头:当天、近 7 天、本月、本季度或全年。每个范围都以今天结束,因此「本月」指的是本月至今,而不是一个已结束的自然月。默认是「当天」,因为打开这个面板时最常问的是「这一会儿花了多少」。
切换范围不会让页面位移。新范围折叠期间,屏幕上已有的数字保持挂载,折叠完成后就地替换,因此摘要条高度不变、下方日历也不动;若这次折叠超过一瞬,那些数字会变淡而不是被清空,排行则改说「正在更新…」而不去标注读者已经离开的那个窗口。区间折叠是对 Host 已有样本的重新计价,因此被藏起来的等待通常只有一两帧;当天详情页同样按「即将到达的数字的高度」先画出自己的骨架。
大金额、大数据也不会让它位移。 版式只由面板宽度决定,永远不由其中的数字决定:格子那排的列数按容器宽度固定(7 / 4 / 3 / 2 列),因此主数字变宽不会把某个格子挤到下一行、进而改变摘要条高度;主数字本身按渲染后的长度分档(普通 / 较长 / 过长)缩小字号,而不是折行或挤压旁边的格子,并且每档都占据同一个 30px 高度,所以换区间时变的是数字,不是页面。所有数字都是一行——金额与计数一旦折行,既读不出来,也会改变所在行的高度——放不下时宁可在格子里省略(精确值离悬停卡与详情页只有一步)。占位骨架的每一根灰线,高度都等于它所替代的那一行行盒,所以数字到达时不会「落一下」。
主数字旁边是一排承载总 token、输入、输出、缓存三个桶、缓存命中率、计费调用次数与会话数的格子。它们与主数字同处一行,而不是各自占一块,因此这些数字读起来像页面的页眉而非正文。当天页面为那一天给出同一组格子,因此某天缓存失效时,能从专门用来解释那一天的页面上直接看见。
页头右下角在重新统计旁有两个图标:费率表,以及用量依据。两者都是查阅而非通读的东西,因此悬停即开、点击可固定,且都不再从版面上占走一块。它们从首帧起就在页头里,只是在年度报告到达前处于惰性状态——与重新统计本身一样:一个在读取期间把自己移走的控件,读者只会报告说这个功能没了,而它周围那圈加载骨架又足够像成品页面,容易被当成已经加载完成。
日历是稳定的画框:以周日为首列,左侧星期栏每个格子只有一个字形——中文是 日 一 二 三 四 五 六,英文是 S M T W T F S,取自字典而不是写死的字符集;它横跨截至本周的滚动 52 周,并且不随范围变化。格子是固定大小的方块,边长由面板实测决定:当一年的列数能以最小边长放下时,格子会长大铺满宽度;再窄就保持最小边长并丢弃最旧的若干周——因此无论面板多窄(包括展开侧边栏时)都不会横向滚动,也不会被裁掉。格子不带描边,唯一的标记就是颜色深浅;悬停任意一天会弹出卡片,给出当天的花费、总 token、输入、输出、缓存命中的 token 数与调用次数,且卡片里的数字是精确值——完整位数,按当前语言分组——因为「只给约数」恰恰是明细卡片回答不了的那件事。
色阶按窗口内活跃日的分位数分级——中位数、75 分位、90 分位——因此它会随这个 profile 的真实消费分布自适应,而不是假定某种分布。
色阶的「无用量」档是一块看得见的中性灰格子,而不是页面底色本身——于是一个没有消费的月份读起来是「空的一个月」,而不是「什么都没有」。它是把正文墨色以低比例混进页面底色得到的,这样两个主题下都成立:没有任何一个 surface token 在两种主题下都与页面底不同,而唯一在浅色下与底不同的那个(bg-layer-2)解析出来正是页面底色。第一档活跃颜色靠色相与它区分——那一档是蓝的——在色差如此之小的一步上,色相在一瞥之间就能读出,而明度不行。
日历下方是 项目 Token 消耗 排行:名次、项目、条形、token、花费与会话数,默认十行,其余一键展开。条形是相对榜首的比例而非占总量比例,因此某个项目一家独大时排行依然可读。这个榜单跟随范围选择器——切到近 7 天,重排的就是上面那排数字所报告的同一周。
当天详情页
每个格子都是按钮,所以日历是一个入口,而不只是一年的画像。点击任意一格进入当天的独立页面,键盘也能到达同一个地方:整个网格是一个 Tab 停留点,方向键在其中行走,Home / End 跳到当前周的两端,Enter 打开当前聚焦的那天,而读屏软件从每个格子的可访问名称里得到日期、花费、token 数与调用次数。详情页是替换面板内容而非浮在其上,所以日历不会被卡片遮住一半;返回控件回到日历,并把焦点交还给你点开的那一格,而不是把你丢在一整年格子的顶部。
页面先给出当天的总量与各 token 桶,再按工作目录拆分。一个目录就是读者心里的「项目」:一个会话只会创建在一个目录下,Harness 自身也正是在磁盘上按它分组存放会话日志,而且它是这份数据里唯一能回答「这一天的 token 花去了哪」而不是「花在了什么时候」的轴。每一行给出该目录的花费、总 token、输出 token、调用次数与贡献会话数,短名下方是完整路径。头部没有记录工作目录的会话不会被丢掉——它们的花费是真实的——而是归入一行带标签的桶,因此各行相加仍然等于当天总量。没有用量的日子会明说,而不是渲染一张空表。
打开一天不是第二次读日志:它只是把同一个折叠收窄到一天的窗口,在 Host 已经持有的样本上重算,这正是「一个格子可以通向一整页」、而不是「一次漫长等待」的原因。
语言
面板跟随 harness 自身的语言设置,没有自己的开关。文案与数字分开处理,因为这是两个不同的问题:
- 文案是字典。所有用户可见字符串都在
src/client/locales.ts里、以usage命名空间下的两个语言各存一份——面板自己不渲染任何字面量。英文字典上的satisfies Record<UsageInsightsLocaleKey, string>使得「某种语言有、另一种没有」直接成为编译错误;而tests/locales.client.spec.ts补上类型系统看不见的那一半:要求两种语言为同一个键声明相同的{name}占位符。原因是 locale 运行时会把匹配不到的占位符原样留在文本里,键名不一致会让读者看到字面量{date},而任何地方都不会报错。语言包新增一门语言只需注册第三份字典,不需要改组件。 - 数字不是文案,由
src/client/format.ts按当前语言格式化:货币符号位置、分组方式、小数点、紧凑数量级、以及日期的书写顺序,都属于读者的语言,而不属于包住它们的那句话。紧凑 token 数量级是最清楚的例子——英文按 K/M/B 计数,中文按 万/亿 计数,所以中文面板上显示214.87M就不是风格问题,而是没翻译。改完之后英文输出不变,因为Intl的 compact 记法解析出来正是原先写死的那套 K/M/B。
由于 Host 返回的报告是语言中立的——它携带计数、费率和日期键,从不携带渲染好的字符串——切换语言只需重渲染一次,不需要重读任何数据。配置有问题时数字会降级而不是让面板挂掉:无法识别的 ISO 货币码渲染为 CODE 0.00,而 Intl 没有数据的合法语言标签会退回默认语言的数字。
这些调用付出了什么
日历下方(仅在窗口内确实记录了内容时)面板会写明可靠性:provider 被要求重试的次数、结果带错误的工具调用、以及以错误结束的上下文压缩——每一项都按产生者给出的代码归类,因此限流与超时读起来不同,文件过期与编辑失败也各不相同。若窗口内记录的都是干净的工作,则什么都不写,而不是给出一份没人要求的完美记录。
它是上方数字的解释,而不是另一份报告:一个不断撞限流、又压缩不了上下文的会话,正是「这个窗口比它的 token 数看起来更贵」的常见原因;而当缓存读 token 攀升时,压缩那一行就是该看的地方。
配置
| 字段 | 默认值 | 含义 |
|---|---|---|
currency |
CNY |
所有金额的表达币种;插件不做汇率换算。 |
models |
DeepSeek V4.1 Flash 与 V4 Pro 两张价目 | 按路由模型匹配的价目表,先匹配者生效。 |
fallback |
Flash 价目 | 没有任何价目命中的路由所用的费率。 |
peakWindows |
09:00-12:00、14:00-18:00 |
北京时间的高峰时段;留空即关闭高峰计价。 |
peakWeekdaysOnly |
true |
周末是否始终按空闲时段计价。 |
peakMultiplier |
2 |
高峰时段对每条费率施加的倍数。 |
warmup |
true |
激活后先在后台折叠一次全部日志,使首次打开面板变成一次重新计价。 |
warmupDelayMs |
4000 |
后台折叠在激活后多久开始(毫秒)。 |
每张价目给出 input、cacheRead、cacheWrite、output(币种单位/百万 token),以及认领路由的 match 子串。内置默认值是 DeepSeek 官方公布的 deepseek-flash 与 deepseek-v4-pro 空闲时段人民币价格;经聚合商或转售商提供的路由必须由使用者自行定价,页面会点名所有回退到默认费率的路由,而不是给出一个它无法支撑的数字。
预热。 装好之后的第一次读取要解码全部会话日志,而这份等待原本落在读者打开面板的那一刻。warmup 让它在进程起来之后(warmupDelayMs 之后)先在后台跑完:面板第一次打开时,折叠结果已经在手里,那次请求只是一次重新计价。窗口用的是「不设边界」,因为折叠缓存保存的是每个会话的全部样本、与窗口无关,所以预热一次就覆盖了面板能选的所有区间;而已经预热过的机器只付一次语料列举的代价。预热期间到达的请求会等这次折叠,而不是把同一批日志再读一遍。不想让它在没人看的时候读盘,把 warmup 关掉即可——代价回到首次打开。
给单个路由定价。 子串规则很方便,也会在一条它从没为之写过的路由恰好包含该子串时悄悄出错——ds123/glm-5.3-flash 会被 flash 价目认领,按 DeepSeek Flash 计价。费率面板会列出该窗口真实出现过的每一条路由,给出它实际生效的费率、以及来源标记(规则、回退费率、或你自己的覆盖),四个价格都可直接改写。覆盖对该确切路由优先于规则,保存在本机,并在下一次报告立即生效。
$DSH_HOME/local-usage/pricing.json # 编辑器写入的文件;DSH_HOME 默认为 ~/.dsh
这个文件是读者写下的状态。写入走 POST /api/dsh-local-usage/pricing,与面板读取报告、读取进度的 report / progress 属于同一族路由;写入前逐字段校验,落盘时先写临时兄弟文件再改名,因此中断的写入不会留下半张表。某一行的还原会清除该覆盖,让这条路由回到它的规则。插件自己写下的持久状态只有 Host 折叠 里那份折叠缓存,里面是样本与计数,从不包含消息正文。
金额能信到什么程度
花费是本插件自己算出来的,不是从 provider 账单读回来的:各桶 token × 配置费率,并按每次调用自身的时刻判定高峰/空闲。面板在页头的费率面板里明确写出公式、当前生效费率和高峰时段——因为一个估算值与账单的差异来源,恰恰是读者容易默认已经包含的那些东西:预购额度、套餐包、阶梯折扣、赠送余额、聚合商加价与转售差价都不在其中。手工给路由定价会消掉该路由估算中最大的一项误差来源;清单上的其余各项,无论是否定价都依然不可见。请以 provider 账单为准。
数据从哪来
面板在页头的用量依据面板里自述来源:Harness 没有用量数据库,因此统计折叠自会话的持久事件日志本身——本机全部会话、跨所有工作目录,经会话查询服务读取并做重放校验。该区块还给出落盘形态(<DSH_HOME>/sessions/<工作目录>/<会话>/session.v3.jsonl.zstd)、不计入的部分(fork 的继承前缀、尚未结算的在途调用、读不出的日志)、不上传也不发起外部请求这一事实、折叠出的样本缓存在本机 <DSH_HOME>/local-usage/fold-cache.json(只有样本与计数,从不包含消息正文),最后给出产生当前数字的那一次读取的计数。
数字的含义
每个计费结算产生一个样本,语义与 Harness 自身的 tokenUsage 投影一致:同一 (turn, step) 槽位的结算会替换先前的样本,llm/retry-started 关闭该槽位使重试的尝试单独计费,完全相同的重复结算不改变任何数字,而未提交可见消息的结算仍通过其内嵌 stream 携带的 usage 计入。因此提取器能精确复现持久投影的总量,这也正是本包针对真实日志所做的验证。uncachedInputTokens 不含缓存流量;cacheReadTokens 与 cacheWriteTokens 在 provider 未上报时计为零。花费为各桶 token × 费率,并按每个样本自身的时刻计价,因此一天之内跨越高峰边界时,两侧样本各自正确,而不会被平均。每一个可靠性数字都是对持久事件的计数——llm/retry 记一次重试,tool/result 带 error 记一次工具失败,compaction/start 与带 error 的 compaction/end 记一次压缩失败——按本地日折叠,并与样本用同一个窗口裁剪,这对面板所请求的每一个以天为界的窗口都是精确的。缓存命中率是全部输入 token 中由 provider 缓存提供的比例——cacheReadTokens ÷ (uncachedInputTokens + cacheReadTokens + cacheWriteTokens)——因此写入缓存的那个 token 计为未命中而非命中;若窗口内没有任何输入 token,则不给比率,而不是给一个 0。
常见问题
面板能打开,但显示无法读取用量数据。 说明浏览器半边已挂载、Host 半边没有,于是它要调用的路由并不存在。启动输出会直接点名原因:
dsh: warning: 1 entry did not activate
local-usage (dsh-local-usage): Error: cannot get property "connection" without inject
这条信息意味着 entry 到达 Loader 时丢掉了 inject 列表,并在第一次读取服务时失败;造成它的唯一一种模块形状见 Dev Note,tests/module-shape.host.spec.ts 守着这条规则。要确认安装本身正常,可以问组合后的配置:dsh --profile <名称> --dump-config 会在 - id: local-usage 上方打印 # == dsh-local-usage。
插件列表里没有它,或安装被拒绝并提示 declares no dsh.bundle。 只有当 manifest 声明了 dsh.bundle.patch、并随包提供它指向的 patch 文件时,一个包才能作为插件层安装;这两者本仓库都已入库,因此被拒绝说明装的是本包的旧副本 —— 请重新从本仓库安装。
安装失败并报 ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF。 这是 profile 自身的状态,与本包无关:它的 node_modules 是由另一个 pnpm 版本或另一套 linker 设置创建的,与当前正在安装的那套不一致。在该 profile 目录里运行 pnpm install,然后重新添加插件即可。
总量看起来低于 provider 的账单。 有三类内容按设计排除,并且会在面板的来源区块里计数,而不是被悄悄丢掉:fork 继承的前缀(已在父会话计费)、仍在流式输出、用量尚未最终确定的结算,以及任何无法读取的日志。没有命中价目表的路由按回退费率计价,并在提示行中点名。
理解实现
Host 折叠
Host 半通过 ctx.sessionQuery.listSessions() 枚举会话,该调用不读取任何事件日志;随后对尚未折叠的日志走两条读取路径之一:挂载的构建提供批量投影时走投影,而带 fork 继承前缀的日志走逐会话精确读取,因为只有那条路径会报告前缀长度。读取是昂贵的一半、折叠不是,因此缓存保存的是每个会话抽取出的样本而非成品报告,每次调用都以当时的配置与读者覆盖重新计价;于是改一个价格无需重读任何日志即可生效。会话一旦记录新事件,其缓存条目立即失效,请求也可以强制全量重读。
这份缓存还能跨进程存活。第二层缓存以「会话 id + 该日志自身的变更令牌」为键,写在 <DSH_HOME>/local-usage/fold-cache.json:令牌是持久层的 revision,由文件的 device、inode、大小与纳秒级时间戳构成,因此只要 revision 仍然一致,这份折叠就正是磁盘上那串字节的折叠,日志根本不会被打开。在开发者自己的语料上——65 个会话、压缩后 71 MiB、51000 个 zstd 帧、解压后 310 MiB——冷读要花约 2.3 秒做解码与折叠;而缓存文档是 2.5 MiB,读回来约 4 毫秒。revision 变了、或后端不再列出的日志,都只是一次未命中,代价仅仅是它本来要省掉的那次读取。最后活动时间早于 400 天的条目会在写入时被丢弃,因此文件规模被面板 52 周窗口够得着的范围所界定;而一个回溯得比这条水位线更远的请求,会走全新读取,而不是去用一份可能已经把它裁掉的缓存。文件里只有样本与可靠性计数——没有任何消息正文、没有任何提示词文本。缓存两个方向都是可选的:没有挂载持久化后端的部署拿不到 revision,于是什么都不会被复用,面板行为与这份缓存存在之前完全一致。
到达这次折叠的有三条路由:GET /api/dsh-local-usage/report 按窗口给出报告,GET /api/dsh-local-usage/progress 给出正在进行的读取的进度,POST /api/dsh-local-usage/pricing 写入按路由的覆盖表。写入在落盘前逐字段校验,因为它落到的那个文件正是之后每一份报告的计价依据。进度单独成一条路由而不是把报告做成流式,是因为报告是浏览器整体解析的一份 JSON 文档,而缓冲了流式响应体的载体丢掉的恰恰就是正在推的进度。
fork 会话的日志以父会话的继承前缀开头,而那些 Turn 已在父会话计费,因此只折叠 inheritedEventCount 及其之后的事件。会话标题仍从完整日志折叠(含继承前缀),因为标题是关于对话本身的事实,而非关于计费。
页面
浏览器半边贡献一个全局面板:一个 sidebar.panellist 条目与一个 main 键控槽占用者共享同一个 id,于是侧栏拥有按钮、框架拥有主列。由于全局面板是被保留而非重新挂载的,面板会读取 usePanelInfo,并且只在自己被选中时才读取日志。它只通过 Host 半边注册的那一个 Fetch 路由触达 Host,因此这半边不含任何折叠或定价逻辑,本包也不需要出现在产品的 Remote 装配中。本产品没有图表库、引入图表库也超出边界,因此日历是用 CSS grid 单元格在语义主题 token 上以 color-mix 着色实现的。
详情页是面板自己的第二个视图,而非一条路由:面板持有当前打开的日期,并用同一条路由以一天的窗口取回那天的报告,Host 则以「对已缓存样本重新计价」作答。那天的报告单独持有状态,因此一次较慢的折叠不会让背后的日历变空白;而页面渲染的按目录行,与折叠整年的是同一个函数——两者唯一的差别就是窗口。
语言经由插件自己的 injected face 抵达面板,而不是再开一份订阅:渲染器会按 locale 版本号重新推导每个条目的字典函数,所以切换语言本身已经会让面板重渲染,注入的 locale() 在渲染期读取服务,读到的必然就是旁边那句文案所用的同一个 id。格式化器按 (locale, currency, digits) 缓存,构造失败的会以「不存在」缓存下来——这正是让配错的货币码不会每次渲染都抛异常的原因。
延伸阅读
以下子系统是本包读取、镜像或据以安装的 DeepSeek Harness 包。由于本包独立发布、已不在那棵源码树内,链接一律指向 Harness 仓库。
- Session query —— Host 半边用于枚举与读取的冷读引擎。
- Token meter —— 本包提取器所镜像的持久
tokenUsage投影,及其结算与重试语义。 - Session projection cache —— 持久化的按会话投影存储,读取日志之外的零 I/O 方案。
- 打包与安装插件 —— 本包据以安装的 bundle 与 profile 模型。
模型体验
无。本包读取持久会话日志并渲染浏览器页面,不添加任何模型可见内容,也不发起模型调用。
KV Cache 影响
无;本包既不组装也不发送 provider 请求。
已知限制与待办
这些限制界定了页面能报告什么;它们是本包当前的约束。
- 价格是配置,不是账单 —— Harness 只记录 token、从不记录货币,因此每个金额都是
token × 配置费率;没有命中价目的路由按回退费率计价,并在页面的提示行中点名,而不是被悄悄估算。 - 覆盖精确对应一条路由 —— 编辑器以日志记录的
provider/model字符串为键,因此聚合商改一次路由名就需要新增一条,也没有通配形式。编辑器列出的是日历已经读过的那一年出现过的路由;某条路由不再使用后它的覆盖会留在pricing.json里,下次该路由出现时重新生效。如果一个窗口完全没有出现过路由,这一节会保留并明说,而不是让读者怀疑这个功能是否还在。 - 费率编辑器不是一份可用于规划的价目表 —— 它编辑的是本机按什么价计费,并且刻意不能新增:无法声明一条从未使用过的路由,也没有地方保存 provider 的完整价目。
- 未建模中国法定节假日 —— 高峰时段遵循官方公布的北京时间周一至周五时段,因此窗口内的节假日工作日会按高峰计价。
- 只随包发布两种语言 —— 中文与英文。语言包注册的第三种语言会按键逐条回退到英文,因此在该语言包同时注册
usage字典之前,它渲染出来是英文句子。文案是双语的,数字则不是——一门背后没有Intl数据的语言会静默地渲染成默认语言的数字。 - 语言是 harness 的,不是面板的 —— 面板没有自己的语言选择。想在英文 harness 上看到中文数字的读者必须改 harness 的语言,而那会改变所有界面,不只是这一个。
- 仍在流式输出的结算不贡献数字 —— 其用量尚未最终确定,后续结算仍可能替换它,因此运行中会话的最新一轮交换只有结算后才出现。
- 按设计排除 fork 继承前缀 —— fork 会话只报告它自身造成的花费,因此按会话的统计不能相加来还原父对话的总花费。
- 日历只展示一个滚动窗口 —— 面板在挂载时固定画框,因此需要任意日期区间的部署目前还没有切换控件。
- 「项目」是工作目录,不是仓库 —— 花费归因到会话创建时所在的目录,因此同一个仓库的两份检出会算作两行,会话即使中途切换目录也仍留在它起始的那个目录下,而且详情页不会下钻到单个会话。
- 报告仍携带页面不再渲染的按模型行 —— 加权路由排行作为第一步已从页面移除,按会话行则为详情页的后续用途保留;两者都留在 wire 契约中,直到有界面需要它们。
- 折叠缓存拿磁盘换一次冷读,仅此而已 —— 它以日志的 revision 为键,因此「数字过期」并不是它可能的失效方式;一份写不进、读不出或读不懂的缓存,代价只是下一次启动还要付它本想省掉的那次读取。它并不能免除第一次读取:在一台从未折叠过任何东西的机器上,第一个会话仍要为全部语料付费,重新统计也一样。
- 保留期会丢掉面板已经问不到的东西 —— 最后活动时间超过 400 天的条目会在写入时移除,因为日历只跨 52 周、没有任何区间能回溯到一年以上。完全没有记录过活动的会话无法定年,因此会被保留:它只有几百字节,而且恰恰是它省下一次读取。
开发
前置条件
Node 22 与 pnpm,外加一份 DeepSeek Harness 源码检出。本包编译与测试所依赖的 @deepseek-ai/* 都是 Harness 的 workspace 包:npm 上的 rc 版本会解析 @deepseek-ai/dsh-type-meta,而它并未发布到 registry,因此只有源码检出才能拿到这些包。
pnpm install
pnpm run link:host -- --src /path/to/deepseek-harness
# 或者:DSH_SRC=/path/to/deepseek-harness pnpm run link:host
link:host 会把本仓库的 node_modules/@deepseek-ai/* 指向该检出(并打印链接来源的 Harness 版本),包清单在 scripts/link-host-packages.mjs;新增宿主 import 时在其中补一条即可。这些链接只是开发期状态:不会被提交,而 lib/ 在运行期从宿主进程解析同样的包。
常用命令
| 命令 | 作用 |
|---|---|
pnpm test |
运行两半的 vitest 套件 |
pnpm run typecheck |
以两个程序分别类型检查两半 |
pnpm run build |
打包 lib/*.js,并产出 lib/types |
pnpm run watch |
只重建打包产物,用于热重载循环 |
这里刻意不提供 prepare 脚本。lib/ 已入库,而 git 依赖上的构建脚本恰恰会迫使每个安装者授予该包「在安装期执行代码」的权限;去掉它,安装才能直接使用入库产物、不索取任何权限。改动 src/ 后请自行运行 pnpm run build 并提交产物。
开发者备注
面向维护者的工作上下文 —— 点击展开
Host 半边与浏览器半边都会在相同的 key 上合并 Cordis Context,但服务不同,因此同一个 TypeScript 程序无法同时看到两者;Harness 自身也是出于同样的原因拆分类型检查。tsconfig.host.json 与 tsconfig.client.json 是 typecheck 与编辑器所用的两个程序,而 tsconfig.host.build.json / tsconfig.client.build.json 是它们收窄后的子集,用于把声明产出到 lib/types —— 这正是 package.json 的 exports 所指向的布局,也是 Harness 自身客户端包的布局(rootDir: src,outDir: lib/types)。两半各自用自己的 glob 收拢文件(src/*.ts 与 tests/*.host.spec.ts,对应 src/client/**/* 与 tests/*.client.spec.ts)而不是逐个列出:手工维护的清单正是一个新模块最终「附带发布却从未被检查」的方式,而拆分本来就是为了防止这件事。
由此有两点需要注意:
tsdown只打包 JavaScript,使用共享的tsconfig.json。两半的dts都关闭:在那里产出的声明会把浏览器产物的 module-loader banner/footer 包进声明文件而破坏解析,Harness 自己的客户端预设也因此关闭它。- 声明只有在
pnpm run build(或pnpm run build:types)之后才存在;由于本包通过 git 分发,声明与打包产物一同入库 —— 只跑tsdown会让exports里的types条件指向不存在的文件。
这里用仓库自己的脚本调用 tsc,而不是 tsc -b:本包不在 Harness 的 project reference 图内,因此它针对检出的已构建声明文件编译,而非针对该工程图。
两半都只导出具名成员,绝不写 export default apply。带 default 导出的模块会以该 default 被挂载,于是插件拿不到模块的 name 与 inject:fiber 以空 inject 列表激活,entry 在第一次读取服务时即以 cannot get property "…" without inject 失败,面板随之没有可读取的路由。tests/module-shape.host.spec.ts 为两半守着这条规则。
Runtime invariant: Host 半边拥有三条 HTTP 路由——折叠报告、进行中读取的进度、以及费率表写入——和两层样本缓存:一层以会话 id 为键存在内存里,一层以「会话 id + 日志 revision」为键存在磁盘上。浏览器半边注册一个本地化的侧栏条目及其对应的 main 面板,并且只通过这些路由访问 Host。不发布 companion,两半都不自行发出 Cordis 事件。