dsh-dock 0.9.5 → 0.9.7

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.
Files changed (35) hide show
  1. package/LICENSE +18 -18
  2. package/README.md +325 -325
  3. package/client.js +42 -8
  4. package/cordis.patch.yml +2 -2
  5. package/features/animation/host.js +660 -660
  6. package/features/animation/view.jsx +2247 -2247
  7. package/features/balance/host.js +494 -494
  8. package/features/balance/view.js +351 -351
  9. package/features/games/games/breakout.jsx +227 -227
  10. package/features/games/games/gomoku.jsx +244 -244
  11. package/features/games/games/racer.jsx +169 -169
  12. package/features/games/games/shared.jsx +118 -118
  13. package/features/games/games/snake.jsx +144 -144
  14. package/features/games/games/sokoban.jsx +180 -180
  15. package/features/games/games/tank.jsx +128 -128
  16. package/features/games/games/tetris.jsx +308 -308
  17. package/features/games/games/xiangqi.jsx +277 -277
  18. package/features/games/tank-core.js +152 -152
  19. package/features/games/view.jsx +639 -639
  20. package/features/games/xiangqi-core.js +299 -299
  21. package/features/heartbeat/view.js +35 -35
  22. package/features/mobile-relay/gateway.js +472 -415
  23. package/features/mobile-relay/host.js +570 -546
  24. package/features/mobile-relay/view.jsx +300 -300
  25. package/features/modelconfig/host.js +403 -403
  26. package/features/modelconfig/view.js +490 -490
  27. package/features/theme/view.js +61 -61
  28. package/features/tokenlog/host.js +678 -678
  29. package/features/tokenlog/view.jsx +561 -561
  30. package/features/visionproxy/host.js +315 -315
  31. package/index.js +175 -118
  32. package/package.json +59 -59
  33. package/src/client.jsx +668 -668
  34. package/src/host-core.js +98 -95
  35. package/src/shared.js +167 -130
@@ -1,678 +1,678 @@
1
- // dsh-dock · 功能模块【用量记录】· 宿主半部(v0.4.0,移植自 @wycto/dsh-token-usage lib/index.js)
2
- //
3
- // 记录 DSH 会话日志中的每次 LLM 调用(assistant/message 的 usage),
4
- // 支持历史全量扫描 + 实时 session/event 增量;RPC 经 webServer HTTP 路由
5
- // POST /dsh-dock/tokenlog/query|export|scan|hello
6
- // 向浏览器半部提供查询/统计/导出。
7
- //
8
- // 数据模型(标量,无 Host 对象引用):
9
- // {
10
- // id, time, sessionId, provider, model, apiKey(掩码),
11
- // inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens,
12
- // billedInput, totalTokens, cacheHitPercent, cost, effort, status, llmMs, turn, step
13
- // }
14
- //
15
- // 定价可经 settings.yaml 的 dsh-dock-tokenlog 命名空间覆盖(未注册 schema,读原始用户层):
16
- // dsh-dock-tokenlog:
17
- // usdCnyRate: 7.2
18
- // pricingUrl: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
19
- // pricingFetchIntervalHours: 24
20
- // pricing:
21
- // deepseek-v4-flash: { input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0 }
22
- import { sendJson, readBody } from '../../src/host-core.js'
23
-
24
- /** 本功能的 settings 覆盖命名空间(未注册 schema:读取原始用户层,字段全部可选)。 */
25
- const NS = 'dsh-dock-tokenlog'
26
-
27
- export const feature = {
28
- id: 'tokenlog',
29
- name: '用量记录',
30
- description: '记录全部 LLM API 调用并统计 Token 用量与花费',
31
- defaultEnabled: false,
32
- setup(ctx) {
33
- const disposers = []
34
- const dispose = () => {
35
- while (disposers.length > 0) {
36
- const fn = disposers.pop()
37
- try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
38
- }
39
- }
40
-
41
- // 用 Map<id, record> 存记录: id = sessionId:seq 天然唯一。
42
- // 实时监听与历史扫描存在重叠窗口(活跃会话既被实时 push 又被扫描全量读),
43
- // 用 set 幂等去重, 避免同一条 LLM 调用被统计两次(此前用数组 push 导致"两倍")。
44
- const records = new Map()
45
- let scannedSessions = new Set()
46
- const headerCache = new Map()
47
- const turnError = new Map()
48
- const turnStatus = new Map()
49
- const stepStart = new Map()
50
-
51
- function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
52
-
53
- // ===== 定价与金额估算 =====
54
- // 定价表以「人民币元 / 百万 tokens」为基准(DeepSeek/智谱官方刊例即人民币), 金额先算 CNY,
55
- // 对外记录的 cost 保持 USD,供旧版客户端兼容;当前客户端和 CSV 统一展示人民币。
56
- // usdCnyRate 与 pricing 均可被 settings.yaml 覆盖:
57
- // dsh-dock-tokenlog:
58
- // usdCnyRate: 7.2 # USD↔CNY 汇率
59
- // pricing: # 可选: 覆盖/新增定价条目(modelPattern -> {...})
60
- // deepseek-v4-flash:
61
- // input: 1.5 output: 4.5 cacheRead: 0.05 cacheWrite: 0
62
- // peak: { start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 }
63
- // 官方价格随时会变动: 改价只改 settings.yaml 即可, 无需升级插件; 重启/重扫后历史记录按新价重算。
64
- let usdCnyRate = 7.2
65
- let fallbackPricing = { match: '*', input: 2.16, output: 6.48, cacheRead: 0.43, cacheWrite: 4.32 }
66
- // 内置定价表(人民币元 / 百万 tokens), 按 model 子串匹配; 未匹配走 fallback。
67
- // 支持峰谷定价: 条目带 peak={start,end,input,output,cacheRead,cacheWrite},
68
- // 高峰时段(插件运行机器本地时间 [start,end))用峰价, 其余用基础价。
69
- // 官方来源: DeepSeek-V4 系列 2026-08-17 生效的峰谷刊例(高峰每日 9:00-14:00, 空闲为高峰一半)。
70
- const DEFAULT_PRICING = [
71
- { match: 'deepseek-v4-flash', input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0,
72
- peak: { start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 } },
73
- { match: 'deepseek-v4-pro', input: 4.5, output: 13.5, cacheRead: 0.15, cacheWrite: 0,
74
- peak: { start: 9, end: 14, input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0 } },
75
- { match: 'deepseek-v3', input: 3.6, output: 7.2, cacheRead: 0.72, cacheWrite: 7.2 },
76
- { match: 'deepseek-chat', input: 1.94, output: 7.92, cacheRead: 0.5, cacheWrite: 7.92 },
77
- { match: 'deepseek-reasoner', input: 3.96, output: 15.77, cacheRead: 1.01, cacheWrite: 7.92 },
78
- { match: 'nemotron', input: 1.08, output: 2.88, cacheRead: 0.22, cacheWrite: 2.16 },
79
- { match: 'glm', input: 2.88, output: 8.64, cacheRead: 0.58, cacheWrite: 7.2 },
80
- ]
81
- let pricingTable = DEFAULT_PRICING
82
-
83
- function normalizePricing(match, e) {
84
- const n = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
85
- const row = { match, input: n(e.input), output: n(e.output), cacheRead: n(e.cacheRead), cacheWrite: n(e.cacheWrite) }
86
- if (e.peak && typeof e.peak === 'object') {
87
- row.peak = {
88
- start: n(e.peak.start), end: n(e.peak.end),
89
- input: n(e.peak.input), output: n(e.peak.output),
90
- cacheRead: n(e.peak.cacheRead), cacheWrite: n(e.peak.cacheWrite),
91
- }
92
- }
93
- return row
94
- }
95
-
96
- // 读取 settings.yaml 的 dsh-dock-tokenlog { usdCnyRate?, pricing? } 覆盖默认值。
97
- // pricing: map modelPattern -> {input,output,cacheRead,cacheWrite,peak?};
98
- // 设置条目排前面优先命中(同 match 覆盖内置); 键 '*' 覆盖 fallback。幂等, 可重复调用。
99
- function loadPricingConfig() {
100
- try {
101
- const settings = ctx.get('settings')
102
- if (!settings || typeof settings.get !== 'function') return
103
- const v = settings.get(NS)
104
- if (!v || typeof v !== 'object') return
105
- if (typeof v.usdCnyRate === 'number' && v.usdCnyRate > 0) usdCnyRate = v.usdCnyRate
106
- if (v.pricing && typeof v.pricing === 'object') {
107
- const overrides = []
108
- for (const [match, entry] of Object.entries(v.pricing)) {
109
- if (!entry || typeof entry !== 'object') continue
110
- const row = normalizePricing(String(match).toLowerCase(), entry)
111
- if (row.match === '*') fallbackPricing = row
112
- else overrides.push(row)
113
- }
114
- pricingTable = overrides.concat(DEFAULT_PRICING)
115
- }
116
- } catch (e) {
117
- console.error('[dsh-dock] tokenlog read pricing config failed', e && e.message)
118
- }
119
- }
120
-
121
- // 命中定价行: 优先官网自动获取 → settings.yaml 覆盖+内置 → fallback; 再按调用时刻取峰/谷价。
122
- // 官网获取的数据若为 USD 则自动折成 CNY(内置表已是 CNY)。返回人民币单价(元/百万 tokens)。
123
- function resolvePricing(model, timeMs) {
124
- const m = String(model || '').toLowerCase()
125
- let row = null
126
- let fromFetch = false
127
- // 1. 优先: 官网自动获取的定价(48h 内有效, 超时回退内置)
128
- const fetched = fetchedPricing
129
- if (fetched && fetched.length > 0 && (Date.now() - lastFetchTime < FETCH_INTERVAL_MS * 2)) {
130
- for (const r of fetched) {
131
- if (m.includes(r.match)) { row = r; fromFetch = true; break }
132
- }
133
- }
134
- // 2. 回退: settings.yaml 覆盖 + 内置表
135
- if (!row) {
136
- for (const r of pricingTable) {
137
- if (m.includes(r.match)) { row = r; break }
138
- }
139
- }
140
- if (!row) row = fallbackPricing
141
- // 3. 峰谷判断(本地时间 [start,end) 用峰价)
142
- const h = (typeof timeMs === 'number' && Number.isFinite(timeMs)) ? new Date(timeMs).getHours() : -1
143
- let prices
144
- if (row.peak && h >= row.peak.start && h < row.peak.end) {
145
- prices = { input: row.peak.input, output: row.peak.output, cacheRead: row.peak.cacheRead, cacheWrite: row.peak.cacheWrite }
146
- } else {
147
- prices = { input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite }
148
- }
149
- // 4. 币种转换: 获取的数据若是 USD 则折成 CNY(内置表已是 CNY, 无需转换)
150
- if (fromFetch && row.currency === 'usd') {
151
- prices.input *= usdCnyRate
152
- prices.output *= usdCnyRate
153
- prices.cacheRead *= usdCnyRate
154
- prices.cacheWrite *= usdCnyRate
155
- }
156
- return prices
157
- }
158
- // 金额估算: 表为人民币价 → 先算 CNY, 再折成 USD(= CNY / usdCnyRate) 供客户端/CSV 使用。
159
- function estimateCost(usage, model, timeMs) {
160
- const p = resolvePricing(model, timeMs)
161
- const cny = (
162
- num(usage.inputTokens) * p.input +
163
- num(usage.cacheReadTokens) * p.cacheRead +
164
- num(usage.cacheWriteTokens) * p.cacheWrite +
165
- num(usage.outputTokens) * p.output
166
- ) / 1e6
167
- return cny / usdCnyRate
168
- }
169
-
170
- // ===== 官网定价自动获取 =====
171
- // 每 24 小时从 DeepSeek 官网抓取最新定价并解析 HTML 表格; 抓取/解析失败时使用内置默认值。
172
- const FETCH_INTERVAL_MS = 24 * 60 * 60 * 1000
173
- let fetchedPricing = null // Array<{ match, input, output, cacheRead, cacheWrite, currency }>
174
- let lastFetchTime = 0
175
-
176
- function getPricingFetchConfig() {
177
- let url = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
178
- let intervalMs = FETCH_INTERVAL_MS
179
- try {
180
- const settings = ctx.get('settings')
181
- if (settings && typeof settings.get === 'function') {
182
- const v = settings.get(NS)
183
- if (v && typeof v === 'object') {
184
- if (typeof v.pricingUrl === 'string' && v.pricingUrl) url = v.pricingUrl
185
- if (typeof v.pricingFetchIntervalHours === 'number' && v.pricingFetchIntervalHours > 0) intervalMs = v.pricingFetchIntervalHours * 3600000
186
- }
187
- }
188
- } catch (e) { /* 用默认 */ }
189
- return { url, intervalMs }
190
- }
191
-
192
- // 从 HTML 中提取所有 <table> 的行列数据
193
- function extractHTMLTables(html) {
194
- const tables = []
195
- const tableRe = /<table[^>]*>([\s\S]*?)<\/table>/gi
196
- let tm
197
- while ((tm = tableRe.exec(html)) !== null) {
198
- const rows = []
199
- const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
200
- let tr
201
- while ((tr = trRe.exec(tm[1])) !== null) {
202
- const cells = []
203
- const tdRe = /<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi
204
- let td
205
- while ((td = tdRe.exec(tr[1])) !== null) {
206
- cells.push(td[1].replace(/<[^>]+>/g, '').replace(/&[a-z]+;/g, ' ').trim())
207
- }
208
- if (cells.length > 0) rows.push(cells)
209
- }
210
- if (rows.length >= 2) tables.push(rows)
211
- }
212
- return tables
213
- }
214
-
215
- // 从价格字符串提取数字(支持 "$0.27", "0.27", "¥1.5", "1.5元", "1,500" 等格式)
216
- function parsePrice(s) {
217
- if (!s) return null
218
- const cleaned = String(s).replace(/,/g, '').replace(/¥|¥|元|\/.*$/g, '').trim()
219
- const m = cleaned.match(/(\d+\.?\d*)/)
220
- return m ? parseFloat(m[1]) : null
221
- }
222
-
223
- // 识别定价表列: 通过表头关键词定位各列
224
- function identifyPricingColumns(headers) {
225
- const h = headers.map(x => x.toLowerCase())
226
- const find = (...patterns) => {
227
- for (const p of patterns) {
228
- const idx = h.findIndex(x => x.includes(p))
229
- if (idx >= 0) return idx
230
- }
231
- return -1
232
- }
233
- return {
234
- model: find('模型', 'model', '名称', 'name'),
235
- inputMiss: find('缓存未命中', '未缓存', 'cache miss', 'uncached'),
236
- inputHit: find('缓存命中', 'cache hit', 'cached'),
237
- output: find('输出', 'output'),
238
- peak: find('高峰', 'peak', 'busy', 'on-peak'),
239
- }
240
- }
241
-
242
- // 从 HTML 解析 DeepSeek 官网定价
243
- // 策略: 提取所有 <table>, 找到含"价格/缓存/输入/输出"关键词的表, 逐行提取模型名+价格;
244
- // 识别 $ 标记区分 USD/CNY 币种; 无法解析时返回空数组(回退内置默认)。
245
- function parsePricingFromHTML(html) {
246
- const tables = extractHTMLTables(html)
247
- const results = []
248
- for (const rows of tables) {
249
- const headers = rows[0]
250
- const cols = identifyPricingColumns(headers)
251
- // 至少需要模型名 + 一个价格列
252
- if (cols.model < 0) continue
253
- if (cols.inputMiss < 0 && cols.output < 0 && cols.inputHit < 0) continue
254
- // 表头需与定价相关
255
- const headerText = headers.join(' ')
256
- if (!/价格|price|缓存|cache|输入|input|输出|output|tokens/i.test(headerText)) continue
257
- for (let i = 1; i < rows.length; i++) {
258
- const row = rows[i]
259
- const name = (row[cols.model] || '').trim()
260
- if (!name || /^(模型|model|名称|合计|total|---)/i.test(name)) continue
261
- const input = cols.inputMiss >= 0 ? parsePrice(row[cols.inputMiss]) : null
262
- const output = cols.output >= 0 ? parsePrice(row[cols.output]) : null
263
- const cacheHit = cols.inputHit >= 0 ? parsePrice(row[cols.inputHit]) : null
264
- if (input === null && output === null && cacheHit === null) continue
265
- // 判断币种
266
- const allText = row.join(' ')
267
- const isUSD = /\$|USD|dollar/i.test(allText)
268
- results.push({
269
- match: name.toLowerCase(),
270
- input: input || 0,
271
- output: output || 0,
272
- cacheRead: cacheHit || 0,
273
- cacheWrite: 0, // DeepSeek 官方不收取缓存写入
274
- currency: isUSD ? 'usd' : 'cny',
275
- })
276
- }
277
- }
278
- return results
279
- }
280
-
281
- // 从官网获取最新定价(异步, 失败静默回退内置默认)
282
- async function fetchOfficialPricing() {
283
- try {
284
- const { url } = getPricingFetchConfig()
285
- console.log('[dsh-dock] tokenlog fetching official pricing from:', url)
286
- const res = await fetch(url, {
287
- headers: { 'User-Agent': 'dsh-dock-tokenlog/0.4', 'Accept': 'text/html,*/*' }
288
- })
289
- if (!res.ok) throw new Error('HTTP ' + res.status)
290
- const html = await res.text()
291
- console.log('[dsh-dock] tokenlog pricing page fetched, size:', html.length)
292
- if (html.length < 500) {
293
- console.warn('[dsh-dock] tokenlog page too small, likely SPA shell — using defaults')
294
- return
295
- }
296
- const parsed = parsePricingFromHTML(html)
297
- if (parsed.length > 0) {
298
- fetchedPricing = parsed
299
- lastFetchTime = Date.now()
300
- console.log('[dsh-dock] tokenlog parsed', parsed.length, 'models from official pricing')
301
- } else {
302
- console.warn('[dsh-dock] tokenlog parsed 0 models from pricing page — check page structure, using defaults')
303
- }
304
- } catch (e) {
305
- console.error('[dsh-dock] tokenlog fetch official pricing failed:', e && e.message, '— using built-in defaults')
306
- }
307
- }
308
-
309
- // 读取 settings.yaml 的 llm-pi-ai 命名空间, 返回「现有提供商」路由名 -> [模型 id]。
310
- // 用途: 前端提供商/模型下拉 = 现有配置 ∪ 历史记录, 去重合并 ——
311
- // - 没有任何记录时仍可选现有提供商;
312
- // - 提供商/模型已从配置删除时, 因有历史记录仍可选中(兼容两者)。
313
- function readConfiguredProviders() {
314
- const out = {}
315
- try {
316
- const settings = ctx.get('settings')
317
- if (!settings || typeof settings.get !== 'function') return out
318
- const sec = settings.get('llm-pi-ai')
319
- const providers = sec && typeof sec === 'object' && sec.providers ? sec.providers : {}
320
- for (const pid of Object.keys(providers || {})) {
321
- const p = providers[pid]
322
- if (!p || typeof p !== 'object') { out[pid] = []; continue }
323
- const models = Array.isArray(p.models) ? p.models : []
324
- const ids = []
325
- for (const m of models) {
326
- const id = m && typeof m === 'object' ? m.id : m
327
- if (typeof id === 'string' && id) ids.push(id)
328
- }
329
- out[pid] = ids
330
- }
331
- } catch (e) {
332
- console.error('[dsh-dock] tokenlog read configured providers failed', e && e.message)
333
- }
334
- return out
335
- }
336
-
337
- // ---------- 事件采集 ----------
338
-
339
- // 预扫描 turn/end: 先建立 turn -> status/error 映射, 再生成记录
340
- // (解决顺序处理导致的状态缺失: assistant/message 先于 turn/end 到达)
341
- function preScanTurnEnds(sessionId, events) {
342
- for (const e of events) {
343
- if (e.type !== 'turn/end') continue
344
- const t = e.data && e.data.turn
345
- const r = e.data && e.data.reason
346
- let status = 'completed'
347
- if (r && typeof r === 'object') status = r.kind || 'completed'
348
- else if (typeof r === 'string') status = r
349
- turnStatus.set(sessionId + ':' + t, status)
350
- let errInfo = null
351
- if (r && typeof r === 'object' && r.error) {
352
- const er = r.error
353
- errInfo = {
354
- statusCode: typeof er.status === 'number' ? er.status : 0,
355
- errorCode: er.code || '',
356
- errorMsg: er.message || '',
357
- }
358
- }
359
- turnError.set(sessionId + ':' + t, errInfo)
360
- }
361
- }
362
-
363
- function ingestEvent(sessionId, event) {
364
- if (event.type === 'request/header') {
365
- const h = event.data && event.data.header
366
- if (h && h.config) {
367
- headerCache.set(sessionId, {
368
- provider: h.config.provider || '',
369
- model: h.config.model || '',
370
- effort: h.config.reasoningEffort || '',
371
- })
372
- }
373
- }
374
- if (event.type === 'turn/end') {
375
- const t = event.data && event.data.turn
376
- const r = event.data && event.data.reason
377
- let status = 'completed'
378
- if (r && typeof r === 'object') status = r.kind || 'completed'
379
- else if (typeof r === 'string') status = r
380
- turnStatus.set(sessionId + ':' + t, status)
381
- let errInfo = null
382
- if (r && typeof r === 'object' && r.error) {
383
- const e = r.error
384
- errInfo = {
385
- statusCode: typeof e.status === 'number' ? e.status : 0,
386
- errorCode: e.code || '',
387
- errorMsg: e.message || '',
388
- }
389
- }
390
- turnError.set(sessionId + ':' + t, errInfo)
391
- }
392
- if (event.type === 'step/start') {
393
- stepStart.set(sessionId + ':' + event.data.turn + ':' + event.data.step, event.time)
394
- }
395
- if (event.type !== 'assistant/message') return null
396
- const d = event.data || {}
397
- let usage = d.usage
398
- if (!usage || typeof usage !== 'object') usage = { inputTokens: 0, outputTokens: 0 }
399
- const head = headerCache.get(sessionId) || { provider: '', model: '', effort: '' }
400
- const turn = num(d.turn)
401
- const step = num(d.step)
402
- const startK = sessionId + ':' + turn + ':' + step
403
- const startMs = stepStart.get(startK)
404
- const llmMs = typeof startMs === 'number' ? Math.max(0, event.time - startMs) : null
405
- const status = turnStatus.get(sessionId + ':' + turn) || 'in-progress'
406
- const ei = turnError.get(sessionId + ':' + turn) || null
407
- const input = num(usage.inputTokens)
408
- const output = num(usage.outputTokens)
409
- const cacheRead = num(usage.cacheReadTokens)
410
- const cacheWrite = num(usage.cacheWriteTokens)
411
- const reasoning = num(usage.reasoningTokens)
412
- const cost = estimateCost(usage, head.model, event.time)
413
- stepStart.delete(startK)
414
- return {
415
- id: sessionId + ':' + event.seq,
416
- time: event.time,
417
- sessionId,
418
- provider: head.provider || '',
419
- model: head.model || '',
420
- apiKey: '',
421
- inputTokens: input,
422
- outputTokens: output,
423
- cacheReadTokens: cacheRead,
424
- cacheWriteTokens: cacheWrite,
425
- reasoningTokens: reasoning,
426
- billedInput: input + cacheRead + cacheWrite,
427
- totalTokens: input + output + cacheRead + cacheWrite,
428
- cacheHitPercent: (input + cacheRead) > 0 ? Math.round((cacheRead / (input + cacheRead)) * 100) : 0,
429
- cost,
430
- effort: head.effort || '',
431
- status,
432
- statusCode: ei ? ei.statusCode : 0,
433
- errorCode: ei ? ei.errorCode : '',
434
- errorMsg: ei ? ei.errorMsg : '',
435
- llmMs,
436
- turn,
437
- step,
438
- }
439
- }
440
-
441
- function ingestEvents(sessionId, events) {
442
- const out = []
443
- for (const e of events) {
444
- const r = ingestEvent(sessionId, e)
445
- if (r) out.push(r)
446
- }
447
- return out
448
- }
449
-
450
- // 历史扫描: 遍历所有会话, 读取完整日志, 重建索引
451
- async function scanHistory() {
452
- if (!sessionQuery) return
453
- try {
454
- const sessions = await sessionQuery.listSessions()
455
- let added = 0
456
- for (const s of sessions) {
457
- const sid = s && s.header ? s.header.id : (s && s.id)
458
- if (!sid || scannedSessions.has(sid)) continue
459
- scannedSessions.add(sid)
460
- try {
461
- const snap = await sessionQuery.readSession(sid)
462
- const events = snap && snap.events ? snap.events : []
463
- // 两遍: 先建 turn/end 映射, 再生成记录
464
- preScanTurnEnds(sid, events)
465
- const recs = ingestEvents(sid, events)
466
- for (const r of recs) records.set(r.id, r)
467
- added += recs.length
468
- } catch (e) {
469
- console.error('[dsh-dock] tokenlog scan session failed', sid, e && e.message)
470
- }
471
- }
472
- console.log('[dsh-dock] tokenlog history scanned: +' + added + ' records, total ' + records.size)
473
- } catch (e) {
474
- console.error('[dsh-dock] tokenlog scanHistory failed', e && e.message)
475
- }
476
- }
477
-
478
- function buildQuery(args) {
479
- const q = args || {}
480
- const from = typeof q.from === 'number' ? q.from : 0
481
- const to = typeof q.to === 'number' ? q.to : Infinity
482
- const provider = (q.provider || '').trim()
483
- const model = (q.model || '').trim()
484
- const status = (q.status || '').trim()
485
- const effort = (q.effort || '').trim()
486
- const sessionId = (q.sessionId || '').trim()
487
- return [...records.values()].filter((r) => {
488
- if (r.time < from || r.time > to) return false
489
- if (provider && r.provider !== provider) return false
490
- if (model && r.model !== model) return false
491
- if (status && r.status !== status) return false
492
- if (effort && r.effort !== effort) return false
493
- if (sessionId && r.sessionId !== sessionId) return false
494
- return true
495
- })
496
- }
497
-
498
- function summarize(list, dim) {
499
- const map = new Map()
500
- for (const r of list) {
501
- const key = r[dim] || '(none)'
502
- const e = map.get(key)
503
- if (e) {
504
- e.calls += 1
505
- e.inputTokens += r.inputTokens
506
- e.outputTokens += r.outputTokens
507
- e.cacheReadTokens += r.cacheReadTokens
508
- e.cacheWriteTokens += r.cacheWriteTokens
509
- e.reasoningTokens += r.reasoningTokens
510
- e.billedInput += r.billedInput
511
- e.totalTokens += r.totalTokens
512
- e.cost += r.cost
513
- if (typeof r.llmMs === 'number') { e.llmMs += r.llmMs; e.timed += 1 }
514
- } else {
515
- map.set(key, {
516
- key, calls: 1,
517
- inputTokens: r.inputTokens, outputTokens: r.outputTokens,
518
- cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens,
519
- reasoningTokens: r.reasoningTokens, billedInput: r.billedInput,
520
- totalTokens: r.totalTokens, cost: r.cost,
521
- llmMs: typeof r.llmMs === 'number' ? r.llmMs : 0,
522
- timed: typeof r.llmMs === 'number' ? 1 : 0,
523
- })
524
- }
525
- }
526
- return [...map.values()]
527
- .map((e) => ({ ...e, cacheHitPct: e.billedInput > 0 ? Math.round((e.cacheReadTokens / e.billedInput) * 100) : 0 }))
528
- .sort((a, b) => b.calls - a.calls)
529
- }
530
-
531
- function totals(list) {
532
- const t = { calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, billedInput: 0, totalTokens: 0, cost: 0, llmMs: 0, timed: 0 }
533
- for (const r of list) {
534
- t.calls += 1
535
- t.inputTokens += r.inputTokens
536
- t.outputTokens += r.outputTokens
537
- t.cacheReadTokens += r.cacheReadTokens
538
- t.cacheWriteTokens += r.cacheWriteTokens
539
- t.reasoningTokens += r.reasoningTokens
540
- t.billedInput += r.billedInput
541
- t.totalTokens += r.totalTokens
542
- t.cost += r.cost
543
- if (typeof r.llmMs === 'number') { t.llmMs += r.llmMs; t.timed += 1 }
544
- }
545
- t.cacheHitPct = t.billedInput > 0 ? Math.round((t.cacheReadTokens / t.billedInput) * 100) : 0
546
- return t
547
- }
548
-
549
- // ---------- RPC(webServer HTTP 路由,前缀 /dsh-dock/tokenlog/) ----------
550
-
551
- // 实时监听 + 历史扫描: 必须等 sessionQuery 服务就绪。
552
- // 注意: 不能在 setup 时用 ctx.get('sessionQuery') 软获取——profile 插件无 inject 声明,
553
- // 可能早于 sessionQuery 提供方激活, 拿到 undefined 会让 监听+扫描 整条采集链路静默失效。
554
- // 改用 ctx.inject(['sessionQuery'], ...): 服务已就绪则立即执行, 否则挂起等待。
555
- let sessionQuery = null
556
- disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
557
- sessionQuery = sqCtx.sessionQuery
558
- // 服务就绪后加载 settings 定价配置(汇率 + pricing 覆盖), 供采集/重扫/导出使用
559
- loadPricingConfig()
560
- // 从官网获取最新定价(启动时 + 每 24h 自动刷新)
561
- fetchOfficialPricing()
562
- const fetchTimer = setInterval(fetchOfficialPricing, getPricingFetchConfig().intervalMs)
563
- sqCtx.effect(() => () => clearInterval(fetchTimer))
564
- // 实时监听: session/event (root scope 可收到所有 session 事件, 与官方 persistence 同款订阅)
565
- disposers.push(sqCtx.on('session/event', (session, event) => {
566
- try {
567
- const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
568
- const rec = ingestEvent(sid, event)
569
- if (rec) records.set(rec.id, rec)
570
- // turn/end 到达后回填该 turn 已有记录的状态/错误(实时增量场景)
571
- if (event.type === 'turn/end') {
572
- const t = event.data && event.data.turn
573
- const key = sid + ':' + t
574
- const st = turnStatus.get(key)
575
- const ei = turnError.get(key) || null
576
- if (st) {
577
- for (const r of records.values()) {
578
- if (r.sessionId === sid && String(r.turn) === String(t)) {
579
- r.status = st
580
- r.statusCode = ei ? ei.statusCode : 0
581
- r.errorCode = ei ? ei.errorCode : ''
582
- r.errorMsg = ei ? ei.errorMsg : ''
583
- }
584
- }
585
- }
586
- }
587
- } catch (err) {
588
- console.error('[dsh-dock] tokenlog ingest error', err)
589
- }
590
- }))
591
- // 服务就绪后立即做一次历史全量扫描
592
- scanHistory()
593
- }))
594
-
595
- // webServer 就绪后注册 /dsh-dock/tokenlog 前缀路由(ctx.inject 保证就绪后再执行)
596
- disposers.push(ctx.inject(['webServer'], (wsCtx) => {
597
- wsCtx.effect(() => wsCtx.webServer.register({
598
- kind: 'prefix',
599
- path: '/dsh-dock/tokenlog',
600
- async handler(req, res) {
601
- try {
602
- const url = new URL(req.url || '/', 'http://dsh.internal')
603
- const method = url.pathname.replace(/^\/dsh-dock\/tokenlog\/?/, '').split('/')[0] || ''
604
- const payload = await readBody(req)
605
- if (method === 'query') {
606
- loadPricingConfig()
607
- const list = buildQuery(payload || {})
608
- const rows = [...list].sort((a, b) => b.time - a.time)
609
- const all = [...records.values()]
610
- const cfg = readConfiguredProviders()
611
- // 提供商/模型下拉 = 现有配置(llm-pi-ai.providers) ∪ 历史记录, 去重排序。
612
- // 兼容两种场景: 无记录时仍可选现有提供商; 提供商/模型已删除时因记录仍可选中。
613
- const providerSet = new Set(all.map((r) => r.provider).filter(Boolean))
614
- const modelSet = new Set(all.map((r) => r.model).filter(Boolean))
615
- for (const pid of Object.keys(cfg)) if (pid) providerSet.add(pid)
616
- for (const ids of Object.values(cfg)) for (const id of ids) modelSet.add(id)
617
- // 提供商 -> 模型 映射(供前端联动筛选: 选中提供商后模型下拉只显示其下模型)
618
- const modelsByProvider = {}
619
- const initMbp = (p) => { if (!modelsByProvider[p]) modelsByProvider[p] = [] }
620
- for (const p of providerSet) initMbp(p)
621
- for (const r of all) {
622
- if (!r.model) continue
623
- const p = r.provider || '(none)'
624
- initMbp(p)
625
- if (!modelsByProvider[p].includes(r.model)) modelsByProvider[p].push(r.model)
626
- }
627
- for (const [pid, ids] of Object.entries(cfg)) {
628
- initMbp(pid)
629
- for (const id of ids) if (!modelsByProvider[pid].includes(id)) modelsByProvider[pid].push(id)
630
- }
631
- for (const p of Object.keys(modelsByProvider)) modelsByProvider[p].sort()
632
- return sendJson(res, 200, {
633
- ok: true,
634
- data: {
635
- records: rows,
636
- counts: { total: records.size, matching: rows.length },
637
- providers: [...providerSet].sort(),
638
- models: [...modelSet].sort(),
639
- modelsByProvider,
640
- statuses: [...new Set(all.map((r) => r.status).filter(Boolean))],
641
- efforts: [...new Set(all.map((r) => r.effort).filter(Boolean))],
642
- sessionIds: [...new Set(all.map((r) => r.sessionId).filter(Boolean))].sort(),
643
- rateUsdCny: usdCnyRate,
644
- totals: totals(list),
645
- summary: payload && payload.dim ? summarize(list, payload.dim) : [],
646
- }
647
- })
648
- }
649
- if (method === 'export') {
650
- loadPricingConfig()
651
- const list = buildQuery(payload || {})
652
- const header = ['time', 'provider', 'model', 'apiKey', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'billedInput', 'cacheHitPercent', 'totalTokens', 'costCny', 'effort', 'status', 'statusCode', 'errorCode', 'errorMsg', 'llmMs', 'sessionId', 'turn', 'step']
653
- const esc = (v) => {
654
- const s = String(v === undefined || v === null ? '' : v)
655
- return /[,"\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
656
- }
657
- const lines = [header.map(esc).join(',')]
658
- for (const r of list) lines.push(header.map((h) => esc(h === 'costCny' ? ((Number(r.cost) || 0) * usdCnyRate) : r[h])).join(','))
659
- return sendJson(res, 200, { ok: true, data: { csv: lines.join('\n'), count: list.length } })
660
- }
661
- if (method === 'scan') {
662
- await scanHistory()
663
- return sendJson(res, 200, { ok: true, data: { total: records.size } })
664
- }
665
- if (method === 'hello') {
666
- return sendJson(res, 200, { ok: true, data: { ok: true, records: records.size, scanned: scannedSessions.size } })
667
- }
668
- return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method, details: {} } })
669
- } catch (e) {
670
- return sendJson(res, 500, { ok: false, error: { code: 'internal', message: (e && e.message) || String(e), details: {} } })
671
- }
672
- }
673
- }), 'dsh-dock tokenlog: /dsh-dock/tokenlog HTTP route')
674
- }))
675
-
676
- return dispose
677
- },
678
- }
1
+ // dsh-dock · 功能模块【用量记录】· 宿主半部(v0.4.0,移植自 @wycto/dsh-token-usage lib/index.js)
2
+ //
3
+ // 记录 DSH 会话日志中的每次 LLM 调用(assistant/message 的 usage),
4
+ // 支持历史全量扫描 + 实时 session/event 增量;RPC 经 webServer HTTP 路由
5
+ // POST /dsh-dock/tokenlog/query|export|scan|hello
6
+ // 向浏览器半部提供查询/统计/导出。
7
+ //
8
+ // 数据模型(标量,无 Host 对象引用):
9
+ // {
10
+ // id, time, sessionId, provider, model, apiKey(掩码),
11
+ // inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens,
12
+ // billedInput, totalTokens, cacheHitPercent, cost, effort, status, llmMs, turn, step
13
+ // }
14
+ //
15
+ // 定价可经 settings.yaml 的 dsh-dock-tokenlog 命名空间覆盖(未注册 schema,读原始用户层):
16
+ // dsh-dock-tokenlog:
17
+ // usdCnyRate: 7.2
18
+ // pricingUrl: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
19
+ // pricingFetchIntervalHours: 24
20
+ // pricing:
21
+ // deepseek-v4-flash: { input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0 }
22
+ import { sendJson, readBody } from '../../src/host-core.js'
23
+
24
+ /** 本功能的 settings 覆盖命名空间(未注册 schema:读取原始用户层,字段全部可选)。 */
25
+ const NS = 'dsh-dock-tokenlog'
26
+
27
+ export const feature = {
28
+ id: 'tokenlog',
29
+ name: '用量记录',
30
+ description: '记录全部 LLM API 调用并统计 Token 用量与花费',
31
+ defaultEnabled: false,
32
+ setup(ctx) {
33
+ const disposers = []
34
+ const dispose = () => {
35
+ while (disposers.length > 0) {
36
+ const fn = disposers.pop()
37
+ try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
38
+ }
39
+ }
40
+
41
+ // 用 Map<id, record> 存记录: id = sessionId:seq 天然唯一。
42
+ // 实时监听与历史扫描存在重叠窗口(活跃会话既被实时 push 又被扫描全量读),
43
+ // 用 set 幂等去重, 避免同一条 LLM 调用被统计两次(此前用数组 push 导致"两倍")。
44
+ const records = new Map()
45
+ let scannedSessions = new Set()
46
+ const headerCache = new Map()
47
+ const turnError = new Map()
48
+ const turnStatus = new Map()
49
+ const stepStart = new Map()
50
+
51
+ function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0 }
52
+
53
+ // ===== 定价与金额估算 =====
54
+ // 定价表以「人民币元 / 百万 tokens」为基准(DeepSeek/智谱官方刊例即人民币), 金额先算 CNY,
55
+ // 对外记录的 cost 保持 USD,供旧版客户端兼容;当前客户端和 CSV 统一展示人民币。
56
+ // usdCnyRate 与 pricing 均可被 settings.yaml 覆盖:
57
+ // dsh-dock-tokenlog:
58
+ // usdCnyRate: 7.2 # USD↔CNY 汇率
59
+ // pricing: # 可选: 覆盖/新增定价条目(modelPattern -> {...})
60
+ // deepseek-v4-flash:
61
+ // input: 1.5 output: 4.5 cacheRead: 0.05 cacheWrite: 0
62
+ // peak: { start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 }
63
+ // 官方价格随时会变动: 改价只改 settings.yaml 即可, 无需升级插件; 重启/重扫后历史记录按新价重算。
64
+ let usdCnyRate = 7.2
65
+ let fallbackPricing = { match: '*', input: 2.16, output: 6.48, cacheRead: 0.43, cacheWrite: 4.32 }
66
+ // 内置定价表(人民币元 / 百万 tokens), 按 model 子串匹配; 未匹配走 fallback。
67
+ // 支持峰谷定价: 条目带 peak={start,end,input,output,cacheRead,cacheWrite},
68
+ // 高峰时段(插件运行机器本地时间 [start,end))用峰价, 其余用基础价。
69
+ // 官方来源: DeepSeek-V4 系列 2026-08-17 生效的峰谷刊例(高峰每日 9:00-14:00, 空闲为高峰一半)。
70
+ const DEFAULT_PRICING = [
71
+ { match: 'deepseek-v4-flash', input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0,
72
+ peak: { start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 } },
73
+ { match: 'deepseek-v4-pro', input: 4.5, output: 13.5, cacheRead: 0.15, cacheWrite: 0,
74
+ peak: { start: 9, end: 14, input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0 } },
75
+ { match: 'deepseek-v3', input: 3.6, output: 7.2, cacheRead: 0.72, cacheWrite: 7.2 },
76
+ { match: 'deepseek-chat', input: 1.94, output: 7.92, cacheRead: 0.5, cacheWrite: 7.92 },
77
+ { match: 'deepseek-reasoner', input: 3.96, output: 15.77, cacheRead: 1.01, cacheWrite: 7.92 },
78
+ { match: 'nemotron', input: 1.08, output: 2.88, cacheRead: 0.22, cacheWrite: 2.16 },
79
+ { match: 'glm', input: 2.88, output: 8.64, cacheRead: 0.58, cacheWrite: 7.2 },
80
+ ]
81
+ let pricingTable = DEFAULT_PRICING
82
+
83
+ function normalizePricing(match, e) {
84
+ const n = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
85
+ const row = { match, input: n(e.input), output: n(e.output), cacheRead: n(e.cacheRead), cacheWrite: n(e.cacheWrite) }
86
+ if (e.peak && typeof e.peak === 'object') {
87
+ row.peak = {
88
+ start: n(e.peak.start), end: n(e.peak.end),
89
+ input: n(e.peak.input), output: n(e.peak.output),
90
+ cacheRead: n(e.peak.cacheRead), cacheWrite: n(e.peak.cacheWrite),
91
+ }
92
+ }
93
+ return row
94
+ }
95
+
96
+ // 读取 settings.yaml 的 dsh-dock-tokenlog { usdCnyRate?, pricing? } 覆盖默认值。
97
+ // pricing: map modelPattern -> {input,output,cacheRead,cacheWrite,peak?};
98
+ // 设置条目排前面优先命中(同 match 覆盖内置); 键 '*' 覆盖 fallback。幂等, 可重复调用。
99
+ function loadPricingConfig() {
100
+ try {
101
+ const settings = ctx.get('settings')
102
+ if (!settings || typeof settings.get !== 'function') return
103
+ const v = settings.get(NS)
104
+ if (!v || typeof v !== 'object') return
105
+ if (typeof v.usdCnyRate === 'number' && v.usdCnyRate > 0) usdCnyRate = v.usdCnyRate
106
+ if (v.pricing && typeof v.pricing === 'object') {
107
+ const overrides = []
108
+ for (const [match, entry] of Object.entries(v.pricing)) {
109
+ if (!entry || typeof entry !== 'object') continue
110
+ const row = normalizePricing(String(match).toLowerCase(), entry)
111
+ if (row.match === '*') fallbackPricing = row
112
+ else overrides.push(row)
113
+ }
114
+ pricingTable = overrides.concat(DEFAULT_PRICING)
115
+ }
116
+ } catch (e) {
117
+ console.error('[dsh-dock] tokenlog read pricing config failed', e && e.message)
118
+ }
119
+ }
120
+
121
+ // 命中定价行: 优先官网自动获取 → settings.yaml 覆盖+内置 → fallback; 再按调用时刻取峰/谷价。
122
+ // 官网获取的数据若为 USD 则自动折成 CNY(内置表已是 CNY)。返回人民币单价(元/百万 tokens)。
123
+ function resolvePricing(model, timeMs) {
124
+ const m = String(model || '').toLowerCase()
125
+ let row = null
126
+ let fromFetch = false
127
+ // 1. 优先: 官网自动获取的定价(48h 内有效, 超时回退内置)
128
+ const fetched = fetchedPricing
129
+ if (fetched && fetched.length > 0 && (Date.now() - lastFetchTime < FETCH_INTERVAL_MS * 2)) {
130
+ for (const r of fetched) {
131
+ if (m.includes(r.match)) { row = r; fromFetch = true; break }
132
+ }
133
+ }
134
+ // 2. 回退: settings.yaml 覆盖 + 内置表
135
+ if (!row) {
136
+ for (const r of pricingTable) {
137
+ if (m.includes(r.match)) { row = r; break }
138
+ }
139
+ }
140
+ if (!row) row = fallbackPricing
141
+ // 3. 峰谷判断(本地时间 [start,end) 用峰价)
142
+ const h = (typeof timeMs === 'number' && Number.isFinite(timeMs)) ? new Date(timeMs).getHours() : -1
143
+ let prices
144
+ if (row.peak && h >= row.peak.start && h < row.peak.end) {
145
+ prices = { input: row.peak.input, output: row.peak.output, cacheRead: row.peak.cacheRead, cacheWrite: row.peak.cacheWrite }
146
+ } else {
147
+ prices = { input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite }
148
+ }
149
+ // 4. 币种转换: 获取的数据若是 USD 则折成 CNY(内置表已是 CNY, 无需转换)
150
+ if (fromFetch && row.currency === 'usd') {
151
+ prices.input *= usdCnyRate
152
+ prices.output *= usdCnyRate
153
+ prices.cacheRead *= usdCnyRate
154
+ prices.cacheWrite *= usdCnyRate
155
+ }
156
+ return prices
157
+ }
158
+ // 金额估算: 表为人民币价 → 先算 CNY, 再折成 USD(= CNY / usdCnyRate) 供客户端/CSV 使用。
159
+ function estimateCost(usage, model, timeMs) {
160
+ const p = resolvePricing(model, timeMs)
161
+ const cny = (
162
+ num(usage.inputTokens) * p.input +
163
+ num(usage.cacheReadTokens) * p.cacheRead +
164
+ num(usage.cacheWriteTokens) * p.cacheWrite +
165
+ num(usage.outputTokens) * p.output
166
+ ) / 1e6
167
+ return cny / usdCnyRate
168
+ }
169
+
170
+ // ===== 官网定价自动获取 =====
171
+ // 每 24 小时从 DeepSeek 官网抓取最新定价并解析 HTML 表格; 抓取/解析失败时使用内置默认值。
172
+ const FETCH_INTERVAL_MS = 24 * 60 * 60 * 1000
173
+ let fetchedPricing = null // Array<{ match, input, output, cacheRead, cacheWrite, currency }>
174
+ let lastFetchTime = 0
175
+
176
+ function getPricingFetchConfig() {
177
+ let url = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
178
+ let intervalMs = FETCH_INTERVAL_MS
179
+ try {
180
+ const settings = ctx.get('settings')
181
+ if (settings && typeof settings.get === 'function') {
182
+ const v = settings.get(NS)
183
+ if (v && typeof v === 'object') {
184
+ if (typeof v.pricingUrl === 'string' && v.pricingUrl) url = v.pricingUrl
185
+ if (typeof v.pricingFetchIntervalHours === 'number' && v.pricingFetchIntervalHours > 0) intervalMs = v.pricingFetchIntervalHours * 3600000
186
+ }
187
+ }
188
+ } catch (e) { /* 用默认 */ }
189
+ return { url, intervalMs }
190
+ }
191
+
192
+ // 从 HTML 中提取所有 <table> 的行列数据
193
+ function extractHTMLTables(html) {
194
+ const tables = []
195
+ const tableRe = /<table[^>]*>([\s\S]*?)<\/table>/gi
196
+ let tm
197
+ while ((tm = tableRe.exec(html)) !== null) {
198
+ const rows = []
199
+ const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
200
+ let tr
201
+ while ((tr = trRe.exec(tm[1])) !== null) {
202
+ const cells = []
203
+ const tdRe = /<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi
204
+ let td
205
+ while ((td = tdRe.exec(tr[1])) !== null) {
206
+ cells.push(td[1].replace(/<[^>]+>/g, '').replace(/&[a-z]+;/g, ' ').trim())
207
+ }
208
+ if (cells.length > 0) rows.push(cells)
209
+ }
210
+ if (rows.length >= 2) tables.push(rows)
211
+ }
212
+ return tables
213
+ }
214
+
215
+ // 从价格字符串提取数字(支持 "$0.27", "0.27", "¥1.5", "1.5元", "1,500" 等格式)
216
+ function parsePrice(s) {
217
+ if (!s) return null
218
+ const cleaned = String(s).replace(/,/g, '').replace(/¥|¥|元|\/.*$/g, '').trim()
219
+ const m = cleaned.match(/(\d+\.?\d*)/)
220
+ return m ? parseFloat(m[1]) : null
221
+ }
222
+
223
+ // 识别定价表列: 通过表头关键词定位各列
224
+ function identifyPricingColumns(headers) {
225
+ const h = headers.map(x => x.toLowerCase())
226
+ const find = (...patterns) => {
227
+ for (const p of patterns) {
228
+ const idx = h.findIndex(x => x.includes(p))
229
+ if (idx >= 0) return idx
230
+ }
231
+ return -1
232
+ }
233
+ return {
234
+ model: find('模型', 'model', '名称', 'name'),
235
+ inputMiss: find('缓存未命中', '未缓存', 'cache miss', 'uncached'),
236
+ inputHit: find('缓存命中', 'cache hit', 'cached'),
237
+ output: find('输出', 'output'),
238
+ peak: find('高峰', 'peak', 'busy', 'on-peak'),
239
+ }
240
+ }
241
+
242
+ // 从 HTML 解析 DeepSeek 官网定价
243
+ // 策略: 提取所有 <table>, 找到含"价格/缓存/输入/输出"关键词的表, 逐行提取模型名+价格;
244
+ // 识别 $ 标记区分 USD/CNY 币种; 无法解析时返回空数组(回退内置默认)。
245
+ function parsePricingFromHTML(html) {
246
+ const tables = extractHTMLTables(html)
247
+ const results = []
248
+ for (const rows of tables) {
249
+ const headers = rows[0]
250
+ const cols = identifyPricingColumns(headers)
251
+ // 至少需要模型名 + 一个价格列
252
+ if (cols.model < 0) continue
253
+ if (cols.inputMiss < 0 && cols.output < 0 && cols.inputHit < 0) continue
254
+ // 表头需与定价相关
255
+ const headerText = headers.join(' ')
256
+ if (!/价格|price|缓存|cache|输入|input|输出|output|tokens/i.test(headerText)) continue
257
+ for (let i = 1; i < rows.length; i++) {
258
+ const row = rows[i]
259
+ const name = (row[cols.model] || '').trim()
260
+ if (!name || /^(模型|model|名称|合计|total|---)/i.test(name)) continue
261
+ const input = cols.inputMiss >= 0 ? parsePrice(row[cols.inputMiss]) : null
262
+ const output = cols.output >= 0 ? parsePrice(row[cols.output]) : null
263
+ const cacheHit = cols.inputHit >= 0 ? parsePrice(row[cols.inputHit]) : null
264
+ if (input === null && output === null && cacheHit === null) continue
265
+ // 判断币种
266
+ const allText = row.join(' ')
267
+ const isUSD = /\$|USD|dollar/i.test(allText)
268
+ results.push({
269
+ match: name.toLowerCase(),
270
+ input: input || 0,
271
+ output: output || 0,
272
+ cacheRead: cacheHit || 0,
273
+ cacheWrite: 0, // DeepSeek 官方不收取缓存写入
274
+ currency: isUSD ? 'usd' : 'cny',
275
+ })
276
+ }
277
+ }
278
+ return results
279
+ }
280
+
281
+ // 从官网获取最新定价(异步, 失败静默回退内置默认)
282
+ async function fetchOfficialPricing() {
283
+ try {
284
+ const { url } = getPricingFetchConfig()
285
+ console.log('[dsh-dock] tokenlog fetching official pricing from:', url)
286
+ const res = await fetch(url, {
287
+ headers: { 'User-Agent': 'dsh-dock-tokenlog/0.4', 'Accept': 'text/html,*/*' }
288
+ })
289
+ if (!res.ok) throw new Error('HTTP ' + res.status)
290
+ const html = await res.text()
291
+ console.log('[dsh-dock] tokenlog pricing page fetched, size:', html.length)
292
+ if (html.length < 500) {
293
+ console.warn('[dsh-dock] tokenlog page too small, likely SPA shell — using defaults')
294
+ return
295
+ }
296
+ const parsed = parsePricingFromHTML(html)
297
+ if (parsed.length > 0) {
298
+ fetchedPricing = parsed
299
+ lastFetchTime = Date.now()
300
+ console.log('[dsh-dock] tokenlog parsed', parsed.length, 'models from official pricing')
301
+ } else {
302
+ console.warn('[dsh-dock] tokenlog parsed 0 models from pricing page — check page structure, using defaults')
303
+ }
304
+ } catch (e) {
305
+ console.error('[dsh-dock] tokenlog fetch official pricing failed:', e && e.message, '— using built-in defaults')
306
+ }
307
+ }
308
+
309
+ // 读取 settings.yaml 的 llm-pi-ai 命名空间, 返回「现有提供商」路由名 -> [模型 id]。
310
+ // 用途: 前端提供商/模型下拉 = 现有配置 ∪ 历史记录, 去重合并 ——
311
+ // - 没有任何记录时仍可选现有提供商;
312
+ // - 提供商/模型已从配置删除时, 因有历史记录仍可选中(兼容两者)。
313
+ function readConfiguredProviders() {
314
+ const out = {}
315
+ try {
316
+ const settings = ctx.get('settings')
317
+ if (!settings || typeof settings.get !== 'function') return out
318
+ const sec = settings.get('llm-pi-ai')
319
+ const providers = sec && typeof sec === 'object' && sec.providers ? sec.providers : {}
320
+ for (const pid of Object.keys(providers || {})) {
321
+ const p = providers[pid]
322
+ if (!p || typeof p !== 'object') { out[pid] = []; continue }
323
+ const models = Array.isArray(p.models) ? p.models : []
324
+ const ids = []
325
+ for (const m of models) {
326
+ const id = m && typeof m === 'object' ? m.id : m
327
+ if (typeof id === 'string' && id) ids.push(id)
328
+ }
329
+ out[pid] = ids
330
+ }
331
+ } catch (e) {
332
+ console.error('[dsh-dock] tokenlog read configured providers failed', e && e.message)
333
+ }
334
+ return out
335
+ }
336
+
337
+ // ---------- 事件采集 ----------
338
+
339
+ // 预扫描 turn/end: 先建立 turn -> status/error 映射, 再生成记录
340
+ // (解决顺序处理导致的状态缺失: assistant/message 先于 turn/end 到达)
341
+ function preScanTurnEnds(sessionId, events) {
342
+ for (const e of events) {
343
+ if (e.type !== 'turn/end') continue
344
+ const t = e.data && e.data.turn
345
+ const r = e.data && e.data.reason
346
+ let status = 'completed'
347
+ if (r && typeof r === 'object') status = r.kind || 'completed'
348
+ else if (typeof r === 'string') status = r
349
+ turnStatus.set(sessionId + ':' + t, status)
350
+ let errInfo = null
351
+ if (r && typeof r === 'object' && r.error) {
352
+ const er = r.error
353
+ errInfo = {
354
+ statusCode: typeof er.status === 'number' ? er.status : 0,
355
+ errorCode: er.code || '',
356
+ errorMsg: er.message || '',
357
+ }
358
+ }
359
+ turnError.set(sessionId + ':' + t, errInfo)
360
+ }
361
+ }
362
+
363
+ function ingestEvent(sessionId, event) {
364
+ if (event.type === 'request/header') {
365
+ const h = event.data && event.data.header
366
+ if (h && h.config) {
367
+ headerCache.set(sessionId, {
368
+ provider: h.config.provider || '',
369
+ model: h.config.model || '',
370
+ effort: h.config.reasoningEffort || '',
371
+ })
372
+ }
373
+ }
374
+ if (event.type === 'turn/end') {
375
+ const t = event.data && event.data.turn
376
+ const r = event.data && event.data.reason
377
+ let status = 'completed'
378
+ if (r && typeof r === 'object') status = r.kind || 'completed'
379
+ else if (typeof r === 'string') status = r
380
+ turnStatus.set(sessionId + ':' + t, status)
381
+ let errInfo = null
382
+ if (r && typeof r === 'object' && r.error) {
383
+ const e = r.error
384
+ errInfo = {
385
+ statusCode: typeof e.status === 'number' ? e.status : 0,
386
+ errorCode: e.code || '',
387
+ errorMsg: e.message || '',
388
+ }
389
+ }
390
+ turnError.set(sessionId + ':' + t, errInfo)
391
+ }
392
+ if (event.type === 'step/start') {
393
+ stepStart.set(sessionId + ':' + event.data.turn + ':' + event.data.step, event.time)
394
+ }
395
+ if (event.type !== 'assistant/message') return null
396
+ const d = event.data || {}
397
+ let usage = d.usage
398
+ if (!usage || typeof usage !== 'object') usage = { inputTokens: 0, outputTokens: 0 }
399
+ const head = headerCache.get(sessionId) || { provider: '', model: '', effort: '' }
400
+ const turn = num(d.turn)
401
+ const step = num(d.step)
402
+ const startK = sessionId + ':' + turn + ':' + step
403
+ const startMs = stepStart.get(startK)
404
+ const llmMs = typeof startMs === 'number' ? Math.max(0, event.time - startMs) : null
405
+ const status = turnStatus.get(sessionId + ':' + turn) || 'in-progress'
406
+ const ei = turnError.get(sessionId + ':' + turn) || null
407
+ const input = num(usage.inputTokens)
408
+ const output = num(usage.outputTokens)
409
+ const cacheRead = num(usage.cacheReadTokens)
410
+ const cacheWrite = num(usage.cacheWriteTokens)
411
+ const reasoning = num(usage.reasoningTokens)
412
+ const cost = estimateCost(usage, head.model, event.time)
413
+ stepStart.delete(startK)
414
+ return {
415
+ id: sessionId + ':' + event.seq,
416
+ time: event.time,
417
+ sessionId,
418
+ provider: head.provider || '',
419
+ model: head.model || '',
420
+ apiKey: '',
421
+ inputTokens: input,
422
+ outputTokens: output,
423
+ cacheReadTokens: cacheRead,
424
+ cacheWriteTokens: cacheWrite,
425
+ reasoningTokens: reasoning,
426
+ billedInput: input + cacheRead + cacheWrite,
427
+ totalTokens: input + output + cacheRead + cacheWrite,
428
+ cacheHitPercent: (input + cacheRead) > 0 ? Math.round((cacheRead / (input + cacheRead)) * 100) : 0,
429
+ cost,
430
+ effort: head.effort || '',
431
+ status,
432
+ statusCode: ei ? ei.statusCode : 0,
433
+ errorCode: ei ? ei.errorCode : '',
434
+ errorMsg: ei ? ei.errorMsg : '',
435
+ llmMs,
436
+ turn,
437
+ step,
438
+ }
439
+ }
440
+
441
+ function ingestEvents(sessionId, events) {
442
+ const out = []
443
+ for (const e of events) {
444
+ const r = ingestEvent(sessionId, e)
445
+ if (r) out.push(r)
446
+ }
447
+ return out
448
+ }
449
+
450
+ // 历史扫描: 遍历所有会话, 读取完整日志, 重建索引
451
+ async function scanHistory() {
452
+ if (!sessionQuery) return
453
+ try {
454
+ const sessions = await sessionQuery.listSessions()
455
+ let added = 0
456
+ for (const s of sessions) {
457
+ const sid = s && s.header ? s.header.id : (s && s.id)
458
+ if (!sid || scannedSessions.has(sid)) continue
459
+ scannedSessions.add(sid)
460
+ try {
461
+ const snap = await sessionQuery.readSession(sid)
462
+ const events = snap && snap.events ? snap.events : []
463
+ // 两遍: 先建 turn/end 映射, 再生成记录
464
+ preScanTurnEnds(sid, events)
465
+ const recs = ingestEvents(sid, events)
466
+ for (const r of recs) records.set(r.id, r)
467
+ added += recs.length
468
+ } catch (e) {
469
+ console.error('[dsh-dock] tokenlog scan session failed', sid, e && e.message)
470
+ }
471
+ }
472
+ console.log('[dsh-dock] tokenlog history scanned: +' + added + ' records, total ' + records.size)
473
+ } catch (e) {
474
+ console.error('[dsh-dock] tokenlog scanHistory failed', e && e.message)
475
+ }
476
+ }
477
+
478
+ function buildQuery(args) {
479
+ const q = args || {}
480
+ const from = typeof q.from === 'number' ? q.from : 0
481
+ const to = typeof q.to === 'number' ? q.to : Infinity
482
+ const provider = (q.provider || '').trim()
483
+ const model = (q.model || '').trim()
484
+ const status = (q.status || '').trim()
485
+ const effort = (q.effort || '').trim()
486
+ const sessionId = (q.sessionId || '').trim()
487
+ return [...records.values()].filter((r) => {
488
+ if (r.time < from || r.time > to) return false
489
+ if (provider && r.provider !== provider) return false
490
+ if (model && r.model !== model) return false
491
+ if (status && r.status !== status) return false
492
+ if (effort && r.effort !== effort) return false
493
+ if (sessionId && r.sessionId !== sessionId) return false
494
+ return true
495
+ })
496
+ }
497
+
498
+ function summarize(list, dim) {
499
+ const map = new Map()
500
+ for (const r of list) {
501
+ const key = r[dim] || '(none)'
502
+ const e = map.get(key)
503
+ if (e) {
504
+ e.calls += 1
505
+ e.inputTokens += r.inputTokens
506
+ e.outputTokens += r.outputTokens
507
+ e.cacheReadTokens += r.cacheReadTokens
508
+ e.cacheWriteTokens += r.cacheWriteTokens
509
+ e.reasoningTokens += r.reasoningTokens
510
+ e.billedInput += r.billedInput
511
+ e.totalTokens += r.totalTokens
512
+ e.cost += r.cost
513
+ if (typeof r.llmMs === 'number') { e.llmMs += r.llmMs; e.timed += 1 }
514
+ } else {
515
+ map.set(key, {
516
+ key, calls: 1,
517
+ inputTokens: r.inputTokens, outputTokens: r.outputTokens,
518
+ cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens,
519
+ reasoningTokens: r.reasoningTokens, billedInput: r.billedInput,
520
+ totalTokens: r.totalTokens, cost: r.cost,
521
+ llmMs: typeof r.llmMs === 'number' ? r.llmMs : 0,
522
+ timed: typeof r.llmMs === 'number' ? 1 : 0,
523
+ })
524
+ }
525
+ }
526
+ return [...map.values()]
527
+ .map((e) => ({ ...e, cacheHitPct: e.billedInput > 0 ? Math.round((e.cacheReadTokens / e.billedInput) * 100) : 0 }))
528
+ .sort((a, b) => b.calls - a.calls)
529
+ }
530
+
531
+ function totals(list) {
532
+ const t = { calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, billedInput: 0, totalTokens: 0, cost: 0, llmMs: 0, timed: 0 }
533
+ for (const r of list) {
534
+ t.calls += 1
535
+ t.inputTokens += r.inputTokens
536
+ t.outputTokens += r.outputTokens
537
+ t.cacheReadTokens += r.cacheReadTokens
538
+ t.cacheWriteTokens += r.cacheWriteTokens
539
+ t.reasoningTokens += r.reasoningTokens
540
+ t.billedInput += r.billedInput
541
+ t.totalTokens += r.totalTokens
542
+ t.cost += r.cost
543
+ if (typeof r.llmMs === 'number') { t.llmMs += r.llmMs; t.timed += 1 }
544
+ }
545
+ t.cacheHitPct = t.billedInput > 0 ? Math.round((t.cacheReadTokens / t.billedInput) * 100) : 0
546
+ return t
547
+ }
548
+
549
+ // ---------- RPC(webServer HTTP 路由,前缀 /dsh-dock/tokenlog/) ----------
550
+
551
+ // 实时监听 + 历史扫描: 必须等 sessionQuery 服务就绪。
552
+ // 注意: 不能在 setup 时用 ctx.get('sessionQuery') 软获取——profile 插件无 inject 声明,
553
+ // 可能早于 sessionQuery 提供方激活, 拿到 undefined 会让 监听+扫描 整条采集链路静默失效。
554
+ // 改用 ctx.inject(['sessionQuery'], ...): 服务已就绪则立即执行, 否则挂起等待。
555
+ let sessionQuery = null
556
+ disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
557
+ sessionQuery = sqCtx.sessionQuery
558
+ // 服务就绪后加载 settings 定价配置(汇率 + pricing 覆盖), 供采集/重扫/导出使用
559
+ loadPricingConfig()
560
+ // 从官网获取最新定价(启动时 + 每 24h 自动刷新)
561
+ fetchOfficialPricing()
562
+ const fetchTimer = setInterval(fetchOfficialPricing, getPricingFetchConfig().intervalMs)
563
+ sqCtx.effect(() => () => clearInterval(fetchTimer))
564
+ // 实时监听: session/event (root scope 可收到所有 session 事件, 与官方 persistence 同款订阅)
565
+ disposers.push(sqCtx.on('session/event', (session, event) => {
566
+ try {
567
+ const sid = typeof session === 'object' && session ? (session.id || '') : String(session || '')
568
+ const rec = ingestEvent(sid, event)
569
+ if (rec) records.set(rec.id, rec)
570
+ // turn/end 到达后回填该 turn 已有记录的状态/错误(实时增量场景)
571
+ if (event.type === 'turn/end') {
572
+ const t = event.data && event.data.turn
573
+ const key = sid + ':' + t
574
+ const st = turnStatus.get(key)
575
+ const ei = turnError.get(key) || null
576
+ if (st) {
577
+ for (const r of records.values()) {
578
+ if (r.sessionId === sid && String(r.turn) === String(t)) {
579
+ r.status = st
580
+ r.statusCode = ei ? ei.statusCode : 0
581
+ r.errorCode = ei ? ei.errorCode : ''
582
+ r.errorMsg = ei ? ei.errorMsg : ''
583
+ }
584
+ }
585
+ }
586
+ }
587
+ } catch (err) {
588
+ console.error('[dsh-dock] tokenlog ingest error', err)
589
+ }
590
+ }))
591
+ // 服务就绪后立即做一次历史全量扫描
592
+ scanHistory()
593
+ }))
594
+
595
+ // webServer 就绪后注册 /dsh-dock/tokenlog 前缀路由(ctx.inject 保证就绪后再执行)
596
+ disposers.push(ctx.inject(['webServer'], (wsCtx) => {
597
+ wsCtx.effect(() => wsCtx.webServer.register({
598
+ kind: 'prefix',
599
+ path: '/dsh-dock/tokenlog',
600
+ async handler(req, res) {
601
+ try {
602
+ const url = new URL(req.url || '/', 'http://dsh.internal')
603
+ const method = url.pathname.replace(/^\/dsh-dock\/tokenlog\/?/, '').split('/')[0] || ''
604
+ const payload = await readBody(req)
605
+ if (method === 'query') {
606
+ loadPricingConfig()
607
+ const list = buildQuery(payload || {})
608
+ const rows = [...list].sort((a, b) => b.time - a.time)
609
+ const all = [...records.values()]
610
+ const cfg = readConfiguredProviders()
611
+ // 提供商/模型下拉 = 现有配置(llm-pi-ai.providers) ∪ 历史记录, 去重排序。
612
+ // 兼容两种场景: 无记录时仍可选现有提供商; 提供商/模型已删除时因记录仍可选中。
613
+ const providerSet = new Set(all.map((r) => r.provider).filter(Boolean))
614
+ const modelSet = new Set(all.map((r) => r.model).filter(Boolean))
615
+ for (const pid of Object.keys(cfg)) if (pid) providerSet.add(pid)
616
+ for (const ids of Object.values(cfg)) for (const id of ids) modelSet.add(id)
617
+ // 提供商 -> 模型 映射(供前端联动筛选: 选中提供商后模型下拉只显示其下模型)
618
+ const modelsByProvider = {}
619
+ const initMbp = (p) => { if (!modelsByProvider[p]) modelsByProvider[p] = [] }
620
+ for (const p of providerSet) initMbp(p)
621
+ for (const r of all) {
622
+ if (!r.model) continue
623
+ const p = r.provider || '(none)'
624
+ initMbp(p)
625
+ if (!modelsByProvider[p].includes(r.model)) modelsByProvider[p].push(r.model)
626
+ }
627
+ for (const [pid, ids] of Object.entries(cfg)) {
628
+ initMbp(pid)
629
+ for (const id of ids) if (!modelsByProvider[pid].includes(id)) modelsByProvider[pid].push(id)
630
+ }
631
+ for (const p of Object.keys(modelsByProvider)) modelsByProvider[p].sort()
632
+ return sendJson(res, 200, {
633
+ ok: true,
634
+ data: {
635
+ records: rows,
636
+ counts: { total: records.size, matching: rows.length },
637
+ providers: [...providerSet].sort(),
638
+ models: [...modelSet].sort(),
639
+ modelsByProvider,
640
+ statuses: [...new Set(all.map((r) => r.status).filter(Boolean))],
641
+ efforts: [...new Set(all.map((r) => r.effort).filter(Boolean))],
642
+ sessionIds: [...new Set(all.map((r) => r.sessionId).filter(Boolean))].sort(),
643
+ rateUsdCny: usdCnyRate,
644
+ totals: totals(list),
645
+ summary: payload && payload.dim ? summarize(list, payload.dim) : [],
646
+ }
647
+ })
648
+ }
649
+ if (method === 'export') {
650
+ loadPricingConfig()
651
+ const list = buildQuery(payload || {})
652
+ const header = ['time', 'provider', 'model', 'apiKey', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'billedInput', 'cacheHitPercent', 'totalTokens', 'costCny', 'effort', 'status', 'statusCode', 'errorCode', 'errorMsg', 'llmMs', 'sessionId', 'turn', 'step']
653
+ const esc = (v) => {
654
+ const s = String(v === undefined || v === null ? '' : v)
655
+ return /[,"\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
656
+ }
657
+ const lines = [header.map(esc).join(',')]
658
+ for (const r of list) lines.push(header.map((h) => esc(h === 'costCny' ? ((Number(r.cost) || 0) * usdCnyRate) : r[h])).join(','))
659
+ return sendJson(res, 200, { ok: true, data: { csv: lines.join('\n'), count: list.length } })
660
+ }
661
+ if (method === 'scan') {
662
+ await scanHistory()
663
+ return sendJson(res, 200, { ok: true, data: { total: records.size } })
664
+ }
665
+ if (method === 'hello') {
666
+ return sendJson(res, 200, { ok: true, data: { ok: true, records: records.size, scanned: scannedSessions.size } })
667
+ }
668
+ return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method, details: {} } })
669
+ } catch (e) {
670
+ return sendJson(res, 500, { ok: false, error: { code: 'internal', message: (e && e.message) || String(e), details: {} } })
671
+ }
672
+ }
673
+ }), 'dsh-dock tokenlog: /dsh-dock/tokenlog HTTP route')
674
+ }))
675
+
676
+ return dispose
677
+ },
678
+ }