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 CHANGED
@@ -1,61 +1,127 @@
1
- # Changelog
2
-
3
- ## [0.2.0] - 2026-10-28
4
-
5
- ### Added
6
-
7
- - 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.
8
- - 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.
9
- - Unpriced-route list in the cost panel, plus a switch to hide unpriced notices.
10
- - Separate settings sections for the turn tail and the composer cost estimate, each with a larger master switch and indented sub-switches. The composer summary now has its own `showDock` switch and the turn tail has `showTurnTail`.
11
- - 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.
12
- - Keyboard access: the cost panel is a modal dialog that takes focus on open; the picker supports ↑/↓/Home/End with a roving tabindex.
13
- - 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.
14
-
15
- ### Changed
16
-
17
- - Peak/off-peak is now chosen from the `request/header` arrival time instead of the completion time.
18
- - A `totalTokens` that disagrees with the three usage buckets keeps the buckets and marks the attempt partial instead of dropping it.
19
- - Catalog and usage reads are cached; catalog caching can be bypassed with `?refresh=1`.
20
- - The composer summary and detail panel are labelled “Composer cost estimate” (输入框下方费用估算), avoiding the “费用明细” name used by another plugin.
21
- - Cost figures no longer carry a trailing asterisk; partial state is shown by the notice instead.
22
- - Historical price rows use a two-row layout so provider, model, and all three prices stay readable; the remove button reads “删除”.
23
- - Requires DSH `^0.2.1-rc.3`.
24
-
25
- ### Fixed
26
-
27
- - Saves that arrive together are serialized so one cannot overwrite the other; oversized bodies keep their 413 status on both `/save` and `/select`.
28
- - Stale temporary state files are removed on load; state written by a newer plugin version is read-only instead of being overwritten.
29
- - The picker no longer takes over when the catalog is unavailable, so the built-in picker remains usable.
30
- - Display settings have a single source of truth in Host state; legacy localStorage values are cleared once.
31
- - Turn-tail and detail-panel amounts share one formatter, and the settings save button is present even with no models configured.
32
-
33
- ## [0.1.2] - 2026-10-22
34
-
35
- ### Added
36
-
37
- - 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.
38
- - A switch to let another picker take over the composer model selector; turning it off restores the built-in DSH picker.
39
- - Declared the supported DSH host range in `peerDependencies` so dsh-market can show host compatibility.
40
-
41
- ### Changed
42
-
43
- - Turn-tail values now use the same secondary text color as the surrounding chat metadata instead of bright white.
44
- - Turn-tail fields are separated by spacing rather than middle dots, and the hover explanation has been removed.
45
- - The model column in the cost details panel is wide enough to show `DeepSeek-V4.1-Flash` without truncation.
46
-
47
- ## [0.1.1] - 2026-10-21
48
-
49
- ### Added
50
-
51
- - Show the model or models used by each turn at the end of that turn, next to the per-turn cost.
52
-
53
- ## [0.1.0] - 2026-10-20
54
-
55
- ### Added
56
-
57
- - Custom ordering for configured models in the composer model picker.
58
- - Three editable per-million-token prices per model: cache-hit input, cache-miss input, and output.
59
- - Built-in official DeepSeek peak/off-peak prices for `deepseek-flash` and `deepseek-v4-pro`, selected from each usage event's Beijing time.
60
- - Per-turn and session cost estimates with a composer summary, turn-tail amount, and a detail panel grouped by turn and model.
61
- - Local persistence at `~/.dsh/model-billing.json` with atomic writes.
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”。缓存写入按缓存未命中价计算,这是三档价格模型下的近似处理。金额来自会话日志中的 provider usage,实际账单以 provider 为准。
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
- 当前 25 个测试通过。插件没有运行时 npm 依赖。
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
- 25 tests currently pass. The plugin has no runtime npm dependencies.
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
- order: 12,
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
- function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new Map(), orderKeys = null) {
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.2.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": [