dsh-dock 0.9.6 → 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.
- package/LICENSE +18 -18
- package/README.md +325 -325
- package/client.js +42 -8
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +660 -660
- package/features/animation/view.jsx +2247 -2247
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -472
- package/features/mobile-relay/host.js +570 -569
- package/features/mobile-relay/view.jsx +300 -300
- package/features/modelconfig/host.js +403 -403
- package/features/modelconfig/view.js +490 -490
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +678 -678
- package/features/tokenlog/view.jsx +561 -561
- package/features/visionproxy/host.js +315 -315
- package/index.js +175 -118
- package/package.json +59 -59
- package/src/client.jsx +668 -668
- package/src/host-core.js +98 -95
- 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
|
+
}
|