dsh-all-usage 1.1.2 → 1.1.3

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,1002 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { COST_SCHEMA_VERSION, addCostAccumulator, addCostAggregate, calculateCost, createCostAccumulator, decimalSubtract, isCostAccumulator, normalizeCostSnapshot, resolvePricing, serializeCostAggregate } from './pricing.js'
3
+ import { extractUsageEvent, normalizeEventSeq, normalizeUsageValues as usageValues, upsertUsageSample as upsertUsageSampleState, usageStepKey, validEventTime as validUsageEventTime } from './usage-core.js'
4
+
5
+ export function createAggregation(host) {
6
+ const { state, markStatsChanged, commitPendingStats, resetSyncState } = host
7
+ const pricingSnapshot = (...args) => host.pricingSnapshot(...args)
8
+ const syncSnapshot = (...args) => host.syncSnapshot(...args)
9
+
10
+ function dayKey(ms) {
11
+ const d = new Date(ms)
12
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
13
+ }
14
+ function dayKeyUtc(ms) {
15
+ const d = new Date(ms)
16
+ return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
17
+ }
18
+ function dateKeys(ms) {
19
+ const d = new Date(ms)
20
+ const local = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
21
+ const utc = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
22
+ return { local, utc }
23
+ }
24
+ function num(v) {
25
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0
26
+ }
27
+ const validEventTime = validUsageEventTime
28
+ function addDateIndex(index, date, key) {
29
+ if (typeof date !== 'string' || date === '') return
30
+ let keys = index.get(date)
31
+ if (keys === undefined) { keys = new Set(); index.set(date, keys) }
32
+ keys.add(key)
33
+ }
34
+ function removeDateIndex(index, date, key) {
35
+ if (typeof date !== 'string' || date === '') return
36
+ const keys = index.get(date)
37
+ if (keys === undefined) return
38
+ keys.delete(key)
39
+ if (keys.size === 0) index.delete(date)
40
+ }
41
+ function indexUsage(item) {
42
+ addDateIndex(state.usageByLocalDate, item.date, item.key)
43
+ addDateIndex(state.usageByUtcDate, item.dateUtc, item.key)
44
+ }
45
+ function unindexUsage(item) {
46
+ removeDateIndex(state.usageByLocalDate, item.date, item.key)
47
+ removeDateIndex(state.usageByUtcDate, item.dateUtc, item.key)
48
+ }
49
+ function indexedEntriesInRange(index, source, start, end) {
50
+ const result = []
51
+ for (const [date, keys] of index) {
52
+ if (date < start || date > end) continue
53
+ for (const key of keys) {
54
+ const item = source.get(key)
55
+ if (item !== undefined) result.push({ item, date })
56
+ }
57
+ }
58
+ return result
59
+ }
60
+ function usageBasisEqual(first, identity, values) {
61
+ if (first === null || first === undefined) return false
62
+ const firstIdentity = first.identity || first.modelId
63
+ const left = coerceIdentity(firstIdentity)
64
+ const right = coerceIdentity(identity)
65
+ if (left.identityKey !== right.identityKey) return false
66
+ return ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'].every((key) => num(first.values && first.values[key]) === num(values && values[key]))
67
+ }
68
+ function resolveCurrentPricing(identity) {
69
+ const normalized = coerceIdentity(identity)
70
+ const key = normalized.identityKey
71
+ const cached = state.pricingResolutionCache.get(key)
72
+ if (cached !== undefined) return cached
73
+ const resolved = resolvePricing(normalized, state.pricingState)
74
+ state.pricingResolutionCache.set(key, resolved)
75
+ while (state.pricingResolutionCache.size > 5000) state.pricingResolutionCache.delete(state.pricingResolutionCache.keys().next().value)
76
+ return resolved
77
+ }
78
+ function costForUsage(values, identity, previous) {
79
+ const previousCost = normalizeCostSnapshot(previous && previous.cost)
80
+ if (previousCost !== null && previousCost.pricingMode === 'official-model' && usageBasisEqual(previous, identity, values)) return previousCost
81
+ return calculateCost(values, resolveCurrentPricing(identity))
82
+ }
83
+ function resetAggregationState() {
84
+ state.aggregationGeneration += 1
85
+ state.wsMeta.clear()
86
+ state.pathIndex.clear()
87
+ state.memberOf.clear()
88
+ state.byDay.clear()
89
+ state.byDayUtc.clear()
90
+ state.perWorkspace.clear()
91
+ state.perModel.clear()
92
+ state.usageByStep.clear()
93
+ state.turnRecords.clear()
94
+ state.usageByLocalDate.clear()
95
+ state.usageByUtcDate.clear()
96
+ for (const timer of state.liveResyncTimers.values()) clearTimeout(timer)
97
+ state.liveResyncTimers.clear()
98
+ state.liveResyncAttempts.clear()
99
+ state.liveResyncPending.clear()
100
+ state.reconcileHintScheduled = false
101
+ if (state.baselineFallbackTimer !== null) {
102
+ clearTimeout(state.baselineFallbackTimer)
103
+ state.baselineFallbackTimer = null
104
+ }
105
+ state.baselineRetryScheduled = false
106
+ state.queryCache.clear()
107
+ state.recordsQueryCache.clear()
108
+ state.sessionModel.clear()
109
+ state.sessionCount.clear()
110
+ state.sessionSeq.clear()
111
+ state.chains.clear()
112
+ state.knownSessionIds.clear()
113
+ state.totals.turns = 0
114
+ state.totals.input = 0
115
+ state.totals.output = 0
116
+ state.totals.cacheRead = 0
117
+ state.totals.cacheWrite = 0
118
+ state.totals.reasoning = 0
119
+ Object.assign(state.totals.cost, createCostAccumulator())
120
+ state.scan.started = false
121
+ state.scan.done = false
122
+ state.scan.scanned = 0
123
+ state.scan.total = 0
124
+ state.scan.failed = 0
125
+ resetSyncState()
126
+ state.baselineRetryDelay = 1000
127
+ markStatsChanged(['data', 'metadata', 'scan'])
128
+ return state.aggregationGeneration
129
+ }
130
+ function ensureDay(dayMap, date) {
131
+ let day = dayMap.get(date)
132
+ if (day === undefined) {
133
+ day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: createCostAccumulator(), perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set(), sessionRefs: new Map(), queryUsage: new Map(), queryTurns: new Map(), queryHours: new Map() }
134
+ dayMap.set(date, day)
135
+ markStatsChanged('metadata')
136
+ }
137
+ return day
138
+ }
139
+ function ensureWs(wsId) {
140
+ let ws = state.perWorkspace.get(wsId)
141
+ if (ws === undefined) {
142
+ ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
143
+ state.perWorkspace.set(wsId, ws)
144
+ markStatsChanged('metadata')
145
+ }
146
+ return ws
147
+ }
148
+ function ensureDayWs(day, wsId) {
149
+ let w = day.byWs.get(wsId)
150
+ if (w === undefined) {
151
+ w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
152
+ day.byWs.set(wsId, w)
153
+ }
154
+ return w
155
+ }
156
+ function ensureModel(value) {
157
+ const identity = coerceIdentity(value)
158
+ let item = state.perModel.get(identity.identityKey)
159
+ if (item === undefined) {
160
+ item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
161
+ state.perModel.set(identity.identityKey, item)
162
+ markStatsChanged('metadata')
163
+ }
164
+ return item
165
+ }
166
+ function ensureDayModel(day, value) {
167
+ const identity = coerceIdentity(value)
168
+ let item = day.byModel.get(identity.identityKey)
169
+ if (item === undefined) {
170
+ item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
171
+ day.byModel.set(identity.identityKey, item)
172
+ }
173
+ return item
174
+ }
175
+ function adjustValues(target, values, direction) {
176
+ target.input += values.input * direction
177
+ target.output += values.output * direction
178
+ target.cacheRead += values.cacheRead * direction
179
+ target.cacheWrite += values.cacheWrite * direction
180
+ target.reasoning += values.reasoning * direction
181
+ }
182
+ function addQueryMetricTokens(target, values, direction = 1) {
183
+ target.input += values.input * direction
184
+ target.output += values.output * direction
185
+ target.cacheRead += values.cacheRead * direction
186
+ target.cacheWrite += values.cacheWrite * direction
187
+ target.reasoning += values.reasoning * direction
188
+ }
189
+ function noValues(target) {
190
+ return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
191
+ }
192
+ function adjustDaySession(day, sid, direction) {
193
+ if (sid === undefined || sid === null) return
194
+ const current = day.sessionRefs.get(sid) || 0
195
+ const next = current + direction
196
+ if (next > 0) {
197
+ day.sessionRefs.set(sid, next)
198
+ day.sessionIds.add(sid)
199
+ } else {
200
+ day.sessionRefs.delete(sid)
201
+ day.sessionIds.delete(sid)
202
+ }
203
+ }
204
+ function adjustDay(dayMap, date, wsId, values, identity, direction, sid, cost) {
205
+ const day = ensureDay(dayMap, date)
206
+ adjustDaySession(day, sid, direction)
207
+ adjustValues(day.tokens, values, direction)
208
+ addCostAggregateDirection(day.cost, cost, direction)
209
+ const dayWs = ensureDayWs(day, wsId)
210
+ adjustValues(dayWs, values, direction)
211
+ addCostAggregateDirection(dayWs.cost, cost, direction)
212
+ if (noValues(dayWs)) day.byWs.delete(wsId)
213
+ const dayModel = ensureDayModel(day, identity)
214
+ dayModel.calls += direction
215
+ adjustValues(dayModel, values, direction)
216
+ addCostAggregateDirection(dayModel.cost, cost, direction)
217
+ if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
218
+ }
219
+ function addCostAggregateDirection(target, cost, direction) {
220
+ if (direction === 1) {
221
+ if (isCostAccumulator(target)) addCostAccumulator(target, cost, 1)
222
+ else addCostAggregate(target, cost)
223
+ return
224
+ }
225
+ if (direction !== -1) return
226
+ if (isCostAccumulator(target)) {
227
+ addCostAccumulator(target, cost, -1)
228
+ return
229
+ }
230
+ const status = cost && typeof cost.status === 'string' ? cost.status : 'unpriced'
231
+ if (status === 'priced') {
232
+ target.input = decimalSubtract(target.input, cost.breakdown && cost.breakdown.input)
233
+ target.output = decimalSubtract(target.output, cost.breakdown && cost.breakdown.output)
234
+ target.cacheRead = decimalSubtract(target.cacheRead, cost.breakdown && cost.breakdown.cacheRead)
235
+ target.cacheWrite = decimalSubtract(target.cacheWrite, cost.breakdown && cost.breakdown.cacheWrite)
236
+ target.baseTotal = decimalSubtract(target.baseTotal, cost.baseTotal)
237
+ target.total = decimalSubtract(target.total, cost.total)
238
+ target.pricedCalls -= 1
239
+ } else if (status === 'ambiguous') target.ambiguousCalls -= 1
240
+ else if (status === 'unsupported') target.unsupportedCalls -= 1
241
+ else target.unpricedCalls -= 1
242
+ }
243
+ function adjustQueryRefs(bucket, field, sid, direction) {
244
+ if (sid === undefined || sid === null) return
245
+ const refs = field === 'sessions' ? bucket.sessionRefs : bucket[field + 'Refs']
246
+ const values = bucket[field]
247
+ const current = refs.get(sid) || 0
248
+ const next = current + direction
249
+ if (next > 0) { refs.set(sid, next); values.add(sid) }
250
+ else { refs.delete(sid); values.delete(sid) }
251
+ }
252
+ function queryUsageMetric(identity) {
253
+ return { identity: coerceIdentity(identity), calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator(), sessions: new Set(), sessionRefs: new Map(), turnKeys: new Set(), turnKeyRefs: new Map() }
254
+ }
255
+ function queryTurnMetric(identity) {
256
+ return { identity: coerceIdentity(identity), turns: 0, sessions: new Set(), sessionRefs: new Map(), records: new Map() }
257
+ }
258
+ function queryWorkspaceMap(root, wsId, create) {
259
+ let value = root.get(wsId)
260
+ if (value === undefined && create) { value = new Map(); root.set(wsId, value) }
261
+ return value
262
+ }
263
+ function adjustQueryUsageMap(root, item, direction) {
264
+ const identity = coerceIdentity(item.identity || item.modelId)
265
+ const workspace = queryWorkspaceMap(root, item.wsId, direction === 1)
266
+ if (workspace === undefined) return
267
+ let bucket = workspace.get(identity.identityKey)
268
+ if (bucket === undefined && direction === 1) { bucket = queryUsageMetric(identity); workspace.set(identity.identityKey, bucket) }
269
+ if (bucket === undefined) return
270
+ bucket.calls += direction
271
+ adjustQueryRefs(bucket, 'sessions', item.sid, direction)
272
+ addQueryMetricTokens(bucket, item.values, direction)
273
+ addCostAccumulator(bucket.cost, item.cost, direction)
274
+ if (item.turn !== null && item.turn !== undefined) {
275
+ const turnKey = String(item.sid) + ':turn:' + String(item.turn)
276
+ const current = bucket.turnKeyRefs.get(turnKey) || 0
277
+ const next = current + direction
278
+ if (next > 0) { bucket.turnKeyRefs.set(turnKey, next); bucket.turnKeys.add(turnKey) }
279
+ else { bucket.turnKeyRefs.delete(turnKey); bucket.turnKeys.delete(turnKey) }
280
+ }
281
+ if (direction === -1 && bucket.calls <= 0) workspace.delete(identity.identityKey)
282
+ if (workspace.size === 0) root.delete(item.wsId)
283
+ }
284
+ function ensureQueryHour(day, hour) {
285
+ let value = day.queryHours.get(hour)
286
+ if (value === undefined) { value = { usage: new Map(), turns: new Map() }; day.queryHours.set(hour, value) }
287
+ return value
288
+ }
289
+ function adjustQueryUsage(day, item, direction, utc) {
290
+ if (day === undefined) return
291
+ adjustQueryUsageMap(day.queryUsage, item, direction)
292
+ const hour = hourStartOf(item.time, utc)
293
+ adjustQueryUsageMap(ensureQueryHour(day, hour).usage, item, direction)
294
+ const hourValue = day.queryHours.get(hour)
295
+ if (hourValue !== undefined && hourValue.usage.size === 0 && hourValue.turns.size === 0) day.queryHours.delete(hour)
296
+ }
297
+ function adjustQueryTurnMap(root, turn, direction) {
298
+ const identity = coerceIdentity(turn.identity)
299
+ const workspace = queryWorkspaceMap(root, turn.wsId, direction === 1)
300
+ if (workspace === undefined) return
301
+ let bucket = workspace.get(identity.identityKey)
302
+ if (bucket === undefined && direction === 1) { bucket = queryTurnMetric(identity); workspace.set(identity.identityKey, bucket) }
303
+ if (bucket === undefined) return
304
+ if (direction === 1) {
305
+ if (bucket.records.has(turn.key)) return
306
+ bucket.records.set(turn.key, turn)
307
+ bucket.turns += 1
308
+ adjustQueryRefs(bucket, 'sessions', turn.sid, 1)
309
+ } else {
310
+ const previous = bucket.records.get(turn.key)
311
+ if (previous === undefined) return
312
+ bucket.records.delete(turn.key)
313
+ bucket.turns -= 1
314
+ adjustQueryRefs(bucket, 'sessions', previous.sid, -1)
315
+ }
316
+ if (direction === -1 && bucket.turns <= 0) workspace.delete(identity.identityKey)
317
+ if (workspace.size === 0) root.delete(turn.wsId)
318
+ }
319
+ function adjustQueryTurn(day, turn, direction, utc) {
320
+ if (day === undefined) return
321
+ adjustQueryTurnMap(day.queryTurns, turn, direction)
322
+ const hour = hourStartOf(turn.time, utc)
323
+ const hourValue = ensureQueryHour(day, hour)
324
+ adjustQueryTurnMap(hourValue.turns, turn, direction)
325
+ if (hourValue.usage.size === 0 && hourValue.turns.size === 0) day.queryHours.delete(hour)
326
+ }
327
+ function adjustUsage(wsId, time, values, identity, direction, sid, cachedDates, cost) {
328
+ const normalized = coerceIdentity(identity)
329
+ const dates = cachedDates && typeof cachedDates.local === 'string' && typeof cachedDates.utc === 'string' ? cachedDates : dateKeys(time)
330
+ const modelTotals = ensureModel(normalized)
331
+ modelTotals.calls += direction
332
+ adjustValues(modelTotals, values, direction)
333
+ addCostAggregateDirection(modelTotals.cost, cost, direction)
334
+ if (modelTotals.calls === 0 && noValues(modelTotals)) {
335
+ state.perModel.delete(normalized.identityKey)
336
+ markStatsChanged('metadata')
337
+ }
338
+ adjustValues(state.totals, values, direction)
339
+ addCostAggregateDirection(state.totals.cost, cost, direction)
340
+ const ws = ensureWs(wsId)
341
+ adjustValues(ws, values, direction)
342
+ addCostAggregateDirection(ws.cost, cost, direction)
343
+ adjustDay(state.byDay, dates.local, wsId, values, normalized, direction, sid, cost)
344
+ adjustDay(state.byDayUtc, dates.utc, wsId, values, normalized, direction, sid, cost)
345
+ }
346
+ function upsertUsageSample(target, sample, options = {}) {
347
+ const previous = target.get(sample.key)
348
+ const candidate = sample.cost === undefined
349
+ ? { ...sample, cost: costForUsage(sample.values, sample.identity, options.costPrevious === undefined ? previous : options.costPrevious) }
350
+ : sample
351
+ const result = upsertUsageSampleState(target, candidate)
352
+ if (!result.accepted || target !== state.usageByStep || options.materialize === false) return result
353
+ const previousItem = result.previous
354
+ if (previousItem !== undefined) {
355
+ unindexUsage(previousItem)
356
+ adjustUsage(previousItem.wsId, previousItem.time, previousItem.values, previousItem.identity || previousItem.modelId, -1, previousItem.sid, { local: previousItem.date, utc: previousItem.dateUtc }, previousItem.cost)
357
+ adjustQueryUsage(state.byDay.get(previousItem.date), previousItem, -1, false)
358
+ adjustQueryUsage(state.byDayUtc.get(previousItem.dateUtc), previousItem, -1, true)
359
+ }
360
+ const next = result.next
361
+ indexUsage(next)
362
+ adjustUsage(next.wsId, next.time, next.values, next.identity, 1, next.sid, { local: next.date, utc: next.dateUtc }, next.cost)
363
+ adjustQueryUsage(state.byDay.get(next.date), next, 1, false)
364
+ adjustQueryUsage(state.byDayUtc.get(next.dateUtc), next, 1, true)
365
+ markStatsChanged('data')
366
+ return result
367
+ }
368
+ function adjustQueryCost(item, cost, direction) {
369
+ if (item === null || item === undefined || typeof item !== 'object') return
370
+ const identity = coerceIdentity(item.identity || item.modelId)
371
+ const dates = typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
372
+ const update = (day, utc) => {
373
+ if (day === undefined) return
374
+ const models = day.queryUsage.get(item.wsId)
375
+ const bucket = models && models.get(identity.identityKey)
376
+ if (bucket !== undefined) addCostAccumulator(bucket.cost, cost, direction)
377
+ const hour = hourStartOf(item.time, utc)
378
+ const hourValue = day.queryHours.get(hour)
379
+ const hourModels = hourValue && hourValue.usage.get(item.wsId)
380
+ const hourBucket = hourModels && hourModels.get(identity.identityKey)
381
+ if (hourBucket !== undefined) addCostAccumulator(hourBucket.cost, cost, direction)
382
+ }
383
+ update(state.byDay.get(dates.local), false)
384
+ update(state.byDayUtc.get(dates.utc), true)
385
+ }
386
+ function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live') {
387
+ if (!validEventTime(time)) return
388
+ const values = usageValues(usage)
389
+ if (values === null || noValues(values)) return
390
+ const identity = coerceIdentity(model)
391
+ const eventSeq = normalizeEventSeq(seq)
392
+ const dates = dateKeys(time)
393
+ const key = usageStepKey(sid, data, seq)
394
+ upsertUsageSample(state.usageByStep, {
395
+ key,
396
+ seq: eventSeq,
397
+ wsId,
398
+ time,
399
+ date: dates.local,
400
+ dateUtc: dates.utc,
401
+ values,
402
+ identity,
403
+ modelId: identity.label,
404
+ turn: data && Number.isSafeInteger(data.turn) && data.turn >= 0 ? data.turn : null,
405
+ step: data && Number.isSafeInteger(data.step) && data.step >= 0 ? data.step : null,
406
+ materialization,
407
+ sid,
408
+ })
409
+ }
410
+ function addDayTurn(dayMap, date, wsId, sid) {
411
+ const day = ensureDay(dayMap, date)
412
+ adjustDaySession(day, sid, 1)
413
+ day.turns += 1
414
+ day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
415
+ }
416
+ function turnRecordKey(sid, turn, seq, time) {
417
+ if (Number.isSafeInteger(turn)) return sid + ':turn:' + turn
418
+ if (Number.isSafeInteger(seq) && seq >= 0) return sid + ':event:' + seq
419
+ return sid + ':time:' + String(time)
420
+ }
421
+ function addTurn(wsId, time, sid, turn, identity, materialization = 'live', seq) {
422
+ if (!validEventTime(time)) return
423
+ const key = turnRecordKey(sid, turn, seq, time)
424
+ if (state.turnRecords.has(key)) return
425
+ const normalized = coerceIdentity(identity)
426
+ const dates = dateKeys(time)
427
+ const record = { key, sid, wsId, time, date: dates.local, dateUtc: dates.utc, turn: typeof turn === 'number' ? turn : null, identity: normalized, materialization }
428
+ state.turnRecords.set(key, record)
429
+ ensureWs(wsId).turns += 1
430
+ state.totals.turns += 1
431
+ addDayTurn(state.byDay, dates.local, wsId, sid)
432
+ addDayTurn(state.byDayUtc, dates.utc, wsId, sid)
433
+ adjustQueryTurn(state.byDay.get(record.date), record, 1, false)
434
+ adjustQueryTurn(state.byDayUtc.get(record.dateUtc), record, 1, true)
435
+ markStatsChanged('data')
436
+ }
437
+ const UNKNOWN_MODEL_LABEL = '未知模型(历史记录缺少路由)'
438
+ function textOrNull(value) {
439
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
440
+ }
441
+ function identityLabel(provider, requestedModel, actualModel, legacyLabel) {
442
+ const model = actualModel || requestedModel
443
+ if (model !== null) return provider === null ? model : provider + ' / ' + model
444
+ return legacyLabel || UNKNOWN_MODEL_LABEL
445
+ }
446
+ function makeIdentity(provider, requestedModel, actualModel, legacyLabel) {
447
+ const normalizedProvider = textOrNull(provider)
448
+ const normalizedRequested = textOrNull(requestedModel)
449
+ const normalizedActual = textOrNull(actualModel)
450
+ const normalizedLegacy = textOrNull(legacyLabel)
451
+ const key = JSON.stringify([normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy])
452
+ return {
453
+ identityKey: key,
454
+ provider: normalizedProvider,
455
+ requestedModel: normalizedRequested,
456
+ actualModel: normalizedActual,
457
+ label: identityLabel(normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy),
458
+ legacy: normalizedLegacy !== null && normalizedProvider === null && normalizedRequested === null && normalizedActual === null,
459
+ }
460
+ }
461
+ function identityFromLegacy(label) {
462
+ return makeIdentity(null, null, null, textOrNull(label) || UNKNOWN_MODEL_LABEL)
463
+ }
464
+ function isCanonicalIdentity(value) {
465
+ return value !== null && typeof value === 'object' && typeof value.identityKey === 'string' && typeof value.label === 'string' && (value.provider === null || typeof value.provider === 'string') && (value.requestedModel === null || typeof value.requestedModel === 'string') && (value.actualModel === null || typeof value.actualModel === 'string') && typeof value.legacy === 'boolean'
466
+ }
467
+ function coerceIdentity(value) {
468
+ if (isCanonicalIdentity(value)) return value
469
+ if (value !== null && typeof value === 'object') {
470
+ return makeIdentity(value.provider, value.requestedModel, value.actualModel, value.legacyLabel || (value.legacy === true ? value.label : null))
471
+ }
472
+ if (typeof value === 'string' && value !== '') return identityFromLegacy(value)
473
+ return makeIdentity(null, null, null, UNKNOWN_MODEL_LABEL)
474
+ }
475
+ function routeObject(data) {
476
+ if (data && typeof data === 'object' && (data.provider !== undefined || data.model !== undefined)) return data
477
+ const config = data && data.header && data.header.config
478
+ return config && typeof config === 'object' ? config : null
479
+ }
480
+ function identityFromRoute(data, fallback) {
481
+ const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
482
+ const route = routeObject(data)
483
+ if (route === null) return base
484
+ return makeIdentity(route.provider === undefined ? base.provider : route.provider, route.model === undefined ? base.requestedModel : route.model, null, base.legacy ? base.label : null)
485
+ }
486
+ function identityFromMessage(data, fallback) {
487
+ const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
488
+ const source = data && data.message && data.message.source
489
+ if (source === null || typeof source !== 'object') return base
490
+ return makeIdentity(source.provider === undefined ? base.provider : source.provider, base.requestedModel || source.model, source.model, base.legacy ? base.label : null)
491
+ }
492
+ function modelFromRoute(data) {
493
+ const identity = identityFromRoute(data)
494
+ return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
495
+ }
496
+ function modelFromMessage(data, fallback) {
497
+ return identityFromMessage(data, fallback).label
498
+ }
499
+
500
+ // ---------- snapshot for the client ----------
501
+ function scanSnapshot() {
502
+ return { started: state.scan.started, done: state.scan.done, scanned: state.scan.scanned, total: state.scan.total, failed: state.scan.failed }
503
+ }
504
+ function queryRevision() {
505
+ return String(state.dataRevision) + ':' + String(state.pricingRevision)
506
+ }
507
+ function revisionSnapshot() {
508
+ return { revision: state.statsRevision, dataRevision: state.dataRevision, metadataRevision: state.metadataRevision, scanRevision: state.scanRevision, pricingRevision: state.pricingRevision, queryRevision: queryRevision() }
509
+ }
510
+ function statusSnapshot() {
511
+ commitPendingStats()
512
+ return { instanceId: state.instanceId, ...revisionSnapshot(), updatedAt: state.statsUpdatedAt, scan: scanSnapshot(), sync: syncSnapshot() }
513
+ }
514
+ function serializeIdentity(identity) {
515
+ const value = coerceIdentity(identity)
516
+ return { identityKey: value.identityKey, provider: value.provider, requestedModel: value.requestedModel, actualModel: value.actualModel, model: value.label, legacy: value.legacy }
517
+ }
518
+ function serializeModelAggregate(item) {
519
+ return { ...serializeIdentity(item), calls: item.calls, input: item.input, output: item.output, cacheRead: item.cacheRead, cacheWrite: item.cacheWrite, reasoning: item.reasoning, cost: serializeCostAggregate(item.cost) }
520
+ }
521
+ function serializeDays(dayMap) {
522
+ const result = []
523
+ for (const pair of dayMap) {
524
+ const date = pair[0]
525
+ const day = pair[1]
526
+ result.push({
527
+ date,
528
+ turns: day.turns,
529
+ sessions: day.sessionIds.size,
530
+ sessionIds: Array.from(day.sessionIds).sort(),
531
+ tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
532
+ cost: serializeCostAggregate(day.cost),
533
+ perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
534
+ byWorkspace: Array.from(day.byWs, (p) => ({ workspaceId: p[0], input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning, cost: serializeCostAggregate(day.byWs.get(p[0]).cost) })),
535
+ byModel: Array.from(day.byModel, (p) => serializeModelAggregate(p[1])),
536
+ })
537
+ }
538
+ result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
539
+ return result
540
+ }
541
+ function snapshot() {
542
+ commitPendingStats()
543
+ const generatedAt = Date.now()
544
+ if (state.snapshotCache !== null && state.snapshotCache.revision === state.statsRevision) return Object.assign({}, state.snapshotCache.value, { generatedAt })
545
+ const value = {
546
+ ...statusSnapshot(),
547
+ generatedAt,
548
+ usageSchemaVersion: 3,
549
+ costSchemaVersion: COST_SCHEMA_VERSION,
550
+ requestToken: state.requestToken,
551
+ workspaces: Array.from(state.wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
552
+ aliases: Object.assign({}, state.aliases),
553
+ pricing: pricingSnapshot({ detailed: false }),
554
+ tokenSemantics: {
555
+ processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
556
+ cacheRead: 'reused context tokens; not newly generated output',
557
+ cacheWrite: 'tokens written into a provider cache',
558
+ // v1.0.7: structured, machine-readable accounting semantics (cc-switch
559
+ // input_token_semantics parity). DSH reports input as fresh (cache read /
560
+ // cache write sit in their own buckets) — verified against real ledger data;
561
+ // reasoning is bucketed separately from output and assumed non-overlapping.
562
+ semantics: {
563
+ input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
564
+ buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
565
+ inputIncludesCache: false,
566
+ cacheBucketed: true,
567
+ reasoningSeparate: true,
568
+ gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
569
+ },
570
+ },
571
+ costSemantics: {
572
+ source: 'models.dev',
573
+ currency: 'USD',
574
+ buckets: ['input', 'output', 'cacheRead', 'cacheWrite'],
575
+ input: 'fresh (DSH TokenUsage already excludes cache)',
576
+ reasoning: 'not added to output again; provider output already carries completion/thoughts where reported',
577
+ multiplier: 'applies only to final total',
578
+ providerMatching: 'DSH provider is ignored; only the official model vendor entry is selected',
579
+ historical: 'positive non-tiered cost snapshots are stable; legacy tiered snapshots migrate to unsupported; only unresolved usage is eligible for backfill',
580
+ },
581
+ totals: { turns: state.totals.turns, sessions: state.sessionCount.size, input: state.totals.input, output: state.totals.output, cacheRead: state.totals.cacheRead, cacheWrite: state.totals.cacheWrite, reasoning: state.totals.reasoning, cost: serializeCostAggregate(state.totals.cost) },
582
+ perWorkspace: Array.from(state.perWorkspace, (p) => ({ workspaceId: p[0], turns: p[1].turns, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning, cost: serializeCostAggregate(p[1].cost) })),
583
+ perModel: Array.from(state.perModel.values(), (item) => serializeModelAggregate(item)),
584
+ byDay: serializeDays(state.byDay),
585
+ byDayUtc: serializeDays(state.byDayUtc),
586
+ }
587
+ state.snapshotCache = { revision: state.statsRevision, value }
588
+ return Object.assign({}, value, { generatedAt })
589
+ }
590
+
591
+ function validDateText(value) {
592
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
593
+ const year = Number(value.slice(0, 4))
594
+ const month = Number(value.slice(5, 7))
595
+ const day = Number(value.slice(8, 10))
596
+ const date = new Date(Date.UTC(year, month - 1, day))
597
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
598
+ }
599
+ function shiftDateText(value, days, utc) {
600
+ const parts = value.split('-').map(Number)
601
+ const date = utc ? new Date(Date.UTC(parts[0], parts[1] - 1, parts[2] + days)) : new Date(parts[0], parts[1] - 1, parts[2] + days)
602
+ return utc ? dayKeyUtc(date.getTime()) : dayKey(date.getTime())
603
+ }
604
+ function queryScopeFromRequest(req) {
605
+ let url
606
+ try { url = new URL(req.url || '/', 'http://all-usage.local') } catch (err) { return { ok: false, message: 'bad-query' } }
607
+ const rawUtc = url.searchParams.get('utc')
608
+ if (rawUtc !== null && rawUtc !== '' && rawUtc !== '0' && rawUtc !== '1') return { ok: false, message: 'invalid-timezone' }
609
+ const utc = rawUtc === '1'
610
+ const today = utc ? dayKeyUtc(Date.now()) : dayKey(Date.now())
611
+ const start = url.searchParams.get('start') || today
612
+ const end = url.searchParams.get('end') || today
613
+ if (!validDateText(start) || !validDateText(end) || start > end) return { ok: false, message: 'invalid-date-range' }
614
+ const readParam = (name, max) => {
615
+ const value = url.searchParams.get(name)
616
+ if (value === null || value === '') return undefined
617
+ return value.length <= max ? value : null
618
+ }
619
+ const workspaceId = readParam('workspaceId', 256)
620
+ const provider = readParam('provider', 256)
621
+ const modelKey = readParam('modelKey', 1024)
622
+ if (workspaceId === null || provider === null || modelKey === null) return { ok: false, message: 'query-too-long' }
623
+ return { ok: true, scope: { start, end, utc, workspaceId, provider, modelKey } }
624
+ }
625
+ function scopeFingerprint(scope) {
626
+ return JSON.stringify({ start: scope.start, end: scope.end, utc: scope.utc === true, workspaceId: scope.workspaceId || null, provider: scope.provider || null, modelKey: scope.modelKey || null })
627
+ }
628
+ function dateInScope(date, scope) {
629
+ return date >= scope.start && date <= scope.end
630
+ }
631
+ function modelNameOfIdentity(identity) {
632
+ const normalized = coerceIdentity(identity)
633
+ const structured = normalized.actualModel || normalized.requestedModel
634
+ if (structured !== null) return structured
635
+ if (normalized.legacy && typeof normalized.label === 'string') {
636
+ const separator = normalized.label.indexOf(' / ')
637
+ if (separator > 0) return normalized.label.slice(separator + 3)
638
+ }
639
+ return normalized.label
640
+ }
641
+ function identityMatchesScope(identity, scope) {
642
+ const normalized = coerceIdentity(identity)
643
+ if (scope.provider !== undefined && scope.provider !== null && normalized.provider !== scope.provider) return false
644
+ if (scope.modelKey !== undefined && scope.modelKey !== null && normalized.identityKey !== scope.modelKey && modelNameOfIdentity(normalized) !== scope.modelKey) return false
645
+ return true
646
+ }
647
+ function queryMetric() {
648
+ return { turns: 0, calls: 0, sessions: new Set(), turnKeys: new Set(), input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
649
+ }
650
+ function queryAggregate() {
651
+ return { totals: queryMetric(), days: new Map(), workspaces: new Map(), models: new Map() }
652
+ }
653
+ function queryDay(aggregate, date) {
654
+ let day = aggregate.days.get(date)
655
+ if (day === undefined) { day = queryMetric(); day.date = date; aggregate.days.set(date, day) }
656
+ return day
657
+ }
658
+ function queryWorkspace(aggregate, workspaceId) {
659
+ let row = aggregate.workspaces.get(workspaceId)
660
+ if (row === undefined) { row = queryMetric(); row.workspaceId = workspaceId; aggregate.workspaces.set(workspaceId, row) }
661
+ return row
662
+ }
663
+ function queryModel(aggregate, identity) {
664
+ const normalized = coerceIdentity(identity)
665
+ let row = aggregate.models.get(normalized.identityKey)
666
+ if (row === undefined) { row = queryMetric(); Object.assign(row, serializeIdentity(normalized)); aggregate.models.set(normalized.identityKey, row) }
667
+ return row
668
+ }
669
+ function addQueryTokens(metric, values) {
670
+ metric.input += values.input
671
+ metric.output += values.output
672
+ metric.cacheRead += values.cacheRead
673
+ metric.cacheWrite += values.cacheWrite
674
+ metric.reasoning += values.reasoning
675
+ }
676
+ function mergeQuerySessions(target, sessions) {
677
+ for (const sid of sessions) target.sessions.add(sid)
678
+ }
679
+ function mergeQueryUsageMetric(target, bucket) {
680
+ target.calls += bucket.calls
681
+ mergeQuerySessions(target, bucket.sessions)
682
+ addQueryTokens(target, bucket)
683
+ addCostAccumulator(target.cost, bucket.cost)
684
+ }
685
+ function addQueryUsageBucket(aggregate, bucket, date, workspaceId) {
686
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, workspaceId)]
687
+ targets.push(queryModel(aggregate, bucket.identity))
688
+ for (const target of targets) mergeQueryUsageMetric(target, bucket)
689
+ }
690
+ function addQueryTurnMetric(target, turn) {
691
+ if (target.turnKeys.has(turn.key)) return
692
+ target.turnKeys.add(turn.key)
693
+ target.turns += 1
694
+ target.sessions.add(turn.sid)
695
+ }
696
+ function mergeQueryTurnBucket(target, bucket) {
697
+ target.turns += bucket.turns
698
+ mergeQuerySessions(target, bucket.sessions)
699
+ }
700
+ function addQueryTurn(aggregate, turn, date) {
701
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, turn.wsId)]
702
+ targets.push(queryModel(aggregate, turn.identity))
703
+ for (const target of targets) addQueryTurnMetric(target, turn)
704
+ }
705
+ function addQueryTurnBucket(aggregate, bucket, date, workspaceId) {
706
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, workspaceId)]
707
+ targets.push(queryModel(aggregate, bucket.identity))
708
+ for (const target of targets) mergeQueryTurnBucket(target, bucket)
709
+ }
710
+ function finalizeQueryMetric(metric) {
711
+ return { turns: metric.turns, calls: metric.calls, sessions: metric.sessions.size, input: metric.input, output: metric.output, cacheRead: metric.cacheRead, cacheWrite: metric.cacheWrite, reasoning: metric.reasoning, cost: serializeCostAggregate(metric.cost) }
712
+ }
713
+ function finalizeQueryAggregate(aggregate) {
714
+ const daily = Array.from(aggregate.days.values()).sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0).map((day) => ({ date: day.date, ...finalizeQueryMetric(day), tokens: { input: day.input, output: day.output, cacheRead: day.cacheRead, cacheWrite: day.cacheWrite, reasoning: day.reasoning } }))
715
+ const perWorkspace = Array.from(aggregate.workspaces.values()).map((row) => ({ workspaceId: row.workspaceId, ...finalizeQueryMetric(row) }))
716
+ const perModel = Array.from(aggregate.models.values()).filter((row) => row.calls > 0 || row.input > 0 || row.output > 0 || row.cacheRead > 0 || row.cacheWrite > 0 || row.reasoning > 0).map((row) => ({ identityKey: row.identityKey, provider: row.provider, requestedModel: row.requestedModel, actualModel: row.actualModel, model: row.model, legacy: row.legacy, ...finalizeQueryMetric(row) }))
717
+ return { totals: finalizeQueryMetric(aggregate.totals), daily, perWorkspace, perModel }
718
+ }
719
+ function heatmapDay(days, date) {
720
+ let day = days.get(date)
721
+ if (day === undefined) { day = { date, turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWorkspace: new Map() }; days.set(date, day) }
722
+ return day
723
+ }
724
+ function addHeatmapUsage(days, bucket, date) {
725
+ const day = heatmapDay(days, date)
726
+ addQueryTokens(day.tokens, bucket)
727
+ }
728
+ function addHeatmapTurnBucket(days, bucket, date, workspaceId) {
729
+ const day = heatmapDay(days, date)
730
+ day.turns += bucket.turns
731
+ day.perWorkspace.set(workspaceId, (day.perWorkspace.get(workspaceId) || 0) + bucket.turns)
732
+ }
733
+ function addHeatmapTurns(days, records, date) {
734
+ const day = heatmapDay(days, date)
735
+ for (const turn of records) {
736
+ day.turns += 1
737
+ day.perWorkspace.set(turn.wsId, (day.perWorkspace.get(turn.wsId) || 0) + 1)
738
+ }
739
+ }
740
+ function serializeHeatmap(days) {
741
+ return Array.from(days.values()).sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0).map((day) => ({ date: day.date, turns: day.turns, tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning }, perWorkspace: Array.from(day.perWorkspace, (pair) => ({ workspaceId: pair[0], turns: pair[1] })) }))
742
+ }
743
+ const HOUR_MS = 60 * 60 * 1000
744
+ function hourStartOf(time, utc) {
745
+ const date = new Date(time)
746
+ if (utc) return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours())
747
+ // Reconstructing a local hour with new Date(y, m, d, h) collapses a DST
748
+ // fall-back twice-repeated hour (both 01:xx map to the first occurrence).
749
+ // Instead align the instant to the local hour boundary using the offset
750
+ // that applied at that instant, keeping repeated local hours distinct.
751
+ const offset = date.getTimezoneOffset()
752
+ const shifted = time - offset * 60000
753
+ return Math.floor(shifted / HOUR_MS) * HOUR_MS + offset * 60000
754
+ }
755
+ function calendarStartOf(dateText, utc) {
756
+ const parts = dateText.split('-').map(Number)
757
+ return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2]) : new Date(parts[0], parts[1] - 1, parts[2]).getTime()
758
+ }
759
+ function nextCalendarStartOf(dateText, utc) {
760
+ const parts = dateText.split('-').map(Number)
761
+ return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2] + 1) : new Date(parts[0], parts[1] - 1, parts[2] + 1).getTime()
762
+ }
763
+ function hourlyRangeOf(scope, nowMs) {
764
+ if (scope.start !== scope.end) return null
765
+ const start = calendarStartOf(scope.start, scope.utc)
766
+ const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
767
+ const end = scope.start === today ? nowMs : nextCalendarStartOf(scope.start, scope.utc)
768
+ return { start, count: Math.max(1, Math.ceil(Math.max(0, end - start) / HOUR_MS)) }
769
+ }
770
+ function serializeTrendMetric(time, metric) {
771
+ const value = finalizeQueryMetric(metric || queryMetric())
772
+ return { time, date: new Date(time).toISOString(), ...value, tokens: { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite, reasoning: value.reasoning } }
773
+ }
774
+ function queryHourlyTrend(day, scope, nowMs, matchingTurnKeys) {
775
+ const range = hourlyRangeOf(scope, nowMs)
776
+ if (range === null) return []
777
+ const buckets = new Map()
778
+ const indexHours = new Map()
779
+ const metricFor = (hour) => {
780
+ // Zone offsets are not always whole hours (Lord Howe +10:30/+11), so a
781
+ // floor can drop or merge fractional-hour buckets; round and clamp keeps
782
+ // every event inside the requested range without losing rows.
783
+ const index = Math.min(range.count - 1, Math.max(0, Math.round((hour - range.start) / HOUR_MS)))
784
+ if (index < 0 || index >= range.count) return null
785
+ const previousHour = indexHours.get(index)
786
+ if (previousHour === undefined || hour < previousHour) indexHours.set(index, hour)
787
+ let metric = buckets.get(index)
788
+ if (metric === undefined) { metric = queryMetric(); buckets.set(index, metric) }
789
+ return metric
790
+ }
791
+ const hasIdentityFilter = (scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)
792
+ const hours = day && day.queryHours instanceof Map ? day.queryHours : new Map()
793
+ for (const [hour, value] of hours) {
794
+ for (const [workspaceId, models] of value.usage) {
795
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
796
+ for (const bucket of models.values()) {
797
+ if (!identityMatchesScope(bucket.identity, scope)) continue
798
+ const metric = metricFor(hour)
799
+ if (metric === null) continue
800
+ mergeQueryUsageMetric(metric, bucket)
801
+ }
802
+ }
803
+ for (const [workspaceId, models] of value.turns) {
804
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
805
+ for (const bucket of models.values()) {
806
+ if (!hasIdentityFilter || identityMatchesScope(bucket.identity, scope)) {
807
+ const metric = metricFor(hour)
808
+ if (metric !== null) mergeQueryTurnBucket(metric, bucket)
809
+ } else if (matchingTurnKeys.size > 0) {
810
+ const metric = metricFor(hour)
811
+ if (metric === null) continue
812
+ for (const turn of bucket.records.values()) if (matchingTurnKeys.has(turn.key)) addQueryTurnMetric(metric, turn)
813
+ }
814
+ }
815
+ }
816
+ }
817
+ // Emit the real bucket boundary when the bucket was rounded into this slot;
818
+ // the uniform grid only labels empty slots, so fractional-hour zones keep
819
+ // exact labels instead of drifting 30 minutes off.
820
+ return Array.from({ length: range.count }, (_, index) => serializeTrendMetric(indexHours.has(index) ? indexHours.get(index) : range.start + index * HOUR_MS, buckets.get(index)))
821
+ }
822
+ function queryUsageScope(scope) {
823
+ commitPendingStats()
824
+ const nowMs = Date.now()
825
+ const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
826
+ const hourlyCacheKey = scope.start === scope.end ? ':' + (scope.start === today ? hourStartOf(nowMs, scope.utc) : 'fixed') : ''
827
+ const currentQueryRevision = queryRevision()
828
+ const key = currentQueryRevision + ':' + scopeFingerprint(scope) + hourlyCacheKey
829
+ const cached = state.queryCache.get(key)
830
+ if (cached !== undefined) {
831
+ const revisions = revisionSnapshot()
832
+ return { ...cached, ...revisions, updatedAt: state.statsUpdatedAt, partial: !state.scan.done, completeThrough: { ...revisions, at: state.statsUpdatedAt } }
833
+ }
834
+ const now = new Date(nowMs)
835
+ const weekday = scope.utc ? now.getUTCDay() : now.getDay()
836
+ const sunday = shiftDateText(today, -weekday, scope.utc)
837
+ const heatStart = shiftDateText(sunday, -52 * 7, scope.utc)
838
+ const selected = queryAggregate()
839
+ const heatmap = new Map()
840
+ const matchingTurnKeys = new Set()
841
+ const dayMap = scope.utc ? state.byDayUtc : state.byDay
842
+ const hasIdentityFilter = (scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)
843
+ for (const [date, day] of dayMap) {
844
+ const inSelected = dateInScope(date, scope)
845
+ const inHeat = date >= heatStart && date <= today
846
+ if (!inSelected && !inHeat) continue
847
+ for (const [workspaceId, models] of day.queryUsage) {
848
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
849
+ for (const bucket of models.values()) {
850
+ if (!identityMatchesScope(bucket.identity, scope)) continue
851
+ if (hasIdentityFilter) for (const turnKey of bucket.turnKeys) matchingTurnKeys.add(turnKey)
852
+ if (inSelected) addQueryUsageBucket(selected, bucket, date, workspaceId)
853
+ if (inHeat) addHeatmapUsage(heatmap, bucket, date)
854
+ }
855
+ }
856
+ }
857
+ for (const [date, day] of dayMap) {
858
+ const inSelected = dateInScope(date, scope)
859
+ const inHeat = date >= heatStart && date <= today
860
+ if (!inSelected && !inHeat) continue
861
+ for (const [workspaceId, models] of day.queryTurns) {
862
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
863
+ for (const bucket of models.values()) {
864
+ if (!hasIdentityFilter || identityMatchesScope(bucket.identity, scope)) {
865
+ if (inSelected) addQueryTurnBucket(selected, bucket, date, workspaceId)
866
+ if (inHeat) addHeatmapTurnBucket(heatmap, bucket, date, workspaceId)
867
+ continue
868
+ }
869
+ if (matchingTurnKeys.size === 0) continue
870
+ const matching = []
871
+ for (const turn of bucket.records.values()) if (matchingTurnKeys.has(turn.key)) matching.push(turn)
872
+ if (matching.length === 0) continue
873
+ if (inSelected) for (const turn of matching) addQueryTurn(selected, turn, date)
874
+ if (inHeat) addHeatmapTurns(heatmap, matching, date)
875
+ }
876
+ }
877
+ }
878
+ const hourly = queryHourlyTrend(dayMap.get(scope.start), scope, nowMs, matchingTurnKeys)
879
+ const revisions = revisionSnapshot()
880
+ const result = { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId: state.instanceId, ...revisions, updatedAt: state.statsUpdatedAt, scope: JSON.parse(scopeFingerprint(scope)), partial: !state.scan.done, completeThrough: { ...revisions, at: state.statsUpdatedAt }, ...finalizeQueryAggregate(selected), hourly, heatmap: serializeHeatmap(heatmap) }
881
+ state.queryCache.set(key, result)
882
+ while (state.queryCache.size > 20) state.queryCache.delete(state.queryCache.keys().next().value)
883
+ return result
884
+ }
885
+ function opaqueRecordId(item) {
886
+ return createHash('sha256').update(item.sid + '\0' + item.key).digest('hex').slice(0, 20)
887
+ }
888
+ function recordOrder(a, b) {
889
+ return b.time - a.time || b.seq - a.seq || (a.sid < b.sid ? -1 : a.sid > b.sid ? 1 : 0) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
890
+ }
891
+ function queryRecords(scope, cursor, limit) {
892
+ commitPendingStats()
893
+ const fingerprint = scopeFingerprint(scope)
894
+ const currentQueryRevision = queryRevision()
895
+ let offset = 0
896
+ if (cursor !== undefined && cursor !== '') {
897
+ try {
898
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
899
+ if (decoded.queryRevision !== currentQueryRevision || decoded.scope !== fingerprint || !Number.isInteger(decoded.offset) || decoded.offset < 0) return { error: 'stale-cursor' }
900
+ offset = decoded.offset
901
+ } catch (err) { return { error: 'bad-cursor' } }
902
+ }
903
+ const cacheKey = currentQueryRevision + ':' + fingerprint
904
+ let rows = state.recordsQueryCache.get(cacheKey)
905
+ if (rows === undefined) {
906
+ const index = scope.utc ? state.usageByUtcDate : state.usageByLocalDate
907
+ rows = indexedEntriesInRange(index, state.usageByStep, scope.start, scope.end).filter(({ item }) => {
908
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) return false
909
+ return identityMatchesScope(item.identity || item.modelId, scope)
910
+ })
911
+ rows.sort((left, right) => recordOrder(left.item, right.item))
912
+ state.recordsQueryCache.set(cacheKey, rows)
913
+ while (state.recordsQueryCache.size > 20) state.recordsQueryCache.delete(state.recordsQueryCache.keys().next().value)
914
+ }
915
+ const page = rows.slice(offset, offset + limit)
916
+ const items = page.map(({ item, date }) => {
917
+ const identity = coerceIdentity(item.identity || item.modelId)
918
+ return { id: opaqueRecordId(item), date, time: item.time, workspaceId: item.wsId, provider: identity.provider, requestedModel: identity.requestedModel, actualModel: identity.actualModel, model: identity.label, identityKey: identity.identityKey, turn: item.turn, step: item.step, seq: item.seq, values: item.values, cost: item.cost, materialization: item.materialization || 'unknown' }
919
+ })
920
+ const nextOffset = offset + items.length
921
+ return { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId: state.instanceId, ...revisionSnapshot(), scope: JSON.parse(fingerprint), items, hasMore: nextOffset < rows.length, nextCursor: nextOffset < rows.length ? Buffer.from(JSON.stringify({ queryRevision: currentQueryRevision, scope: fingerprint, offset: nextOffset })).toString('base64url') : null }
922
+ }
923
+
924
+ return {
925
+ dayKey,
926
+ dayKeyUtc,
927
+ dateKeys,
928
+ num,
929
+ validEventTime,
930
+ addDateIndex,
931
+ removeDateIndex,
932
+ indexUsage,
933
+ unindexUsage,
934
+ indexedEntriesInRange,
935
+ usageBasisEqual,
936
+ resolveCurrentPricing,
937
+ costForUsage,
938
+ resetAggregationState,
939
+ ensureDay,
940
+ ensureWs,
941
+ ensureDayWs,
942
+ ensureModel,
943
+ ensureDayModel,
944
+ adjustValues,
945
+ noValues,
946
+ adjustDaySession,
947
+ adjustDay,
948
+ addCostAggregateDirection,
949
+ adjustUsage,
950
+ adjustQueryCost,
951
+ upsertUsageSample,
952
+ addUsage,
953
+ addDayTurn,
954
+ turnRecordKey,
955
+ addTurn,
956
+ textOrNull,
957
+ identityLabel,
958
+ makeIdentity,
959
+ identityFromLegacy,
960
+ isCanonicalIdentity,
961
+ coerceIdentity,
962
+ routeObject,
963
+ identityFromRoute,
964
+ identityFromMessage,
965
+ modelFromRoute,
966
+ modelFromMessage,
967
+ scanSnapshot,
968
+ queryRevision,
969
+ revisionSnapshot,
970
+ statusSnapshot,
971
+ serializeIdentity,
972
+ serializeModelAggregate,
973
+ serializeDays,
974
+ snapshot,
975
+ validDateText,
976
+ shiftDateText,
977
+ queryScopeFromRequest,
978
+ scopeFingerprint,
979
+ dateInScope,
980
+ modelNameOfIdentity,
981
+ identityMatchesScope,
982
+ queryMetric,
983
+ queryAggregate,
984
+ queryDay,
985
+ queryWorkspace,
986
+ queryModel,
987
+ addQueryTokens,
988
+ addQueryTurn,
989
+ finalizeQueryMetric,
990
+ finalizeQueryAggregate,
991
+ hourStartOf,
992
+ calendarStartOf,
993
+ nextCalendarStartOf,
994
+ hourlyRangeOf,
995
+ serializeTrendMetric,
996
+ queryHourlyTrend,
997
+ queryUsageScope,
998
+ opaqueRecordId,
999
+ recordOrder,
1000
+ queryRecords
1001
+ }
1002
+ }