@sixzjd/dsh-usage-dashboard 0.0.0-stage → 0.6.1
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 +59 -0
- package/LICENSE +21 -0
- package/README.en.md +212 -0
- package/README.md +194 -2
- package/client.js +357 -0
- package/cordis.patch.yml +11 -0
- package/lib/aggregate.js +422 -0
- package/lib/backfill.js +116 -0
- package/lib/catalog.js +230 -0
- package/lib/history.js +150 -0
- package/lib/index.js +2 -0
- package/lib/plugin.js +939 -0
- package/lib/pricing.js +232 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
package/lib/aggregate.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage aggregation for dsh-usage-dashboard.
|
|
3
|
+
*
|
|
4
|
+
* One fold over the durable session log produces the same numbers whether it
|
|
5
|
+
* runs over a historical snapshot or over live events, so a reload and a
|
|
6
|
+
* running conversation agree.
|
|
7
|
+
*
|
|
8
|
+
* Token accounting follows the host's documented DISJOINT semantics
|
|
9
|
+
* (`@deepseek-ai/dsh-llm`): `inputTokens` is uncached input only, and cached
|
|
10
|
+
* input is reported separately as `cacheReadTokens` / `cacheWriteTokens`.
|
|
11
|
+
* That makes "real consumed tokens" exactly
|
|
12
|
+
*
|
|
13
|
+
* input + output + cacheWrite + cacheRead
|
|
14
|
+
*
|
|
15
|
+
* and the cache hit rate
|
|
16
|
+
*
|
|
17
|
+
* cacheRead / (input + cacheWrite + cacheRead)
|
|
18
|
+
*
|
|
19
|
+
* Adapters that never report `cacheWriteTokens` (OpenAI-style protocols) simply
|
|
20
|
+
* contribute 0 there, which is why the hit rate stays correct instead of
|
|
21
|
+
* silently inflated.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { buildPriceIndex, lookupPrice, priceUsage, emptyCost, addCosts } from './pricing.js'
|
|
25
|
+
|
|
26
|
+
const ZERO = 0
|
|
27
|
+
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeInt(value) {
|
|
33
|
+
const n = Number(value)
|
|
34
|
+
if (!Number.isFinite(n) || n < 0) return null
|
|
35
|
+
return Math.trunc(n)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validTime(value) {
|
|
39
|
+
const n = Number(value)
|
|
40
|
+
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Local (host timezone) calendar day, so "today" means the user's today. */
|
|
44
|
+
export function localDay(atMs) {
|
|
45
|
+
const d = new Date(atMs)
|
|
46
|
+
const y = d.getFullYear()
|
|
47
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
48
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
49
|
+
return `${y}-${m}-${day}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function emptyTokens() {
|
|
53
|
+
return { input: ZERO, output: ZERO, cacheRead: ZERO, cacheWrite: ZERO }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function addTokens(target, usage) {
|
|
57
|
+
target.input += safeInt(usage?.inputTokens ?? usage?.input) ?? ZERO
|
|
58
|
+
target.output += safeInt(usage?.outputTokens ?? usage?.output) ?? ZERO
|
|
59
|
+
target.cacheRead += safeInt(usage?.cacheReadTokens ?? usage?.cacheRead) ?? ZERO
|
|
60
|
+
target.cacheWrite += safeInt(usage?.cacheWriteTokens ?? usage?.cacheWrite) ?? ZERO
|
|
61
|
+
return target
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isAllZero(tokens) {
|
|
65
|
+
return tokens.input === 0 && tokens.output === 0 && tokens.cacheRead === 0 && tokens.cacheWrite === 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Real consumed tokens, the cc-switch "真实消耗 Tokens" figure. */
|
|
69
|
+
export function realTokens(tokens) {
|
|
70
|
+
return tokens.input + tokens.output + tokens.cacheWrite + tokens.cacheRead
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Cache hit rate in 0..1, or null when nothing was cacheable. */
|
|
74
|
+
export function cacheHitRate(tokens) {
|
|
75
|
+
const denominator = tokens.input + tokens.cacheWrite + tokens.cacheRead
|
|
76
|
+
if (denominator <= 0) return null
|
|
77
|
+
return tokens.cacheRead / denominator
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One call slot. `message` wins over `chunk` because a provider's final usage
|
|
82
|
+
* report supersedes the streaming one for the same attempt; an all-zero chunk
|
|
83
|
+
* never overwrites a recorded real number (replays and aborted requests).
|
|
84
|
+
*/
|
|
85
|
+
function makeSlot() {
|
|
86
|
+
return { tokens: emptyTokens(), time: null, modelId: null, provider: null, hasMessage: false }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createAggregator(options = {}) {
|
|
90
|
+
const priceIndex = buildPriceIndex(options.prices ?? [])
|
|
91
|
+
// sessionId -> (turn:step -> slot). Replacing a whole session re-derives its
|
|
92
|
+
// map, so a rewritten history cannot double count.
|
|
93
|
+
const bySession = new Map()
|
|
94
|
+
// sessionId -> { modelId, provider } carried by the newest request header.
|
|
95
|
+
const sessionRoute = new Map()
|
|
96
|
+
|
|
97
|
+
function routeFor(sid, event) {
|
|
98
|
+
const current = sessionRoute.get(sid) ?? { modelId: null, provider: null }
|
|
99
|
+
if (!isRecord(event) || !isRecord(event.data)) return current
|
|
100
|
+
if (event.type === 'request/header') {
|
|
101
|
+
const config = event.data.header?.config
|
|
102
|
+
if (isRecord(config)) {
|
|
103
|
+
return {
|
|
104
|
+
modelId: typeof config.model === 'string' ? config.model : current.modelId,
|
|
105
|
+
provider: typeof config.provider === 'string' ? config.provider : current.provider,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return current
|
|
109
|
+
}
|
|
110
|
+
if (event.type === 'request/context') {
|
|
111
|
+
const data = event.data
|
|
112
|
+
return {
|
|
113
|
+
modelId: typeof data.model === 'string' ? data.model : current.modelId,
|
|
114
|
+
provider: typeof data.provider === 'string' ? data.provider : current.provider,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return current
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Extract one usage report, or null when the event carries none. */
|
|
121
|
+
function usageOf(event) {
|
|
122
|
+
if (!isRecord(event) || !isRecord(event.data)) return null
|
|
123
|
+
const data = event.data
|
|
124
|
+
if (event.type === 'assistant/chunk') {
|
|
125
|
+
if (!isRecord(data.chunk) || data.chunk.type !== 'usage') return null
|
|
126
|
+
return { usage: data.chunk.usage, kind: 'chunk', data }
|
|
127
|
+
}
|
|
128
|
+
if (event.type === 'assistant/message') {
|
|
129
|
+
if (!Object.hasOwn(data, 'usage') || data.usage === undefined) return null
|
|
130
|
+
return { usage: data.usage, kind: 'message', data }
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function slotKey(data) {
|
|
136
|
+
const turn = safeInt(data?.turn)
|
|
137
|
+
const step = safeInt(data?.step)
|
|
138
|
+
if (turn === null || step === null) return null
|
|
139
|
+
return `${turn}:${step}`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The slot a retained history row names, given `${sessionId}:<slot>` ids. */
|
|
143
|
+
function retainedSlotKey(sessionId, id) {
|
|
144
|
+
if (typeof id !== 'string' || id === '') return null
|
|
145
|
+
const prefix = `${sessionId}:`
|
|
146
|
+
if (id.startsWith(prefix)) {
|
|
147
|
+
const rest = id.slice(prefix.length)
|
|
148
|
+
return rest === '' ? null : rest
|
|
149
|
+
}
|
|
150
|
+
const at = id.indexOf(':')
|
|
151
|
+
return at < 0 ? null : id.slice(at + 1)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function foldEvent(sessionId, event) {
|
|
155
|
+
const extracted = usageOf(event)
|
|
156
|
+
const time = validTime(event?.time)
|
|
157
|
+
// Route tracking runs even for non-usage events: the model identity of a
|
|
158
|
+
// later call comes from the header that precedes it.
|
|
159
|
+
const route = routeFor(sessionId, event)
|
|
160
|
+
if (route.modelId !== null || route.provider !== null) {
|
|
161
|
+
const previous = sessionRoute.get(sessionId)
|
|
162
|
+
if (previous?.modelId !== route.modelId || previous?.provider !== route.provider) {
|
|
163
|
+
sessionRoute.set(sessionId, route)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (extracted === null) return
|
|
167
|
+
const slots = bySession.get(sessionId)
|
|
168
|
+
if (slots === undefined) return
|
|
169
|
+
const key = slotKey(extracted.data)
|
|
170
|
+
if (key === null) return
|
|
171
|
+
const tokens = emptyTokens()
|
|
172
|
+
addTokens(tokens, extracted.usage)
|
|
173
|
+
// A replayed all-zero report must not erase a real one.
|
|
174
|
+
if (isAllZero(tokens)) {
|
|
175
|
+
const existing = slots.get(key)
|
|
176
|
+
if (existing !== undefined && !isAllZero(existing.tokens)) return
|
|
177
|
+
}
|
|
178
|
+
const existing = slots.get(key) ?? makeSlot()
|
|
179
|
+
if (existing.hasMessage && extracted.kind !== 'message') return
|
|
180
|
+
const record = {
|
|
181
|
+
tokens,
|
|
182
|
+
time: time ?? existing.time,
|
|
183
|
+
modelId: route.modelId ?? existing.modelId,
|
|
184
|
+
provider: route.provider ?? existing.provider,
|
|
185
|
+
hasMessage: existing.hasMessage || extracted.kind === 'message',
|
|
186
|
+
}
|
|
187
|
+
slots.set(key, record)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resetSession(sessionId) {
|
|
191
|
+
bySession.set(sessionId, new Map())
|
|
192
|
+
sessionRoute.delete(sessionId)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function removeSession(sessionId) {
|
|
196
|
+
bySession.delete(sessionId)
|
|
197
|
+
sessionRoute.delete(sessionId)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Load one complete session log, replacing any previous view of it. */
|
|
201
|
+
function loadSession(sessionId, events) {
|
|
202
|
+
resetSession(sessionId)
|
|
203
|
+
if (!Array.isArray(events)) return
|
|
204
|
+
for (const event of events) foldEvent(sessionId, event)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Load rows recovered from the durable history store.
|
|
209
|
+
*
|
|
210
|
+
* These describe call slots the aggregator may no longer be able to read:
|
|
211
|
+
* the session they belonged to was deleted, so `sessionQuery` will never
|
|
212
|
+
* list it again and its log can never be folded. Without them a deletion
|
|
213
|
+
* silently subtracts real usage from every total.
|
|
214
|
+
*
|
|
215
|
+
* Ordering is part of the contract: call this BEFORE the session logs are
|
|
216
|
+
* folded, so a session that still exists is always measured from its own
|
|
217
|
+
* log and a stale retained copy can never win.
|
|
218
|
+
*/
|
|
219
|
+
function loadRetained(rows) {
|
|
220
|
+
if (!Array.isArray(rows)) return
|
|
221
|
+
for (const row of rows) {
|
|
222
|
+
if (!isRecord(row) || typeof row.sessionId !== 'string') continue
|
|
223
|
+
const key = retainedSlotKey(row.sessionId, row.id)
|
|
224
|
+
if (key === null) continue
|
|
225
|
+
const tokens = emptyTokens()
|
|
226
|
+
addTokens(tokens, row.tokens)
|
|
227
|
+
if (isAllZero(tokens)) continue
|
|
228
|
+
let slots = bySession.get(row.sessionId)
|
|
229
|
+
if (slots === undefined) {
|
|
230
|
+
slots = new Map()
|
|
231
|
+
bySession.set(row.sessionId, slots)
|
|
232
|
+
}
|
|
233
|
+
const existing = slots.get(key)
|
|
234
|
+
// A live fold of the same slot is authoritative; keep it.
|
|
235
|
+
if (existing !== undefined && !isAllZero(existing.tokens)) continue
|
|
236
|
+
const time = validTime(row.time)
|
|
237
|
+
if (existing !== undefined) {
|
|
238
|
+
slots.set(key, {
|
|
239
|
+
...existing,
|
|
240
|
+
tokens,
|
|
241
|
+
time: time ?? existing.time,
|
|
242
|
+
modelId: existing.modelId ?? (typeof row.modelId === 'string' ? row.modelId : null),
|
|
243
|
+
provider: existing.provider ?? (typeof row.provider === 'string' ? row.provider : null),
|
|
244
|
+
})
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
slots.set(key, {
|
|
248
|
+
tokens,
|
|
249
|
+
time,
|
|
250
|
+
modelId: typeof row.modelId === 'string' ? row.modelId : null,
|
|
251
|
+
provider: typeof row.provider === 'string' ? row.provider : null,
|
|
252
|
+
hasMessage: true,
|
|
253
|
+
})
|
|
254
|
+
const route = sessionRoute.get(row.sessionId) ?? { modelId: null, provider: null }
|
|
255
|
+
if (route.modelId === null && typeof row.modelId === 'string') {
|
|
256
|
+
sessionRoute.set(row.sessionId, {
|
|
257
|
+
modelId: row.modelId,
|
|
258
|
+
provider: route.provider ?? (typeof row.provider === 'string' ? row.provider : null),
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Apply one live event to an already-loaded session. */
|
|
265
|
+
function applyEvent(sessionId, event) {
|
|
266
|
+
if (!bySession.has(sessionId)) resetSession(sessionId)
|
|
267
|
+
foldEvent(sessionId, event)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fold everything into day / model buckets.
|
|
272
|
+
*
|
|
273
|
+
* `days` is keyed by local calendar day; `models` by model id. Both carry the
|
|
274
|
+
* same totals so a range query can merge days without touching per-call rows.
|
|
275
|
+
*/
|
|
276
|
+
function snapshot() {
|
|
277
|
+
const days = new Map()
|
|
278
|
+
const models = new Map()
|
|
279
|
+
const providers = new Map()
|
|
280
|
+
const requests = []
|
|
281
|
+
const totals = { calls: 0, tokens: emptyTokens(), cost: emptyCost(), turns: new Set() }
|
|
282
|
+
let earliest = null
|
|
283
|
+
let latest = null
|
|
284
|
+
|
|
285
|
+
for (const [sessionId, slots] of bySession) {
|
|
286
|
+
for (const [key, record] of slots) {
|
|
287
|
+
const tokens = record.tokens
|
|
288
|
+
const calls = 1
|
|
289
|
+
const day = record.time === null ? 'unknown' : localDay(record.time)
|
|
290
|
+
const dayEntry = days.get(day) ?? {
|
|
291
|
+
date: day, calls: 0, tokens: emptyTokens(), cost: emptyCost(),
|
|
292
|
+
sessions: new Set(), turns: new Set(),
|
|
293
|
+
}
|
|
294
|
+
dayEntry.calls += calls
|
|
295
|
+
addTokens(dayEntry.tokens, tokens)
|
|
296
|
+
dayEntry.sessions.add(sessionId)
|
|
297
|
+
dayEntry.turns.add(`${sessionId}:${key.split(':')[0]}`)
|
|
298
|
+
|
|
299
|
+
const modelKey = record.modelId ?? 'unknown'
|
|
300
|
+
const providerKey = record.provider ?? 'unknown'
|
|
301
|
+
const modelAggregateKey = `${providerKey}\u0000${modelKey}`
|
|
302
|
+
const modelEntry = models.get(modelAggregateKey) ?? {
|
|
303
|
+
modelId: modelKey, provider: record.provider ?? null,
|
|
304
|
+
calls: 0, tokens: emptyTokens(), cost: emptyCost(),
|
|
305
|
+
}
|
|
306
|
+
modelEntry.calls += calls
|
|
307
|
+
addTokens(modelEntry.tokens, tokens)
|
|
308
|
+
if (record.provider && !modelEntry.provider) modelEntry.provider = record.provider
|
|
309
|
+
const providerEntry = providers.get(providerKey) ?? {
|
|
310
|
+
provider: record.provider ?? null, calls: 0, tokens: emptyTokens(), cost: emptyCost(),
|
|
311
|
+
}
|
|
312
|
+
providerEntry.calls += calls
|
|
313
|
+
addTokens(providerEntry.tokens, tokens)
|
|
314
|
+
|
|
315
|
+
totals.calls += calls
|
|
316
|
+
addTokens(totals.tokens, tokens)
|
|
317
|
+
for (const turn of dayEntry.turns) totals.turns.add(turn)
|
|
318
|
+
|
|
319
|
+
if (record.time !== null) {
|
|
320
|
+
if (earliest === null || record.time < earliest) earliest = record.time
|
|
321
|
+
if (latest === null || record.time > latest) latest = record.time
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Price per call so a peak/off-peak band can be chosen from its own time.
|
|
325
|
+
const entry = lookupPrice(priceIndex, modelKey)
|
|
326
|
+
const cost = entry === null ? null : priceUsage(entry, tokens, record.time ?? Date.now())
|
|
327
|
+
if (cost === null) {
|
|
328
|
+
dayEntry.cost.unpriced += calls
|
|
329
|
+
modelEntry.cost.unpriced += calls
|
|
330
|
+
providerEntry.cost.unpriced += calls
|
|
331
|
+
totals.cost.unpriced += calls
|
|
332
|
+
} else {
|
|
333
|
+
addCosts(dayEntry.cost, cost)
|
|
334
|
+
addCosts(modelEntry.cost, cost)
|
|
335
|
+
addCosts(providerEntry.cost, cost)
|
|
336
|
+
addCosts(totals.cost, cost)
|
|
337
|
+
}
|
|
338
|
+
requests.push({
|
|
339
|
+
id: `${sessionId}:${key}`,
|
|
340
|
+
sessionId,
|
|
341
|
+
time: record.time,
|
|
342
|
+
date: day,
|
|
343
|
+
provider: record.provider,
|
|
344
|
+
modelId: record.modelId,
|
|
345
|
+
tokens: { ...tokens },
|
|
346
|
+
realTokens: realTokens(tokens),
|
|
347
|
+
hitRate: cacheHitRate(tokens),
|
|
348
|
+
cost: cost ?? { ...emptyCost(), unpriced: 1 },
|
|
349
|
+
status: 'complete',
|
|
350
|
+
})
|
|
351
|
+
days.set(day, dayEntry)
|
|
352
|
+
models.set(modelAggregateKey, modelEntry)
|
|
353
|
+
providers.set(providerKey, providerEntry)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const dayList = [...days.values()]
|
|
358
|
+
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
|
359
|
+
.map((entry) => ({
|
|
360
|
+
date: entry.date,
|
|
361
|
+
calls: entry.calls,
|
|
362
|
+
sessions: entry.sessions.size,
|
|
363
|
+
turns: entry.turns.size,
|
|
364
|
+
tokens: entry.tokens,
|
|
365
|
+
realTokens: realTokens(entry.tokens),
|
|
366
|
+
hitRate: cacheHitRate(entry.tokens),
|
|
367
|
+
cost: entry.cost,
|
|
368
|
+
}))
|
|
369
|
+
|
|
370
|
+
const modelList = [...models.values()]
|
|
371
|
+
.map((entry) => ({
|
|
372
|
+
modelId: entry.modelId,
|
|
373
|
+
provider: entry.provider,
|
|
374
|
+
calls: entry.calls,
|
|
375
|
+
tokens: entry.tokens,
|
|
376
|
+
realTokens: realTokens(entry.tokens),
|
|
377
|
+
hitRate: cacheHitRate(entry.tokens),
|
|
378
|
+
cost: entry.cost,
|
|
379
|
+
priced: lookupPrice(priceIndex, entry.modelId) !== null,
|
|
380
|
+
}))
|
|
381
|
+
.sort((a, b) => b.realTokens - a.realTokens)
|
|
382
|
+
|
|
383
|
+
const providerList = [...providers.values()]
|
|
384
|
+
.map((entry) => ({
|
|
385
|
+
provider: entry.provider,
|
|
386
|
+
calls: entry.calls,
|
|
387
|
+
tokens: entry.tokens,
|
|
388
|
+
realTokens: realTokens(entry.tokens),
|
|
389
|
+
hitRate: cacheHitRate(entry.tokens),
|
|
390
|
+
cost: entry.cost,
|
|
391
|
+
}))
|
|
392
|
+
.sort((a, b) => b.realTokens - a.realTokens)
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
calls: totals.calls,
|
|
396
|
+
turns: totals.turns.size,
|
|
397
|
+
sessions: bySession.size,
|
|
398
|
+
tokens: totals.tokens,
|
|
399
|
+
realTokens: realTokens(totals.tokens),
|
|
400
|
+
hitRate: cacheHitRate(totals.tokens),
|
|
401
|
+
cost: totals.cost,
|
|
402
|
+
earliest,
|
|
403
|
+
latest,
|
|
404
|
+
days: dayList,
|
|
405
|
+
models: modelList,
|
|
406
|
+
providers: providerList,
|
|
407
|
+
requests: requests.sort((a, b) => (a.time ?? 0) - (b.time ?? 0)),
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function sessionCount() {
|
|
412
|
+
return bySession.size
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function callCount() {
|
|
416
|
+
let total = 0
|
|
417
|
+
for (const slots of bySession.values()) total += slots.size
|
|
418
|
+
return total
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { loadSession, loadRetained, applyEvent, removeSession, resetSession, snapshot, sessionCount, callCount }
|
|
422
|
+
}
|
package/lib/backfill.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot backfill of usage for sessions whose log is already gone.
|
|
3
|
+
*
|
|
4
|
+
* The durable history store only starts helping from the moment it is turned
|
|
5
|
+
* on; sessions deleted BEFORE that have no retained row. Their totals do
|
|
6
|
+
* survive in one place: the host's persisted session-projection checkpoint
|
|
7
|
+
* (`<storage root>/session_projcache/sessions/<id>.json`), whose `tokenUsage`
|
|
8
|
+
* row carries the whole-log `uncachedInputTokens` / `outputTokens` /
|
|
9
|
+
* `cacheReadTokens` / `cacheWriteTokens` for that session.
|
|
10
|
+
*
|
|
11
|
+
* This module reads those checkpoints directly, for three reasons:
|
|
12
|
+
*
|
|
13
|
+
* - `sessionQuery` cannot help: it lists sessions, and a deleted session is
|
|
14
|
+
* by definition no longer listed.
|
|
15
|
+
* - The host exposes no service that serves projections for a session that
|
|
16
|
+
* no longer exists, so there is no public API to call.
|
|
17
|
+
* - The files are a documented, stable on-disk layout of a shipped package,
|
|
18
|
+
* and this is a read-only, best-effort backfill: anything unreadable is
|
|
19
|
+
* skipped rather than guessed at.
|
|
20
|
+
*
|
|
21
|
+
* What it CANNOT recover: a per-day or per-model split. `tokenUsage` is one
|
|
22
|
+
* whole-session figure. The row is therefore anchored at the session's
|
|
23
|
+
* creation time and recorded as a single synthetic call, which keeps day and
|
|
24
|
+
* model totals honest at the session level and never invents a distribution.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
/** The on-disk location of the projection checkpoints, relative to a JSON storage root. */
|
|
31
|
+
export const PROJCACHE_DIR = 'session_projcache/sessions'
|
|
32
|
+
|
|
33
|
+
function isRecord(value) {
|
|
34
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeInt(value) {
|
|
38
|
+
const n = Number(value)
|
|
39
|
+
if (!Number.isFinite(n) || n < 0) return 0
|
|
40
|
+
return Math.trunc(n)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read one `tokenUsage` checkpoint document.
|
|
45
|
+
*
|
|
46
|
+
* Returns `null` for anything that is not a usable checkpoint: an unreadable
|
|
47
|
+
* file, invalid JSON, a missing row, or an all-zero total. A backfill that
|
|
48
|
+
* cannot prove a number must contribute nothing rather than a guess.
|
|
49
|
+
*/
|
|
50
|
+
export function parseCheckpoint(document, sessionId) {
|
|
51
|
+
if (!isRecord(document)) return null
|
|
52
|
+
const record = isRecord(document.record) ? document.record : null
|
|
53
|
+
if (record === null) return null
|
|
54
|
+
const rows = isRecord(record.rows) ? record.rows : null
|
|
55
|
+
if (rows === null) return null
|
|
56
|
+
const usageRow = isRecord(rows.tokenUsage) ? rows.tokenUsage : null
|
|
57
|
+
if (usageRow === null) return null
|
|
58
|
+
const totals = isRecord(usageRow.val) ? usageRow.val.totals : null
|
|
59
|
+
if (!isRecord(totals)) return null
|
|
60
|
+
const tokens = {
|
|
61
|
+
input: safeInt(totals.uncachedInputTokens ?? totals.inputTokens ?? totals.input),
|
|
62
|
+
output: safeInt(totals.outputTokens ?? totals.output),
|
|
63
|
+
cacheRead: safeInt(totals.cacheReadTokens ?? totals.cacheRead),
|
|
64
|
+
cacheWrite: safeInt(totals.cacheWriteTokens ?? totals.cacheWrite),
|
|
65
|
+
}
|
|
66
|
+
if (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite === 0) return null
|
|
67
|
+
const identity = isRecord(record.identity) ? record.identity : {}
|
|
68
|
+
const createdAt = Number(identity.createdAt)
|
|
69
|
+
return {
|
|
70
|
+
id: `${sessionId}:retained`,
|
|
71
|
+
sessionId,
|
|
72
|
+
time: Number.isFinite(createdAt) && createdAt >= 0 ? Math.trunc(createdAt) : null,
|
|
73
|
+
date: null,
|
|
74
|
+
provider: null,
|
|
75
|
+
modelId: null,
|
|
76
|
+
tokens,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Collect backfill rows for sessions the current scan did not find.
|
|
82
|
+
*
|
|
83
|
+
* `known` is the set of session ids the scan DID find; only sessions outside
|
|
84
|
+
* it are considered, so a live session is never backfilled from a checkpoint
|
|
85
|
+
* (its log is authoritative and already more precise).
|
|
86
|
+
*/
|
|
87
|
+
export async function readBackfillRows(root, known, options = {}) {
|
|
88
|
+
const dir = join(root, PROJCACHE_DIR)
|
|
89
|
+
const limit = Number.isFinite(options.limit) ? Math.trunc(options.limit) : Infinity
|
|
90
|
+
const skip = known instanceof Set ? known : new Set(known ?? [])
|
|
91
|
+
let names
|
|
92
|
+
try {
|
|
93
|
+
names = await readdir(dir)
|
|
94
|
+
} catch {
|
|
95
|
+
// No checkpoint directory is a normal state (fresh install, other backend).
|
|
96
|
+
return { rows: [], scanned: 0, failed: 0 }
|
|
97
|
+
}
|
|
98
|
+
const rows = []
|
|
99
|
+
let scanned = 0
|
|
100
|
+
let failed = 0
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
if (!name.endsWith('.json')) continue
|
|
103
|
+
const sessionId = name.slice(0, -'.json'.length)
|
|
104
|
+
if (sessionId === '' || skip.has(sessionId)) continue
|
|
105
|
+
if (scanned >= limit) break
|
|
106
|
+
scanned += 1
|
|
107
|
+
try {
|
|
108
|
+
const text = await readFile(join(dir, name), 'utf8')
|
|
109
|
+
const row = parseCheckpoint(JSON.parse(text), sessionId)
|
|
110
|
+
if (row !== null) rows.push(row)
|
|
111
|
+
} catch {
|
|
112
|
+
failed += 1
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { rows, scanned, failed }
|
|
116
|
+
}
|