dsh-all-usage 1.1.2 → 1.1.4

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