dsh-all-usage 1.1.18 → 1.2.1

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