给 DSH(DeepSeek Harness)的每一次任务加一条预算线:按 人民币金额 或 Token 数量 限制消耗,接近上限时预警,达到阈值时自动中止(auto)或询问你“继续 / 中止”(manual),并在每轮结束后估算还需多少步、还要花多少钱。
🚀 快速安装
dsh plugin --profile web add dsh-budget-guard-plugin
安装后请重启 Web Profile。完整步骤(GitHub 安装、更新/卸载、本地开发)见快速开始。
🚀 快速开始 · ✨ 核心能力 · ⚙️ 配置 · 🐛 反馈
目录
- 它能做什么
- 拦截流程
- 快速开始
- 兼容性
- 设置预算
- 配置项
- 高峰 / 空闲计价
- 怎么核对金额(和官网账单对不上时)
- 冷启动与快照
- 未知模型 / 价格未知
- 父任务 / 子代理聚合
- 重试 / 时段归属
- 手动模式:临时放行 vs 关闭提示
- HTTP 路由
- 只读字段
- 每轮预估怎么算(不花额外 Token)
- 界面
- 拦截行为细节
- 零依赖与 Config 投影
- 构建与测试
- 已知边界
- License
它能做什么
| 图标 | 能力 | 实现方式 |
|---|---|---|
| 🚦 | 预算声明 | 插件 Config(全局默认)或对话里的 /budget 命令(仅当前会话) |
| 💸 | 实时核算 | 从会话日志的 assistant/message.usage 累计 input/output/cache Token;每条消息按它实际发生时刻的北京高峰/空闲价计费 |
| 🛑 | 自动拦截 | agent/turn-stopping 达到 interceptRatio 时调用 agent.cancel({ kind: 'hook' });agent/pre-step 在超预算时拒绝下一步,避免继续产生模型调用 |
| ✋ | 手动拦截 | manual 模式只提示,不自动取消;composer 下方的预算条出现「继续 / 中止」按钮 |
| ⚠️ | 预警 | 达到 warnRatio(默认 80%)在预算条与每轮卡片上显示醒目提示 |
| 📐 | 任务前粗估 | 首轮开始前给出 3–12 步、金额/Token 范围与预算占比(可关) |
| 📊 | 降级预估 | 只展示近期历史燃烧率(最近 3 轮/步的平均消耗);只有模型明确写出计划时才显示剩余步数与剩余金额,不预测未来总花费;不新增 LLM 调用 |
| 🧭 | 输出位置 | estimateOutput: chat(每轮卡片)、sidebar(右侧栏常驻页)、both(默认)。右侧栏页由 dsh-better-sidebar 提供;没有该 bundle 时自动退回卡片 |
| ✅ | 配置校验 | 插件加载时校验枚举、数值范围、未知键;无效值直接报 ValidationError |
| 🛡️ | 安全护栏 | /config/save 与 /action 默认只接受本机 loopback 请求;冷启动快照 + 从会话日志重放,保证账目可恢复、不被重复计费 |
拦截流程
flowchart LR
U["用户请求"] --> M["agent/pre-step 拦截器"]
M -->|预估成本| C["计费引擎"]
C -->|匹配价格表| P["高峰/空闲计价"]
P -->|累计| E["预算判定"]
E -->|未超阈值| A["放行"]
E -->|达到阈值| B["拦截/询问"]
E -->|超出| X["强制中止"]
每条 assistant/message 都按它自己的 event.time 逐条计价后累计;判定使用金额与 Token 两条独立预算线,任一先到即触发,值为 0 的维度不参与。
快速开始
环境要求
- DeepSeek Harness
0.2.x或更高版本; - Node.js
>=22.13; PATH中存在 pnpm,供dsh plugin调用。
从 npm 安装(推荐)
dsh plugin --profile web add dsh-budget-guard-plugin
DSH 会识别包内的 dsh.bundle manifest,并自动把 dsh-budget-guard-plugin 加入 Web Profile。安装完成后,请重启正在运行的 Web Profile。
安装后 bundle 会插入一行 budget-guard,默认配置为:
- insert:
- id: budget-guard
name: 'dsh-budget-guard-plugin'
config:
interceptMode: auto
enableTurnEstimate: true
estimateOutput: both
package.json 已声明 dsh.bundle.patch、exports["./client"]、locale/*.json、icon,files 包含 lib/ 与 cordis.patch.yml,因此打成的 npm 包也能被 dsh plugin 识别。
从 GitHub 安装
dsh plugin --profile web add github:Existence001100/dsh-budget-guard-plugin
需要测试 main 中尚未发布的变更时,可以使用 GitHub 来源;如需可复现安装,请固定到具体 commit。
更新或卸载
dsh plugin --profile web update dsh-budget-guard-plugin
dsh plugin --profile web remove dsh-budget-guard-plugin
执行任一操作后请重启 Profile,确保运行时 composition 与已安装 Bundle 一致。
安装本地 Checkout 进行开发
git clone https://github.com/Existence001100/dsh-budget-guard-plugin.git
cd dsh-budget-guard-plugin
pnpm install --frozen-lockfile
node build.mjs
dsh plugin --profile web add .
插件自带的 cordis.patch.yml 会把 budget-guard 插入宿主 composition,对所有会话生效。
兼容性
| 组件 | 支持或持续验证范围 |
|---|---|
| DeepSeek Harness | 0.2.0 及以上版本。持续跟进 DSH 宿主 API 变更。 |
| Node.js | >=22.13 |
| pnpm | 最新版,配合 dsh plugin 使用 |
| 操作系统 | Windows / macOS / Linux |
本插件基于 DSH 官方扩展点(agent/turn-stopping、agent/pre-step 等)开发,不侵入模型核心逻辑。如果在使用中发现特定版本的 DSH 存在接口不兼容,请提交 Issue。
设置预算
方式一:对话命令(推荐,只影响当前会话)
/budget 2.5 # 本次任务 ¥2.50
/budget 20000 tokens # 本次任务 20k tokens
/budget 20k # 同上
/budget both 2 20000 # 金额 + Token 双上限(任一先到即触发)
/budget # 查看当前预算、已用、进度与上一轮预估
/budget continue # manual 模式:继续,再增加约 10% 预算后再次提示
/budget abort # manual 模式:中止当前轮次
/budget unlock # 解除本会话的预算拦截(费用仍统计,但不再中止/拦截)
/budget lock # 恢复本会话的预算拦截
/budget off # 清除会话覆盖,回到 Config 默认值(同时恢复拦截)
/budget reset-snapshot # 丢弃本会话的缓存与快照条目,按会话日志重新计费(排障用)
预算为 0(未设置)时不会拦截。 budgetAmount 与 tokenBudget 默认都是 0,含义是“这条线不存在”:不会取消轮次、不会在 pre-step 拒绝、也不会弹“是否继续”。只有金额或 Token 任一 > 0 才启用阈值判定。你没设预算就会被拦,是 bug 而不是设计——有回归测试钉住(花掉几十万 token 也不 cancel / reject)。
/budget reset-snapshot 用于“明细无法回滚导致结算被拒绝”的排障:
- 清理范围:本会话的计费缓存(
cost、stepCharges、settledCharges、结算水位、blockedSettlements)全部重建。 - 保留:「继续 / 不再提示」等手动决定与
/budget设的预算声明——它们是用户的选择,不是账目。 - 一次性操作:不写「可重放的效果」。日志里那条
command/run在重启重放时只会被识别成一个无副作用的参数,不会每次重启都再清一次缓存。 - 多实例边界:执行时立即按日志重算并写一份时间戳更新的快照(墓碑);同一会话在目录里“
savedAt最新者为准”,所以别的实例那份旧缓存不会被读回来。若另一个实例在 reset 之后又写入新状态,则以新状态为准(那是它自己折得更远的日志视图)。 - reset 之后:下一次读取就是重算后的真实状态——不会短暂显示
loading,也不会先归零再跳回;日志里确实有花费时,重算结果与日志一致。
命令不会调用模型,只写一条 command/run / command/done 日志;重启后插件会从 command/run 重放这条预算声明。
方式二:输入框下方预算条(只影响当前会话)
输入框下方只保留一行 未设置预算 · 已用 ¥0.00(有预算时是 预算 ¥2.00 · 已用 ¥1.15)。点击这行会展开:
- 费用明细:输入(未命中)/ 缓存命中 / 输出、峰价与谷价、最近模型、当前时段、Token、未知模型提示;
- 「设置本次预算…」按钮:展开金额与 Token 输入框,保存后通过同一条
/budget命令写入会话日志,因此可重放、可审计。
方式三:Config 默认值 / 设置页
在 profile 的 cordis.patch.yml 中覆盖本行,或打开 设置 → 预算护栏。设置页覆盖常用可调项(21 个),每项都带一句说明:
| 分组 | 可调项 |
|---|---|
| 拦截 | 拦截模式(auto / manual 滑动开关)、预警比例、拦截阈值、未知模型告警、累计放行上限、手动硬停倍数、每步前硬拦截 |
| 每轮预估 | 每轮预估开关、输出位置(对话卡片 / 右侧栏 / 两者)、预估最小间隔、任务前粗估开关 |
| 默认预算 | 默认金额、默认 Token、币种 |
| 快照 | 冷启动快照开关、保留会话数、非活跃保留天数、明细保留轮数、每会话结算记录上限(snapshotPath 不在此处,见下) |
| 访问 | 仅本机可改设置(requireLocalControl)、仅本机可操作本轮(requireLocalAction) |
4 个键不在表单里,只能手改 cordis.patch.yml:snapshotPath(自由路径)、allowedOrigins(数组)、holidays(日期数组)、useBuiltinHolidays(与 holidays 联动的开关)。测试会断言「表单键 + 这 4 个 == DEFAULTS 的全部键」,所以新增 Config 键时漏加控件会直接测试失败。
比例项以百分数输入(85 即 0.85)。底部显示价格来源、节假日来源、跨源白名单数量、右侧栏是否可用。
点「保存全部设置」后走 POST /dsh-budget/config/save:Host 把补丁合并进当前 Config,再用与插件加载完全相同的校验器校验整体,因此跨字段规则(warnRatio ≤ interceptRatio、放行上限必须低于硬停线)会在这里就被拒绝,并返回具体原因;未知键直接 400。写回的是当前 bundle 行的 config,与手工编辑 cordis.patch.yml 等价。
0.3.10 修复:设置页曾把已经是比例的
warnRatio: 0.8再除以 100 变成0.008,导致「保存全部设置」每次都被 Host 以warnRatio must be ... between 0.01 and 1拒绝。现在提交的仍是0.8 / 0.95 / 0.2。
- id: budget-guard
config:
budgetAmount: 2
tokenBudget: 0
currency: CNY
warnRatio: 0.8
interceptRatio: 0.95
interceptMode: auto
enablePreEstimate: true
enableTurnEstimate: true
estimateOutput: both
turnEstimateMinInterval: 1500
enforceAtPreStep: true
holidays: []
金额与 Token 是两条独立预算线;两条都配置时,任意一条达到阈值就触发拦截。未配置(值为 0)的维度不参与拦截。
配置项
| 键 | 默认值 | 说明 |
|---|---|---|
budgetAmount |
0 |
每个会话默认金额预算;0 表示不设金额上限 |
tokenBudget |
0 |
每个会话默认 Token 预算;0 表示不设 Token 上限 |
currency |
CNY |
只支持 CNY / RMB;两者都是人民币,统一显示 ¥(不做汇率换算) |
warnRatio |
0.8 |
预警比例,必须 ≤ interceptRatio |
interceptRatio |
0.95 |
拦截阈值(0.01–1) |
interceptMode |
auto |
auto:自动取消;manual:只提示,等用户点「继续 / 中止」 |
enablePreEstimate |
true |
任务开始前给出粗估 |
enableTurnEstimate |
true |
每轮结束后显示消耗估算:未提供计划时只展示近期历史均值,不做未来外推 |
estimateOutput |
both |
chat / sidebar / both |
turnEstimateMinInterval |
1500 |
两次预估的最小间隔(毫秒),避免连续快轮重复计算 |
enforceAtPreStep |
true |
在 agent/pre-step 也做硬拦截,防止超预算后继续买模型调用 |
holidays |
[] |
法定节假日(YYYY-MM-DD)。非空时完全覆盖内置表;为空 / 全部非法且 useBuiltinHolidays=true 时回退内置表 |
useBuiltinHolidays |
true |
为空时是否允许回退内置 2024–2026 节假日表;false 时为空表示“无节假日”;2027 起内置表超范围会 warn |
allowedOrigins |
[] |
开发用跨源白名单;生产默认只允许同源,见“HTTP 路由” |
enableSnapshot |
true |
将累计状态写入冷启动快照 |
snapshotPath |
'' |
快照基路径;空值时使用 $DSH_HOME/budget-guard/snapshots.json,实际写入 <stem>.<pid>.json |
snapshotMaxSessions |
200 |
每个实例快照最多保留的会话数;活跃会话优先保留 |
snapshotKeepTurns |
8 |
快照保留最近多少轮的全部 stepCharges 明细;更早的重试会被拒绝并 warn,不会重复累加 |
snapshotMaxAgeDays |
30 |
非活跃快照的最长保留天数;0 表示不按时间清理 |
unknownWarnRatio |
0.2 |
未定价 token 占比达到此值时提示“按最高价估算,实际可能偏低” |
maxGrantedPercent |
30 |
manual「继续」最多累计放行比例;默认终值 130%,严格低于 142.5% 硬停线 |
manualHardStopRatio |
1.5 |
manual 模式(含 suppress)超过 interceptRatio × 该值 时强制中止 |
perSessionMaxSettled |
2000 |
每个会话保留多少条已结算摘要;超过后新步骤只记标记,重试会被拒绝而不是替换 |
requireLocalControl |
true |
/config/save(改全局预算)只接受本机(loopback)请求;远程 GUI 改预算需显式关闭 |
requireLocalAction |
true |
/action(继续 / 中止 / 设置本次预算)只接受本机请求;可单独关闭以支持远程 manual 操作 |
单价只影响“金额”核算;Token 预算始终按真实 usage 计算。价格不在 Config 里硬编码,见下一节。
GET /dsh-budget/config 还返回 4 个只读字段(output / holidaysSource / priceSources / server),它们不能通过 /config/save 写回;含义见「HTTP 路由」下的「只读字段」小节。
高峰 / 空闲计价
- 时区固定
Asia/Shanghai(UTC+08:00,无夏令时),用固定偏移读取北京时间,不依赖时区库。 - 高峰:周一至周五
09:00–12:00、14:00–18:00(左闭右开);其余时间、周六周日、holidays中的日期全天为空闲。 - 判定基于每条
assistant/message的event.time,不是渲染时的当前时间;同一会话跨峰谷时逐条计价后求和。 - 单价单位:元 / 百万 token,结构:
{
"models": {
"deepseek-flash": {
"peak": { "input_miss": 2, "input_hit": 0.04, "output": 8 },
"offpeak": { "input_miss": 1, "input_hit": 0.02, "output": 4 }
}
}
}
0.4.0 修正:
output回到官方标价8(高峰)、4(空闲 50%);输入未命中2 / 1、缓存命中0.04 / 0.02(DeepSeek 官方调价后)。同时删除了独立的缓存写入计价项:缓存写入 token 已包含在input_miss中,按未命中价计费。
- 优先级:代码内置默认 → prices/official-prices.json →
prices/local-prices.json。把 prices/local-prices.example.json 复制为prices/local-prices.json即可覆盖任意字段。 - 仓库里的
official-prices.json是保守示例值(插件不允许联网拉价);上线前请用local-prices.json覆盖为你实际账单上的官方单价。修改价格文件后禁用/启用一次插件即可生效。 offpeak缺失字段默认取peak × 50%。价格表只有input_miss/input_hit/output三个字段;缓存写入没有单独单价。- 未知模型:token 计入
unknownTokens,展示费用按真实已知计(未定价部分为 0),拦截判定另用shadowCost;预算条与明细显示未知模型警示。 - 内置节假日表是覆盖 2024–2026 的保守清单;请按官方安排通过
holidays覆盖。 - 缺字段:按 0 计并记录 warn;非法时间戳:跳过该条并 warn。
- DSH 的
usage.inputTokens已经是“未命中输入”,cacheReadTokens是额外字段;计价按inputTokens × input_miss + cacheRead × input_hit,不会把缓存命中重复按未命中收费。对把缓存包含在inputTokens里的 OpenAI 风格 usage,代码会检测totalTokens关系并先减去cacheReadTokens。
怎么核对金额(和官网账单对不上时)
插件不调用计费接口,它只是把 assistant/message 里的 usage 按本地价格表换算。核对三步:
- 看口径:
/budget prices打印字段映射与每 1M token 的单价;预算条明细底部也有一行「计费口径(每 1M token,高峰/空闲价)」。 - 看单笔:
/budget debug 5打印最近 5 笔结算的 key、时间、时段、各项 token、单价、单项金额与合计,并附上 Host 传来的原始 usage JSON;单价取结算当时记录的charge.price,事后再改价格表也不会让公式和记录值对不上。 - 清缓存重算:怀疑旧快照累加有问题时,
/budget reset-snapshot丢弃缓存并按日志重放。
常见差异来源:
| 来源 | 说明 |
|---|---|
| 缓存命中率 | 命中价通常只有未命中价的 1/10;命中率从 90% 掉到 60%,金额能翻几倍——这是账单差异最常见的原因 |
| 时段判定 | 工作日 09–12、14–18 高峰(2×),其余与周末/节假日按基准价;/budget debug 会标出当时的判定 |
| 四舍五入 | 单项线性计算,合计保留 6 位小数后展示,对账差几厘属正常 |
| 未定价模型 | 展示按真实已知(通常 0),拦截按“已加载价格表里的最高单价” |
| 厂商调价 | 内置价是示例值;改 prices/local-prices.json 即可,无需改代码 |
冷启动与快照
- 每条快照包含最近
snapshotKeepTurns轮的全部stepCharges(sessionId:turn:step、peakCost/offpeakCost、eventTime、seq)。 - 每个
assistant/message、turn/end、command/run之后都同步 flush 一次快照(其余事件走 1.5s 防抖):这三个点会改变结算或预算声明,热重载不执行 effect cleanup 时也不能丢。 - 除了
stepCharges,每条快照还保存settledCharges:每个已结算 step 的回滚摘要,14 个字段固定顺序(tier, unknown, cost, shadow, tokens, input, inputMiss, output, cacheRead, 三个分项, model, seq)。某 step 的明细被轮次截断后,重试仍能「先减旧、再加新」,不再拒绝。byModel的calls/峰谷拆分与byTier.tokens不需要单独入摘要:它们由(model, tier, cost, tokens)推导(见addBucket的契约),摘要里这四个值已经够用。 settledCharges有两级上限:最近perSessionMaxSettled(默认 2000)条保留可回滚摘要;更早的记录降级为null标记(约 30 字节,只表示“已计费”),其重试会被拒绝并计入counters.blockedSettlements,绝不会重复计费。整张表硬上限为2 × perSessionMaxSettled,超出后丢弃最早的记录(counters.settledEvicted计数 + 警示):新步骤永远会被记录(先摘要、后标记),只有被丢弃的极旧记录若重试才会重复计费。- 降级是批量的:摘要数达到 cap 后,一次把最早的 64 条(
DEMOTE_BATCH)改成标记,然后接下来 64 次结算都是 O(1) 追加。所以稳态是摊还 O(1),不会出现每步扫描 2000 条的 O(n²)。 - 审计计数(
replacedSettlements、blockedSettlements、settledEvicted、invalidTimeMessages)在多进程合并时取 max,是保守下界:两个进程可能各替换/拒绝/逐出了不同的 step,真实总数只会更大。它们适合看“有没有发生”,不适合精确对账。 - 这样设计的原因是:重试只发生在仍在进行的那一轮,所以“最新窗口保留摘要、旧记录只留标记”既保证替换正确,又让快照体积有界。
- 每个实例写入自己的
<stem>.<pid>.<startedAt>.json,读取时合并同目录所有<stem>*.json;同一会话取savedAt最新记录。 - 多实例写入改为合并:合并永远不加钱,只对账记录。水位取
max(lastSeq, settledSeqWatermark):lastSeq= 已折叠的最大事件 seq;settledSeqWatermark= 已结算 charge 的最大 seq,逐出不会让它回退。- 进入合并的条件是
磁盘 lastSeq >= 本进程 lastSeq,即磁盘在同一份日志上折得不比本进程浅。此时本进程每一笔结算的seq都 ≤ 磁盘水位,磁盘总额必然已经包含它们,所以合并没有“补金额”这条路径(adopted计数在 0.3.3 已删除)。 - 这条规则专门防「逐出」:逐出会删掉最早的
settledCharges记录,但金额仍留在cost里。若按“磁盘有没有列出这个 key”来判断,被逐出的 step 看起来像“磁盘从没结算过”,会被另一个进程补入并重复计费。 seq不超过水位的 key 才补一个标记(null,无摘要、无金额),作用仅是让后续重试被拒绝而不是重新计费;超过水位的 key 什么都不补,留给磁盘自己结算(补了标记会让磁盘拒绝自己的第一次结算)。不会搬运摘要:磁盘记的是它自己的金额,搬过来做回滚会与总额不一致。- warn 会区分两种结果:
kept N marker(s) without money/dropped N already-known key(s)。没有“补金额”也没有“留给磁盘”那两个计数——合并路径上不存在这两种情况(前者在 0.3.3、后者在 0.3.4 被确认为不可达并删除)。
lastSeq相等时:仍以本进程为写入方(保留最近明细的可替换性),只把settledSeqWatermark向磁盘最大值纠偏,绝不回退(回退会让下次合并误判“磁盘没见过很多 step”)。日志里是snapshot watermark restore。lastSeq的语义:per-session 的日志水位,等于该状态已折叠过的最大session/event.seq;合并时取 max,快照里存于session.lastSeq,冷启动用它作为重放起点(event.seq <= lastSeq的事件跳过)。- 清理旧实例文件时按文件名里的
<pid>.<startTs>判断:本进程 pid 但startTs不是本次启动(pid 复用)的文件直接删除;其他 pid 只要进程仍存活就不删,否则按snapshotMaxAgeDays清理。SNAPSHOT_VERSION=2起的文件才会被读取;0.3.x 的旧格式不会被半解析,直接按日志重放重算。 - 冷启动读取快照,UI 先显示
≈¥17.09(待校正),不会拿 0 冒充真实值;agent/created或新事件到达后按日志重放校正,stale变回false。 - 没有任何状态且也没有快照时,接口返回
loading: true,预算条显示“预算状态加载中…”。 snapshotMaxSessions/snapshotMaxAgeDays控制增长:活跃会话优先保留;非活跃快照按年龄清理。损坏的 JSON 文件 warn 后跳过,不影响其他实例。- 快照是派生缓存,不是真相:真正的累计仍来自会话日志;日志比快照新时以日志为准。
结构变更(0.4.0):快照版本升到 2,摘要从 16 项减为 14 项(去掉两个缓存写入位置),旧的 0.3.x 快照不再读取。
// 0.3.x(不再读取)
{ "settledCharges": [["s:1:1", [1,0,0.002,0,1000,1000,1000,0,0,0,0.002,0,0,0,"deepseek-chat",3]]] }
// 0.4.0
{ "settledCharges": [["s:1:1", [1,0,0.002,0,1000,1000,1000,0,0,0.002,0,0,"deepseek-chat",3]],
["s:1:2", [1,0,0.002,0,1000,1000,1000,0,0,0.002,0,0,"deepseek-chat",4]]],
"settledTrimmed": false, "settledOverflow": false, "settledEvicted": 0,
"settledSeqWatermark": 4,
"stepCharges": [["s:1:2", { "cost": 0.002, "...": "..." }]] }
摘要数组顺序固定为 14 项:[tier(1=peak), unknown(1), cost, shadow, tokens, input, inputMiss, output, cacheRead, bdMiss, bdHit, bdOut, model, seq]。seq 有三个角色:① 审计;② 跨进程合并的水位比较(见上节);③ 不参与 applyCharge 的加减——替换判定以 (sessionId, turn, step) 这个 key 为准。
settledSeqWatermark(0.3.2 新增)是“已结算 charge 的最大 seq”,逐出与降级都不会让它回退。旧快照没有该字段时按 lastSeq 推导(偏向“已结算”,宁可拒绝补入也不重复计费)。
旧快照兼容:版本 2 之前的文件会被忽略,冷启动按会话日志完整重放。这样 0.3.x 的 16 项摘要不会用错误布局回滚金额。
15 项旧摘要(seq 字段出现之前的布局):这类记录无法回滚。处理方式是“宁可少计、不可重复”——该 key 的旧结算不再可替换,后续重试会被拒绝并计入 blockedSettlements,账目停在旧值;合并时它也只会变成标记,永远不会被补金额。
看到「有 N 次结算被拒绝」怎么办(blockedSettlements):
- 它是历史累计次数,只增不减——反映“有多少次重试被拒绝”,不代表当前有多少条坏数据。反复重试会让这个数字一直涨,账目本身并没有变得更糟。
- 常见来源:旧版本快照里的 15 项摘要、被逐出的极旧步骤、手工编辑过的快照文件。
- 恢复路径(账目以会话日志为准,快照只是缓存):
- 在会话里执行
/budget reset-snapshot:丢弃本会话的缓存与快照条目,并按日志重新计费。它会写一份时间戳更新的墓碑快照,因此其他实例的旧缓存不会被读回来(详见上一节的“多实例边界”)。 - 想彻底清空:关闭 DSH,删除
$DSH_HOME/budget-guard/snapshots.*.json,再启动。所有会话都会从日志重新重放(会丢掉冷启动加速,账目更准确)。
- 在会话里执行
- 两种做法都不会改会话日志,也不会改任何预算设置。
未知模型 / 价格未知
- 未定价模型 token 计入
unknownTokens。展示费用按真实已知计算(通常为 0),拦截判定使用shadowCost—— 两者必须同时显示,否则会出现“看着没超却被拦”。 shadowCost的口径固定为:取已加载价格表中「整模型 × 整时段」总价最高的那一个(不是逐字段取最高,避免组合出不存在的超高价;也不按当前时段打折),即“最贵模型的最贵时段价”。单元测试固定了这条规则。文案统一写成「已加载价格表中的最高单价」,不要写成“全网最高价”。- 有 shadowCost 时,预算条主行显示
已用 ¥1.20(判定 ¥2.50,含未知估算),真实值与拦截值同时可见;明细展开“未知估算(仅用于拦截判断)”。 - 未定价 token 占比达到
unknownWarnRatio(默认 20%)时提示“未定价模型占 X% token,已按已加载价格表中的最高单价估算拦截,实际可能偏低”。 - 预算条追加
· 价格未知,文字变黄并带⚠。 /budget prices打印已加载模型、价格来源、未定价模型清单和处理方式。- 补齐
prices/local-prices.json后禁用/启用插件,标识自动消失。 - 未做“同厂商均价”兜底:那会制造另一个看似精确但不准确的金额;当前选择明确显示“价格未知”,并在拦截层使用最高已知单价保护。
父任务 / 子代理聚合
- 每条状态记录
parentSessionId(来自SessionHeader.parentSession);/dsh-budget/state附带aggregate:{ self, children[], childrenCost, totalCost, childrenTokens, totalTokens, complete, scope }。 children[]是递归展开的,带depth;childrenCost包含所有已发现后代。complete目前只表示“已发现集合里没有快照-only/待校正子代理”,并附scope: 'discovered';DSH 没有稳定的SessionHeader.children时无法证明未加载子代理不存在(TODO: 需确认)。- 预算条明细显示“含子代理:自身 ¥A + 子代理 ¥B = ¥C”,并可展开每个子代理。
- 聚合只影响展示,不改变父/子各自的预算拦截;父任务统一限额仍需 Host 提供稳定
taskId(TODO: 需确认)。
重试 / 时段归属
- 每笔结算封装为单一
Charge,回滚时applyCharge(state, charge, -1)整体减掉cost/shadowCost/totalTokens/input*、costBreakdown、byModel、byTier、unknownTokens/unknownModels;新增维度不会漏。被截断的结算用同字段的摘要回滚,口径一致。 - 「继续」默认最多累计放行 30%(终值 130%),低于硬停线
0.95 × 1.5 = 142.5%;Config 校验会拒绝1 + maxGrantedPercent/100 >= interceptRatio × manualHardStopRatio的组合,错误信息会写出具体数值和该改哪个字段。 counters.replacedSettlements/blockedSettlements/lastSeq/settledCharges都是 per-session 且写入快照,重启后审计计数保留;三者都会出现在/dsh-budget/state的counters里,预算条明细也会显示“有 N 次结算被拒绝 / 已结算 M 条 / lastSeq X”。- 时段归属使用
assistant/message的event.time(提交时刻),不是渲染时间;toEpochMs支持Z/+08:00/ 不带时区(按 Asia/Shanghai 解释并 warn)三种格式。 - 构造跨整点流式用例时,按“提交时刻”验收。
手动模式:临时放行 vs 关闭提示
manual达到阈值时只提示,不取消。- 「继续 (+10%)」每次放行 10% 预算,提示显示“累计已放行 X%”;累计达到
maxGrantedPercent(默认 30%,终值 130%)后按钮变为「已达上限」,只能中止或/budget off。也就是说默认点 3 次就到顶,第 4 次已经没有“继续”可点。 - 「不再提示」设置
manualSuppressed:本次任务内不再弹提示,但仍记录费用;超过interceptRatio × manualHardStopRatio(默认 1.5,即约 142.5%)时仍强制中止,避免完全绕过护栏。 - 「中止」调用
agent.cancel({ kind: 'user' })。
被拦截之后怎么继续(auto 模式同样适用)
auto 模式到达阈值会直接取消本轮。取消不是终点:预算条下方会出现一行
本轮已因预算中止。 [继续 (+10%)] [解除限制] [查看明细]
- 继续 (+10%):把下一次拦截线推后约 10% 预算,够你再跑几步;累计上限仍是
maxGrantedPercent。 - 解除限制:本会话不再因预算中止或拒绝(
/budget unlock,等价于在命令里敲一次)。费用照常统计、明细照常更新,只是不再拦你——这才是“被卡住后能继续工作”的出口。输入/budget lock、或在命令里重设一次预算(/budget 3)都会自动恢复拦截。 - 解除状态写在快照里,重启/重放后仍保持;预算条会显示
· 已解除限制。
设计取舍:解除限制是按会话的,不写回 Config,不影响其他会话;它也不会清掉已记录的消费。
/budget off清除会话覆盖、回到 Config 默认预算,并按实际配置分四种文案:
| 默认配置 | 文案 |
|---|---|
| 金额 + Token | 已清除会话覆盖,恢复默认预算:默认金额 ¥2.00 / 默认 Token 20.0k tokens(仍会按默认预算拦截)。 |
| 仅金额 | …恢复默认预算:默认金额 ¥2.00(仍会按默认预算拦截)。 |
| 仅 Token | …恢复默认预算:默认 Token 20.0k tokens(仍会按默认预算拦截)。 |
| 都没有 | 已清除会话覆盖;当前没有默认预算,本会话不再拦截。 |
只有金额与 Token 都为 0 才算“没有默认预算”;只设了 Token 线时同样会继续拦截。
HTTP 路由
/dsh-budget/state、/dsh-budget/action、/dsh-budget/config、/dsh-budget/config/save。/action的set-budget传amount: 0, tokens: 0等价于/budget off:清除会话覆盖、回到 Config 默认预算(不需要 live agent)。/config返回全部 Config 键,外加四个只读字段output、holidaysSource、priceSources、server(见下节「只读字段」)。/config/save的键白名单直接来自DEFAULTS:新增 Config 键自动可保存;未知键 400unknown-key,越界/枚举错误/跨字段冲突统一 400invalid,issues是[{ path: ['字段'], message: '原因' }]数组,设置页会把path+message直接显示出来。- 凭证机制(0.3.0):
Origin和Sec-Fetch-Site都是普通请求头,脚本可以随意伪造,所以它们不是凭证。真正起作用的是 TCP 对端地址。读与写分开:/config/save(改全局预算)默认只接受 loopback 对端(requireLocalControl,默认true);/action(继续 / 不再提示 / 中止 / 设置本次预算)默认同样只接受 loopback(requireLocalAction,默认true),但它可以单独关闭:它只影响当前会话,风险低于改全局预算;- 读路由(
/state、/config)保持同源放行,远程 GUI 能正常看到数字; - bind 地址通过
webServer.getOrigin()读取并写入/config的server字段;非 loopback 绑定时连Sec-Fetch-Site兜底也取消,必须精确Origin。
- 远程 GUI 的 manual 工作流(
TODO: 需确认的答案):远程浏览器默认不能继续/中止(403)。两条可行路径:① 用 SSH 端口转发把127.0.0.1:56675映射到本地,请求源地址就是 loopback;② 在设置页「访问」里关闭requireLocalAction,让/action接受同源远程请求(/config/save仍受requireLocalControl保护,两者独立)。关闭后局域网内同源页面也能继续/中止当前会话,风险自负。 Origin: null显式拒绝;allowedOrigins通过 URL 规范化比较协议、主机和端口,精确匹配,不支持通配符。- 开发跨端口时设置
allowedOrigins: ['http://localhost:3000'],生产保持默认空列表;不提供 CORS。 - 反向代理:如果 GUI 通过反向代理访问(
Origin/Host与 DSH 实际绑定的不同),同源判定会失败。要么让代理保持同源(改写Host并转发为127.0.0.1),要么把代理后的 origin 写进allowedOrigins。注意allowedOrigins只是跨源放行,不会绕过/config/save、/action的 loopback 要求——反代场景请配合requireLocalControl/requireLocalAction按需调整。 - 当前部署实测(
server字段与getOrigin()):监听在127.0.0.1,非 loopback 客户端无法连上;如果将来改成0.0.0.0,插件会在日志里 warn 并自动启用精确 Origin 规则。
本地保存被 403 拦住时怎么查
/config/save 与 /action 的规则(0.3.9 起,0.3.10 补齐 Host 回退):
- 对端必须是本机(loopback)——除非你把
requireLocalControl/requireLocalAction关掉; Host必须指向这个监听器(127.0.0.1:端口/localhost:端口,或allowedOrigins里配置的代理主机)——这是防 DNS rebinding 的关键,evil.com解析到 127.0.0.1 也过不了;- 满足上面两条时不再要求
Origin。早期版本强制要求Origin或Sec-Fetch-Site,而桌面壳/部分同源 fetch 两个头都不发,于是本机保存被误杀——这是 0.3.9 修掉的真实问题。0.3.10 起,即使webServer.getOrigin()不可用(server.checked: false),只要对端是 loopback 且Host是 localhost/127.0.0.1/[::1] 也放行,不再依赖 bind 元数据。
被拒时,GET /dsh-budget/config 的 server.lastReject 会给出 { remote, forwardedFor, host, origin, secFetchSite, reason, at }:
| reason | 含义 / 处理 |
|---|---|
not-loopback |
对端不是本机:你可能是从远程访问,或代理把 localhost 转走了。让 localhost / 127.0.0.1 直连;确实要远程改就关掉 requireLocalControl |
no-origin-and-host-not-local |
没带 Origin,且 Host 不是本监听器(反代/自定义域名)。把代理后的 origin 加进 allowedOrigins |
no-origin-on-public-bind |
服务绑在 0.0.0.0,此时必须带精确 Origin(任何人都能连,Host 不再证明什么) |
origin-host-mismatch / origin-null |
Origin 与 Host 不一致,或被显式置为 null |
无法改代理时的逃生舱:设置页「访问」里关掉 requireLocalControl(或在 cordis.patch.yml 给 budget-guard 行加 requireLocalControl: false)。代价是同网段任意页面都能改全局预算,用完建议改回来。
只读字段
GET /dsh-budget/config 比 Config schema 多出四个字段,它们不能通过 /config/save 写回(写进去会被判为未知键):
| 字段 | 含义 |
|---|---|
output |
由 enableTurnEstimate + estimateOutput 推导的允许位:{ mode, chat, sidebar } |
holidaysSource |
config / default / none,节假日表实际来自哪里 |
priceSources |
已加载的价格来源顺序,如 ["内置默认", "official-prices.json"] |
server |
{ origin, host, loopback, checked },webServer 实际公告的绑定地址 |
每轮预估怎么算(不花额外 Token)
每轮 turn/end 提交后,插件只读取本会话已经记录的数字,不再用历史平均值预测未来:
- 有明确计划
- 仅当模型在本轮文本中自己写出计划(
共 6 步、Step 3 of 8、6 steps)时,才计算剩余步数; - 剩余金额/Token 用“近期平均每轮(或每步)消耗 × 计划剩余步数/轮数”估算;
- 返回数据带
source: 'model_plan',UI 会标注「(模型计划,仅供参考)」。
- 仅当模型在本轮文本中自己写出计划(
- 没有明确计划
remainingSteps、remainingCost、remainingTokens、projectedCost、projectedTokens全部返回null;- 只展示近期燃烧率:最近 3 轮(不足 3 轮时用已有轮次;本轮尚未结束则用最近 3 步)的平均
¥和 Token; - UI 显示
近期消耗:约 ¥0.05 / 15k tokens 每轮,不会出现“预计还需 X 轮”或“预计花费 X 元”。
- 超预算提示
- 已消耗达到
warnRatio(默认 80%)时,照常显示红色预算预警; - 未到预警线、但“模型计划”外推出的预计值会超时,只显示黄色小字
按近期速度,可能会超出预算; auto模式仍按原阈值硬拦截,不会因为“预估还没超”而放宽。
- 已消耗达到
估算结果始终带「估算」标记;它不是新的模型请求,不会改变会话历史,也不会重复计费。历史均值不是承诺,单步消耗可能剧烈波动。
界面
- 每轮卡片(
conversation.chat.turnTail):在该轮操作行之前显示「预算进度」,只在最新一轮出现。有模型计划时显示完成度、剩余步数/轮数、剩余金额/Token,并标注「(模型计划,仅供参考)」;无计划时只显示进度条、已用金额与「近期消耗:约 ¥X / Y tokens 每轮」,不显示预计合计。超预算仅在达到warnRatio时红色,计划外推只给黄色小字提示。 - 右侧栏页(
budget-guard:estimate,DSH 右侧栏的一个原生标签页):标题「预算与进度」,常驻显示预算 / 已用 / 判定 / Token、进度条;有模型计划时显示剩余步数与金额,无计划时显示近期燃烧率,不显示预计合计。下方是完整费用明细与会话预算表单。标签页由dsh-better-sidebar(ctx.betterSidebar.registerTab,single: true表示该 id 全局单例、重复注册只聚焦不叠加)注册。- 打开时机:
estimateOutput=sidebar时每会话自动打开一次(localStorage记录,7 天过期,多个标签页不会重复抢焦点);both时只在“模型计划外推会超预算”时自动打开——它不是常驻面板,想要常驻请选sidebar或点预算条明细里的「在右侧栏打开…」。 - 历史均值可能低估或高估,但它不再被用来预测未来;预警仍会通过预算条变色与卡片提示。
- 打开时机:
- 常驻预算条(
conversation.composer.dock):与 Host 自己的 usage 行同高同字号。主行只显示未设置预算 · 已用 ¥37.07或预算 ¥2.00 · 已用 ¥1.15;有shadowCost时追加(判定 ¥1.50,含未知估算),即真实值与拦截判定同时可见。冷启动快照显示≈…(待校正),未定价模型追加· 价格未知,手动模式关闭提示后显示· 已关闭提示。点击展开明细:分项费用、峰/谷费用、最近模型、当前时段、Token、未知模型、未知估算(仅用于拦截判断)、父任务/子代理聚合、结算健康度(有 N 次结算被拒绝/M 条已结算 · lastSeq)、价格来源,并可展开「设置本次预算」输入框。达到阈值时文字变色并出现⚠;manual模式提供「继续 (+10%)」「不再提示」「中止」。 - 设置页(
settings.section→ 预算护栏):拦截 / 每轮预估 / 默认预算 / 快照 / 访问五组设置,见上文「方式三」;页脚显示价格来源、节假日来源、右侧栏可用性与绑定地址。 - 所有 UI 使用
--dsw-alias-*主题变量,跟随明暗主题。 - 预算条与 Host 的
activity/usage状态 pill 对齐:字体calc(var(--dsh-content-font-size-secondary, 13px) - 1px)、行高calc(20px + var(--dsh-content-font-delta-secondary, 0px))、颜色--dsw-alias-label-tertiary、padding: 1px 8px、border-radius: var(--dsw-radius-sm)、gap: 6px与tabular-nums,不额外添加外边距;hover 背景使用--dsw-alias-interactive-bg-hover。
拦截行为细节
auto:turn-stopping达到阈值 →agent.cancel({ kind: 'hook', reason: ... }, { keepInbox: true });如果本步已经超预算,pre-step直接返回{ kind: 'reject' }并取消,防止再发一次模型请求。- DSH 当前
agent/turn-stopping的签名是Promise<void>,没有“返回取消信号”的返回值;插件在这个扩展点内调用具体 Agent 的cancel(),效果等价于规范中的取消信号,并保留turn/end: aborted审计。 manual:阈值时只置manualPending,不取消;预算条出现「继续 (+10%) / 不再提示 / 中止」。点「中止」调用agent.cancel({ kind: 'user' });点「继续」把下一次提示线推后约 10% 预算并累计提示;「不再提示」只关闭本次任务提示,仍记录费用。- 取消记录会进入本会话
turn/end(aborted),因此审计链完整;插件不新增事件类型,不会破坏会话重放。
零依赖与 Config 投影
Config 不 import @deepseek-ai/schemastery,而是导出一个带 Symbol.for('schemastery')、type、meta 的原生 schema 图。DSH 的 app-boot 投影器会静态遍历它(不执行校验器),因此:
Config.listConfigs对本插件返回status: "schema"、limitations: [];- 插件管理器的配置页能生成真实表单;
- 真正校验仍由
Config['~standard'].validate完成,坏值会抛ValidationError。
这样本地目录安装(junction)不会因为包外依赖解析失败而无法加载。
构建与测试
node build.mjs # 用 Node 内置 stripTypeScriptTypes 生成 lib/index.js、lib/pricing.js 与 lib/client.js
node test/pricing.test.mjs
node test/host.test.mjs
node test/client.test.mjs
node test/regression.test.mjs # 用户路径回归:保存 / 拦截 / 解锁 / 恢复 / 命令 / 冷启动
node test/bom.test.mjs # 全仓库 BOM 检查,含 lib/(运行时真正加载的生成物)
只依赖 Node 内置模块,不需要 TypeScript、esbuild 或网络安装。
npm test 先跑 node build.mjs 再跑五个测试文件——测试 import 的是 lib/,新克隆的仓库没有 lib/,不先构建会直接 ERR_MODULE_NOT_FOUND(bom.test.mjs 里还有一条“扫描必须命中 lib/client.js”的断言)。想跳过构建只跑测试,就用上面的逐条命令。
已知边界
- 预算是按会话核算的;子代理是独立会话、独立预算。父/子展示聚合已提供(
aggregate),但父任务统一限额需要 Host 暴露稳定taskId,当前未做(TODO: 需确认);complete只代表已发现集合。 - 冷启动先显示快照(标注
待校正);若连快照都没有,显示“预算状态加载中…”,等agent/created或第一条事件后收敛。/budget声明可从command/run重放。 - 金额是估算,不替代账单;未定价模型展示费用按真实已知计、拦截判定用
shadowCost(已加载价格表里最贵模型的最贵时段价),主行同时显示真实值与判定值;请用prices/local-prices.json补齐。 - 时段判定使用
assistant/message的event.time(会话提交时刻),不是请求开始时刻;秒级时间戳自动归一为毫秒;不带时区的字符串按 Asia/Shanghai 解释且只 warn 一次;同一 step 的重试以最终结算替换(被截断的用摘要回滚),Charge整体回滚,counters.replacedSettlements/blockedSettlements持久化审计。 - 手动模式「继续」每次放行约 10%,累计到
maxGrantedPercent(默认 30%,约 3 次)后只能中止;「不再提示」只关闭提示,达到manualHardStopRatio仍强制中止;/budget off恢复 Config 默认预算(金额或 Token 任一非 0 都仍会拦截,文案会说明是哪一条)。 - 快照按实例分文件(
<stem>.<pid>.<startedAt>.json)并合并读取;snapshotKeepTurns控制stepCharges明细保留轮数,更早的重试用settledCharges摘要替换(不再拒绝),只有超过perSessionMaxSettled的标记才降级为拒绝并计入blockedSettlements;snapshotMaxSessions/snapshotMaxAgeDays控制增长;stale writer 改为合并写入(磁盘为底 + 本进程独有结算),清理时按<pid>.<startTs>识别 pid 复用。 - 读写路径的凭证:
/config/save与/action默认只认 loopback 对端(requireLocalControl),Origin/Sec-Fetch-Site只是附加校验而不是凭证;Origin: null拒绝;非 loopback bind 时连Sec-Fetch-Site兜底也取消;远端 GUI 需要改预算时必须显式把requireLocalControl设为false(文档已标注风险)。 - 右侧栏页依赖
dsh-better-sidebar发布的ctx.betterSidebar服务(DSH 自己的右侧栏没有“新增标签页”的公开 Slot):安装了该 bundle 才会注册budget-guard:estimate,否则只是少一个入口,estimateOutput=sidebar会自动退回对话卡片。设置页页脚会显示「右侧栏:可用 / 不可用」。 - 自动打开右侧栏用
localStorage记“每会话一次”(7 天过期);隐私模式下 storage 不可用时退回内存标记,同一页面内仍只开一次。注意localStorage按 origin 隔离:127.0.0.1:56675与localhost:56675会被当成两个站点,同一个会话可能各自动打开一次(TODO: 需确认是否还有其他端口别名)。 - 非法时间戳只累加
counters.invalidTimeMessages(一个数字),不会为每条非法消息保留记录,因此快照不会因此增长。 - 子代理聚合结果有 500ms 缓存,只在
assistant/message(真的产生了费用)与agent/created/agent/disposed时失效。高密度消息下缓存命中率仍然很低——这是正确性优先的取舍,聚合查询本身是内存遍历,不需要更激进的优化。
License
本项目基于 MIT License 发布。
No comments yet. Be the first to write one.