@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.
@@ -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
+ }
@@ -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
+ }