dsh-plugin-billing-card 0.1.0 → 0.3.0

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
@@ -2,19 +2,31 @@
2
2
 
3
3
  在 DeepSeek Harness 的输入框下方实时显示**当前会话**的 token 用量与估算费用,按模型分项统计,
4
4
  默认内置 DeepSeek 官方价格(含**峰谷分时计价**),并保留完整的扩展能力:价格表、峰谷规则、
5
- 其他模型/提供商都可以在插件配置里追加,不需要改代码。
5
+ 其他模型/提供商都可以在插件配置里追加,不需要改代码。0.2.0 起同时统计 **DeepSeek 官方网页搜索 /
6
+ 抓取 API** 的调用次数与按次费用(Search 服务端调用免费至 2026-11-01 00:00,之后按每 1000 次
7
+ 5 元估算);0.3.0 起卡片按当前**实际生效的 provider** 标注——服务端搜索(`deepseek-official`)、
8
+ 直连 Search / Fetch API(`deepseek-search-api` / `deepseek-fetch-api`)、本地 HTTP 抓取(`http`)
9
+ 各按自己的计费口径显示,不会再把本地免费抓取或直连 API 标成「免费期内」。
10
+
11
+ > npm:[`dsh-plugin-billing-card@0.3.0`](https://www.npmjs.com/package/dsh-plugin-billing-card)
12
+ > · `tofu-box@1.2.2` 起包含本版本。
13
+ >
14
+ > 兼容性:`engines.dsh` 声明为 `>=0.2.0-rc.2 <0.3.0-0`,已实测运行于 dsh **0.2.1-rc.3**。
6
15
 
7
16
  - **Host 半**:注册一个客户端可见的 Session 投影单元 `billingUsage`,在提交事件时折叠
8
- `assistant/message` / `assistant/attempt` 中模型上报的 `usage`,按 `request/header` 归属模型路由,
17
+ `assistant/message` / `assistant/attempt` 中模型上报的 `usage`(按 `request/header` 归属模型路由),
18
+ 并折叠 `tool/call` / `tool/result` 里的 `web_search` / `web_fetch` 调用来估算按次费用;
9
19
  纯函数、可重放、不产生任何模型调用。
10
20
  - **Client 半**:在 `conversation.composer.dock`(输入框下方的环境条目区)新增一个
11
- `billing-card` 条目;平时是一枚小药丸(金额 + token 数),点开后是分模型明细卡片。
21
+ `billing-card` 条目;平时是一枚只显示估算费用的小药丸(token 总量已由 DSH 自带用量药丸显示,
22
+ 这里不重复),点开后是明细卡片。
12
23
 
13
- > 显示的是**按 token 数与价格表计算的估算值**,不是官方账单。实际扣费以 DeepSeek 账单为准。
24
+ > 显示的是**按 token 数、按次价格与价格表计算的估算值**,不是官方账单。实际扣费以 DeepSeek 账单为准。
14
25
 
15
26
  ## 显示内容
16
27
 
17
- 药丸(收起态):`¥0.0123 · 15.2K tok`,鼠标悬停/读屏可读到完整摘要(金额、token、缓存命中率)。
28
+ 药丸(收起态):`¥0.0123`。只显示估算费用——会话 token 总量与缓存命中率已由 DSH 自带的用量药丸
29
+ 显示,不重复占位。鼠标悬停/读屏可读到「预估费用 ¥…」。
18
30
 
19
31
  卡片(展开态):
20
32
 
@@ -22,31 +34,32 @@
22
34
  - token 汇总:缓存未命中输入、缓存命中输入(及命中率)、输出、合计。
23
35
  - 按模型分项:模型名、token 数、估算费用;没有配置价格的模型显示「未配置价格」,
24
36
  token 仍计入合计,但不计入费用。
37
+ - 联网搜索小节(本会话有搜索/抓取调用时出现):搜索调用次数、provider 请求数、预计计费请求数、
38
+ 搜索费用、抓取次数;标注随当前 provider 变化——服务端搜索免费期内显示「免费期内(11/1 起 …)」,
39
+ 直连 API 直接显示按次费用,本地 HTTP 抓取显示「免费」。
25
40
 
26
41
  会话还没有任何已结算的 token 用量时,卡片不显示(不会占位、不会报错)。
27
42
 
28
43
  ## 安装
29
44
 
30
- 推荐先在一个**独立的开发 profile** 里安装与验证,不要装进你日常使用的 `desktop` profile:
45
+ 已发布 npm:`dsh-plugin-billing-card@0.3.0`,同时也是 `tofu-box` 伞包(1.2.2 起)的成员。
46
+ 插件行 id 为 `billing-card`,Client 半是手写浏览器模块,无需构建步骤。
31
47
 
32
48
  ```bash
33
- # 1. 从官方 web 模板新建一个独立 profile(只在不存在时创建;不会打开浏览器)
34
- dsh --profile billing-dev --from-default-profile web --dump-config
35
-
36
- # 2. 安装本插件(本地目录,link 方式;无需构建步骤)
37
- dsh plugin --profile billing-dev add D:\program\dsh-plugins\dsh-plugin-billing-card
38
-
39
- # 3. 启动 web profile(--no-open 不会抢占浏览器/桌面焦点;端口按需修改)
40
- dsh --profile billing-dev --no-open --port 58121
41
- ```
49
+ # 1) 单装(npm)
50
+ dsh plugin --profile <你的profile> add dsh-plugin-billing-card
42
51
 
43
- 启动日志会打印带一次性 `?token=` 的地址,用浏览器打开即可;插件行 id 为 `billing-card`,
44
- 包名为 `dsh-plugin-billing-card`。验证完成后,也可以把它装进你自己的正式 profile:
52
+ # 2) 或装整套伞包(自动包含本插件)
53
+ dsh plugin --profile <你的profile> add tofu-box
45
54
 
46
- ```bash
55
+ # 3) 开发/未发版:从本仓库本地目录安装(link 方式,改代码不用重装)
47
56
  dsh plugin --profile <你的profile> add D:\program\dsh-plugins\dsh-plugin-billing-card
48
57
  ```
49
58
 
59
+ > 建议先在一个**独立 profile** 里验证,不要装进你日常使用的 `desktop` profile:用官方 web 模板新建
60
+ > `dsh --profile billing-dev --from-default-profile web --dump-config`,装好后
61
+ > `dsh --profile billing-dev --no-open --port 58121` 启动(日志会打印带一次性 `?token=` 的地址)。
62
+
50
63
  > 新装的 Client 模块需要页面刷新一次才会加载;旧会话的投影基线也在刷新/重连后才会带上
51
64
  > `billingUsage`,刷新后输入框下方就会出现计费药丸。
52
65
 
@@ -95,6 +108,43 @@ plugin_manager install_bundle target = D:\program\dsh-plugins\dsh-plugin-billing
95
108
  > 价格来源:DeepSeek 官方「模型 & 价格」页(中文 / 英文),核对时间 2026-10-17。
96
109
  > 官方保留调价权利,请以官网与你的实际结算为准;调价后覆盖本插件配置即可。
97
110
 
111
+ ## 联网搜索 / 抓取计费(0.3.0)
112
+
113
+ 官方口径(Search / Fetch API,2026-10-17 上线):
114
+
115
+ - 按**成功请求次数**计费:每 1,000 次成功请求 **5 元 / $0.75**;失败、空结果、超时不计费,
116
+ `count` 只控制返回条数、不影响单价。
117
+ - 在 DeepSeek Harness / Claude Code / Codex 中由**服务端工具调用**触发的 Search / Fetch 目前免费,
118
+ **北京时间 2026-11-01 00:00 起**按次收费;而直连 API(`web.searchProvider: deepseek-search-api` /
119
+ `web.fetchProvider: deepseek-fetch-api`)自上线起就按次计费,没有免费期。
120
+ - 本插件从会话日志统计(不额外发请求):
121
+ - `web_search`:每次工具调用按**去重后的 query 数**计为 provider 请求数(工具本身就是每个
122
+ query 发一个请求);一次调用成功且结果源非空时,这些 query 计为「预计计费请求」。
123
+ - `web_fetch`:按 HTTP 200 的成功抓取计为一次抓取请求。
124
+ - 费用 = 预计计费请求数 × `search.perRequest`(默认 0.005 元/次,美元 0.00075/次)。
125
+ - 卡片读取当前 `web` 行的 `searchProvider` / `fetchProvider`(「设置 → 插件 → Web 搜索」里的
126
+ 「启用新版 Web 搜索 API」开关写的就是这两个字段),据此标注:
127
+
128
+ | 生效 provider | 搜索费用标签 | 抓取费用标签 |
129
+ |---|---|---|
130
+ | 服务端 Messages 搜索 `deepseek-official` | 免费期内显示「免费期内(11/1 起 ¥…)」 | —(默认抓取是 `http`) |
131
+ | 直连 Search / Fetch API | 直接显示按次费用,并注明没有服务端免费期 | 显示 `次数 · 费用` |
132
+ | 本地 HTTP 抓取 `http` | — | 显示 `次数 · 免费`,并注明不计费 |
133
+ | 其他 provider(Exa / Perplexity 等) | 只显示按次估算,不声称免费期 | 同上 |
134
+
135
+ 读不到设置时按出厂默认(`deepseek-official` + `http`)标注,不会因此隐藏卡片。
136
+
137
+ 已知限制(估算口径):
138
+
139
+ - 服务端一次 provider 请求内部可能触发 1 次以上 Search(`max_uses`,默认 5),日志不记录实际
140
+ 次数,因此「预计计费」是**下限估算**。
141
+ - 多 query 批量搜索失败时,已成功的那部分可能已被官方计费,但这里整单失败、不计费;
142
+ 空结果的成功调用不计费(与官方一致)。
143
+ - 服务端搜索附带的辅助模型 token 费用不写入会话日志,本卡片不统计。
144
+ - 计数只按工具名(`web_search` / `web_fetch`),不区分 provider;换用 Exa / Perplexity 等
145
+ 第三方 provider 时次数仍会累计,卡片只会显示按 `search.perRequest` 的估算而不声称官方口径。
146
+ 这类部署若不想看到按次费用,把 `search.enabled` 设为 `false`。
147
+
98
148
  ## 配置
99
149
 
100
150
  配置写在插件行 `billing-card` 的 `config` 里(profile 的 `cordis.patch.yml` 对整行 `config`
@@ -124,6 +174,13 @@ plugin_manager install_bundle target = D:\program\dsh-plugins\dsh-plugin-billing
124
174
  windows: [['09:00', '12:00'], ['14:00', '18:00']]
125
175
  holidays: [] # 例:['2026-10-01', '2026-10-02'];列出的当地日期全天按空闲计价
126
176
  multiplier: 2
177
+
178
+ # 联网搜索 / 抓取按次计价(0.2.0 起,默认即下方值)
179
+ search:
180
+ enabled: true # false = 完全关闭搜索/抓取统计
181
+ perRequest: 0.005 # 每次成功请求的价格(当前币种);默认 CNY 0.005 / USD 0.00075
182
+ searchFreeUntil: 2026-10-31T16:00:00.000Z # 搜索服务端调用的免费截止(北京时间 11/1 00:00);null = 已计费
183
+ trackFetch: true # 是否统计直连 DeepSeek Fetch API 的 web_fetch 调用
127
184
  ```
128
185
 
129
186
  ### 支持其他提供商 / 模型
@@ -166,7 +223,7 @@ plugin_manager install_bundle target = D:\program\dsh-plugins\dsh-plugin-billing
166
223
  - **法定节假日不会自动识别**:官方规则里中国法定节假日按空闲计价,插件无法内置年年变化的
167
224
  节假日表,请在 `peak.holidays` 里按需填写;不填写时只按「周一至周五 + 时间窗口」判断。
168
225
  - 金额为估算值,浮点计算后仅用于展示(保留 2–4 位小数);并发上限、搜索/抓取等按次计费的
169
- 接口不在统计范围。
226
+ 接口按上面的口径估算,不是账单金额。
170
227
 
171
228
  ## 结构
172
229
 
package/billing-core.js CHANGED
@@ -45,7 +45,34 @@ const PRICE_RULE_FIELDS = Object.freeze([
45
45
  ])
46
46
 
47
47
  /** Recognized top-level config fields; anything else is a typo. */
48
- const CONFIG_FIELDS = Object.freeze(['currency', 'pricing', 'peak'])
48
+ const CONFIG_FIELDS = Object.freeze(['currency', 'pricing', 'peak', 'search'])
49
+
50
+ /** Recognized `search` policy fields. */
51
+ const SEARCH_FIELDS = Object.freeze(['enabled', 'perRequest', 'searchFreeUntil', 'trackFetch'])
52
+
53
+ /**
54
+ * Official DeepSeek Search / Fetch per-request prices, in the configured
55
+ * currency: 5 CNY per 1,000 successful requests, $0.75 per 1,000.
56
+ */
57
+ const DEFAULT_SEARCH_PER_REQUEST = Object.freeze({ CNY: 0.005, USD: 0.00075 })
58
+
59
+ /**
60
+ * DeepSeek keeps server-side Search / Fetch calls free through Beijing
61
+ * 2026-11-01 00:00 (2026-10-31T16:00:00Z); direct API requests are billed from
62
+ * launch. Only the server-side search window is configurable here.
63
+ */
64
+ const DEFAULT_SEARCH_FREE_UNTIL = '2026-10-31T16:00:00.000Z'
65
+
66
+ /** The all-zero search counters of a fresh fold state. */
67
+ const ZERO_SEARCH_COUNTERS = Object.freeze({
68
+ calls: 0,
69
+ requests: 0,
70
+ billable: 0,
71
+ emptyCalls: 0,
72
+ failedCalls: 0,
73
+ fetchCalls: 0,
74
+ fetchBillable: 0,
75
+ })
49
76
 
50
77
  /** Recognized `peak` policy fields. */
51
78
  const PEAK_FIELDS = Object.freeze([
@@ -196,6 +223,65 @@ function resolveCurrency(value, path) {
196
223
  return /** @type {'CNY'|'USD'} */ (value)
197
224
  }
198
225
 
226
+ /**
227
+ * Resolve one optional boolean flag.
228
+ * @param {unknown} value - configured flag or `undefined`.
229
+ * @param {string} path - config path of the flag.
230
+ * @param {boolean} fallback - value used when the flag is absent.
231
+ * @returns {boolean} the effective flag.
232
+ */
233
+ function resolveBoolean(value, path, fallback) {
234
+ if (value === undefined) return fallback
235
+ if (typeof value !== 'boolean') failConfig(`${path} must be a boolean`)
236
+ return value
237
+ }
238
+
239
+ /**
240
+ * Resolve one optional ISO-8601 timestamp, where `null` disables the window.
241
+ * @param {unknown} value - configured timestamp, `null`, or `undefined`.
242
+ * @param {string} path - config path of the value.
243
+ * @param {string} fallback - ISO-8601 timestamp used when the value is absent.
244
+ * @returns {number|null} epoch milliseconds, or `null` when the window is disabled.
245
+ */
246
+ function resolveTimestamp(value, path, fallback) {
247
+ if (value === undefined) return Date.parse(fallback)
248
+ if (value === null) return null
249
+ if (typeof value !== 'string' || value.length === 0) failConfig(`${path} must be an ISO-8601 timestamp or null`)
250
+ const parsed = Date.parse(value)
251
+ if (!Number.isFinite(parsed)) failConfig(`${path} must be an ISO-8601 timestamp or null`)
252
+ return parsed
253
+ }
254
+
255
+ /**
256
+ * Validate the optional `search` policy and apply every default. The policy
257
+ * covers DeepSeek's per-request Search / Fetch billing: one price for both
258
+ * APIs, a free window for server-side search calls only, and a switch for the
259
+ * direct Fetch API path used by the runtime's web fetch tool.
260
+ * @param {unknown} rawSearch - raw `config.search` value.
261
+ * @param {'CNY'|'USD'} currency - configured billing currency.
262
+ * @returns {{ enabled: boolean, perRequest: number, searchFreeUntil: number|null, trackFetch: boolean }} resolved policy.
263
+ */
264
+ function resolveSearchConfig(rawSearch, currency) {
265
+ if (rawSearch === undefined) {
266
+ return {
267
+ enabled: true,
268
+ perRequest: DEFAULT_SEARCH_PER_REQUEST[currency],
269
+ searchFreeUntil: Date.parse(DEFAULT_SEARCH_FREE_UNTIL),
270
+ trackFetch: true,
271
+ }
272
+ }
273
+ if (!isPlainObject(rawSearch)) failConfig('config.search must be a plain object')
274
+ rejectUnknownKeys(rawSearch, SEARCH_FIELDS, 'config.search')
275
+ return {
276
+ enabled: resolveBoolean(rawSearch.enabled, 'config.search.enabled', true),
277
+ perRequest: rawSearch.perRequest === undefined
278
+ ? DEFAULT_SEARCH_PER_REQUEST[currency]
279
+ : finiteNonNegative(rawSearch.perRequest, 'config.search.perRequest'),
280
+ searchFreeUntil: resolveTimestamp(rawSearch.searchFreeUntil, 'config.search.searchFreeUntil', DEFAULT_SEARCH_FREE_UNTIL),
281
+ trackFetch: resolveBoolean(rawSearch.trackFetch, 'config.search.trackFetch', true),
282
+ }
283
+ }
284
+
199
285
  /**
200
286
  * Convert `HH:MM` to a minute-of-day offset.
201
287
  * @param {string} time - validated `HH:MM` string.
@@ -411,7 +497,7 @@ function buildBuiltinRules(peakMultiplier) {
411
497
  * negative prices, bad currencies, `HH:MM` times, weekdays, or holiday dates
412
498
  * throw an `Error` whose message names the config path.
413
499
  * @param {unknown} [rawConfig] - raw row config from the profile composition.
414
- * @returns {{ currency: 'CNY'|'USD', rules: Array<Record<string, number|string>>, builtinRules: Array<Record<string, number|string>>, peak: { enabled: boolean, utcOffsetMinutes: number, weekdays: number[], windows: Array<[string, string]>, holidays: string[], multiplier: number } }} resolved config.
500
+ * @returns {{ currency: 'CNY'|'USD', rules: Array<Record<string, number|string>>, builtinRules: Array<Record<string, number|string>>, peak: { enabled: boolean, utcOffsetMinutes: number, weekdays: number[], windows: Array<[string, string]>, holidays: string[], multiplier: number }, search: { enabled: boolean, perRequest: number, searchFreeUntil: number|null, trackFetch: boolean } }} resolved config.
415
501
  */
416
502
  export function resolveBillingConfig(rawConfig = {}) {
417
503
  if (!isPlainObject(rawConfig)) failConfig('config must be a plain object')
@@ -420,9 +506,10 @@ export function resolveBillingConfig(rawConfig = {}) {
420
506
  ? DEFAULT_CURRENCY
421
507
  : resolveCurrency(rawConfig.currency, 'config.currency')
422
508
  const peak = resolvePeakPolicy(rawConfig.peak)
509
+ const search = resolveSearchConfig(rawConfig.search, currency)
423
510
  const rules = resolvePriceRules(rawConfig.pricing, currency, peak.multiplier)
424
511
  const builtinRules = buildBuiltinRules(peak.multiplier)
425
- return { currency, rules, builtinRules, peak }
512
+ return { currency, rules, builtinRules, peak, search }
426
513
  }
427
514
 
428
515
  // --------------------------------------------------------------------------
@@ -660,6 +747,139 @@ function addBuckets(routes, routeKey, provider, model, tier, buckets) {
660
747
  return { ...routes, [routeKey]: { ...entry, [tier]: next } }
661
748
  }
662
749
 
750
+ // --------------------------------------------------------------------------
751
+ // Search / fetch fold
752
+ // --------------------------------------------------------------------------
753
+
754
+ /**
755
+ * Count the distinct non-blank queries of one `web_search` tool call. The tool
756
+ * collapses exact duplicate queries before dispatching one provider request per
757
+ * distinct query, so this count is that call's provider-request count.
758
+ * @param {unknown} argumentsValue - raw `arguments` JSON string or parsed object.
759
+ * @returns {number|null} distinct query count, or `null` when the payload is not a valid search call.
760
+ */
761
+ function searchQueriesOf(argumentsValue) {
762
+ let args = argumentsValue
763
+ if (typeof args === 'string') {
764
+ try {
765
+ args = JSON.parse(args)
766
+ } catch {
767
+ return null
768
+ }
769
+ }
770
+ if (!isPlainObject(args) || !Array.isArray(args.queries)) return null
771
+ const queries = new Set()
772
+ for (const query of args.queries) {
773
+ if (typeof query === 'string' && query.trim().length > 0) queries.add(query)
774
+ }
775
+ return queries.size === 0 ? null : queries.size
776
+ }
777
+
778
+ /**
779
+ * Read the tool call id off one persisted `tool/result` message.
780
+ * @param {Record<string, any>} message - the tool message.
781
+ * @returns {string|undefined} the call id, when the message carries one.
782
+ */
783
+ function resultCallId(message) {
784
+ if (typeof message.toolCallId === 'string' && message.toolCallId.length > 0) return message.toolCallId
785
+ const source = message.source
786
+ if (isPlainObject(source) && typeof source.callId === 'string' && source.callId.length > 0) return source.callId
787
+ return undefined
788
+ }
789
+
790
+ /**
791
+ * Fold one `tool/call` event into the search / fetch counters. A search call
792
+ * records one provider request per distinct query (the tool dispatches one per
793
+ * query, and the current runtime logs no separate request event) under the call
794
+ * id until the result arrives; a fetch call only records its call id (its
795
+ * result meta carries the status).
796
+ * @param {{ search: Record<string, any> }} state - current state.
797
+ * @param {{ data?: any }} event - committed `tool/call` event.
798
+ * @param {{ trackFetch: boolean }} searchPolicy - resolved search policy.
799
+ * @returns {typeof state} the next state, or the same reference for other tools.
800
+ */
801
+ function applySearchToolCall(state, event, searchPolicy) {
802
+ const data = event.data
803
+ if (!isPlainObject(data)) return state
804
+ if (typeof data.callId !== 'string' || data.callId.length === 0) return state
805
+ const search = state.search
806
+ if (data.name === 'web_search') {
807
+ const queries = searchQueriesOf(data.arguments)
808
+ if (queries === null) return state
809
+ return {
810
+ ...state,
811
+ search: {
812
+ ...search,
813
+ calls: search.calls + 1,
814
+ requests: search.requests + queries,
815
+ pending: { ...search.pending, [data.callId]: queries },
816
+ },
817
+ }
818
+ }
819
+ if (data.name === 'web_fetch' && searchPolicy.trackFetch === true) {
820
+ return {
821
+ ...state,
822
+ search: {
823
+ ...search,
824
+ fetchCalls: search.fetchCalls + 1,
825
+ pendingFetch: { ...search.pendingFetch, [data.callId]: 1 },
826
+ },
827
+ }
828
+ }
829
+ return state
830
+ }
831
+
832
+ /**
833
+ * Fold one `tool/result` event that completes a tracked search or fetch call.
834
+ *
835
+ * A search result is billable only when the call succeeded with a non-empty
836
+ * merged source list: the provider returns sources only when the Search API
837
+ * returned results, and an empty source list is the tool's "No results found"
838
+ * success. A fetch result is billable only with HTTP 200 and no tool error.
839
+ * The pending entry is removed on the first result, so a compaction-pruner
840
+ * replacement result for the same call is ignored.
841
+ * @param {{ search: Record<string, any> }} state - current state.
842
+ * @param {{ data?: any }} event - committed `tool/result` event.
843
+ * @param {{ trackFetch: boolean }} searchPolicy - resolved search policy.
844
+ * @returns {typeof state} the next state, or the same reference for other calls.
845
+ */
846
+ function applySearchToolResult(state, event, searchPolicy) {
847
+ const data = event.data
848
+ if (!isPlainObject(data) || !isPlainObject(data.message)) return state
849
+ const message = data.message
850
+ const callId = resultCallId(message)
851
+ if (callId === undefined) return state
852
+ const search = state.search
853
+ if (Object.hasOwn(search.pending, callId)) {
854
+ const queries = search.pending[callId]
855
+ const pending = { ...search.pending }
856
+ delete pending[callId]
857
+ const next = { ...search, pending }
858
+ if (message.isError === true) {
859
+ return { ...state, search: { ...next, failedCalls: next.failedCalls + 1 } }
860
+ }
861
+ const sources = isPlainObject(data.meta) && Array.isArray(data.meta.sources) ? data.meta.sources : null
862
+ if (sources === null) return { ...state, search: { ...next, failedCalls: next.failedCalls + 1 } }
863
+ if (sources.length === 0) return { ...state, search: { ...next, emptyCalls: next.emptyCalls + 1 } }
864
+ return { ...state, search: { ...next, billable: next.billable + queries } }
865
+ }
866
+ if (searchPolicy.trackFetch === true && Object.hasOwn(search.pendingFetch, callId)) {
867
+ const pendingFetch = { ...search.pendingFetch }
868
+ delete pendingFetch[callId]
869
+ const meta = isPlainObject(data.meta) ? data.meta : null
870
+ const billable = message.isError !== true && meta !== null && meta.statusCode === 200
871
+ return {
872
+ ...state,
873
+ search: {
874
+ ...search,
875
+ pendingFetch,
876
+ fetchBillable: search.fetchBillable + (billable ? 1 : 0),
877
+ },
878
+ }
879
+ }
880
+ return state
881
+ }
882
+
663
883
  // --------------------------------------------------------------------------
664
884
  // Fold transitions
665
885
  // --------------------------------------------------------------------------
@@ -742,22 +962,28 @@ function applyUsage(state, event, peakPolicy) {
742
962
  routes,
743
963
  current: state.current,
744
964
  last: { turn: data.turn, step: data.step, routeKey, tier, buckets },
965
+ search: state.search,
745
966
  }
746
967
  }
747
968
 
748
969
  /**
749
970
  * The projection fold: the one transition from committed event to state.
750
- * @param {{ routes: Record<string, any>, current: { provider: string, model: string }|null, last: any }} state - state covering all prior events.
971
+ * @param {{ routes: Record<string, any>, current: { provider: string, model: string }|null, last: any, search: Record<string, any> }} state - state covering all prior events.
751
972
  * @param {any} event - the next committed Session event.
752
- * @param {{ enabled: boolean, utcOffsetMinutes: number, weekdays: number[], windows: Array<[string, string]>, holidays: string[], multiplier: number }} peakPolicy - resolved peak policy.
973
+ * @param {ReturnType<typeof resolveBillingConfig>} resolvedConfig - resolved config.
753
974
  * @returns {typeof state} the next state; the same reference when the event is not this unit's.
754
975
  */
755
- function applyEvent(state, event, peakPolicy) {
976
+ function applyEvent(state, event, resolvedConfig) {
756
977
  if (!isPlainObject(event) || typeof event.type !== 'string') return state
757
978
  if (event.type === 'request/header') return applyRequestHeader(state, event)
758
979
  if (event.type === 'llm/retry-started') return applyRetryStarted(state, event)
980
+ const searchPolicy = resolvedConfig.search
981
+ if (searchPolicy !== undefined && searchPolicy.enabled === true) {
982
+ if (event.type === 'tool/call') return applySearchToolCall(state, event, searchPolicy)
983
+ if (event.type === 'tool/result') return applySearchToolResult(state, event, searchPolicy)
984
+ }
759
985
  if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return state
760
- return applyUsage(state, event, peakPolicy)
986
+ return applyUsage(state, event, resolvedConfig.peak)
761
987
  }
762
988
 
763
989
  // --------------------------------------------------------------------------
@@ -843,6 +1069,55 @@ function viewRoute(entry, resolvedConfig) {
843
1069
  }
844
1070
  }
845
1071
 
1072
+ /**
1073
+ * Coerce one persisted search counter into a non-negative integer. Missing,
1074
+ * non-finite, and negative values count as 0.
1075
+ * @param {unknown} value - raw counter.
1076
+ * @returns {number} the counter value.
1077
+ */
1078
+ function counterValue(value) {
1079
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0
1080
+ }
1081
+
1082
+ /**
1083
+ * Build the wire view of the search / fetch cost section. Counts are always
1084
+ * present; costs are estimates from the resolved per-request price. Both costs
1085
+ * assume the DeepSeek APIs charge per request, so the search cost is the
1086
+ * post-free-window estimate and the fetch cost the direct-API estimate; the
1087
+ * client decides how to label each one from the `web` row's provider selection,
1088
+ * which this replayable fold deliberately does not read.
1089
+ * @param {{ search?: Record<string, any> }} state - fold state.
1090
+ * @param {ReturnType<typeof resolveBillingConfig>} resolvedConfig - resolved config.
1091
+ * @returns {Record<string, any>} the `search` wire section.
1092
+ */
1093
+ function searchView(state, resolvedConfig) {
1094
+ const policy = resolvedConfig.search
1095
+ const search = isPlainObject(state) && isPlainObject(state.search) ? state.search : {}
1096
+ const trackFetch = policy.trackFetch === true
1097
+ const calls = counterValue(search.calls)
1098
+ const requests = counterValue(search.requests)
1099
+ const billable = counterValue(search.billable)
1100
+ const fetchCalls = trackFetch ? counterValue(search.fetchCalls) : 0
1101
+ const fetchBillable = trackFetch ? counterValue(search.fetchBillable) : 0
1102
+ const searchCost = billable * policy.perRequest
1103
+ const fetchCost = fetchBillable * policy.perRequest
1104
+ return {
1105
+ enabled: policy.enabled === true,
1106
+ perRequest: policy.perRequest,
1107
+ searchFreeUntil: policy.searchFreeUntil,
1108
+ calls,
1109
+ requests,
1110
+ billable,
1111
+ emptyCalls: counterValue(search.emptyCalls),
1112
+ failedCalls: counterValue(search.failedCalls),
1113
+ fetchCalls,
1114
+ fetchBillable,
1115
+ searchCost,
1116
+ fetchCost,
1117
+ totalCost: searchCost + fetchCost,
1118
+ }
1119
+ }
1120
+
846
1121
  /**
847
1122
  * Project one fold state to the client wire view.
848
1123
  *
@@ -850,9 +1125,9 @@ function viewRoute(entry, resolvedConfig) {
850
1125
  * object. Only routes with at least one token are listed; unpriced routes keep
851
1126
  * their tokens in `totals` but carry `priced: false`, `pricing: null`, and zero
852
1127
  * cost fields. Costs are raw floats in the configured currency.
853
- * @param {{ routes: Record<string, { provider: string, model: string, peak: Record<string, number>, offPeak: Record<string, number> }> }} state - fold state.
1128
+ * @param {{ routes: Record<string, { provider: string, model: string, peak: Record<string, number>, offPeak: Record<string, number> }>, search?: Record<string, any> }} state - fold state.
854
1129
  * @param {ReturnType<typeof resolveBillingConfig>} resolvedConfig - resolved config.
855
- * @returns {{ version: 1, currency: string, routes: Array<Record<string, any>>, totals: Record<string, number> }} the wire view.
1130
+ * @returns {{ version: 1, currency: string, routes: Array<Record<string, any>>, totals: Record<string, number>, search: Record<string, any> }} the wire view.
856
1131
  */
857
1132
  export function billingView(state, resolvedConfig) {
858
1133
  const currency = isPlainObject(resolvedConfig) ? resolvedConfig.currency : DEFAULT_CURRENCY
@@ -891,7 +1166,7 @@ export function billingView(state, resolvedConfig) {
891
1166
  if (route.priced) totals.pricedRoutes += 1
892
1167
  else totals.unpricedRoutes += 1
893
1168
  }
894
- return { version: 1, currency, routes, totals }
1169
+ return { version: 1, currency, routes, totals, search: searchView(state, resolvedConfig) }
895
1170
  }
896
1171
 
897
1172
  // --------------------------------------------------------------------------
@@ -1010,6 +1285,50 @@ function lastSampleIssue(value) {
1010
1285
  return bucketIssue(value.buckets, 'state.last.buckets')
1011
1286
  }
1012
1287
 
1288
+ /**
1289
+ * Describe why a value is not a valid `state.search` pending map.
1290
+ * @param {unknown} value - candidate pending map.
1291
+ * @param {string} path - field path for the issue description.
1292
+ * @returns {string|null} the issue, if any.
1293
+ */
1294
+ function searchPendingIssue(value, path) {
1295
+ if (!isPlainObject(value)) return `${path} must be a plain object`
1296
+ for (const [callId, entry] of Object.entries(value)) {
1297
+ if (callId.length === 0) return `${path} keys must be non-empty call ids`
1298
+ const issue = integerIssue(entry, `${path}[${JSON.stringify(callId)}]`)
1299
+ if (issue !== null) return issue
1300
+ }
1301
+ return null
1302
+ }
1303
+
1304
+ /**
1305
+ * Describe why a value is not a valid `state.search` counter block.
1306
+ * @param {unknown} value - candidate search state.
1307
+ * @returns {string|null} the issue, if any.
1308
+ */
1309
+ function searchStateIssue(value) {
1310
+ if (!isPlainObject(value)) return 'state.search must be a plain object'
1311
+ const fields = [
1312
+ 'calls',
1313
+ 'requests',
1314
+ 'billable',
1315
+ 'emptyCalls',
1316
+ 'failedCalls',
1317
+ 'fetchCalls',
1318
+ 'fetchBillable',
1319
+ 'pending',
1320
+ 'pendingFetch',
1321
+ ]
1322
+ if (!hasExactKeys(value, fields)) return `state.search must carry exactly ${fields.join(', ')}`
1323
+ for (const field of fields) {
1324
+ if (field === 'pending' || field === 'pendingFetch') continue
1325
+ const issue = integerIssue(value[field], `state.search.${field}`)
1326
+ if (issue !== null) return issue
1327
+ }
1328
+ return searchPendingIssue(value.pending, 'state.search.pending')
1329
+ ?? searchPendingIssue(value.pendingFetch, 'state.search.pendingFetch')
1330
+ }
1331
+
1013
1332
  /**
1014
1333
  * Describe why a value is not a valid `billingUsage` fold state. Used by
1015
1334
  * `stateSchema` before a persisted checkpoint seeds a fold.
@@ -1018,13 +1337,13 @@ function lastSampleIssue(value) {
1018
1337
  */
1019
1338
  function stateIssue(value) {
1020
1339
  if (!isPlainObject(value)) return 'state must be a plain object'
1021
- if (!hasExactKeys(value, ['routes', 'current', 'last'])) return 'state must carry exactly routes, current, and last'
1340
+ if (!hasExactKeys(value, ['routes', 'current', 'last', 'search'])) return 'state must carry exactly routes, current, last, and search'
1022
1341
  if (!isPlainObject(value.routes)) return 'state.routes must be a plain object'
1023
1342
  for (const [routeKey, route] of Object.entries(value.routes)) {
1024
1343
  const issue = stateRouteIssue(route, `state.routes[${JSON.stringify(routeKey)}]`)
1025
1344
  if (issue !== null) return issue
1026
1345
  }
1027
- return currentRouteIssue(value.current) ?? lastSampleIssue(value.last)
1346
+ return currentRouteIssue(value.current) ?? lastSampleIssue(value.last) ?? searchStateIssue(value.search)
1028
1347
  }
1029
1348
 
1030
1349
  /**
@@ -1114,6 +1433,47 @@ function totalsIssue(value, path) {
1114
1433
  return integerIssue(value.unpricedRoutes, `${path}.unpricedRoutes`)
1115
1434
  }
1116
1435
 
1436
+ /**
1437
+ * Describe why a value is not a valid `search` section of the wire view.
1438
+ * @param {unknown} value - candidate search section.
1439
+ * @returns {string|null} the issue, if any.
1440
+ */
1441
+ function searchViewIssue(value) {
1442
+ if (!isPlainObject(value)) return 'view.search must be a plain object'
1443
+ const fields = [
1444
+ 'enabled',
1445
+ 'perRequest',
1446
+ 'searchFreeUntil',
1447
+ 'calls',
1448
+ 'requests',
1449
+ 'billable',
1450
+ 'emptyCalls',
1451
+ 'failedCalls',
1452
+ 'fetchCalls',
1453
+ 'fetchBillable',
1454
+ 'searchCost',
1455
+ 'fetchCost',
1456
+ 'totalCost',
1457
+ ]
1458
+ if (!hasExactKeys(value, fields)) return `view.search must carry exactly ${fields.join(', ')}`
1459
+ if (typeof value.enabled !== 'boolean') return 'view.search.enabled must be a boolean'
1460
+ const perRequestIssue = numberIssue(value.perRequest, 'view.search.perRequest')
1461
+ if (perRequestIssue !== null) return perRequestIssue
1462
+ if (value.searchFreeUntil !== null) {
1463
+ const issue = numberIssue(value.searchFreeUntil, 'view.search.searchFreeUntil')
1464
+ if (issue !== null) return issue
1465
+ }
1466
+ for (const field of ['calls', 'requests', 'billable', 'emptyCalls', 'failedCalls', 'fetchCalls', 'fetchBillable']) {
1467
+ const issue = integerIssue(value[field], `view.search.${field}`)
1468
+ if (issue !== null) return issue
1469
+ }
1470
+ for (const field of ['searchCost', 'fetchCost', 'totalCost']) {
1471
+ const issue = numberIssue(value[field], `view.search.${field}`)
1472
+ if (issue !== null) return issue
1473
+ }
1474
+ return null
1475
+ }
1476
+
1117
1477
  /**
1118
1478
  * Describe why a value is not a valid wire view for this projection.
1119
1479
  * @param {unknown} value - candidate view.
@@ -1121,8 +1481,8 @@ function totalsIssue(value, path) {
1121
1481
  */
1122
1482
  function viewIssue(value) {
1123
1483
  if (!isPlainObject(value)) return 'view must be a plain object'
1124
- if (!hasExactKeys(value, ['version', 'currency', 'routes', 'totals'])) {
1125
- return 'view must carry exactly version, currency, routes, and totals'
1484
+ if (!hasExactKeys(value, ['version', 'currency', 'routes', 'totals', 'search'])) {
1485
+ return 'view must carry exactly version, currency, routes, totals, and search'
1126
1486
  }
1127
1487
  if (value.version !== 1) return 'view.version must be 1'
1128
1488
  if (!CURRENCIES.includes(/** @type {string} */ (value.currency))) return "view.currency must be 'CNY' or 'USD'"
@@ -1131,7 +1491,7 @@ function viewIssue(value) {
1131
1491
  const issue = viewRouteIssue(value.routes[index], `view.routes[${index}]`)
1132
1492
  if (issue !== null) return issue
1133
1493
  }
1134
- return totalsIssue(value.totals, 'view.totals')
1494
+ return totalsIssue(value.totals, 'view.totals') ?? searchViewIssue(value.search)
1135
1495
  }
1136
1496
 
1137
1497
  /** Validates one persisted `billingUsage` fold state. */
@@ -1156,7 +1516,6 @@ const viewSchema = structuralSchema('billingUsage view', viewIssue)
1156
1516
  * @returns {{ key: string, stateVersion: number, stateSchema: { parse: Function, safeParse: Function }, init: () => object, apply: (state: object, event: unknown) => object, wire: { viewSchema: { parse: Function, safeParse: Function }, view: (state: object) => object } }} the projection definition.
1157
1517
  */
1158
1518
  export function createBillingProjection(resolvedConfig) {
1159
- const peakPolicy = resolvedConfig.peak
1160
1519
  let cachedState
1161
1520
  let cachedView
1162
1521
  const view = (state) => {
@@ -1167,10 +1526,15 @@ export function createBillingProjection(resolvedConfig) {
1167
1526
  }
1168
1527
  return {
1169
1528
  key: BILLING_PROJECTION_KEY,
1170
- stateVersion: 1,
1529
+ stateVersion: 2,
1171
1530
  stateSchema,
1172
- init: () => ({ routes: {}, current: null, last: null }),
1173
- apply: (state, event) => applyEvent(state, event, peakPolicy),
1531
+ init: () => ({
1532
+ routes: {},
1533
+ current: null,
1534
+ last: null,
1535
+ search: { ...ZERO_SEARCH_COUNTERS, pending: {}, pendingFetch: {} },
1536
+ }),
1537
+ apply: (state, event) => applyEvent(state, event, resolvedConfig),
1174
1538
  wire: { viewSchema, view },
1175
1539
  }
1176
1540
  }
package/client.js CHANGED
@@ -43,10 +43,23 @@ window.__ModuleLoader__.load({
43
43
  'routes.unknownModel': '未知模型',
44
44
  'unpriced.badge': '未配置价格',
45
45
  'unpriced.hint': '仅统计 token',
46
+ 'search.title': '联网搜索',
47
+ 'search.calls': '网页搜索',
48
+ 'search.callsValue': '{calls} 次({requests} 个请求)',
49
+ 'search.billable': '预计计费',
50
+ 'search.billableValue': '{count} 次',
51
+ 'search.cost': '搜索费用',
52
+ 'search.freeValue': '免费期内(11/1 起 {cost})',
53
+ 'search.freeNote': '服务端搜索调用免费至 2026-11-01 00:00,之后按官方每 1000 次价格估算。',
54
+ 'search.directNote': '当前使用直连 DeepSeek Search API,按次计费,没有服务端免费期。',
55
+ 'search.fetch': '网页抓取',
56
+ 'search.fetchValue': '{count} 次 · {cost}',
57
+ 'search.fetchLocalValue': '{count} 次 · 免费',
58
+ 'search.fetchNote': '网页抓取为直连 DeepSeek Fetch API,按次计费。',
59
+ 'search.fetchLocalNote': '网页抓取走本地 HTTP,不产生按次费用。',
46
60
  'footer.estimate': '费用为按配置价格表估算的预估值,非实际账单。',
47
61
  'footer.config': '价格与峰谷时段可在插件配置中调整。',
48
- 'pill.aria': '预估费用 {cost},{tokens},缓存命中率 {percent}%',
49
- 'pill.ariaNoCache': '预估费用 {cost},{tokens}',
62
+ 'pill.aria': '预估费用 {cost}',
50
63
  }
51
64
 
52
65
  const ENGLISH = {
@@ -63,10 +76,23 @@ window.__ModuleLoader__.load({
63
76
  'routes.unknownModel': 'Unknown model',
64
77
  'unpriced.badge': 'Unpriced',
65
78
  'unpriced.hint': 'Tokens only',
79
+ 'search.title': 'Web search',
80
+ 'search.calls': 'Search',
81
+ 'search.callsValue': '{calls} calls ({requests} provider requests)',
82
+ 'search.billable': 'Estimated billable',
83
+ 'search.billableValue': '{count} requests',
84
+ 'search.cost': 'Search cost',
85
+ 'search.freeValue': 'Free until Nov 1 (then {cost})',
86
+ 'search.freeNote': 'Server-side search calls are free until 2026-11-01 00:00 Beijing; afterward priced at the official per-1,000-request rate.',
87
+ 'search.directNote': 'The direct DeepSeek Search API is billed per request with no server-side free window.',
88
+ 'search.fetch': 'Web fetch',
89
+ 'search.fetchValue': '{count} requests · {cost}',
90
+ 'search.fetchLocalValue': '{count} requests · free',
91
+ 'search.fetchNote': 'Web fetch calls the DeepSeek Fetch API directly and is billed per request.',
92
+ 'search.fetchLocalNote': 'Web fetch uses local HTTP and incurs no per-request charge.',
66
93
  'footer.estimate': 'Costs are estimates from the configured price table, not an actual bill.',
67
94
  'footer.config': 'Pricing and peak windows are configurable in the plugin config.',
68
- 'pill.aria': 'Estimated cost {cost}, {tokens}, cache hit {percent}%',
69
- 'pill.ariaNoCache': 'Estimated cost {cost}, {tokens}',
95
+ 'pill.aria': 'Estimated cost {cost}',
70
96
  }
71
97
 
72
98
  /** Locale ids the browser client ships; `bind`'s fallback chain anchors on them. */
@@ -176,13 +202,101 @@ window.__ModuleLoader__.load({
176
202
  return Math.round(cacheRead / denominator * 100)
177
203
  }
178
204
 
205
+ /**
206
+ * Provider ids the `web` row selects. Both its fields are volatile, so the
207
+ * card follows the live selection instead of the composition's startup value.
208
+ */
209
+ const DIRECT_SEARCH_PROVIDER = 'deepseek-search-api'
210
+ const DIRECT_FETCH_PROVIDER = 'deepseek-fetch-api'
211
+ /** The shipped `web` selection; also the fallback when settings are unreadable. */
212
+ const MESSAGES_SEARCH_PROVIDER = 'deepseek-official'
213
+ const HTTP_FETCH_PROVIDER = 'http'
214
+
215
+ /**
216
+ * Classify the selected web providers into the per-request billing each one
217
+ * carries. Only these two DeepSeek deployments are known: server-side search
218
+ * has the free window, the direct Search and Fetch APIs bill from launch, and
219
+ * the local HTTP fetch bills nothing. Any other provider id stays `other`, so
220
+ * the card reports the resolved cost without claiming a DeepSeek rate.
221
+ * @param webValue - the `web` settings namespace value, or `null` when unread.
222
+ * @returns `{ search, fetch }` modes: `side`/`direct`/`other`, `local`/`api`/`other`.
223
+ */
224
+ function searchBilling(webValue) {
225
+ const value = webValue !== null && typeof webValue === 'object' ? webValue : null
226
+ const providerOf = (field, fallback) => {
227
+ const id = value === null ? undefined : value[field]
228
+ return typeof id === 'string' && id !== '' ? id : fallback
229
+ }
230
+ const searchProvider = providerOf('searchProvider', MESSAGES_SEARCH_PROVIDER)
231
+ const fetchProvider = providerOf('fetchProvider', HTTP_FETCH_PROVIDER)
232
+ return {
233
+ search: searchProvider === DIRECT_SEARCH_PROVIDER
234
+ ? 'direct'
235
+ : searchProvider === MESSAGES_SEARCH_PROVIDER ? 'side' : 'other',
236
+ fetch: fetchProvider === DIRECT_FETCH_PROVIDER
237
+ ? 'api'
238
+ : fetchProvider === HTTP_FETCH_PROVIDER ? 'local' : 'other',
239
+ }
240
+ }
241
+
242
+ /** Modes of the shipped selection, used until (or unless) settings resolve. */
243
+ const SHIPPED_SEARCH_BILLING = Object.freeze(searchBilling(null))
244
+
245
+ /**
246
+ * Bare observable over the `web` row's provider selection for the
247
+ * registration's `hooks` compartment.
248
+ *
249
+ * The settings form is resolved lazily because the settings service may
250
+ * activate after this plugin, and an absent service must not remove the
251
+ * card: the shipped selection is the answer until one resolves. The
252
+ * snapshot keeps one identity until the form itself changes, because the
253
+ * renderer compares snapshots by reference.
254
+ * @param ctx - the plugin's client context.
255
+ * @returns `{ getSnapshot, subscribe }`.
256
+ */
257
+ function webSearchBillingSource(ctx) {
258
+ let form
259
+ /** The `web` form, or `undefined` while the settings service is absent. */
260
+ const formOf = () => {
261
+ if (form !== undefined) return form
262
+ const forms = typeof ctx.get === 'function' ? ctx.get('configForms') : undefined
263
+ if (forms === undefined || forms === null || typeof forms.get !== 'function') return undefined
264
+ try {
265
+ form = forms.get('web')
266
+ } catch {
267
+ return undefined
268
+ }
269
+ return form
270
+ }
271
+ let cachedSnapshot
272
+ let cachedModes = SHIPPED_SEARCH_BILLING
273
+ return {
274
+ getSnapshot() {
275
+ const current = formOf()
276
+ if (current === undefined || typeof current.getSnapshot !== 'function') return SHIPPED_SEARCH_BILLING
277
+ const snapshot = current.getSnapshot()
278
+ if (snapshot !== cachedSnapshot) {
279
+ cachedSnapshot = snapshot
280
+ cachedModes = Object.freeze(searchBilling(snapshot !== null && typeof snapshot === 'object' ? snapshot.value : null))
281
+ }
282
+ return cachedModes
283
+ },
284
+ subscribe(listener) {
285
+ const current = formOf()
286
+ return current === undefined || typeof current.subscribe !== 'function'
287
+ ? () => {}
288
+ : current.subscribe(listener)
289
+ },
290
+ }
291
+ }
292
+
179
293
  /**
180
294
  * Validate and normalize the Host wire view. Any value that does not match
181
295
  * the version-1 shape with a positive token total yields null, so a
182
296
  * missing projection, a future version, or a fresh Session leaves the
183
297
  * slot empty instead of throwing.
184
298
  * @param view - `useProjection('billingUsage')` result.
185
- * @returns `{ currency, routes, totals }` or null.
299
+ * @returns `{ currency, routes, totals, search }` or null.
186
300
  */
187
301
  function renderableView(view) {
188
302
  if (view === null || typeof view !== 'object') return null
@@ -190,10 +304,12 @@ window.__ModuleLoader__.load({
190
304
  const totals = view.totals
191
305
  if (totals === null || typeof totals !== 'object') return null
192
306
  if (!(finite(totals.total) > 0)) return null
307
+ const search = view.search !== null && typeof view.search === 'object' ? view.search : null
193
308
  return {
194
309
  currency: view.currency,
195
310
  routes: Array.isArray(view.routes) ? view.routes : [],
196
311
  totals,
312
+ search,
197
313
  }
198
314
  }
199
315
 
@@ -229,11 +345,18 @@ window.__ModuleLoader__.load({
229
345
  /**
230
346
  * The dock entry. Renders the collapsed pill always (when usage exists)
231
347
  * and portals the expanded card above it while open.
232
- * @param props - framework standard props plus the plugin's `locale` service.
348
+ * @param props - framework standard props, the plugin's `locale` service,
349
+ * and the provider-mode hook bound from the registration's inject face.
233
350
  */
234
- function BillingCard({ useProjection, locale }) {
351
+ function BillingCard({ useProjection, useWebSearchBilling, locale }) {
235
352
  const t = useTranslate(locale)
236
353
  const data = renderableView(useProjection(PROJECTION_KEY))
354
+ // A registration always supplies the hook; the shipped selection keeps a
355
+ // hand-mounted component rendering instead of throwing. The renderer's
356
+ // selector hook requires a selector, so pass identity.
357
+ const billing = typeof useWebSearchBilling === 'function'
358
+ ? useWebSearchBilling(value => value)
359
+ : SHIPPED_SEARCH_BILLING
237
360
  const visible = data !== null
238
361
  const [open, setOpen] = React.useState(false)
239
362
  const [position, setPosition] = React.useState(null)
@@ -302,14 +425,13 @@ window.__ModuleLoader__.load({
302
425
 
303
426
  if (!visible) return null
304
427
 
305
- const { currency, routes, totals } = data
428
+ const { currency, routes, totals, search } = data
306
429
  const percent = cacheHitPercent(totals)
307
430
  const costText = formatCost(totals.cost, currency)
308
431
  const tokenCount = value => t('tokens.count', { count: formatCount(value) })
309
- const totalTokensText = tokenCount(totals.total)
310
- const ariaLabel = percent === null
311
- ? t('pill.ariaNoCache', { cost: costText, tokens: totalTokensText })
312
- : t('pill.aria', { cost: costText, tokens: totalTokensText, percent })
432
+ // The dock row already shows the Session's official token count, so the
433
+ // pill carries only the thing that row cannot: the estimated cost.
434
+ const ariaLabel = t('pill.aria', { cost: costText })
313
435
 
314
436
  const panelStyle = position === null
315
437
  ? { visibility: 'hidden' }
@@ -362,8 +484,53 @@ window.__ModuleLoader__.load({
362
484
  priced ? null : h('span', { className: 'bc-route-hint' }, t('unpriced.hint')))))
363
485
  }
364
486
 
365
- const panel = open ? ReactDOM.createPortal(
366
- h('div', {
487
+ // Search / fetch section: the DeepSeek per-request API spend the token
488
+ // fold cannot see. Hidden until this Session actually searched or fetched.
489
+ const searchCalls = search === null ? 0 : finite(search.calls)
490
+ const fetchCalls = search === null ? 0 : finite(search.fetchCalls)
491
+ const showSearch = search !== null
492
+ && search.enabled === true
493
+ && (searchCalls > 0 || fetchCalls > 0)
494
+ const searchRows = []
495
+ const searchNotes = []
496
+ if (showSearch) {
497
+ // The free window is a DeepSeek server-side fact: it holds only while the
498
+ // deployment still searches through Messages. The direct Search API has
499
+ // none, and an unknown provider is not claimed to have one.
500
+ const freeUntil = typeof search.searchFreeUntil === 'number' ? search.searchFreeUntil : null
501
+ const freePeriod = billing.search === 'side' && freeUntil !== null && Date.now() < freeUntil
502
+ const pushSearchRow = (key, label, value) => {
503
+ searchRows.push(h('dt', { key: `search-${key}-label`, className: 'bc-row-label' }, label))
504
+ searchRows.push(h('dd', { key: `search-${key}-value`, className: 'bc-row-value' }, value))
505
+ }
506
+ pushSearchRow('calls', t('search.calls'), t('search.callsValue', {
507
+ calls: formatCount(searchCalls),
508
+ requests: formatCount(finite(search.requests)),
509
+ }))
510
+ pushSearchRow('billable', t('search.billable'), t('search.billableValue', {
511
+ count: formatCount(finite(search.billable)),
512
+ }))
513
+ const searchCostText = formatCost(finite(search.searchCost), currency)
514
+ pushSearchRow('cost', t('search.cost'), freePeriod
515
+ ? t('search.freeValue', { cost: searchCostText })
516
+ : searchCostText)
517
+ if (fetchCalls > 0) {
518
+ const fetchCount = formatCount(finite(search.fetchBillable))
519
+ // The projection counts every answered fetch; only the direct Fetch API
520
+ // charges for one, so the local HTTP provider reports no cost.
521
+ pushSearchRow('fetch', t('search.fetch'), billing.fetch === 'local'
522
+ ? t('search.fetchLocalValue', { count: fetchCount })
523
+ : t('search.fetchValue', { count: fetchCount, cost: formatCost(finite(search.fetchCost), currency) }))
524
+ }
525
+ if (freePeriod) searchNotes.push(t('search.freeNote'))
526
+ if (billing.search === 'direct') searchNotes.push(t('search.directNote'))
527
+ if (fetchCalls > 0) {
528
+ if (billing.fetch === 'api') searchNotes.push(t('search.fetchNote'))
529
+ else if (billing.fetch === 'local') searchNotes.push(t('search.fetchLocalNote'))
530
+ }
531
+ }
532
+
533
+ const panel = open ? ReactDOM.createPortal( h('div', {
367
534
  ref: panelRef,
368
535
  className: 'bc-panel',
369
536
  role: 'dialog',
@@ -384,6 +551,15 @@ window.__ModuleLoader__.load({
384
551
  h('div', { className: 'bc-section-title' }, t('routes.title')),
385
552
  routeRows)
386
553
  : null,
554
+ showSearch
555
+ ? h('div', { className: 'bc-search' },
556
+ h('div', { className: 'bc-section-title' }, t('search.title')),
557
+ h('dl', { className: 'bc-rows' }, searchRows),
558
+ searchNotes.map((note, index) => h('p', {
559
+ key: `search-note-${index}`,
560
+ className: 'bc-note bc-search-note',
561
+ }, note)))
562
+ : null,
387
563
  h('div', { className: 'bc-footer' },
388
564
  h('p', { className: 'bc-note' }, t('footer.estimate')),
389
565
  h('p', { className: 'bc-note' }, t('footer.config')))),
@@ -402,9 +578,7 @@ window.__ModuleLoader__.load({
402
578
  onClick: toggle,
403
579
  },
404
580
  billingIcon(),
405
- h('span', { className: 'bc-pill-cost' }, costText),
406
- h('span', { className: 'bc-pill-sep', 'aria-hidden': 'true' }, '·'),
407
- h('span', { className: 'bc-pill-tokens' }, totalTokensText)),
581
+ h('span', { className: 'bc-pill-cost' }, costText)),
408
582
  panel)
409
583
  }
410
584
 
@@ -415,8 +589,6 @@ window.__ModuleLoader__.load({
415
589
  .bc-pill:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
416
590
  .bc-icon{width:14px;height:14px;flex:none}
417
591
  .bc-pill-cost{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
418
- .bc-pill-sep{margin:0 6px;color:var(--dsw-alias-separator-primary)}
419
- .bc-pill-tokens{min-width:0;overflow:hidden;text-overflow:ellipsis}
420
592
  .bc-panel{position:fixed;z-index:1100;box-sizing:border-box;width:300px;max-width:calc(100vw - 16px);max-height:min(70vh,520px);overflow:auto;overscroll-behavior:contain;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;line-height:18px;text-align:left;cursor:default}
421
593
  .bc-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
422
594
  .bc-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
@@ -427,6 +599,8 @@ window.__ModuleLoader__.load({
427
599
  .bc-row-label{min-width:0;margin:0;color:var(--dsw-alias-label-secondary)}
428
600
  .bc-row-value{min-width:0;margin:0;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;text-align:right;overflow-wrap:anywhere}
429
601
  .bc-routes{margin-top:12px}
602
+ .bc-search{margin-top:12px}
603
+ .bc-search-note{margin:6px 0 0}
430
604
  .bc-section-title{margin-bottom:2px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;letter-spacing:.02em}
431
605
  .bc-route{padding:6px 0 2px;border-top:1px solid var(--dsw-alias-border-l1)}
432
606
  .bc-route-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;min-width:0}
@@ -455,8 +629,18 @@ window.__ModuleLoader__.load({
455
629
 
456
630
  ctx.effect(() => registerDictionaries(ctx.locale))
457
631
 
632
+ // The `web` row's provider selection decides which Search / Fetch
633
+ // deployment this card is labelling; it is read from the settings
634
+ // domain rather than the projection so the fold stays replayable.
635
+ const webSearchBilling = webSearchBillingSource(ctx)
636
+
458
637
  ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register(
459
- { name: 'conversation.composer.dock', id: 'billing-card', order: 2 },
638
+ {
639
+ name: 'conversation.composer.dock',
640
+ id: 'billing-card',
641
+ order: 2,
642
+ inject: () => ({ hooks: { webSearchBilling } }),
643
+ },
460
644
  props => h(BillingCard, { ...props, locale: ctx.locale })))
461
645
  },
462
646
  }
package/cordis.patch.yml CHANGED
@@ -7,8 +7,8 @@
7
7
  # activity/usage pills, not in place of them).
8
8
  #
9
9
  # Tunables live in the row config and survive upgrades; see README.md for the
10
- # `currency`, `pricing`, and `peak` fields. Override the whole config of this
11
- # row from the profile's cordis.patch.yml:
10
+ # `currency`, `pricing`, `peak`, and `search` fields. Override the whole config
11
+ # of this row from the profile's cordis.patch.yml:
12
12
  #
13
13
  # - id: billing-card
14
14
  # config:
@@ -18,6 +18,10 @@
18
18
  # inputPerM: 1
19
19
  # cacheReadPerM: 0.02
20
20
  # outputPerM: 4
21
+ # search:
22
+ # perRequest: 0.005 # CNY per successful Search / Fetch request
23
+ # searchFreeUntil: 2026-10-31T16:00:00.000Z # or null when billed already
24
+ # trackFetch: true # count direct Fetch API calls too
21
25
  #
22
26
  # The row below starts with no config so the built-in DeepSeek defaults apply.
23
27
  - insert:
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "dsh-plugin-billing-card",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
+ "engines": { "dsh": ">=0.2.0-rc.2 <0.3.0-0" },
4
5
  "license": "MIT",
5
6
  "keywords": ["dsh-plugin", "deepseek-harness", "dsh", "billing", "token", "usage", "cost", "deepseek"],
6
7
  "publishConfig": { "registry": "https://registry.npmjs.org" },
7
8
  "repository": { "type": "git", "url": "git+https://github.com/tofu-zyc/dsh-plugin-tofu-box.git", "directory": "dsh-plugin-billing-card" },
8
9
  "bugs": { "url": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/issues" },
9
10
  "homepage": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/tree/main/dsh-plugin-billing-card",
10
- "description": "计费卡片:在输入框下方实时统计当前会话各模型的 token 用量,并按可配置的价格表(默认 DeepSeek 官方峰谷价,CNY/USD)估算费用。Host 端注册 billingUsage 会话投影(纯函数、可重放),Client 端是输入框下方的药丸与分模型明细卡片;价格表和峰谷规则可通过插件配置扩展以支持其他模型/提供商。",
11
+ "description": "计费卡片:在输入框下方实时统计当前会话各模型的 token 用量,并按可配置的价格表(默认 DeepSeek 官方峰谷价,CNY/USD)估算费用;同时统计 DeepSeek 官方网页搜索 / 抓取 API 的调用次数与按次费用(Search 服务端调用免费至 2026-11-01 00:00,之后按每 1000 次 5 元估算)。Host 端注册 billingUsage 会话投影(纯函数、可重放),Client 端是输入框下方的药丸与明细卡片;价格表、峰谷规则和搜索计价可通过插件配置扩展以支持其他模型/提供商。",
11
12
  "type": "module",
12
13
  "main": "index.js",
13
14
  "exports": {