dsh-cost-meter 1.5.5

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.
@@ -0,0 +1,405 @@
1
+ /**
2
+ * 历史账本按模型回填:按模型统计(byProviderModel)上线之前的账本只有
3
+ * 每日/会话合计,没有 provider:model 拆分。本模块回放宿主会话日志
4
+ * ($DSH_HOME/sessions/<项目>/<会话>/session.jsonl[.zstd]),按与 costUsage
5
+ * 投影一致的逻辑逐次重建用量,并按事件时刻的档位(峰谷时代前按
6
+ * legacyBase 历史价)计算费用,回填到账本中 byProviderModel 为空的
7
+ * 日期与会话条目。
8
+ *
9
+ * 幂等:只填补空 byProviderModel 的日期/会话;已有记录的日期不改动,
10
+ * 避免与实时计费重复计数。会话日志是宿主的只读数据,本模块从不写入。
11
+ */
12
+
13
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import * as zlib from 'node:zlib'
16
+ import { costOf, providerPriceEntryFor } from './pricing.js'
17
+ import { localDayKey } from './store.js'
18
+
19
+ const ZSTD_MAGIC = 4247762216
20
+ /** 打包行(文本/推理/工具调用增量游程)不含 header 与 usage,回放时跳过。 */
21
+ const PACKED_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
22
+
23
+ /**
24
+ * 结构化扫描拼接的 Zstandard frame 边界(不解压块内容),与宿主
25
+ * dsh-session-persistence-jsonl 的容器格式一致:每个追加批次一个独立
26
+ * 带校验和的 frame。残缺尾帧(崩溃截断)直接忽略。
27
+ * @param buffer - 会话日志原始字节。
28
+ * @returns 完整 frame 的字节区间数组。
29
+ */
30
+ export function scanZstdFrames(buffer) {
31
+ const frames = []
32
+ let offset = 0
33
+ while (offset < buffer.length) {
34
+ const start = offset
35
+ if (buffer.length - offset < 4) return frames
36
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return frames
37
+ offset += 4
38
+ if (offset === buffer.length) return frames
39
+ const descriptor = buffer.readUInt8(offset)
40
+ offset += 1
41
+ if ((descriptor & 24) !== 0) return frames // 保留位:结构非法,停止扫描
42
+ const contentSizeFlag = descriptor >>> 6
43
+ const singleSegment = (descriptor & 32) !== 0
44
+ const checksum = (descriptor & 4) !== 0
45
+ const dictionaryFlag = descriptor & 3
46
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
47
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
48
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
49
+ if (buffer.length - offset < remainingHeaderBytes) return frames
50
+ offset += remainingHeaderBytes
51
+ for (;;) {
52
+ if (buffer.length - offset < 3) return frames
53
+ const blockHeader = buffer.readUIntLE(offset, 3)
54
+ offset += 3
55
+ const lastBlock = (blockHeader & 1) !== 0
56
+ const blockType = (blockHeader >>> 1) & 3
57
+ const blockSize = blockHeader >>> 3
58
+ if (blockType === 3) return frames // 保留块类型:结构非法
59
+ const payloadBytes = blockType === 1 ? 1 : blockSize
60
+ if (buffer.length - offset < payloadBytes) return frames
61
+ offset += payloadBytes
62
+ if (lastBlock) break
63
+ }
64
+ if (checksum) {
65
+ if (buffer.length - offset < 4) return frames
66
+ offset += 4
67
+ }
68
+ frames.push({ start, end: offset })
69
+ }
70
+ return frames
71
+ }
72
+
73
+ /**
74
+ * 读取一份会话日志的全部事件行(zstd 逐 frame 解压;明文直接按行)。
75
+ * @param path - session.jsonl.zstd 或 session.jsonl 路径。
76
+ * @returns 逐行 JSON.parse 后的记录数组(坏行跳过)。
77
+ */
78
+ export function readSessionRecords(path) {
79
+ const buffer = readFileSync(path)
80
+ let text
81
+ if (path.endsWith('.zstd')) {
82
+ if (typeof zlib.zstdDecompressSync !== 'function') return []
83
+ const frames = scanZstdFrames(buffer)
84
+ if (frames.length === 0) return []
85
+ const parts = frames.map(f => zlib.zstdDecompressSync(buffer.subarray(f.start, f.end)))
86
+ text = Buffer.concat(parts).toString('utf8')
87
+ } else {
88
+ text = buffer.toString('utf8')
89
+ }
90
+ const records = []
91
+ for (const line of text.split('\n')) {
92
+ if (line.length === 0) continue
93
+ try {
94
+ records.push(JSON.parse(line))
95
+ } catch {
96
+ // 坏行跳过:回放是尽力而为,不让单行损坏阻断整个会话。
97
+ }
98
+ }
99
+ return records
100
+ }
101
+
102
+ /** 枚举会话根目录下全部会话日志路径(<root>/<项目>/<会话>/session.jsonl[.zstd])。 */
103
+ export function listSessionLogs(root) {
104
+ const paths = []
105
+ let projects
106
+ try {
107
+ projects = readdirSync(root, { withFileTypes: true })
108
+ } catch {
109
+ return paths
110
+ }
111
+ for (const project of projects) {
112
+ if (!project.isDirectory()) continue
113
+ let sessions
114
+ try {
115
+ sessions = readdirSync(join(root, project.name), { withFileTypes: true })
116
+ } catch {
117
+ continue
118
+ }
119
+ for (const session of sessions) {
120
+ if (!session.isDirectory()) continue
121
+ for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
122
+ const path = join(root, project.name, session.name, name)
123
+ try {
124
+ if (statSync(path).isFile()) {
125
+ paths.push(path)
126
+ break // 同一会话两种编码互斥,取先命中者
127
+ }
128
+ } catch {
129
+ // 不存在:继续尝试另一后缀。
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return paths
135
+ }
136
+
137
+ /**
138
+ * 回放单个会话的事件流,重建逐次用量。与 costUsage 投影同规则:
139
+ * request/header 切换当前 provider/model;usage 块按 (turn, step) 去重,
140
+ * 同键最终样本替换流式样本(先减后加);按事件时刻计价。
141
+ * @param records - readSessionRecords 的输出。
142
+ * @param config - 账本配置(prices / peak* / priceMatch / priceOverrides)。
143
+ * @param wantDates - 只统计这些日期键(YYYY-MM-DD)内的调用;null = 全部。
144
+ * @returns { sessionId, days: { date: { providerKey: 桶 } } }。
145
+ */
146
+ export function replaySessionRecords(records, config, wantDates = null) {
147
+ const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 })
148
+ const num = value => {
149
+ const n = Number(value)
150
+ return Number.isFinite(n) && n > 0 ? n : 0
151
+ }
152
+ let sessionId = ''
153
+ let provider = 'deepseek'
154
+ let model = 'default'
155
+ let last = null
156
+ const days = {}
157
+ const shift = (sample, sign) => {
158
+ const dayMap = days[sample.date] ?? (days[sample.date] = {})
159
+ const current = dayMap[sample.providerKey] ?? zeroBuckets()
160
+ dayMap[sample.providerKey] = {
161
+ input: current.input + sign * sample.buckets.input,
162
+ output: current.output + sign * sample.buckets.output,
163
+ cacheRead: current.cacheRead + sign * sample.buckets.cacheRead,
164
+ cacheWrite: current.cacheWrite + sign * sample.buckets.cacheWrite,
165
+ reasoning: current.reasoning + sign * sample.buckets.reasoning,
166
+ calls: current.calls + sign,
167
+ cost: current.cost + sign * sample.cost,
168
+ }
169
+ }
170
+ for (const event of records) {
171
+ if (event === null || typeof event !== 'object') continue
172
+ if (event.type === 'session' && typeof event.id === 'string') {
173
+ sessionId = event.id
174
+ continue
175
+ }
176
+ if (PACKED_ROW_TYPES.has(event.type)) continue
177
+ if (event.type === 'request/header') {
178
+ const nextModel = event.data?.header?.config?.model
179
+ const nextProvider = event.data?.header?.config?.provider
180
+ model = typeof nextModel === 'string' && nextModel.length > 0 ? nextModel : 'default'
181
+ provider = typeof nextProvider === 'string' && nextProvider.length > 0 ? nextProvider : 'deepseek'
182
+ continue
183
+ }
184
+ let usage = null
185
+ let turn = 0
186
+ let step = 0
187
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage !== undefined) {
188
+ usage = event.data.chunk.usage
189
+ turn = event.data.turn
190
+ step = event.data.step
191
+ } else if (event.type === 'assistant/message' && event.data?.usage !== undefined) {
192
+ usage = event.data.usage
193
+ turn = event.data.turn
194
+ step = event.data.step
195
+ } else {
196
+ continue
197
+ }
198
+ const atMs = Number(event.time)
199
+ if (!Number.isFinite(atMs) || atMs <= 0) continue
200
+ const date = localDayKey(atMs)
201
+ if (wantDates !== null && !wantDates.has(date)) continue
202
+ const buckets = {
203
+ input: num(usage.inputTokens),
204
+ output: num(usage.outputTokens),
205
+ cacheRead: num(usage.cacheReadTokens),
206
+ cacheWrite: num(usage.cacheWriteTokens),
207
+ reasoning: num(usage.reasoningTokens),
208
+ }
209
+ const key = `${turn}:${step}`
210
+ const prev = last !== null && last.key === key ? last : null
211
+ if (prev !== null && prev.providerKey === `${provider}:${model}`
212
+ && prev.buckets.input === buckets.input && prev.buckets.output === buckets.output
213
+ && prev.buckets.cacheRead === buckets.cacheRead && prev.buckets.cacheWrite === buckets.cacheWrite
214
+ && prev.buckets.reasoning === buckets.reasoning) {
215
+ continue
216
+ }
217
+ // 按事件时刻计费(历史正确):峰谷时代前用 legacyBase,之后按峰谷两档。
218
+ const resolved = providerPriceEntryFor(provider, model, config?.prices, {
219
+ mode: config?.priceMatch === 'exact' ? 'exact' : 'auto',
220
+ overrides: config?.priceOverrides,
221
+ })
222
+ const peak = {
223
+ enabled: resolved.billingMode === 'deepseek-peak' && config?.peakEnabled === true,
224
+ effectiveAtMs: Date.parse(config?.peakEffectiveAt ?? ''),
225
+ windows: config?.peakWindows,
226
+ }
227
+ const cost = resolved.priced ? costOf(buckets, resolved.entry, atMs, peak) : 0
228
+ const providerKey = `${provider}:${model}`
229
+ if (prev !== null) shift(prev, -1)
230
+ const sample = { key, date, providerKey, buckets, cost }
231
+ shift(sample, 1)
232
+ last = sample
233
+ }
234
+ return { sessionId, days }
235
+ }
236
+
237
+ /**
238
+ * 扫描会话日志并回填账本中缺失的按模型统计。
239
+ * - 日期级:byProviderModel 为空且 calls > 0 的日期,整体写入回放聚合;
240
+ * - 会话级:byProviderModel 为空且 calls > 0 的会话条目,按会话 id + 日期
241
+ * 写入该会话当日的回放拆分。
242
+ * @param ledger - 已打开的账本。
243
+ * @param sessionsRoot - 宿主会话根目录($DSH_HOME/sessions)。
244
+ * @returns { days, sessions, scanned } 实际填补的日期/会话数与扫描文件数。
245
+ */
246
+ export async function backfillLegacyLedger(ledger, sessionsRoot) {
247
+ const result = { days: 0, sessions: 0, scanned: 0 }
248
+ const needDates = new Set()
249
+ for (const [date, day] of Object.entries(ledger.days ?? {})) {
250
+ if ((day?.calls ?? 0) > 0 && Object.keys(day?.byProviderModel ?? {}).length === 0) needDates.add(date)
251
+ }
252
+ // 日期级不缺的,会话级可能仍缺(补记录上线后当天更早的会话段)。
253
+ let needSessionLevel = false
254
+ for (const day of Object.values(ledger.days ?? {})) {
255
+ for (const session of day?.sessions ?? []) {
256
+ if ((session?.calls ?? 0) > 0 && Object.keys(session?.byProviderModel ?? {}).length === 0) {
257
+ needSessionLevel = true
258
+ const dates = collectSessionDates(ledger, session.id)
259
+ for (const date of dates) needDates.add(date)
260
+ }
261
+ }
262
+ }
263
+ if (needDates.size === 0) return result
264
+ const bySession = new Map()
265
+ let scannedCount = 0
266
+ for (const path of listSessionLogs(sessionsRoot)) {
267
+ // 会话日志多时逐份解压会长时间占住事件循环:每 8 份让出一次,不卡宿主 UI。
268
+ if ((scannedCount += 1) % 8 === 0) await new Promise(resolve => setImmediate(resolve))
269
+ result.scanned += 1
270
+ let replayed
271
+ try {
272
+ replayed = replaySessionRecords(readSessionRecords(path), ledger.config, needDates)
273
+ } catch {
274
+ continue // 单文件损坏不阻断整体回填。
275
+ }
276
+ if (replayed.sessionId.length === 0) continue
277
+ const existing = bySession.get(replayed.sessionId)
278
+ if (existing === undefined) bySession.set(replayed.sessionId, replayed.days)
279
+ else mergeDayMaps(existing, replayed.days)
280
+ }
281
+ for (const [date, day] of Object.entries(ledger.days ?? {})) {
282
+ const dayIsEmpty = (day?.calls ?? 0) > 0 && Object.keys(day?.byProviderModel ?? {}).length === 0
283
+ // 日期级聚合:跨全部会话汇总当日拆分(仅在日期级为空时写入)。
284
+ if (dayIsEmpty) {
285
+ const aggregate = {}
286
+ for (const days of bySession.values()) {
287
+ const pm = days[date]
288
+ if (pm === undefined) continue
289
+ mergeBucketsInto(aggregate, pm)
290
+ }
291
+ const replayed = Object.values(aggregate).reduce((acc, b) => {
292
+ acc.input += b.input ?? 0
293
+ acc.output += b.output ?? 0
294
+ acc.cacheRead += b.cacheRead ?? 0
295
+ acc.cacheWrite += b.cacheWrite ?? 0
296
+ acc.reasoning += b.reasoning ?? 0
297
+ acc.calls += b.calls ?? 0
298
+ acc.cost += b.cost ?? 0
299
+ return acc
300
+ }, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 })
301
+ // 回放完整覆盖当日全部调用与 token 时,按回放结果重算当日总额(issue #18):
302
+ // 旧版本曾把订阅制模型模糊匹配到同家族付费价实时误计费,回放按事件时刻
303
+ // 正确计价,重算可修正历史虚高;仅部分覆盖时保留原始记录,差额入 legacy 行。
304
+ if (replayed.calls === (day.calls ?? 0)
305
+ && replayed.input === (day.input ?? 0) && replayed.output === (day.output ?? 0)
306
+ && replayed.cacheRead === (day.cacheRead ?? 0) && replayed.cacheWrite === (day.cacheWrite ?? 0)) {
307
+ day.cost = replayed.cost
308
+ result.recosted = (result.recosted ?? 0) + 1
309
+ }
310
+ // 会话日志已被清理等无法回放的调用:用账本合计与回放结果的差额
311
+ // 归入 deepseek:legacy 行(客户端有专门文案),保证按模型合计与总量对齐。
312
+ if (replayed.calls < (day.calls ?? 0)) {
313
+ aggregate['deepseek:legacy'] = {
314
+ input: Math.max(0, (day.input ?? 0) - replayed.input),
315
+ output: Math.max(0, (day.output ?? 0) - replayed.output),
316
+ cacheRead: Math.max(0, (day.cacheRead ?? 0) - replayed.cacheRead),
317
+ cacheWrite: Math.max(0, (day.cacheWrite ?? 0) - replayed.cacheWrite),
318
+ reasoning: Math.max(0, (day.reasoning ?? 0) - replayed.reasoning),
319
+ calls: (day.calls ?? 0) - replayed.calls,
320
+ cost: Math.max(0, (day.cost ?? 0) - replayed.cost),
321
+ }
322
+ }
323
+ if (Object.keys(aggregate).length > 0) {
324
+ day.byProviderModel = aggregate
325
+ result.days += 1
326
+ }
327
+ }
328
+ // 会话级:按会话 id + 日期定向填补空条目;完整覆盖时同步重算会话金额。
329
+ for (const session of day?.sessions ?? []) {
330
+ if ((session?.calls ?? 0) <= 0) continue
331
+ const pm = bySession.get(session.id)?.[date]
332
+ if (pm === undefined || Object.keys(pm).length === 0) continue
333
+ if (Object.keys(session.byProviderModel ?? {}).length === 0) {
334
+ session.byProviderModel = cloneDayMap(pm)
335
+ result.sessions += 1
336
+ }
337
+ const sTotals = Object.values(pm).reduce((acc, b) => {
338
+ acc.input += b.input ?? 0
339
+ acc.output += b.output ?? 0
340
+ acc.cacheRead += b.cacheRead ?? 0
341
+ acc.cacheWrite += b.cacheWrite ?? 0
342
+ acc.calls += b.calls ?? 0
343
+ acc.cost += b.cost ?? 0
344
+ return acc
345
+ }, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0, cost: 0 })
346
+ if (sTotals.calls === (session.calls ?? 0)
347
+ && dayIsEmpty
348
+ && sTotals.input === (session.input ?? 0) && sTotals.output === (session.output ?? 0)
349
+ && sTotals.cacheRead === (session.cacheRead ?? 0) && sTotals.cacheWrite === (session.cacheWrite ?? 0)) {
350
+ session.cost = sTotals.cost
351
+ }
352
+ }
353
+ }
354
+ if (result.days > 0 || result.sessions > 0) ledger.scheduleWrite()
355
+ return result
356
+ }
357
+
358
+ /** 找到某会话在账本中出现过的全部日期(会话可跨天,每日一行)。 */
359
+ function collectSessionDates(ledger, sessionId) {
360
+ const dates = new Set()
361
+ for (const [date, day] of Object.entries(ledger.days ?? {})) {
362
+ if ((day?.sessions ?? []).some(s => s?.id === sessionId)) dates.add(date)
363
+ }
364
+ return dates
365
+ }
366
+
367
+ /** 深合并回放结果(同会话出现在多个文件时)。 */
368
+ function mergeDayMaps(target, source) {
369
+ for (const [date, pm] of Object.entries(source)) {
370
+ const current = target[date]
371
+ if (current === undefined) target[date] = cloneDayMap(pm)
372
+ else mergeBucketsInto(current, pm)
373
+ }
374
+ }
375
+
376
+ function mergeBucketsInto(target, source) {
377
+ for (const [key, b] of Object.entries(source)) {
378
+ const current = target[key] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 }
379
+ target[key] = {
380
+ input: current.input + (b.input ?? 0),
381
+ output: current.output + (b.output ?? 0),
382
+ cacheRead: current.cacheRead + (b.cacheRead ?? 0),
383
+ cacheWrite: current.cacheWrite + (b.cacheWrite ?? 0),
384
+ reasoning: current.reasoning + (b.reasoning ?? 0),
385
+ calls: current.calls + (b.calls ?? 0),
386
+ cost: current.cost + (b.cost ?? 0),
387
+ }
388
+ }
389
+ }
390
+
391
+ function cloneDayMap(pm) {
392
+ const out = {}
393
+ for (const [key, b] of Object.entries(pm)) {
394
+ out[key] = {
395
+ input: b.input ?? 0,
396
+ output: b.output ?? 0,
397
+ cacheRead: b.cacheRead ?? 0,
398
+ cacheWrite: b.cacheWrite ?? 0,
399
+ reasoning: b.reasoning ?? 0,
400
+ calls: b.calls ?? 0,
401
+ cost: b.cost ?? 0,
402
+ }
403
+ }
404
+ return out
405
+ }