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 +62 -19
- package/billing-core.js +382 -19
- package/client.js +81 -17
- package/cordis.patch.yml +6 -2
- package/package.json +2 -2
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
|
|
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`
|
|
17
|
+
`billing-card` 条目;平时是一枚只显示估算费用的小药丸(token 总量已由 DSH 自带用量药丸显示,
|
|
18
|
+
这里不重复),点开后是明细卡片。
|
|
12
19
|
|
|
13
|
-
> 显示的是**按 token
|
|
20
|
+
> 显示的是**按 token 数、按次价格与价格表计算的估算值**,不是官方账单。实际扣费以 DeepSeek 账单为准。
|
|
14
21
|
|
|
15
22
|
## 显示内容
|
|
16
23
|
|
|
17
|
-
药丸(收起态):`¥0.0123
|
|
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
|
-
|
|
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
|
|
34
|
-
dsh --profile
|
|
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
|
-
|
|
44
|
-
|
|
47
|
+
# 2) 或装整套伞包(自动包含本插件)
|
|
48
|
+
dsh plugin --profile <你的profile> add tofu-box
|
|
45
49
|
|
|
46
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
1528
|
+
stateVersion: 2,
|
|
1171
1529
|
stateSchema,
|
|
1172
|
-
init: () => ({
|
|
1173
|
-
|
|
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}
|
|
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}
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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 `
|
|
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.
|
|
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
|
|
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": {
|