@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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/README.zh.md +208 -0
- package/bin/dsh-token-ledger.mjs +12 -0
- package/cordis.patch.yml +20 -0
- package/docs/VERIFICATION.md +173 -0
- package/lib/cli.js +443 -0
- package/lib/index.js +253 -0
- package/lib/ledger.js +653 -0
- package/lib/session-log.js +142 -0
- package/lib/store.js +101 -0
- package/package.json +75 -0
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
|
+
}
|