dsh-model-billing 0.2.0 → 0.3.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/CHANGELOG.md +127 -61
- package/README.md +10 -4
- package/client.js +15 -1
- package/host.js +109 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,61 +1,127 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## [0.
|
|
4
|
-
|
|
5
|
-
###
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
###
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
###
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
###
|
|
36
|
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.3.1] - 2026-11-05
|
|
4
|
+
|
|
5
|
+
### 变更
|
|
6
|
+
|
|
7
|
+
- 只对 DeepSeek 独立的 Search / Fetch API(`deepseek-search-api` / `deepseek-fetch-api`)计入按次费;Messages 搜索(`deepseek-official`)的检索在模型回合内完成,费用已随该次请求的 token 计入,不再重复按次收费。
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Per-request Search/Fetch fees now apply only to DeepSeek's standalone Search / Fetch APIs (`deepseek-search-api` / `deepseek-fetch-api`). Messages search (`deepseek-official`) runs inside a model turn, so its cost already arrives as that turn's tokens and is no longer charged per request.
|
|
12
|
+
|
|
13
|
+
## [0.3.0] - 2026-11-02
|
|
14
|
+
|
|
15
|
+
### 新增
|
|
16
|
+
|
|
17
|
+
- 计入 DeepSeek Search/Fetch 按次费:当 DSH 使用 DeepSeek 自带的 `deepseek-official`、`deepseek-search-api` 或 `deepseek-fetch-api` 时,按 ¥5 / 1,000 次成功请求(2026-11-01 00:00 北京时间起)计费;DuckDuckGo、Tavily、普通 HTTP 抓取等免费 provider 不计费。
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- DeepSeek Search/Fetch per-request fees are now included when DSH runs on DeepSeek's own `deepseek-official`, `deepseek-search-api`, or `deepseek-fetch-api` providers: ¥5 per 1,000 successful requests from 2026-11-01 00:00 Beijing time. Free providers such as DuckDuckGo, Tavily, and plain HTTP fetch are not charged.
|
|
22
|
+
|
|
23
|
+
## [0.2.0] - 2026-10-28
|
|
24
|
+
|
|
25
|
+
### 新增
|
|
26
|
+
|
|
27
|
+
- 设置 → 模型与费用新增历史价格簿:可为已不在当前配置列表中的模型新增、编辑或删除三档价格。移除模型时会保留其价格,因此历史轮次仍能计算费用;没有显示名时回退显示原始模型 id。
|
|
28
|
+
- 价格来源标识与一键恢复:完整的自定义价格会覆盖 DeepSeek 内置价格表并标记为“自定义”;无需再次请求即可恢复官方峰谷价表。
|
|
29
|
+
- 费用面板新增未定价路由列表,并提供隐藏未定价提示的开关。
|
|
30
|
+
- 轮尾与输入框下方费用估算拆分为独立设置区,各自拥有总开关。
|
|
31
|
+
- 局域网与反向代理访问:除回环地址外,当请求的 Host 头与套接字实际监听地址一致时也信任该请求;伪造的 Host 仍会被拒绝。
|
|
32
|
+
- 键盘访问:费用面板为模态对话框,打开时获取焦点;模型选择器支持 ↑/↓/Home/End 与 roving tabindex。
|
|
33
|
+
- 新增回归测试:计价一致性、按到达时间判断峰谷、catalog 与 usage 缓存、并发保存、413 状态保留、历史价格簿、移除模型后的历史计费、可信地址校验。
|
|
34
|
+
|
|
35
|
+
### 变更
|
|
36
|
+
|
|
37
|
+
- 峰谷档位改为按 `request/header` 的到达时间判断,而不是完成时间。
|
|
38
|
+
- `totalTokens` 与三档用量不一致时,保留三档并将该次调用标记为 partial,而不是整条丢弃。
|
|
39
|
+
- catalog 与 usage 读取增加缓存;catalog 缓存可用 `?refresh=1` 绕过。
|
|
40
|
+
- 宿主要求为 DSH `^0.2.1-rc.3`。
|
|
41
|
+
|
|
42
|
+
### 修复
|
|
43
|
+
|
|
44
|
+
- 并发到达的保存会串行执行,避免互相覆盖;超大请求体在 `/save` 与 `/select` 上均保留 413 状态。
|
|
45
|
+
- 加载时清理残留的临时状态文件;由更高版本插件写入的状态改为只读,不再被覆盖。
|
|
46
|
+
- catalog 不可用时选择器不再接管,内置选择器保持可用。
|
|
47
|
+
- 显示设置以 Host 状态为唯一来源;旧版 localStorage 值会清理一次。
|
|
48
|
+
- 即使没有配置模型,设置页也保留保存按钮。
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- Historical price book under Settings → Models & Cost: add, edit, or remove three-price entries for models that are no longer in the configured list. Removing a model keeps its price, so past turns keep their cost and fall back to the raw model id when no display name exists.
|
|
53
|
+
- Price-source labels and one-click restore: a complete custom price overrides the built-in DeepSeek table and is marked “Custom”; the official peak/off-peak table can be restored without another request.
|
|
54
|
+
- Unpriced-route list in the cost panel, plus a switch to hide unpriced notices.
|
|
55
|
+
- Separate settings sections for the turn tail and the composer cost estimate, each with its own master switch.
|
|
56
|
+
- LAN and reverse-proxy access: besides loopback, a request is trusted when its Host header matches the address the socket actually listens on. A forged Host is still rejected.
|
|
57
|
+
- Keyboard access: the cost panel is a modal dialog that takes focus on open; the picker supports ↑/↓/Home/End with a roving tabindex.
|
|
58
|
+
- Regression tests for pricing consistency, arrival-time peak/off-peak, catalog and usage caches, concurrent saves, 413 preservation, archive pricing, historical billing after model removal, and trusted authorities.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Peak/off-peak is now chosen from the `request/header` arrival time instead of the completion time.
|
|
63
|
+
- A `totalTokens` that disagrees with the three usage buckets keeps the buckets and marks the attempt partial instead of dropping it.
|
|
64
|
+
- Catalog and usage reads are cached; catalog caching can be bypassed with `?refresh=1`.
|
|
65
|
+
- Requires DSH `^0.2.1-rc.3`.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- Saves that arrive together are serialized so one cannot overwrite the other; oversized bodies keep their 413 status on both `/save` and `/select`.
|
|
70
|
+
- Stale temporary state files are removed on load; state written by a newer plugin version is read-only instead of being overwritten.
|
|
71
|
+
- The picker no longer takes over when the catalog is unavailable, so the built-in picker remains usable.
|
|
72
|
+
- Display settings have a single source of truth in Host state; legacy localStorage values are cleared once.
|
|
73
|
+
- The settings save button is present even with no models configured.
|
|
74
|
+
|
|
75
|
+
## [0.1.2] - 2026-10-22
|
|
76
|
+
|
|
77
|
+
### 新增
|
|
78
|
+
|
|
79
|
+
- 设置 → 模型与费用新增轮尾显示设置:可选择模型、模型提供商、费用、token、缓存命中率与文字标签,并可调整字号、间距与字重。
|
|
80
|
+
- 新增“让其他选择器接管输入框模型选择器”开关;关闭后恢复 DSH 内置选择器。
|
|
81
|
+
- 在 `peerDependencies` 中声明支持的 DSH 宿主范围,便于 dsh-market 展示宿主兼容性。
|
|
82
|
+
|
|
83
|
+
### 变更
|
|
84
|
+
|
|
85
|
+
- 轮尾数值改用与周围聊天元数据一致的次级文字颜色,不再是亮白色。
|
|
86
|
+
- 轮尾字段改用间距分隔,不再使用中点;移除悬停说明。
|
|
87
|
+
- 费用详情面板的模型列加宽,可完整显示 `DeepSeek-V4.1-Flash`。
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- Turn-tail display settings under Settings → Models & Cost: choose models, model provider, cost, tokens, cache-hit rate, and text labels; adjust font size, spacing, and weight.
|
|
92
|
+
- A switch to let another picker take over the composer model selector; turning it off restores the built-in DSH picker.
|
|
93
|
+
- Declared the supported DSH host range in `peerDependencies` so dsh-market can show host compatibility.
|
|
94
|
+
|
|
95
|
+
### Changed
|
|
96
|
+
|
|
97
|
+
- Turn-tail values now use the same secondary text color as the surrounding chat metadata instead of bright white.
|
|
98
|
+
- Turn-tail fields are separated by spacing rather than middle dots, and the hover explanation has been removed.
|
|
99
|
+
- The model column in the cost details panel is wide enough to show `DeepSeek-V4.1-Flash` without truncation.
|
|
100
|
+
|
|
101
|
+
## [0.1.1] - 2026-10-21
|
|
102
|
+
|
|
103
|
+
### 新增
|
|
104
|
+
|
|
105
|
+
- 在每轮末尾、本轮费用旁显示该轮使用的模型。
|
|
106
|
+
|
|
107
|
+
### Added
|
|
108
|
+
|
|
109
|
+
- Show the model or models used by each turn at the end of that turn, next to the per-turn cost.
|
|
110
|
+
|
|
111
|
+
## [0.1.0] - 2026-10-20
|
|
112
|
+
|
|
113
|
+
### 新增
|
|
114
|
+
|
|
115
|
+
- 输入框模型选择器支持自定义已配置模型的顺序。
|
|
116
|
+
- 每个模型可编辑三档每百万 token 价格:缓存命中输入、缓存未命中输入、输出。
|
|
117
|
+
- 为 `deepseek-flash` 与 `deepseek-v4-pro` 内置 DeepSeek 官方峰谷价,按每次用量事件的北京时间选择档位。
|
|
118
|
+
- 提供逐轮与会话费用估算:输入框下方摘要、轮尾金额,以及按轮次和模型分组的详情面板。
|
|
119
|
+
- 状态本地持久化到 `~/.dsh/model-billing.json`,使用原子写入。
|
|
120
|
+
|
|
121
|
+
### Added
|
|
122
|
+
|
|
123
|
+
- Custom ordering for configured models in the composer model picker.
|
|
124
|
+
- Three editable per-million-token prices per model: cache-hit input, cache-miss input, and output.
|
|
125
|
+
- Built-in official DeepSeek peak/off-peak prices for `deepseek-flash` and `deepseek-v4-pro`, selected from each usage event's Beijing time.
|
|
126
|
+
- Per-turn and session cost estimates with a composer summary, turn-tail amount, and a detail panel grouped by turn and model.
|
|
127
|
+
- Local persistence at `~/.dsh/model-billing.json` with atomic writes.
|
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
- **逐轮费用与使用模型**:每轮结束后在轮尾显示这一轮使用的模型,以及该轮费用和缓存命中率。
|
|
35
35
|
- **可配置轮尾**:在设置中总开关轮尾显示,并可分别开关使用模型、模型提供商、费用、token、缓存命中率、文字标签,调整字号、间距和字重;也可以关闭选择器接管,恢复 DSH 内置模型选择器。输入框下方的费用估算另有独立开关。
|
|
36
36
|
- **本会话费用**:输入框下方显示本会话累计估算、最近一轮费用和缓存命中率;展开后可查看按轮次和按模型的明细。
|
|
37
|
+
- **DeepSeek Search/Fetch 按次费**:当 DSH 的 Web 搜索或抓取使用 DeepSeek 独立的 Search / Fetch API 时,按官方 ¥5 / 1,000 次成功请求计入合计,并在费用明细里单独列出搜索、抓取的计费次数。
|
|
37
38
|
- **历史价格与未定价路由**:设置页的“历史价格”区可以为已移出配置的模型补价、改价或删除,不必把模型加回列表;从模型列表删除模型不会删除它的价格,历史调用仍按保存的价格计算,查不到名称时显示原始模型 id。缺价时显示统一提示,可在设置里关闭这条提示。
|
|
38
39
|
- **DeepSeek 也计入金额**:每条 usage 按事件发生时的北京时间自动选择高峰或空闲价,与第三方模型一起进入统计。
|
|
39
40
|
|
|
@@ -43,9 +44,10 @@
|
|
|
43
44
|
缓存命中输入 × 命中价
|
|
44
45
|
+(未缓存输入 + 缓存写入)× 未命中价
|
|
45
46
|
+ 输出 × 输出价
|
|
47
|
+
+ DeepSeek Search/Fetch 成功请求次数 × 单次价
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
价格单位是“元 / 百万 tokens
|
|
50
|
+
价格单位是“元 / 百万 tokens”;Search/Fetch 按次计费,单次价为 ¥0.005(¥5 / 1,000 次成功请求)。缓存写入按缓存未命中价计算,这是三档价格模型下的近似处理。金额来自会话日志中的 provider usage 与工具结果,实际账单以 provider 为准。
|
|
49
51
|
|
|
50
52
|
### DeepSeek 官方价
|
|
51
53
|
|
|
@@ -131,6 +133,7 @@ npm 包和 GitHub Release 使用相同版本号,两个渠道分别发布:
|
|
|
131
133
|
- 一轮里的多次模型尝试(重试、失败等)会分别计入;失败调用如果 provider 没有返回 usage,则无法计入。
|
|
132
134
|
- 只统计当前载入 Host 的会话;未打开的历史会话在进入后按需计算。
|
|
133
135
|
- provider 返回的 `totalTokens` 与三档明细不一致时,保留三档明细并把该轮标为“部分未定价/不完整”,不再整条丢弃,避免大幅少报。
|
|
136
|
+
- 当 DSH 的 Web 搜索或抓取使用 DeepSeek 独立的 `deepseek-search-api` / `deepseek-fetch-api` 时,按 DeepSeek 官方价把 **Search/Fetch 按次费**(¥5 / 1,000 次成功请求,2026-11-01 00:00 北京时间起)计入合计;Messages 搜索(`deepseek-official`)在模型回合内完成,费用已随该次请求的 token 计入,因此不再重复收按次费。DuckDuckGo、Tavily、普通 HTTP 抓取等不收费的 provider 不会被计费。一次 `web_search` 含多个 query 时按一次成功工具调用计,可能低估其中多个 query 各自的按次费。
|
|
134
137
|
- 第三方价格是静态配置,不会自动跟随官网调价;内置节假日表目前只覆盖 2026 年,2027 年起需更新 `DEEPSEEK_HOLIDAYS`。
|
|
135
138
|
|
|
136
139
|
### 开发
|
|
@@ -141,7 +144,7 @@ node --check client.js
|
|
|
141
144
|
node --test test/*.test.mjs
|
|
142
145
|
```
|
|
143
146
|
|
|
144
|
-
|
|
147
|
+
插件没有运行时 npm 依赖。
|
|
145
148
|
|
|
146
149
|
### License
|
|
147
150
|
|
|
@@ -161,6 +164,7 @@ node --test test/*.test.mjs
|
|
|
161
164
|
- **Per-turn model and amount** at the end of each turn, together with the turn's cache-hit rate.
|
|
162
165
|
- **Configurable turn tail**: a master switch for the turn tail, plus individual toggles for models, provider names, cost, tokens, cache-hit rate, and text labels; adjust font size, spacing, and weight; or disable the picker takeover to restore the built-in DSH picker. The composer cost estimate has its own switch.
|
|
163
166
|
- **Session summary** below the composer plus a detail panel grouped by turn and by model.
|
|
167
|
+
- **DeepSeek Search/Fetch per-request fees**: when DSH web search or fetch runs on DeepSeek's standalone Search / Fetch APIs, the official fee of ¥5 per 1,000 successful requests is included, with search and fetch billed counts shown separately in the detail panel.
|
|
164
168
|
- **Historical prices and unpriced routes**: the "Historical prices" section in Settings lets you add, edit, or remove prices for models no longer in your order without re-adding them. Removing a model from the list keeps its price, so historical calls still bill correctly; when the name is unknown, the raw model id is shown. Unpriced calls share one notice that can be switched off.
|
|
165
169
|
- **DeepSeek is billed too**, using the same three-price formula and the event's timestamp.
|
|
166
170
|
|
|
@@ -170,9 +174,10 @@ node --test test/*.test.mjs
|
|
|
170
174
|
cache-hit input × hit price
|
|
171
175
|
+ (uncached input + cache write) × miss price
|
|
172
176
|
+ output × output price
|
|
177
|
+
+ DeepSeek Search/Fetch successful requests × per-request price
|
|
173
178
|
```
|
|
174
179
|
|
|
175
|
-
Prices are CNY per million tokens. Cache writes are approximated at the cache-miss price. Figures come from provider usage in the session log; the provider's invoice is authoritative.
|
|
180
|
+
Prices are CNY per million tokens; Search/Fetch is billed per request at ¥0.005 (¥5 per 1,000 successful requests). Cache writes are approximated at the cache-miss price. Figures come from provider usage and tool results in the session log; the provider's invoice is authoritative.
|
|
176
181
|
|
|
177
182
|
The plugin ships **no third-party price defaults**. Enter your provider's actual rates in Settings → Models & Cost. Models without a complete three-price set are marked as unpriced and contribute no amount. DeepSeek prices come from the built-in official table; enter a complete custom set to override it, or clear all three fields to restore the official rates.
|
|
178
183
|
|
|
@@ -230,6 +235,7 @@ Order and prices live in `~/.dsh/model-billing.json` (or `$DSH_HOME/model-billin
|
|
|
230
235
|
- Multiple attempts within a turn are counted separately; failed calls without provider usage cannot be counted.
|
|
231
236
|
- Only sessions loaded by the current Host are counted.
|
|
232
237
|
- When a provider's `totalTokens` disagrees with its three buckets, the buckets are kept and the turn is marked partial instead of being dropped, which avoids large under-reporting.
|
|
238
|
+
- When DSH web search or fetch runs on DeepSeek's standalone `deepseek-search-api` / `deepseek-fetch-api` providers, the DeepSeek **Search/Fetch per-request fee** (¥5 per 1,000 successful requests, from 2026-11-01 00:00 Beijing time) is included. Messages search (`deepseek-official`) runs inside a model turn, so its cost already arrives as that turn's tokens and is not charged again per request. Free providers such as DuckDuckGo, Tavily, and plain HTTP fetch are never charged. A `web_search` call containing several queries is counted as one successful tool call, so the per-query part may be understated.
|
|
233
239
|
- Third-party prices are static and are not refreshed automatically. The bundled holiday table covers 2026 only; update `DEEPSEEK_HOLIDAYS` for 2027 onward.
|
|
234
240
|
|
|
235
241
|
### Development
|
|
@@ -240,7 +246,7 @@ node --check client.js
|
|
|
240
246
|
node --test test/*.test.mjs
|
|
241
247
|
```
|
|
242
248
|
|
|
243
|
-
|
|
249
|
+
The plugin has no runtime npm dependencies.
|
|
244
250
|
|
|
245
251
|
### License
|
|
246
252
|
|
package/client.js
CHANGED
|
@@ -95,6 +95,7 @@ window.__ModuleLoader__.load({
|
|
|
95
95
|
invalidRoute: '提供商和模型都不能为空。',
|
|
96
96
|
remove: '删除',
|
|
97
97
|
estimateNote: '金额由会话日志中的 token 用量和缓存命中估算;DeepSeek 按事件时间使用内置峰谷价,实际账单以提供方为准。',
|
|
98
|
+
webToolNote: '含 DeepSeek Search/Fetch 按次费 ≈ {amount}(搜索 {searchBilled}/{searchCalls} 次,抓取 {fetchBilled}/{fetchCalls} 次)。',
|
|
98
99
|
listSep: '、',
|
|
99
100
|
turnCost: '费用',
|
|
100
101
|
turnTokens: '本轮 Token',
|
|
@@ -210,6 +211,7 @@ window.__ModuleLoader__.load({
|
|
|
210
211
|
invalidRoute: 'Provider and model are both required.',
|
|
211
212
|
remove: 'Remove',
|
|
212
213
|
estimateNote: 'Amounts are estimated from token usage and cache hits in the session log; DeepSeek uses built-in peak/off-peak prices at the event time, and the provider bill is authoritative.',
|
|
214
|
+
webToolNote: 'Includes DeepSeek Search/Fetch per-request fees ≈ {amount} (search {searchBilled}/{searchCalls}, fetch {fetchBilled}/{fetchCalls}).',
|
|
213
215
|
listSep: ', ',
|
|
214
216
|
turnCost: 'Cost',
|
|
215
217
|
turnTokens: 'Turn tokens',
|
|
@@ -1595,6 +1597,15 @@ window.__ModuleLoader__.load({
|
|
|
1595
1597
|
modelTable,
|
|
1596
1598
|
unpricedTable,
|
|
1597
1599
|
h('p', { className: 'mb-panel-note' }, translate('estimateNote')),
|
|
1600
|
+
totals.toolCost > 0
|
|
1601
|
+
? h('p', { className: 'mb-panel-note' }, translate('webToolNote', {
|
|
1602
|
+
amount: formatMoney(totals.toolCost),
|
|
1603
|
+
searchBilled: totals.webSearchBilled ?? 0,
|
|
1604
|
+
searchCalls: totals.webSearchCalls ?? 0,
|
|
1605
|
+
fetchBilled: totals.webFetchBilled ?? 0,
|
|
1606
|
+
fetchCalls: totals.webFetchCalls ?? 0,
|
|
1607
|
+
}))
|
|
1608
|
+
: null,
|
|
1598
1609
|
totals.partial && display.showUnpricedNotice
|
|
1599
1610
|
? h('p', { className: 'mb-warn' }, translate('partialNote'))
|
|
1600
1611
|
: null)
|
|
@@ -2249,7 +2260,10 @@ window.__ModuleLoader__.load({
|
|
|
2249
2260
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
2250
2261
|
name: 'settings.section',
|
|
2251
2262
|
id: 'model-billing',
|
|
2252
|
-
|
|
2263
|
+
// Native sections run -10..20 (account, general, models, plugins,
|
|
2264
|
+
// agent presets); third-party pages start at 25 so they sit below
|
|
2265
|
+
// the native group instead of splitting it.
|
|
2266
|
+
order: 25,
|
|
2253
2267
|
label: () => t('nav'),
|
|
2254
2268
|
}, SettingsSection))
|
|
2255
2269
|
|
package/host.js
CHANGED
|
@@ -25,6 +25,17 @@ const MAX_STORED_PRICES = 2000
|
|
|
25
25
|
const MAX_BODY_BYTES = 256 * 1024
|
|
26
26
|
const PRICE_FIELDS = ['cacheHit', 'cacheMiss', 'output']
|
|
27
27
|
const KEY_SEPARATOR = '\u0000'
|
|
28
|
+
// DeepSeek Search/Fetch: ¥5 per 1,000 successful requests, billed from
|
|
29
|
+
// 2026-11-01 00:00 Beijing time. The web tools record one tool/call and one
|
|
30
|
+
// tool/result per invocation, so the fee can be folded from the session log.
|
|
31
|
+
const DEEPSEEK_WEB_TOOL_START = Date.parse('2026-11-01T00:00:00+08:00')
|
|
32
|
+
const DEEPSEEK_WEB_TOOL_PRICE = 0.005
|
|
33
|
+
// Only the standalone Search/Fetch APIs are billed per request. The Messages
|
|
34
|
+
// search provider (`deepseek-official`) runs Anthropic's `web_search_20250305`
|
|
35
|
+
// server tool inside a model turn, so its cost already arrives as prompt and
|
|
36
|
+
// completion tokens; charging ¥0.005 there would bill the same call twice.
|
|
37
|
+
const DEEPSEEK_SEARCH_PROVIDERS = new Set(['deepseek-search-api'])
|
|
38
|
+
const DEEPSEEK_FETCH_PROVIDERS = new Set(['deepseek-fetch-api'])
|
|
28
39
|
const DISPLAY_DEFAULTS = Object.freeze({
|
|
29
40
|
showTurnTail: true,
|
|
30
41
|
showDock: true,
|
|
@@ -483,13 +494,39 @@ function userTextPreview(message) {
|
|
|
483
494
|
* sample without a lifecycle route is retained with an unknown route so the UI
|
|
484
495
|
* can report it as unpriced instead of silently dropping tokens.
|
|
485
496
|
*/
|
|
486
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Which web providers the current composition uses. Only DeepSeek's own
|
|
499
|
+
* providers are billed per successful request; DuckDuckGo, Tavily and local
|
|
500
|
+
* HTTP fetch are free, so they must never be charged here.
|
|
501
|
+
*/
|
|
502
|
+
function webToolBilling(ctx) {
|
|
503
|
+
const read = (value) => typeof value?.get === 'function' ? value.get() : value
|
|
504
|
+
let web
|
|
505
|
+
try {
|
|
506
|
+
web = ctx?.get?.('web')
|
|
507
|
+
} catch {
|
|
508
|
+
web = undefined
|
|
509
|
+
}
|
|
510
|
+
const search = read(web?.config?.searchProvider) ?? process.env.DSH_WEB_SEARCH_PROVIDER
|
|
511
|
+
const fetch = read(web?.config?.fetchProvider) ?? process.env.DSH_WEB_FETCH_PROVIDER
|
|
512
|
+
return {
|
|
513
|
+
search: typeof search === 'string' && DEEPSEEK_SEARCH_PROVIDERS.has(search) ? DEEPSEEK_WEB_TOOL_PRICE : null,
|
|
514
|
+
fetch: typeof fetch === 'string' && DEEPSEEK_FETCH_PROVIDERS.has(fetch) ? DEEPSEEK_WEB_TOOL_PRICE : null,
|
|
515
|
+
start: DEEPSEEK_WEB_TOOL_START,
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Tools whose DeepSeek execution is charged per successful invocation. */
|
|
520
|
+
const BILLABLE_WEB_TOOLS = new Set(['web_search', 'web_fetch'])
|
|
521
|
+
|
|
522
|
+
function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new Map(), orderKeys = null, toolBilling = null) {
|
|
487
523
|
const turns = new Map()
|
|
488
524
|
// When the caller knows which routes are still configured, an unpriced call can
|
|
489
525
|
// be split into "needs attention now" and "historical leftovers". Without that
|
|
490
526
|
// set every unpriced call is historical, which keeps older callers unchanged.
|
|
491
527
|
const orderSet = orderKeys instanceof Set ? orderKeys : null
|
|
492
528
|
const unpricedRoutes = new Map()
|
|
529
|
+
const pendingToolCalls = new Map()
|
|
493
530
|
let headerRoute
|
|
494
531
|
let headerAt
|
|
495
532
|
let latestTime = 0
|
|
@@ -507,6 +544,7 @@ function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new M
|
|
|
507
544
|
seenUser: false,
|
|
508
545
|
attempts: [],
|
|
509
546
|
models: new Map(),
|
|
547
|
+
toolCharges: [],
|
|
510
548
|
}
|
|
511
549
|
turns.set(turn, entry)
|
|
512
550
|
}
|
|
@@ -605,6 +643,39 @@ function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new M
|
|
|
605
643
|
}
|
|
606
644
|
if (event?.type === 'assistant/attempt') {
|
|
607
645
|
addAttempt(event, headerRoute, normalizeUsage(undefined, event.data?.stream))
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
if (event?.type === 'tool/call') {
|
|
649
|
+
const callId = event.data?.callId
|
|
650
|
+
const name = event.data?.name
|
|
651
|
+
if (typeof callId === 'string' && BILLABLE_WEB_TOOLS.has(name)) {
|
|
652
|
+
pendingToolCalls.set(callId, { turn: event.data?.turn, name, time: Number(event.time) || latestTime })
|
|
653
|
+
}
|
|
654
|
+
continue
|
|
655
|
+
}
|
|
656
|
+
if (event?.type === 'tool/result') {
|
|
657
|
+
const callId = event.data?.message?.toolCallId ?? event.data?.callId
|
|
658
|
+
const call = typeof callId === 'string' ? pendingToolCalls.get(callId) : undefined
|
|
659
|
+
if (call === undefined) continue
|
|
660
|
+
pendingToolCalls.delete(callId)
|
|
661
|
+
const turn = Number.isSafeInteger(event.data?.turn) ? event.data.turn : call.turn
|
|
662
|
+
if (!Number.isSafeInteger(turn)) continue
|
|
663
|
+
const time = Number(event.time) || call.time
|
|
664
|
+
const entry = ensureTurn(turn, time)
|
|
665
|
+
entry.endTime = Math.max(entry.endTime, time)
|
|
666
|
+
const kind = call.name === 'web_fetch' ? 'fetch' : 'search'
|
|
667
|
+
const price = toolBilling?.[kind]
|
|
668
|
+
if (price === null || price === undefined) continue
|
|
669
|
+
const failed = event.data?.error !== undefined || event.data?.message?.isError === true
|
|
670
|
+
let succeeded = false
|
|
671
|
+
if (!failed && call.name === 'web_search') {
|
|
672
|
+
succeeded = Array.isArray(event.data?.meta?.sources) && event.data.meta.sources.length > 0
|
|
673
|
+
} else if (!failed && call.name === 'web_fetch') {
|
|
674
|
+
succeeded = event.data?.meta?.statusCode === 200
|
|
675
|
+
}
|
|
676
|
+
const billed = succeeded && time >= toolBilling.start
|
|
677
|
+
entry.toolCharges.push({ name: call.name, time, billed, cost: billed ? price : 0 })
|
|
678
|
+
continue
|
|
608
679
|
}
|
|
609
680
|
}
|
|
610
681
|
|
|
@@ -612,6 +683,11 @@ function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new M
|
|
|
612
683
|
const byModel = new Map()
|
|
613
684
|
const totals = {
|
|
614
685
|
cost: 0,
|
|
686
|
+
toolCost: 0,
|
|
687
|
+
webSearchCalls: 0,
|
|
688
|
+
webSearchBilled: 0,
|
|
689
|
+
webFetchCalls: 0,
|
|
690
|
+
webFetchBilled: 0,
|
|
615
691
|
promptTokens: 0,
|
|
616
692
|
uncachedInputTokens: 0,
|
|
617
693
|
cacheReadTokens: 0,
|
|
@@ -632,13 +708,18 @@ function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new M
|
|
|
632
708
|
}
|
|
633
709
|
|
|
634
710
|
for (const entry of [...turns.values()].sort((left, right) => left.turn - right.turn)) {
|
|
635
|
-
if (entry.attempts.length === 0) continue
|
|
711
|
+
if (entry.attempts.length === 0 && entry.toolCharges.length === 0) continue
|
|
636
712
|
const row = {
|
|
637
713
|
turn: entry.turn,
|
|
638
714
|
startTime: entry.startTime,
|
|
639
715
|
endTime: entry.endTime,
|
|
640
716
|
preview: entry.preview,
|
|
641
717
|
cost: 0,
|
|
718
|
+
toolCost: 0,
|
|
719
|
+
webSearchCalls: 0,
|
|
720
|
+
webSearchBilled: 0,
|
|
721
|
+
webFetchCalls: 0,
|
|
722
|
+
webFetchBilled: 0,
|
|
642
723
|
promptTokens: 0,
|
|
643
724
|
uncachedInputTokens: 0,
|
|
644
725
|
cacheReadTokens: 0,
|
|
@@ -738,6 +819,18 @@ function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new M
|
|
|
738
819
|
if (attempt.priced) aggregate.pricedAttempts += 1
|
|
739
820
|
else aggregate.unpricedAttempts += 1
|
|
740
821
|
}
|
|
822
|
+
for (const charge of entry.toolCharges) {
|
|
823
|
+
if (charge.name === 'web_search') {
|
|
824
|
+
row.webSearchCalls += 1
|
|
825
|
+
if (charge.billed) row.webSearchBilled += 1
|
|
826
|
+
} else if (charge.name === 'web_fetch') {
|
|
827
|
+
row.webFetchCalls += 1
|
|
828
|
+
if (charge.billed) row.webFetchBilled += 1
|
|
829
|
+
}
|
|
830
|
+
row.toolCost += charge.cost
|
|
831
|
+
row.hasBillable = true
|
|
832
|
+
}
|
|
833
|
+
row.cost += row.toolCost
|
|
741
834
|
row.cacheHit = row.cacheReported && row.promptTokens > 0 ? row.cacheReadTokens / row.promptTokens : null
|
|
742
835
|
row.partial = row.unpricedAttempts > 0 || usagePartial
|
|
743
836
|
row.partialCurrent = row.unpricedCurrentAttempts > 0
|
|
@@ -745,6 +838,11 @@ function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new M
|
|
|
745
838
|
turnRows.push(row)
|
|
746
839
|
|
|
747
840
|
totals.cost += row.cost
|
|
841
|
+
totals.toolCost += row.toolCost
|
|
842
|
+
totals.webSearchCalls += row.webSearchCalls
|
|
843
|
+
totals.webSearchBilled += row.webSearchBilled
|
|
844
|
+
totals.webFetchCalls += row.webFetchCalls
|
|
845
|
+
totals.webFetchBilled += row.webFetchBilled
|
|
748
846
|
totals.promptTokens += row.promptTokens
|
|
749
847
|
totals.uncachedInputTokens += row.uncachedInputTokens
|
|
750
848
|
totals.cacheReadTokens += row.cacheReadTokens
|
|
@@ -1067,12 +1165,12 @@ const USAGE_CACHE_LIMIT = 50
|
|
|
1067
1165
|
const usageCache = new Map()
|
|
1068
1166
|
|
|
1069
1167
|
/** Cache one session's folded usage until its event tail or pricing changes. */
|
|
1070
|
-
function usageCacheKey(sessionId, lastSeq, savedAt) {
|
|
1071
|
-
return `${sessionId}\u0000${lastSeq ?? 'none'}\u0000${savedAt ?? 'none'}`
|
|
1168
|
+
function usageCacheKey(sessionId, lastSeq, savedAt, webKey = '') {
|
|
1169
|
+
return `${sessionId}\u0000${lastSeq ?? 'none'}\u0000${savedAt ?? 'none'}\u0000${webKey}`
|
|
1072
1170
|
}
|
|
1073
1171
|
|
|
1074
|
-
function cachedUsage(sessionId, lastSeq, savedAt, fold) {
|
|
1075
|
-
const key = usageCacheKey(sessionId, lastSeq, savedAt)
|
|
1172
|
+
function cachedUsage(sessionId, lastSeq, savedAt, fold, webKey = '') {
|
|
1173
|
+
const key = usageCacheKey(sessionId, lastSeq, savedAt, webKey)
|
|
1076
1174
|
const hit = usageCache.get(key)
|
|
1077
1175
|
if (hit !== undefined) {
|
|
1078
1176
|
usageCache.delete(key)
|
|
@@ -1107,12 +1205,14 @@ async function serveUsage(ctx, request, response) {
|
|
|
1107
1205
|
const state = await loadState(ctx)
|
|
1108
1206
|
const events = session.ownEvents()
|
|
1109
1207
|
const lastSeq = Array.isArray(events) && events.length > 0 ? events[events.length - 1]?.seq : undefined
|
|
1208
|
+
const billing = webToolBilling(ctx)
|
|
1209
|
+
const webKey = `${billing.search ?? 'free'}:${billing.fetch ?? 'free'}:${billing.start}`
|
|
1110
1210
|
const usage = cachedUsage(sessionId, lastSeq, state.savedAt, () => {
|
|
1111
1211
|
const pricingByKey = new Map()
|
|
1112
1212
|
for (const [key, value] of Object.entries(state.pricing)) pricingByKey.set(key, publicPricing(value))
|
|
1113
1213
|
const orderKeys = new Set(state.order.map((entry) => modelKey(entry.provider, entry.model)))
|
|
1114
|
-
return computeSessionUsage(events, pricingByKey, new Map(), orderKeys)
|
|
1115
|
-
})
|
|
1214
|
+
return computeSessionUsage(events, pricingByKey, new Map(), orderKeys, billing)
|
|
1215
|
+
}, webKey)
|
|
1116
1216
|
sendJson(response, 200, {
|
|
1117
1217
|
ok: true,
|
|
1118
1218
|
sessionId,
|
|
@@ -1286,4 +1386,5 @@ export const __test = {
|
|
|
1286
1386
|
statePath,
|
|
1287
1387
|
trustedAuthority,
|
|
1288
1388
|
validRoute,
|
|
1389
|
+
webToolBilling,
|
|
1289
1390
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-billing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reorder models in the DSH composer picker, store three per-million-token prices, and estimate per-turn and session cost with built-in DeepSeek peak/off-peak rates.",
|
|
6
6
|
"keywords": [
|