@goodandready/dsh-clinebot 0.5.33 → 0.6.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.
@@ -1,51 +1,72 @@
1
1
  /**
2
- * Persistent JSON Token Analytics Storage (Issue #129)
2
+ * Persistent JSON telemetry storage.
3
3
  *
4
- * Persists token consumption and request telemetry to ~/.dsh/clinebot-stats.json.
5
- * Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
4
+ * Version 2 adds cache buckets, estimated cost, TTFT/latency sums and a bounded
5
+ * per-session dimension. Version 1 files are upgraded on read.
6
6
  */
7
7
 
8
8
  import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import path from 'node:path'
11
11
 
12
+ const MAX_SESSIONS = 500
13
+ const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
14
+ const HEATMAP_RETENTION_MS = 95 * 24 * 60 * 60 * 1000
15
+
12
16
  let inMemoryStats = createEmptyStats()
13
17
  let saveTimer = null
14
18
  let activeStatsPath = ''
15
19
 
20
+ function emptyBucket() {
21
+ return {
22
+ requests: 0,
23
+ successful: 0,
24
+ failed: 0,
25
+ rateLimited429: 0,
26
+ promptTokens: 0,
27
+ completionTokens: 0,
28
+ reasoningTokens: 0,
29
+ cacheReadTokens: 0,
30
+ cacheWriteTokens: 0,
31
+ totalTokens: 0,
32
+ costUsd: 0,
33
+ pricedRequests: 0,
34
+ unpricedRequests: 0,
35
+ ttftMsSum: 0,
36
+ latencyMsSum: 0,
37
+ firstAt: 0,
38
+ lastAt: 0,
39
+ model: '',
40
+ accountId: '',
41
+ purpose: '',
42
+ }
43
+ }
44
+
16
45
  /**
17
46
  * Create a fresh, empty stats object.
18
47
  */
19
48
  export function createEmptyStats() {
20
49
  return {
21
- version: 1,
50
+ version: 2,
22
51
  createdAt: Date.now(),
23
52
  updatedAt: Date.now(),
24
- totals: {
25
- requests: 0,
26
- successful: 0,
27
- failed: 0,
28
- rateLimited429: 0,
29
- promptTokens: 0,
30
- completionTokens: 0,
31
- reasoningTokens: 0,
32
- totalTokens: 0
33
- },
53
+ totals: emptyBucket(),
34
54
  byDay: {},
35
55
  byMonth: {},
36
56
  byModel: {},
37
- byAccount: {}
57
+ byAccount: {},
58
+ bySession: {},
59
+ byHour: {},
60
+ byHourModel: {},
38
61
  }
39
62
  }
40
63
 
41
- /**
42
- * Resolve '~' in paths to user's home directory.
43
- */
44
- /**
45
- * Get active .dsh directory, respecting DSH_HOME and HOME environment variables.
46
- *
47
- * @returns {string}
48
- */
64
+ /** Local `YYYY-MM-DD` key, used by the request heatmap. */
65
+ function localDateKey(date) {
66
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
67
+ }
68
+
69
+ /** Get active .dsh directory, respecting DSH_HOME and HOME. */
49
70
  export function getDshHome() {
50
71
  if (process.env.DSH_HOME) return process.env.DSH_HOME
51
72
  const home = process.env.HOME || homedir()
@@ -65,12 +86,8 @@ export function resolvePath(targetPath) {
65
86
  break
66
87
  }
67
88
  }
68
- if (typeof targetPath !== 'string') {
69
- targetPath = ''
70
- }
71
- if (!targetPath) {
72
- return path.join(getDshHome(), 'clinebot-stats.json')
73
- }
89
+ if (typeof targetPath !== 'string') targetPath = ''
90
+ if (!targetPath) return path.join(getDshHome(), 'clinebot-stats.json')
74
91
  if (targetPath.startsWith('~/') || targetPath === '~') {
75
92
  const home = process.env.HOME || homedir()
76
93
  return path.join(home, targetPath.slice(1))
@@ -80,9 +97,7 @@ export function resolvePath(targetPath) {
80
97
 
81
98
  /**
82
99
  * Load stats from disk.
83
- *
84
100
  * @param {string} [customPath]
85
- * @returns {Object} Loaded stats
86
101
  */
87
102
  export function loadStats(customPath) {
88
103
  const filePath = resolvePath(customPath)
@@ -92,20 +107,25 @@ export function loadStats(customPath) {
92
107
  const raw = readFileSync(filePath, 'utf8')
93
108
  const parsed = JSON.parse(raw)
94
109
  if (parsed && typeof parsed === 'object' && parsed.totals) {
110
+ const empty = createEmptyStats()
95
111
  inMemoryStats = {
96
- ...createEmptyStats(),
112
+ ...empty,
97
113
  ...parsed,
98
- totals: { ...createEmptyStats().totals, ...parsed.totals },
114
+ version: 2,
115
+ totals: { ...empty.totals, ...parsed.totals },
99
116
  byDay: parsed.byDay || {},
100
117
  byMonth: parsed.byMonth || {},
101
118
  byModel: parsed.byModel || {},
102
- byAccount: parsed.byAccount || {}
119
+ byAccount: parsed.byAccount || {},
120
+ bySession: parsed.bySession || {},
121
+ byHour: parsed.byHour || {},
122
+ byHourModel: parsed.byHourModel || {},
103
123
  }
124
+ backfillHourlyFromSessions()
104
125
  return inMemoryStats
105
126
  }
106
127
  }
107
- } catch (err) {
108
- // If reading fails or file is corrupted, preserve corrupted file for diagnostics (#158, #201)
128
+ } catch {
109
129
  try {
110
130
  if (existsSync(filePath)) {
111
131
  const corruptBackup = `${filePath}.corrupt.${Date.now()}`
@@ -113,52 +133,29 @@ export function loadStats(customPath) {
113
133
  const dir = path.dirname(filePath)
114
134
  const base = path.basename(filePath)
115
135
  const prefix = `${base}.corrupt.`
116
- const existing = readdirSync(dir)
117
- .filter((name) => name.startsWith(prefix))
118
- .sort()
136
+ const existing = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort()
119
137
  while (existing.length > 3) {
120
138
  const oldest = existing.shift()
121
- try {
122
- unlinkSync(path.join(dir, oldest))
123
- } catch {
124
- /* ignore deletion failure */
125
- }
139
+ try { unlinkSync(path.join(dir, oldest)) } catch {}
126
140
  }
127
141
  }
128
- } catch {
129
- /* ignore backup failure */
130
- }
142
+ } catch {}
131
143
  }
132
-
133
144
  inMemoryStats = createEmptyStats()
134
145
  return inMemoryStats
135
146
  }
136
147
 
137
- /**
138
- * Switch active stats storage to a new path.
139
- * Flushes pending writes of current storage and loads stats from the new path.
140
- *
141
- * @param {string} newPath
142
- * @returns {string} active stats path
143
- */
148
+ /** Switch active stats storage and load the target file. */
144
149
  export function switchStatsStorage(newPath) {
145
150
  const resolved = resolvePath(newPath)
146
151
  if (resolved && resolved !== activeStatsPath) {
147
- if (activeStatsPath) {
148
- flushStats(activeStatsPath)
149
- }
152
+ if (activeStatsPath) flushStats(activeStatsPath)
150
153
  activeStatsPath = resolved
151
154
  loadStats(resolved)
152
155
  }
153
156
  return activeStatsPath
154
157
  }
155
158
 
156
-
157
- /**
158
- * Flush any debounced pending writes immediately to disk.
159
- *
160
- * @param {string} [customPath]
161
- */
162
159
  export function flushStats(customPath) {
163
160
  if (saveTimer) {
164
161
  clearTimeout(saveTimer)
@@ -167,167 +164,368 @@ export function flushStats(customPath) {
167
164
  saveStatsSync(customPath)
168
165
  }
169
166
 
170
- /**
171
- * Synchronously write stats to disk using atomic rename.
172
- *
173
- * @param {string} [customPath]
174
- */
175
167
  export function saveStatsSync(customPath) {
176
168
  try {
177
169
  const filePath = resolvePath(customPath || activeStatsPath)
178
170
  activeStatsPath = filePath
179
-
180
171
  const dir = path.dirname(filePath)
181
- if (!existsSync(dir)) {
182
- mkdirSync(dir, { recursive: true })
183
- }
184
-
172
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
185
173
  inMemoryStats.updatedAt = Date.now()
186
174
  const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
187
- const data = JSON.stringify(inMemoryStats, null, 2)
188
-
189
- writeFileSync(tempPath, data, 'utf8')
175
+ writeFileSync(tempPath, JSON.stringify(inMemoryStats, null, 2), 'utf8')
190
176
  renameSync(tempPath, filePath)
191
177
  } catch {
192
- // Best-effort disk persistence
178
+ /* best-effort disk persistence */
193
179
  }
194
180
  }
195
181
 
196
- /**
197
- * Queue a debounced write to avoid disk thrashing during rapid streaming tokens.
198
- *
199
- * @param {string} [customPath]
200
- * @param {number} [debounceMs=500]
201
- */
202
182
  function queueSaveStats(customPath, debounceMs = 500) {
203
- if (saveTimer) {
204
- clearTimeout(saveTimer)
205
- }
183
+ if (saveTimer) clearTimeout(saveTimer)
206
184
  saveTimer = setTimeout(() => {
207
185
  saveTimer = null
208
- try {
209
- saveStatsSync(customPath)
210
- } catch {
211
- // Best-effort disk persistence, never crash host process
212
- }
186
+ try { saveStatsSync(customPath) } catch {}
213
187
  }, debounceMs)
214
188
  }
215
189
 
190
+ function addToBucket(bucket, entry) {
191
+ bucket.requests += 1
192
+ if (entry.isError) bucket.failed += 1
193
+ else bucket.successful += 1
194
+ if (entry.is429) bucket.rateLimited429 += 1
195
+ bucket.promptTokens += entry.promptTokens
196
+ bucket.completionTokens += entry.completionTokens
197
+ bucket.reasoningTokens += entry.reasoningTokens
198
+ bucket.cacheReadTokens += entry.cacheReadTokens
199
+ bucket.cacheWriteTokens += entry.cacheWriteTokens
200
+ bucket.totalTokens += entry.totalTokens
201
+ bucket.costUsd += entry.costUsd
202
+ if (entry.priced) bucket.pricedRequests += 1
203
+ else bucket.unpricedRequests += 1
204
+ bucket.ttftMsSum += entry.ttftMs
205
+ bucket.latencyMsSum += entry.latencyMs
206
+ bucket.firstAt = bucket.firstAt || entry.now
207
+ bucket.lastAt = entry.now
208
+ if (entry.model) bucket.model = entry.model
209
+ if (entry.accountId) bucket.accountId = entry.accountId
210
+ if (entry.purpose) bucket.purpose = entry.purpose
211
+ }
212
+
213
+ function incrementBucket(container, key, entry) {
214
+ if (!container[key]) container[key] = emptyBucket()
215
+ addToBucket(container[key], entry)
216
+ }
217
+
218
+ function pruneSessions(now = Date.now()) {
219
+ const sessions = inMemoryStats.bySession
220
+ for (const [id, bucket] of Object.entries(sessions)) {
221
+ if (now - Number(bucket.lastAt || 0) > SESSION_TTL_MS) delete sessions[id]
222
+ }
223
+ const ids = Object.keys(sessions)
224
+ if (ids.length <= MAX_SESSIONS) return
225
+ ids.sort((a, b) => Number(sessions[a].lastAt || 0) - Number(sessions[b].lastAt || 0))
226
+ for (const id of ids.slice(0, ids.length - MAX_SESSIONS)) delete sessions[id]
227
+ }
228
+
229
+ function pruneHours(now = Date.now()) {
230
+ const cutoff = localDateKey(new Date(now - HEATMAP_RETENTION_MS))
231
+ for (const container of [inMemoryStats.byHour, inMemoryStats.byHourModel]) {
232
+ for (const key of Object.keys(container || {})) {
233
+ if (key < cutoff) delete container[key]
234
+ }
235
+ }
236
+ }
237
+
216
238
  /**
217
- * Record a completion event with usage metrics.
218
- *
219
- * @param {Object} params
220
- * @param {string} [params.model]
221
- * @param {string} [params.accountId]
222
- * @param {number} [params.promptTokens=0]
223
- * @param {number} [params.completionTokens=0]
224
- * @param {number} [params.totalTokens=0]
225
- * @param {boolean} [params.isError=false]
226
- * @param {boolean} [params.is429=false]
227
- * @param {string} [params.statsPath]
239
+ * Best-effort first-run backfill: older files predate the hourly buckets, so
240
+ * each persisted session contributes its requests to the hour it last ran.
241
+ * This is approximate by design; it only runs while the hourly map is empty.
242
+ */
243
+ function backfillHourlyFromSessions() {
244
+ if (Object.keys(inMemoryStats.byHour || {}).length > 0) return
245
+ for (const bucket of Object.values(inMemoryStats.bySession || {})) {
246
+ const requests = Number(bucket?.requests || 0)
247
+ if (requests <= 0) continue
248
+ const at = Number(bucket.lastAt || bucket.firstAt || 0)
249
+ if (!Number.isFinite(at) || at <= 0) continue
250
+ const local = new Date(at)
251
+ const key = `${localDateKey(local)}T${String(local.getHours()).padStart(2, '0')}`
252
+ if (!inMemoryStats.byHour[key]) inMemoryStats.byHour[key] = emptyBucket()
253
+ const hour = inMemoryStats.byHour[key]
254
+ hour.requests += requests
255
+ hour.successful += Number(bucket.successful || 0)
256
+ hour.failed += Number(bucket.failed || 0)
257
+ hour.rateLimited429 += Number(bucket.rateLimited429 || 0)
258
+ hour.promptTokens += Number(bucket.promptTokens || 0)
259
+ hour.completionTokens += Number(bucket.completionTokens || 0)
260
+ hour.reasoningTokens += Number(bucket.reasoningTokens || 0)
261
+ hour.cacheReadTokens += Number(bucket.cacheReadTokens || 0)
262
+ hour.cacheWriteTokens += Number(bucket.cacheWriteTokens || 0)
263
+ hour.totalTokens += Number(bucket.totalTokens || 0)
264
+ hour.costUsd += Number(bucket.costUsd || 0)
265
+ hour.pricedRequests += Number(bucket.pricedRequests || 0)
266
+ hour.unpricedRequests += Number(bucket.unpricedRequests || 0)
267
+ hour.ttftMsSum += Number(bucket.ttftMsSum || 0)
268
+ hour.latencyMsSum += Number(bucket.latencyMsSum || 0)
269
+ hour.firstAt = hour.firstAt ? Math.min(hour.firstAt, bucket.firstAt || at) : (bucket.firstAt || at)
270
+ hour.lastAt = Math.max(hour.lastAt, at)
271
+ if (bucket.model) hour.model = bucket.model
272
+ if (bucket.accountId) hour.accountId = bucket.accountId
273
+ const model = bucket.model || 'unknown'
274
+ if (!inMemoryStats.byHourModel[key]) inMemoryStats.byHourModel[key] = {}
275
+ inMemoryStats.byHourModel[key][model] = Number(inMemoryStats.byHourModel[key][model] || 0) + requests
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Record one completion event.
281
+ * @param {object} entry
228
282
  */
229
283
  export function recordUsage({
230
284
  model = 'unknown',
231
285
  accountId = 'default',
286
+ sessionId,
287
+ purpose,
232
288
  promptTokens = 0,
233
289
  completionTokens = 0,
234
290
  reasoningTokens = 0,
291
+ cacheReadTokens = 0,
292
+ cacheWriteTokens = 0,
235
293
  totalTokens = 0,
294
+ costUsd = 0,
295
+ priced = false,
296
+ ttftMs = 0,
297
+ latencyMs = 0,
236
298
  isError = false,
237
299
  is429 = false,
238
- statsPath
300
+ statsPath,
239
301
  } = {}) {
240
- if (statsPath) {
241
- switchStatsStorage(statsPath)
242
- }
243
- const now = new Date()
244
- const dayKey = now.toISOString().slice(0, 10) // 'YYYY-MM-DD'
245
- const monthKey = dayKey.slice(0, 7) // 'YYYY-MM'
246
-
302
+ if (statsPath) switchStatsStorage(statsPath)
303
+ const now = Date.now()
304
+ const dayKey = new Date(now).toISOString().slice(0, 10)
305
+ const monthKey = dayKey.slice(0, 7)
247
306
  const safePrompt = Math.max(0, Number(promptTokens) || 0)
248
307
  const safeComp = Math.max(0, Number(completionTokens) || 0)
249
- const safeReasoning = Math.max(0, Number(reasoningTokens) || 0)
250
- const safeTotal = Math.max(safePrompt + safeComp, Number(totalTokens) || 0)
251
-
252
- // Totals
253
- inMemoryStats.totals.requests += 1
254
- if (isError) {
255
- inMemoryStats.totals.failed += 1
256
- } else {
257
- inMemoryStats.totals.successful += 1
308
+ const entry = {
309
+ now,
310
+ model,
311
+ accountId,
312
+ purpose: purpose || '',
313
+ promptTokens: safePrompt,
314
+ completionTokens: safeComp,
315
+ reasoningTokens: Math.max(0, Number(reasoningTokens) || 0),
316
+ cacheReadTokens: Math.max(0, Number(cacheReadTokens) || 0),
317
+ cacheWriteTokens: Math.max(0, Number(cacheWriteTokens) || 0),
318
+ totalTokens: Math.max(safePrompt + safeComp, Number(totalTokens) || 0),
319
+ costUsd: Math.max(0, Number(costUsd) || 0),
320
+ priced: Boolean(priced),
321
+ ttftMs: Math.max(0, Number(ttftMs) || 0),
322
+ latencyMs: Math.max(0, Number(latencyMs) || 0),
323
+ isError: Boolean(isError),
324
+ is429: Boolean(is429),
258
325
  }
259
- if (is429) {
260
- inMemoryStats.totals.rateLimited429 += 1
261
- }
262
- inMemoryStats.totals.promptTokens += safePrompt
263
- inMemoryStats.totals.completionTokens += safeComp
264
- inMemoryStats.totals.reasoningTokens = (inMemoryStats.totals.reasoningTokens || 0) + safeReasoning
265
- inMemoryStats.totals.totalTokens += safeTotal
266
326
 
267
- // Helper to increment bucket
268
- function incrementBucket(container, key) {
269
- if (!container[key]) {
270
- container[key] = {
271
- requests: 0,
272
- promptTokens: 0,
273
- completionTokens: 0,
274
- reasoningTokens: 0,
275
- totalTokens: 0
276
- }
277
- }
278
- container[key].requests += 1
279
- container[key].promptTokens += safePrompt
280
- container[key].completionTokens += safeComp
281
- container[key].reasoningTokens = (container[key].reasoningTokens || 0) + safeReasoning
282
- container[key].totalTokens += safeTotal
327
+ addToBucket(inMemoryStats.totals, entry)
328
+ incrementBucket(inMemoryStats.byDay, dayKey, entry)
329
+ incrementBucket(inMemoryStats.byMonth, monthKey, entry)
330
+ incrementBucket(inMemoryStats.byModel, model, entry)
331
+ incrementBucket(inMemoryStats.byAccount, accountId, entry)
332
+ const localNow = new Date(now)
333
+ const hourKey = `${localDateKey(localNow)}T${String(localNow.getHours()).padStart(2, '0')}`
334
+ incrementBucket(inMemoryStats.byHour, hourKey, entry)
335
+ if (!inMemoryStats.byHourModel[hourKey]) inMemoryStats.byHourModel[hourKey] = {}
336
+ inMemoryStats.byHourModel[hourKey][model] = Number(inMemoryStats.byHourModel[hourKey][model] || 0) + 1
337
+ pruneHours(now)
338
+ if (sessionId) {
339
+ incrementBucket(inMemoryStats.bySession, String(sessionId), entry)
340
+ pruneSessions(now)
283
341
  }
284
-
285
- incrementBucket(inMemoryStats.byDay, dayKey)
286
- incrementBucket(inMemoryStats.byMonth, monthKey)
287
- incrementBucket(inMemoryStats.byModel, model)
288
- incrementBucket(inMemoryStats.byAccount, accountId)
289
-
290
342
  queueSaveStats(statsPath)
291
343
  }
292
344
 
293
- /**
294
- * Retrieve current statistics summary.
295
- *
296
- * @returns {Object}
297
- */
298
345
  export function getStatsSummary() {
299
346
  return JSON.parse(JSON.stringify(inMemoryStats))
300
347
  }
301
348
 
302
- /**
303
- * Reset statistics to initial state and persist.
304
- *
305
- * @param {string} [customPath]
306
- */
307
349
  export function resetStats(customPath) {
308
350
  inMemoryStats = createEmptyStats()
309
351
  saveStatsSync(customPath)
310
352
  return inMemoryStats
311
353
  }
312
354
 
355
+ /**
356
+ * Hourly request heatmap for the last `days` local days.
357
+ * @param {number} [days]
358
+ * @param {number} [to]
359
+ */
360
+ export function getHeatmap(days = 7, to = Date.now(), model = '') {
361
+ const count = Math.max(1, Math.min(95, Number(days) || 7))
362
+ const end = new Date(Number.isFinite(Number(to)) ? Number(to) : Date.now())
363
+ const start = new Date(end.getFullYear(), end.getMonth(), end.getDate() - (count - 1))
364
+ const startKey = localDateKey(start)
365
+ const endKey = localDateKey(end)
366
+ const grid = Array.from({ length: 7 }, () => Array(24).fill(0))
367
+ const hours = Array(24).fill(0)
368
+ let total = 0
369
+
370
+ for (const [key, bucket] of Object.entries(inMemoryStats.byHour || {})) {
371
+ const datePart = key.slice(0, 10)
372
+ if (datePart < startKey || datePart > endKey) continue
373
+ const hour = Number(key.slice(11, 13))
374
+ if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue
375
+ const date = new Date(`${datePart}T00:00:00`)
376
+ if (Number.isNaN(date.getTime())) continue
377
+ const weekday = (date.getDay() + 6) % 7
378
+ const requests = model
379
+ ? Number(inMemoryStats.byHourModel?.[key]?.[model] || 0)
380
+ : Number(bucket.requests || 0)
381
+ if (requests <= 0) continue
382
+ grid[weekday][hour] += requests
383
+ hours[hour] += requests
384
+ total += requests
385
+ }
386
+
387
+ const modelTotals = new Map()
388
+ for (const [key, perModel] of Object.entries(inMemoryStats.byHourModel || {})) {
389
+ const datePart = key.slice(0, 10)
390
+ if (datePart < startKey || datePart > endKey) continue
391
+ for (const [id, value] of Object.entries(perModel || {})) {
392
+ if (!id) continue
393
+ modelTotals.set(id, (modelTotals.get(id) || 0) + Number(value || 0))
394
+ }
395
+ }
396
+ const models = [...modelTotals.entries()]
397
+ .filter(([, value]) => value > 0)
398
+ .map(([id, value]) => ({ model: id, total: value }))
399
+ .sort((a, b) => b.total - a.total)
400
+
401
+ return { total, grid, hours, models, days: count }
402
+ }
313
403
 
314
404
  /**
315
- * Export usage analytics as a formatted CSV string.
316
- *
317
- * @returns {string} CSV format
405
+ * Aggregate analytics for a time range.
406
+ * @param {{ from?: number|string, to?: number|string, groupBy?: string, limit?: number, model?: string, account?: string, heatmapDays?: number|string, heatmapModel?: string }} [query]
318
407
  */
319
- export function exportStatsAsCsv() {
320
- const lines = ['Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,TotalTokens']
408
+ export function getAnalytics(query = {}) {
409
+ const now = Date.now()
410
+ const from = Number.isFinite(Number(query.from)) ? Number(query.from) : 0
411
+ const to = Number.isFinite(Number(query.to)) ? Number(query.to) : now
412
+ const groupBy = ['session', 'day', 'model', 'account'].includes(query.groupBy) ? query.groupBy : 'model'
413
+ const limit = Math.max(1, Math.min(500, Number(query.limit) || 50))
414
+ const source = groupBy === 'session'
415
+ ? inMemoryStats.bySession
416
+ : groupBy === 'day'
417
+ ? inMemoryStats.byDay
418
+ : groupBy === 'account'
419
+ ? inMemoryStats.byAccount
420
+ : inMemoryStats.byModel
421
+
422
+ const groups = []
423
+ for (const [key, bucket] of Object.entries(source || {})) {
424
+ const at = groupBy === 'day' ? Date.parse(`${key}T00:00:00Z`) : Number(bucket.lastAt || 0)
425
+ if (Number.isFinite(at) && (at < from || at > to)) continue
426
+ if (query.model && bucket.model && bucket.model !== query.model && key !== query.model) continue
427
+ if (query.account && bucket.accountId && bucket.accountId !== query.account && key !== query.account) continue
428
+ const requests = Number(bucket.requests || 0)
429
+ const successful = Number(bucket.successful || 0)
430
+ groups.push({
431
+ key,
432
+ label: key,
433
+ requests,
434
+ successful,
435
+ failed: Number(bucket.failed || 0),
436
+ rateLimited429: Number(bucket.rateLimited429 || 0),
437
+ promptTokens: Number(bucket.promptTokens || 0),
438
+ completionTokens: Number(bucket.completionTokens || 0),
439
+ reasoningTokens: Number(bucket.reasoningTokens || 0),
440
+ cacheReadTokens: Number(bucket.cacheReadTokens || 0),
441
+ cacheWriteTokens: Number(bucket.cacheWriteTokens || 0),
442
+ totalTokens: Number(bucket.totalTokens || 0),
443
+ costUsd: Number(bucket.costUsd || 0),
444
+ pricedRequests: Number(bucket.pricedRequests || 0),
445
+ unpricedRequests: Number(bucket.unpricedRequests || 0),
446
+ successRate: requests > 0 ? (successful / requests) * 100 : 0,
447
+ avgTtftMs: successful > 0 ? Number(bucket.ttftMsSum || 0) / successful : 0,
448
+ avgLatencyMs: requests > 0 ? Number(bucket.latencyMsSum || 0) / requests : 0,
449
+ ttftMsSum: Number(bucket.ttftMsSum || 0),
450
+ latencyMsSum: Number(bucket.latencyMsSum || 0),
451
+ model: bucket.model || '',
452
+ accountId: bucket.accountId || '',
453
+ lastAt: Number(bucket.lastAt || 0),
454
+ })
455
+ }
456
+ groups.sort((a, b) => groupBy === 'session'
457
+ ? b.lastAt - a.lastAt
458
+ : (b.totalTokens - a.totalTokens) || (b.requests - a.requests))
459
+
460
+ const totals = emptyBucket()
461
+ for (const group of groups) {
462
+ totals.requests += group.requests
463
+ totals.successful += group.successful
464
+ totals.failed += group.failed
465
+ totals.rateLimited429 += group.rateLimited429
466
+ totals.promptTokens += group.promptTokens
467
+ totals.completionTokens += group.completionTokens
468
+ totals.reasoningTokens += group.reasoningTokens
469
+ totals.cacheReadTokens += group.cacheReadTokens
470
+ totals.cacheWriteTokens += group.cacheWriteTokens
471
+ totals.totalTokens += group.totalTokens
472
+ totals.costUsd += group.costUsd
473
+ totals.pricedRequests += group.pricedRequests
474
+ totals.unpricedRequests += group.unpricedRequests
475
+ totals.ttftMsSum += group.ttftMsSum
476
+ totals.latencyMsSum += group.latencyMsSum
477
+ if (group.lastAt) {
478
+ totals.firstAt = totals.firstAt ? Math.min(totals.firstAt, group.lastAt) : group.lastAt
479
+ totals.lastAt = Math.max(totals.lastAt, group.lastAt)
480
+ }
481
+ if (group.model) totals.model = group.model
482
+ if (group.accountId) totals.accountId = group.accountId
483
+ }
484
+ totals.successRate = totals.requests > 0 ? (totals.successful / totals.requests) * 100 : 0
485
+ totals.avgTtftMs = totals.successful > 0 ? totals.ttftMsSum / totals.successful : 0
486
+ totals.avgLatencyMs = totals.requests > 0 ? totals.latencyMsSum / totals.requests : 0
487
+
488
+ return {
489
+ range: { from, to },
490
+ totals,
491
+ groups: groups.slice(0, limit),
492
+ unpriced: Number(totals.unpricedRequests || 0),
493
+ heatmap: getHeatmap(query.heatmapDays, to, query.heatmapModel),
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Export CSV. The compact form keeps the historical schema; `detailed`
499
+ * appends cache, cost, TTFT and latency columns.
500
+ * @param {boolean} [detailed]
501
+ */
502
+ export function exportStatsAsCsv(detailed = false) {
503
+ const header = detailed
504
+ ? 'Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,CacheReadTokens,CacheWriteTokens,TotalTokens,CostUsd,TtftMs,LatencyMs'
505
+ : 'Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,TotalTokens'
506
+ const lines = [header]
321
507
  const addRows = (dimension, obj) => {
322
508
  for (const [key, val] of Object.entries(obj || {})) {
323
- lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
509
+ if (detailed) {
510
+ lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.cacheReadTokens || 0},${val.cacheWriteTokens || 0},${val.totalTokens || 0},${Number(val.costUsd || 0).toFixed(6)},${Math.round(val.ttftMsSum || 0)},${Math.round(val.latencyMsSum || 0)}`)
511
+ } else {
512
+ lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
513
+ }
324
514
  }
325
515
  }
326
516
  const totals = inMemoryStats.totals || {}
327
- lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.totalTokens || 0}`)
517
+ if (detailed) {
518
+ lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.cacheReadTokens || 0},${totals.cacheWriteTokens || 0},${totals.totalTokens || 0},${Number(totals.costUsd || 0).toFixed(6)},${Math.round(totals.ttftMsSum || 0)},${Math.round(totals.latencyMsSum || 0)}`)
519
+ } else {
520
+ lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.totalTokens || 0}`)
521
+ }
328
522
  addRows('Day', inMemoryStats.byDay)
329
523
  addRows('Month', inMemoryStats.byMonth)
330
524
  addRows('Model', inMemoryStats.byModel)
331
525
  addRows('Account', inMemoryStats.byAccount)
526
+ if (detailed) {
527
+ addRows('Session', inMemoryStats.bySession)
528
+ addRows('Hour', inMemoryStats.byHour)
529
+ }
332
530
  return lines.join('\n')
333
531
  }