dsh-all-usage 1.1.18 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +453 -394
  2. package/LICENSE +21 -21
  3. package/README.en.md +270 -0
  4. package/README.md +142 -279
  5. package/assets/model-icons/ai2-color.svg +1 -0
  6. package/assets/model-icons/ai21-brand-color.svg +1 -0
  7. package/assets/model-icons/aionlabs.svg +1 -0
  8. package/assets/model-icons/alephalpha.svg +1 -0
  9. package/assets/model-icons/baai.svg +1 -0
  10. package/assets/model-icons/baichuan-color.svg +1 -0
  11. package/assets/model-icons/baidu-color.svg +1 -0
  12. package/assets/model-icons/bytedance-color.svg +1 -0
  13. package/assets/model-icons/cohere-color.svg +1 -0
  14. package/assets/model-icons/hunyuan-color.svg +1 -0
  15. package/assets/model-icons/ibm.svg +1 -0
  16. package/assets/model-icons/iflytekcloud-color.svg +1 -0
  17. package/assets/model-icons/internlm-color.svg +1 -0
  18. package/assets/model-icons/kling-color.svg +1 -0
  19. package/assets/model-icons/liquid.svg +1 -0
  20. package/assets/model-icons/longcat-color.svg +1 -0
  21. package/assets/model-icons/manifest.json +739 -213
  22. package/assets/model-icons/microsoft-color.svg +1 -0
  23. package/assets/model-icons/mistral-color.svg +1 -0
  24. package/assets/model-icons/nousresearch.svg +1 -0
  25. package/assets/model-icons/nova-color.svg +1 -0
  26. package/assets/model-icons/nvidia-color.svg +1 -0
  27. package/assets/model-icons/perplexity-color.svg +1 -0
  28. package/assets/model-icons/sensenova-color.svg +1 -0
  29. package/assets/model-icons/skywork-color.svg +1 -0
  30. package/assets/model-icons/snowflake-color.svg +1 -0
  31. package/assets/model-icons/stepfun-color.svg +1 -0
  32. package/assets/model-icons/tencent-color.svg +1 -0
  33. package/assets/model-icons/tii-color.svg +1 -0
  34. package/assets/model-icons/upstage-color.svg +1 -0
  35. package/assets/model-icons/voyage-color.svg +1 -0
  36. package/assets/model-icons/xiaomimimo.svg +1 -0
  37. package/assets/model-icons/yandex.svg +1 -0
  38. package/assets/readme-architecture-en.svg +95 -0
  39. package/assets/readme-architecture-zh.svg +95 -0
  40. package/assets/readme-sync-en.svg +81 -0
  41. package/assets/readme-sync-zh.svg +81 -0
  42. package/assets/screenshots/en/01-overview.png +0 -0
  43. package/assets/screenshots/en/02-heatmap.png +0 -0
  44. package/assets/screenshots/en/03-model-usage.png +0 -0
  45. package/assets/screenshots/en/04-request-logs.png +0 -0
  46. package/assets/screenshots/en/05-cost-settings.png +0 -0
  47. package/assets/screenshots/zh/01-overview.png +0 -0
  48. package/assets/screenshots/zh/02-heatmap.png +0 -0
  49. package/assets/screenshots/zh/03-model-usage.png +0 -0
  50. package/assets/screenshots/zh/04-request-logs.png +0 -0
  51. package/assets/screenshots/zh/05-cost-settings.png +0 -0
  52. package/cordis.patch.yml +5 -5
  53. package/lib/aggregation.js +1842 -1112
  54. package/lib/client.js +1 -1
  55. package/lib/http.js +417 -385
  56. package/lib/icons.js +40 -0
  57. package/lib/ledger.js +1000 -531
  58. package/lib/plugin.js +347 -327
  59. package/lib/pricing-runtime.js +615 -551
  60. package/lib/pricing.js +1463 -1412
  61. package/lib/records-cache.js +113 -0
  62. package/lib/session-sync.js +976 -841
  63. package/lib/usage-core.js +33 -10
  64. package/lib/version-check.js +133 -133
  65. package/package.json +7 -3
  66. package/screenshots.json +10 -3
  67. package/assets/screenshot-1.png +0 -0
  68. package/assets/screenshot-2.png +0 -0
  69. package/assets/screenshot-3.png +0 -0
@@ -1,1112 +1,1842 @@
1
- import { createHash } from 'node:crypto'
2
- import { RETIRED_WORKSPACE_ID } from './ledger.js'
3
- import { COST_SCHEMA_VERSION, addCostAccumulator, addCostAggregate, calculateCost, createCostAccumulator, decimalSubtract, isCostAccumulator, normalizeCostSnapshot, resolvePricing, serializeCostAggregate, temporalPlanFor } from './pricing.js'
4
- import { extractUsageEvent, normalizeEventSeq, normalizeUsageValues as usageValues, upsertUsageSample as upsertUsageSampleState, usageStepKey, validEventTime as validUsageEventTime } from './usage-core.js'
5
-
6
- export function createAggregation(host) {
7
- const { state, markStatsChanged, commitPendingStats, resetSyncState } = host
8
- const pricingSnapshot = (...args) => host.pricingSnapshot(...args)
9
- const syncSnapshot = (...args) => host.syncSnapshot(...args)
10
-
11
- function dayKey(ms) {
12
- const d = new Date(ms)
13
- return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
14
- }
15
- function dayKeyUtc(ms) {
16
- const d = new Date(ms)
17
- return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
18
- }
19
- function dateKeys(ms) {
20
- const d = new Date(ms)
21
- const local = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
22
- const utc = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
23
- return { local, utc }
24
- }
25
- function num(v) {
26
- return typeof v === 'number' && Number.isFinite(v) ? v : 0
27
- }
28
- const validEventTime = validUsageEventTime
29
- function addDateIndex(index, date, key) {
30
- if (typeof date !== 'string' || date === '') return
31
- let keys = index.get(date)
32
- if (keys === undefined) { keys = new Set(); index.set(date, keys) }
33
- keys.add(key)
34
- }
35
- function removeDateIndex(index, date, key) {
36
- if (typeof date !== 'string' || date === '') return
37
- const keys = index.get(date)
38
- if (keys === undefined) return
39
- keys.delete(key)
40
- if (keys.size === 0) index.delete(date)
41
- }
42
- function indexUsage(item) {
43
- addDateIndex(state.usageByLocalDate, item.date, item.key)
44
- addDateIndex(state.usageByUtcDate, item.dateUtc, item.key)
45
- }
46
- function unindexUsage(item) {
47
- removeDateIndex(state.usageByLocalDate, item.date, item.key)
48
- removeDateIndex(state.usageByUtcDate, item.dateUtc, item.key)
49
- }
50
- function indexedEntriesInRange(index, source, start, end) {
51
- const result = []
52
- for (const [date, keys] of index) {
53
- if (date < start || date > end) continue
54
- for (const key of keys) {
55
- const item = source.get(key)
56
- if (item !== undefined) result.push({ item, date })
57
- }
58
- }
59
- return result
60
- }
61
- function usageBasisEqual(first, identity, values) {
62
- if (first === null || first === undefined) return false
63
- const firstIdentity = first.identity || first.modelId
64
- const left = coerceIdentity(firstIdentity)
65
- const right = coerceIdentity(identity)
66
- if (left.identityKey !== right.identityKey) return false
67
- return ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'].every((key) => num(first.values && first.values[key]) === num(values && values[key]))
68
- }
69
- function resolveCurrentPricing(identity) {
70
- const normalized = coerceIdentity(identity)
71
- const key = normalized.identityKey
72
- const cached = state.pricingResolutionCache.get(key)
73
- if (cached !== undefined) return cached
74
- const resolved = resolvePricing(normalized, state.pricingState)
75
- state.pricingResolutionCache.set(key, resolved)
76
- while (state.pricingResolutionCache.size > 5000) state.pricingResolutionCache.delete(state.pricingResolutionCache.keys().next().value)
77
- return resolved
78
- }
79
- function costForUsage(values, identity, previous, pricingAt, pricingTimeSource) {
80
- const previousCost = normalizeCostSnapshot(previous && previous.cost)
81
- if (previousCost !== null && previousCost.pricingMode === 'official-model' && previousCost.pricingTimeSource !== 'legacy-unknown' && usageBasisEqual(previous, identity, values) && costTemporalCompatible(previousCost, identity, pricingAt, pricingTimeSource)) return previousCost
82
- return calculateCost(values, resolveCurrentPricing(identity), pricingAt, pricingTimeSource)
83
- }
84
- function costTemporalCompatible(previousCost, identity, pricingAt, pricingTimeSource) {
85
- if (previousCost.pricingAt === null || previousCost.pricingTimeSource === null) return false
86
- const resolved = resolveCurrentPricing(identity)
87
- const plan = temporalPlanFor(resolved, pricingAt)
88
- const normalizedAt = Number.isFinite(pricingAt) && pricingAt >= 0 ? Math.trunc(pricingAt) : null
89
- const normalizedSource = pricingTimeSource === 'request-context' || pricingTimeSource === 'usage-event' ? pricingTimeSource : 'usage-event'
90
- 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
91
- }
92
- function resetAggregationState() {
93
- state.aggregationGeneration += 1
94
- state.wsMeta.clear()
95
- if (state.sessionContextTimes) state.sessionContextTimes.clear()
96
- state.pathIndex.clear()
97
- state.memberOf.clear()
98
- state.byDay.clear()
99
- state.byDayUtc.clear()
100
- state.perWorkspace.clear()
101
- // The deleted bucket is re-derived from the ledger during the rebuild that
102
- // follows, so a stale flag cannot advertise an empty row.
103
- state.retiredBucketUsed = false
104
- state.perModel.clear()
105
- state.usageByStep.clear()
106
- state.turnRecords.clear()
107
- state.usageByLocalDate.clear()
108
- state.usageByUtcDate.clear()
109
- for (const timer of state.liveResyncTimers.values()) clearTimeout(timer)
110
- state.liveResyncTimers.clear()
111
- state.liveResyncAttempts.clear()
112
- state.liveResyncPending.clear()
113
- state.reconcileHintScheduled = false
114
- if (state.baselineFallbackTimer !== null) {
115
- clearTimeout(state.baselineFallbackTimer)
116
- state.baselineFallbackTimer = null
117
- }
118
- state.baselineRetryScheduled = false
119
- state.queryCache.clear()
120
- state.recordsQueryCache.clear()
121
- state.sessionModel.clear()
122
- state.sessionCount.clear()
123
- state.sessionSeq.clear()
124
- state.chains.clear()
125
- state.knownSessionIds.clear()
126
- state.totals.turns = 0
127
- state.totals.input = 0
128
- state.totals.output = 0
129
- state.totals.cacheRead = 0
130
- state.totals.cacheWrite = 0
131
- state.totals.reasoning = 0
132
- Object.assign(state.totals.cost, createCostAccumulator())
133
- state.scan.started = false
134
- state.scan.done = false
135
- state.scan.scanned = 0
136
- state.scan.total = 0
137
- state.scan.failed = 0
138
- resetSyncState()
139
- state.baselineRetryDelay = 1000
140
- markStatsChanged(['data', 'metadata', 'scan'])
141
- return state.aggregationGeneration
142
- }
143
- function ensureDay(dayMap, date) {
144
- let day = dayMap.get(date)
145
- if (day === undefined) {
146
- 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() }
147
- dayMap.set(date, day)
148
- markStatsChanged('metadata')
149
- }
150
- return day
151
- }
152
- function ensureWs(wsId) {
153
- let ws = state.perWorkspace.get(wsId)
154
- if (ws === undefined) {
155
- ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
156
- state.perWorkspace.set(wsId, ws)
157
- markStatsChanged('metadata')
158
- }
159
- return ws
160
- }
161
- function ensureDayWs(day, wsId) {
162
- let w = day.byWs.get(wsId)
163
- if (w === undefined) {
164
- w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
165
- day.byWs.set(wsId, w)
166
- }
167
- return w
168
- }
169
- function ensureModel(value) {
170
- const identity = coerceIdentity(value)
171
- let item = state.perModel.get(identity.identityKey)
172
- if (item === undefined) {
173
- item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
174
- state.perModel.set(identity.identityKey, item)
175
- markStatsChanged('metadata')
176
- }
177
- return item
178
- }
179
- function ensureDayModel(day, value) {
180
- const identity = coerceIdentity(value)
181
- let item = day.byModel.get(identity.identityKey)
182
- if (item === undefined) {
183
- item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
184
- day.byModel.set(identity.identityKey, item)
185
- }
186
- return item
187
- }
188
- function adjustValues(target, values, direction) {
189
- target.input += values.input * direction
190
- target.output += values.output * direction
191
- target.cacheRead += values.cacheRead * direction
192
- target.cacheWrite += values.cacheWrite * direction
193
- target.reasoning += values.reasoning * direction
194
- }
195
- function addQueryMetricTokens(target, values, direction = 1) {
196
- target.input += values.input * direction
197
- target.output += values.output * direction
198
- target.cacheRead += values.cacheRead * direction
199
- target.cacheWrite += values.cacheWrite * direction
200
- target.reasoning += values.reasoning * direction
201
- }
202
- function noValues(target) {
203
- return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
204
- }
205
- function adjustDaySession(day, sid, direction) {
206
- if (sid === undefined || sid === null) return
207
- const current = day.sessionRefs.get(sid) || 0
208
- const next = current + direction
209
- if (next > 0) {
210
- day.sessionRefs.set(sid, next)
211
- day.sessionIds.add(sid)
212
- } else {
213
- day.sessionRefs.delete(sid)
214
- day.sessionIds.delete(sid)
215
- }
216
- }
217
- function adjustDay(dayMap, date, wsId, values, identity, direction, sid, cost) {
218
- const day = ensureDay(dayMap, date)
219
- adjustDaySession(day, sid, direction)
220
- adjustValues(day.tokens, values, direction)
221
- addCostAggregateDirection(day.cost, cost, direction)
222
- const dayWs = ensureDayWs(day, wsId)
223
- adjustValues(dayWs, values, direction)
224
- addCostAggregateDirection(dayWs.cost, cost, direction)
225
- if (noValues(dayWs)) day.byWs.delete(wsId)
226
- const dayModel = ensureDayModel(day, identity)
227
- dayModel.calls += direction
228
- adjustValues(dayModel, values, direction)
229
- addCostAggregateDirection(dayModel.cost, cost, direction)
230
- if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
231
- }
232
- function addCostAggregateDirection(target, cost, direction) {
233
- if (direction === 1) {
234
- if (isCostAccumulator(target)) addCostAccumulator(target, cost, 1)
235
- else addCostAggregate(target, cost)
236
- return
237
- }
238
- if (direction !== -1) return
239
- if (isCostAccumulator(target)) {
240
- addCostAccumulator(target, cost, -1)
241
- return
242
- }
243
- const status = cost && typeof cost.status === 'string' ? cost.status : 'unpriced'
244
- if (status === 'priced') {
245
- target.input = decimalSubtract(target.input, cost.breakdown && cost.breakdown.input)
246
- target.output = decimalSubtract(target.output, cost.breakdown && cost.breakdown.output)
247
- target.cacheRead = decimalSubtract(target.cacheRead, cost.breakdown && cost.breakdown.cacheRead)
248
- target.cacheWrite = decimalSubtract(target.cacheWrite, cost.breakdown && cost.breakdown.cacheWrite)
249
- target.baseTotal = decimalSubtract(target.baseTotal, cost.baseTotal)
250
- target.total = decimalSubtract(target.total, cost.total)
251
- target.pricedCalls -= 1
252
- } else if (status === 'ambiguous') target.ambiguousCalls -= 1
253
- else if (status === 'unsupported') target.unsupportedCalls -= 1
254
- else target.unpricedCalls -= 1
255
- }
256
- function adjustQueryRefs(bucket, field, sid, direction) {
257
- if (sid === undefined || sid === null) return
258
- const refs = field === 'sessions' ? bucket.sessionRefs : bucket[field + 'Refs']
259
- const values = bucket[field]
260
- const current = refs.get(sid) || 0
261
- const next = current + direction
262
- if (next > 0) { refs.set(sid, next); values.add(sid) }
263
- else { refs.delete(sid); values.delete(sid) }
264
- }
265
- function queryUsageMetric(identity) {
266
- 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() }
267
- }
268
- function queryTurnMetric(identity) {
269
- return { identity: coerceIdentity(identity), turns: 0, sessions: new Set(), sessionRefs: new Map(), records: new Map() }
270
- }
271
- function queryWorkspaceMap(root, wsId, create) {
272
- let value = root.get(wsId)
273
- if (value === undefined && create) { value = new Map(); root.set(wsId, value) }
274
- return value
275
- }
276
- function adjustQueryUsageMap(root, item, direction) {
277
- const identity = coerceIdentity(item.identity || item.modelId)
278
- const workspace = queryWorkspaceMap(root, item.wsId, direction === 1)
279
- if (workspace === undefined) return
280
- let bucket = workspace.get(identity.identityKey)
281
- if (bucket === undefined && direction === 1) { bucket = queryUsageMetric(identity); workspace.set(identity.identityKey, bucket) }
282
- if (bucket === undefined) return
283
- bucket.calls += direction
284
- adjustQueryRefs(bucket, 'sessions', item.sid, direction)
285
- addQueryMetricTokens(bucket, item.values, direction)
286
- addCostAccumulator(bucket.cost, item.cost, direction)
287
- if (item.turn !== null && item.turn !== undefined) {
288
- const turnKey = String(item.sid) + ':turn:' + String(item.turn)
289
- const current = bucket.turnKeyRefs.get(turnKey) || 0
290
- const next = current + direction
291
- if (next > 0) { bucket.turnKeyRefs.set(turnKey, next); bucket.turnKeys.add(turnKey) }
292
- else { bucket.turnKeyRefs.delete(turnKey); bucket.turnKeys.delete(turnKey) }
293
- }
294
- if (direction === -1 && bucket.calls <= 0) workspace.delete(identity.identityKey)
295
- if (workspace.size === 0) root.delete(item.wsId)
296
- }
297
- function ensureQueryHour(day, hour) {
298
- let value = day.queryHours.get(hour)
299
- if (value === undefined) { value = { usage: new Map(), turns: new Map() }; day.queryHours.set(hour, value) }
300
- return value
301
- }
302
- function adjustQueryUsage(day, item, direction, utc) {
303
- if (day === undefined) return
304
- adjustQueryUsageMap(day.queryUsage, item, direction)
305
- const hour = hourStartOf(item.time, utc)
306
- adjustQueryUsageMap(ensureQueryHour(day, hour).usage, item, direction)
307
- const hourValue = day.queryHours.get(hour)
308
- if (hourValue !== undefined && hourValue.usage.size === 0 && hourValue.turns.size === 0) day.queryHours.delete(hour)
309
- }
310
- function adjustQueryTurnMap(root, turn, direction) {
311
- const identity = coerceIdentity(turn.identity)
312
- const workspace = queryWorkspaceMap(root, turn.wsId, direction === 1)
313
- if (workspace === undefined) return
314
- let bucket = workspace.get(identity.identityKey)
315
- if (bucket === undefined && direction === 1) { bucket = queryTurnMetric(identity); workspace.set(identity.identityKey, bucket) }
316
- if (bucket === undefined) return
317
- if (direction === 1) {
318
- if (bucket.records.has(turn.key)) return
319
- bucket.records.set(turn.key, turn)
320
- bucket.turns += 1
321
- adjustQueryRefs(bucket, 'sessions', turn.sid, 1)
322
- } else {
323
- const previous = bucket.records.get(turn.key)
324
- if (previous === undefined) return
325
- bucket.records.delete(turn.key)
326
- bucket.turns -= 1
327
- adjustQueryRefs(bucket, 'sessions', previous.sid, -1)
328
- }
329
- if (direction === -1 && bucket.turns <= 0) workspace.delete(identity.identityKey)
330
- if (workspace.size === 0) root.delete(turn.wsId)
331
- }
332
- function adjustQueryTurn(day, turn, direction, utc) {
333
- if (day === undefined) return
334
- adjustQueryTurnMap(day.queryTurns, turn, direction)
335
- const hour = hourStartOf(turn.time, utc)
336
- const hourValue = ensureQueryHour(day, hour)
337
- adjustQueryTurnMap(hourValue.turns, turn, direction)
338
- if (hourValue.usage.size === 0 && hourValue.turns.size === 0) day.queryHours.delete(hour)
339
- }
340
- function adjustUsage(wsId, time, values, identity, direction, sid, cachedDates, cost) {
341
- const normalized = coerceIdentity(identity)
342
- const dates = cachedDates && typeof cachedDates.local === 'string' && typeof cachedDates.utc === 'string' ? cachedDates : dateKeys(time)
343
- const modelTotals = ensureModel(normalized)
344
- modelTotals.calls += direction
345
- adjustValues(modelTotals, values, direction)
346
- addCostAggregateDirection(modelTotals.cost, cost, direction)
347
- if (modelTotals.calls === 0 && noValues(modelTotals)) {
348
- state.perModel.delete(normalized.identityKey)
349
- markStatsChanged('metadata')
350
- }
351
- adjustValues(state.totals, values, direction)
352
- addCostAggregateDirection(state.totals.cost, cost, direction)
353
- const ws = ensureWs(wsId)
354
- adjustValues(ws, values, direction)
355
- addCostAggregateDirection(ws.cost, cost, direction)
356
- adjustDay(state.byDay, dates.local, wsId, values, normalized, direction, sid, cost)
357
- adjustDay(state.byDayUtc, dates.utc, wsId, values, normalized, direction, sid, cost)
358
- }
359
- function removeSession(sid) {
360
- if (typeof sid !== 'string' || sid === '') return 0
361
- let removed = 0
362
- const usageKeys = []
363
- for (const [key, item] of state.usageByStep) if (item.sid === sid) usageKeys.push([key, item])
364
- for (const [key, item] of usageKeys) {
365
- unindexUsage(item)
366
- adjustUsage(item.wsId, item.time, item.values, item.identity || item.modelId, -1, sid, { local: item.date, utc: item.dateUtc }, item.cost)
367
- adjustQueryUsage(state.byDay.get(item.date), item, -1, false)
368
- adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, -1, true)
369
- state.usageByStep.delete(key)
370
- removed += 1
371
- }
372
- const turnKeys = []
373
- for (const [key, turn] of state.turnRecords) if (turn.sid === sid) turnKeys.push([key, turn])
374
- for (const [key, turn] of turnKeys) {
375
- removeTurnRecord(turn)
376
- state.turnRecords.delete(key)
377
- removed += 1
378
- }
379
- state.sessionContextTimes.delete(sid)
380
- state.sessionModel.delete(sid)
381
- if (removed > 0) markStatsChanged('data')
382
- return removed
383
- }
384
- // Re-key every aggregate entry owned by one session to another workspace id.
385
- //
386
- // Retiring a workspace cannot rely on the ledger alone: usage folded from the
387
- // live feed in the last few milliseconds has no persisted row yet, so it would
388
- // keep a workspace id the dashboard no longer advertises. Moving the in-memory
389
- // records keeps the totals, the per-workspace split and the query buckets
390
- // consistent no matter when the workspace disappears.
391
- function retargetSession(sid, workspaceId) {
392
- if (typeof sid !== 'string' || sid === '' || typeof workspaceId !== 'string' || workspaceId === '') return 0
393
- let moved = 0
394
- for (const item of state.usageByStep.values()) {
395
- if (item.sid !== sid || item.wsId === workspaceId) continue
396
- const previousWs = item.wsId
397
- const identity = item.identity || item.modelId
398
- const dates = { local: item.date, utc: item.dateUtc }
399
- // Decrement under the old workspace, then increment under the new one: the
400
- // session, model and global totals cancel out exactly.
401
- adjustUsage(previousWs, item.time, item.values, identity, -1, sid, dates, item.cost)
402
- adjustQueryUsage(state.byDay.get(item.date), item, -1, false)
403
- adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, -1, true)
404
- item.wsId = workspaceId
405
- adjustUsage(workspaceId, item.time, item.values, identity, 1, sid, dates, item.cost)
406
- adjustQueryUsage(state.byDay.get(item.date), item, 1, false)
407
- adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, 1, true)
408
- moved += 1
409
- }
410
- for (const turn of state.turnRecords.values()) {
411
- if (turn.sid !== sid || turn.wsId === workspaceId) continue
412
- removeTurnRecord(turn)
413
- turn.wsId = workspaceId
414
- ensureWs(workspaceId).turns += 1
415
- state.totals.turns += 1
416
- addDayTurn(state.byDay, turn.date, workspaceId, turn.sid, 1)
417
- addDayTurn(state.byDayUtc, turn.dateUtc, workspaceId, turn.sid, 1)
418
- adjustQueryTurn(state.byDay.get(turn.date), turn, 1, false)
419
- adjustQueryTurn(state.byDayUtc.get(turn.dateUtc), turn, 1, true)
420
- moved += 1
421
- }
422
- if (moved > 0) markStatsChanged('data')
423
- return moved
424
- }
425
- function upsertUsageSample(target, sample, options = {}) {
426
- const previous = target.get(sample.key)
427
- const candidate = sample.cost === undefined
428
- ? { ...sample, cost: costForUsage(sample.values, sample.identity, options.costPrevious === undefined ? previous : options.costPrevious, sample.pricingAt, sample.pricingTimeSource) }
429
- : sample
430
- const result = upsertUsageSampleState(target, candidate)
431
- if (!result.accepted || target !== state.usageByStep || options.materialize === false) return result
432
- const previousItem = result.previous
433
- if (previousItem !== undefined) {
434
- unindexUsage(previousItem)
435
- adjustUsage(previousItem.wsId, previousItem.time, previousItem.values, previousItem.identity || previousItem.modelId, -1, previousItem.sid, { local: previousItem.date, utc: previousItem.dateUtc }, previousItem.cost)
436
- adjustQueryUsage(state.byDay.get(previousItem.date), previousItem, -1, false)
437
- adjustQueryUsage(state.byDayUtc.get(previousItem.dateUtc), previousItem, -1, true)
438
- }
439
- const next = result.next
440
- indexUsage(next)
441
- adjustUsage(next.wsId, next.time, next.values, next.identity, 1, next.sid, { local: next.date, utc: next.dateUtc }, next.cost)
442
- adjustQueryUsage(state.byDay.get(next.date), next, 1, false)
443
- adjustQueryUsage(state.byDayUtc.get(next.dateUtc), next, 1, true)
444
- markStatsChanged('data')
445
- return result
446
- }
447
- function adjustQueryCost(item, cost, direction) {
448
- if (item === null || item === undefined || typeof item !== 'object') return
449
- const identity = coerceIdentity(item.identity || item.modelId)
450
- const dates = typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
451
- const update = (day, utc) => {
452
- if (day === undefined) return
453
- const models = day.queryUsage.get(item.wsId)
454
- const bucket = models && models.get(identity.identityKey)
455
- if (bucket !== undefined) addCostAccumulator(bucket.cost, cost, direction)
456
- const hour = hourStartOf(item.time, utc)
457
- const hourValue = day.queryHours.get(hour)
458
- const hourModels = hourValue && hourValue.usage.get(item.wsId)
459
- const hourBucket = hourModels && hourModels.get(identity.identityKey)
460
- if (hourBucket !== undefined) addCostAccumulator(hourBucket.cost, cost, direction)
461
- }
462
- update(state.byDay.get(dates.local), false)
463
- update(state.byDayUtc.get(dates.utc), true)
464
- }
465
- function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live', pricingAt, pricingTimeSource) {
466
- if (!validEventTime(time)) return
467
- const values = usageValues(usage)
468
- if (values === null || noValues(values)) return
469
- const identity = coerceIdentity(model)
470
- const eventSeq = normalizeEventSeq(seq)
471
- const dates = dateKeys(time)
472
- const key = usageStepKey(sid, data, seq)
473
- upsertUsageSample(state.usageByStep, {
474
- key,
475
- seq: eventSeq,
476
- wsId,
477
- time,
478
- date: dates.local,
479
- dateUtc: dates.utc,
480
- values,
481
- identity,
482
- modelId: identity.label,
483
- turn: data && Number.isSafeInteger(data.turn) && data.turn >= 0 ? data.turn : null,
484
- step: data && Number.isSafeInteger(data.step) && data.step >= 0 ? data.step : null,
485
- materialization,
486
- sid,
487
- ...(pricingAt === undefined ? {} : { pricingAt }),
488
- ...(pricingTimeSource === undefined ? {} : { pricingTimeSource }),
489
- })
490
- }
491
- function addDayTurn(dayMap, date, wsId, sid, direction = 1) {
492
- const day = ensureDay(dayMap, date)
493
- adjustDaySession(day, sid, direction)
494
- day.turns = Math.max(0, day.turns + direction)
495
- const next = (day.perWs.get(wsId) || 0) + direction
496
- if (next > 0) day.perWs.set(wsId, next)
497
- else day.perWs.delete(wsId)
498
- }
499
- function removeTurnRecord(turn) {
500
- const ws = state.perWorkspace.get(turn.wsId)
501
- if (ws !== undefined) ws.turns = Math.max(0, ws.turns - 1)
502
- state.totals.turns = Math.max(0, state.totals.turns - 1)
503
- addDayTurn(state.byDay, turn.date, turn.wsId, turn.sid, -1)
504
- addDayTurn(state.byDayUtc, turn.dateUtc, turn.wsId, turn.sid, -1)
505
- adjustQueryTurn(state.byDay.get(turn.date), turn, -1, false)
506
- adjustQueryTurn(state.byDayUtc.get(turn.dateUtc), turn, -1, true)
507
- }
508
- function turnRecordKey(sid, turn, seq, time) {
509
- if (Number.isSafeInteger(turn)) return sid + ':turn:' + turn
510
- if (Number.isSafeInteger(seq) && seq >= 0) return sid + ':event:' + seq
511
- return sid + ':time:' + String(time)
512
- }
513
- function addTurn(wsId, time, sid, turn, identity, materialization = 'live', seq) {
514
- if (!validEventTime(time)) return
515
- const key = turnRecordKey(sid, turn, seq, time)
516
- if (state.turnRecords.has(key)) return
517
- const normalized = coerceIdentity(identity)
518
- const dates = dateKeys(time)
519
- const record = { key, sid, wsId, time, date: dates.local, dateUtc: dates.utc, turn: typeof turn === 'number' ? turn : null, identity: normalized, materialization }
520
- state.turnRecords.set(key, record)
521
- ensureWs(wsId).turns += 1
522
- state.totals.turns += 1
523
- addDayTurn(state.byDay, dates.local, wsId, sid)
524
- addDayTurn(state.byDayUtc, dates.utc, wsId, sid)
525
- adjustQueryTurn(state.byDay.get(record.date), record, 1, false)
526
- adjustQueryTurn(state.byDayUtc.get(record.dateUtc), record, 1, true)
527
- markStatsChanged('data')
528
- }
529
- const UNKNOWN_MODEL_LABEL = '未知模型(历史记录缺少路由)'
530
- function textOrNull(value) {
531
- return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
532
- }
533
- function identityLabel(provider, requestedModel, actualModel, legacyLabel) {
534
- const model = actualModel || requestedModel
535
- if (model !== null) return provider === null ? model : provider + ' / ' + model
536
- return legacyLabel || UNKNOWN_MODEL_LABEL
537
- }
538
- function makeIdentity(provider, requestedModel, actualModel, legacyLabel) {
539
- const normalizedProvider = textOrNull(provider)
540
- const normalizedRequested = textOrNull(requestedModel)
541
- const normalizedActual = textOrNull(actualModel)
542
- const normalizedLegacy = textOrNull(legacyLabel)
543
- const key = JSON.stringify([normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy])
544
- return {
545
- identityKey: key,
546
- provider: normalizedProvider,
547
- requestedModel: normalizedRequested,
548
- actualModel: normalizedActual,
549
- label: identityLabel(normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy),
550
- legacy: normalizedLegacy !== null && normalizedProvider === null && normalizedRequested === null && normalizedActual === null,
551
- }
552
- }
553
- function identityFromLegacy(label) {
554
- return makeIdentity(null, null, null, textOrNull(label) || UNKNOWN_MODEL_LABEL)
555
- }
556
- function isCanonicalIdentity(value) {
557
- 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'
558
- }
559
- function coerceIdentity(value) {
560
- if (isCanonicalIdentity(value)) return value
561
- if (value !== null && typeof value === 'object') {
562
- return makeIdentity(value.provider, value.requestedModel, value.actualModel, value.legacyLabel || (value.legacy === true ? value.label : null))
563
- }
564
- if (typeof value === 'string' && value !== '') return identityFromLegacy(value)
565
- return makeIdentity(null, null, null, UNKNOWN_MODEL_LABEL)
566
- }
567
- function routeObject(data) {
568
- if (data && typeof data === 'object' && (data.provider !== undefined || data.model !== undefined)) return data
569
- const config = data && data.header && data.header.config
570
- return config && typeof config === 'object' ? config : null
571
- }
572
- function identityFromRoute(data, fallback) {
573
- const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
574
- const route = routeObject(data)
575
- if (route === null) return base
576
- return makeIdentity(route.provider === undefined ? base.provider : route.provider, route.model === undefined ? base.requestedModel : route.model, null, base.legacy ? base.label : null)
577
- }
578
- function identityFromMessage(data, fallback) {
579
- const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
580
- const source = data && data.message && data.message.source
581
- if (source === null || typeof source !== 'object') return base
582
- return makeIdentity(source.provider === undefined ? base.provider : source.provider, base.requestedModel || source.model, source.model, base.legacy ? base.label : null)
583
- }
584
- function modelFromRoute(data) {
585
- const identity = identityFromRoute(data)
586
- return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
587
- }
588
- function modelFromMessage(data, fallback) {
589
- return identityFromMessage(data, fallback).label
590
- }
591
-
592
- // ---------- snapshot for the client ----------
593
- function scanSnapshot() {
594
- return { started: state.scan.started, done: state.scan.done, scanned: state.scan.scanned, total: state.scan.total, failed: state.scan.failed }
595
- }
596
- function queryRevision() {
597
- return String(state.dataRevision) + ':' + String(state.pricingRevision)
598
- }
599
- function revisionSnapshot() {
600
- return { revision: state.statsRevision, dataRevision: state.dataRevision, metadataRevision: state.metadataRevision, scanRevision: state.scanRevision, pricingRevision: state.pricingRevision, queryRevision: queryRevision() }
601
- }
602
- function statusSnapshot() {
603
- commitPendingStats()
604
- return {
605
- instanceId: state.instanceId,
606
- // The write capability is minted per plugin apply, so a reload rotates it
607
- // without moving the process instance id or any revision. The status poll
608
- // carries only this derived id (never the capability itself), which is enough
609
- // for the page to notice the rotation and re-fetch the full snapshot.
610
- capabilityId: state.capabilityId,
611
- pluginVersion: state.pluginVersion,
612
- ...revisionSnapshot(),
613
- updatedAt: state.statsUpdatedAt,
614
- scan: scanSnapshot(),
615
- sync: syncSnapshot(),
616
- clientEnv: state.clientEnv,
617
- }
618
- }
619
- function serializeIdentity(identity) {
620
- const value = coerceIdentity(identity)
621
- return { identityKey: value.identityKey, provider: value.provider, requestedModel: value.requestedModel, actualModel: value.actualModel, model: value.label, legacy: value.legacy }
622
- }
623
- function serializeModelAggregate(item) {
624
- 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) }
625
- }
626
- function serializeDays(dayMap) {
627
- const result = []
628
- for (const pair of dayMap) {
629
- const date = pair[0]
630
- const day = pair[1]
631
- result.push({
632
- date,
633
- turns: day.turns,
634
- sessions: day.sessionIds.size,
635
- sessionIds: Array.from(day.sessionIds).sort(),
636
- tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
637
- cost: serializeCostAggregate(day.cost),
638
- perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
639
- 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) })),
640
- byModel: Array.from(day.byModel, (p) => serializeModelAggregate(p[1])),
641
- })
642
- }
643
- result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
644
- return result
645
- }
646
- function snapshot() {
647
- commitPendingStats()
648
- const generatedAt = Date.now()
649
- if (state.snapshotCache !== null && state.snapshotCache.revision === state.statsRevision) return Object.assign({}, state.snapshotCache.value, { generatedAt })
650
- const value = {
651
- ...statusSnapshot(),
652
- generatedAt,
653
- usageSchemaVersion: 3,
654
- costSchemaVersion: COST_SCHEMA_VERSION,
655
- requestToken: state.requestToken,
656
- workspaces: [
657
- ...Array.from(state.wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
658
- ...(state.retiredBucketUsed ? [{ id: RETIRED_WORKSPACE_ID, title: '', path: '', deleted: true, retiredBucket: true }] : []),
659
- ],
660
- aliases: Object.assign({}, state.aliases),
661
- pricing: pricingSnapshot({ detailed: false }),
662
- tokenSemantics: {
663
- processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
664
- cacheRead: 'reused context tokens; not newly generated output',
665
- cacheWrite: 'tokens written into a provider cache',
666
- // v1.0.7: structured, machine-readable accounting semantics (cc-switch
667
- // input_token_semantics parity). DSH reports input as fresh (cache read /
668
- // cache write sit in their own buckets) — verified against real ledger data;
669
- // reasoning is bucketed separately from output and assumed non-overlapping.
670
- semantics: {
671
- input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
672
- buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
673
- inputIncludesCache: false,
674
- cacheBucketed: true,
675
- reasoningSeparate: true,
676
- gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
677
- },
678
- },
679
- costSemantics: {
680
- source: 'models.dev',
681
- currency: 'USD',
682
- buckets: ['input', 'output', 'cacheRead', 'cacheWrite'],
683
- input: 'fresh (DSH TokenUsage already excludes cache)',
684
- reasoning: 'not added to output again; provider output already carries completion/thoughts where reported',
685
- multiplier: 'applies only to final total',
686
- providerMatching: 'DSH provider is ignored; only the official model vendor entry is selected',
687
- historical: 'positive non-tiered cost snapshots are stable; legacy tiered snapshots migrate to unsupported; only unresolved usage is eligible for backfill',
688
- },
689
- 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) },
690
- 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) })),
691
- perModel: Array.from(state.perModel.values(), (item) => serializeModelAggregate(item)),
692
- byDay: serializeDays(state.byDay),
693
- byDayUtc: serializeDays(state.byDayUtc),
694
- }
695
- state.snapshotCache = { revision: state.statsRevision, value }
696
- return Object.assign({}, value, { generatedAt })
697
- }
698
-
699
- function validDateText(value) {
700
- if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
701
- const year = Number(value.slice(0, 4))
702
- const month = Number(value.slice(5, 7))
703
- const day = Number(value.slice(8, 10))
704
- const date = new Date(Date.UTC(year, month - 1, day))
705
- return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
706
- }
707
- function shiftDateText(value, days, utc) {
708
- const parts = value.split('-').map(Number)
709
- 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)
710
- return utc ? dayKeyUtc(date.getTime()) : dayKey(date.getTime())
711
- }
712
- function queryScopeFromRequest(req) {
713
- let url
714
- try { url = new URL(req.url || '/', 'http://all-usage.local') } catch (err) { return { ok: false, message: 'bad-query' } }
715
- const rawUtc = url.searchParams.get('utc')
716
- if (rawUtc !== null && rawUtc !== '' && rawUtc !== '0' && rawUtc !== '1') return { ok: false, message: 'invalid-timezone' }
717
- const utc = rawUtc === '1'
718
- const today = utc ? dayKeyUtc(Date.now()) : dayKey(Date.now())
719
- const start = url.searchParams.get('start') || today
720
- const end = url.searchParams.get('end') || today
721
- if (!validDateText(start) || !validDateText(end) || start > end) return { ok: false, message: 'invalid-date-range' }
722
- const readParam = (name, max) => {
723
- const value = url.searchParams.get(name)
724
- if (value === null || value === '') return undefined
725
- return value.length <= max ? value : null
726
- }
727
- const workspaceId = readParam('workspaceId', 256)
728
- const provider = readParam('provider', 256)
729
- const modelKey = readParam('modelKey', 1024)
730
- if (workspaceId === null || provider === null || modelKey === null) return { ok: false, message: 'query-too-long' }
731
- return { ok: true, scope: { start, end, utc, workspaceId, provider, modelKey } }
732
- }
733
- function scopeFingerprint(scope) {
734
- 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 })
735
- }
736
- function dateInScope(date, scope) {
737
- return date >= scope.start && date <= scope.end
738
- }
739
- function modelNameOfIdentity(identity) {
740
- const normalized = coerceIdentity(identity)
741
- const structured = normalized.actualModel || normalized.requestedModel
742
- if (structured !== null) return structured
743
- if (normalized.legacy && typeof normalized.label === 'string') {
744
- const separator = normalized.label.indexOf(' / ')
745
- if (separator > 0) return normalized.label.slice(separator + 3)
746
- }
747
- return normalized.label
748
- }
749
- function identityMatchesScope(identity, scope) {
750
- const normalized = coerceIdentity(identity)
751
- if (scope.provider !== undefined && scope.provider !== null && normalized.provider !== scope.provider) return false
752
- if (scope.modelKey !== undefined && scope.modelKey !== null && normalized.identityKey !== scope.modelKey && modelNameOfIdentity(normalized) !== scope.modelKey) return false
753
- return true
754
- }
755
- function queryMetric() {
756
- return { turns: 0, calls: 0, sessions: new Set(), turnKeys: new Set(), input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
757
- }
758
- function queryAggregate() {
759
- return { totals: queryMetric(), days: new Map(), workspaces: new Map(), models: new Map() }
760
- }
761
- function queryDay(aggregate, date) {
762
- let day = aggregate.days.get(date)
763
- if (day === undefined) { day = queryMetric(); day.date = date; aggregate.days.set(date, day) }
764
- return day
765
- }
766
- function queryWorkspace(aggregate, workspaceId) {
767
- let row = aggregate.workspaces.get(workspaceId)
768
- if (row === undefined) { row = queryMetric(); row.workspaceId = workspaceId; aggregate.workspaces.set(workspaceId, row) }
769
- return row
770
- }
771
- function queryModel(aggregate, identity) {
772
- const normalized = coerceIdentity(identity)
773
- let row = aggregate.models.get(normalized.identityKey)
774
- if (row === undefined) { row = queryMetric(); Object.assign(row, serializeIdentity(normalized)); aggregate.models.set(normalized.identityKey, row) }
775
- return row
776
- }
777
- function addQueryTokens(metric, values) {
778
- metric.input += values.input
779
- metric.output += values.output
780
- metric.cacheRead += values.cacheRead
781
- metric.cacheWrite += values.cacheWrite
782
- metric.reasoning += values.reasoning
783
- }
784
- function mergeQuerySessions(target, sessions) {
785
- for (const sid of sessions) target.sessions.add(sid)
786
- }
787
- function mergeQueryUsageMetric(target, bucket) {
788
- target.calls += bucket.calls
789
- mergeQuerySessions(target, bucket.sessions)
790
- addQueryTokens(target, bucket)
791
- addCostAccumulator(target.cost, bucket.cost)
792
- }
793
- function addQueryUsageBucket(aggregate, bucket, date, workspaceId) {
794
- const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, workspaceId)]
795
- targets.push(queryModel(aggregate, bucket.identity))
796
- for (const target of targets) mergeQueryUsageMetric(target, bucket)
797
- }
798
- function addQueryTurnMetric(target, turn) {
799
- if (target.turnKeys.has(turn.key)) return
800
- target.turnKeys.add(turn.key)
801
- target.turns += 1
802
- target.sessions.add(turn.sid)
803
- }
804
- function mergeQueryTurnBucket(target, bucket) {
805
- target.turns += bucket.turns
806
- mergeQuerySessions(target, bucket.sessions)
807
- }
808
- function addQueryTurn(aggregate, turn, date) {
809
- const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, turn.wsId)]
810
- targets.push(queryModel(aggregate, turn.identity))
811
- for (const target of targets) addQueryTurnMetric(target, turn)
812
- }
813
- function addQueryTurnBucket(aggregate, bucket, date, workspaceId) {
814
- const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, workspaceId)]
815
- targets.push(queryModel(aggregate, bucket.identity))
816
- for (const target of targets) mergeQueryTurnBucket(target, bucket)
817
- }
818
- function finalizeQueryMetric(metric) {
819
- 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) }
820
- }
821
- function finalizeQueryAggregate(aggregate) {
822
- 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 } }))
823
- const perWorkspace = Array.from(aggregate.workspaces.values()).map((row) => ({ workspaceId: row.workspaceId, ...finalizeQueryMetric(row) }))
824
- 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) }))
825
- return { totals: finalizeQueryMetric(aggregate.totals), daily, perWorkspace, perModel }
826
- }
827
- function heatmapDay(days, date) {
828
- let day = days.get(date)
829
- 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) }
830
- return day
831
- }
832
- function addHeatmapUsage(days, bucket, date) {
833
- const day = heatmapDay(days, date)
834
- addQueryTokens(day.tokens, bucket)
835
- }
836
- function addHeatmapTurnBucket(days, bucket, date, workspaceId) {
837
- const day = heatmapDay(days, date)
838
- day.turns += bucket.turns
839
- day.perWorkspace.set(workspaceId, (day.perWorkspace.get(workspaceId) || 0) + bucket.turns)
840
- }
841
- function addHeatmapTurns(days, records, date) {
842
- const day = heatmapDay(days, date)
843
- for (const turn of records) {
844
- day.turns += 1
845
- day.perWorkspace.set(turn.wsId, (day.perWorkspace.get(turn.wsId) || 0) + 1)
846
- }
847
- }
848
- function serializeHeatmap(days) {
849
- 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] })) }))
850
- }
851
- const HOUR_MS = 60 * 60 * 1000
852
- function hourStartOf(time, utc) {
853
- const date = new Date(time)
854
- if (utc) return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours())
855
- // Reconstructing a local hour with new Date(y, m, d, h) collapses a DST
856
- // fall-back twice-repeated hour (both 01:xx map to the first occurrence).
857
- // Instead align the instant to the local hour boundary using the offset
858
- // that applied at that instant, keeping repeated local hours distinct.
859
- const offset = date.getTimezoneOffset()
860
- const shifted = time - offset * 60000
861
- return Math.floor(shifted / HOUR_MS) * HOUR_MS + offset * 60000
862
- }
863
- function calendarStartOf(dateText, utc) {
864
- const parts = dateText.split('-').map(Number)
865
- return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2]) : new Date(parts[0], parts[1] - 1, parts[2]).getTime()
866
- }
867
- function nextCalendarStartOf(dateText, utc) {
868
- const parts = dateText.split('-').map(Number)
869
- return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2] + 1) : new Date(parts[0], parts[1] - 1, parts[2] + 1).getTime()
870
- }
871
- function hourlyRangeOf(scope, nowMs) {
872
- if (scope.start !== scope.end) return null
873
- const start = calendarStartOf(scope.start, scope.utc)
874
- const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
875
- const end = scope.start === today ? nowMs : nextCalendarStartOf(scope.start, scope.utc)
876
- return { start, count: Math.max(1, Math.ceil(Math.max(0, end - start) / HOUR_MS)) }
877
- }
878
- function serializeTrendMetric(time, metric) {
879
- const value = finalizeQueryMetric(metric || queryMetric())
880
- return { time, date: new Date(time).toISOString(), ...value, tokens: { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite, reasoning: value.reasoning } }
881
- }
882
- function queryHourlyTrend(day, scope, nowMs, matchingTurnKeys) {
883
- const range = hourlyRangeOf(scope, nowMs)
884
- if (range === null) return []
885
- const buckets = new Map()
886
- const indexHours = new Map()
887
- const metricFor = (hour) => {
888
- // Zone offsets are not always whole hours (Lord Howe +10:30/+11), so a
889
- // floor can drop or merge fractional-hour buckets; round and clamp keeps
890
- // every event inside the requested range without losing rows.
891
- const index = Math.min(range.count - 1, Math.max(0, Math.round((hour - range.start) / HOUR_MS)))
892
- if (index < 0 || index >= range.count) return null
893
- const previousHour = indexHours.get(index)
894
- if (previousHour === undefined || hour < previousHour) indexHours.set(index, hour)
895
- let metric = buckets.get(index)
896
- if (metric === undefined) { metric = queryMetric(); buckets.set(index, metric) }
897
- return metric
898
- }
899
- const hasIdentityFilter = (scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)
900
- const hours = day && day.queryHours instanceof Map ? day.queryHours : new Map()
901
- for (const [hour, value] of hours) {
902
- for (const [workspaceId, models] of value.usage) {
903
- if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
904
- for (const bucket of models.values()) {
905
- if (!identityMatchesScope(bucket.identity, scope)) continue
906
- const metric = metricFor(hour)
907
- if (metric === null) continue
908
- mergeQueryUsageMetric(metric, bucket)
909
- }
910
- }
911
- for (const [workspaceId, models] of value.turns) {
912
- if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
913
- for (const bucket of models.values()) {
914
- if (!hasIdentityFilter || identityMatchesScope(bucket.identity, scope)) {
915
- const metric = metricFor(hour)
916
- if (metric !== null) mergeQueryTurnBucket(metric, bucket)
917
- } else if (matchingTurnKeys.size > 0) {
918
- const metric = metricFor(hour)
919
- if (metric === null) continue
920
- for (const turn of bucket.records.values()) if (matchingTurnKeys.has(turn.key)) addQueryTurnMetric(metric, turn)
921
- }
922
- }
923
- }
924
- }
925
- // Emit the real bucket boundary when the bucket was rounded into this slot;
926
- // the uniform grid only labels empty slots, so fractional-hour zones keep
927
- // exact labels instead of drifting 30 minutes off.
928
- return Array.from({ length: range.count }, (_, index) => serializeTrendMetric(indexHours.has(index) ? indexHours.get(index) : range.start + index * HOUR_MS, buckets.get(index)))
929
- }
930
- function queryUsageScope(scope) {
931
- commitPendingStats()
932
- const nowMs = Date.now()
933
- const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
934
- const hourlyCacheKey = scope.start === scope.end ? ':' + (scope.start === today ? hourStartOf(nowMs, scope.utc) : 'fixed') : ''
935
- const currentQueryRevision = queryRevision()
936
- const key = currentQueryRevision + ':' + scopeFingerprint(scope) + hourlyCacheKey
937
- const cached = state.queryCache.get(key)
938
- if (cached !== undefined) {
939
- const revisions = revisionSnapshot()
940
- return { ...cached, ...revisions, updatedAt: state.statsUpdatedAt, partial: !state.scan.done, completeThrough: { ...revisions, at: state.statsUpdatedAt } }
941
- }
942
- const now = new Date(nowMs)
943
- const weekday = scope.utc ? now.getUTCDay() : now.getDay()
944
- const sunday = shiftDateText(today, -weekday, scope.utc)
945
- const heatStart = shiftDateText(sunday, -52 * 7, scope.utc)
946
- const selected = queryAggregate()
947
- const heatmap = new Map()
948
- const matchingTurnKeys = new Set()
949
- const dayMap = scope.utc ? state.byDayUtc : state.byDay
950
- const hasIdentityFilter = (scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)
951
- for (const [date, day] of dayMap) {
952
- const inSelected = dateInScope(date, scope)
953
- const inHeat = date >= heatStart && date <= today
954
- if (!inSelected && !inHeat) continue
955
- for (const [workspaceId, models] of day.queryUsage) {
956
- if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
957
- for (const bucket of models.values()) {
958
- if (!identityMatchesScope(bucket.identity, scope)) continue
959
- if (hasIdentityFilter) for (const turnKey of bucket.turnKeys) matchingTurnKeys.add(turnKey)
960
- if (inSelected) addQueryUsageBucket(selected, bucket, date, workspaceId)
961
- if (inHeat) addHeatmapUsage(heatmap, bucket, date)
962
- }
963
- }
964
- }
965
- for (const [date, day] of dayMap) {
966
- const inSelected = dateInScope(date, scope)
967
- const inHeat = date >= heatStart && date <= today
968
- if (!inSelected && !inHeat) continue
969
- for (const [workspaceId, models] of day.queryTurns) {
970
- if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
971
- for (const bucket of models.values()) {
972
- if (!hasIdentityFilter || identityMatchesScope(bucket.identity, scope)) {
973
- if (inSelected) addQueryTurnBucket(selected, bucket, date, workspaceId)
974
- if (inHeat) addHeatmapTurnBucket(heatmap, bucket, date, workspaceId)
975
- continue
976
- }
977
- if (matchingTurnKeys.size === 0) continue
978
- const matching = []
979
- for (const turn of bucket.records.values()) if (matchingTurnKeys.has(turn.key)) matching.push(turn)
980
- if (matching.length === 0) continue
981
- if (inSelected) for (const turn of matching) addQueryTurn(selected, turn, date)
982
- if (inHeat) addHeatmapTurns(heatmap, matching, date)
983
- }
984
- }
985
- }
986
- const hourly = queryHourlyTrend(dayMap.get(scope.start), scope, nowMs, matchingTurnKeys)
987
- const revisions = revisionSnapshot()
988
- 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) }
989
- state.queryCache.set(key, result)
990
- while (state.queryCache.size > 20) state.queryCache.delete(state.queryCache.keys().next().value)
991
- return result
992
- }
993
- function opaqueRecordId(item) {
994
- return createHash('sha256').update(item.sid + '\0' + item.key).digest('hex').slice(0, 20)
995
- }
996
- function recordOrder(a, b) {
997
- 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)
998
- }
999
- function queryRecords(scope, cursor, limit) {
1000
- commitPendingStats()
1001
- const fingerprint = scopeFingerprint(scope)
1002
- const currentQueryRevision = queryRevision()
1003
- let offset = 0
1004
- if (cursor !== undefined && cursor !== '') {
1005
- try {
1006
- const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
1007
- if (decoded.queryRevision !== currentQueryRevision || decoded.scope !== fingerprint || !Number.isInteger(decoded.offset) || decoded.offset < 0) return { error: 'stale-cursor' }
1008
- offset = decoded.offset
1009
- } catch (err) { return { error: 'bad-cursor' } }
1010
- }
1011
- const cacheKey = currentQueryRevision + ':' + fingerprint
1012
- let rows = state.recordsQueryCache.get(cacheKey)
1013
- if (rows === undefined) {
1014
- const index = scope.utc ? state.usageByUtcDate : state.usageByLocalDate
1015
- rows = indexedEntriesInRange(index, state.usageByStep, scope.start, scope.end).filter(({ item }) => {
1016
- if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) return false
1017
- return identityMatchesScope(item.identity || item.modelId, scope)
1018
- })
1019
- rows.sort((left, right) => recordOrder(left.item, right.item))
1020
- state.recordsQueryCache.set(cacheKey, rows)
1021
- while (state.recordsQueryCache.size > 20) state.recordsQueryCache.delete(state.recordsQueryCache.keys().next().value)
1022
- }
1023
- const page = rows.slice(offset, offset + limit)
1024
- const items = page.map(({ item, date }) => {
1025
- const identity = coerceIdentity(item.identity || item.modelId)
1026
- 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' }
1027
- })
1028
- const nextOffset = offset + items.length
1029
- 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 }
1030
- }
1031
-
1032
- return {
1033
- dayKey,
1034
- dayKeyUtc,
1035
- dateKeys,
1036
- num,
1037
- validEventTime,
1038
- addDateIndex,
1039
- removeDateIndex,
1040
- indexUsage,
1041
- unindexUsage,
1042
- indexedEntriesInRange,
1043
- usageBasisEqual,
1044
- resolveCurrentPricing,
1045
- costForUsage,
1046
- removeSession,
1047
- retargetSession,
1048
- resetAggregationState,
1049
- ensureDay,
1050
- ensureWs,
1051
- ensureDayWs,
1052
- ensureModel,
1053
- ensureDayModel,
1054
- adjustValues,
1055
- noValues,
1056
- adjustDaySession,
1057
- adjustDay,
1058
- addCostAggregateDirection,
1059
- adjustUsage,
1060
- adjustQueryCost,
1061
- upsertUsageSample,
1062
- addUsage,
1063
- addDayTurn,
1064
- turnRecordKey,
1065
- addTurn,
1066
- textOrNull,
1067
- identityLabel,
1068
- makeIdentity,
1069
- identityFromLegacy,
1070
- isCanonicalIdentity,
1071
- coerceIdentity,
1072
- routeObject,
1073
- identityFromRoute,
1074
- identityFromMessage,
1075
- modelFromRoute,
1076
- modelFromMessage,
1077
- scanSnapshot,
1078
- queryRevision,
1079
- revisionSnapshot,
1080
- statusSnapshot,
1081
- serializeIdentity,
1082
- serializeModelAggregate,
1083
- serializeDays,
1084
- snapshot,
1085
- validDateText,
1086
- shiftDateText,
1087
- queryScopeFromRequest,
1088
- scopeFingerprint,
1089
- dateInScope,
1090
- modelNameOfIdentity,
1091
- identityMatchesScope,
1092
- queryMetric,
1093
- queryAggregate,
1094
- queryDay,
1095
- queryWorkspace,
1096
- queryModel,
1097
- addQueryTokens,
1098
- addQueryTurn,
1099
- finalizeQueryMetric,
1100
- finalizeQueryAggregate,
1101
- hourStartOf,
1102
- calendarStartOf,
1103
- nextCalendarStartOf,
1104
- hourlyRangeOf,
1105
- serializeTrendMetric,
1106
- queryHourlyTrend,
1107
- queryUsageScope,
1108
- opaqueRecordId,
1109
- recordOrder,
1110
- queryRecords
1111
- }
1112
- }
1
+ import { createHash } from 'node:crypto'
2
+ import { RETIRED_WORKSPACE_ID } from './ledger.js'
3
+ import { cacheRecordsResult, deleteRecordsResult, getRecordsResult, recordKeyHash, recordPageReferences, recordsCacheStats } from './records-cache.js'
4
+ import { COST_SCHEMA_VERSION, addCostAccumulator, addCostAggregate, calculateCost, createCostAccumulator, decimalSubtract, isCostAccumulator, normalizeCostSnapshot, resolvePricing, serializeCostAggregate, temporalPlanFor } from './pricing.js'
5
+ import { contextTimeIsFresh, extractUsageEvent, normalizeEventSeq, normalizeUsageValues as usageValues, upsertUsageSample as upsertUsageSampleState, usageStepKey, validEventTime as validUsageEventTime } from './usage-core.js'
6
+
7
+ export function createAggregation(host) {
8
+ const { state, markStatsChanged, commitPendingStats, resetSyncState } = host
9
+ // Older hosts (and hand-built test states) predate the per-session digest and the
10
+ // date -> sessions index the /records loader consults.
11
+ if (!(state.perSession instanceof Map)) state.perSession = new Map()
12
+ if (!(state.sessionsByLocalDate instanceof Map)) state.sessionsByLocalDate = new Map()
13
+ if (!(state.sessionsByUtcDate instanceof Map)) state.sessionsByUtcDate = new Map()
14
+ if (!(state.rowTouch instanceof Map)) state.rowTouch = new Map()
15
+ if (!(state.recordsQueryCache instanceof Map)) state.recordsQueryCache = new Map()
16
+ const pricingSnapshot = (...args) => host.pricingSnapshot(...args)
17
+ const syncSnapshot = (...args) => host.syncSnapshot(...args)
18
+
19
+ function dayKey(ms) {
20
+ const d = new Date(ms)
21
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
22
+ }
23
+ function dayKeyUtc(ms) {
24
+ const d = new Date(ms)
25
+ return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
26
+ }
27
+ function dateKeys(ms) {
28
+ const d = new Date(ms)
29
+ const local = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
30
+ const utc = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
31
+ return { local, utc }
32
+ }
33
+ function num(v) {
34
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0
35
+ }
36
+ const validEventTime = validUsageEventTime
37
+ function addDateIndex(index, date, key) {
38
+ if (typeof date !== 'string' || date === '') return
39
+ let keys = index.get(date)
40
+ if (keys === undefined) { keys = new Set(); index.set(date, keys) }
41
+ keys.add(key)
42
+ }
43
+ function removeDateIndex(index, date, key) {
44
+ if (typeof date !== 'string' || date === '') return
45
+ const keys = index.get(date)
46
+ if (keys === undefined) return
47
+ keys.delete(key)
48
+ if (keys.size === 0) index.delete(date)
49
+ }
50
+ // A per-session digest of everything that was folded in: the day/workspace/model
51
+ // cubes can be unwound from it without the session's rows, which is what lets the
52
+ // detail rows be dropped from memory and read back from disk on demand.
53
+ function sessionAggregateFor(sid, create) {
54
+ if (typeof sid !== 'string' || sid === '') return undefined
55
+ let session = state.perSession.get(sid)
56
+ if (session === undefined) {
57
+ if (create !== true) return undefined
58
+ // newestTime is the fence the live feed is checked against: everything older has
59
+ // already been folded from the ledger (or will be), and a replay must not add it
60
+ // a second time under a renumbered key.
61
+ session = { keys: new Map(), buckets: new Map(), newestTime: 0 }
62
+ state.perSession.set(sid, session)
63
+ }
64
+ return session
65
+ }
66
+ function pruneSessionAggregate(sid, session) {
67
+ if (session === undefined) return
68
+ if (session.keys.size === 0 && session.buckets.size === 0) state.perSession.delete(sid)
69
+ }
70
+ /**
71
+ * How a row's cost was decided. A row stays resident only when something can still
72
+ * change it: an unpriced row (backfill) or one priced from a request context that
73
+ * had already gone stale (the temporal reconcile rewrites exactly those).
74
+ */
75
+ function pricingExposureOf(cost, eventTime) {
76
+ const priced = cost !== null && cost !== undefined && cost.status === 'priced'
77
+ let requestContext = 0
78
+ if (cost !== null && cost !== undefined && cost.pricingTimeSource === 'request-context') {
79
+ const at = cost.pricingAt
80
+ requestContext = Number.isFinite(at) && contextTimeIsFresh(at, eventTime) ? 0 : 1
81
+ }
82
+ return { unpriced: priced ? 0 : 1, requestContext }
83
+ }
84
+ /** True when nothing about this session's rows can still change without a rebuild. */
85
+ function sessionIsSettled(session) {
86
+ for (const bucket of session.buckets.values()) if (bucket.unpriced > 0 || bucket.requestContext > 0) return false
87
+ return true
88
+ }
89
+ // date -> session -> how many of that session's rows fall on the date. A
90
+ // reference count, because a session usually has several rows in one day and a
91
+ // plain Set would drop it after the first one is forgotten.
92
+ function addSessionDate(index, date, sid) {
93
+ if (typeof date !== 'string' || date === '' || typeof sid !== 'string' || sid === '') return
94
+ let day = index.get(date)
95
+ if (day === undefined) { day = new Map(); index.set(date, day) }
96
+ day.set(sid, (day.get(sid) || 0) + 1)
97
+ }
98
+ function forgetSessionDate(index, date, sid) {
99
+ const day = index.get(date)
100
+ if (day === undefined) return
101
+ const next = (day.get(sid) || 0) - 1
102
+ if (next > 0) day.set(sid, next)
103
+ else day.delete(sid)
104
+ if (day.size === 0) index.delete(date)
105
+ }
106
+ function sessionBucketKey(entry) {
107
+ // Keep each bucket inside one local/UTC day and hour so aggregate unwinds can
108
+ // remove query-hour contributions without retaining every detail row.
109
+ const identityKey = typeof entry.identityKey === 'string' && entry.identityKey !== '' ? entry.identityKey : coerceIdentity(entry.identity).identityKey
110
+ const time = Number.isFinite(entry.time) ? entry.time : 0
111
+ return JSON.stringify([entry.date, entry.dateUtc, entry.wsId, identityKey, hourStartOf(time, false), hourStartOf(time, true)])
112
+ }
113
+ /** Roll one usage row into its session's digest (both directions). */
114
+ function adjustSessionUsage(sid, direction, entry, count = 1) {
115
+ const session = sessionAggregateFor(sid, direction === 1)
116
+ if (session === undefined) return
117
+ const key = sessionBucketKey(entry)
118
+ let bucket = session.buckets.get(key)
119
+ if (bucket === undefined) {
120
+ if (direction !== 1) return
121
+ bucket = {
122
+ time: entry.time,
123
+ date: entry.date,
124
+ dateUtc: entry.dateUtc,
125
+ wsId: entry.wsId,
126
+ identity: coerceIdentity(entry.identity),
127
+ calls: 0,
128
+ unpriced: 0,
129
+ requestContext: 0,
130
+ turns: new Map(),
131
+ values: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
132
+ cost: createCostAccumulator(),
133
+ }
134
+ session.buckets.set(key, bucket)
135
+ }
136
+ bucket.calls += direction * count
137
+ if (direction === 1 && Number.isFinite(entry.time) && entry.time > (session.newestTime || 0)) session.newestTime = entry.time
138
+ const exposure = pricingExposureOf(entry.cost, entry.time)
139
+ bucket.unpriced += direction * exposure.unpriced * count
140
+ bucket.requestContext += direction * exposure.requestContext * count
141
+ adjustValues(bucket.values, entry.values, direction)
142
+ addCostAggregateDirection(bucket.cost, entry.cost, direction)
143
+ if (entry.turn !== null && entry.turn !== undefined) {
144
+ const turnKey = String(sid) + ':turn:' + String(entry.turn)
145
+ const next = (bucket.turns.get(turnKey) || 0) + direction * count
146
+ if (next > 0) bucket.turns.set(turnKey, next)
147
+ else bucket.turns.delete(turnKey)
148
+ }
149
+ if (bucket.calls <= 0 && noValues(bucket.values)) session.buckets.delete(key)
150
+ pruneSessionAggregate(sid, session)
151
+ }
152
+ /**
153
+ * Only the cost changed (a backfill or re-price): keep the session digest's
154
+ * bucket in step, or a later removal would unwind the stale amount.
155
+ */
156
+ function adjustSessionCostOnly(item, cost, direction) {
157
+ if (item === null || item === undefined || typeof item.sid !== 'string' || item.sid === '') return
158
+ const session = sessionAggregateFor(item.sid, false)
159
+ if (session === undefined) return
160
+ const identity = coerceIdentity(item.identity || item.modelId)
161
+ const dates = typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
162
+ const bucket = session.buckets.get(sessionBucketKey({ date: dates.local, dateUtc: dates.utc, wsId: item.wsId, identityKey: identity.identityKey, time: item.time }))
163
+ if (bucket === undefined) return
164
+ const exposure = pricingExposureOf(cost, item.time)
165
+ bucket.unpriced += direction * exposure.unpriced
166
+ bucket.requestContext += direction * exposure.requestContext
167
+ addCostAggregateDirection(bucket.cost, cost, direction)
168
+ }
169
+ function recordSessionKey(item) {
170
+ const session = sessionAggregateFor(item.sid, true)
171
+ if (session === undefined) return
172
+ // The metadata is the bounded description a cold /records query filters and
173
+ // orders on, so it keeps the row's time/seq and shares its identity object.
174
+ // Values, cost and the row itself stay out: those are read back per page.
175
+ session.keys.set(item.key, {
176
+ date: item.date,
177
+ dateUtc: item.dateUtc,
178
+ wsId: item.wsId,
179
+ time: Number.isFinite(item.time) ? item.time : undefined,
180
+ seq: Number.isFinite(item.seq) ? item.seq : undefined,
181
+ identity: item.identity,
182
+ })
183
+ // which sessions have rows on a date: the disk lookup for /records starts here
184
+ addSessionDate(state.sessionsByLocalDate, item.date, item.sid)
185
+ addSessionDate(state.sessionsByUtcDate, item.dateUtc, item.sid)
186
+ if (state.rowTouch instanceof Map) state.rowTouch.set(item.sid, (state.rowTouch.get(item.sid) || 0) + 1)
187
+ }
188
+ function forgetSessionKey(item) {
189
+ const session = sessionAggregateFor(item.sid, false)
190
+ if (session === undefined) return
191
+ session.keys.delete(item.key)
192
+ if (state.sessionsByLocalDate instanceof Map) forgetSessionDate(state.sessionsByLocalDate, item.date, item.sid)
193
+ if (state.sessionsByUtcDate instanceof Map) forgetSessionDate(state.sessionsByUtcDate, item.dateUtc, item.sid)
194
+ pruneSessionAggregate(item.sid, session)
195
+ }
196
+ /** The resident detail-row budget; the rest lives in the ledger on disk. */
197
+ function residentRowBudget() {
198
+ const configured = Number(process.env.DSH_ALL_USAGE_RESIDENT_ROWS)
199
+ if (Number.isFinite(configured)) return Math.floor(configured)
200
+ return 12000
201
+ }
202
+ function rowIsProtected(sid) {
203
+ if (state.ledgerDirtySessions instanceof Set && state.ledgerDirtySessions.has(sid)) return true
204
+ if (state.ledgerPending instanceof Map && state.ledgerPending.has(sid)) return true
205
+ if (state.ledgerWriting instanceof Set && state.ledgerWriting.has(sid)) return true
206
+ if (state.ledgerReading instanceof Map && (state.ledgerReading.get(sid) || 0) > 0) return true
207
+ if (state.liveResyncPending instanceof Set && state.liveResyncPending.has(sid)) return true
208
+ if (state.ledgerPrefetching instanceof Set && state.ledgerPrefetching.has(sid)) return true
209
+ return false
210
+ }
211
+ /**
212
+ * Drop the detail rows of sessions whose contribution is already in the cubes and
213
+ * whose pricing is final. The digest keeps such a session removable, and the rows
214
+ * are read back from the ledger when a query or a rebuild asks for them.
215
+ */
216
+ function evictColdRows(options) {
217
+ bumpCount('evict.runs')
218
+ const requested = options !== null && options !== undefined ? options.rows : undefined
219
+ const budget = Number.isFinite(requested) ? Math.floor(requested) : residentRowBudget()
220
+ const keep = options !== null && options !== undefined && options.keep instanceof Set ? options.keep : null
221
+ if (!(state.usageByStep instanceof Map)) return { sessions: 0, rows: 0 }
222
+ // A negative budget keeps every row resident (the pre-eviction behaviour).
223
+ if (budget < 0) return { sessions: 0, rows: 0 }
224
+ if (state.usageByStep.size <= budget) return { sessions: 0, rows: 0 }
225
+ const candidates = []
226
+ for (const [sid, session] of state.perSession) {
227
+ if (session.keys.size === 0) continue
228
+ if (keep !== null && keep.has(sid)) continue
229
+ if (rowIsProtected(sid)) continue
230
+ candidates.push([sid, state.rowTouch instanceof Map ? (state.rowTouch.get(sid) || 0) : 0])
231
+ }
232
+ // Least recently folded first: the rows of the sessions the scan is working
233
+ // through stay, and only rows whose cost is final are dropped, so a session with
234
+ // one unsettled row keeps just that row.
235
+ candidates.sort((left, right) => left[1] - right[1])
236
+ const startSize = state.usageByStep.size
237
+ let sessions = 0
238
+ let rows = 0
239
+ for (const [sid] of candidates) {
240
+ if (startSize - rows <= budget) break
241
+ const session = state.perSession.get(sid)
242
+ if (session === undefined) continue
243
+ let dropped = 0
244
+ for (const key of session.keys.keys()) {
245
+ const item = state.usageByStep.get(key)
246
+ if (item === undefined) continue
247
+ const exposure = pricingExposureOf(item.cost, item.time)
248
+ if (exposure.unpriced > 0 || exposure.requestContext > 0) continue
249
+ if (state.usageByStep.delete(key)) dropped += 1
250
+ }
251
+ if (dropped > 0) sessions += 1
252
+ rows += dropped
253
+ }
254
+ return { sessions, rows }
255
+ }
256
+ /** The sessions that own at least one row on a date inside the range. */
257
+ function sessionsForRange(utc, start, end) {
258
+ const index = utc === true ? state.sessionsByUtcDate : state.sessionsByLocalDate
259
+ const result = new Set()
260
+ if (!(index instanceof Map)) return result
261
+ for (const [date, day] of index) {
262
+ if (typeof start === 'string' && start !== '' && date < start) continue
263
+ if (typeof end === 'string' && end !== '' && date > end) continue
264
+ // The day is a Map of session id -> row count, so its keys are the sessions.
265
+ for (const sid of day.keys()) result.add(sid)
266
+ }
267
+ return result
268
+ }
269
+ /** One usage row, built the same way whether it is folded or read back. */
270
+ function usageRowOf(record, item, materialization) {
271
+ const identity = item.identity || identityFromLegacy(item.modelId)
272
+ const dates = dateKeys(item.time)
273
+ return {
274
+ key: item.key,
275
+ seq: item.seq,
276
+ wsId: item.workspaceId,
277
+ time: item.time,
278
+ date: dates.local,
279
+ dateUtc: dates.utc,
280
+ values: item.values,
281
+ identity,
282
+ modelId: identity.label,
283
+ cost: item.cost,
284
+ turn: item.turn,
285
+ step: item.step,
286
+ materialization,
287
+ sid: record.sessionId,
288
+ }
289
+ }
290
+ /**
291
+ * Put a row back for a query without touching the cubes: they already hold its
292
+ * contribution, so it is data for the log view and nothing else.
293
+ */
294
+ function adoptUsageRow(row) {
295
+ if (row === null || row === undefined || typeof row.key !== 'string' || row.key === '') return false
296
+ if (state.usageByStep.has(row.key)) return false
297
+ state.usageByStep.set(row.key, row)
298
+ bumpCount('row.adopted')
299
+ return true
300
+ }
301
+ /**
302
+ * Fold one row into the cubes, unless the digest says it is already in them. That
303
+ * is what makes applying a record idempotent once its rows may have been dropped.
304
+ */
305
+ function foldUsageRow(row, materialization) {
306
+ if (row === null || row === undefined || typeof row.key !== 'string' || row.key === '') return false
307
+ const session = sessionAggregateFor(row.sid, false)
308
+ if (session !== undefined && session.keys.has(row.key)) { bumpCount('fold.skipped'); return false }
309
+ bumpCount('fold.folded')
310
+ const candidate = materialization === undefined || materialization === null || row.materialization === materialization ? row : { ...row, materialization }
311
+ const result = upsertUsageSample(state.usageByStep, candidate)
312
+ return result !== null && result !== undefined && result.accepted === true
313
+ }
314
+ function indexUsage(item) {
315
+ addDateIndex(state.usageByLocalDate, item.date, item.key)
316
+ addDateIndex(state.usageByUtcDate, item.dateUtc, item.key)
317
+ recordSessionKey(item)
318
+ }
319
+ function unindexUsage(item) {
320
+ removeDateIndex(state.usageByLocalDate, item.date, item.key)
321
+ removeDateIndex(state.usageByUtcDate, item.dateUtc, item.key)
322
+ forgetSessionKey(item)
323
+ }
324
+ function indexedEntriesInRange(index, source, start, end) {
325
+ const result = []
326
+ for (const [date, keys] of index) {
327
+ if (date < start || date > end) continue
328
+ for (const key of keys) {
329
+ const item = source.get(key)
330
+ if (item !== undefined) result.push({ item, date })
331
+ }
332
+ }
333
+ return result
334
+ }
335
+ function usageBasisEqual(first, identity, values) {
336
+ if (first === null || first === undefined) return false
337
+ const firstIdentity = first.identity || first.modelId
338
+ const left = coerceIdentity(firstIdentity)
339
+ const right = coerceIdentity(identity)
340
+ if (left.identityKey !== right.identityKey) return false
341
+ return ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'].every((key) => num(first.values && first.values[key]) === num(values && values[key]))
342
+ }
343
+ function resolveCurrentPricing(identity) {
344
+ const normalized = coerceIdentity(identity)
345
+ const key = normalized.identityKey
346
+ const cached = state.pricingResolutionCache.get(key)
347
+ if (cached !== undefined) return cached
348
+ const resolved = resolvePricing(normalized, state.pricingState)
349
+ state.pricingResolutionCache.set(key, resolved)
350
+ while (state.pricingResolutionCache.size > 5000) state.pricingResolutionCache.delete(state.pricingResolutionCache.keys().next().value)
351
+ return resolved
352
+ }
353
+ function costForUsage(values, identity, previous, pricingAt, pricingTimeSource) {
354
+ const previousCost = normalizeCostSnapshot(previous && previous.cost)
355
+ if (previousCost !== null && previousCost.pricingMode === 'official-model' && previousCost.pricingTimeSource !== 'legacy-unknown' && usageBasisEqual(previous, identity, values) && costTemporalCompatible(previousCost, identity, pricingAt, pricingTimeSource)) return previousCost
356
+ return calculateCost(values, resolveCurrentPricing(identity), pricingAt, pricingTimeSource)
357
+ }
358
+ function costTemporalCompatible(previousCost, identity, pricingAt, pricingTimeSource) {
359
+ if (previousCost.pricingAt === null || previousCost.pricingTimeSource === null) return false
360
+ const resolved = resolveCurrentPricing(identity)
361
+ const plan = temporalPlanFor(resolved, pricingAt)
362
+ const normalizedAt = Number.isFinite(pricingAt) && pricingAt >= 0 ? Math.trunc(pricingAt) : null
363
+ const normalizedSource = pricingTimeSource === 'request-context' || pricingTimeSource === 'usage-event' ? pricingTimeSource : 'usage-event'
364
+ 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
365
+ }
366
+ function resetAggregationState() {
367
+ state.aggregationGeneration += 1
368
+ state.wsMeta.clear()
369
+ if (state.sessionContextTimes) state.sessionContextTimes.clear()
370
+ state.pathIndex.clear()
371
+ state.memberOf.clear()
372
+ state.byDay.clear()
373
+ state.byDayUtc.clear()
374
+ state.perWorkspace.clear()
375
+ // The deleted bucket is re-derived from the ledger during the rebuild that
376
+ // follows, so a stale flag cannot advertise an empty row.
377
+ state.retiredBucketUsed = false
378
+ state.perModel.clear()
379
+ state.usageByStep.clear()
380
+ if (state.perSession instanceof Map) state.perSession.clear()
381
+ if (state.sessionsByLocalDate instanceof Map) state.sessionsByLocalDate.clear()
382
+ if (state.sessionsByUtcDate instanceof Map) state.sessionsByUtcDate.clear()
383
+ if (state.rowTouch instanceof Map) state.rowTouch.clear()
384
+ state.turnRecords.clear()
385
+ state.usageByLocalDate.clear()
386
+ state.usageByUtcDate.clear()
387
+ for (const timer of state.liveResyncTimers.values()) clearTimeout(timer)
388
+ state.liveResyncTimers.clear()
389
+ state.liveResyncAttempts.clear()
390
+ const triggerSessions = state.liveResyncTriggers instanceof Map ? Array.from(state.liveResyncTriggers.keys()) : []
391
+ state.liveResyncPending.clear()
392
+ for (const sid of triggerSessions) state.liveResyncPending.add(sid)
393
+ state.reconcileHintScheduled = false
394
+ if (state.baselineFallbackTimer !== null) {
395
+ clearTimeout(state.baselineFallbackTimer)
396
+ state.baselineFallbackTimer = null
397
+ }
398
+ state.baselineRetryScheduled = false
399
+ state.queryCache.clear()
400
+ state.recordsQueryCache.clear()
401
+ state.sessionModel.clear()
402
+ state.sessionCount.clear()
403
+ state.sessionSeq.clear()
404
+ state.chains.clear()
405
+ state.knownSessionIds.clear()
406
+ state.totals.turns = 0
407
+ state.totals.input = 0
408
+ state.totals.output = 0
409
+ state.totals.cacheRead = 0
410
+ state.totals.cacheWrite = 0
411
+ state.totals.reasoning = 0
412
+ Object.assign(state.totals.cost, createCostAccumulator())
413
+ state.scan.started = false
414
+ state.scan.done = false
415
+ state.scan.scanned = 0
416
+ state.scan.total = 0
417
+ state.scan.failed = 0
418
+ resetSyncState()
419
+ state.baselineRetryDelay = 1000
420
+ markStatsChanged(['data', 'metadata', 'scan'])
421
+ return state.aggregationGeneration
422
+ }
423
+ function ensureDay(dayMap, date) {
424
+ let day = dayMap.get(date)
425
+ if (day === undefined) {
426
+ 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() }
427
+ dayMap.set(date, day)
428
+ markStatsChanged('metadata')
429
+ }
430
+ return day
431
+ }
432
+ function ensureWs(wsId) {
433
+ let ws = state.perWorkspace.get(wsId)
434
+ if (ws === undefined) {
435
+ ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
436
+ state.perWorkspace.set(wsId, ws)
437
+ markStatsChanged('metadata')
438
+ }
439
+ return ws
440
+ }
441
+ function ensureDayWs(day, wsId) {
442
+ let w = day.byWs.get(wsId)
443
+ if (w === undefined) {
444
+ w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
445
+ day.byWs.set(wsId, w)
446
+ }
447
+ return w
448
+ }
449
+ function ensureModel(value) {
450
+ const identity = coerceIdentity(value)
451
+ let item = state.perModel.get(identity.identityKey)
452
+ if (item === undefined) {
453
+ item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
454
+ state.perModel.set(identity.identityKey, item)
455
+ markStatsChanged('metadata')
456
+ }
457
+ return item
458
+ }
459
+ function ensureDayModel(day, value) {
460
+ const identity = coerceIdentity(value)
461
+ let item = day.byModel.get(identity.identityKey)
462
+ if (item === undefined) {
463
+ item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
464
+ day.byModel.set(identity.identityKey, item)
465
+ }
466
+ return item
467
+ }
468
+ function adjustValues(target, values, direction) {
469
+ target.input += values.input * direction
470
+ target.output += values.output * direction
471
+ target.cacheRead += values.cacheRead * direction
472
+ target.cacheWrite += values.cacheWrite * direction
473
+ target.reasoning += values.reasoning * direction
474
+ }
475
+ function addQueryMetricTokens(target, values, direction = 1) {
476
+ target.input += values.input * direction
477
+ target.output += values.output * direction
478
+ target.cacheRead += values.cacheRead * direction
479
+ target.cacheWrite += values.cacheWrite * direction
480
+ target.reasoning += values.reasoning * direction
481
+ }
482
+ function noValues(target) {
483
+ return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
484
+ }
485
+ function adjustDaySession(day, sid, direction) {
486
+ if (sid === undefined || sid === null) return
487
+ const current = day.sessionRefs.get(sid) || 0
488
+ const next = current + direction
489
+ if (next > 0) {
490
+ day.sessionRefs.set(sid, next)
491
+ day.sessionIds.add(sid)
492
+ } else {
493
+ day.sessionRefs.delete(sid)
494
+ day.sessionIds.delete(sid)
495
+ }
496
+ }
497
+ function adjustDay(dayMap, date, wsId, values, identity, direction, sid, cost, count = 1) {
498
+ const day = ensureDay(dayMap, date)
499
+ adjustDaySession(day, sid, direction * count)
500
+ adjustValues(day.tokens, values, direction)
501
+ addCostAggregateDirection(day.cost, cost, direction)
502
+ const dayWs = ensureDayWs(day, wsId)
503
+ adjustValues(dayWs, values, direction)
504
+ addCostAggregateDirection(dayWs.cost, cost, direction)
505
+ if (noValues(dayWs)) day.byWs.delete(wsId)
506
+ const dayModel = ensureDayModel(day, identity)
507
+ dayModel.calls += direction * count
508
+ adjustValues(dayModel, values, direction)
509
+ addCostAggregateDirection(dayModel.cost, cost, direction)
510
+ if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
511
+ }
512
+ function addCostAggregateDirection(target, cost, direction) {
513
+ if (direction === 1) {
514
+ if (isCostAccumulator(target)) addCostAccumulator(target, cost, 1)
515
+ else addCostAggregate(target, cost)
516
+ return
517
+ }
518
+ if (direction !== -1) return
519
+ if (isCostAccumulator(target)) {
520
+ addCostAccumulator(target, cost, -1)
521
+ return
522
+ }
523
+ const status = cost && typeof cost.status === 'string' ? cost.status : 'unpriced'
524
+ if (status === 'priced') {
525
+ target.input = decimalSubtract(target.input, cost.breakdown && cost.breakdown.input)
526
+ target.output = decimalSubtract(target.output, cost.breakdown && cost.breakdown.output)
527
+ target.cacheRead = decimalSubtract(target.cacheRead, cost.breakdown && cost.breakdown.cacheRead)
528
+ target.cacheWrite = decimalSubtract(target.cacheWrite, cost.breakdown && cost.breakdown.cacheWrite)
529
+ target.baseTotal = decimalSubtract(target.baseTotal, cost.baseTotal)
530
+ target.total = decimalSubtract(target.total, cost.total)
531
+ target.pricedCalls -= 1
532
+ } else if (status === 'ambiguous') target.ambiguousCalls -= 1
533
+ else if (status === 'unsupported') target.unsupportedCalls -= 1
534
+ else target.unpricedCalls -= 1
535
+ }
536
+ function adjustQueryRefs(bucket, field, sid, direction) {
537
+ if (sid === undefined || sid === null) return
538
+ const refs = field === 'sessions' ? bucket.sessionRefs : bucket[field + 'Refs']
539
+ const values = bucket[field]
540
+ const current = refs.get(sid) || 0
541
+ const next = current + direction
542
+ if (next > 0) { refs.set(sid, next); values.add(sid) }
543
+ else { refs.delete(sid); values.delete(sid) }
544
+ }
545
+ function queryUsageMetric(identity) {
546
+ 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() }
547
+ }
548
+ function queryTurnMetric(identity) {
549
+ return { identity: coerceIdentity(identity), turns: 0, sessions: new Set(), sessionRefs: new Map(), records: new Map() }
550
+ }
551
+ function queryWorkspaceMap(root, wsId, create) {
552
+ let value = root.get(wsId)
553
+ if (value === undefined && create) { value = new Map(); root.set(wsId, value) }
554
+ return value
555
+ }
556
+ function adjustQueryUsageMap(root, item, direction, count = 1) {
557
+ bumpCount(direction === 1 ? 'query.add' : 'query.sub')
558
+ const identity = coerceIdentity(item.identity || item.modelId)
559
+ // A subtraction has to land even when the bucket is gone: taking the row back out
560
+ // of the cube is what keeps it honest. The old code returned early when the
561
+ // workspace or bucket was missing, so the amounts it had added stayed in the cube
562
+ // for ever - the current day's query cube drifted several times above the day
563
+ // cube that way. Buckets are also no longer deleted when they empty, so a later
564
+ // subtraction still finds them.
565
+ const workspace = queryWorkspaceMap(root, item.wsId, true)
566
+ if (workspace === undefined) return
567
+ let bucket = workspace.get(identity.identityKey)
568
+ if (bucket === undefined) { bucket = queryUsageMetric(identity); workspace.set(identity.identityKey, bucket) }
569
+ bucket.calls += direction * count
570
+ adjustQueryRefs(bucket, 'sessions', item.sid, direction * count)
571
+ addQueryMetricTokens(bucket, item.values, direction)
572
+ addCostAccumulator(bucket.cost, item.cost, direction)
573
+ if (item.turn !== null && item.turn !== undefined) {
574
+ const turnKey = String(item.sid) + ':turn:' + String(item.turn)
575
+ const current = bucket.turnKeyRefs.get(turnKey) || 0
576
+ const next = current + direction * count
577
+ if (next > 0) { bucket.turnKeyRefs.set(turnKey, next); bucket.turnKeys.add(turnKey) }
578
+ else { bucket.turnKeyRefs.delete(turnKey); bucket.turnKeys.delete(turnKey) }
579
+ }
580
+ }
581
+ /** The trailing segment of "<sid>:turn:<n>" as a number, or null. */
582
+ function turnNumberOf(turnKey) {
583
+ const at = typeof turnKey === 'string' ? turnKey.lastIndexOf(':') : -1
584
+ if (at < 0) return null
585
+ const value = Number(turnKey.slice(at + 1))
586
+ return Number.isSafeInteger(value) && value >= 0 ? value : null
587
+ }
588
+ /**
589
+ * Drop the turn-key reference a usage row left behind in a query bucket, without
590
+ * touching that bucket's calls/tokens/cost (the caller already did that).
591
+ */
592
+ function adjustQueryUsageTurnRefs(day, item, direction, utc) {
593
+ if (day === undefined) return
594
+ adjustQueryTurnRefsMap(day.queryUsage, item, direction)
595
+ adjustQueryTurnRefsMap(ensureQueryHour(day, hourStartOf(item.time, utc)).usage, item, direction)
596
+ }
597
+ function adjustQueryTurnRefsMap(root, item, direction) {
598
+ const identity = coerceIdentity(item.identity || item.modelId)
599
+ const workspace = queryWorkspaceMap(root, item.wsId, direction === 1)
600
+ if (workspace === undefined) return
601
+ const bucket = workspace.get(identity.identityKey)
602
+ if (bucket === undefined) return
603
+ if (item.turn === null || item.turn === undefined) return
604
+ const turnKey = String(item.sid) + ':turn:' + String(item.turn)
605
+ const current = bucket.turnKeyRefs.get(turnKey) || 0
606
+ const next = current + direction
607
+ if (next > 0) { bucket.turnKeyRefs.set(turnKey, next); bucket.turnKeys.add(turnKey) }
608
+ else { bucket.turnKeyRefs.delete(turnKey); bucket.turnKeys.delete(turnKey) }
609
+ }
610
+ function ensureQueryHour(day, hour) {
611
+ let value = day.queryHours.get(hour)
612
+ if (value === undefined) { value = { usage: new Map(), turns: new Map() }; day.queryHours.set(hour, value) }
613
+ return value
614
+ }
615
+ function adjustQueryUsage(day, item, direction, utc, count = 1) {
616
+ if (day === undefined) return
617
+ // Every add has to come from the wrapper that also feeds the day cube. Anything
618
+ // else would explain two cubes diverging while their call counters agree.
619
+ if (state.diagInWrapper !== true) {
620
+ bumpCount('q.outside.wrapper.' + (direction === 1 ? 'add' : 'sub'))
621
+ bumpSum('q.outside.wrapper.input', direction * ((item.values && item.values.input) || 0))
622
+ }
623
+ // Scoped to the day the add lands on, so the total can be compared with what the
624
+ // root actually holds (and with the day cube the same row also feeds).
625
+ const scopeKey = utc ? item.dateUtc : item.date
626
+ if (direction === 1) bumpCount('q.' + (utc ? 'utc' : 'local') + '.count.' + scopeKey)
627
+ bumpSum('q.' + (utc ? 'utc' : 'local') + '.' + scopeKey, direction * ((item.values && item.values.input) || 0))
628
+ adjustQueryUsageMap(day.queryUsage, item, direction, count)
629
+ const hour = hourStartOf(item.time, utc)
630
+ adjustQueryUsageMap(ensureQueryHour(day, hour).usage, item, direction, count)
631
+ const hourValue = day.queryHours.get(hour)
632
+ if (hourValue !== undefined && hourValue.usage.size === 0 && hourValue.turns.size === 0) day.queryHours.delete(hour)
633
+ }
634
+ function adjustQueryTurnMap(root, turn, direction) {
635
+ const identity = coerceIdentity(turn.identity)
636
+ const workspace = queryWorkspaceMap(root, turn.wsId, direction === 1)
637
+ if (workspace === undefined) return
638
+ let bucket = workspace.get(identity.identityKey)
639
+ if (bucket === undefined && direction === 1) { bucket = queryTurnMetric(identity); workspace.set(identity.identityKey, bucket) }
640
+ if (bucket === undefined) return
641
+ if (direction === 1) {
642
+ if (bucket.records.has(turn.key)) return
643
+ bucket.records.set(turn.key, turn)
644
+ bucket.turns += 1
645
+ adjustQueryRefs(bucket, 'sessions', turn.sid, 1)
646
+ } else {
647
+ const previous = bucket.records.get(turn.key)
648
+ if (previous === undefined) return
649
+ bucket.records.delete(turn.key)
650
+ bucket.turns -= 1
651
+ adjustQueryRefs(bucket, 'sessions', previous.sid, -1)
652
+ }
653
+ if (direction === -1 && bucket.turns <= 0) workspace.delete(identity.identityKey)
654
+ if (workspace.size === 0) root.delete(turn.wsId)
655
+ }
656
+ function adjustQueryTurn(day, turn, direction, utc) {
657
+ if (day === undefined) return
658
+ adjustQueryTurnMap(day.queryTurns, turn, direction)
659
+ const hour = hourStartOf(turn.time, utc)
660
+ const hourValue = ensureQueryHour(day, hour)
661
+ adjustQueryTurnMap(hourValue.turns, turn, direction)
662
+ if (hourValue.usage.size === 0 && hourValue.turns.size === 0) day.queryHours.delete(hour)
663
+ }
664
+ function bumpCount(name, amount = 1) {
665
+ if (!(state.diagCounters instanceof Map)) state.diagCounters = new Map()
666
+ state.diagCounters.set(name, (state.diagCounters.get(name) || 0) + amount)
667
+ }
668
+ function bumpSum(name, amount) {
669
+ if (!Number.isFinite(amount) || amount === 0) return
670
+ if (!(state.diagSums instanceof Map)) state.diagSums = new Map()
671
+ state.diagSums.set(name, (state.diagSums.get(name) || 0) + amount)
672
+ }
673
+ /**
674
+ * What the accounting added versus what it holds. The two must agree; when they do
675
+ * not, the difference names the layer that loses or duplicates a row.
676
+ */
677
+ function accountingSnapshot() {
678
+ const todayKey = dayKey(Date.now())
679
+ const day = state.byDay.get(todayKey)
680
+ let queryInput = 0
681
+ let queryCalls = 0
682
+ let queryBuckets = 0
683
+ if (day !== undefined && day.queryUsage instanceof Map) {
684
+ const seen = new Set()
685
+ for (const models of day.queryUsage.values()) {
686
+ queryBuckets += models.size
687
+ for (const bucket of models.values()) {
688
+ if (seen.has(bucket)) continue
689
+ seen.add(bucket)
690
+ queryInput += bucket.input
691
+ queryCalls += bucket.calls
692
+ }
693
+ }
694
+ }
695
+ return {
696
+ date: todayKey,
697
+ added: Object.fromEntries(state.diagSums instanceof Map ? state.diagSums : []),
698
+ dayTokensInput: day === undefined ? null : day.tokens.input,
699
+ dayCalls: day === undefined || !(day.byModel instanceof Map) ? null : Array.from(day.byModel.values()).reduce((acc, row) => acc + (row.calls || 0), 0),
700
+ queryRootInput: queryInput,
701
+ queryRootCalls: queryCalls,
702
+ queryBuckets,
703
+ residentRows: state.usageByStep instanceof Map ? state.usageByStep.size : null,
704
+ }
705
+ }
706
+ function adjustUsage(wsId, time, values, identity, direction, sid, cachedDates, cost, turn, count = 1) {
707
+ bumpCount(direction === 1 ? 'day.add' : 'day.sub')
708
+ const normalized = coerceIdentity(identity)
709
+ const dates = cachedDates && typeof cachedDates.local === 'string' && typeof cachedDates.utc === 'string' ? cachedDates : dateKeys(time)
710
+ const modelTotals = ensureModel(normalized)
711
+ modelTotals.calls += direction * count
712
+ adjustValues(modelTotals, values, direction)
713
+ addCostAggregateDirection(modelTotals.cost, cost, direction)
714
+ if (modelTotals.calls === 0 && noValues(modelTotals)) {
715
+ state.perModel.delete(normalized.identityKey)
716
+ markStatsChanged('metadata')
717
+ }
718
+ adjustValues(state.totals, values, direction)
719
+ addCostAggregateDirection(state.totals.cost, cost, direction)
720
+ const ws = ensureWs(wsId)
721
+ adjustValues(ws, values, direction)
722
+ addCostAggregateDirection(ws.cost, cost, direction)
723
+ // What the same row hands the day cube's local map: comparing this with what the
724
+ // query root received says whether one call carries one value or two.
725
+ if (direction === 1) bumpCount('d.local.count.' + dates.local)
726
+ bumpSum('d.local.' + dates.local, direction * ((values && values.input) || 0))
727
+ adjustDay(state.byDay, dates.local, wsId, values, normalized, direction, sid, cost, count)
728
+ adjustDay(state.byDayUtc, dates.utc, wsId, values, normalized, direction, sid, cost, count)
729
+ adjustSessionUsage(sid, direction, { time, date: dates.local, dateUtc: dates.utc, wsId, identity: normalized, identityKey: normalized.identityKey, values, cost, turn }, count)
730
+ }
731
+ /**
732
+ * Undo one session's contribution. The per-session digest describes it without
733
+ * the rows, so a session whose detail rows were evicted still unwinds exactly;
734
+ * a session that only ever existed as rows (a hand-built test state) falls back
735
+ * to the row walk.
736
+ */
737
+ function removeSession(sid) {
738
+ if (typeof sid !== 'string' || sid === '') return 0
739
+ let removed = 0
740
+ const session = sessionAggregateFor(sid, false)
741
+ if (session !== undefined) {
742
+ for (const bucket of session.buckets.values()) {
743
+ const identity = coerceIdentity(bucket.identity)
744
+ const count = bucket.calls
745
+ const values = { ...bucket.values }
746
+ const cost = createCostAccumulator()
747
+ addCostAccumulator(cost, bucket.cost)
748
+ adjustUsage(bucket.wsId, bucket.time, values, identity, -1, sid, { local: bucket.date, utc: bucket.dateUtc }, cost, null, count)
749
+ const item = { sid, wsId: bucket.wsId, time: bucket.time, values, identity, cost, turn: null }
750
+ adjustQueryUsage(state.byDay.get(bucket.date), item, -1, false, count)
751
+ adjustQueryUsage(state.byDayUtc.get(bucket.dateUtc), item, -1, true, count)
752
+ for (const [turnKey, turnCount] of bucket.turns) {
753
+ const turnItem = { sid, wsId: bucket.wsId, time: bucket.time, identity, turn: turnNumberOf(turnKey) }
754
+ const refs = Number.isSafeInteger(turnCount) && turnCount > 0 ? turnCount : 1
755
+ adjustQueryUsageTurnRefs(state.byDay.get(bucket.date), turnItem, -refs, false)
756
+ adjustQueryUsageTurnRefs(state.byDayUtc.get(bucket.dateUtc), turnItem, -refs, true)
757
+ }
758
+ removed += count
759
+ }
760
+ for (const [key, dates] of session.keys) {
761
+ state.usageByStep.delete(key)
762
+ removeDateIndex(state.usageByLocalDate, dates.date, key)
763
+ removeDateIndex(state.usageByUtcDate, dates.dateUtc, key)
764
+ if (state.sessionsByLocalDate instanceof Map) forgetSessionDate(state.sessionsByLocalDate, dates.date, sid)
765
+ if (state.sessionsByUtcDate instanceof Map) forgetSessionDate(state.sessionsByUtcDate, dates.dateUtc, sid)
766
+ }
767
+ if (state.rowTouch instanceof Map) state.rowTouch.delete(sid)
768
+ state.perSession.delete(sid)
769
+ } else {
770
+ const usageKeys = []
771
+ for (const [key, item] of state.usageByStep) if (item.sid === sid) usageKeys.push([key, item])
772
+ for (const [key, item] of usageKeys) {
773
+ unindexUsage(item)
774
+ adjustUsage(item.wsId, item.time, item.values, item.identity || item.modelId, -1, sid, { local: item.date, utc: item.dateUtc }, item.cost, item.turn)
775
+ adjustQueryUsage(state.byDay.get(item.date), item, -1, false)
776
+ adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, -1, true)
777
+ state.usageByStep.delete(key)
778
+ removed += 1
779
+ }
780
+ }
781
+ const turnKeys = []
782
+ for (const [key, turn] of state.turnRecords) if (turn.sid === sid) turnKeys.push([key, turn])
783
+ for (const [key, turn] of turnKeys) {
784
+ removeTurnRecord(turn)
785
+ state.turnRecords.delete(key)
786
+ removed += 1
787
+ }
788
+ state.sessionContextTimes.delete(sid)
789
+ state.sessionModel.delete(sid)
790
+ if (removed > 0) markStatsChanged('data')
791
+ return removed
792
+ }
793
+ // Re-key every aggregate entry owned by one session to another workspace id.
794
+ //
795
+ // Retiring a workspace cannot rely on the ledger alone: usage folded from the
796
+ // live feed in the last few milliseconds has no persisted row yet, so it would
797
+ // keep a workspace id the dashboard no longer advertises. Moving the in-memory
798
+ // records keeps the totals, the per-workspace split and the query buckets
799
+ // consistent no matter when the workspace disappears.
800
+ function retargetSession(sid, workspaceId) {
801
+ if (typeof sid !== 'string' || sid === '' || typeof workspaceId !== 'string' || workspaceId === '') return 0
802
+ let moved = 0
803
+ for (const item of state.usageByStep.values()) {
804
+ if (item.sid !== sid || item.wsId === workspaceId) continue
805
+ const previousWs = item.wsId
806
+ const identity = item.identity || item.modelId
807
+ const dates = { local: item.date, utc: item.dateUtc }
808
+ // Decrement under the old workspace, then increment under the new one: the
809
+ // session, model and global totals cancel out exactly.
810
+ adjustUsage(previousWs, item.time, item.values, identity, -1, sid, dates, item.cost, item.turn)
811
+ adjustQueryUsage(state.byDay.get(item.date), item, -1, false)
812
+ adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, -1, true)
813
+ item.wsId = workspaceId
814
+ const session = sessionAggregateFor(sid, false)
815
+ const keyMeta = session && session.keys.get(item.key)
816
+ if (keyMeta !== undefined) session.keys.set(item.key, { ...keyMeta, wsId: workspaceId })
817
+ adjustUsage(workspaceId, item.time, item.values, identity, 1, sid, dates, item.cost, item.turn)
818
+ adjustQueryUsage(state.byDay.get(item.date), item, 1, false)
819
+ adjustQueryUsage(state.byDayUtc.get(item.dateUtc), item, 1, true)
820
+ moved += 1
821
+ }
822
+ for (const turn of state.turnRecords.values()) {
823
+ if (turn.sid !== sid || turn.wsId === workspaceId) continue
824
+ removeTurnRecord(turn)
825
+ turn.wsId = workspaceId
826
+ ensureWs(workspaceId).turns += 1
827
+ state.totals.turns += 1
828
+ addDayTurn(state.byDay, turn.date, workspaceId, turn.sid, 1)
829
+ addDayTurn(state.byDayUtc, turn.dateUtc, workspaceId, turn.sid, 1)
830
+ adjustQueryTurn(state.byDay.get(turn.date), turn, 1, false)
831
+ adjustQueryTurn(state.byDayUtc.get(turn.dateUtc), turn, 1, true)
832
+ moved += 1
833
+ }
834
+ if (moved > 0) markStatsChanged('data')
835
+ return moved
836
+ }
837
+ function upsertUsageSample(target, sample, options = {}) {
838
+ const previous = target.get(sample.key)
839
+ const candidate = sample.cost === undefined
840
+ ? { ...sample, cost: costForUsage(sample.values, sample.identity, options.costPrevious === undefined ? previous : options.costPrevious, sample.pricingAt, sample.pricingTimeSource) }
841
+ : sample
842
+ const digest = target === state.usageByStep && options.materialize !== false && previous === undefined
843
+ ? sessionAggregateFor(sample.sid, false)
844
+ : undefined
845
+ if (digest !== undefined && !digest.keys.has(sample.key) && sample.materialization === 'live') {
846
+ const turn = Number.isSafeInteger(sample.turn) && sample.turn >= 0 ? sample.turn : null
847
+ const completedTurnKey = turn === null ? '' : String(sample.sid) + ':turn:' + String(turn)
848
+ if (completedTurnKey !== '' && state.turnRecords instanceof Map && state.turnRecords.has(completedTurnKey)) {
849
+ bumpCount('row.turnCovered')
850
+ return { accepted: false, reason: 'completed-turn-replay', key: sample.key, previous: undefined, next: undefined }
851
+ }
852
+ // The live stream may replay older history under a renumbered key. Keep this
853
+ // heuristic out of scans: persisted ledger events, including late timestamps,
854
+ // are authoritative and must always be folded.
855
+ if (typeof digest.newestTime === 'number' && digest.newestTime > 0 && Number.isFinite(sample.time) && sample.time < digest.newestTime) {
856
+ bumpCount('row.olderThanDigest')
857
+ return { accepted: false, reason: 'older-than-ledger-digest', key: sample.key, previous: undefined, next: undefined }
858
+ }
859
+ }
860
+ const result = upsertUsageSampleState(target, candidate)
861
+ if (!result.accepted || target !== state.usageByStep || options.materialize === false) return result
862
+ const previousItem = result.previous
863
+ if (previousItem === undefined) {
864
+ // A row the digest already knows was counted before - either by an earlier fold
865
+ // whose detail rows were handed back to the ledger, or by the same request being
866
+ // replayed when the host restarts. Store the row (the log needs it) and leave the
867
+ // cubes alone; without this every restart counted the current day all over again.
868
+ if (digest !== undefined && digest.keys.has(sample.key)) {
869
+ bumpCount('row.replayed')
870
+ return result
871
+ }
872
+ }
873
+ if (previousItem !== undefined) {
874
+ unindexUsage(previousItem)
875
+ adjustUsage(previousItem.wsId, previousItem.time, previousItem.values, previousItem.identity || previousItem.modelId, -1, previousItem.sid, { local: previousItem.date, utc: previousItem.dateUtc }, previousItem.cost, previousItem.turn)
876
+ adjustQueryUsage(state.byDay.get(previousItem.date), previousItem, -1, false)
877
+ adjustQueryUsage(state.byDayUtc.get(previousItem.dateUtc), previousItem, -1, true)
878
+ }
879
+ const next = result.next
880
+ indexUsage(next)
881
+ // Watch the row's own numbers while the two cubes take them: a shared values object
882
+ // that changes under one of the calls is the one way the same call can hand one sum
883
+ // to the day cube and another to the query root.
884
+ const values = next.values === null || next.values === undefined ? null : next.values
885
+ if (values !== null) {
886
+ if (!(state.diagValuesSeen instanceof WeakSet)) state.diagValuesSeen = new WeakSet()
887
+ if (state.diagValuesSeen.has(values)) bumpCount('values.shared')
888
+ else state.diagValuesSeen.add(values)
889
+ }
890
+ const beforeUsage = values === null ? null : values.input
891
+ adjustUsage(next.wsId, next.time, next.values, next.identity, 1, next.sid, { local: next.date, utc: next.dateUtc }, next.cost, next.turn)
892
+ const afterUsage = values === null ? null : values.input
893
+ if (beforeUsage !== afterUsage) bumpCount('values.mutated.by.usage')
894
+ state.diagInWrapper = true
895
+ // Measure the local bucket's own delta for this one row and compare it with the
896
+ // number handed in: if the query path adds more than it was given, the difference
897
+ // is the bug, named with a real amount instead of a ratio.
898
+ const localDay = state.byDay.get(next.date)
899
+ const bucketBefore = localDay === undefined || !(localDay.queryUsage instanceof Map) ? null : (() => {
900
+ const models = localDay.queryUsage.get(next.wsId)
901
+ const bucket = models === undefined ? undefined : models.get(coerceIdentity(next.identity).identityKey)
902
+ return bucket === undefined ? 0 : bucket.input
903
+ })()
904
+ adjustQueryUsage(localDay, next, 1, false)
905
+ adjustQueryUsage(state.byDayUtc.get(next.dateUtc), next, 1, true)
906
+ state.diagInWrapper = false
907
+ if (bucketBefore !== null) {
908
+ const models = localDay.queryUsage.get(next.wsId)
909
+ const bucket = models === undefined ? undefined : models.get(coerceIdentity(next.identity).identityKey)
910
+ const bucketAfter = bucket === undefined ? 0 : bucket.input
911
+ const expected = next.values === undefined || next.values === null ? 0 : next.values.input
912
+ if (bucketAfter - bucketBefore !== expected) {
913
+ bumpCount('q.bucket.delta.mismatch')
914
+ if (state.diagMismatch === undefined || state.diagMismatch === null) {
915
+ state.diagMismatch = { key: next.key, delta: bucketAfter - bucketBefore, expected, date: next.date, wsId: next.wsId }
916
+ }
917
+ }
918
+ }
919
+ if (values !== null && afterUsage !== values.input) bumpCount('values.mutated.by.query')
920
+ markStatsChanged('data')
921
+ return result
922
+ }
923
+ function adjustQueryCost(item, cost, direction) {
924
+ if (item === null || item === undefined || typeof item !== 'object') return
925
+ const identity = coerceIdentity(item.identity || item.modelId)
926
+ const dates = typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
927
+ const update = (day, utc) => {
928
+ if (day === undefined) return
929
+ const models = day.queryUsage.get(item.wsId)
930
+ const bucket = models && models.get(identity.identityKey)
931
+ if (bucket !== undefined) addCostAccumulator(bucket.cost, cost, direction)
932
+ const hour = hourStartOf(item.time, utc)
933
+ const hourValue = day.queryHours.get(hour)
934
+ const hourModels = hourValue && hourValue.usage.get(item.wsId)
935
+ const hourBucket = hourModels && hourModels.get(identity.identityKey)
936
+ if (hourBucket !== undefined) addCostAccumulator(hourBucket.cost, cost, direction)
937
+ }
938
+ update(state.byDay.get(dates.local), false)
939
+ update(state.byDayUtc.get(dates.utc), true)
940
+ }
941
+ function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live', pricingAt, pricingTimeSource) {
942
+ bumpCount('live.addUsage')
943
+ if (!validEventTime(time)) return
944
+ const values = usageValues(usage)
945
+ if (values === null || noValues(values)) return
946
+ const identity = coerceIdentity(model)
947
+ const eventSeq = normalizeEventSeq(seq)
948
+ const dates = dateKeys(time)
949
+ const key = usageStepKey(sid, data, seq)
950
+ // One row per request comes from the ledger with a stable ":step:" key; a live
951
+ // event that cannot name its turn and step lands under a different key, and the
952
+ // ledger will never carry that one. Counting the two shapes says whether the
953
+ // current day is inflated by such rows.
954
+ if (materialization === 'live') {
955
+ const shape = key.includes(':step:') ? 'step' : key.includes(':usage:') ? 'usage' : key.includes(':event:') ? 'event' : key.includes(':time:') ? 'time' : 'other'
956
+ bumpCount('live.key.' + shape)
957
+ }
958
+ upsertUsageSample(state.usageByStep, {
959
+ key,
960
+ seq: eventSeq,
961
+ wsId,
962
+ time,
963
+ date: dates.local,
964
+ dateUtc: dates.utc,
965
+ values,
966
+ identity,
967
+ modelId: identity.label,
968
+ turn: data && Number.isSafeInteger(data.turn) && data.turn >= 0 ? data.turn : null,
969
+ step: data && Number.isSafeInteger(data.step) && data.step >= 0 ? data.step : null,
970
+ materialization,
971
+ sid,
972
+ ...(pricingAt === undefined ? {} : { pricingAt }),
973
+ ...(pricingTimeSource === undefined ? {} : { pricingTimeSource }),
974
+ })
975
+ }
976
+ function addDayTurn(dayMap, date, wsId, sid, direction = 1) {
977
+ const day = ensureDay(dayMap, date)
978
+ adjustDaySession(day, sid, direction)
979
+ day.turns = Math.max(0, day.turns + direction)
980
+ const next = (day.perWs.get(wsId) || 0) + direction
981
+ if (next > 0) day.perWs.set(wsId, next)
982
+ else day.perWs.delete(wsId)
983
+ }
984
+ function removeTurnRecord(turn) {
985
+ const ws = state.perWorkspace.get(turn.wsId)
986
+ if (ws !== undefined) ws.turns = Math.max(0, ws.turns - 1)
987
+ state.totals.turns = Math.max(0, state.totals.turns - 1)
988
+ addDayTurn(state.byDay, turn.date, turn.wsId, turn.sid, -1)
989
+ addDayTurn(state.byDayUtc, turn.dateUtc, turn.wsId, turn.sid, -1)
990
+ adjustQueryTurn(state.byDay.get(turn.date), turn, -1, false)
991
+ adjustQueryTurn(state.byDayUtc.get(turn.dateUtc), turn, -1, true)
992
+ }
993
+ function turnRecordKey(sid, turn, seq, time) {
994
+ if (Number.isSafeInteger(turn)) return sid + ':turn:' + turn
995
+ if (Number.isSafeInteger(seq) && seq >= 0) return sid + ':event:' + seq
996
+ return sid + ':time:' + String(time)
997
+ }
998
+ function addTurn(wsId, time, sid, turn, identity, materialization = 'live', seq) {
999
+ if (!validEventTime(time)) return
1000
+ const key = turnRecordKey(sid, turn, seq, time)
1001
+ if (state.turnRecords.has(key)) return
1002
+ const normalized = coerceIdentity(identity)
1003
+ const dates = dateKeys(time)
1004
+ const record = { key, sid, wsId, time, date: dates.local, dateUtc: dates.utc, turn: typeof turn === 'number' ? turn : null, identity: normalized, materialization }
1005
+ state.turnRecords.set(key, record)
1006
+ ensureWs(wsId).turns += 1
1007
+ state.totals.turns += 1
1008
+ addDayTurn(state.byDay, dates.local, wsId, sid)
1009
+ addDayTurn(state.byDayUtc, dates.utc, wsId, sid)
1010
+ adjustQueryTurn(state.byDay.get(record.date), record, 1, false)
1011
+ adjustQueryTurn(state.byDayUtc.get(record.dateUtc), record, 1, true)
1012
+ markStatsChanged('data')
1013
+ }
1014
+ const UNKNOWN_MODEL_LABEL = '未知模型(历史记录缺少路由)'
1015
+ function textOrNull(value) {
1016
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
1017
+ }
1018
+ function identityLabel(provider, requestedModel, actualModel, legacyLabel) {
1019
+ const model = actualModel || requestedModel
1020
+ if (model !== null) return provider === null ? model : provider + ' / ' + model
1021
+ return legacyLabel || UNKNOWN_MODEL_LABEL
1022
+ }
1023
+ function makeIdentity(provider, requestedModel, actualModel, legacyLabel) {
1024
+ const normalizedProvider = textOrNull(provider)
1025
+ const normalizedRequested = textOrNull(requestedModel)
1026
+ const normalizedActual = textOrNull(actualModel)
1027
+ const normalizedLegacy = textOrNull(legacyLabel)
1028
+ const key = JSON.stringify([normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy])
1029
+ return {
1030
+ identityKey: key,
1031
+ provider: normalizedProvider,
1032
+ requestedModel: normalizedRequested,
1033
+ actualModel: normalizedActual,
1034
+ label: identityLabel(normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy),
1035
+ legacy: normalizedLegacy !== null && normalizedProvider === null && normalizedRequested === null && normalizedActual === null,
1036
+ }
1037
+ }
1038
+ function identityFromLegacy(label) {
1039
+ return makeIdentity(null, null, null, textOrNull(label) || UNKNOWN_MODEL_LABEL)
1040
+ }
1041
+ function isCanonicalIdentity(value) {
1042
+ 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'
1043
+ }
1044
+ function coerceIdentity(value) {
1045
+ if (isCanonicalIdentity(value)) return value
1046
+ if (value !== null && typeof value === 'object') {
1047
+ return makeIdentity(value.provider, value.requestedModel, value.actualModel, value.legacyLabel || (value.legacy === true ? value.label : null))
1048
+ }
1049
+ if (typeof value === 'string' && value !== '') return identityFromLegacy(value)
1050
+ return makeIdentity(null, null, null, UNKNOWN_MODEL_LABEL)
1051
+ }
1052
+ function routeObject(data) {
1053
+ if (data && typeof data === 'object' && (data.provider !== undefined || data.model !== undefined)) return data
1054
+ const config = data && data.header && data.header.config
1055
+ return config && typeof config === 'object' ? config : null
1056
+ }
1057
+ function identityFromRoute(data, fallback) {
1058
+ const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
1059
+ const route = routeObject(data)
1060
+ if (route === null) return base
1061
+ return makeIdentity(route.provider === undefined ? base.provider : route.provider, route.model === undefined ? base.requestedModel : route.model, null, base.legacy ? base.label : null)
1062
+ }
1063
+ function identityFromMessage(data, fallback) {
1064
+ const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
1065
+ const source = data && data.message && data.message.source
1066
+ if (source === null || typeof source !== 'object') return base
1067
+ return makeIdentity(source.provider === undefined ? base.provider : source.provider, base.requestedModel || source.model, source.model, base.legacy ? base.label : null)
1068
+ }
1069
+ function modelFromRoute(data) {
1070
+ const identity = identityFromRoute(data)
1071
+ return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
1072
+ }
1073
+ function modelFromMessage(data, fallback) {
1074
+ return identityFromMessage(data, fallback).label
1075
+ }
1076
+
1077
+ // ---------- snapshot for the client ----------
1078
+ function scanSnapshot() {
1079
+ return { started: state.scan.started, done: state.scan.done, scanned: state.scan.scanned, total: state.scan.total, failed: state.scan.failed }
1080
+ }
1081
+ function queryRevision() {
1082
+ return String(state.dataRevision) + ':' + String(state.pricingRevision)
1083
+ }
1084
+ function revisionSnapshot() {
1085
+ return { revision: state.statsRevision, dataRevision: state.dataRevision, metadataRevision: state.metadataRevision, scanRevision: state.scanRevision, pricingRevision: state.pricingRevision, queryRevision: queryRevision() }
1086
+ }
1087
+ function statusSnapshot() {
1088
+ commitPendingStats()
1089
+ return {
1090
+ instanceId: state.instanceId,
1091
+ // The write capability is minted per plugin apply, so a reload rotates it
1092
+ // without moving the process instance id or any revision. The status poll
1093
+ // carries only this derived id (never the capability itself), which is enough
1094
+ // for the page to notice the rotation and re-fetch the full snapshot.
1095
+ capabilityId: state.capabilityId,
1096
+ pluginVersion: state.pluginVersion,
1097
+ ...revisionSnapshot(),
1098
+ updatedAt: state.statsUpdatedAt,
1099
+ scan: scanSnapshot(),
1100
+ sync: syncSnapshot(),
1101
+ clientEnv: state.clientEnv,
1102
+ // What the plugin itself is holding, so the residency policy can be checked
1103
+ // from the outside instead of guessed from the host process.
1104
+ counters: Object.fromEntries(state.diagCounters instanceof Map ? state.diagCounters : []),
1105
+ mismatch: state.diagMismatch === undefined ? null : state.diagMismatch,
1106
+ accounting: accountingSnapshot(),
1107
+ memory: {
1108
+ heapUsedMB: Number((process.memoryUsage().heapUsed / 1048576).toFixed(1)),
1109
+ rssMB: Number((process.memoryUsage().rss / 1048576).toFixed(1)),
1110
+ residentRows: state.usageByStep instanceof Map ? state.usageByStep.size : 0,
1111
+ residentRecords: state.ledgerRecords instanceof Map ? state.ledgerRecords.size : 0,
1112
+ recordCacheMB: Number(((state.ledgerRecordBytes || 0) / 1048576).toFixed(1)),
1113
+ recordsCache: recordsCacheStats(state.recordsQueryCache),
1114
+ sessions: state.perSession instanceof Map ? state.perSession.size : 0,
1115
+ },
1116
+ }
1117
+ }
1118
+ function serializeIdentity(identity) {
1119
+ const value = coerceIdentity(identity)
1120
+ return { identityKey: value.identityKey, provider: value.provider, requestedModel: value.requestedModel, actualModel: value.actualModel, model: value.label, legacy: value.legacy }
1121
+ }
1122
+ function serializeModelAggregate(item) {
1123
+ 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) }
1124
+ }
1125
+ // Per-day session ids are the only part of a snapshot row that grows with the
1126
+ // number of sessions rather than the number of days, and the panel only needs
1127
+ // them for recent history (the request log for an old day is fetched on
1128
+ // demand). Older rows keep their count and scope session ids, so the payload
1129
+ // stops carrying every session id ever recorded.
1130
+ const SNAPSHOT_SESSION_WINDOW_DAYS = 180
1131
+ function serializeDays(dayMap, sessionWindowStart) {
1132
+ const result = []
1133
+ for (const pair of dayMap) {
1134
+ const date = pair[0]
1135
+ const day = pair[1]
1136
+ result.push({
1137
+ date,
1138
+ turns: day.turns,
1139
+ sessions: day.sessionIds.size,
1140
+ // Time-based so the local and UTC copies window identically.
1141
+ sessionIds: sessionWindowStart !== undefined && calendarStartOf(date, false) < sessionWindowStart ? [] : Array.from(day.sessionIds).sort(),
1142
+ tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
1143
+ cost: serializeCostAggregate(day.cost),
1144
+ perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
1145
+ 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) })),
1146
+ byModel: Array.from(day.byModel, (p) => serializeModelAggregate(p[1])),
1147
+ })
1148
+ }
1149
+ result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
1150
+ return result
1151
+ }
1152
+ function snapshot(utc = null) {
1153
+ commitPendingStats()
1154
+ const generatedAt = Date.now()
1155
+ const cacheKey = utc === null ? 'both' : (utc ? 'utc' : 'local')
1156
+ if (state.snapshotCache !== null && state.snapshotCache.revision === state.statsRevision && state.snapshotCache.key === cacheKey) return Object.assign({}, state.snapshotCache.value, { generatedAt })
1157
+ const windowStart = Date.now() - SNAPSHOT_SESSION_WINDOW_DAYS * 86400000
1158
+ const value = {
1159
+ ...statusSnapshot(),
1160
+ generatedAt,
1161
+ usageSchemaVersion: 3,
1162
+ costSchemaVersion: COST_SCHEMA_VERSION,
1163
+ requestToken: state.requestToken,
1164
+ workspaces: [
1165
+ ...Array.from(state.wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
1166
+ ...(state.retiredBucketUsed ? [{ id: RETIRED_WORKSPACE_ID, title: '', path: '', deleted: true, retiredBucket: true }] : []),
1167
+ ],
1168
+ aliases: Object.assign({}, state.aliases),
1169
+ pricing: pricingSnapshot({ detailed: false }),
1170
+ tokenSemantics: {
1171
+ processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
1172
+ cacheRead: 'reused context tokens; not newly generated output',
1173
+ cacheWrite: 'tokens written into a provider cache',
1174
+ // v1.0.7: structured, machine-readable accounting semantics (cc-switch
1175
+ // input_token_semantics parity). DSH reports input as fresh (cache read /
1176
+ // cache write sit in their own buckets) — verified against real ledger data;
1177
+ // reasoning is bucketed separately from output and assumed non-overlapping.
1178
+ semantics: {
1179
+ input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
1180
+ buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
1181
+ inputIncludesCache: false,
1182
+ cacheBucketed: true,
1183
+ reasoningSeparate: true,
1184
+ gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
1185
+ },
1186
+ },
1187
+ costSemantics: {
1188
+ source: 'models.dev',
1189
+ currency: 'USD',
1190
+ buckets: ['input', 'output', 'cacheRead', 'cacheWrite'],
1191
+ input: 'fresh (DSH TokenUsage already excludes cache)',
1192
+ reasoning: 'not added to output again; provider output already carries completion/thoughts where reported',
1193
+ multiplier: 'applies only to final total',
1194
+ providerMatching: 'DSH provider is ignored; only the official model vendor entry is selected',
1195
+ historical: 'positive non-tiered cost snapshots are stable; legacy tiered snapshots migrate to unsupported; only unresolved usage is eligible for backfill',
1196
+ },
1197
+ 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) },
1198
+ 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) })),
1199
+ perModel: Array.from(state.perModel.values(), (item) => serializeModelAggregate(item)),
1200
+ byDay: utc === true ? [] : serializeDays(state.byDay, windowStart),
1201
+ byDayUtc: utc === false ? [] : serializeDays(state.byDayUtc, windowStart),
1202
+ }
1203
+ state.snapshotCache = { revision: state.statsRevision, key: cacheKey, value }
1204
+ return Object.assign({}, value, { generatedAt })
1205
+ }
1206
+
1207
+ function validDateText(value) {
1208
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
1209
+ const year = Number(value.slice(0, 4))
1210
+ const month = Number(value.slice(5, 7))
1211
+ const day = Number(value.slice(8, 10))
1212
+ const date = new Date(Date.UTC(year, month - 1, day))
1213
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
1214
+ }
1215
+ function shiftDateText(value, days, utc) {
1216
+ const parts = value.split('-').map(Number)
1217
+ 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)
1218
+ return utc ? dayKeyUtc(date.getTime()) : dayKey(date.getTime())
1219
+ }
1220
+ function queryScopeFromRequest(req) {
1221
+ let url
1222
+ try { url = new URL(req.url || '/', 'http://all-usage.local') } catch (err) { return { ok: false, message: 'bad-query' } }
1223
+ const rawUtc = url.searchParams.get('utc')
1224
+ if (rawUtc !== null && rawUtc !== '' && rawUtc !== '0' && rawUtc !== '1') return { ok: false, message: 'invalid-timezone' }
1225
+ const utc = rawUtc === '1'
1226
+ const today = utc ? dayKeyUtc(Date.now()) : dayKey(Date.now())
1227
+ const start = url.searchParams.get('start') || today
1228
+ const end = url.searchParams.get('end') || today
1229
+ if (!validDateText(start) || !validDateText(end) || start > end) return { ok: false, message: 'invalid-date-range' }
1230
+ const readParam = (name, max) => {
1231
+ const value = url.searchParams.get(name)
1232
+ if (value === null || value === '') return undefined
1233
+ return value.length <= max ? value : null
1234
+ }
1235
+ const workspaceId = readParam('workspaceId', 256)
1236
+ const provider = readParam('provider', 256)
1237
+ const modelKey = readParam('modelKey', 1024)
1238
+ if (workspaceId === null || provider === null || modelKey === null) return { ok: false, message: 'query-too-long' }
1239
+ return { ok: true, scope: { start, end, utc, workspaceId, provider, modelKey } }
1240
+ }
1241
+ function scopeFingerprint(scope) {
1242
+ 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 })
1243
+ }
1244
+ function dateInScope(date, scope) {
1245
+ return date >= scope.start && date <= scope.end
1246
+ }
1247
+ function modelNameOfIdentity(identity) {
1248
+ const normalized = coerceIdentity(identity)
1249
+ const structured = normalized.actualModel || normalized.requestedModel
1250
+ if (structured !== null) return structured
1251
+ if (normalized.legacy && typeof normalized.label === 'string') {
1252
+ const separator = normalized.label.indexOf(' / ')
1253
+ if (separator > 0) return normalized.label.slice(separator + 3)
1254
+ }
1255
+ return normalized.label
1256
+ }
1257
+ /**
1258
+ * The same model is often served through several routes, whose ids differ only by
1259
+ * a vendor namespace or letter case (`deepseek-v4.1-flash` versus
1260
+ * `deepseek/deepseek-v4.1-flash`). A model filter is about the model, so it
1261
+ * compares this key: the last path segment, lowercased. The exact identity key
1262
+ * still matches, which keeps a stored route-level value working.
1263
+ */
1264
+ function modelNameKeyOf(value) {
1265
+ if (typeof value !== 'string') return ''
1266
+ const text = value.trim()
1267
+ if (text === '') return ''
1268
+ const slash = text.lastIndexOf('/')
1269
+ return (slash >= 0 ? text.slice(slash + 1) : text).trim().toLowerCase()
1270
+ }
1271
+ function identityMatchesScope(identity, scope) {
1272
+ const normalized = coerceIdentity(identity)
1273
+ if (scope.provider !== undefined && scope.provider !== null && normalized.provider !== scope.provider) return false
1274
+ if (scope.modelKey !== undefined && scope.modelKey !== null) {
1275
+ const wanted = String(scope.modelKey)
1276
+ const byIdentity = normalized.identityKey === wanted
1277
+ const byName = modelNameOfIdentity(normalized) === wanted
1278
+ const byModelKey = modelNameKeyOf(modelNameOfIdentity(normalized)) === modelNameKeyOf(wanted) && modelNameKeyOf(wanted) !== ''
1279
+ if (!byIdentity && !byName && !byModelKey) return false
1280
+ }
1281
+ return true
1282
+ }
1283
+ function queryMetric() {
1284
+ return { turns: 0, calls: 0, sessions: new Set(), turnKeys: new Set(), input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() }
1285
+ }
1286
+ function queryAggregate() {
1287
+ return { totals: queryMetric(), days: new Map(), workspaces: new Map(), models: new Map() }
1288
+ }
1289
+ function queryDay(aggregate, date) {
1290
+ let day = aggregate.days.get(date)
1291
+ if (day === undefined) { day = queryMetric(); day.date = date; aggregate.days.set(date, day) }
1292
+ return day
1293
+ }
1294
+ function queryWorkspace(aggregate, workspaceId) {
1295
+ let row = aggregate.workspaces.get(workspaceId)
1296
+ if (row === undefined) { row = queryMetric(); row.workspaceId = workspaceId; aggregate.workspaces.set(workspaceId, row) }
1297
+ return row
1298
+ }
1299
+ function queryModel(aggregate, identity) {
1300
+ const normalized = coerceIdentity(identity)
1301
+ let row = aggregate.models.get(normalized.identityKey)
1302
+ if (row === undefined) { row = queryMetric(); Object.assign(row, serializeIdentity(normalized)); aggregate.models.set(normalized.identityKey, row) }
1303
+ return row
1304
+ }
1305
+ function addQueryTokens(metric, values) {
1306
+ metric.input += values.input
1307
+ metric.output += values.output
1308
+ metric.cacheRead += values.cacheRead
1309
+ metric.cacheWrite += values.cacheWrite
1310
+ metric.reasoning += values.reasoning
1311
+ }
1312
+ function mergeQuerySessions(target, sessions) {
1313
+ for (const sid of sessions) target.sessions.add(sid)
1314
+ }
1315
+ function mergeQueryUsageMetric(target, bucket) {
1316
+ // The source's numbers are copied first, and its cost is only merged when it is
1317
+ // not the same accumulator: a bucket must never be able to aggregate into
1318
+ // itself. That aliasing is what let the current day's query numbers run away -
1319
+ // every fresh read doubled what the previous one had left behind.
1320
+ const calls = bucket.calls
1321
+ const values = { input: bucket.input, output: bucket.output, cacheRead: bucket.cacheRead, cacheWrite: bucket.cacheWrite, reasoning: bucket.reasoning }
1322
+ target.calls += calls
1323
+ mergeQuerySessions(target, bucket.sessions)
1324
+ addQueryTokens(target, values)
1325
+ if (target.cost !== bucket.cost) addCostAccumulator(target.cost, bucket.cost)
1326
+ }
1327
+ function addQueryUsageBucket(aggregate, bucket, date, workspaceId) {
1328
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, workspaceId)]
1329
+ targets.push(queryModel(aggregate, bucket.identity))
1330
+ for (const target of targets) mergeQueryUsageMetric(target, bucket)
1331
+ }
1332
+ function addQueryTurnMetric(target, turn) {
1333
+ if (target.turnKeys.has(turn.key)) return
1334
+ target.turnKeys.add(turn.key)
1335
+ target.turns += 1
1336
+ target.sessions.add(turn.sid)
1337
+ }
1338
+ function mergeQueryTurnBucket(target, bucket) {
1339
+ target.turns += bucket.turns
1340
+ mergeQuerySessions(target, bucket.sessions)
1341
+ }
1342
+ function addQueryTurn(aggregate, turn, date) {
1343
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, turn.wsId)]
1344
+ targets.push(queryModel(aggregate, turn.identity))
1345
+ for (const target of targets) addQueryTurnMetric(target, turn)
1346
+ }
1347
+ function addQueryTurnBucket(aggregate, bucket, date, workspaceId) {
1348
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, workspaceId)]
1349
+ targets.push(queryModel(aggregate, bucket.identity))
1350
+ for (const target of targets) mergeQueryTurnBucket(target, bucket)
1351
+ }
1352
+ function finalizeQueryMetric(metric) {
1353
+ 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) }
1354
+ }
1355
+ function finalizeQueryAggregate(aggregate) {
1356
+ 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 } }))
1357
+ const perWorkspace = Array.from(aggregate.workspaces.values()).map((row) => ({ workspaceId: row.workspaceId, ...finalizeQueryMetric(row) }))
1358
+ 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) }))
1359
+ return { totals: finalizeQueryMetric(aggregate.totals), daily, perWorkspace, perModel }
1360
+ }
1361
+ function heatmapDay(days, date) {
1362
+ let day = days.get(date)
1363
+ 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) }
1364
+ return day
1365
+ }
1366
+ function addHeatmapUsage(days, bucket, date) {
1367
+ const day = heatmapDay(days, date)
1368
+ addQueryTokens(day.tokens, bucket)
1369
+ }
1370
+ function addHeatmapTurnBucket(days, bucket, date, workspaceId) {
1371
+ const day = heatmapDay(days, date)
1372
+ day.turns += bucket.turns
1373
+ day.perWorkspace.set(workspaceId, (day.perWorkspace.get(workspaceId) || 0) + bucket.turns)
1374
+ }
1375
+ function addHeatmapTurns(days, records, date) {
1376
+ const day = heatmapDay(days, date)
1377
+ for (const turn of records) {
1378
+ day.turns += 1
1379
+ day.perWorkspace.set(turn.wsId, (day.perWorkspace.get(turn.wsId) || 0) + 1)
1380
+ }
1381
+ }
1382
+ function serializeHeatmap(days) {
1383
+ 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] })) }))
1384
+ }
1385
+ const HOUR_MS = 60 * 60 * 1000
1386
+ function hourStartOf(time, utc) {
1387
+ const date = new Date(time)
1388
+ if (utc) return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours())
1389
+ // Reconstructing a local hour with new Date(y, m, d, h) collapses a DST
1390
+ // fall-back twice-repeated hour (both 01:xx map to the first occurrence).
1391
+ // Instead align the instant to the local hour boundary using the offset
1392
+ // that applied at that instant, keeping repeated local hours distinct.
1393
+ const offset = date.getTimezoneOffset()
1394
+ const shifted = time - offset * 60000
1395
+ return Math.floor(shifted / HOUR_MS) * HOUR_MS + offset * 60000
1396
+ }
1397
+ function calendarStartOf(dateText, utc) {
1398
+ const parts = dateText.split('-').map(Number)
1399
+ return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2]) : new Date(parts[0], parts[1] - 1, parts[2]).getTime()
1400
+ }
1401
+ function nextCalendarStartOf(dateText, utc) {
1402
+ const parts = dateText.split('-').map(Number)
1403
+ return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2] + 1) : new Date(parts[0], parts[1] - 1, parts[2] + 1).getTime()
1404
+ }
1405
+ function hourlyRangeOf(scope, nowMs) {
1406
+ if (scope.start !== scope.end) return null
1407
+ const start = calendarStartOf(scope.start, scope.utc)
1408
+ const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
1409
+ const end = scope.start === today ? nowMs : nextCalendarStartOf(scope.start, scope.utc)
1410
+ return { start, count: Math.max(1, Math.ceil(Math.max(0, end - start) / HOUR_MS)) }
1411
+ }
1412
+ function serializeTrendMetric(time, metric) {
1413
+ const value = finalizeQueryMetric(metric || queryMetric())
1414
+ return { time, date: new Date(time).toISOString(), ...value, tokens: { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite, reasoning: value.reasoning } }
1415
+ }
1416
+ function queryHourlyTrend(day, scope, nowMs, matchingTurnKeys) {
1417
+ const range = hourlyRangeOf(scope, nowMs)
1418
+ if (range === null) return []
1419
+ const buckets = new Map()
1420
+ const indexHours = new Map()
1421
+ const metricFor = (hour) => {
1422
+ // Zone offsets are not always whole hours (Lord Howe +10:30/+11), so a
1423
+ // floor can drop or merge fractional-hour buckets; round and clamp keeps
1424
+ // every event inside the requested range without losing rows.
1425
+ const index = Math.min(range.count - 1, Math.max(0, Math.round((hour - range.start) / HOUR_MS)))
1426
+ if (index < 0 || index >= range.count) return null
1427
+ const previousHour = indexHours.get(index)
1428
+ if (previousHour === undefined || hour < previousHour) indexHours.set(index, hour)
1429
+ let metric = buckets.get(index)
1430
+ if (metric === undefined) { metric = queryMetric(); buckets.set(index, metric) }
1431
+ return metric
1432
+ }
1433
+ const hasIdentityFilter = (scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)
1434
+ const hours = day && day.queryHours instanceof Map ? day.queryHours : new Map()
1435
+ for (const [hour, value] of hours) {
1436
+ for (const [workspaceId, models] of value.usage) {
1437
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
1438
+ for (const bucket of models.values()) {
1439
+ if (!identityMatchesScope(bucket.identity, scope)) continue
1440
+ const metric = metricFor(hour)
1441
+ if (metric === null) continue
1442
+ mergeQueryUsageMetric(metric, bucket)
1443
+ }
1444
+ }
1445
+ for (const [workspaceId, models] of value.turns) {
1446
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
1447
+ for (const bucket of models.values()) {
1448
+ if (!hasIdentityFilter || identityMatchesScope(bucket.identity, scope)) {
1449
+ const metric = metricFor(hour)
1450
+ if (metric !== null) mergeQueryTurnBucket(metric, bucket)
1451
+ } else if (matchingTurnKeys.size > 0) {
1452
+ const metric = metricFor(hour)
1453
+ if (metric === null) continue
1454
+ for (const turn of bucket.records.values()) if (matchingTurnKeys.has(turn.key)) addQueryTurnMetric(metric, turn)
1455
+ }
1456
+ }
1457
+ }
1458
+ }
1459
+ // Emit the real bucket boundary when the bucket was rounded into this slot;
1460
+ // the uniform grid only labels empty slots, so fractional-hour zones keep
1461
+ // exact labels instead of drifting 30 minutes off.
1462
+ return Array.from({ length: range.count }, (_, index) => serializeTrendMetric(indexHours.has(index) ? indexHours.get(index) : range.start + index * HOUR_MS, buckets.get(index)))
1463
+ }
1464
+ /**
1465
+ * Rebuild a drifted scoped cube from the session digests. These retain local/UTC
1466
+ * hours and row/turn counts even after detail eviction. The normal update path
1467
+ * preserves both cubes; this remains a recovery guard for already drifted state.
1468
+ */
1469
+ function reconcileScopedUsage(date, utc) {
1470
+ const dayMap = utc === true ? state.byDayUtc : state.byDay
1471
+ const day = dayMap.get(date)
1472
+ if (day === undefined || !(day.queryUsage instanceof Map)) return 0
1473
+ let scoped = 0
1474
+ for (const models of day.queryUsage.values()) for (const bucket of models.values()) scoped += bucket.input
1475
+ const expected = day.tokens.input
1476
+ if (expected === 0 || Math.abs(scoped - expected) <= Math.max(expected * 0.01, 1000)) return 0
1477
+ const rebuilt = new Map()
1478
+ const rebuiltHours = new Map()
1479
+ let restored = 0
1480
+ for (const [sid, session] of state.perSession) {
1481
+ for (const bucket of session.buckets.values()) {
1482
+ const bucketDate = utc === true ? bucket.dateUtc : bucket.date
1483
+ if (bucketDate !== date) continue
1484
+ const values = bucket.values
1485
+ if (values === null || values === undefined || noValues(values)) continue
1486
+ const scopedDay = { queryUsage: rebuilt, queryHours: rebuiltHours, queryTurns: new Map() }
1487
+ adjustQueryUsage(scopedDay, {
1488
+ sid,
1489
+ wsId: bucket.wsId,
1490
+ time: bucket.time,
1491
+ values,
1492
+ cost: bucket.cost,
1493
+ identity: bucket.identity,
1494
+ turn: null,
1495
+ }, 1, utc === true, bucket.calls)
1496
+ for (const [turnKey, count] of bucket.turns) {
1497
+ const turn = turnNumberOf(turnKey)
1498
+ if (turn === null) continue
1499
+ const refs = Number.isSafeInteger(count) && count > 0 ? count : 1
1500
+ adjustQueryUsageTurnRefs(scopedDay, { sid, wsId: bucket.wsId, time: bucket.time, identity: bucket.identity, turn }, refs, utc === true)
1501
+ }
1502
+ restored += 1
1503
+ }
1504
+ }
1505
+ // Usage reconciliation must preserve the turn/end layer of each hour.
1506
+ for (const [hour, current] of day.queryHours) {
1507
+ let target = rebuiltHours.get(hour)
1508
+ if (target === undefined) { target = { usage: new Map(), turns: new Map() }; rebuiltHours.set(hour, target) }
1509
+ target.turns = current.turns
1510
+ }
1511
+ day.queryUsage = rebuilt
1512
+ day.queryHours = rebuiltHours
1513
+ bumpCount('q.reconciled.' + date, 1)
1514
+ return restored
1515
+ }
1516
+ function queryUsageScope(scope) {
1517
+ bumpCount('read.queryUsageScope')
1518
+ commitPendingStats()
1519
+ const nowMs = Date.now()
1520
+ const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
1521
+ const hourlyCacheKey = scope.start === scope.end ? ':' + (scope.start === today ? hourStartOf(nowMs, scope.utc) : 'fixed') : ''
1522
+ const currentQueryRevision = queryRevision()
1523
+ const key = currentQueryRevision + ':' + scopeFingerprint(scope) + hourlyCacheKey
1524
+ const cached = state.queryCache.get(key)
1525
+ if (cached !== undefined) {
1526
+ const revisions = revisionSnapshot()
1527
+ return { ...cached, ...revisions, updatedAt: state.statsUpdatedAt, partial: !state.scan.done, completeThrough: { ...revisions, at: state.statsUpdatedAt } }
1528
+ }
1529
+ const now = new Date(nowMs)
1530
+ const weekday = scope.utc ? now.getUTCDay() : now.getDay()
1531
+ const sunday = shiftDateText(today, -weekday, scope.utc)
1532
+ const heatStart = shiftDateText(sunday, -52 * 7, scope.utc)
1533
+ const selected = queryAggregate()
1534
+ const heatmap = new Map()
1535
+ const matchingTurnKeys = new Set()
1536
+ const dayMap = scope.utc ? state.byDayUtc : state.byDay
1537
+ // Repair a drifted day before answering from it (cheap when they agree).
1538
+ for (const [date, day] of dayMap) {
1539
+ if (!dateInScope(date, scope)) continue
1540
+ reconcileScopedUsage(date, scope.utc === true)
1541
+ }
1542
+ const hasIdentityFilter = (scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)
1543
+ for (const [date, day] of dayMap) {
1544
+ const inSelected = dateInScope(date, scope)
1545
+ const inHeat = date >= heatStart && date <= today
1546
+ if (!inSelected && !inHeat) continue
1547
+ // A bucket is counted once per day, whatever the workspace map it hangs under:
1548
+ // the same object can be reachable from two keys after a session is moved.
1549
+ const countedBuckets = new Set()
1550
+ for (const [workspaceId, models] of day.queryUsage) {
1551
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
1552
+ for (const bucket of models.values()) {
1553
+ if (bucket.calls <= 0 && noValues(bucket)) continue
1554
+ if (countedBuckets.has(bucket)) continue
1555
+ countedBuckets.add(bucket)
1556
+ if (!identityMatchesScope(bucket.identity, scope)) continue
1557
+ if (hasIdentityFilter) for (const turnKey of bucket.turnKeys) matchingTurnKeys.add(turnKey)
1558
+ if (inSelected) addQueryUsageBucket(selected, bucket, date, workspaceId)
1559
+ if (inHeat) addHeatmapUsage(heatmap, bucket, date)
1560
+ }
1561
+ }
1562
+ }
1563
+ for (const [date, day] of dayMap) {
1564
+ const inSelected = dateInScope(date, scope)
1565
+ const inHeat = date >= heatStart && date <= today
1566
+ if (!inSelected && !inHeat) continue
1567
+ for (const [workspaceId, models] of day.queryTurns) {
1568
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && workspaceId !== scope.workspaceId) continue
1569
+ for (const bucket of models.values()) {
1570
+ if (!hasIdentityFilter || identityMatchesScope(bucket.identity, scope)) {
1571
+ if (inSelected) addQueryTurnBucket(selected, bucket, date, workspaceId)
1572
+ if (inHeat) addHeatmapTurnBucket(heatmap, bucket, date, workspaceId)
1573
+ continue
1574
+ }
1575
+ if (matchingTurnKeys.size === 0) continue
1576
+ const matching = []
1577
+ for (const turn of bucket.records.values()) if (matchingTurnKeys.has(turn.key)) matching.push(turn)
1578
+ if (matching.length === 0) continue
1579
+ if (inSelected) for (const turn of matching) addQueryTurn(selected, turn, date)
1580
+ if (inHeat) addHeatmapTurns(heatmap, matching, date)
1581
+ }
1582
+ }
1583
+ }
1584
+ const hourly = queryHourlyTrend(dayMap.get(scope.start), scope, nowMs, matchingTurnKeys)
1585
+ const revisions = revisionSnapshot()
1586
+ 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) }
1587
+ state.queryCache.set(key, result)
1588
+ while (state.queryCache.size > 20) state.queryCache.delete(state.queryCache.keys().next().value)
1589
+ return result
1590
+ }
1591
+ function opaqueRecordId(item) {
1592
+ return createHash('sha256').update(item.sid + '\0' + item.key).digest('hex').slice(0, 20)
1593
+ }
1594
+ function recordOrder(a, b) {
1595
+ 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)
1596
+ }
1597
+ /**
1598
+ * Describe a scope's detail rows from the per-session digest alone. The result
1599
+ * carries only bounded metadata; the payloads are read back per page. Returns
1600
+ * undefined when the host cannot page rows or the digest cannot describe the
1601
+ * scope exactly, so the caller falls back to the full record read instead of
1602
+ * silently dropping a row.
1603
+ */
1604
+ function digestRecordsResult(scope) {
1605
+ if (host.ledger === undefined || typeof host.ledger.readUsageRows !== 'function') return undefined
1606
+ const utc = scope.utc === true
1607
+ const index = utc ? state.usageByUtcDate : state.usageByLocalDate
1608
+ if (!(index instanceof Map)) return undefined
1609
+ const sessions = sessionsForRange(utc, scope.start, scope.end)
1610
+ if (!(sessions instanceof Set)) return undefined
1611
+ const inRange = new Map()
1612
+ for (const sid of sessions) {
1613
+ const session = state.perSession.get(sid)
1614
+ if (session === undefined || !(session.keys instanceof Map)) return undefined
1615
+ for (const [key, meta] of session.keys) {
1616
+ if (meta === null || typeof meta !== 'object') return undefined
1617
+ const date = utc ? meta.dateUtc : meta.date
1618
+ if (typeof date !== 'string' || date === '') return undefined
1619
+ if (date < scope.start || date > scope.end) continue
1620
+ inRange.set(key, { sid, key, date, meta })
1621
+ }
1622
+ }
1623
+ // Cross-check the date index so a partially populated digest can never
1624
+ // shorten the page: any disagreement uses the full record read.
1625
+ let indexed = 0
1626
+ for (const [date, keys] of index) {
1627
+ if (date < scope.start || date > scope.end) continue
1628
+ for (const key of keys) {
1629
+ indexed += 1
1630
+ if (!inRange.has(key)) return undefined
1631
+ }
1632
+ }
1633
+ if (indexed !== inRange.size) return undefined
1634
+ const candidates = []
1635
+ for (const entry of inRange.values()) {
1636
+ const { meta } = entry
1637
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && meta.wsId !== scope.workspaceId) continue
1638
+ if (meta.identity === undefined || meta.identity === null) return undefined
1639
+ if (!identityMatchesScope(meta.identity, scope)) continue
1640
+ if (!Number.isFinite(meta.time) || !Number.isFinite(meta.seq)) return undefined
1641
+ candidates.push({ sid: entry.sid, key: entry.key, date: entry.date, time: meta.time, seq: meta.seq })
1642
+ }
1643
+ candidates.sort(recordOrder)
1644
+ // A small scope whose rows are all still resident keeps the real payloads,
1645
+ // exactly as the full read would have; anything evicted pages by key.
1646
+ const rows = new Array(candidates.length)
1647
+ let resident = true
1648
+ for (let position = 0; position < candidates.length; position += 1) {
1649
+ const candidate = candidates[position]
1650
+ const item = state.usageByStep.get(candidate.key)
1651
+ if (item === undefined || item.sid !== candidate.sid) { resident = false; break }
1652
+ rows[position] = { item, date: candidate.date }
1653
+ }
1654
+ if (resident) return { rows, forceIndex: false }
1655
+ return { rows: candidates.map(candidate => ({ item: { sid: candidate.sid, key: candidate.key }, date: candidate.date })), forceIndex: true }
1656
+ }
1657
+ async function queryRecords(scope, cursor, limit) {
1658
+ bumpCount('read.queryRecords')
1659
+ commitPendingStats()
1660
+ const fingerprint = scopeFingerprint(scope)
1661
+ let currentQueryRevision = queryRevision()
1662
+ let offset = 0
1663
+ const hasCursor = cursor !== undefined && cursor !== ''
1664
+ if (hasCursor) {
1665
+ try {
1666
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
1667
+ if (decoded.queryRevision !== currentQueryRevision || decoded.scope !== fingerprint || !Number.isInteger(decoded.offset) || decoded.offset < 0) return { error: 'stale-cursor' }
1668
+ offset = decoded.offset
1669
+ } catch (err) { return { error: 'bad-cursor' } }
1670
+ }
1671
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1672
+ const cacheKey = currentQueryRevision + ':' + fingerprint
1673
+ let entry = getRecordsResult(state.recordsQueryCache, cacheKey)
1674
+ if (entry === undefined) {
1675
+ const digest = digestRecordsResult(scope)
1676
+ if (digest !== undefined) {
1677
+ // The digest path never loads the whole range: its compact index pages
1678
+ // through only the sessions each page actually needs.
1679
+ entry = cacheRecordsResult(state.recordsQueryCache, cacheKey, digest.rows, digest.forceIndex)
1680
+ evictColdRows()
1681
+ } else try {
1682
+ if (host.ledger !== undefined && typeof host.ledger.loadRowsForRange === 'function') {
1683
+ await host.ledger.loadRowsForRange(scope.utc === true, scope.start, scope.end)
1684
+ }
1685
+ commitPendingStats()
1686
+ const observed = queryRevision()
1687
+ if (observed !== currentQueryRevision) {
1688
+ if (hasCursor) return { error: 'stale-cursor' }
1689
+ currentQueryRevision = observed
1690
+ continue
1691
+ }
1692
+ entry = getRecordsResult(state.recordsQueryCache, cacheKey)
1693
+ if (entry === undefined) {
1694
+ const index = scope.utc ? state.usageByUtcDate : state.usageByLocalDate
1695
+ const rows = indexedEntriesInRange(index, state.usageByStep, scope.start, scope.end).filter(({ item }) => {
1696
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) return false
1697
+ return identityMatchesScope(item.identity || item.modelId, scope)
1698
+ })
1699
+ rows.sort((left, right) => recordOrder(left.item, right.item))
1700
+ entry = cacheRecordsResult(state.recordsQueryCache, cacheKey, rows)
1701
+ }
1702
+ } finally { evictColdRows() }
1703
+ }
1704
+ let page
1705
+ if (entry.kind === 'rows') page = entry.rows.slice(offset, offset + limit)
1706
+ else {
1707
+ const references = recordPageReferences(entry, offset, limit)
1708
+ if (host.ledger !== undefined && typeof host.ledger.readUsageRows === 'function') {
1709
+ try { page = await host.ledger.readUsageRows(references, scope.utc === true) }
1710
+ catch (error) {
1711
+ commitPendingStats()
1712
+ const observed = queryRevision()
1713
+ if (observed === currentQueryRevision) throw error
1714
+ if (hasCursor) return { error: 'stale-cursor' }
1715
+ currentQueryRevision = observed
1716
+ continue
1717
+ }
1718
+ } else {
1719
+ page = references.map(({ sid, key, keyHash }) => {
1720
+ const item = key === undefined ? Array.from(state.usageByStep.values()).find(row => row.sid === sid && recordKeyHash(row.key) === keyHash) : state.usageByStep.get(key)
1721
+ return item !== undefined && item.sid === sid ? { item, date: scope.utc ? item.dateUtc : item.date } : undefined
1722
+ })
1723
+ }
1724
+ // Page reads can race with a reprice/resync even on a cache hit. Never
1725
+ // attach the fresh revision to rows selected from the previous index.
1726
+ commitPendingStats()
1727
+ const observed = queryRevision()
1728
+ if (observed !== currentQueryRevision) {
1729
+ // The index was built under the previous revision; releasing it keeps an
1730
+ // unstable construction from leaving a cached scope behind.
1731
+ if (state.recordsQueryCache.get(cacheKey) === entry) deleteRecordsResult(state.recordsQueryCache, cacheKey)
1732
+ if (hasCursor) return { error: 'stale-cursor' }
1733
+ currentQueryRevision = observed
1734
+ continue
1735
+ }
1736
+ if (page.some(row => row === undefined)) {
1737
+ if (state.recordsQueryCache.get(cacheKey) === entry) deleteRecordsResult(state.recordsQueryCache, cacheKey)
1738
+ // Rebuilding from an incomplete record would hide a missing row while
1739
+ // leaving its aggregate and revision unchanged, even on a first page.
1740
+ return { error: 'stale-cursor' }
1741
+ }
1742
+ }
1743
+ const items = page.map(({ item, date }) => {
1744
+ const identity = coerceIdentity(item.identity || item.modelId)
1745
+ 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' }
1746
+ })
1747
+ const length = entry.kind === 'rows' ? entry.rows.length : entry.ids.length
1748
+ const nextOffset = offset + items.length
1749
+ return { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId: state.instanceId, ...revisionSnapshot(), scope: JSON.parse(fingerprint), items, hasMore: nextOffset < length, nextCursor: nextOffset < length ? Buffer.from(JSON.stringify({ queryRevision: currentQueryRevision, scope: fingerprint, offset: nextOffset })).toString('base64url') : null }
1750
+ }
1751
+ return { error: 'stale-cursor' }
1752
+ }
1753
+
1754
+ return {
1755
+ dayKey,
1756
+ dayKeyUtc,
1757
+ dateKeys,
1758
+ num,
1759
+ validEventTime,
1760
+ addDateIndex,
1761
+ removeDateIndex,
1762
+ indexUsage,
1763
+ unindexUsage,
1764
+ indexedEntriesInRange,
1765
+ usageBasisEqual,
1766
+ resolveCurrentPricing,
1767
+ costForUsage,
1768
+ removeSession,
1769
+ retargetSession,
1770
+ resetAggregationState,
1771
+ ensureDay,
1772
+ ensureWs,
1773
+ ensureDayWs,
1774
+ ensureModel,
1775
+ ensureDayModel,
1776
+ adjustValues,
1777
+ noValues,
1778
+ adjustDaySession,
1779
+ adjustDay,
1780
+ addCostAggregateDirection,
1781
+ adjustSessionCostOnly,
1782
+ adjustUsage,
1783
+ adjustQueryCost,
1784
+ upsertUsageSample,
1785
+ addUsage,
1786
+ addDayTurn,
1787
+ turnRecordKey,
1788
+ addTurn,
1789
+ textOrNull,
1790
+ identityLabel,
1791
+ makeIdentity,
1792
+ identityFromLegacy,
1793
+ isCanonicalIdentity,
1794
+ coerceIdentity,
1795
+ routeObject,
1796
+ identityFromRoute,
1797
+ identityFromMessage,
1798
+ modelFromRoute,
1799
+ modelFromMessage,
1800
+ scanSnapshot,
1801
+ queryRevision,
1802
+ revisionSnapshot,
1803
+ statusSnapshot,
1804
+ serializeIdentity,
1805
+ serializeModelAggregate,
1806
+ serializeDays,
1807
+ snapshot,
1808
+ validDateText,
1809
+ shiftDateText,
1810
+ queryScopeFromRequest,
1811
+ scopeFingerprint,
1812
+ dateInScope,
1813
+ modelNameOfIdentity,
1814
+ identityMatchesScope,
1815
+ queryMetric,
1816
+ queryAggregate,
1817
+ queryDay,
1818
+ queryWorkspace,
1819
+ queryModel,
1820
+ addQueryTokens,
1821
+ addQueryTurn,
1822
+ finalizeQueryMetric,
1823
+ finalizeQueryAggregate,
1824
+ hourStartOf,
1825
+ calendarStartOf,
1826
+ nextCalendarStartOf,
1827
+ hourlyRangeOf,
1828
+ serializeTrendMetric,
1829
+ queryHourlyTrend,
1830
+ queryUsageScope,
1831
+ opaqueRecordId,
1832
+ recordOrder,
1833
+ queryRecords,
1834
+ // Used by the ledger: one row, built the same way whether it is folded or
1835
+ // read back from its document for a query.
1836
+ usageRowOf,
1837
+ foldUsageRow,
1838
+ adoptUsageRow,
1839
+ sessionsForRange,
1840
+ evictColdRows
1841
+ }
1842
+ }