dsh-all-usage 1.0.9 → 1.1.2

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/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // dsh-all-usage 插件 Host 半(永久版)
2
2
  // 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
3
- import { randomBytes, timingSafeEqual } from 'node:crypto'
3
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
4
+ import { COST_SCHEMA_VERSION, addCostAggregate, calculateCost, createEmptyPricingState, decimalSubtract, fetchModelsDevCatalog, emptyCostAggregate, normalizeCostSnapshot, normalizePricingState, officialProviderIds, resolvePricing, serializeCostAggregate, serializePricingState } from './pricing.js'
4
5
 
5
6
  const name = 'dsh-all-usage'
6
7
  const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
@@ -60,13 +61,28 @@ function sendJson(res, code, value) {
60
61
  function readBody(req, maxBytes) {
61
62
  return new Promise((resolve) => {
62
63
  const chunks = []
63
- let size = 0
64
+ const declaredLength = Number(requestHeader(req, 'content-length'))
65
+ let size = Number.isFinite(declaredLength) && declaredLength > maxBytes ? maxBytes + 1 : 0
66
+ let tooLarge = size > maxBytes
67
+ let settled = false
68
+ const finish = (text, oversized) => {
69
+ if (settled) return
70
+ settled = true
71
+ resolve({ text, tooLarge: oversized })
72
+ }
64
73
  req.on('data', (chunk) => {
65
- size += chunk.length
66
- if (size <= maxBytes) chunks.push(chunk)
74
+ if (tooLarge) return
75
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
76
+ size += value.length
77
+ if (size > maxBytes) {
78
+ tooLarge = true
79
+ chunks.length = 0
80
+ return
81
+ }
82
+ chunks.push(value)
67
83
  })
68
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
69
- req.on('error', () => resolve(''))
84
+ req.on('end', () => finish(tooLarge ? '' : Buffer.concat(chunks).toString('utf8'), tooLarge))
85
+ req.on('error', () => finish('', false))
70
86
  })
71
87
  }
72
88
 
@@ -91,15 +107,24 @@ function apply(ctx) {
91
107
  // One canonical usage contribution per session turn/step. This makes retries and
92
108
  // replacement messages update a logical model call instead of double-counting it.
93
109
  const usageByStep = new Map()
110
+ const turnRecords = new Map()
111
+ const usageByLocalDate = new Map()
112
+ const usageByUtcDate = new Map()
113
+ const turnsByLocalDate = new Map()
114
+ const turnsByUtcDate = new Map()
94
115
  const sessionModel = new Map()
95
- const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
116
+ const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
96
117
  const sessionCount = new Set()
97
118
  const sessionSeq = new Map()
98
119
  const chains = new Map()
120
+ const liveResyncPending = new Set()
121
+ const liveResyncTimers = new Map()
122
+ const liveResyncAttempts = new Map()
99
123
  const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
100
124
  const aliases = {}
101
125
  let kvUnit = null
102
126
  let aliasWriteChain = Promise.resolve()
127
+ let aliasesReady = Promise.resolve()
103
128
  let balanceCache = { fetchedAt: 0, payload: null }
104
129
  const requestToken = randomBytes(32).toString('base64url')
105
130
  // A non-secret identity distinguishes HMR/restart revision resets from a stale page.
@@ -120,10 +145,22 @@ function apply(ctx) {
120
145
  sessionsFailed: 0,
121
146
  }
122
147
  const ledgerRecords = new Map()
148
+ const queryCache = new Map()
149
+ const recordsQueryCache = new Map()
150
+ let snapshotCache = null
123
151
  let ledgerUnit = null
124
152
  let ledgerReady = Promise.resolve()
125
153
  let ledgerWriteChain = Promise.resolve()
126
- const LEDGER_VERSION = 1
154
+ let pricingState = createEmptyPricingState()
155
+ const pricingResolutionCache = new Map()
156
+ let pricingUnit = null
157
+ let pricingReady = Promise.resolve()
158
+ let pricingWriteChain = Promise.resolve()
159
+ let pricingSyncInFlight = false
160
+ let pricingSyncTimer = null
161
+ const LEDGER_VERSION = 3
162
+ const PREVIOUS_LEDGER_VERSION = 2
163
+ const LEGACY_LEDGER_VERSION = 1
127
164
  let ledgerRevision = Date.now()
128
165
  let disposed = false
129
166
  let baselineRetryDelay = 1000
@@ -146,21 +183,103 @@ function apply(ctx) {
146
183
  const d = new Date(ms)
147
184
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
148
185
  }
186
+ function dateKeys(ms) {
187
+ const d = new Date(ms)
188
+ const local = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
189
+ const utc = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
190
+ return { local, utc }
191
+ }
149
192
  function num(v) {
150
193
  return typeof v === 'number' && Number.isFinite(v) ? v : 0
151
194
  }
195
+ function validEventTime(value) {
196
+ return typeof value === 'number' && Number.isFinite(value)
197
+ }
198
+ function addDateIndex(index, date, key) {
199
+ if (typeof date !== 'string' || date === '') return
200
+ let keys = index.get(date)
201
+ if (keys === undefined) { keys = new Set(); index.set(date, keys) }
202
+ keys.add(key)
203
+ }
204
+ function removeDateIndex(index, date, key) {
205
+ if (typeof date !== 'string' || date === '') return
206
+ const keys = index.get(date)
207
+ if (keys === undefined) return
208
+ keys.delete(key)
209
+ if (keys.size === 0) index.delete(date)
210
+ }
211
+ function indexUsage(item) {
212
+ addDateIndex(usageByLocalDate, item.date, item.key)
213
+ addDateIndex(usageByUtcDate, item.dateUtc, item.key)
214
+ }
215
+ function unindexUsage(item) {
216
+ removeDateIndex(usageByLocalDate, item.date, item.key)
217
+ removeDateIndex(usageByUtcDate, item.dateUtc, item.key)
218
+ }
219
+ function indexTurn(turn) {
220
+ addDateIndex(turnsByLocalDate, turn.date, turn.key)
221
+ addDateIndex(turnsByUtcDate, turn.dateUtc, turn.key)
222
+ }
223
+ function indexedEntries(index, source, scope, heatStart, today) {
224
+ const result = []
225
+ for (const [date, keys] of index) {
226
+ if (!dateInScope(date, scope) && (date < heatStart || date > today)) continue
227
+ for (const key of keys) {
228
+ const item = source.get(key)
229
+ if (item !== undefined) result.push({ item, date })
230
+ }
231
+ }
232
+ return result
233
+ }
234
+ function indexedEntriesInRange(index, source, start, end) {
235
+ const result = []
236
+ for (const [date, keys] of index) {
237
+ if (date < start || date > end) continue
238
+ for (const key of keys) {
239
+ const item = source.get(key)
240
+ if (item !== undefined) result.push({ item, date })
241
+ }
242
+ }
243
+ return result
244
+ }
245
+ function usageBasisEqual(first, identity, values) {
246
+ if (first === null || first === undefined) return false
247
+ const firstIdentity = first.identity || first.modelId
248
+ const left = coerceIdentity(firstIdentity)
249
+ const right = coerceIdentity(identity)
250
+ if (left.identityKey !== right.identityKey) return false
251
+ return ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'].every((key) => num(first.values && first.values[key]) === num(values && values[key]))
252
+ }
253
+ function resolveCurrentPricing(identity) {
254
+ const normalized = coerceIdentity(identity)
255
+ const key = normalized.identityKey
256
+ const cached = pricingResolutionCache.get(key)
257
+ if (cached !== undefined) return cached
258
+ const resolved = resolvePricing(normalized, pricingState)
259
+ pricingResolutionCache.set(key, resolved)
260
+ while (pricingResolutionCache.size > 5000) pricingResolutionCache.delete(pricingResolutionCache.keys().next().value)
261
+ return resolved
262
+ }
263
+ function costForUsage(values, identity, previous) {
264
+ const previousCost = normalizeCostSnapshot(previous && previous.cost)
265
+ if (previousCost !== null && previousCost.pricingMode === 'official-model' && usageBasisEqual(previous, identity, values)) return previousCost
266
+ return calculateCost(values, resolveCurrentPricing(identity))
267
+ }
152
268
  // Coalesce synchronous aggregation writes without adding a timer lifecycle.
269
+ function commitPendingStats() {
270
+ if (!statsDirtyScheduled) return
271
+ statsDirtyScheduled = false
272
+ if (disposed) return
273
+ statsRevision += 1
274
+ statsUpdatedAt = Date.now()
275
+ }
153
276
  function markStatsChanged() {
277
+ snapshotCache = null
278
+ recordsQueryCache.clear()
154
279
  if (disposed || statsDirtyScheduled) return
155
280
  statsDirtyScheduled = true
156
- const flush = () => {
157
- statsDirtyScheduled = false
158
- if (disposed) return
159
- statsRevision += 1
160
- statsUpdatedAt = Date.now()
161
- }
162
- if (typeof queueMicrotask === 'function') queueMicrotask(flush)
163
- else Promise.resolve().then(flush)
281
+ if (typeof queueMicrotask === 'function') queueMicrotask(commitPendingStats)
282
+ else Promise.resolve().then(commitPendingStats)
164
283
  }
165
284
  function resetSyncState() {
166
285
  sync.lastStartedAt = 0
@@ -218,6 +337,23 @@ function apply(ctx) {
218
337
  perWorkspace.clear()
219
338
  perModel.clear()
220
339
  usageByStep.clear()
340
+ turnRecords.clear()
341
+ usageByLocalDate.clear()
342
+ usageByUtcDate.clear()
343
+ turnsByLocalDate.clear()
344
+ turnsByUtcDate.clear()
345
+ for (const timer of liveResyncTimers.values()) clearTimeout(timer)
346
+ liveResyncTimers.clear()
347
+ liveResyncAttempts.clear()
348
+ liveResyncPending.clear()
349
+ reconcileHintScheduled = false
350
+ if (baselineFallbackTimer !== null) {
351
+ clearTimeout(baselineFallbackTimer)
352
+ baselineFallbackTimer = null
353
+ }
354
+ baselineRetryScheduled = false
355
+ queryCache.clear()
356
+ recordsQueryCache.clear()
221
357
  sessionModel.clear()
222
358
  sessionCount.clear()
223
359
  sessionSeq.clear()
@@ -229,6 +365,7 @@ function apply(ctx) {
229
365
  totals.cacheRead = 0
230
366
  totals.cacheWrite = 0
231
367
  totals.reasoning = 0
368
+ Object.assign(totals.cost, emptyCostAggregate())
232
369
  scan.started = false
233
370
  scan.done = false
234
371
  scan.scanned = 0
@@ -242,7 +379,7 @@ function apply(ctx) {
242
379
  function ensureDay(dayMap, date) {
243
380
  let day = dayMap.get(date)
244
381
  if (day === undefined) {
245
- day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set() }
382
+ day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: emptyCostAggregate(), perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set(), sessionRefs: new Map() }
246
383
  dayMap.set(date, day)
247
384
  }
248
385
  return day
@@ -250,7 +387,7 @@ function apply(ctx) {
250
387
  function ensureWs(wsId) {
251
388
  let ws = perWorkspace.get(wsId)
252
389
  if (ws === undefined) {
253
- ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
390
+ ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
254
391
  perWorkspace.set(wsId, ws)
255
392
  }
256
393
  return ws
@@ -258,24 +395,26 @@ function apply(ctx) {
258
395
  function ensureDayWs(day, wsId) {
259
396
  let w = day.byWs.get(wsId)
260
397
  if (w === undefined) {
261
- w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
398
+ w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
262
399
  day.byWs.set(wsId, w)
263
400
  }
264
401
  return w
265
402
  }
266
- function ensureModel(model) {
267
- let item = perModel.get(model)
403
+ function ensureModel(value) {
404
+ const identity = coerceIdentity(value)
405
+ let item = perModel.get(identity.identityKey)
268
406
  if (item === undefined) {
269
- item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
270
- perModel.set(model, item)
407
+ item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
408
+ perModel.set(identity.identityKey, item)
271
409
  }
272
410
  return item
273
411
  }
274
- function ensureDayModel(day, model) {
275
- let item = day.byModel.get(model)
412
+ function ensureDayModel(day, value) {
413
+ const identity = coerceIdentity(value)
414
+ let item = day.byModel.get(identity.identityKey)
276
415
  if (item === undefined) {
277
- item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
278
- day.byModel.set(model, item)
416
+ item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
417
+ day.byModel.set(identity.identityKey, item)
279
418
  }
280
419
  return item
281
420
  }
@@ -298,75 +437,187 @@ function apply(ctx) {
298
437
  function noValues(target) {
299
438
  return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
300
439
  }
301
- function adjustDay(dayMap, date, wsId, values, modelId, direction, sid) {
440
+ function adjustDaySession(day, sid, direction) {
441
+ if (sid === undefined || sid === null) return
442
+ const current = day.sessionRefs.get(sid) || 0
443
+ const next = current + direction
444
+ if (next > 0) {
445
+ day.sessionRefs.set(sid, next)
446
+ day.sessionIds.add(sid)
447
+ } else {
448
+ day.sessionRefs.delete(sid)
449
+ day.sessionIds.delete(sid)
450
+ }
451
+ }
452
+ function adjustDay(dayMap, date, wsId, values, identity, direction, sid, cost) {
302
453
  const day = ensureDay(dayMap, date)
303
- if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
454
+ adjustDaySession(day, sid, direction)
304
455
  adjustValues(day.tokens, values, direction)
456
+ addCostAggregateDirection(day.cost, cost, direction)
305
457
  const dayWs = ensureDayWs(day, wsId)
306
458
  adjustValues(dayWs, values, direction)
459
+ addCostAggregateDirection(dayWs.cost, cost, direction)
307
460
  if (noValues(dayWs)) day.byWs.delete(wsId)
308
- const dayModel = ensureDayModel(day, modelId)
461
+ const dayModel = ensureDayModel(day, identity)
309
462
  dayModel.calls += direction
310
463
  adjustValues(dayModel, values, direction)
311
- if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(modelId)
464
+ addCostAggregateDirection(dayModel.cost, cost, direction)
465
+ if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
312
466
  }
313
- function adjustUsage(wsId, time, values, modelId, direction, sid) {
314
- const modelTotals = ensureModel(modelId)
467
+ function addCostAggregateDirection(target, cost, direction) {
468
+ if (direction === 1) {
469
+ addCostAggregate(target, cost)
470
+ return
471
+ }
472
+ if (direction !== -1) return
473
+ const status = cost && typeof cost.status === 'string' ? cost.status : 'unpriced'
474
+ if (status === 'priced') {
475
+ target.input = decimalSubtract(target.input, cost.breakdown && cost.breakdown.input)
476
+ target.output = decimalSubtract(target.output, cost.breakdown && cost.breakdown.output)
477
+ target.cacheRead = decimalSubtract(target.cacheRead, cost.breakdown && cost.breakdown.cacheRead)
478
+ target.cacheWrite = decimalSubtract(target.cacheWrite, cost.breakdown && cost.breakdown.cacheWrite)
479
+ target.baseTotal = decimalSubtract(target.baseTotal, cost.baseTotal)
480
+ target.total = decimalSubtract(target.total, cost.total)
481
+ target.pricedCalls -= 1
482
+ } else if (status === 'ambiguous') target.ambiguousCalls -= 1
483
+ else if (status === 'unsupported') target.unsupportedCalls -= 1
484
+ else target.unpricedCalls -= 1
485
+ }
486
+ function adjustUsage(wsId, time, values, identity, direction, sid, cachedDates, cost) {
487
+ const normalized = coerceIdentity(identity)
488
+ const dates = cachedDates && typeof cachedDates.local === 'string' && typeof cachedDates.utc === 'string' ? cachedDates : dateKeys(time)
489
+ const modelTotals = ensureModel(normalized)
315
490
  modelTotals.calls += direction
316
491
  adjustValues(modelTotals, values, direction)
317
- if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(modelId)
492
+ addCostAggregateDirection(modelTotals.cost, cost, direction)
493
+ if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(normalized.identityKey)
318
494
  adjustValues(totals, values, direction)
495
+ addCostAggregateDirection(totals.cost, cost, direction)
319
496
  const ws = ensureWs(wsId)
320
497
  adjustValues(ws, values, direction)
321
- adjustDay(byDay, dayKey(time), wsId, values, modelId, direction, sid)
322
- adjustDay(byDayUtc, dayKeyUtc(time), wsId, values, modelId, direction, sid)
498
+ addCostAggregateDirection(ws.cost, cost, direction)
499
+ adjustDay(byDay, dates.local, wsId, values, normalized, direction, sid, cost)
500
+ adjustDay(byDayUtc, dates.utc, wsId, values, normalized, direction, sid, cost)
323
501
  }
324
- function usageStepKey(sid, data, seq) {
502
+ function usageStepKey(sid, data, seq, fallback) {
325
503
  const turn = data && typeof data.turn === 'number' ? data.turn : null
326
504
  const step = data && typeof data.step === 'number' ? data.step : null
327
505
  if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
328
- return sid + ':event:' + (typeof seq === 'number' ? seq : String(Date.now()))
506
+ if (typeof seq === 'number') return sid + ':event:' + seq
507
+ return sid + ':event:' + (fallback === undefined ? JSON.stringify(data || {}) : String(fallback))
329
508
  }
330
- function addUsage(wsId, time, usage, model, sid, data, seq) {
509
+ function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live') {
510
+ if (!validEventTime(time)) return
331
511
  const values = usageValues(usage)
332
- const modelId = typeof model === 'string' && model !== '' ? model : '未知模型(历史记录缺少路由)'
512
+ const identity = coerceIdentity(model)
333
513
  const eventSeq = typeof seq === 'number' ? seq : -1
334
514
  // v1.0.7: a usage event carrying no billable token in any bucket must not add a
335
515
  // meaningless row nor wipe previously recorded real usage (cc-switch
336
516
  // has_billable_tokens parity). Pure cache-read requests are billable and pass.
337
517
  if (noValues(values)) return
518
+ const dates = dateKeys(time)
338
519
  const key = usageStepKey(sid, data, seq)
339
520
  const previous = usageByStep.get(key)
340
521
  // A late replay of an older raw event cannot replace the canonical later step.
341
522
  if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
342
- if (previous !== undefined) adjustUsage(previous.wsId, previous.time, previous.values, previous.modelId, -1, previous.sid)
343
- const next = { seq: eventSeq, wsId, time, values, modelId, sid }
523
+ if (previous !== undefined) {
524
+ unindexUsage(previous)
525
+ adjustUsage(previous.wsId, previous.time, previous.values, previous.identity || previous.modelId, -1, previous.sid, { local: previous.date, utc: previous.dateUtc }, previous.cost)
526
+ }
527
+ const cost = costForUsage(values, identity, previous)
528
+ const next = { key, seq: eventSeq, wsId, time, date: dates.local, dateUtc: dates.utc, values, identity, modelId: identity.label, cost, turn: data && typeof data.turn === 'number' ? data.turn : null, step: data && typeof data.step === 'number' ? data.step : null, materialization, sid }
344
529
  usageByStep.set(key, next)
345
- adjustUsage(wsId, time, values, modelId, 1, sid)
530
+ indexUsage(next)
531
+ adjustUsage(wsId, time, values, identity, 1, sid, dates, cost)
346
532
  markStatsChanged()
347
533
  }
348
534
  function addDayTurn(dayMap, date, wsId, sid) {
349
535
  const day = ensureDay(dayMap, date)
350
- if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
536
+ adjustDaySession(day, sid, 1)
351
537
  day.turns += 1
352
538
  day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
353
539
  }
354
- function addTurn(wsId, time, sid) {
540
+ function turnRecordKey(sid, turn, seq, time) {
541
+ if (typeof turn === 'number' && Number.isFinite(turn)) return sid + ':turn:' + turn
542
+ if (typeof seq === 'number' && Number.isFinite(seq)) return sid + ':event:' + seq
543
+ return sid + ':time:' + String(time)
544
+ }
545
+ function addTurn(wsId, time, sid, turn, identity, materialization = 'live', seq) {
546
+ if (!validEventTime(time)) return
547
+ const key = turnRecordKey(sid, turn, seq, time)
548
+ if (turnRecords.has(key)) return
549
+ const normalized = coerceIdentity(identity)
550
+ const dates = dateKeys(time)
551
+ const record = { key, sid, wsId, time, date: dates.local, dateUtc: dates.utc, turn: typeof turn === 'number' ? turn : null, identity: normalized, materialization }
552
+ turnRecords.set(key, record)
553
+ indexTurn(record)
355
554
  ensureWs(wsId).turns += 1
356
555
  totals.turns += 1
357
- addDayTurn(byDay, dayKey(time), wsId, sid)
358
- addDayTurn(byDayUtc, dayKeyUtc(time), wsId, sid)
556
+ addDayTurn(byDay, dates.local, wsId, sid)
557
+ addDayTurn(byDayUtc, dates.utc, wsId, sid)
359
558
  markStatsChanged()
360
559
  }
361
- function routeLabel(route) {
362
- if (route && typeof route.model === 'string' && route.model !== '') return (typeof route.provider === 'string' && route.provider !== '' ? route.provider + ' / ' : '') + route.model
363
- return undefined
560
+ const UNKNOWN_MODEL_LABEL = '未知模型(历史记录缺少路由)'
561
+ function textOrNull(value) {
562
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
563
+ }
564
+ function identityLabel(provider, requestedModel, actualModel, legacyLabel) {
565
+ const model = actualModel || requestedModel
566
+ if (model !== null) return provider === null ? model : provider + ' / ' + model
567
+ return legacyLabel || UNKNOWN_MODEL_LABEL
568
+ }
569
+ function makeIdentity(provider, requestedModel, actualModel, legacyLabel) {
570
+ const normalizedProvider = textOrNull(provider)
571
+ const normalizedRequested = textOrNull(requestedModel)
572
+ const normalizedActual = textOrNull(actualModel)
573
+ const normalizedLegacy = textOrNull(legacyLabel)
574
+ const key = JSON.stringify([normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy])
575
+ return {
576
+ identityKey: key,
577
+ provider: normalizedProvider,
578
+ requestedModel: normalizedRequested,
579
+ actualModel: normalizedActual,
580
+ label: identityLabel(normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy),
581
+ legacy: normalizedLegacy !== null && normalizedProvider === null && normalizedRequested === null && normalizedActual === null,
582
+ }
583
+ }
584
+ function identityFromLegacy(label) {
585
+ return makeIdentity(null, null, null, textOrNull(label) || UNKNOWN_MODEL_LABEL)
586
+ }
587
+ function isCanonicalIdentity(value) {
588
+ 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'
589
+ }
590
+ function coerceIdentity(value) {
591
+ if (isCanonicalIdentity(value)) return value
592
+ if (value !== null && typeof value === 'object') {
593
+ return makeIdentity(value.provider, value.requestedModel, value.actualModel, value.legacyLabel || (value.legacy === true ? value.label : null))
594
+ }
595
+ if (typeof value === 'string' && value !== '') return identityFromLegacy(value)
596
+ return makeIdentity(null, null, null, UNKNOWN_MODEL_LABEL)
597
+ }
598
+ function routeObject(data) {
599
+ if (data && typeof data === 'object' && (data.provider !== undefined || data.model !== undefined)) return data
600
+ const config = data && data.header && data.header.config
601
+ return config && typeof config === 'object' ? config : null
602
+ }
603
+ function identityFromRoute(data, fallback) {
604
+ const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
605
+ const route = routeObject(data)
606
+ if (route === null) return base
607
+ return makeIdentity(route.provider === undefined ? base.provider : route.provider, route.model === undefined ? base.requestedModel : route.model, null, base.legacy ? base.label : null)
608
+ }
609
+ function identityFromMessage(data, fallback) {
610
+ const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
611
+ const source = data && data.message && data.message.source
612
+ if (source === null || typeof source !== 'object') return base
613
+ return makeIdentity(source.provider === undefined ? base.provider : source.provider, base.requestedModel || source.model, source.model, base.legacy ? base.label : null)
364
614
  }
365
615
  function modelFromRoute(data) {
366
- return routeLabel(data) || routeLabel(data && data.header && data.header.config)
616
+ const identity = identityFromRoute(data)
617
+ return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
367
618
  }
368
619
  function modelFromMessage(data, fallback) {
369
- return routeLabel(data && data.message && data.message.source) || fallback
620
+ return identityFromMessage(data, fallback).label
370
621
  }
371
622
  function nextLedgerRevision() {
372
623
  ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
@@ -375,64 +626,120 @@ function apply(ctx) {
375
626
  function ledgerEventKey(event, index) {
376
627
  return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
377
628
  }
378
- function buildLedgerRecord(session, workspaceId, source = 'scan', revision) {
629
+ function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord) {
379
630
  const sid = session && typeof session.id === 'string' ? session.id : ''
380
631
  const events = session && Array.isArray(session.events) ? session.events : []
381
632
  if (sid === '' || workspaceId === undefined) return null
382
633
  const turns = new Map()
383
634
  const usage = new Map()
384
- let currentModel
635
+ const previousUsage = new Map(Array.isArray(previousRecord && previousRecord.usage) ? previousRecord.usage.map((item) => [item.key, item]) : [])
636
+ let currentIdentity = makeIdentity(null, null, null, null)
385
637
  for (let index = 0; index < events.length; index += 1) {
386
638
  const event = events[index]
387
639
  if (event === null || typeof event !== 'object') continue
388
640
  const data = event.data
389
641
  if (event.type === 'request/context' || event.type === 'request/header') {
390
- const model = modelFromRoute(data)
391
- if (model !== undefined) currentModel = model
642
+ currentIdentity = identityFromRoute(data, currentIdentity)
392
643
  continue
393
644
  }
394
645
  if (event.type === 'turn/end') {
646
+ if (!validEventTime(event.time)) continue
395
647
  const key = ledgerEventKey(event, index)
396
- turns.set(key, { key, time: event.time, workspaceId })
648
+ const turn = data && typeof data.turn === 'number' ? data.turn : null
649
+ turns.set(key, { key, seq: typeof event.seq === 'number' ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
397
650
  continue
398
651
  }
399
- if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
652
+ if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined || !validEventTime(event.time)) continue
400
653
  const values = usageValues(data.usage)
401
654
  // v1.0.7: all-zero usage rows carry no billable tokens and stay out of the
402
655
  // durable ledger (cc-switch has_billable_tokens parity).
403
656
  if (noValues(values)) continue
404
- const modelId = typeof modelFromMessage(data, currentModel) === 'string' && modelFromMessage(data, currentModel) !== ''
405
- ? modelFromMessage(data, currentModel) : '未知模型(历史记录缺少路由)'
657
+ const identity = identityFromMessage(data, currentIdentity)
406
658
  const eventSeq = typeof event.seq === 'number' ? event.seq : -1
407
- const key = usageStepKey(sid, data, event.seq)
659
+ const key = usageStepKey(sid, data, event.seq, index)
408
660
  const previous = usage.get(key)
409
661
  if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
410
- usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
662
+ const previousItem = previousUsage.get(key)
663
+ const previousCost = normalizeCostSnapshot(previousItem && previousItem.cost)
664
+ const cost = previousItem !== undefined && previousCost !== null && previousCost.pricingMode === 'official-model' && usageBasisEqual(previousItem, identity, values) ? previousCost : calculateCost(values, resolveCurrentPricing(identity))
665
+ usage.set(key, {
666
+ key,
667
+ seq: eventSeq,
668
+ time: event.time,
669
+ workspaceId,
670
+ identity,
671
+ modelId: identity.label,
672
+ cost,
673
+ turn: data && typeof data.turn === 'number' ? data.turn : null,
674
+ step: data && typeof data.step === 'number' ? data.step : null,
675
+ values,
676
+ })
677
+ currentIdentity = identity
411
678
  }
412
- return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
679
+ return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
413
680
  }
414
681
  function normalizeLedgerRecord(raw, key) {
415
- if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
682
+ if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== PREVIOUS_LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
416
683
  if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
684
+ const needsUpgrade = raw.version !== LEDGER_VERSION || raw.usage.some((item) => { const cost = normalizeCostSnapshot(item && item.cost); return item === null || typeof item !== 'object' || item.identity === undefined || cost === null || cost.pricingMode !== 'official-model' })
417
685
  const turnMap = new Map()
418
686
  for (const turn of raw.turns) {
419
- if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, time: turn.time, workspaceId: turn.workspaceId })
687
+ if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, seq: typeof turn.seq === 'number' ? turn.seq : -1, time: turn.time, workspaceId: turn.workspaceId, turn: typeof turn.turn === 'number' ? turn.turn : null, identity: coerceIdentity(turn.identity || turn.modelId) })
420
688
  }
421
689
  const usageMap = new Map()
422
690
  for (const item of raw.usage) {
423
- if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || typeof item.modelId !== 'string' || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
424
- const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, modelId: item.modelId, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
691
+ if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
692
+ const identity = coerceIdentity(item.identity || item.modelId)
693
+ const normalizedCost = normalizeCostSnapshot(item.cost)
694
+ const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, ...(normalizedCost === null ? {} : { cost: normalizedCost }), turn: typeof item.turn === 'number' ? item.turn : null, step: typeof item.step === 'number' ? item.step : null, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
425
695
  const previous = usageMap.get(normalized.key)
426
696
  if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
427
697
  }
428
698
  const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
429
699
  ledgerRevision = Math.max(ledgerRevision, updatedAt)
430
- return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
700
+ const lastUsage = Array.from(usageMap.values()).at(-1)
701
+ const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
702
+ const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
703
+ Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
704
+ return normalizedRecord
705
+ }
706
+ function prepareLedgerRecord(record) {
707
+ if (record === null || record === undefined) return record
708
+ let changed = record.needsUpgrade === true || record.version !== LEDGER_VERSION
709
+ for (const item of record.usage) {
710
+ const cost = normalizeCostSnapshot(item.cost)
711
+ if (cost !== null && cost.pricingMode === 'official-model') { item.cost = cost; continue }
712
+ const identity = item.identity || identityFromLegacy(item.modelId)
713
+ item.cost = calculateCost(item.values, resolveCurrentPricing(identity))
714
+ changed = true
715
+ }
716
+ if (changed) {
717
+ record.version = LEDGER_VERSION
718
+ record.updatedAt = nextLedgerRevision()
719
+ record.needsUpgrade = false
720
+ ledgerRecords.set(record.sessionId, record)
721
+ void persistLedgerRecord(record)
722
+ }
723
+ return record
431
724
  }
432
- function applyLedgerRecord(record) {
725
+ function applyLedgerRecord(record, materialization = 'ledger-reuse') {
433
726
  if (record === null || record === undefined) return
434
- for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
435
- for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
727
+ prepareLedgerRecord(record)
728
+ if (record.lastIdentity !== undefined) sessionModel.set(record.sessionId, record.lastIdentity)
729
+ for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
730
+ for (const item of record.usage) {
731
+ const identity = item.identity || identityFromLegacy(item.modelId)
732
+ const dates = dateKeys(item.time)
733
+ const previous = usageByStep.get(item.key)
734
+ if (previous !== undefined) {
735
+ unindexUsage(previous)
736
+ adjustUsage(previous.wsId, previous.time, previous.values, previous.identity || previous.modelId, -1, previous.sid, { local: previous.date, utc: previous.dateUtc }, previous.cost)
737
+ }
738
+ const next = { key: item.key, seq: item.seq, wsId: item.workspaceId, time: item.time, date: dates.local, dateUtc: dates.utc, values: item.values, identity, modelId: identity.label, cost: item.cost, turn: item.turn, step: item.step, materialization, sid: record.sessionId }
739
+ adjustUsage(item.workspaceId, item.time, item.values, identity, 1, record.sessionId, dates, item.cost)
740
+ usageByStep.set(item.key, next)
741
+ indexUsage(next)
742
+ }
436
743
  if (record.turns.length > 0 || record.usage.length > 0) {
437
744
  sessionCount.add(record.sessionId)
438
745
  if (record.usage.length > 0) markStatsChanged()
@@ -441,6 +748,10 @@ function apply(ctx) {
441
748
  function ledgerRank(record) {
442
749
  return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
443
750
  }
751
+ function replaceLedgerRecord(record) {
752
+ ledgerRecords.set(record.sessionId, record)
753
+ return record
754
+ }
444
755
  function storeLedgerRecord(record) {
445
756
  const current = ledgerRecords.get(record.sessionId)
446
757
  if (current !== undefined) {
@@ -452,7 +763,7 @@ function apply(ctx) {
452
763
  return record
453
764
  }
454
765
  function persistLedgerRecord(record) {
455
- if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
766
+ if (disposed || ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
456
767
  const write = ledgerWriteChain.then(async () => {
457
768
  if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
458
769
  })
@@ -461,34 +772,54 @@ function apply(ctx) {
461
772
  })
462
773
  return ledgerWriteChain
463
774
  }
464
- function foldEvent(wsId, time, type, data, sid, seq) {
775
+ function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
776
+ if ((type === 'turn/end' || type === 'assistant/message') && !validEventTime(time)) return
465
777
  if (type === 'request/context' || type === 'request/header') {
466
- const model = modelFromRoute(data)
467
- if (model !== undefined) sessionModel.set(sid, model)
468
- } else if (type === 'turn/end') addTurn(wsId, time, sid)
469
- else if (type === 'assistant/message' && data && data.usage) addUsage(wsId, time, data.usage, modelFromMessage(data, sessionModel.get(sid)), sid, data, seq)
778
+ sessionModel.set(sid, identityFromRoute(data, sessionModel.get(sid)))
779
+ } else if (type === 'turn/end') {
780
+ addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, sessionModel.get(sid), materialization, seq)
781
+ } else if (type === 'assistant/message' && data && data.usage) {
782
+ const identity = identityFromMessage(data, sessionModel.get(sid))
783
+ sessionModel.set(sid, identity)
784
+ addUsage(wsId, time, data.usage, identity, sid, data, seq, materialization)
785
+ }
470
786
  }
471
- function foldEvents(wsId, events, fromSeq, sid) {
787
+ function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
472
788
  for (const ev of events) {
473
789
  if (fromSeq !== undefined) {
474
790
  const s = typeof ev.seq === 'number' ? ev.seq : -1
475
791
  if (s <= fromSeq) continue
476
792
  }
477
- if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq)
793
+ if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq, materialization)
478
794
  }
479
795
  }
480
796
  function lastSeqOf(events) {
481
- let last = 0
797
+ let last = -1
482
798
  for (const ev of events) {
483
799
  const s = typeof ev.seq === 'number' ? ev.seq : -1
484
800
  if (s > last) last = s
485
801
  }
486
802
  return last
487
803
  }
804
+ function sequenceProfile(events) {
805
+ let previous = -1
806
+ let last = -1
807
+ let nonMonotonic = false
808
+ for (const ev of events) {
809
+ const seq = ev && typeof ev.seq === 'number' ? ev.seq : -1
810
+ if (seq < 0) continue
811
+ if (seq <= previous) nonMonotonic = true
812
+ previous = seq
813
+ if (seq > last) last = seq
814
+ }
815
+ return { lastSeq: last, nonMonotonic }
816
+ }
488
817
  function enqueue(sid, task) {
489
818
  const prev = chains.get(sid) || Promise.resolve()
490
819
  const next = prev.then(() => task(), () => task())
491
820
  chains.set(sid, next)
821
+ const cleanup = () => { if (chains.get(sid) === next) chains.delete(sid) }
822
+ void next.then(cleanup, cleanup)
492
823
  return next
493
824
  }
494
825
  function wsForLiveSession(session, sid) {
@@ -501,37 +832,89 @@ function apply(ctx) {
501
832
  if (wsId !== undefined) memberOf.set(sid, wsId)
502
833
  return wsId
503
834
  }
835
+ function cancelLiveResync(sid) {
836
+ const timer = liveResyncTimers.get(sid)
837
+ if (timer !== undefined) {
838
+ clearTimeout(timer)
839
+ liveResyncTimers.delete(sid)
840
+ }
841
+ liveResyncAttempts.delete(sid)
842
+ liveResyncPending.delete(sid)
843
+ }
844
+ function scheduleLiveResync(sid, wsId, generation) {
845
+ if (disposed || generation !== aggregationGeneration || !liveResyncPending.has(sid) || liveResyncTimers.has(sid)) return
846
+ const attempt = liveResyncAttempts.get(sid) || 0
847
+ const delay = Math.min(30000, 1000 * Math.pow(2, Math.min(attempt, 5)))
848
+ const timer = setTimeout(() => {
849
+ liveResyncTimers.delete(sid)
850
+ if (disposed || generation !== aggregationGeneration || !liveResyncPending.has(sid)) return
851
+ void enqueue(sid, () => resyncLiveSession(sid, wsId, generation))
852
+ }, delay)
853
+ liveResyncTimers.set(sid, timer)
854
+ if (timer && typeof timer.unref === 'function') timer.unref()
855
+ }
856
+ function foldLiveFallback(sid, wsId, event) {
857
+ if (event === null || event === undefined) return
858
+ foldEvent(wsId, event.time, event.type, event.data, sid, event.seq, 'live')
859
+ const seq = typeof event.seq === 'number' ? event.seq : -1
860
+ if (seq >= 0) {
861
+ const current = sessionSeq.get(sid)
862
+ if (current === undefined || seq > current) sessionSeq.set(sid, seq)
863
+ }
864
+ sessionCount.add(sid)
865
+ }
866
+ async function syncLiveSession(sid, wsId, event, generation) {
867
+ try {
868
+ const snap = await ctx.sessionQuery.readSession(sid)
869
+ if (disposed || generation !== aggregationGeneration) return true
870
+ if (snap && Array.isArray(snap.events)) {
871
+ const previousLast = sessionSeq.get(sid)
872
+ const snapshotLast = lastSeqOf(snap.events)
873
+ foldEvents(wsId, snap.events, undefined, sid, 'live')
874
+ let nextLast = Math.max(previousLast === undefined ? -1 : previousLast, snapshotLast)
875
+ const eventSeq = event === null || event === undefined || typeof event.seq !== 'number' ? -1 : event.seq
876
+ const needsFollowup = event !== null && event !== undefined && (eventSeq < 0 || eventSeq > snapshotLast)
877
+ if (needsFollowup) {
878
+ foldLiveFallback(sid, wsId, event)
879
+ const current = sessionSeq.get(sid)
880
+ nextLast = Math.max(nextLast, current === undefined ? -1 : current)
881
+ }
882
+ sessionSeq.set(sid, nextLast)
883
+ sessionCount.add(sid)
884
+ if (needsFollowup) scheduleLiveResync(sid, wsId, generation)
885
+ else cancelLiveResync(sid)
886
+ return true
887
+ }
888
+ } catch (err) {
889
+ // Keep the event as a fallback and retry a complete session sync later.
890
+ }
891
+ return false
892
+ }
893
+ async function resyncLiveSession(sid, wsId, generation) {
894
+ if (disposed || generation !== aggregationGeneration || !liveResyncPending.has(sid)) return
895
+ if (await syncLiveSession(sid, wsId, null, generation)) return
896
+ liveResyncAttempts.set(sid, (liveResyncAttempts.get(sid) || 0) + 1)
897
+ scheduleLiveResync(sid, wsId, generation)
898
+ }
504
899
  async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
505
900
  if (disposed || generation !== aggregationGeneration) return
506
901
  const seq = typeof event.seq === 'number' ? event.seq : -1
507
902
  const last = sessionSeq.get(sid)
508
- if (last === undefined) {
509
- try {
510
- const snap = await ctx.sessionQuery.readSession(sid)
511
- if (disposed || generation !== aggregationGeneration) return
512
- if (snap && Array.isArray(snap.events)) {
513
- foldEvents(wsId, snap.events, undefined, sid)
514
- sessionSeq.set(sid, lastSeqOf(snap.events))
515
- sessionCount.add(sid)
516
- }
517
- } catch (err) { /* retry on the next event */ }
903
+ const needsSync = last === undefined || liveResyncPending.has(sid) || (seq >= 0 && seq > last + 1)
904
+ if (needsSync) {
905
+ liveResyncPending.add(sid)
906
+ if (await syncLiveSession(sid, wsId, event, generation)) return
907
+ foldLiveFallback(sid, wsId, event)
908
+ liveResyncAttempts.set(sid, (liveResyncAttempts.get(sid) || 0) + 1)
909
+ scheduleLiveResync(sid, wsId, generation)
518
910
  return
519
911
  }
520
- if (seq <= last) return
521
- if (seq > last + 1) {
522
- try {
523
- const snap = await ctx.sessionQuery.readSession(sid)
524
- if (disposed || generation !== aggregationGeneration) return
525
- if (snap && Array.isArray(snap.events)) {
526
- foldEvents(wsId, snap.events, last, sid)
527
- sessionSeq.set(sid, lastSeqOf(snap.events))
528
- }
529
- } catch (err) { /* keep last; retry later */ }
912
+ if (seq < 0) {
913
+ foldLiveFallback(sid, wsId, event)
530
914
  return
531
915
  }
532
- foldEvent(wsId, event.time, event.type, event.data, sid, event.seq)
533
- sessionSeq.set(sid, seq)
534
- sessionCount.add(sid)
916
+ if (seq <= last) return
917
+ foldLiveFallback(sid, wsId, event)
535
918
  }
536
919
 
537
920
  // ---------- durable usage ledger ----------
@@ -557,6 +940,197 @@ function apply(ctx) {
557
940
  }
558
941
  }
559
942
 
943
+
944
+ // ---------- pricing catalog and cost backfill ----------
945
+ function adjustCostOnly(item, cost, direction) {
946
+ const identity = coerceIdentity(item.identity || item.modelId)
947
+ const dates = item && typeof item.date === 'string' && typeof item.dateUtc === 'string' ? { local: item.date, utc: item.dateUtc } : dateKeys(item.time)
948
+ const targets = []
949
+ const seen = new Set()
950
+ for (const target of [
951
+ ensureModel(identity).cost,
952
+ totals.cost,
953
+ ensureWs(item.wsId).cost,
954
+ ensureDay(byDay, dates.local).cost,
955
+ ensureDay(byDayUtc, dates.utc).cost,
956
+ ensureDayWs(ensureDay(byDay, dates.local), item.wsId).cost,
957
+ ensureDayWs(ensureDay(byDayUtc, dates.utc), item.wsId).cost,
958
+ ensureDayModel(ensureDay(byDay, dates.local), identity).cost,
959
+ ensureDayModel(ensureDay(byDayUtc, dates.utc), identity).cost,
960
+ ]) {
961
+ if (seen.has(target)) continue
962
+ seen.add(target)
963
+ targets.push(target)
964
+ }
965
+ for (const target of targets) addCostAggregateDirection(target, cost, direction)
966
+ }
967
+ function usedPricingModels() {
968
+ const rows = []
969
+ const seen = new Set()
970
+ for (const item of usageByStep.values()) {
971
+ const identity = coerceIdentity(item.identity || item.modelId)
972
+ if (seen.has(identity.identityKey)) continue
973
+ seen.add(identity.identityKey)
974
+ const resolved = resolveCurrentPricing(identity)
975
+ rows.push({ identityKey: identity.identityKey, provider: identity.provider, requestedModel: identity.requestedModel, actualModel: identity.actualModel, model: identity.label, status: resolved.status, reason: resolved.reason || '', pricingModel: resolved.pricingModel || null, providerId: resolved.providerId || null, source: resolved.source || 'none', currency: resolved.currency || 'USD', rates: resolved.rates || null, tiered: resolved.tiered === true })
976
+ if (rows.length >= 500) break
977
+ }
978
+ rows.sort((a, b) => String(a.model).localeCompare(String(b.model)))
979
+ return rows
980
+ }
981
+ function pricingModelSearch(query, limit = 20) {
982
+ const raw = typeof query === 'string' ? query.trim().toLowerCase() : ''
983
+ if (raw === '') return []
984
+ const normalized = raw.replace(/\s+/g, ' ')
985
+ const selected = new Map()
986
+ for (const entry of pricingState.catalogEntries) {
987
+ const official = officialProviderIds(entry.modelId)
988
+ if (!official.has(String(entry.providerId || '').toLowerCase())) continue
989
+ const modelId = entry.modelId.toLowerCase()
990
+ const displayName = String(entry.displayName || '').toLowerCase()
991
+ if (!modelId.includes(normalized) && !displayName.includes(normalized)) continue
992
+ const score = modelId === normalized ? 0 : modelId.startsWith(normalized) ? 1 : displayName.startsWith(normalized) ? 2 : 3
993
+ const previous = selected.get(entry.modelId)
994
+ if (previous === undefined || score < previous.score) selected.set(entry.modelId, { value: entry.modelId, label: entry.displayName, providerId: entry.providerId, score })
995
+ }
996
+ return Array.from(selected.values()).sort((a, b) => a.score - b.score || a.value.localeCompare(b.value)).slice(0, Math.max(1, Math.min(50, Number.isInteger(limit) ? limit : 20))).map(({ score, ...entry }) => entry)
997
+ }
998
+ function pricingSnapshot() {
999
+ const state = pricingState
1000
+ const used = usedPricingModels()
1001
+ return {
1002
+ schemaVersion: state.schemaVersion,
1003
+ source: { ...state.source },
1004
+ sync: { ...state.sync },
1005
+ catalogModelCount: state.catalogEntries.length,
1006
+ overrideCount: state.overrides.length,
1007
+ mappingCount: state.mappings.length,
1008
+ configured: state.catalogEntries.length > 0 || state.overrides.length > 0 || state.mappings.length > 0,
1009
+ config: { sync: { ...state.sync }, mappings: state.mappings.map(({ provider, identityKey, ...mapping }) => ({ ...mapping })), overrides: state.overrides.map(({ providerId, ...entry }) => ({ ...entry })) },
1010
+ usedModels: used,
1011
+ cost: serializeCostAggregate(totals.cost),
1012
+ }
1013
+ }
1014
+ async function loadPricing() {
1015
+ if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
1016
+ try {
1017
+ const backend = storage.backend.get('json')
1018
+ if (backend === undefined || backend === null || backend.kv === undefined) return
1019
+ const unit = await backend.kv.open({ name: 'all_usage_pricing', version: 0, tables: [], hasGlobal: true })
1020
+ if (disposed) { await unit.close().catch(() => {}); return }
1021
+ pricingUnit = unit
1022
+ const snapshot = await unit.loadAll()
1023
+ const global = snapshot && snapshot.global
1024
+ const raw = global && typeof global === 'object' && global.pricing !== undefined ? global.pricing : global
1025
+ pricingState = normalizePricingState(raw)
1026
+ pricingResolutionCache.clear()
1027
+ } catch (err) {
1028
+ console.error('[all-usage] pricing catalog unavailable:', err)
1029
+ }
1030
+ }
1031
+ function persistPricing() {
1032
+ if (disposed) return pricingWriteChain
1033
+ const payload = { pricing: serializePricingState(pricingState) }
1034
+ pricingWriteChain = pricingWriteChain.then(async () => {
1035
+ if (pricingUnit === null || pricingUnit === undefined) return
1036
+ await pricingUnit.setGlobal(payload)
1037
+ })
1038
+ pricingWriteChain = pricingWriteChain.catch((err) => {
1039
+ console.error('[all-usage] pricing persist failed:', err)
1040
+ })
1041
+ return pricingWriteChain
1042
+ }
1043
+ async function syncPricing(force = false) {
1044
+ await pricingReady
1045
+ if (pricingSyncInFlight) return { ok: false, message: 'pricing-sync-in-progress', pricing: pricingSnapshot() }
1046
+ const now = Date.now()
1047
+ if (!force && pricingState.sync.lastSuccessAt > 0 && now - pricingState.sync.lastSuccessAt < pricingState.sync.intervalMs) return { ok: true, skipped: true, pricing: pricingSnapshot() }
1048
+ pricingSyncInFlight = true
1049
+ pricingState.sync.lastAttemptAt = now
1050
+ markStatsChanged()
1051
+ try {
1052
+ const result = await fetchModelsDevCatalog()
1053
+ if (!result.ok) {
1054
+ pricingState.source.lastError = result.error
1055
+ pricingState.sync.lastError = result.error
1056
+ markStatsChanged()
1057
+ await persistPricing()
1058
+ return { ok: false, message: result.error, pricing: pricingSnapshot() }
1059
+ }
1060
+ pricingState = normalizePricingState({
1061
+ ...serializePricingState(pricingState),
1062
+ source: { url: result.catalog.sourceUrl, fetchedAt: result.catalog.fetchedAt, catalogHash: result.catalog.catalogHash, lastError: '' },
1063
+ sync: { ...pricingState.sync, lastSuccessAt: result.catalog.fetchedAt, lastError: '' },
1064
+ catalogEntries: result.catalog.entries,
1065
+ })
1066
+ pricingResolutionCache.clear()
1067
+ const backfill = backfillUnpricedCosts()
1068
+ markStatsChanged()
1069
+ await persistPricing()
1070
+ await ledgerWriteChain
1071
+ return { ok: true, skipped: false, backfill, pricing: pricingSnapshot() }
1072
+ } finally {
1073
+ pricingSyncInFlight = false
1074
+ schedulePricingSync()
1075
+ }
1076
+ }
1077
+ function backfillUnpricedCosts() {
1078
+ let considered = 0
1079
+ let priced = 0
1080
+ const touched = new Set()
1081
+ for (const item of usageByStep.values()) {
1082
+ const oldCost = normalizeCostSnapshot(item.cost)
1083
+ if (oldCost !== null && oldCost.pricingMode === 'official-model' && oldCost.status === 'priced') continue
1084
+ considered += 1
1085
+ const next = calculateCost(item.values, resolveCurrentPricing(item.identity || item.modelId))
1086
+ if (next.status !== 'priced') continue
1087
+ if (oldCost !== null) adjustCostOnly(item, oldCost, -1)
1088
+ item.cost = next
1089
+ adjustCostOnly(item, next, 1)
1090
+ const record = ledgerRecords.get(item.sid)
1091
+ if (record !== undefined) {
1092
+ const stored = record.usage.find((candidate) => candidate.key === item.key)
1093
+ if (stored !== undefined) { stored.cost = next; touched.add(record) }
1094
+ }
1095
+ priced += 1
1096
+ }
1097
+ for (const record of touched) {
1098
+ record.version = LEDGER_VERSION
1099
+ record.updatedAt = nextLedgerRevision()
1100
+ record.needsUpgrade = false
1101
+ void persistLedgerRecord(record)
1102
+ }
1103
+ let remaining = 0
1104
+ for (const item of usageByStep.values()) {
1105
+ const cost = normalizeCostSnapshot(item.cost)
1106
+ if (cost === null || cost.status !== 'priced') remaining += 1
1107
+ }
1108
+ return { considered, priced, remaining }
1109
+ }
1110
+ function updatePricingState(raw, backfill) {
1111
+ const input = raw && typeof raw === 'object' && raw.pricing && typeof raw.pricing === 'object' ? raw.pricing : raw
1112
+ const current = serializePricingState(pricingState)
1113
+ const merged = { ...current, ...(input && typeof input === 'object' ? input : {}) }
1114
+ if (input && typeof input === 'object' && input.sync && typeof input.sync === 'object' && !Array.isArray(input.sync)) merged.sync = { ...current.sync, ...input.sync }
1115
+ pricingState = normalizePricingState(merged)
1116
+ pricingResolutionCache.clear()
1117
+ const result = backfill === true ? backfillUnpricedCosts() : { considered: 0, priced: 0, remaining: totals.cost.unpricedCalls + totals.cost.ambiguousCalls + totals.cost.unsupportedCalls }
1118
+ markStatsChanged()
1119
+ schedulePricingSync()
1120
+ return result
1121
+ }
1122
+ function schedulePricingSync() {
1123
+ if (pricingSyncTimer !== null) { clearTimeout(pricingSyncTimer); pricingSyncTimer = null }
1124
+ if (disposed || pricingState.sync.autoEnabled !== true) return
1125
+ const elapsed = pricingState.sync.lastSuccessAt > 0 ? Date.now() - pricingState.sync.lastSuccessAt : pricingState.sync.intervalMs
1126
+ const delay = Math.max(0, pricingState.sync.intervalMs - elapsed)
1127
+ pricingSyncTimer = setTimeout(() => {
1128
+ pricingSyncTimer = null
1129
+ void syncPricing(true)
1130
+ }, delay)
1131
+ if (pricingSyncTimer && typeof pricingSyncTimer.unref === 'function') pricingSyncTimer.unref()
1132
+ }
1133
+
560
1134
  // ---------- baseline scan over durable logs ----------
561
1135
  function scheduleNativeBaselineRetry(generation, delay) {
562
1136
  if (disposed || baselineFallbackTimer !== null) return
@@ -572,9 +1146,10 @@ function apply(ctx) {
572
1146
  const delay = baselineRetryDelay
573
1147
  baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
574
1148
  void safeContextTimeout(delay).then((ready) => {
1149
+ if (generation !== aggregationGeneration) return undefined
575
1150
  baselineRetryScheduled = false
576
- if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
577
- if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
1151
+ if (ready && !scan.started && !scan.done) return runBaseline(generation)
1152
+ if (!ready && !disposed) scheduleNativeBaselineRetry(generation, delay)
578
1153
  return undefined
579
1154
  })
580
1155
  }
@@ -582,7 +1157,7 @@ function apply(ctx) {
582
1157
  if (scan.started || disposed || generation !== aggregationGeneration) return
583
1158
  scan.started = true
584
1159
  beginSync()
585
- await ledgerReady
1160
+ await Promise.all([ledgerReady, pricingReady])
586
1161
  if (disposed || generation !== aggregationGeneration) return
587
1162
  let setupFailed = false
588
1163
  try {
@@ -654,7 +1229,7 @@ function apply(ctx) {
654
1229
  }
655
1230
  for (const [sid, record] of ledgerRecords) {
656
1231
  if (!listedSessionIds.has(sid)) {
657
- applyLedgerRecord(record)
1232
+ applyLedgerRecord(record, 'ledger-recovery')
658
1233
  if (record.turns.length > 0 || record.usage.length > 0) sync.sessionsRestoredFromLedger += 1
659
1234
  }
660
1235
  }
@@ -678,18 +1253,15 @@ function apply(ctx) {
678
1253
  await enqueue(sid, async () => {
679
1254
  if (disposed || generation !== aggregationGeneration) return
680
1255
  try {
681
- if (sessionSeq.has(sid)) return
1256
+ if (sessionSeq.has(sid) && !liveResyncPending.has(sid)) return
682
1257
  // v1.0.8: when the persisted log revision is unchanged since the last ledger
683
1258
  // write, the whole readSession (full event transfer) is skipped — the ledger
684
1259
  // record is applied directly and the live feed keeps catching new events.
685
1260
  const previousRecord = ledgerRecords.get(sid)
686
1261
  const revision = snapshots === null ? undefined : snapshots.get(sid)
687
- if (previousRecord !== undefined && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
1262
+ if (!liveResyncPending.has(sid) && previousRecord !== undefined && previousRecord.needsUpgrade !== true && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
688
1263
  sync.sessionsSkippedByRevision += 1
689
- applyLedgerRecord(previousRecord)
690
- for (const item of previousRecord.usage) {
691
- usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
692
- }
1264
+ applyLedgerRecord(previousRecord, 'ledger-reuse')
693
1265
  sessionSeq.set(sid, previousRecord.lastSeq)
694
1266
  sessionCount.add(sid)
695
1267
  markStatsChanged()
@@ -705,53 +1277,43 @@ function apply(ctx) {
705
1277
  // session applies its canonical record directly and never re-folds;
706
1278
  // a changed session seeds the previous record once, then folds only the
707
1279
  // new tail (previously every listed session was re-read and fully rebuilt).
708
- const currentLastSeq = lastSeqOf(snap.events)
1280
+ const sequence = sequenceProfile(snap.events)
1281
+ const currentLastSeq = sequence.lastSeq
709
1282
  const previous = ledgerRecords.get(sid)
710
- if (previous !== undefined) {
711
- applyLedgerRecord(previous)
712
- // Seed usageByStep so a later live retry of an already-recorded step
713
- // reverses the ledger contribution instead of double-counting it.
714
- for (const item of previous.usage) {
715
- usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
716
- }
717
- if (previous.lastSeq >= currentLastSeq) {
718
- // Content unchanged despite a revision change (rare: ctime-only churn):
719
- // refresh the stored revision so future restarts can skip the read.
720
- if (revision !== undefined && previous.lastRevision !== revision) {
721
- previous.lastRevision = revision
722
- void persistLedgerRecord(previous)
723
- }
724
- sessionSeq.set(sid, previous.lastSeq)
725
- sessionCount.add(sid)
726
- return
727
- }
728
- foldEvents(wsId, snap.events, previous.lastSeq, sid)
1283
+ const canFoldTail = previous !== undefined && previous.needsUpgrade !== true && !sequence.nonMonotonic && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
1284
+ if (canFoldTail) {
1285
+ applyLedgerRecord(previous, 'ledger-reuse')
1286
+ foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
729
1287
  } else {
730
- foldEvents(wsId, snap.events, undefined, sid)
1288
+ // A changed revision with no new tail may still contain a replacement;
1289
+ // rebuild from the complete read instead of trusting lastSeq alone.
1290
+ foldEvents(wsId, snap.events, undefined, sid, 'scan')
731
1291
  }
732
- const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision)
733
- const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
1292
+ const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision, previous)
1293
+ const canonical = ledger === null ? ledgerRecords.get(sid) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
734
1294
  if (canonical === ledger) {
735
1295
  void persistLedgerRecord(ledger)
736
- } else if (canonical !== undefined) {
737
- applyLedgerRecord(canonical)
738
1296
  }
739
- sessionSeq.set(sid, currentLastSeq)
1297
+ const observedLastSeq = sessionSeq.get(sid)
1298
+ const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
1299
+ sessionSeq.set(sid, nextLastSeq)
740
1300
  sessionCount.add(sid)
1301
+ if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
1302
+ else scheduleLiveResync(sid, wsId, generation)
741
1303
  }
742
1304
  } catch (err) {
743
1305
  if (generation !== aggregationGeneration) return
744
1306
  sync.sessionsFailed += 1
1307
+ scan.failed += 1
745
1308
  noteSyncError('session-read-failed')
746
1309
  const saved = ledgerRecords.get(sid)
747
1310
  if (saved !== undefined) {
748
- applyLedgerRecord(saved)
1311
+ applyLedgerRecord(saved, 'ledger-recovery')
749
1312
  if (saved.turns.length > 0 || saved.usage.length > 0) sync.sessionsRestoredFromLedger += 1
750
1313
  sessionSeq.set(sid, -1)
751
1314
  sessionCount.add(sid)
752
1315
  } else {
753
1316
  sessionSeq.set(sid, -1)
754
- scan.failed += 1
755
1317
  }
756
1318
  } finally {
757
1319
  if (generation === aggregationGeneration) {
@@ -770,6 +1332,12 @@ function apply(ctx) {
770
1332
  if (disposed || generation !== aggregationGeneration) return
771
1333
  await ledgerWriteChain
772
1334
  if (disposed || generation !== aggregationGeneration) return
1335
+ const costBackfill = backfillUnpricedCosts()
1336
+ if (costBackfill.priced > 0) {
1337
+ await ledgerWriteChain
1338
+ markStatsChanged()
1339
+ }
1340
+ if (disposed || generation !== aggregationGeneration) return
773
1341
  knownSessionIds.clear()
774
1342
  for (const sid of listedSessionIds) knownSessionIds.add(sid)
775
1343
  scan.done = true
@@ -826,11 +1394,13 @@ function apply(ctx) {
826
1394
  reconcilePending = true
827
1395
  if (reconcileHintScheduled || reconcileInFlight) return
828
1396
  reconcileHintScheduled = true
1397
+ const generation = aggregationGeneration
829
1398
  void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
1399
+ if (generation !== aggregationGeneration) return
830
1400
  reconcileHintScheduled = false
831
1401
  if (ready && !disposed) void reconcileSessions()
832
1402
  }, () => {
833
- reconcileHintScheduled = false
1403
+ if (generation === aggregationGeneration) reconcileHintScheduled = false
834
1404
  })
835
1405
  }
836
1406
  function scheduleReconcileTimer() {
@@ -861,11 +1431,11 @@ function apply(ctx) {
861
1431
  })
862
1432
  ctx.on('session/flush', async (session) => {
863
1433
  if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
864
- await ledgerReady
1434
+ await Promise.all([ledgerReady, pricingReady])
865
1435
  if (disposed) return
866
1436
  const wsId = wsForLiveSession(session, session.id)
867
1437
  if (wsId === undefined) return
868
- const ledger = buildLedgerRecord(session, wsId, 'flush')
1438
+ const ledger = buildLedgerRecord(session, wsId, 'flush', undefined, ledgerRecords.get(session.id))
869
1439
  if (ledger === null) return
870
1440
  const canonical = storeLedgerRecord(ledger)
871
1441
  if (canonical === ledger) await persistLedgerRecord(ledger)
@@ -881,6 +1451,7 @@ function apply(ctx) {
881
1451
  const backend = storage.backend.get('json')
882
1452
  if (backend === undefined || backend === null || backend.kv === undefined) return
883
1453
  const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
1454
+ if (disposed) { await unit.close().catch(() => {}); return }
884
1455
  kvUnit = unit
885
1456
  const snap = await unit.loadAll()
886
1457
  const g = snap && snap.global
@@ -896,6 +1467,7 @@ function apply(ctx) {
896
1467
  }
897
1468
  }
898
1469
  function persistAliases() {
1470
+ if (disposed) return aliasWriteChain
899
1471
  const snapshotAliases = {}
900
1472
  for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
901
1473
  aliasWriteChain = aliasWriteChain.then(() => {
@@ -906,7 +1478,9 @@ function apply(ctx) {
906
1478
  })
907
1479
  }
908
1480
  function setAlias(wsId, raw) {
909
- const alias = typeof raw === 'string' ? raw.trim().slice(0, 80) : ''
1481
+ if (typeof wsId !== 'string' || wsId.length === 0 || wsId.length > 256) return { ok: false, message: 'invalid-workspace', aliases: Object.assign({}, aliases) }
1482
+ if (typeof raw !== 'string') return { ok: false, message: 'invalid-alias', aliases: Object.assign({}, aliases) }
1483
+ const alias = raw.trim().slice(0, 80)
910
1484
  if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
911
1485
  if (alias === '') delete aliases[wsId]
912
1486
  else aliases[wsId] = alias
@@ -914,7 +1488,7 @@ function apply(ctx) {
914
1488
  markStatsChanged()
915
1489
  return { ok: true, aliases: Object.assign({}, aliases) }
916
1490
  }
917
- ctx.effect(() => () => {
1491
+ ctx.effect(() => async () => {
918
1492
  disposed = true
919
1493
  if (reconcileTimer !== null) {
920
1494
  clearTimeout(reconcileTimer)
@@ -924,17 +1498,22 @@ function apply(ctx) {
924
1498
  clearTimeout(baselineFallbackTimer)
925
1499
  baselineFallbackTimer = null
926
1500
  }
927
- })
928
- ctx.effect(() => () => {
929
- const unit = kvUnit
1501
+ if (pricingSyncTimer !== null) {
1502
+ clearTimeout(pricingSyncTimer)
1503
+ pricingSyncTimer = null
1504
+ }
1505
+ for (const timer of liveResyncTimers.values()) clearTimeout(timer)
1506
+ liveResyncTimers.clear()
1507
+ liveResyncAttempts.clear()
1508
+ liveResyncPending.clear()
1509
+ baselineRetryScheduled = false
1510
+ chains.clear()
1511
+ await Promise.all([aliasesReady, ledgerReady, pricingReady, aliasWriteChain, ledgerWriteChain, pricingWriteChain])
1512
+ const units = [kvUnit, ledgerUnit, pricingUnit]
930
1513
  kvUnit = null
931
- if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
932
- })
933
- ctx.effect(() => async () => {
934
- const unit = ledgerUnit
935
- await ledgerWriteChain
936
- if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
937
- if (ledgerUnit === unit) ledgerUnit = null
1514
+ ledgerUnit = null
1515
+ pricingUnit = null
1516
+ await Promise.all(units.map((unit) => unit === null || unit === undefined ? undefined : unit.close().catch(() => {})))
938
1517
  })
939
1518
 
940
1519
  // ---------- snapshot for the client ----------
@@ -942,8 +1521,16 @@ function apply(ctx) {
942
1521
  return { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed }
943
1522
  }
944
1523
  function statusSnapshot() {
1524
+ commitPendingStats()
945
1525
  return { instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scan: scanSnapshot(), sync: syncSnapshot() }
946
1526
  }
1527
+ function serializeIdentity(identity) {
1528
+ const value = coerceIdentity(identity)
1529
+ return { identityKey: value.identityKey, provider: value.provider, requestedModel: value.requestedModel, actualModel: value.actualModel, model: value.label, legacy: value.legacy }
1530
+ }
1531
+ function serializeModelAggregate(item) {
1532
+ 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) }
1533
+ }
947
1534
  function serializeDays(dayMap) {
948
1535
  const result = []
949
1536
  for (const pair of dayMap) {
@@ -955,21 +1542,28 @@ function apply(ctx) {
955
1542
  sessions: day.sessionIds.size,
956
1543
  sessionIds: Array.from(day.sessionIds).sort(),
957
1544
  tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
1545
+ cost: serializeCostAggregate(day.cost),
958
1546
  perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
959
- 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 })),
960
- byModel: Array.from(day.byModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
1547
+ 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) })),
1548
+ byModel: Array.from(day.byModel, (p) => serializeModelAggregate(p[1])),
961
1549
  })
962
1550
  }
963
1551
  result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
964
1552
  return result
965
1553
  }
966
1554
  function snapshot() {
967
- return {
1555
+ commitPendingStats()
1556
+ const generatedAt = Date.now()
1557
+ if (snapshotCache !== null && snapshotCache.revision === statsRevision) return Object.assign({}, snapshotCache.value, { generatedAt })
1558
+ const value = {
968
1559
  ...statusSnapshot(),
969
- generatedAt: Date.now(),
1560
+ generatedAt,
1561
+ usageSchemaVersion: 3,
1562
+ costSchemaVersion: COST_SCHEMA_VERSION,
970
1563
  requestToken,
971
1564
  workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
972
1565
  aliases: Object.assign({}, aliases),
1566
+ pricing: pricingSnapshot(),
973
1567
  tokenSemantics: {
974
1568
  processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
975
1569
  cacheRead: 'reused context tokens; not newly generated output',
@@ -987,12 +1581,283 @@ function apply(ctx) {
987
1581
  gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
988
1582
  },
989
1583
  },
990
- totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
991
- perWorkspace: Array.from(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 })),
992
- perModel: Array.from(perModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
1584
+ costSemantics: {
1585
+ source: 'models.dev',
1586
+ currency: 'USD',
1587
+ buckets: ['input', 'output', 'cacheRead', 'cacheWrite'],
1588
+ input: 'fresh (DSH TokenUsage already excludes cache)',
1589
+ reasoning: 'not added to output again; provider output already carries completion/thoughts where reported',
1590
+ multiplier: 'applies only to final total',
1591
+ providerMatching: 'DSH provider is ignored; only the official model vendor entry is selected',
1592
+ historical: 'positive cost snapshots are stable; only unresolved usage is eligible for backfill',
1593
+ },
1594
+ totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning, cost: serializeCostAggregate(totals.cost) },
1595
+ perWorkspace: Array.from(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) })),
1596
+ perModel: Array.from(perModel.values(), (item) => serializeModelAggregate(item)),
993
1597
  byDay: serializeDays(byDay),
994
1598
  byDayUtc: serializeDays(byDayUtc),
995
1599
  }
1600
+ snapshotCache = { revision: statsRevision, value }
1601
+ return Object.assign({}, value, { generatedAt })
1602
+ }
1603
+
1604
+ function validDateText(value) {
1605
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
1606
+ const year = Number(value.slice(0, 4))
1607
+ const month = Number(value.slice(5, 7))
1608
+ const day = Number(value.slice(8, 10))
1609
+ const date = new Date(Date.UTC(year, month - 1, day))
1610
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
1611
+ }
1612
+ function shiftDateText(value, days, utc) {
1613
+ const parts = value.split('-').map(Number)
1614
+ 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)
1615
+ return utc ? dayKeyUtc(date.getTime()) : dayKey(date.getTime())
1616
+ }
1617
+ function queryScopeFromRequest(req) {
1618
+ let url
1619
+ try { url = new URL(req.url || '/', 'http://all-usage.local') } catch (err) { return { ok: false, message: 'bad-query' } }
1620
+ const rawUtc = url.searchParams.get('utc')
1621
+ if (rawUtc !== null && rawUtc !== '' && rawUtc !== '0' && rawUtc !== '1') return { ok: false, message: 'invalid-timezone' }
1622
+ const utc = rawUtc === '1'
1623
+ const today = utc ? dayKeyUtc(Date.now()) : dayKey(Date.now())
1624
+ const start = url.searchParams.get('start') || today
1625
+ const end = url.searchParams.get('end') || today
1626
+ if (!validDateText(start) || !validDateText(end) || start > end) return { ok: false, message: 'invalid-date-range' }
1627
+ const readParam = (name, max) => {
1628
+ const value = url.searchParams.get(name)
1629
+ if (value === null || value === '') return undefined
1630
+ return value.length <= max ? value : null
1631
+ }
1632
+ const workspaceId = readParam('workspaceId', 256)
1633
+ const provider = readParam('provider', 256)
1634
+ const modelKey = readParam('modelKey', 1024)
1635
+ if (workspaceId === null || provider === null || modelKey === null) return { ok: false, message: 'query-too-long' }
1636
+ return { ok: true, scope: { start, end, utc, workspaceId, provider, modelKey } }
1637
+ }
1638
+ function scopeFingerprint(scope) {
1639
+ 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 })
1640
+ }
1641
+ function dateInScope(date, scope) {
1642
+ return date >= scope.start && date <= scope.end
1643
+ }
1644
+ function modelNameOfIdentity(identity) {
1645
+ const normalized = coerceIdentity(identity)
1646
+ const structured = normalized.actualModel || normalized.requestedModel
1647
+ if (structured !== null) return structured
1648
+ if (normalized.legacy && typeof normalized.label === 'string') {
1649
+ const separator = normalized.label.indexOf(' / ')
1650
+ if (separator > 0) return normalized.label.slice(separator + 3)
1651
+ }
1652
+ return normalized.label
1653
+ }
1654
+ function identityMatchesScope(identity, scope) {
1655
+ const normalized = coerceIdentity(identity)
1656
+ if (scope.provider !== undefined && scope.provider !== null && normalized.provider !== scope.provider) return false
1657
+ if (scope.modelKey !== undefined && scope.modelKey !== null && normalized.identityKey !== scope.modelKey && modelNameOfIdentity(normalized) !== scope.modelKey) return false
1658
+ return true
1659
+ }
1660
+ function queryMetric() {
1661
+ return { turns: 0, calls: 0, sessions: new Set(), turnKeys: new Set(), input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
1662
+ }
1663
+ function queryAggregate() {
1664
+ return { totals: queryMetric(), days: new Map(), workspaces: new Map(), models: new Map() }
1665
+ }
1666
+ function queryDay(aggregate, date) {
1667
+ let day = aggregate.days.get(date)
1668
+ if (day === undefined) { day = queryMetric(); day.date = date; aggregate.days.set(date, day) }
1669
+ return day
1670
+ }
1671
+ function queryWorkspace(aggregate, workspaceId) {
1672
+ let row = aggregate.workspaces.get(workspaceId)
1673
+ if (row === undefined) { row = queryMetric(); row.workspaceId = workspaceId; aggregate.workspaces.set(workspaceId, row) }
1674
+ return row
1675
+ }
1676
+ function queryModel(aggregate, identity) {
1677
+ const normalized = coerceIdentity(identity)
1678
+ let row = aggregate.models.get(normalized.identityKey)
1679
+ if (row === undefined) { row = queryMetric(); Object.assign(row, serializeIdentity(normalized)); aggregate.models.set(normalized.identityKey, row) }
1680
+ return row
1681
+ }
1682
+ function addQueryTokens(metric, values) {
1683
+ metric.input += values.input
1684
+ metric.output += values.output
1685
+ metric.cacheRead += values.cacheRead
1686
+ metric.cacheWrite += values.cacheWrite
1687
+ metric.reasoning += values.reasoning
1688
+ }
1689
+ function addQueryTurn(aggregate, turn, date) {
1690
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, turn.wsId)]
1691
+ const model = queryModel(aggregate, turn.identity)
1692
+ targets.push(model)
1693
+ const key = turn.key
1694
+ for (const target of targets) {
1695
+ if (target.turnKeys.has(key)) continue
1696
+ target.turnKeys.add(key)
1697
+ target.turns += 1
1698
+ target.sessions.add(turn.sid)
1699
+ }
1700
+ }
1701
+ function addQueryUsage(aggregate, item, date) {
1702
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, item.wsId)]
1703
+ const model = queryModel(aggregate, item.identity || item.modelId)
1704
+ targets.push(model)
1705
+ for (const target of targets) {
1706
+ target.calls += 1
1707
+ target.sessions.add(item.sid)
1708
+ addQueryTokens(target, item.values)
1709
+ addCostAggregate(target.cost, item.cost)
1710
+ }
1711
+ }
1712
+ function finalizeQueryMetric(metric) {
1713
+ 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) }
1714
+ }
1715
+ function finalizeQueryAggregate(aggregate) {
1716
+ 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 } }))
1717
+ const perWorkspace = Array.from(aggregate.workspaces.values()).map((row) => ({ workspaceId: row.workspaceId, ...finalizeQueryMetric(row) }))
1718
+ 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) }))
1719
+ return { totals: finalizeQueryMetric(aggregate.totals), daily, perWorkspace, perModel }
1720
+ }
1721
+ const HOUR_MS = 60 * 60 * 1000
1722
+ function hourStartOf(time, utc) {
1723
+ const date = new Date(time)
1724
+ return utc ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours()) : new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours()).getTime()
1725
+ }
1726
+ function calendarStartOf(dateText, utc) {
1727
+ const parts = dateText.split('-').map(Number)
1728
+ return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2]) : new Date(parts[0], parts[1] - 1, parts[2]).getTime()
1729
+ }
1730
+ function nextCalendarStartOf(dateText, utc) {
1731
+ const parts = dateText.split('-').map(Number)
1732
+ return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2] + 1) : new Date(parts[0], parts[1] - 1, parts[2] + 1).getTime()
1733
+ }
1734
+ function hourlyRangeOf(scope, nowMs) {
1735
+ if (scope.start !== scope.end) return null
1736
+ const start = calendarStartOf(scope.start, scope.utc)
1737
+ const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
1738
+ const end = scope.start === today ? nowMs : nextCalendarStartOf(scope.start, scope.utc)
1739
+ return { start, count: Math.max(1, Math.ceil(Math.max(0, end - start) / HOUR_MS)) }
1740
+ }
1741
+ function serializeTrendMetric(time, metric) {
1742
+ const value = finalizeQueryMetric(metric || queryMetric())
1743
+ return { time, date: new Date(time).toISOString(), ...value, tokens: { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite, reasoning: value.reasoning } }
1744
+ }
1745
+ function queryHourlyTrend(matchingUsage, matchingTurns, scope, nowMs) {
1746
+ const range = hourlyRangeOf(scope, nowMs)
1747
+ if (range === null) return []
1748
+ const buckets = new Map()
1749
+ const metricFor = (time) => {
1750
+ const index = Math.floor((hourStartOf(time, scope.utc) - range.start) / HOUR_MS)
1751
+ if (index < 0 || index >= range.count) return null
1752
+ let metric = buckets.get(index)
1753
+ if (metric === undefined) { metric = queryMetric(); buckets.set(index, metric) }
1754
+ return metric
1755
+ }
1756
+ for (const entry of matchingUsage) {
1757
+ if (!dateInScope(entry.date, scope)) continue
1758
+ const metric = metricFor(entry.item.time)
1759
+ if (metric === null) continue
1760
+ metric.calls += 1
1761
+ metric.sessions.add(entry.item.sid)
1762
+ addQueryTokens(metric, entry.item.values)
1763
+ addCostAggregate(metric.cost, entry.item.cost)
1764
+ }
1765
+ for (const entry of matchingTurns) {
1766
+ if (!dateInScope(entry.date, scope)) continue
1767
+ const metric = metricFor(entry.turn.time)
1768
+ if (metric === null || metric.turnKeys.has(entry.turn.key)) continue
1769
+ metric.turnKeys.add(entry.turn.key)
1770
+ metric.turns += 1
1771
+ metric.sessions.add(entry.turn.sid)
1772
+ }
1773
+ return Array.from({ length: range.count }, (_, index) => serializeTrendMetric(range.start + index * HOUR_MS, buckets.get(index)))
1774
+ }
1775
+ function queryUsageScope(scope) {
1776
+ commitPendingStats()
1777
+ const nowMs = Date.now()
1778
+ const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
1779
+ const hourlyCacheKey = scope.start === scope.end ? ':' + (scope.start === today ? hourStartOf(nowMs, scope.utc) : 'fixed') : ''
1780
+ const key = statsRevision + ':' + scopeFingerprint(scope) + hourlyCacheKey
1781
+ const cached = queryCache.get(key)
1782
+ if (cached !== undefined) return cached
1783
+ const now = new Date(nowMs)
1784
+ const weekday = scope.utc ? now.getUTCDay() : now.getDay()
1785
+ const sunday = shiftDateText(today, -weekday, scope.utc)
1786
+ const heatStart = shiftDateText(sunday, -52 * 7, scope.utc)
1787
+ const matchingUsage = []
1788
+ const matchingTurnKeys = new Set()
1789
+ const usageIndex = scope.utc ? usageByUtcDate : usageByLocalDate
1790
+ for (const indexed of indexedEntries(usageIndex, usageByStep, scope, heatStart, today)) {
1791
+ const item = indexed.item
1792
+ const date = indexed.date
1793
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) continue
1794
+ if (!identityMatchesScope(item.identity || item.modelId, scope)) continue
1795
+ matchingUsage.push({ item, date })
1796
+ if (item.turn !== null && item.turn !== undefined) matchingTurnKeys.add(item.sid + ':turn:' + item.turn)
1797
+ }
1798
+ const matchingTurns = []
1799
+ const turnIndex = scope.utc ? turnsByUtcDate : turnsByLocalDate
1800
+ for (const indexed of indexedEntries(turnIndex, turnRecords, scope, heatStart, today)) {
1801
+ const turn = indexed.item
1802
+ const date = indexed.date
1803
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && turn.wsId !== scope.workspaceId) continue
1804
+ if ((scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)) {
1805
+ if (!identityMatchesScope(turn.identity, scope) && !matchingTurnKeys.has(turn.key)) continue
1806
+ }
1807
+ matchingTurns.push({ turn, date })
1808
+ }
1809
+ const selected = queryAggregate()
1810
+ const heatmap = queryAggregate()
1811
+ for (const entry of matchingUsage) {
1812
+ if (dateInScope(entry.date, scope)) addQueryUsage(selected, entry.item, entry.date)
1813
+ if (entry.date >= heatStart && entry.date <= today) addQueryUsage(heatmap, entry.item, entry.date)
1814
+ }
1815
+ for (const entry of matchingTurns) {
1816
+ if (dateInScope(entry.date, scope)) addQueryTurn(selected, entry.turn, entry.date)
1817
+ if (entry.date >= heatStart && entry.date <= today) addQueryTurn(heatmap, entry.turn, entry.date)
1818
+ }
1819
+ const hourly = queryHourlyTrend(matchingUsage, matchingTurns, scope, nowMs)
1820
+ const result = { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scope: JSON.parse(scopeFingerprint(scope)), partial: !scan.done, completeThrough: { revision: statsRevision, at: statsUpdatedAt }, ...finalizeQueryAggregate(selected), hourly, heatmap: finalizeQueryAggregate(heatmap).daily }
1821
+ queryCache.set(key, result)
1822
+ while (queryCache.size > 20) queryCache.delete(queryCache.keys().next().value)
1823
+ return result
1824
+ }
1825
+ function opaqueRecordId(item) {
1826
+ return createHash('sha256').update(item.sid + '\0' + item.key).digest('hex').slice(0, 20)
1827
+ }
1828
+ function recordOrder(a, b) {
1829
+ 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)
1830
+ }
1831
+ function queryRecords(scope, cursor, limit) {
1832
+ commitPendingStats()
1833
+ const fingerprint = scopeFingerprint(scope)
1834
+ let offset = 0
1835
+ if (cursor !== undefined && cursor !== '') {
1836
+ try {
1837
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
1838
+ if (decoded.revision !== statsRevision || decoded.scope !== fingerprint || !Number.isInteger(decoded.offset) || decoded.offset < 0) return { error: 'stale-cursor' }
1839
+ offset = decoded.offset
1840
+ } catch (err) { return { error: 'bad-cursor' } }
1841
+ }
1842
+ const cacheKey = statsRevision + ':' + fingerprint
1843
+ let rows = recordsQueryCache.get(cacheKey)
1844
+ if (rows === undefined) {
1845
+ const index = scope.utc ? usageByUtcDate : usageByLocalDate
1846
+ rows = indexedEntriesInRange(index, usageByStep, scope.start, scope.end).filter(({ item }) => {
1847
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) return false
1848
+ return identityMatchesScope(item.identity || item.modelId, scope)
1849
+ })
1850
+ rows.sort((left, right) => recordOrder(left.item, right.item))
1851
+ recordsQueryCache.set(cacheKey, rows)
1852
+ while (recordsQueryCache.size > 20) recordsQueryCache.delete(recordsQueryCache.keys().next().value)
1853
+ }
1854
+ const page = rows.slice(offset, offset + limit)
1855
+ const items = page.map(({ item, date }) => {
1856
+ const identity = coerceIdentity(item.identity || item.modelId)
1857
+ 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' }
1858
+ })
1859
+ const nextOffset = offset + items.length
1860
+ return { schemaVersion: 1, usageSchemaVersion: 3, costSchemaVersion: COST_SCHEMA_VERSION, instanceId, revision: statsRevision, scope: JSON.parse(fingerprint), items, hasMore: nextOffset < rows.length, nextCursor: nextOffset < rows.length ? Buffer.from(JSON.stringify({ revision: statsRevision, scope: fingerprint, offset: nextOffset })).toString('base64url') : null }
996
1861
  }
997
1862
 
998
1863
  // ---------- account balance (DeepSeek open platform) ----------
@@ -1099,6 +1964,89 @@ function apply(ctx) {
1099
1964
  // ---------- HTTP data routes for the client half ----------
1100
1965
  if (webServer !== undefined) {
1101
1966
  const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
1967
+ ctx.effect(() => webServer.register({
1968
+ kind: 'exact',
1969
+ path: '/api/all-usage/query',
1970
+ handler: (req, res) => {
1971
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
1972
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
1973
+ const parsed = queryScopeFromRequest(req)
1974
+ if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
1975
+ sendJson(res, 200, queryUsageScope(parsed.scope))
1976
+ },
1977
+ }))
1978
+ ctx.effect(() => webServer.register({
1979
+ kind: 'exact',
1980
+ path: '/api/all-usage/records',
1981
+ handler: (req, res) => {
1982
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
1983
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
1984
+ const parsed = queryScopeFromRequest(req)
1985
+ if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
1986
+ let limit = 50
1987
+ let cursor
1988
+ try {
1989
+ const url = new URL(req.url || '/', 'http://all-usage.local')
1990
+ const rawLimit = url.searchParams.get('limit')
1991
+ if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
1992
+ cursor = url.searchParams.get('cursor') || undefined
1993
+ } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
1994
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) { sendJson(res, 400, { ok: false, message: 'invalid-limit' }); return }
1995
+ const result = queryRecords(parsed.scope, cursor, limit)
1996
+ if (result.error !== undefined) { sendJson(res, result.error === 'stale-cursor' ? 409 : 400, { ok: false, message: result.error }); return }
1997
+ sendJson(res, 200, result)
1998
+ },
1999
+ }))
2000
+ ctx.effect(() => webServer.register({
2001
+ kind: 'exact',
2002
+ path: '/api/all-usage/pricing/models',
2003
+ handler: (req, res) => {
2004
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
2005
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
2006
+ let query = ''
2007
+ let limit = 20
2008
+ try {
2009
+ const url = new URL(req.url || '/', 'http://all-usage.local')
2010
+ query = url.searchParams.get('q') || ''
2011
+ const rawLimit = url.searchParams.get('limit')
2012
+ if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
2013
+ } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
2014
+ if (query.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 50) { sendJson(res, 400, { ok: false, message: 'invalid-model-search' }); return }
2015
+ sendJson(res, 200, { items: pricingModelSearch(query, limit) })
2016
+ },
2017
+ }))
2018
+ ctx.effect(() => webServer.register({
2019
+ kind: 'exact',
2020
+ path: '/api/all-usage/pricing',
2021
+ handler: async (req, res) => {
2022
+ if (req.method === 'GET') {
2023
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
2024
+ sendJson(res, 200, pricingSnapshot())
2025
+ return
2026
+ }
2027
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
2028
+ if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
2029
+ const body = await readBody(req, 256 * 1024)
2030
+ if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
2031
+ let args = null
2032
+ try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
2033
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) { sendJson(res, 400, { ok: false, message: 'bad-pricing-request' }); return }
2034
+ const result = updatePricingState(args.pricing || args, args.backfill === true)
2035
+ await persistPricing()
2036
+ await ledgerWriteChain
2037
+ sendJson(res, 200, { ok: true, backfill: result, pricing: pricingSnapshot() })
2038
+ },
2039
+ }))
2040
+ ctx.effect(() => webServer.register({
2041
+ kind: 'exact',
2042
+ path: '/api/all-usage/pricing/sync',
2043
+ handler: async (req, res) => {
2044
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
2045
+ if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
2046
+ const result = await syncPricing(true)
2047
+ sendJson(res, result.ok ? 200 : 502, result)
2048
+ },
2049
+ }))
1102
2050
  ctx.effect(() => webServer.register({
1103
2051
  kind: 'exact',
1104
2052
  path: '/api/all-usage/status',
@@ -1145,23 +2093,26 @@ function apply(ctx) {
1145
2093
  }
1146
2094
  if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
1147
2095
  const body = await readBody(req, 16 * 1024)
2096
+ if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
1148
2097
  let args = null
1149
2098
  try {
1150
- args = JSON.parse(body)
2099
+ args = JSON.parse(body.text)
1151
2100
  } catch (err) { /* invalid json */ }
1152
- const result = args !== null && args !== undefined && typeof args.workspaceId === 'string'
2101
+ const validAliasRequest = args !== null && args !== undefined && typeof args === 'object' && !Array.isArray(args) && typeof args.workspaceId === 'string' && args.workspaceId.length > 0 && args.workspaceId.length <= 256 && typeof args.alias === 'string'
2102
+ const result = validAliasRequest
1153
2103
  ? setAlias(args.workspaceId, args.alias)
1154
2104
  : { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
1155
- sendJson(res, 200, result)
2105
+ sendJson(res, result.ok ? 200 : 400, result)
1156
2106
  },
1157
2107
  }))
1158
2108
  }
1159
2109
 
1160
2110
  // ---------- start the historical backfill immediately ----------
1161
2111
  ledgerReady = loadLedger()
2112
+ pricingReady = loadPricing().then(() => { schedulePricingSync() })
1162
2113
  void runBaseline()
1163
2114
  scheduleReconcileTimer()
1164
- void loadAliases()
2115
+ aliasesReady = loadAliases()
1165
2116
  }
1166
2117
 
1167
2118
  export { name, inject, apply }