dsh-turn-cost —— DSH 轮次花费(人民币)
只想要“照着做一遍”的封装版本(含安装、配置与排错表),见同名技能包 dsh-turn-cost;本仓库是代码本体。
在 DSH 输入框下方、以及每一轮回复的尾部,显示这一轮和整个对话花了多少人民币。
这是什么
一个 DSH 插件,两端一模块:
| 位置 | 文件 | 职责 |
|---|---|---|
| 宿主端 | lib/index.js |
注册一个只读的会话投影 turnCost,把每次模型调用的 token 桶按轮次折叠成金额 |
| 计价 | lib/pricing.js |
读价格表 / 汇率 / 峰谷规则,把 token 桶换算成人民币 |
| 浏览器端 | lib/client.js |
从投影取值,在 DSH 既有界面上就地加两个元素 |
| 装载行 | cordis.patch.yml |
往 profile 补丁层插一条 loader 条目 |
| 离线测试 | test.mjs |
只覆盖 lib/pricing.js,见「测试」 |
宿主端只做加法:不修改 DSH 任何源码、样式或既有插件。它注册的是会话投影(对持久会话日志的纯 fold), 不介入模型调用链。
解决什么问题
DSH 界面上本来就有「1 轮 71 步·257 tok/s」「10.7M tok·缓存命中 99%」这类统计,但没有「这一轮花了多少钱」。
本插件把 usage 里的 token 桶按 dsh-cost-meter 账本里的价格换算成人民币(缺省符号 ¥、4 位小数),补上这一格:
- 输入框下方 —— 看整个对话累计花了多少;
- 每轮回复尾部 —— 看这一轮花了多少。
显示在哪、长什么样
两个落点都是「就地加一个元素」,不新开行、不新开面板。
1) 输入框下方的统计药丸
在 DSH 自己那一排统计药丸里追加第三个,文本形如:
本轮 ¥0.42 · 本对话 ¥3.17
本对话累计金额为 0、或投影还没有数据时,这一行什么都不加。 悬停提示按行给分项(金额、四个 token 桶、模型调用次数):
本对话 · 金额 ¥3.17 · 未缓存输入 2.1M tok · 缓存读取 10.4M tok · 缓存写入 0 tok · 输出 186K tok · 模型调用 63 次
本轮 · 金额 ¥0.42 · 未缓存输入 320K tok · 缓存读取 1.8M tok · 缓存写入 0 tok · 输出 21.3K tok · 模型调用 9 次
2) 每个回合尾部的徽章
在每个回合的动作条(与复制 / 点赞 / 分叉按钮、「用量 X tok」同排)里加一枚小徽章:
本轮 ¥0.42
它显示的是这条助手消息所属轮次的金额——宿主端维护了「助手消息 id → 轮次号」的映射,徽章靠它定位。 映射找不到、或那一轮金额为 0 时,不渲染任何东西。
样式
自带样式写在插件自己的 <style data-plugin-css> 标签里,类名统一 dtc- 前缀,
颜色一律取全局主题变量(--dsw-alias-label-tertiary 等),不引用、也不修改 DSH 的任何类名与样式表。
药丸复刻邻居药丸的规格(继承行内字号与行高、1px 8px 内边距、24px 圆角、tabular-nums),
所以它看起来和旁边两个是同一排的。
数据从哪来
宿主端:turnCost 会话投影
lib/index.js 通过 ctx.inject(['sessionProjections'], …) 注册一个 key 为 turnCost 的会话投影,
从会话事件流里读两类用量样本:
| 事件 | 取什么 |
|---|---|
assistant/chunk,且 chunk.type === 'usage' |
chunk.usage,加上 data.turn / data.step |
assistant/message |
data.usage,加上 data.turn / data.step |
usage 的四个桶按字段名映射:inputTokens → 未缓存输入、outputTokens → 输出、
cacheReadTokens → 缓存读取、cacheWriteTokens → 缓存写入;每一项都先夹到 ≥ 0(负数与非数字当 0)。
另外两件事:
request/header事件里的data.header.config.model决定当前模型 id;取不到时用default;assistant/message的data.message.id(或data.messageId)用来维护「助手消息 id → 轮次号」的映射。
同一轮不会重复计数
流式过程中会先到一个不完整的 usage 样本,回复结束时会再来一个最终样本。投影按 轮次:步 折叠:
- 同一个
轮次:步的新样本与上一个完全一致 → 直接跳过 (这顺带避免了切模型后重复上报同一样本把「模型调用次数」多算一次); - 不一致 → 先把旧样本减掉,再把新样本加上;
- 只有全新的
轮次:步才把「模型调用次数」+1。
客户端:只读投影
lib/client.js 用 props.useProjection('turnCost') 取值;取不到(宿主端没加载、或投影尚未建立)时什么都不显示。
宿主端对客户端只暴露显示需要的字段:symbol / decimals / totals / turns / latestTurn / messageTurn。
安装
前提:Node ≥ 18,本机已装 DSH。
运行时的 zod 由 DSH 宿主提供,本包在 package.json 里以 peerDependencies 声明它("zod": "*"),
不把宿主运行时复制进自身依赖树。因而:在 DSH 里装载不需要额外安装;单独 npm install 本包时,
npm 会按 peer 规则自行解析 zod。
git clone https://github.com/CHIP-PHILO-GH/dsh-turn-cost
cd dsh-turn-cost
把这个目录装成名为 dsh-turn-cost 的 DSH 插件——放进 profile 的插件目录,例如
$env:DSH_HOME\profiles\node_modules\dsh-turn-cost(DSH_HOME 未设置时是 ~/.dsh)。
装载行由本包自带的 cordis.patch.yml 提供(package.json 的 dsh.bundle.patch 指向它),
内容是往 profile 补丁层插一条 loader 条目:
- insert:
- id: turn-cost
name: 'dsh-turn-cost'
装好后重启 dsh web 生效(宿主端与浏览器端 bundle 都只在启动时加载,本插件不做热重载)。
重启后宿主端会打印一行自检日志,里面的三个值就是本次生效的符号 / 小数位 / 汇率:
[dsh-turn-cost] 已加载,计价:¥ 小数 4 位,汇率 7.2
卸载
- 从 profile 的
cordis.patch.yml里删掉id: turn-cost那条 loader 条目; - 删掉插件目录
…\profiles\node_modules\dsh-turn-cost; - 重启
dsh web。
插件自身不写任何文件,也没有自己的配置文件,所以没有残留状态要清理(页面上那两个元素随页面刷新消失)。
配置项
本插件没有自己的配置文件。 价格口径直接沿用 dsh-cost-meter 的账本配置:
<DSH_HOME>\storages\cost-meter\ledger.json → 顶层 "config" 字段
<DSH_HOME> 取环境变量 DSH_HOME,未设置时取 ~/.dsh。
这个文件不存在、读不了或格式不对时,插件不会报错,而是整份退回内置兜底配置(见「计价依据」)。
config 里被读取的字段一共这些,其余字段一律忽略:
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
symbol |
string | ¥ |
金额前缀。空串或非字符串时用 ¥ |
decimals |
number | 4 |
显示小数位。先向下取整,再夹到 0–10 |
exchangeRate |
number | 7.2 |
美元 → 人民币汇率。非正数或非数字时用 7.2 |
peakEnabled |
boolean | false |
峰谷计价开关。必须严格等于 true 才启用(字符串 "true" 不算) |
peakEffectiveAt |
string | 无 | 峰谷档生效时刻,交给 Date.parse 解析。该时刻之前的调用一律按基础价 |
peakWindows |
array | [] |
峰时段窗口,单位是 UTC 小时,见下 |
prices.models |
object | {} |
模型 id → 单价记录 |
prices.default |
object | 兜底单价 | 模型没登记时用它 |
单价记录(prices.models.<模型 id> 与 prices.default)的字段:
| 键 | 说明 |
|---|---|
cacheHit |
缓存命中价(缓存读取与缓存写入都用它) |
cacheMiss |
未命中输入价 |
output |
输出价 |
offPeak |
可选。非峰时段档位,字段同上;缺失时退回基础价 |
peak |
可选。峰时段档位,字段同上;缺失时退回基础价 |
三条归一化规则:单位一律是美元 / 1M tokens;记录里缺哪个字段,就用兜底单价里的同名字段补;
offPeak / peak 里缺的字段再用该记录自身的基础价补。
peakWindows 的每一项是 { start, end },单位是 UTC 小时(0–23):
start < end:命中区间[start, end);start > end:跨零点环绕,命中[start, 24) ∪ [0, end);- 某项的
start/end不是有限数字 → 该项被忽略。
peakEnabled 为 true 时还有一条容易踩的规则:peakEffectiveAt 必须是能解析成时间的字符串。
- 有效:该时刻之前的调用一律按基础价;之后落在窗口内用
peak档,其余用offPeak档; - 无效或缺失:落在窗口内仍然用
peak档,但窗口外一律用基础价——offPeak档不会被用到 (代码只在「调用时刻 ≥ 生效时刻」时才取offPeak)。
判定用的是调用发生的时刻(事件自带时间戳,缺失时退回当前时刻),不是「现在」。
⚠️ 配置在插件加载时读一次。改完账本要重启
dsh web才生效。
计价依据
公式
美元成本 = (未缓存输入 × cacheMiss + 输出 × output + (缓存读取 + 缓存写入) × cacheHit) / 1,000,000
人民币 = 美元成本 × exchangeRate
四个 token 桶都先夹到 ≥ 0。缓存写入按命中价(cacheHit)计费,口径与 dsh-cost-meter 一致。
汇率不是有限正数时按 1 处理。
兜底单价(写死在代码里的那份)
lib/pricing.js 的 FALLBACK_ENTRY,单位 美元 / 1M tokens,
代码注释标明对应 DeepSeek 官方 deepseek-flash 档:
| 项 | 单价(美元 / 1M tokens) |
|---|---|
缓存命中(cacheHit,含缓存读取与缓存写入) |
0.0028 |
未命中输入(cacheMiss) |
0.14 |
输出(output) |
0.28 |
兜底配置的其余部分:符号 ¥、小数位 4、汇率 7.2、峰谷开关关闭、prices.models 为空。
按兜底表算几个数(汇率 7.2):
| 用量 | 美元 | 人民币 |
|---|---|---|
| 1M 未命中输入 | 0.14 | ¥1.008 |
| 1M 输出 | 0.28 | ¥2.016 |
| 1M 缓存读取 | 0.0028 | ¥0.02016 |
| 1M 缓存写入 | 0.0028 | ¥0.02016 |
| 1M 未命中输入 + 1M 输出 + 1M 缓存读取 + 1M 缓存写入 | 0.4256 | ¥3.06432 |
有账本配置时
只要 ledger.json 读得到,用的就是账本里的价格表、汇率、小数位与峰谷规则;
内置那份兜底表只在读不到的时候整份顶上(模型级单价缺失时退回 prices.default,default 也缺失时退回兜底单价)。
能力边界与已知限制
它给的是估算,不是账单。 以下是代码层面能确定的口径与边界:
- 只统计 usage 的四个 token 桶。会话事件流里 usage 之外的任何计费项都不计入。
- 价格依赖账本。账本读不到时按内置兜底表(DeepSeek 官方 deepseek-flash 档 + 汇率 7.2)算。 你的模型或渠道不在账本里、或账本价格与你的实际合同价不同时,显示值会与实际扣费不一致。
- 缓存写入按命中价计。这是与
dsh-cost-meter对齐的口径;渠道对缓存写入另有定价时,这里不会体现。 - 峰谷档按 UTC 小时判断,且
peakEffectiveAt之前的调用一律按基础价;peakEnabled不是布尔true(例如配置里写成字符串)时峰谷规则完全不生效;peakEffectiveAt无效或缺失时,窗口外的调用不会走offPeak档,而是按基础价。 - 配置只读一次(插件加载时),改账本要重启
dsh web。 - 金额是四舍五入后的显示值:按
decimals位toFixed后去掉末尾的 0; 金额小于10^-decimals时自动多给两位小数(最多 12 位),所以极小金额不会显示成¥0。 - 「本轮」= 轮次号最大的一轮,不是「正在跑的那一轮」。
- 只有一轮时,两段文案会重复显示同一个金额:输入框下方的文案本意是「只有一轮时不重复两遍」,
但那条合并条件比较的是对象引用,而
turns里的条目和totals不是同一个对象, 所以一轮时仍会显示「本轮 ¥x · 本对话 ¥x」。这一条是从代码读出来的,没有在浏览器里逐帧实测。 - 输入框下方的药丸靠 DOM 落位:它选的是带
[data-composer-stats]标记属性的那个容器。 宿主哪天改掉这个标记,药丸就不再出现(回合尾部的徽章走的是插件槽位,不受影响)。 插件对 DOM 变化挂了MutationObserver:那一行被会话切换重建、或宿主往末尾追加自己的节点时, 会自动把自己插回并挪到最后一个位置,同时认领已存在的同类节点以避免同一行出现两个。 - 宿主端没起来就什么都不显示:客户端取不到
turnCost投影时静默不渲染(不报错、不占位)。
测试
node test.mjs
test.mjs 零依赖(只用 Node 内置模块),也不需要 DSH 在运行,更不需要装 react / zod。
它覆盖 lib/pricing.js——本插件里唯一能在离线环境判定的部分,因为它是个纯函数模块,
只依赖 node:fs / node:path / node:os。测的内容:
- 兜底单价、符号、小数位、汇率、峰谷开关;
- 从临时
ledger.json解析配置,含decimals夹取、exchangeRate兜底、peakWindows跨零点环绕; - 模型单价查找与回退(模型 →
prices.default→ 兜底单价); - 档位选择(峰谷关闭 / 生效前 / 峰时段 / 非峰时段 / 档位缺失);
- 金额公式(四个桶、负值夹 0、汇率非法按 1);
- 发布字段:包名、无
private、engines、repository、files、版本号与 CHANGELOG 一致; - 改名的三处一致性:
package.json的name==cordis.patch.yml里的name==lib/client.js里window.__ModuleLoader__.load({ id })的 id(客户端模块按这个 id 注册,对不上会加载失败); - 文档一致性:每个被读取的配置键都必须在
README.md与README.en.md里出现, 且 README 里的兜底单价与汇率与代码一致; - 仓库里所有文本文件都不含本机路径片段。
CI 跑的就是这一条命令(见 .github/workflows/ci.yml),任何 Node ≥ 18 的机器上都能跑通。
没有覆盖、也没放进 CI 的部分:
| 部分 | 为什么 |
|---|---|
lib/index.js(宿主端投影) |
import { z } from 'zod',且投影要跑在 DSH 的 sessionProjections 上下文里 |
lib/client.js(浏览器端) |
单文件 bundle,靠 window.__ModuleLoader__ 加载,要真实浏览器 + DSH 页面 |
| 端到端(真的显示出金额) | 需要 DSH 正在运行,并且有一次真实模型调用 |
这三部分只能手动核验:重启 dsh web 后看那行自检日志,再在一次真实回复后看输入框下方有没有多出
「本轮 ¥… · 本对话 ¥…」药丸、以及该轮尾巴上有没有徽章。本仓库没有对它们做自动化验证。
许可
MIT。
离线验证
陌生人 clone 后可直接运行:
node test.mjs
该命令只使用 Node 内置模块,实际 import lib/pricing.js,覆盖兜底配置、计价公式、模型/峰谷单价选择、账本解析、边界处理和发布字段一致性。输出会给出“离线可测 N 项 / 需真实环境 M 项”,并明确打印每个 SKIP;React 渲染、DSH runtime 装配、真实用量事件和浏览器页面不在离线覆盖范围内。
No comments yet. Be the first to write one.