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