dsh-plugin-billing 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,236 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-billing
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在 DeepSeek Harness Web GUI 的**会话统计行下方**多显示一行账户账目:
4
+
5
+ ```
6
+ 余额 ¥45.26 | 本轮 ¥0.0098 | 本会话 ¥0.0431
7
+ ```
8
+
9
+ - **余额** —— 提供方账户余额(充值 + 赠送),来自上游余额接口。
10
+ - **本轮** —— 当前这一轮对话花掉的钱。
11
+ - **本会话** —— 本次会话(已加载记录)累计花掉的钱;只有一轮时不显示,避免和统计行重复。
12
+
13
+ 鼠标悬停看明细:余额拆分、计价模型、当前档位(高峰/空闲)与三档单价、本轮与会话的 token 拆分、单价来源。
14
+
15
+ ## 金额是怎么算出来的
16
+
17
+ ### 单价
18
+
19
+ 取自 DeepSeek 官方「模型 & 价格」页([中文](https://api-docs.deepseek.com/zh-cn/quick_start/pricing) / [English](https://api-docs.deepseek.com/quick_start/pricing)),**2026-09-18 抓取**:
20
+
21
+ | 模型 | 输入(缓存命中) | 输入(缓存未命中) | 输出 |
22
+ |---|---|---|---|
23
+ | `deepseek-flash`(DeepSeek-V4.1-Flash) | ¥0.04 | ¥2 | ¥8 |
24
+ | `deepseek-v4-pro` | ¥0.30 | ¥9 | ¥27 |
25
+ | `deepseek-v4-flash`、`deepseek-v4-flash-vision-exp`(旧名,已下线) | ¥0.04 | ¥2 | ¥8 |
26
+
27
+ 上表是**高峰时段**价(元 / 百万 tokens)。官方口径:**空闲时段价格为高峰的一半**;高峰时段为北京时间周一至周五 09:00–12:00 与 14:00–18:00,其余为空闲时段。
28
+
29
+ > **已知偏差:法定节假日**。官方注(2) 还写明「中国法定节假日全天按空闲时段计」,本插件只识别周末,不识别节假日历。因此**工作日的法定假日**里,显示金额会按高峰价算,**约为实际的两倍**;周末与调休上班日不受影响。要精确到节假日需要每年维护一份假期表,暂未内置。
30
+
31
+ > **旧模型名仍按 Flash 价计费**:官方定价页注(1) 说明 `deepseek-v4-flash` / `deepseek-v4-flash-vision-exp` 对应模型已下线,请求由 DeepSeek-V4.1-Flash 提供并按 Flash 价格计费,因此这三个 id 在本插件里同价。界面模型座位显示 `DeepSeek-V41-Flash` 时,`settings.yaml` 里写的通常是 `deepseek-flash`。
32
+
33
+ ### 改价:界面上点一下就行
34
+
35
+ **设置 → 通用设置 → 用量价格表 → 「立即更新」**。这一行会显示当前单价来源与上次更新日期,
36
+ 点按钮后 Host 侧去抓官方中文页、跑完四条结构自校验、写本机覆盖文件(不动仓库),
37
+ 然后把结果直接显示在这一行上:`已是最新` / `已更新:deepseek-flash` / `更新失败:<原因>`。
38
+ 更新成功后金额行**立刻**用新单价重算(不用等它自己那一分钟一次的轮询)。
39
+
40
+ 命令行是"想看清楚再改"时才用的(同一个实现,见 `lib/prices.js`):
41
+
42
+ ```bash
43
+ node scripts/update-prices.mjs # 只对比,不动文件
44
+ node scripts/update-prices.mjs --write # 更新仓库里的 pricing.json(旧表备份为 .bak)
45
+ node scripts/update-prices.mjs --override # 只写 ~/.dsh/billing-prices.json,不动仓库
46
+ node scripts/update-prices.mjs --help
47
+ ```
48
+
49
+ 想手改也行:`pricing.json` 是内置表(唯一事实来源),`~/.dsh/billing-prices.json` 是本机覆盖文件
50
+ (优先级更高,**可以只写变化的模型**,按模型合并):
51
+
52
+ ```jsonc
53
+ // ~/.dsh/billing-prices.json
54
+ { "source": "我的价目表 2026-09-20",
55
+ "peakPrices": { "deepseek-flash": { "cacheHit": 0.04, "cacheMiss": 2, "output": 8 } } }
56
+ ```
57
+
58
+ Host 侧每次请求(5 秒缓存)读这两份并随响应下发 `prices` / `priceSource`,客户端**优先用下发的表**;
59
+ bundle 里内联的那份只是兜底(路由不可用时仍能计价)。所以正常改价**既不用跑构建脚本,也不用重启服务**
60
+ ——实测:写入覆盖文件后 6 秒,接口下发的 flash 缓存未命中价从 `2` 变成 `3`、`priceSource` 同步变成覆盖文件里的
61
+ 来源,而没被覆盖的 `deepseek-v4-pro` 仍来自内置表(按模型合并)。
62
+
63
+ **更新器为什么值得用**:它**宁可失败也不写错钱**。解析官方页时自校验四条——
64
+ 时段标签必须严格交替「空闲/高峰 × 3」、价格数字个数必须 = 6 × 模型数、
65
+ 每个档位必须满足「空闲 = 高峰的一半」、币种必须解析成人民币;
66
+ 任一不满足就拒绝写入并说明原因(页面改版、官方取消空闲价、误抓英文 USD 页都会被拦下)。
67
+ 另外官方注(1) 的「旧模型名 `deepseek-v4-flash` / `deepseek-v4-flash-vision-exp` 按 Flash 计费」
68
+ 由它自动补齐。这四条 + 三个反例(改坏数值、缺行、换成美元)都在 `scripts/verify.mjs` 里有离线夹具测试。
69
+
70
+ **怎么知道表该更新了**:设置里那一行直接写着「当前:<来源> · 上次更新于 <日期>」;
71
+ 鼠标悬停金额行,tooltip 最后一行 `detail.source` 也是同一份信息。日期旧了就点一下更新。
72
+
73
+ 两个注意点:覆盖表里只写**高峰价**(空闲价照旧按倍率折算);畸形记录(缺字段、负数、非数字)会被忽略而不是让整表失效。
74
+ 如果哪天官方不再"空闲半价",更新器会因为找不到空闲行而拒绝写入,并提示你改 `src/pricing-body.js` 的倍率。
75
+
76
+ ### token 与计费桶
77
+
78
+ harness 记录的 `TokenUsage` 映射到三个计费项(与 DeepSeek 口径一致):
79
+
80
+ | 桶 | 来源字段 | 计价档 |
81
+ |---|---|---|
82
+ | 缓存未命中输入 | `inputTokens` | 缓存未命中单价 |
83
+ | 缓存命中输入 | `cacheReadTokens` | 缓存命中单价 |
84
+ | 缓存写入 | `cacheWriteTokens` | 并入缓存未命中(DeepSeek 不单列此项) |
85
+ | 输出 | `outputTokens` | 输出单价 |
86
+
87
+ `reasoningTokens` 是输出的一部分(harness 的 token-meter 同口径),**不重复计入**。
88
+
89
+ ### 每轮各自定档
90
+
91
+ 金额不是"总额 × 当前单价",而是**逐轮按该轮发生时刻的档位算,再加起来**。所以一轮在高峰、下一轮在空闲时,结果仍然正确。轮次时刻取该轮节点的 `time`。
92
+
93
+ ## 组成
94
+
95
+ | 文件 | 运行位置 | 作用 |
96
+ |---|---|---|
97
+ | `lib/index.js` | Host(Node) | 注册 `/plugins/billing/summary` 路由:解析提供方凭据、查询余额(带 TTL 缓存)、报告当前路由模型 |
98
+ | `pricing.json` | 构建期 + Host 运行时 | **价格表唯一事实来源**(可编辑,Host 每请求读取并下发) |
99
+ | `src/pricing-body.js` | 编译进 bundle | 高峰窗口、计价函数、价格表兜底 |
100
+ | `src/client-body.js` | 编译进 bundle | 文案字典、金额行组件、视图装配 |
101
+ | `lib/client.js` | 浏览器 | 由 `scripts/build-client.mjs` 从上面两个源文件生成 |
102
+
103
+ ### 排版:和原生用量行同一排
104
+
105
+ 金额行和原生用量行(`ui-chat` 的 `StatsPills`)、上下文圆环**挤在同一行**里
106
+ (`conversation.composer.dock`,容器是 `display:flex; justify-content:center; gap:12px`),
107
+ 所以排版必须逐项对齐,否则一眼就是外挂。抄的是原生数值:
108
+
109
+ | 项 | 值 |
110
+ |---|---|
111
+ | 字号 | `calc(var(--dsh-content-font-size-secondary,13px) - 1px)` → 默认 12px(**必须显式写**:dock 在输入卡片外面,不写会继承 body 的 14px) |
112
+ | 行高 | `calc(20px + var(--dsh-content-font-delta-secondary,0px))` → 默认 20px |
113
+ | 胶囊 | `padding:1px 8px`、`border-radius:999px`、`gap:6px`、无底色无边框 → 1px+20px+1px = **22px 高**,与原生胶囊/圆环等高 |
114
+ | 文字色 | `var(--dsw-alias-label-tertiary)`;分隔符 `var(--dsw-alias-separator-primary)` |
115
+ | 分隔符 | 原生中点 `·`(`margin:0 6px`),**不是竖线** |
116
+ | 数字 | `font-variant-numeric:tabular-nums` |
117
+
118
+ **不要**写 `width:100%` / `text-align` / `margin:0 auto` / `opacity` / `flex:1`:
119
+ 外层容器已经居中,条目抢宽度会把原生那两枚胶囊挤开(`text-align` 对 flex 子项无效)。
120
+ 这五条已固化进自检的最后两项,改坏了会红。
121
+
122
+ ### 为什么余额走 HTTP 路由而不是 Remote
123
+
124
+ 本部署的 Host↔Client 类型化 Remote(Typert)需要随包发布的生成产物(`typert.host.js` / `typert.remote-client.js`),不适合一个本地小插件。`ctx.webServer` 本来就允许插件注册具名路由(`dsh-client-modules` 提供的 `/plugins` 也是这么注册的),浏览器同源 `fetch` 即可。
125
+
126
+ 路由是**精确匹配**:`webServer.match()` 先查 exact 表再查最长前缀,所以 `/plugins/billing/summary` 不会被 `/plugins` 前缀路由抢走。
127
+
128
+ ### 为什么金额在浏览器侧算
129
+
130
+ token 账目本来就在浏览器侧(统计行的 `tokenUsage` 投影与会话节点),余额必须由 Host 拿凭据去查。于是分工是:Host 只提供"客户端拿不到的东西"(余额 + 当前模型),金额在浏览器侧算。这样不需要新增投影单元,也不需要 schemastery 依赖。
131
+
132
+ ## 配置
133
+
134
+ 全部走环境变量(插件不声明 `Config` schema,避免与组合器校验耦合):
135
+
136
+ | 环境变量 | 默认值 | 作用 |
137
+ |---|---|---|
138
+ | `DSH_BILLING_API_KEY_ENV` | `DEEPSEEK_API_KEY` | 用哪个凭据引用解析密钥 |
139
+ | `DSH_BILLING_BASE_URL` / `DEEPSEEK_BASE_URL` | `https://api.deepseek.com` | 提供方端点 |
140
+ | `DSH_BILLING_BALANCE_TTL_MS` | `30000` | 余额缓存时长(毫秒) |
141
+
142
+ 密钥按凭据 seam 每次请求解析(`ctx.credentials.resolve`,它已叠加进程环境、托管存储与 `.env`),所以**换密钥不用重启**;余额缓存按 TTL 生效,只缓存成功结果。
143
+
144
+ ## 安装与激活
145
+
146
+ **从 npm 安装**(插件市场里搜 `dsh-plugin-billing` 点"安装"走的就是这条路):
147
+
148
+ ```bash
149
+ dsh plugin --profile desktop add dsh-plugin-billing@latest
150
+ ```
151
+
152
+ **本机开发用 `link:` 安装**,改代码即时生效:
153
+
154
+ ```
155
+ ~/.dsh/profiles/<profile>/package.json
156
+ dependencies: "dsh-plugin-billing": "link:~/dsh-dingtalk-workspace/dsh-plugin-billing"
157
+ dsh.profile.bundles: [..., "dsh-plugin-billing"]
158
+ ```
159
+
160
+ > `link:` 和 npm 版本不能混着点:市场里的"安装 / 更新"一律执行 `pnpm add <包>@latest`,
161
+ > 会把 `link:` 依赖换成 registry 版本。本机开发时别点它,改完代码重启即可。
162
+
163
+ 本包自带 `cordis.patch.yml`(`dsh.bundle.patch` 声明),由 profile 的 `bundles` 列表激活,因此 profile 自己的 `cordis.patch.yml` 不需要改动。
164
+
165
+ **激活需要重启 DSH**:客户端插件名录与 `window.__DSH_BOOT__` 模块图在进程启动时组装,GUI 的插件列表是只读的,所以没有热启用路径。
166
+
167
+ ```bash
168
+ # 改完客户端源码或单价表后
169
+ node scripts/build-client.mjs
170
+
171
+ # 重启 DSH(会结束当前会话回合,历史可恢复)
172
+ ```
173
+
174
+ ## 自检
175
+
176
+ ```bash
177
+ node scripts/verify.mjs
178
+
179
+ # 附带一个真的打上游余额接口的用例(需要本机凭据)
180
+ DSH_BILLING_LIVE_TEST=1 node scripts/verify.mjs
181
+ ```
182
+
183
+ 覆盖:计价数学(高峰/空闲判定含边界与周末、三档折算、计费桶归并、推理不重复计费、小额金额精度)、轮次折叠(忽略非 assistant 节点、跨档求和)、客户端注册(slot 席位、文案字典 zh/en 键与占位符一一对应)、**视图装配**(余额/本轮/会话三组的取值与 hover 明细,逐条独立重算比对)、Host 侧(路由类型与路径、405、缺凭据时的可解析失败响应、响应不泄露密钥)。
184
+
185
+ ## 跨版本兼容(0.1.1 ↔ 0.1.5)
186
+
187
+ 插件依赖了几处**非公开内部契约**,0.1.5-rc.1 改动了其中三处。凡是变动过的地方都写成
188
+ "新版优先、旧版回退",因此同一份代码在新旧 dsh 上都能跑:
189
+
190
+ | 契约 | 0.1.1-rc.2 | 0.1.5-rc.1 | 本插件的处理 |
191
+ |---|---|---|---|
192
+ | 组件座位名 | `useSession` | **`useChat`** | 优先 `useChat`,回退 `useSession` |
193
+ | 会话 store 路径 | `s.chat.legacy.nodes` | **`s.legacy.nodes`** | `selectNodes()` 两种形状都认 |
194
+ | 读会话日志 | `sessionPersistence.inspect(id)` | **`open(id,'read')` + `handle.read()`**(`inspect` 已移除) | `readSessionEvents()` 新版优先、旧版回退 |
195
+ | `dsh-client-runtime` 包 | 存在 | **已删除** | `dsh.client.inject` 里不再声明它 |
196
+ | 插槽名 `conversation.composer.dock` | 相同 | 相同 | 无需改 |
197
+ | `inject: (sessionId) => …` 约定 | 相同 | 相同 | 无需改 |
198
+ | 外壳种子 `react` / `react/jsx-runtime` / `ui-primitives` | 相同(0.1.5 起 `ui-slots`/`ui-primitives` 只是种子,不再是包) | 相同 | 无需改 |
199
+ | `ctx.theme.overrideTokens` 与 `--dsw-alias-*` 令牌 | 相同 | 相同 | eyecare 无需改 |
200
+
201
+ > 0.1.5 起客户端 bundle 是**启动时快照进内存**并按 rev 提供服务("不匹配的修订会被拒绝,
202
+ > 而不是返回更新的字节"),所以改完 `lib/client.js` 后要么等 `dsh-client-hmr` 重新哈希,
203
+ > 要么重启服务。
204
+
205
+ ## 数据面准入(与 `/api` 对齐)
206
+
207
+ 余额路由返回的是**数据**,所以它和 `/api` 用同一套准入:`connection.requestRejection(request)`
208
+ (0.1.5 起由 `@deepseek-ai/dsh-client-connection` 提供)。它一次做两件事:
209
+
210
+ 1. **Host/Origin 信任围栏** —— authority 不在受信列表里直接 **403**(防 DNS rebinding);
211
+ 2. **浏览器会话认证** —— 带 `?token=` 访问一次首页后,服务器签发绑定 authority 的签名
212
+ cookie(`dsh-auth-<authority-hash>`,`Path=/`、`HttpOnly`、`SameSite=Strict`),
213
+ 之后校验它,失败给 **401**。
214
+
215
+ 页面里的同源 `fetch` 会自动带上该 cookie,因此界面不受影响(沙箱实测:无 cookie → 401、
216
+ 带 cookie → 200、伪造 Host → 403)。
217
+
218
+ 0.1.1 没有这个方法(它的围栏在别处),此时跳过准入,保持与旧版部署一致。
219
+
220
+ > 注意:客户端 bundle(`/plugins/*/client.js`)本身**不受**这层保护(`authorizeIndex`
221
+ > 只护首页),所以插件 bundle 属于公开面;但**数据**请求(`/api` 与本路由)都受保护。
222
+
223
+ ## 已知边界
224
+
225
+ - **会话金额按"已加载记录"折算**:轮次语料来自会话窗口内的节点(与统计行的统计口径同源)。被压缩或分页移出窗口的历史不在其中,因此这个数字是"看得见的这些轮次"的金额,hover 明细里也这么标注。
226
+ - **模型按会话解析,失败才退回默认路由**:客户端把当前会话 id 一并带上(slot 的 `inject` 回调会给出 `sessionId`),Host 用 `sessionPersistence.inspect(id)` 读该会话自己的日志,取**最后一条 `request/context`** 的 provider/model —— 这是精确值,`inspect()` 是只读视图(不提交恢复、不发布 Session),对正在运行的会话也安全。读不到时(会话过新、后端不支持、id 缺失)才退回 `agentDefaultModel.currentSelection()`,并在 hover 里明确标注是"该会话实际路由"还是"部署默认路由"。这个区分不是洁癖:本机上部署默认路由与实际会话可能取到不同模型,单价可能相差数倍。
227
+ - **未登记的模型只报 token、不编金额**,绝不猜价。
228
+ - **单价需要随官方调价更新**:写在 `src/pricing-body.js` 并注明来源页。官方有调价时改这一处、重新构建、重启即可。
229
+ - **余额路由在本机回环上无鉴权**:与 `/plugins/*` 等既有路由同样的信任模型(整个 GUI 都只在回环提供)。它只返回账户余额与模型名,不返回任何凭据。
230
+
231
+ ## 卸载
232
+
233
+ ```bash
234
+ dsh plugin --profile web remove dsh-plugin-billing
235
+ # 再从 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles 里删掉 "dsh-plugin-billing"
236
+ ```
@@ -0,0 +1,7 @@
1
+ # 余额与金额插件的组合包 patch。
2
+ #
3
+ # profile 的 `dsh.profile.bundles` 列出本包后,这条 insert 会把插件行挂进配置树;
4
+ # 行名解析到本包自身,因此 profile 自己的 cordis.patch.yml 不需要改动。
5
+ - insert:
6
+ - id: billing
7
+ name: dsh-plugin-billing
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="Balance &amp; cost">
2
+ <!-- 余额与花费:一枚硬币里的 ¥(宿主会把整份 SVG 内联成 data URI,因此颜色必须自带) -->
3
+ <circle cx="16" cy="16" r="11.25" fill="#E7ECFF" stroke="#4C6BFF" stroke-width="2"/>
4
+ <path d="M11.5 11.5 16 16.5l4.5-5" fill="none" stroke="#4C6BFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
5
+ <path d="M16 16.5V22" fill="none" stroke="#4C6BFF" stroke-width="2" stroke-linecap="round"/>
6
+ <path d="M12 18.5h8M12 21h8" fill="none" stroke="#4C6BFF" stroke-width="2" stroke-linecap="round"/>
7
+ </svg>