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