@chenmiao8563/dsh-token-ledger 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ledger.js ADDED
@@ -0,0 +1,653 @@
1
+ /**
2
+ * The token ledger: a deterministic fold over DSH session events.
3
+ *
4
+ * This module is intentionally pure — no Cordis context, no filesystem, no
5
+ * clock beyond the timestamps already carried by events. The same event
6
+ * sequence always produces the same ledger, which is what makes the ledger
7
+ * auditable: the CLI can recompute it from raw logs and diff the result
8
+ * against whatever the running plugin persisted.
9
+ *
10
+ * ## Which events carry usage
11
+ *
12
+ * | Event | Payload | Meaning |
13
+ * | -------------------- | ------------------------------ | ---------------------------------------- |
14
+ * | `assistant/message` | `data.usage`, plus turn/step | authoritative usage of a completed step |
15
+ * | `assistant/chunk` | `data.chunk.usage` when the | streaming sample; only a candidate |
16
+ * | | chunk type is `usage` | |
17
+ * | `compaction/summary` | `data.usage` | one provider call for compaction |
18
+ * | `request/header` | `data.header.config` | provider/model attribution |
19
+ *
20
+ * ## Counting rules
21
+ *
22
+ * 1. **Successful anchors only.** A failed or cancelled model attempt never
23
+ * appends `assistant/message`, so it is never counted.
24
+ * 2. **Replace, do not add.** When a step emits a `usage` chunk and then its
25
+ * final `assistant/message`, the final value replaces the earlier sample.
26
+ * 3. **Inherited history is not recounted.** A forked or resumed session
27
+ * carries its parent's prefix; folding starts at the inherited cut.
28
+ * 4. **`totalTokens` is the sum of the four buckets.** It is derived rather
29
+ * than read from the provider's own `totalTokens`, because the provider
30
+ * field is exactly `input + output + cacheRead + cacheWrite` and deriving it
31
+ * keeps the buckets and the total consistent by construction.
32
+ * 5. **Reasoning tokens are a subset of output.** They are reported separately
33
+ * for interest and are never added into `totalTokens`.
34
+ * 6. **Days are local days.** Users read their own calendar, not UTC.
35
+ *
36
+ * @module dsh-token-ledger/ledger
37
+ */
38
+
39
+ /** Bumped whenever the folded state shape changes, so stale files are ignored. */
40
+ export const LEDGER_VERSION = 1
41
+
42
+ /** The four disjoint provider usage buckets, all defaulting to zero. */
43
+ const BUCKET_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
44
+
45
+ /**
46
+ * Coerce a provider-reported count into a non-negative integer.
47
+ *
48
+ * @param {unknown} value - the raw field.
49
+ * @returns {number} a safe integer, or 0 when the value is unusable.
50
+ */
51
+ function toCount(value) {
52
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 0
53
+ return Math.floor(value)
54
+ }
55
+
56
+ /**
57
+ * A fresh, zeroed bucket set.
58
+ *
59
+ * @returns {{ inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, totalTokens: number, reasoningTokens: number }}
60
+ */
61
+ export function emptyCounters() {
62
+ return {
63
+ inputTokens: 0,
64
+ outputTokens: 0,
65
+ cacheReadTokens: 0,
66
+ cacheWriteTokens: 0,
67
+ totalTokens: 0,
68
+ reasoningTokens: 0,
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Convert one provider `usage` object into ledger counters.
74
+ *
75
+ * @param {unknown} usage - a provider usage report.
76
+ * @returns {ReturnType<typeof emptyCounters> | undefined} `undefined` when the
77
+ * report carries no usable counts, so callers can tell "no usage" apart from
78
+ * "zero usage".
79
+ */
80
+ export function countersFromUsage(usage) {
81
+ if (usage === null || typeof usage !== 'object') return undefined
82
+ const counters = {
83
+ inputTokens: toCount(usage.inputTokens),
84
+ outputTokens: toCount(usage.outputTokens),
85
+ cacheReadTokens: toCount(usage.cacheReadTokens),
86
+ cacheWriteTokens: toCount(usage.cacheWriteTokens),
87
+ totalTokens: 0,
88
+ reasoningTokens: toCount(usage.reasoningTokens),
89
+ }
90
+ counters.totalTokens =
91
+ counters.inputTokens + counters.outputTokens + counters.cacheReadTokens + counters.cacheWriteTokens
92
+ if (counters.totalTokens === 0) return undefined
93
+ return counters
94
+ }
95
+
96
+ /**
97
+ * Sum two counter sets into a new object.
98
+ *
99
+ * @param {ReturnType<typeof emptyCounters>} left - the accumulator.
100
+ * @param {ReturnType<typeof emptyCounters>} right - the addend.
101
+ * @returns {ReturnType<typeof emptyCounters>} the elementwise sum.
102
+ */
103
+ export function addCounters(left, right) {
104
+ const out = {}
105
+ for (const key of [...BUCKET_KEYS, 'totalTokens', 'reasoningTokens']) out[key] = left[key] + right[key]
106
+ return out
107
+ }
108
+
109
+ /**
110
+ * Add counters in place.
111
+ *
112
+ * @param {ReturnType<typeof emptyCounters>} target - mutated accumulator.
113
+ * @param {ReturnType<typeof emptyCounters>} delta - the addend.
114
+ * @returns {void}
115
+ */
116
+ function addInto(target, delta) {
117
+ for (const key of [...BUCKET_KEYS, 'totalTokens', 'reasoningTokens']) target[key] += delta[key]
118
+ }
119
+
120
+ /**
121
+ * Subtract counters in place. Used when a later sample replaces an earlier one.
122
+ *
123
+ * @param {ReturnType<typeof emptyCounters>} target - mutated accumulator.
124
+ * @param {ReturnType<typeof emptyCounters>} delta - the subtrahend.
125
+ * @returns {void}
126
+ */
127
+ function subInto(target, delta) {
128
+ for (const key of [...BUCKET_KEYS, 'totalTokens', 'reasoningTokens']) target[key] -= delta[key]
129
+ }
130
+
131
+ /**
132
+ * Compare the four buckets, ignoring the derived total and reasoning subset.
133
+ *
134
+ * @param {ReturnType<typeof emptyCounters>} a - left counters.
135
+ * @param {ReturnType<typeof emptyCounters>} b - right counters.
136
+ * @returns {boolean} true when both describe the same provider usage.
137
+ */
138
+ function sameBuckets(a, b) {
139
+ return BUCKET_KEYS.every((key) => a[key] === b[key])
140
+ }
141
+
142
+ function pad2(value) {
143
+ return value < 10 ? `0${value}` : String(value)
144
+ }
145
+
146
+ /**
147
+ * Format an event timestamp as a local calendar day.
148
+ *
149
+ * @param {unknown} timeMs - event time in milliseconds since the epoch.
150
+ * @param {Date} [now] - clock fallback for events without a usable time.
151
+ * @returns {string} `YYYY-MM-DD`.
152
+ */
153
+ export function dateKeyOf(timeMs, now = new Date()) {
154
+ const date = new Date(typeof timeMs === 'number' && Number.isFinite(timeMs) && timeMs > 0 ? timeMs : now.getTime())
155
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
156
+ }
157
+
158
+ /**
159
+ * Decide how much of a session's stored history is somebody else's.
160
+ *
161
+ * A session log can carry a prefix of already-recorded history. Two very
162
+ * different situations produce one, and only one of them must be cut:
163
+ *
164
+ * - **Fork** (`header.parentSession` is set): the prefix is the *parent
165
+ * session's* history, and the parent is counted separately. Folding the
166
+ * prefix here would count those tokens twice, so it is cut at
167
+ * `inheritedEventCount`.
168
+ * - **Resume** (no parent): the prefix is *this session's own* earlier history,
169
+ * stored once, with no other session to double count it against. Cutting it
170
+ * would lose tokens, so nothing is cut.
171
+ *
172
+ * The distinction was established against real logs, not inferred: for every
173
+ * forked session whose parent log was still on disk, the usage fingerprints
174
+ * found before the `session/end-seed` marker were a subset of the parent's,
175
+ * while for the non-forked logs carrying the same marker the prefix did not
176
+ * reappear later in the file.
177
+ *
178
+ * @param {object} [header] - a `SessionHeader`-shaped object.
179
+ * @param {number} [inheritedEventCount] - the cut storage reports for this session.
180
+ * @returns {number} the number of leading events to skip.
181
+ */
182
+ export function inheritedCut(header, inheritedEventCount) {
183
+ const isFork = header?.parentSession !== undefined && header?.parentSession !== null
184
+ if (!isFork) return 0
185
+ return typeof inheritedEventCount === 'number' && inheritedEventCount > 0 ? inheritedEventCount : 0
186
+ }
187
+
188
+ /**
189
+ * Fold a sequence of DSH session events into cumulative token usage.
190
+ *
191
+ * The ledger is incremental and restart-safe: it keeps a per-session cursor
192
+ * over consumed events, and `snapshot()`/`restore()` round-trip that cursor so
193
+ * a restarted process resumes instead of recounting history.
194
+ */
195
+ export class UsageLedger {
196
+ constructor() {
197
+ this.reset()
198
+ }
199
+
200
+ /** Drop all state. */
201
+ reset() {
202
+ /** @type {ReturnType<typeof emptyCounters>} */
203
+ this.totals = emptyCounters()
204
+ /** Number of committed provider calls. */
205
+ this.calls = 0
206
+ /** @type {Map<string, { date: string, counters: ReturnType<typeof emptyCounters>, calls: number }>} */
207
+ this.daily = new Map()
208
+ /** @type {Map<string, { model: string, counters: ReturnType<typeof emptyCounters>, calls: number }>} */
209
+ this.models = new Map()
210
+ /** @type {Map<string, { sessionId: string, counters: ReturnType<typeof emptyCounters>, calls: number, firstAt: number|null, lastAt: number|null }>} */
211
+ this.sessions = new Map()
212
+ /** @type {Map<string, number>} next unconsumed event index, per session */
213
+ this.cursors = new Map()
214
+ /** @type {Map<string, { turn: unknown, step: unknown, counters: ReturnType<typeof emptyCounters> }>} */
215
+ this.pending = new Map()
216
+ /** @type {Map<string, { key: string, counters: ReturnType<typeof emptyCounters>, dateKey: string, modelKey: string }>} */
217
+ this.last = new Map()
218
+ /** @type {Map<string, { provider: string, model: string }>} */
219
+ this.routes = new Map()
220
+ this.compactionSeq = 0
221
+ }
222
+
223
+ /**
224
+ * The provider/model currently attributed to a session.
225
+ *
226
+ * @param {string} sessionId - the session id.
227
+ * @returns {{ provider: string, model: string }} the last observed route.
228
+ */
229
+ routeOf(sessionId) {
230
+ return this.routes.get(sessionId) ?? { provider: 'unknown', model: 'unknown' }
231
+ }
232
+
233
+ sessionRecord(sessionId) {
234
+ let record = this.sessions.get(sessionId)
235
+ if (record === undefined) {
236
+ record = { sessionId, counters: emptyCounters(), calls: 0, firstAt: null, lastAt: null }
237
+ this.sessions.set(sessionId, record)
238
+ }
239
+ return record
240
+ }
241
+
242
+ dailyRecord(dateKey) {
243
+ let record = this.daily.get(dateKey)
244
+ if (record === undefined) {
245
+ record = { date: dateKey, counters: emptyCounters(), calls: 0 }
246
+ this.daily.set(dateKey, record)
247
+ }
248
+ return record
249
+ }
250
+
251
+ modelRecord(modelKey) {
252
+ let record = this.models.get(modelKey)
253
+ if (record === undefined) {
254
+ record = { model: modelKey, counters: emptyCounters(), calls: 0 }
255
+ this.models.set(modelKey, record)
256
+ }
257
+ return record
258
+ }
259
+
260
+ /**
261
+ * Commit one provider call into every aggregate.
262
+ *
263
+ * When `replacementKey` equals the previous commit's key for this session
264
+ * (same turn and step), the earlier value is subtracted first, so a streamed
265
+ * sample followed by its final message counts once.
266
+ *
267
+ * @param {string} sessionId - the owning session.
268
+ * @param {object} input - the commit.
269
+ * @param {ReturnType<typeof emptyCounters>} input.counters - the usage.
270
+ * @param {unknown} input.time - the event timestamp.
271
+ * @param {string|null} input.provider - explicit provider override.
272
+ * @param {string|null} input.model - explicit model override.
273
+ * @param {string} input.replacementKey - identity of the attempt.
274
+ * @returns {void}
275
+ */
276
+ commit(sessionId, { counters, time, provider = null, model = null, replacementKey }) {
277
+ const dateKey = dateKeyOf(time)
278
+ const session = this.sessionRecord(sessionId)
279
+ const previous = this.last.get(sessionId)
280
+
281
+ if (previous !== undefined && previous.key === replacementKey) {
282
+ if (sameBuckets(previous.counters, counters)) return
283
+ subInto(this.totals, previous.counters)
284
+ subInto(session.counters, previous.counters)
285
+ subInto(this.dailyRecord(previous.dateKey).counters, previous.counters)
286
+ subInto(this.modelRecord(previous.modelKey).counters, previous.counters)
287
+ this.calls -= 1
288
+ session.calls -= 1
289
+ this.dailyRecord(previous.dateKey).calls -= 1
290
+ this.modelRecord(previous.modelKey).calls -= 1
291
+ }
292
+
293
+ const route = this.routeOf(sessionId)
294
+ const modelKey = `${provider ?? route.provider}/${model ?? route.model}`
295
+
296
+ addInto(this.totals, counters)
297
+ addInto(session.counters, counters)
298
+ addInto(this.dailyRecord(dateKey).counters, counters)
299
+ addInto(this.modelRecord(modelKey).counters, counters)
300
+ this.calls += 1
301
+ session.calls += 1
302
+ this.dailyRecord(dateKey).calls += 1
303
+ this.modelRecord(modelKey).calls += 1
304
+
305
+ const at = typeof time === 'number' && Number.isFinite(time) ? time : null
306
+ if (at !== null) {
307
+ if (session.firstAt === null || at < session.firstAt) session.firstAt = at
308
+ if (session.lastAt === null || at > session.lastAt) session.lastAt = at
309
+ }
310
+
311
+ this.last.set(sessionId, { key: replacementKey, counters, dateKey, modelKey })
312
+ }
313
+
314
+ /**
315
+ * Fold a single session event.
316
+ *
317
+ * Events the ledger does not care about return immediately, so hooking this
318
+ * to every session event stays cheap.
319
+ *
320
+ * @param {string} sessionId - the owning session.
321
+ * @param {object} event - one committed `SessionEvent`.
322
+ * @returns {void}
323
+ */
324
+ consume(sessionId, event) {
325
+ if (event === null || typeof event !== 'object') return
326
+ const data = event.data ?? {}
327
+
328
+ switch (event.type) {
329
+ case 'request/header': {
330
+ const config = data.header?.config
331
+ if (config !== undefined && config !== null) this.setRoute(sessionId, config)
332
+ return
333
+ }
334
+
335
+ case 'compaction/summary': {
336
+ const counters = countersFromUsage(data.usage)
337
+ if (counters === undefined) return
338
+ this.compactionSeq += 1
339
+ this.commit(sessionId, {
340
+ counters,
341
+ time: event.time,
342
+ replacementKey: `compaction#${this.compactionSeq}`,
343
+ })
344
+ return
345
+ }
346
+
347
+ case 'assistant/chunk': {
348
+ const chunk = data.chunk
349
+ if (chunk === null || typeof chunk !== 'object' || chunk.type !== 'usage') return
350
+ const counters = countersFromUsage(chunk.usage)
351
+ if (counters === undefined) return
352
+ this.pending.set(sessionId, { turn: data.turn, step: data.step, counters })
353
+ return
354
+ }
355
+
356
+ case 'assistant/message': {
357
+ const { turn, step } = data
358
+ const pending = this.pending.get(sessionId)
359
+ const matching =
360
+ pending !== undefined && pending.turn === turn && pending.step === step ? pending : undefined
361
+ const counters = countersFromUsage(data.usage) ?? matching?.counters
362
+ if (counters === undefined) return
363
+ this.pending.delete(sessionId)
364
+ this.commit(sessionId, {
365
+ counters,
366
+ time: event.time,
367
+ replacementKey: `attempt#${turn}#${step}`,
368
+ })
369
+ return
370
+ }
371
+
372
+ case 'step/end':
373
+ case 'turn/end': {
374
+ this.pending.delete(sessionId)
375
+ return
376
+ }
377
+
378
+ default:
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Record the provider/model a session's calls should be attributed to.
384
+ *
385
+ * @param {string} sessionId - the session id.
386
+ * @param {object} config - a `request/header` call configuration.
387
+ * @returns {void}
388
+ */
389
+ setRoute(sessionId, config) {
390
+ this.routes.set(sessionId, {
391
+ provider: typeof config.provider === 'string' ? config.provider : 'unknown',
392
+ model: typeof config.model === 'string' ? config.model : 'unknown',
393
+ })
394
+ }
395
+
396
+ /**
397
+ * Seed a session's route from a skipped prefix.
398
+ *
399
+ * Only `request/header` is inspected; no usage is folded, so an inherited
400
+ * prefix can never contribute tokens.
401
+ *
402
+ * @param {string} sessionId - the session id.
403
+ * @param {object[]} events - the session's full event list.
404
+ * @param {number} cut - length of the prefix to skip.
405
+ * @returns {void}
406
+ */
407
+ primeRoute(sessionId, events, cut) {
408
+ for (let index = 0; index < cut; index += 1) {
409
+ const event = events[index]
410
+ if (event?.type !== 'request/header') continue
411
+ const config = event.data?.header?.config
412
+ if (config !== undefined && config !== null) this.setRoute(sessionId, config)
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Fold an offline history: an ordered event array plus the inherited cut.
418
+ *
419
+ * This is the path used for backfilling every stored session on startup, and
420
+ * by the CLI when it recomputes the ledger from raw logs.
421
+ *
422
+ * @param {object} history - the session's stored content.
423
+ * @param {string} history.sessionId - the session id.
424
+ * @param {object[]} history.events - every event, inherited prefix included.
425
+ * @param {number} [history.inheritedEventCount] - length of the inherited prefix.
426
+ * @returns {void}
427
+ */
428
+ adoptHistory({ sessionId, events, inheritedEventCount = 0 }) {
429
+ if (!Array.isArray(events)) return
430
+ const id = String(sessionId)
431
+ if (!this.cursors.has(id)) {
432
+ const cut = inheritedEventCount > 0 ? inheritedEventCount : 0
433
+ // A forked session's `request/header` usually lives in the inherited
434
+ // prefix we are about to skip. Read the route out of that prefix without
435
+ // counting any usage in it, or the fork's calls would be attributed to
436
+ // `unknown/unknown`.
437
+ if (cut > 0) this.primeRoute(id, events, cut)
438
+ this.cursors.set(id, cut)
439
+ }
440
+ let cursor = this.cursors.get(id)
441
+ while (cursor < events.length) {
442
+ const event = events[cursor]
443
+ if (event === undefined) break
444
+ this.consume(id, event)
445
+ cursor += 1
446
+ }
447
+ this.cursors.set(id, cursor)
448
+ }
449
+
450
+ /**
451
+ * Fold a live Cordis `Session` by reading only the events not yet consumed.
452
+ *
453
+ * Prefers the incremental `seq`/`eventAt` pair so that a session with a long
454
+ * log is not re-materialized on every appended event.
455
+ *
456
+ * @param {object} session - a live Session.
457
+ * @returns {void}
458
+ */
459
+ adoptSession(session) {
460
+ const id = String(session?.id ?? session?.sessionId ?? '')
461
+ if (id === '') return
462
+ if (!this.cursors.has(id)) {
463
+ const inherited = session.inheritedEventCount
464
+ const fromHeader = session.header?.seedLength ?? session.firstLiveSeq
465
+ const start = typeof inherited === 'number' && inherited > 0 ? inherited : typeof fromHeader === 'number' && fromHeader > 0 ? fromHeader : 0
466
+ this.cursors.set(id, start)
467
+ }
468
+
469
+ const total = typeof session.seq === 'number' ? session.seq : (session.events?.length ?? 0)
470
+ let cursor = this.cursors.get(id)
471
+ while (cursor < total) {
472
+ const event = typeof session.eventAt === 'function' ? session.eventAt(cursor) : session.events?.[cursor]
473
+ if (event === undefined) break
474
+ this.consume(id, event)
475
+ cursor += 1
476
+ }
477
+ this.cursors.set(id, cursor)
478
+ }
479
+
480
+ /**
481
+ * Export the ledger as plain JSON.
482
+ *
483
+ * Everything here is a primitive or a plain object, so the result is
484
+ * losslessly serializable and safe to hand to a browser half.
485
+ *
486
+ * @returns {object} the snapshot.
487
+ */
488
+ snapshot() {
489
+ const countersOf = (record) => ({ ...record.counters })
490
+ return {
491
+ version: LEDGER_VERSION,
492
+ updatedAt: Date.now(),
493
+ totals: { ...this.totals, calls: this.calls },
494
+ daily: [...this.daily.values()]
495
+ .sort((a, b) => a.date.localeCompare(b.date))
496
+ .map((day) => ({ date: day.date, calls: day.calls, ...countersOf(day) })),
497
+ models: [...this.models.values()]
498
+ .sort((a, b) => b.counters.totalTokens - a.counters.totalTokens)
499
+ .map((model) => ({ model: model.model, calls: model.calls, ...countersOf(model) })),
500
+ sessions: [...this.sessions.values()]
501
+ .sort((a, b) => b.counters.totalTokens - a.counters.totalTokens)
502
+ .map((session) => ({
503
+ sessionId: session.sessionId,
504
+ calls: session.calls,
505
+ firstAt: session.firstAt,
506
+ lastAt: session.lastAt,
507
+ ...countersOf(session),
508
+ })),
509
+ cursors: Object.fromEntries(this.cursors),
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Merge a snapshot back in. Returns false for missing or incompatible state,
515
+ * which callers treat as "start fresh" rather than as an error.
516
+ *
517
+ * @param {unknown} snapshot - a previously produced snapshot.
518
+ * @returns {boolean} whether the snapshot was usable.
519
+ */
520
+ restore(snapshot) {
521
+ if (snapshot === null || typeof snapshot !== 'object') return false
522
+ if (snapshot.version !== LEDGER_VERSION) return false
523
+ this.reset()
524
+
525
+ const absorb = (raw) => countersFromUsage(raw)
526
+ const totals = absorb(snapshot.totals)
527
+ if (totals !== undefined) addInto(this.totals, totals)
528
+ this.calls = toCount(snapshot.totals?.calls)
529
+
530
+ for (const day of snapshot.daily ?? []) {
531
+ const record = this.dailyRecord(String(day.date))
532
+ const counters = absorb(day)
533
+ if (counters !== undefined) addInto(record.counters, counters)
534
+ record.calls = toCount(day.calls)
535
+ }
536
+ for (const model of snapshot.models ?? []) {
537
+ const record = this.modelRecord(String(model.model ?? 'unknown/unknown'))
538
+ const counters = absorb(model)
539
+ if (counters !== undefined) addInto(record.counters, counters)
540
+ record.calls = toCount(model.calls)
541
+ }
542
+ for (const session of snapshot.sessions ?? []) {
543
+ const id = String(session.sessionId ?? '')
544
+ if (id === '') continue
545
+ const record = this.sessionRecord(id)
546
+ const counters = absorb(session)
547
+ if (counters !== undefined) addInto(record.counters, counters)
548
+ record.calls = toCount(session.calls)
549
+ record.firstAt = typeof session.firstAt === 'number' ? session.firstAt : null
550
+ record.lastAt = typeof session.lastAt === 'number' ? session.lastAt : null
551
+ }
552
+ for (const [id, cursor] of Object.entries(snapshot.cursors ?? {})) {
553
+ this.cursors.set(String(id), toCount(cursor))
554
+ }
555
+ return true
556
+ }
557
+
558
+ /**
559
+ * Render a human-readable summary.
560
+ *
561
+ * @param {object} [options] - rendering options.
562
+ * @param {number} [options.days] - how many recent days to list.
563
+ * @param {number} [options.models] - how many models to list.
564
+ * @returns {string} the summary text.
565
+ */
566
+ format({ days = 7, models = 5 } = {}) {
567
+ const n = (value) => value.toLocaleString('en-US')
568
+ const lines = [
569
+ 'Token ledger',
570
+ '',
571
+ ` calls ${n(this.calls)}`,
572
+ ` input (uncached) ${n(this.totals.inputTokens)}`,
573
+ ` cache read ${n(this.totals.cacheReadTokens)}`,
574
+ ` cache write ${n(this.totals.cacheWriteTokens)}`,
575
+ ` output ${n(this.totals.outputTokens)}`,
576
+ ` total ${n(this.totals.totalTokens)}`,
577
+ ]
578
+ if (this.totals.reasoningTokens > 0) {
579
+ lines.push(` of which reasoning ${n(this.totals.reasoningTokens)}`)
580
+ }
581
+ if (this.totals.cacheReadTokens > 0) {
582
+ const share = Math.round((this.totals.cacheReadTokens / this.totals.totalTokens) * 100)
583
+ lines.push(` cache hit share ${share}%`)
584
+ }
585
+
586
+ const recent = [...this.daily.values()].sort((a, b) => b.date.localeCompare(a.date)).slice(0, days)
587
+ if (recent.length > 0) {
588
+ lines.push('', `Last ${recent.length} day(s)`)
589
+ for (const day of recent) {
590
+ lines.push(` ${day.date} ${n(day.counters.totalTokens).padStart(15)} ${n(day.calls).padStart(6)} calls`)
591
+ }
592
+ }
593
+
594
+ const top = [...this.models.values()]
595
+ .sort((a, b) => b.counters.totalTokens - a.counters.totalTokens)
596
+ .slice(0, models)
597
+ if (top.length > 0) {
598
+ lines.push('', 'By model')
599
+ for (const model of top) {
600
+ lines.push(` ${model.model} ${n(model.counters.totalTokens)} ${n(model.calls)} calls`)
601
+ }
602
+ }
603
+ return lines.join('\n')
604
+ }
605
+
606
+ /**
607
+ * Render one aggregate as CSV, for spreadsheets and audit trails.
608
+ *
609
+ * @param {'daily'|'sessions'|'models'} [kind] - which table to emit.
610
+ * @returns {string} CSV text with a header row and CRLF line endings.
611
+ */
612
+ toCsv(kind = 'daily') {
613
+ const header = [
614
+ 'key',
615
+ 'calls',
616
+ 'inputTokens',
617
+ 'outputTokens',
618
+ 'cacheReadTokens',
619
+ 'cacheWriteTokens',
620
+ 'totalTokens',
621
+ 'reasoningTokens',
622
+ ]
623
+ const rows = []
624
+ const push = (key, record) => {
625
+ rows.push([
626
+ key,
627
+ record.calls,
628
+ record.counters.inputTokens,
629
+ record.counters.outputTokens,
630
+ record.counters.cacheReadTokens,
631
+ record.counters.cacheWriteTokens,
632
+ record.counters.totalTokens,
633
+ record.counters.reasoningTokens,
634
+ ])
635
+ }
636
+
637
+ if (kind === 'daily') {
638
+ for (const day of [...this.daily.values()].sort((a, b) => a.date.localeCompare(b.date))) push(day.date, day)
639
+ } else if (kind === 'models') {
640
+ for (const model of [...this.models.values()].sort((a, b) => b.counters.totalTokens - a.counters.totalTokens)) {
641
+ push(model.model, model)
642
+ }
643
+ } else if (kind === 'sessions') {
644
+ for (const session of [...this.sessions.values()].sort((a, b) => b.counters.totalTokens - a.counters.totalTokens)) {
645
+ push(session.sessionId, session)
646
+ }
647
+ } else {
648
+ throw new Error(`unknown CSV table "${kind}" (expected daily, sessions, or models)`)
649
+ }
650
+
651
+ return [header, ...rows].map((row) => row.join(',')).join('\r\n') + '\r\n'
652
+ }
653
+ }