<h1 align="center">DSH Cost Meter（对话花费计量）</h1>
<p align="center"><img src="docs/assets/banner-cost-meter.svg" alt="DSH Cost Meter banner" width="100%"/></p>

<p align="center">
  <a href="./README.md">English</a>
  &nbsp;·&nbsp;
  <strong>简体中文</strong>
</p>

**DSH Cost Meter** 是为 DeepSeek Harness（DSH）Web GUI 打造的对话成本追踪插件——**价格快照锚定**的逐轮成本（感知**峰值/闲时**）、**账户余额**、**花费标签页**、**每条回复成本小标签**，以及带**流式实时估算**的**头部胶囊**。每一步的成本、价格档位与快照版本都**只计算一次**——锚定到该用量事件自身时刻生效的价格快照，此后永不重算——因此后续价格变动绝不会改写已写入的对话记录。Search 是独立的按成功请求计费 API，账本会统计成功的 `web_search` 调用，并按官方每 1,000 次 ¥5 / US$0.75 计价——按官方页面说明，2026-11-01 00:00（北京时间）前免费。`web_fetch` 走插件随附的本地 HTTP provider，DeepSeek 不对此计费，因此保持免费。

- Host 半区（`src/`）：DeepSeek `GET /user/balance` 余额查询、持久化的快照锚定价格簿、`sessionCost` 投影与不读价格簿的 `sessionCostIndex` 消息索引、子代理成本聚合，以及带信任围栏的 `/cost-meter` 路由。
- Client 半区（`src/client/`）：输入框下方读数、花费标签页、每条回复成本小标签、头部胶囊，以及插件配置页（设置 → 插件）——内置简体中文与英文。

---

## 安装

本插件已发布到 npm：`@gamegeek-saikel/dsh-cost-meter`，以官方 DSH 插件 bundle 形态交付（单个 `cordis.patch.yml` 行同时挂载 host 与浏览器两半）。

通过官方 DSH CLI（npx 方式，无需全局安装）装入 web profile：

```bash
npx @deepseek-ai/dsh plugin --profile web add @gamegeek-saikel/dsh-cost-meter
```

上面装的是 npm 的 `latest` 标签，它跟随当前 DSH 发布线。本插件与 DSH 使用同样的三条 npm 通道（`latest`、`next`、`alpha`），而每个构建只能在它对应的那条 DSH 发布线上运行，因此请按你的运行时选择通道：

| npm dist-tag | 对应 | 安装命令 |
| --- | --- | --- |
| `latest` | 当前 DSH 发布线 | `dsh plugin --profile web add @gamegeek-saikel/dsh-cost-meter` |
| `next` | 下一条 DSH 发布线 | `dsh plugin --profile web add @gamegeek-saikel/dsh-cost-meter@next` |
| `alpha` | alpha 构建 | `dsh plugin --profile web add @gamegeek-saikel/dsh-cost-meter@alpha` |

用 `npm dist-tag ls @deepseek-ai/dsh` 可查看各条通道对应哪个 DSH 版本，用 `npm view @gamegeek-saikel/dsh-cost-meter dist-tags` 查看各条通道对应哪个插件版本。DSH 运行时会拒绝加载对等依赖不匹配的 bundle，因此选错通道会直接安装失败，而不会把插件装成半可用的状态。

然后启动：

```bash
npx @deepseek-ai/dsh web
```

如果已全局安装 DSH CLI，也可以使用 `dsh` 代替 `npx @deepseek-ai/dsh`。安装到其他 profile 时，把 `web` 替换成你的 profile 名称即可。每个已发布版本都声明并已验证兼容上表所列对应通道的 DSH 发布线。开发环境要求 Node `^22.19.0 || >=24.0.0` 与 pnpm `11.7.0`。

## 概述

DeepSeek 的价格随时间变化（官方价目表、USD→CNY 汇率、2026-08-17 上线的峰值/闲时分时计价，以及 2026-09-10 的模型改名——Flash 列更名为 `deepseek-flash` 并下调为 V4.1-Flash 价），而一次对话跨越很多轮，且每轮都含缓存命中、缓存未命中、缓存写入与输出等 token 桶。若按"当前价格"重算成本，每次价目变动都会让历史记录漂移。

**Cost Meter** 用**只追加的价格簿**解决这一问题：每次价格/汇率/分时表变化都会开启一个新的不可变 `PricebookSnapshot`（单调 `version`、`effectiveAt`），每个用量事件锚定到其自身时刻生效的快照。结果是一个**只增长、永不改写**的不可变逐步成本账本。流式实时估算明确标注为"估算"——因为它使用*当前*价格；一旦该步结算，即被精确的锚定值取代。

## 关键性质

| 性质 | 值 |
|---|---|
| 成本锚定 | 只追加价格簿快照；步成本在事件自身时刻只计算一次 |
| 价格来源 | 手动覆盖 > 官方价格页 > 内置回退 > OpenRouter（仅回退，USD→CNY）> 无 |
| 官方页适配 | 解析当前 2026-09-10 版中英文页面（`deepseek-flash` / `deepseek-v4-pro` 两列、每个 token 桶行内的空闲/高峰单元格，以及英文 UTC 时段），同时兼容此前的合并表与旧版分表；已下线的 `deepseek-v4-flash`、`deepseek-v4-flash-vision-exp` 不再有独立列，按页面说明直接以 Flash 价格计费；页面不再提供旧单价表时继续用内置历史单价锚定 `single` |
| 峰值定价 | 2026-08-17 00:00 北京生效；高峰窗口仅周一至周五，且不含中国法定节假日（中英文页面时区可能不同，缺省 09:00–12:00 / 14:00–18:00 北京），因此周末、法定节假日全天及其余时段均为半价闲时 |
| 成本公式 | 未命中输入 + 缓存命中（命中价）+ 缓存写入（按未命中输入价计）+ 输出，每百万 tokens，CNY |
| 账户余额 | 官方 `GET /user/balance`，缓存 60 秒，单飞请求，路由带信任围栏 |
| 子代理支持 | 沿 `subagents.listDescendants` 枚举本会话持久子代理树（子代理再派生的孙代理、已结束或已冷启动的子会话都计入，无层数上限）；账本优先取常驻会话的投影，未常驻的经 `sessionQuery.readSession` + `sessionProjections.restore` 从其持久日志冷读——因此主机重启后，旧会话的历史子代理花费同样会被补算；未挂载该服务时回退到活跃代理树 BFS |
| 对话总花费 | 主会话 + 全部后代子代理（任意层数）；主会话自身账本尚未就绪时仍展示子代理合计 |
| UI 表面 | 输入框读数 · 花费标签页 · 回复小标签 · 头部胶囊（实时估算）· 插件配置页 |
| 本地化 | 简体中文（键源）+ 英文 |
| 复杂度 | 全同步折叠；经内存镜像 O(1) 查价 |

## 用法

安装后，插件贡献五个浏览器表面（默认显示简体中文文案）：

| 表面 | 插槽 | 说明 |
|---|---|---|
| 输入框下方读数 | `conversation.composer.dock` | 锚定的本会话花费 + 账户余额，每分钟刷新；悬停查看分类明细与快照信息 |
| 花费标签页 | `conversation.view` | 全对话总花费（主会话 + 任意层级的子代理）、分类小计、带层级的子代理列表与逐回复锚定账本 |
| 每条回复成本小标签 | `conversation.chat.assistant-actions` | 单条已定稿回复的锚定成本，做成与官方「用量」「用时」完全同形的统计胶囊（28px 药丸、悬停底色、`aria-expanded` 展开锚定在触发件上方的对话框）；位置收在统计串末：在「用量」「用时」之后、末尾时刻文本之前，间距沿用行自身的 gap；按计价档位着色（闲时绿、高峰红）；对话框列出三类计费、档位与倍率、模型与快照版本；插槽只给出消息 id，由 `sessionCostIndex` 投影解析为账本坐标（无价格时显示 `—`） |
| 头部胶囊 | `conversation.session.header.utilities` | 锚定总花费；流式中显示 `预计 ¥x.xx（估算）`；点击展开详情面板 |
| 插件配置页 | `plugins.row.config` | 设置 → 插件 中 cost-meter 行自己的配置页（DSH 0.2.0 以 `<包名>#<行 id>` 为键托管插件自带配置）：按模型覆盖价、OpenRouter 别名、节假日日历的额外休息日与工作日、缓存折扣、汇率模式、开关与立即刷新。可编辑字段即 schema 中标记 `volatile()` 的字段——部署类字段（端点、凭据引用、刷新周期、可信主机、历史上限）仍在 profile patch 中手工维护，这正是 DSH 配置投影能够写入的边界 |

`/cost-meter` 宿主路由通过 GET 提供余额快照、价格簿视图与子代理合计；通过 POST（`{"action":"refresh"}`）执行手动刷新。与 `/api` 围栏一致，路由只应答 `Host` 头为回环地址或已声明可信主机的请求——这是防 DNS 重绑定的安全校验。

## 价格簿与快照锚定

价格簿（`src/pricebook.ts`）是持久的定价源，持久化在 `pricebook` 存储域全局槽上：

- **优先级链**——按规范模型键（`provider/model`、裸模型名，或 DeepSeek 系模型的 `flash`/`pro` 定价键）：手动覆盖 > 官方页面 > 内置回退 > OpenRouter（仅回退，USD→CNY，缓存读按配置折扣）> 无。
- **快照选取**——`snapshotForTime` 取 `effectiveAt <= 事件时间` 的最新快照（安装前的会话一次性锚到首个快照）。
- **峰值/闲时**——2026-08-17 上线前所有步按单一价目计费；上线后按事件自身时刻与该价格簿页面的时段表选档（中英文页面各自解析自己的高峰窗口，英文新版为 UTC，抓取失败时回退到北京 09:00–12:00 / 14:00–18:00，其余闲时）。官方页面限定高峰仅周一至周五且不含中国法定节假日，因此时区表内的周六、周日全天为闲时，法定节假日同样全天闲时——即便在英文页面的 UTC 窗口下，节假日也按北京日历日判定。日期取自国务院办公厅通知，转录在 [`src/holidays.ts`](src/holidays.ts)（2026 年，依据国办发明电〔2025〕7号）；尚未覆盖的年份回退到「周一至周五」原规则，可用设置页的休息日/工作日清单在不发版的情况下补齐。调休来的周末工作日仍按闲时计（因为页面写的是周一至周五），若要按高峰计，把它们列进工作日清单即可。新版合并表没有旧单价列时，`single` 继续锚定内置历史单价；页面若有独立旧表，则优先用它。每步的档位在折叠时锚定一次，逐回复卡片与回复小标签永远显示该轮对话当时被计价的档位，而不是查看时刻的档位。
- **不可变账本**——`sessionCost` 投影（`src/session-cost-projection.ts`）把 `request/header`（模型）与携带用量的事件折叠为逐步记录；同一 (turn, step) 的第二次用量样本**替换**第一条（同一步终结，而非重新计价），总计以 O(1) 增量维护。配套的 `sessionCostIndex` 投影（`src/session-cost-index.ts`）把每条已定稿助手消息 id 映射到其账本坐标；它不读价格簿、不算成本，因此折叠它绝不会重新计价任何会话。

## 项目结构

```
src/
  index.ts                      # Host 入口：apply() 装配、余额、路由、信任围栏
  types.ts                      # wire/公共类型 + 投影映射合并
  pricing.ts                    # 官方价格页解析、峰值定价、北京时段
  pricebook.ts                  # 只追加快照、优先级链、存储域
  session-cost-projection.ts    # sessionCost 投影（不可变逐步账本）
  session-cost-index.ts         # sessionCostIndex：消息 id → 账本坐标
  holidays.ts                   # 中国法定节假日日历及其部署覆盖项
  subagent-cost.ts              # 子代理成本聚合（持久子代理树 + 活跃代理树回退）
  invariant.ts                  # 路由释放对称性 invariant 伴生插件
  client/                       # 浏览器半区：5 个插槽组件 + 数学/格式化/本地化
shared/
  tsdown.client.ts              # 共享 tsdown 预设（CSS Modules、模块表、纯度门）
  web-platform.ts               # 浏览器平台模块清单
tests/                          # 封闭式 vitest 套件（网络全部 stub）
cordis.patch.yml                # web profile 插件行（同时挂载两半）
```

## 开发

```bash
pnpm install
pnpm typecheck   # tsc -b（仅 src）
pnpm test        # vitest run（封闭式，网络 stub）
pnpm build       # tsc -b && tsdown（lib/ + lib/client.js）
```

测试套件完全离线：价格页 HTML、OpenRouter 模型目录与汇率接口全部 stub。覆盖：信任围栏、余额解析、价格簿优先级链与快照选取、不可变账本折叠（含同一步替换、按*事件时间*选峰值/闲时档，以及贯穿官方免费期的 Search/Fetch 请求计费）、子代理聚合（持久子代理树的嵌套/冷会话/失败回退，以及真实 `SessionStore` + 投影注册表上的多层链路），以及客户端表面（jsdom）。`.github/workflows/compat.yml` 在推送到 `main`、`next`、`alpha` 时只测试对应通道；在 Actions 里手动运行时则跑三条通道的完整矩阵，检查 npm 上当前的 DSH 补丁。没有定时任务。

### 发布通道

推送 `v*` 标签后由 `.github/workflows/publish.yml` 发布。标签决定 npm dist-tag，而这不只是记账问题：社区市场安装的是**不带版本号的包名**，也就是 `latest` 指向的那个版本，直接装进访客的运行时。DSH 同时并行三条发布线（`@deepseek-ai/dsh` 把当前线放在 `latest`、下一条线放在 `next`、alpha 线放在 `alpha`），而插件版本只能在它的 `@deepseek-ai/dsh*` 对等依赖所接受的那条线上运行，因此 `scripts/dist-tag.mjs` 会用这些依赖范围去比对 registry 上 DSH 自己的发布线，据此选定通道：

| 版本 | 通道 |
|---|---|
| 接受 DSH `latest` 线 | `latest` |
| 接受 DSH `next` 线（或带 `-next.` 后缀） | `next` |
| 带 `-alpha.` 先行版本后缀 | `alpha` |

`node scripts/dist-tag.mjs pairs` 会打印所有已发布版本的归属表；`node scripts/dist-tag.mjs reconcile` 会把每个标签重新指向支持它的最新版本，同时修正早期发布流程写错的标签（`.github/workflows/dist-tags.yml` 可按需单独执行同一套 reconcile）。

## 文档

- [`src/pricing.ts`](src/pricing.ts)、[`src/pricebook.ts`](src/pricebook.ts)、[`src/session-cost-projection.ts`](src/session-cost-projection.ts)、[`src/session-cost-index.ts`](src/session-cost-index.ts)、[`src/holidays.ts`](src/holidays.ts)——解析、锚定、节假日日历与账本契约的详细模块注释
- [`README.md`](README.md) — English version

## 许可证

本仓库（源码、测试、README 与 DSH 插件 bundle 形态）以 **MIT License** 授权——见 [`LICENSE`](LICENSE)。

Copyright (c) 2026 Saikel-Orado-Liu aka GameGeek-Saikel
