dsh-plugin-billing-card 0.1.0 → 0.2.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,27 @@
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 元估算)。
8
+
9
+ > npm:[`dsh-plugin-billing-card@0.2.0`](https://www.npmjs.com/package/dsh-plugin-billing-card)
10
+ > · `tofu-box@1.2.1` 起包含本版本(伞包 1.2.0 含 0.1.0)。
6
11
 
7
12
  - **Host 半**:注册一个客户端可见的 Session 投影单元 `billingUsage`,在提交事件时折叠
8
- `assistant/message` / `assistant/attempt` 中模型上报的 `usage`,按 `request/header` 归属模型路由,
13
+ `assistant/message` / `assistant/attempt` 中模型上报的 `usage`(按 `request/header` 归属模型路由),
14
+ 并折叠 `tool/call` / `tool/result` 里的 `web_search` / `web_fetch` 调用来估算按次费用;
9
15
  纯函数、可重放、不产生任何模型调用。
10
16
  - **Client 半**:在 `conversation.composer.dock`(输入框下方的环境条目区)新增一个
11
- `billing-card` 条目;平时是一枚小药丸(金额 + token 数),点开后是分模型明细卡片。
17
+ `billing-card` 条目;平时是一枚只显示估算费用的小药丸(token 总量已由 DSH 自带用量药丸显示,
18
+ 这里不重复),点开后是明细卡片。
12
19
 
13
- > 显示的是**按 token 数与价格表计算的估算值**,不是官方账单。实际扣费以 DeepSeek 账单为准。
20
+ > 显示的是**按 token 数、按次价格与价格表计算的估算值**,不是官方账单。实际扣费以 DeepSeek 账单为准。
14
21
 
15
22
  ## 显示内容
16
23
 
17
- 药丸(收起态):`¥0.0123 · 15.2K tok`,鼠标悬停/读屏可读到完整摘要(金额、token、缓存命中率)。
24
+ 药丸(收起态):`¥0.0123`。只显示估算费用——会话 token 总量与缓存命中率已由 DSH 自带的用量药丸
25
+ 显示,不重复占位。鼠标悬停/读屏可读到「预估费用 ¥…」。
18
26
 
19
27
  卡片(展开态):
20
28
 
@@ -22,31 +30,31 @@
22
30
  - token 汇总:缓存未命中输入、缓存命中输入(及命中率)、输出、合计。
23
31
  - 按模型分项:模型名、token 数、估算费用;没有配置价格的模型显示「未配置价格」,
24
32
  token 仍计入合计,但不计入费用。
33
+ - 联网搜索小节(本会话有搜索/抓取调用时出现):搜索调用次数、provider 请求数、预计计费请求数、
34
+ 搜索费用;直连 Fetch API 的抓取次数与费用。搜索仍处免费期时显示「免费期内(11/1 起 …)」。
25
35
 
26
36
  会话还没有任何已结算的 token 用量时,卡片不显示(不会占位、不会报错)。
27
37
 
28
38
  ## 安装
29
39
 
30
- 推荐先在一个**独立的开发 profile** 里安装与验证,不要装进你日常使用的 `desktop` profile:
40
+ 已发布 npm:`dsh-plugin-billing-card@0.2.0`,同时也是 `tofu-box` 伞包(1.2.1 起)的成员。
41
+ 插件行 id 为 `billing-card`,Client 半是手写浏览器模块,无需构建步骤。
31
42
 
32
43
  ```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
- ```
44
+ # 1) 单装(npm)
45
+ dsh plugin --profile <你的profile> add dsh-plugin-billing-card
42
46
 
43
- 启动日志会打印带一次性 `?token=` 的地址,用浏览器打开即可;插件行 id 为 `billing-card`,
44
- 包名为 `dsh-plugin-billing-card`。验证完成后,也可以把它装进你自己的正式 profile:
47
+ # 2) 或装整套伞包(自动包含本插件)
48
+ dsh plugin --profile <你的profile> add tofu-box
45
49
 
46
- ```bash
50
+ # 3) 开发/未发版:从本仓库本地目录安装(link 方式,改代码不用重装)
47
51
  dsh plugin --profile <你的profile> add D:\program\dsh-plugins\dsh-plugin-billing-card
48
52
  ```
49
53
 
54
+ > 建议先在一个**独立 profile** 里验证,不要装进你日常使用的 `desktop` profile:用官方 web 模板新建
55
+ > `dsh --profile billing-dev --from-default-profile web --dump-config`,装好后
56
+ > `dsh --profile billing-dev --no-open --port 58121` 启动(日志会打印带一次性 `?token=` 的地址)。
57
+
50
58
  > 新装的 Client 模块需要页面刷新一次才会加载;旧会话的投影基线也在刷新/重连后才会带上
51
59
  > `billingUsage`,刷新后输入框下方就会出现计费药丸。
52
60
 
@@ -95,6 +103,34 @@ plugin_manager install_bundle target = D:\program\dsh-plugins\dsh-plugin-billing
95
103
  > 价格来源:DeepSeek 官方「模型 & 价格」页(中文 / 英文),核对时间 2026-10-17。
96
104
  > 官方保留调价权利,请以官网与你的实际结算为准;调价后覆盖本插件配置即可。
97
105
 
106
+ ## 联网搜索 / 抓取计费(0.2.0)
107
+
108
+ 官方口径(Search / Fetch API,2026-10-17 上线):
109
+
110
+ - 按**成功请求次数**计费:每 1,000 次成功请求 **5 元 / $0.75**;失败、空结果、超时不计费,
111
+ `count` 只控制返回条数、不影响单价。
112
+ - 在 DeepSeek Harness / Claude Code / Codex 中由**服务端工具调用**触发的 Search / Fetch 目前免费,
113
+ **北京时间 2026-11-01 00:00 起**按次收费。DSH 的 `web_search` 走服务端 `web_search` 工具;
114
+ 而 `web_fetch` 是直连 `POST https://api.deepseek.com/v1/tools/fetch` 的普通 API 请求,
115
+ 不属于服务端工具调用,按官方规则自上线起计费。
116
+ - 本插件从会话日志统计(不额外发请求):
117
+ - `web_search`:每次工具调用按**去重后的 query 数**计为 provider 请求数(工具本身就是每个
118
+ query 发一个请求);一次调用成功且结果源非空时,这些 query 计为「预计计费请求」。
119
+ - `web_fetch`:按 HTTP 200 的成功抓取计为 Fetch API 计费请求。
120
+ - 费用 = 预计计费请求数 × `search.perRequest`(默认 0.005 元/次,美元 0.00075/次)。
121
+ - 搜索仍在免费期时,卡片显示「免费期内(11/1 起 ¥…)」;抓取费用始终按已计费估算显示。
122
+
123
+ 已知限制(估算口径):
124
+
125
+ - 服务端一次 provider 请求内部可能触发 1 次以上 Search(`max_uses`,默认 5),日志不记录实际
126
+ 次数,因此「预计计费」是**下限估算**。
127
+ - 多 query 批量搜索失败时,已成功的那部分可能已被官方计费,但这里整单失败、不计费;
128
+ 空结果的成功调用不计费(与官方一致)。
129
+ - 服务端搜索附带的辅助模型 token 费用不写入会话日志,本卡片不统计。
130
+ - 统计假设 `web_search` / `web_fetch` 由 DeepSeek 官方 provider 提供(出厂 profile 即如此)。
131
+ 若你改配了 Exa / Perplexity 搜索或本地 HTTP 抓取,请把 `search.enabled` 设为 `false`,
132
+ 否则按次费用会误计。
133
+
98
134
  ## 配置
99
135
 
100
136
  配置写在插件行 `billing-card` 的 `config` 里(profile 的 `cordis.patch.yml` 对整行 `config`
@@ -124,6 +160,13 @@ plugin_manager install_bundle target = D:\program\dsh-plugins\dsh-plugin-billing
124
160
  windows: [['09:00', '12:00'], ['14:00', '18:00']]
125
161
  holidays: [] # 例:['2026-10-01', '2026-10-02'];列出的当地日期全天按空闲计价
126
162
  multiplier: 2
163
+
164
+ # 联网搜索 / 抓取按次计价(0.2.0 起,默认即下方值)
165
+ search:
166
+ enabled: true # false = 完全关闭搜索/抓取统计
167
+ perRequest: 0.005 # 每次成功请求的价格(当前币种);默认 CNY 0.005 / USD 0.00075
168
+ searchFreeUntil: 2026-10-31T16:00:00.000Z # 搜索服务端调用的免费截止(北京时间 11/1 00:00);null = 已计费
169
+ trackFetch: true # 是否统计直连 DeepSeek Fetch API 的 web_fetch 调用
127
170
  ```
128
171
 
129
172
  ### 支持其他提供商 / 模型
@@ -166,7 +209,7 @@ plugin_manager install_bundle target = D:\program\dsh-plugins\dsh-plugin-billing
166
209
  - **法定节假日不会自动识别**:官方规则里中国法定节假日按空闲计价,插件无法内置年年变化的
167
210
  节假日表,请在 `peak.holidays` 里按需填写;不填写时只按「周一至周五 + 时间窗口」判断。
168
211
  - 金额为估算值,浮点计算后仅用于展示(保留 2–4 位小数);并发上限、搜索/抓取等按次计费的
169
- 接口不在统计范围。
212
+ 接口按上面的口径估算,不是账单金额。
170
213
 
171
214
  ## 结构
172
215
 
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,54 @@ 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. The search
1085
+ * cost is the post-free-window estimate (the client decides how to label it
1086
+ * while `searchFreeUntil` is still in the future), while the fetch cost is the
1087
+ * direct Fetch API spend, which is billable from launch.
1088
+ * @param {{ search?: Record<string, any> }} state - fold state.
1089
+ * @param {ReturnType<typeof resolveBillingConfig>} resolvedConfig - resolved config.
1090
+ * @returns {Record<string, any>} the `search` wire section.
1091
+ */
1092
+ function searchView(state, resolvedConfig) {
1093
+ const policy = resolvedConfig.search
1094
+ const search = isPlainObject(state) && isPlainObject(state.search) ? state.search : {}
1095
+ const trackFetch = policy.trackFetch === true
1096
+ const calls = counterValue(search.calls)
1097
+ const requests = counterValue(search.requests)
1098
+ const billable = counterValue(search.billable)
1099
+ const fetchCalls = trackFetch ? counterValue(search.fetchCalls) : 0
1100
+ const fetchBillable = trackFetch ? counterValue(search.fetchBillable) : 0
1101
+ const searchCost = billable * policy.perRequest
1102
+ const fetchCost = fetchBillable * policy.perRequest
1103
+ return {
1104
+ enabled: policy.enabled === true,
1105
+ perRequest: policy.perRequest,
1106
+ searchFreeUntil: policy.searchFreeUntil,
1107
+ calls,
1108
+ requests,
1109
+ billable,
1110
+ emptyCalls: counterValue(search.emptyCalls),
1111
+ failedCalls: counterValue(search.failedCalls),
1112
+ fetchCalls,
1113
+ fetchBillable,
1114
+ searchCost,
1115
+ fetchCost,
1116
+ totalCost: searchCost + fetchCost,
1117
+ }
1118
+ }
1119
+
846
1120
  /**
847
1121
  * Project one fold state to the client wire view.
848
1122
  *
@@ -850,9 +1124,9 @@ function viewRoute(entry, resolvedConfig) {
850
1124
  * object. Only routes with at least one token are listed; unpriced routes keep
851
1125
  * their tokens in `totals` but carry `priced: false`, `pricing: null`, and zero
852
1126
  * 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.
1127
+ * @param {{ routes: Record<string, { provider: string, model: string, peak: Record<string, number>, offPeak: Record<string, number> }>, search?: Record<string, any> }} state - fold state.
854
1128
  * @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.
1129
+ * @returns {{ version: 1, currency: string, routes: Array<Record<string, any>>, totals: Record<string, number>, search: Record<string, any> }} the wire view.
856
1130
  */
857
1131
  export function billingView(state, resolvedConfig) {
858
1132
  const currency = isPlainObject(resolvedConfig) ? resolvedConfig.currency : DEFAULT_CURRENCY
@@ -891,7 +1165,7 @@ export function billingView(state, resolvedConfig) {
891
1165
  if (route.priced) totals.pricedRoutes += 1
892
1166
  else totals.unpricedRoutes += 1
893
1167
  }
894
- return { version: 1, currency, routes, totals }
1168
+ return { version: 1, currency, routes, totals, search: searchView(state, resolvedConfig) }
895
1169
  }
896
1170
 
897
1171
  // --------------------------------------------------------------------------
@@ -1010,6 +1284,50 @@ function lastSampleIssue(value) {
1010
1284
  return bucketIssue(value.buckets, 'state.last.buckets')
1011
1285
  }
1012
1286
 
1287
+ /**
1288
+ * Describe why a value is not a valid `state.search` pending map.
1289
+ * @param {unknown} value - candidate pending map.
1290
+ * @param {string} path - field path for the issue description.
1291
+ * @returns {string|null} the issue, if any.
1292
+ */
1293
+ function searchPendingIssue(value, path) {
1294
+ if (!isPlainObject(value)) return `${path} must be a plain object`
1295
+ for (const [callId, entry] of Object.entries(value)) {
1296
+ if (callId.length === 0) return `${path} keys must be non-empty call ids`
1297
+ const issue = integerIssue(entry, `${path}[${JSON.stringify(callId)}]`)
1298
+ if (issue !== null) return issue
1299
+ }
1300
+ return null
1301
+ }
1302
+
1303
+ /**
1304
+ * Describe why a value is not a valid `state.search` counter block.
1305
+ * @param {unknown} value - candidate search state.
1306
+ * @returns {string|null} the issue, if any.
1307
+ */
1308
+ function searchStateIssue(value) {
1309
+ if (!isPlainObject(value)) return 'state.search must be a plain object'
1310
+ const fields = [
1311
+ 'calls',
1312
+ 'requests',
1313
+ 'billable',
1314
+ 'emptyCalls',
1315
+ 'failedCalls',
1316
+ 'fetchCalls',
1317
+ 'fetchBillable',
1318
+ 'pending',
1319
+ 'pendingFetch',
1320
+ ]
1321
+ if (!hasExactKeys(value, fields)) return `state.search must carry exactly ${fields.join(', ')}`
1322
+ for (const field of fields) {
1323
+ if (field === 'pending' || field === 'pendingFetch') continue
1324
+ const issue = integerIssue(value[field], `state.search.${field}`)
1325
+ if (issue !== null) return issue
1326
+ }
1327
+ return searchPendingIssue(value.pending, 'state.search.pending')
1328
+ ?? searchPendingIssue(value.pendingFetch, 'state.search.pendingFetch')
1329
+ }
1330
+
1013
1331
  /**
1014
1332
  * Describe why a value is not a valid `billingUsage` fold state. Used by
1015
1333
  * `stateSchema` before a persisted checkpoint seeds a fold.
@@ -1018,13 +1336,13 @@ function lastSampleIssue(value) {
1018
1336
  */
1019
1337
  function stateIssue(value) {
1020
1338
  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'
1339
+ if (!hasExactKeys(value, ['routes', 'current', 'last', 'search'])) return 'state must carry exactly routes, current, last, and search'
1022
1340
  if (!isPlainObject(value.routes)) return 'state.routes must be a plain object'
1023
1341
  for (const [routeKey, route] of Object.entries(value.routes)) {
1024
1342
  const issue = stateRouteIssue(route, `state.routes[${JSON.stringify(routeKey)}]`)
1025
1343
  if (issue !== null) return issue
1026
1344
  }
1027
- return currentRouteIssue(value.current) ?? lastSampleIssue(value.last)
1345
+ return currentRouteIssue(value.current) ?? lastSampleIssue(value.last) ?? searchStateIssue(value.search)
1028
1346
  }
1029
1347
 
1030
1348
  /**
@@ -1114,6 +1432,47 @@ function totalsIssue(value, path) {
1114
1432
  return integerIssue(value.unpricedRoutes, `${path}.unpricedRoutes`)
1115
1433
  }
1116
1434
 
1435
+ /**
1436
+ * Describe why a value is not a valid `search` section of the wire view.
1437
+ * @param {unknown} value - candidate search section.
1438
+ * @returns {string|null} the issue, if any.
1439
+ */
1440
+ function searchViewIssue(value) {
1441
+ if (!isPlainObject(value)) return 'view.search must be a plain object'
1442
+ const fields = [
1443
+ 'enabled',
1444
+ 'perRequest',
1445
+ 'searchFreeUntil',
1446
+ 'calls',
1447
+ 'requests',
1448
+ 'billable',
1449
+ 'emptyCalls',
1450
+ 'failedCalls',
1451
+ 'fetchCalls',
1452
+ 'fetchBillable',
1453
+ 'searchCost',
1454
+ 'fetchCost',
1455
+ 'totalCost',
1456
+ ]
1457
+ if (!hasExactKeys(value, fields)) return `view.search must carry exactly ${fields.join(', ')}`
1458
+ if (typeof value.enabled !== 'boolean') return 'view.search.enabled must be a boolean'
1459
+ const perRequestIssue = numberIssue(value.perRequest, 'view.search.perRequest')
1460
+ if (perRequestIssue !== null) return perRequestIssue
1461
+ if (value.searchFreeUntil !== null) {
1462
+ const issue = numberIssue(value.searchFreeUntil, 'view.search.searchFreeUntil')
1463
+ if (issue !== null) return issue
1464
+ }
1465
+ for (const field of ['calls', 'requests', 'billable', 'emptyCalls', 'failedCalls', 'fetchCalls', 'fetchBillable']) {
1466
+ const issue = integerIssue(value[field], `view.search.${field}`)
1467
+ if (issue !== null) return issue
1468
+ }
1469
+ for (const field of ['searchCost', 'fetchCost', 'totalCost']) {
1470
+ const issue = numberIssue(value[field], `view.search.${field}`)
1471
+ if (issue !== null) return issue
1472
+ }
1473
+ return null
1474
+ }
1475
+
1117
1476
  /**
1118
1477
  * Describe why a value is not a valid wire view for this projection.
1119
1478
  * @param {unknown} value - candidate view.
@@ -1121,8 +1480,8 @@ function totalsIssue(value, path) {
1121
1480
  */
1122
1481
  function viewIssue(value) {
1123
1482
  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'
1483
+ if (!hasExactKeys(value, ['version', 'currency', 'routes', 'totals', 'search'])) {
1484
+ return 'view must carry exactly version, currency, routes, totals, and search'
1126
1485
  }
1127
1486
  if (value.version !== 1) return 'view.version must be 1'
1128
1487
  if (!CURRENCIES.includes(/** @type {string} */ (value.currency))) return "view.currency must be 'CNY' or 'USD'"
@@ -1131,7 +1490,7 @@ function viewIssue(value) {
1131
1490
  const issue = viewRouteIssue(value.routes[index], `view.routes[${index}]`)
1132
1491
  if (issue !== null) return issue
1133
1492
  }
1134
- return totalsIssue(value.totals, 'view.totals')
1493
+ return totalsIssue(value.totals, 'view.totals') ?? searchViewIssue(value.search)
1135
1494
  }
1136
1495
 
1137
1496
  /** Validates one persisted `billingUsage` fold state. */
@@ -1156,7 +1515,6 @@ const viewSchema = structuralSchema('billingUsage view', viewIssue)
1156
1515
  * @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
1516
  */
1158
1517
  export function createBillingProjection(resolvedConfig) {
1159
- const peakPolicy = resolvedConfig.peak
1160
1518
  let cachedState
1161
1519
  let cachedView
1162
1520
  const view = (state) => {
@@ -1167,10 +1525,15 @@ export function createBillingProjection(resolvedConfig) {
1167
1525
  }
1168
1526
  return {
1169
1527
  key: BILLING_PROJECTION_KEY,
1170
- stateVersion: 1,
1528
+ stateVersion: 2,
1171
1529
  stateSchema,
1172
- init: () => ({ routes: {}, current: null, last: null }),
1173
- apply: (state, event) => applyEvent(state, event, peakPolicy),
1530
+ init: () => ({
1531
+ routes: {},
1532
+ current: null,
1533
+ last: null,
1534
+ search: { ...ZERO_SEARCH_COUNTERS, pending: {}, pendingFetch: {} },
1535
+ }),
1536
+ apply: (state, event) => applyEvent(state, event, resolvedConfig),
1174
1537
  wire: { viewSchema, view },
1175
1538
  }
1176
1539
  }
package/client.js CHANGED
@@ -43,10 +43,20 @@ 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.fetch': '网页抓取',
55
+ 'search.fetchValue': '{count} 次 · {cost}',
56
+ 'search.fetchNote': '网页抓取为直连 DeepSeek Fetch API,按次计费。',
46
57
  'footer.estimate': '费用为按配置价格表估算的预估值,非实际账单。',
47
58
  'footer.config': '价格与峰谷时段可在插件配置中调整。',
48
- 'pill.aria': '预估费用 {cost},{tokens},缓存命中率 {percent}%',
49
- 'pill.ariaNoCache': '预估费用 {cost},{tokens}',
59
+ 'pill.aria': '预估费用 {cost}',
50
60
  }
51
61
 
52
62
  const ENGLISH = {
@@ -63,10 +73,20 @@ window.__ModuleLoader__.load({
63
73
  'routes.unknownModel': 'Unknown model',
64
74
  'unpriced.badge': 'Unpriced',
65
75
  'unpriced.hint': 'Tokens only',
76
+ 'search.title': 'Web search',
77
+ 'search.calls': 'Search',
78
+ 'search.callsValue': '{calls} calls ({requests} provider requests)',
79
+ 'search.billable': 'Estimated billable',
80
+ 'search.billableValue': '{count} requests',
81
+ 'search.cost': 'Search cost',
82
+ 'search.freeValue': 'Free until Nov 1 (then {cost})',
83
+ '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.',
84
+ 'search.fetch': 'Web fetch',
85
+ 'search.fetchValue': '{count} requests · {cost}',
86
+ 'search.fetchNote': 'Web fetch calls the DeepSeek Fetch API directly and is billed per request.',
66
87
  'footer.estimate': 'Costs are estimates from the configured price table, not an actual bill.',
67
88
  '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}',
89
+ 'pill.aria': 'Estimated cost {cost}',
70
90
  }
71
91
 
72
92
  /** Locale ids the browser client ships; `bind`'s fallback chain anchors on them. */
@@ -182,7 +202,7 @@ window.__ModuleLoader__.load({
182
202
  * missing projection, a future version, or a fresh Session leaves the
183
203
  * slot empty instead of throwing.
184
204
  * @param view - `useProjection('billingUsage')` result.
185
- * @returns `{ currency, routes, totals }` or null.
205
+ * @returns `{ currency, routes, totals, search }` or null.
186
206
  */
187
207
  function renderableView(view) {
188
208
  if (view === null || typeof view !== 'object') return null
@@ -190,10 +210,12 @@ window.__ModuleLoader__.load({
190
210
  const totals = view.totals
191
211
  if (totals === null || typeof totals !== 'object') return null
192
212
  if (!(finite(totals.total) > 0)) return null
213
+ const search = view.search !== null && typeof view.search === 'object' ? view.search : null
193
214
  return {
194
215
  currency: view.currency,
195
216
  routes: Array.isArray(view.routes) ? view.routes : [],
196
217
  totals,
218
+ search,
197
219
  }
198
220
  }
199
221
 
@@ -302,14 +324,13 @@ window.__ModuleLoader__.load({
302
324
 
303
325
  if (!visible) return null
304
326
 
305
- const { currency, routes, totals } = data
327
+ const { currency, routes, totals, search } = data
306
328
  const percent = cacheHitPercent(totals)
307
329
  const costText = formatCost(totals.cost, currency)
308
330
  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 })
331
+ // The dock row already shows the Session's official token count, so the
332
+ // pill carries only the thing that row cannot: the estimated cost.
333
+ const ariaLabel = t('pill.aria', { cost: costText })
313
334
 
314
335
  const panelStyle = position === null
315
336
  ? { visibility: 'hidden' }
@@ -362,8 +383,44 @@ window.__ModuleLoader__.load({
362
383
  priced ? null : h('span', { className: 'bc-route-hint' }, t('unpriced.hint')))))
363
384
  }
364
385
 
365
- const panel = open ? ReactDOM.createPortal(
366
- h('div', {
386
+ // Search / fetch section: the DeepSeek per-request API spend the token
387
+ // fold cannot see. Hidden until this Session actually searched or fetched.
388
+ const searchCalls = search === null ? 0 : finite(search.calls)
389
+ const fetchCalls = search === null ? 0 : finite(search.fetchCalls)
390
+ const showSearch = search !== null
391
+ && search.enabled === true
392
+ && (searchCalls > 0 || fetchCalls > 0)
393
+ const searchRows = []
394
+ const searchNotes = []
395
+ if (showSearch) {
396
+ const freeUntil = typeof search.searchFreeUntil === 'number' ? search.searchFreeUntil : null
397
+ const freePeriod = freeUntil !== null && Date.now() < freeUntil
398
+ const pushSearchRow = (key, label, value) => {
399
+ searchRows.push(h('dt', { key: `search-${key}-label`, className: 'bc-row-label' }, label))
400
+ searchRows.push(h('dd', { key: `search-${key}-value`, className: 'bc-row-value' }, value))
401
+ }
402
+ pushSearchRow('calls', t('search.calls'), t('search.callsValue', {
403
+ calls: formatCount(searchCalls),
404
+ requests: formatCount(finite(search.requests)),
405
+ }))
406
+ pushSearchRow('billable', t('search.billable'), t('search.billableValue', {
407
+ count: formatCount(finite(search.billable)),
408
+ }))
409
+ const searchCostText = formatCost(finite(search.searchCost), currency)
410
+ pushSearchRow('cost', t('search.cost'), freePeriod
411
+ ? t('search.freeValue', { cost: searchCostText })
412
+ : searchCostText)
413
+ if (fetchCalls > 0) {
414
+ pushSearchRow('fetch', t('search.fetch'), t('search.fetchValue', {
415
+ count: formatCount(finite(search.fetchBillable)),
416
+ cost: formatCost(finite(search.fetchCost), currency),
417
+ }))
418
+ }
419
+ if (freePeriod) searchNotes.push(t('search.freeNote'))
420
+ if (fetchCalls > 0) searchNotes.push(t('search.fetchNote'))
421
+ }
422
+
423
+ const panel = open ? ReactDOM.createPortal( h('div', {
367
424
  ref: panelRef,
368
425
  className: 'bc-panel',
369
426
  role: 'dialog',
@@ -384,6 +441,15 @@ window.__ModuleLoader__.load({
384
441
  h('div', { className: 'bc-section-title' }, t('routes.title')),
385
442
  routeRows)
386
443
  : null,
444
+ showSearch
445
+ ? h('div', { className: 'bc-search' },
446
+ h('div', { className: 'bc-section-title' }, t('search.title')),
447
+ h('dl', { className: 'bc-rows' }, searchRows),
448
+ searchNotes.map((note, index) => h('p', {
449
+ key: `search-note-${index}`,
450
+ className: 'bc-note bc-search-note',
451
+ }, note)))
452
+ : null,
387
453
  h('div', { className: 'bc-footer' },
388
454
  h('p', { className: 'bc-note' }, t('footer.estimate')),
389
455
  h('p', { className: 'bc-note' }, t('footer.config')))),
@@ -402,9 +468,7 @@ window.__ModuleLoader__.load({
402
468
  onClick: toggle,
403
469
  },
404
470
  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)),
471
+ h('span', { className: 'bc-pill-cost' }, costText)),
408
472
  panel)
409
473
  }
410
474
 
@@ -415,8 +479,6 @@ window.__ModuleLoader__.load({
415
479
  .bc-pill:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
416
480
  .bc-icon{width:14px;height:14px;flex:none}
417
481
  .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
482
  .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
483
  .bc-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
422
484
  .bc-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
@@ -427,6 +489,8 @@ window.__ModuleLoader__.load({
427
489
  .bc-row-label{min-width:0;margin:0;color:var(--dsw-alias-label-secondary)}
428
490
  .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
491
  .bc-routes{margin-top:12px}
492
+ .bc-search{margin-top:12px}
493
+ .bc-search-note{margin:6px 0 0}
430
494
  .bc-section-title{margin-bottom:2px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;letter-spacing:.02em}
431
495
  .bc-route{padding:6px 0 2px;border-top:1px solid var(--dsw-alias-border-l1)}
432
496
  .bc-route-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;min-width:0}
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,13 @@
1
1
  {
2
2
  "name": "dsh-plugin-billing-card",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "keywords": ["dsh-plugin", "deepseek-harness", "dsh", "billing", "token", "usage", "cost", "deepseek"],
6
6
  "publishConfig": { "registry": "https://registry.npmjs.org" },
7
7
  "repository": { "type": "git", "url": "git+https://github.com/tofu-zyc/dsh-plugin-tofu-box.git", "directory": "dsh-plugin-billing-card" },
8
8
  "bugs": { "url": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/issues" },
9
9
  "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 端是输入框下方的药丸与分模型明细卡片;价格表和峰谷规则可通过插件配置扩展以支持其他模型/提供商。",
10
+ "description": "计费卡片:在输入框下方实时统计当前会话各模型的 token 用量,并按可配置的价格表(默认 DeepSeek 官方峰谷价,CNY/USD)估算费用;同时统计 DeepSeek 官方网页搜索 / 抓取 API 的调用次数与按次费用(Search 服务端调用免费至 2026-11-01 00:00,之后按每 1000 次 5 元估算)。Host 端注册 billingUsage 会话投影(纯函数、可重放),Client 端是输入框下方的药丸与明细卡片;价格表、峰谷规则和搜索计价可通过插件配置扩展以支持其他模型/提供商。",
11
11
  "type": "module",
12
12
  "main": "index.js",
13
13
  "exports": {