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.
- package/LICENSE +21 -0
- package/README.en.md +316 -0
- package/README.md +320 -0
- package/cordis.patch.yml +6 -0
- package/docs/provider-pricing.json +777 -0
- package/lib/backfill.js +405 -0
- package/lib/client.js +4116 -0
- package/lib/coding-plans.js +346 -0
- package/lib/custom-balance.js +147 -0
- package/lib/index.js +975 -0
- package/lib/pricing.js +799 -0
- package/lib/store.js +990 -0
- package/lib/typert.host.js +402 -0
- package/package.json +76 -0
package/lib/backfill.js
ADDED
|
@@ -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
|
+
}
|