dsh-all-usage 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,2119 +1,2 @@
1
- // dsh-all-usage 插件 Host 半(永久版)
2
- // 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
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'
5
-
6
- const name = 'dsh-all-usage'
7
- const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
8
-
9
- // webServer route handlers do not inherit the connection API fence; keep this plugin
10
- // local and require a browser-originated capability for state-changing reads/writes.
11
- function requestHeader(req, name) {
12
- const headers = req && req.headers
13
- if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
14
- const value = headers[name.toLowerCase()]
15
- if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
16
- return typeof value === 'string' ? value : undefined
17
- }
18
-
19
- function isLoopbackHostname(hostname) {
20
- if (hostname === 'localhost' || hostname === '[::1]') return true
21
- const parts = hostname.split('.')
22
- return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
23
- }
24
-
25
- function isTrustedLocalApiRequest(req, requireOrigin) {
26
- const host = requestHeader(req, 'host')
27
- if (host === undefined) return false
28
- let hostUrl
29
- try {
30
- hostUrl = new URL('http://' + host)
31
- } catch (err) {
32
- return false
33
- }
34
- if (!isLoopbackHostname(hostUrl.hostname)) return false
35
- if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
36
- const origin = requestHeader(req, 'origin')
37
- if (origin === undefined) return requireOrigin !== true
38
- try {
39
- const originUrl = new URL(origin)
40
- return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
41
- } catch (err) {
42
- return false
43
- }
44
- }
45
-
46
- function hasWriteToken(req, expected) {
47
- const actual = requestHeader(req, 'x-all-usage-request-token')
48
- if (typeof actual !== 'string' || typeof expected !== 'string') return false
49
- const actualBytes = Buffer.from(actual)
50
- const expectedBytes = Buffer.from(expected)
51
- return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
52
- }
53
-
54
- function sendJson(res, code, value) {
55
- res.statusCode = code
56
- res.setHeader('content-type', 'application/json; charset=utf-8')
57
- res.setHeader('cache-control', 'no-store')
58
- res.end(JSON.stringify(value))
59
- }
60
-
61
- function readBody(req, maxBytes) {
62
- return new Promise((resolve) => {
63
- const chunks = []
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
- }
73
- req.on('data', (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)
83
- })
84
- req.on('end', () => finish(tooLarge ? '' : Buffer.concat(chunks).toString('utf8'), tooLarge))
85
- req.on('error', () => finish('', false))
86
- })
87
- }
88
-
89
- function apply(ctx) {
90
- const credentials = ctx.get('credentials')
91
- const settings = ctx.get('settings')
92
- const storage = ctx.get('storage')
93
- const webServer = ctx.get('webServer')
94
- // v1.0.8: optional sessionPersistence exposes listSnapshots() — a cheap per-session
95
- // revision (header line + stat, no full-log read) that lets the baseline skip
96
- // re-reading unchanged sessions after a DSH restart.
97
- const sessionPersistence = ctx.get('sessionPersistence')
98
-
99
- // ---------- owned aggregation state ----------
100
- const wsMeta = new Map()
101
- const pathIndex = new Map()
102
- const memberOf = new Map()
103
- const byDay = new Map()
104
- const byDayUtc = new Map()
105
- const perWorkspace = new Map()
106
- const perModel = new Map()
107
- // One canonical usage contribution per session turn/step. This makes retries and
108
- // replacement messages update a logical model call instead of double-counting it.
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()
115
- const sessionModel = new Map()
116
- const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
117
- const sessionCount = new Set()
118
- const sessionSeq = new Map()
119
- const chains = new Map()
120
- const liveResyncPending = new Set()
121
- const liveResyncTimers = new Map()
122
- const liveResyncAttempts = new Map()
123
- const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
124
- const aliases = {}
125
- let kvUnit = null
126
- let aliasWriteChain = Promise.resolve()
127
- let aliasesReady = Promise.resolve()
128
- let balanceCache = { fetchedAt: 0, payload: null }
129
- const requestToken = randomBytes(32).toString('base64url')
130
- // A non-secret identity distinguishes HMR/restart revision resets from a stale page.
131
- const instanceId = randomBytes(12).toString('base64url')
132
- let statsRevision = 0
133
- let statsUpdatedAt = Date.now()
134
- let statsDirtyScheduled = false
135
- const sync = {
136
- lastStartedAt: 0,
137
- lastCompletedAt: 0,
138
- lastErrorAt: 0,
139
- lastErrorCode: null,
140
- persistenceSnapshotsAvailable: false,
141
- sessionsTotal: 0,
142
- sessionsRead: 0,
143
- sessionsSkippedByRevision: 0,
144
- sessionsRestoredFromLedger: 0,
145
- sessionsFailed: 0,
146
- }
147
- const ledgerRecords = new Map()
148
- const queryCache = new Map()
149
- const recordsQueryCache = new Map()
150
- let snapshotCache = null
151
- let ledgerUnit = null
152
- let ledgerReady = Promise.resolve()
153
- let ledgerWriteChain = Promise.resolve()
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
164
- let ledgerRevision = Date.now()
165
- let disposed = false
166
- let baselineRetryDelay = 1000
167
- let baselineRetryScheduled = false
168
- let baselineFallbackTimer = null
169
- const knownSessionIds = new Set()
170
- let aggregationGeneration = 0
171
- let reconcileHintScheduled = false
172
- let reconcilePending = false
173
- let reconcileInFlight = false
174
- let reconcileTimer = null
175
- const RECONCILE_INTERVAL_MS = 120000
176
- const RECONCILE_HINT_DELAY_MS = 3000
177
-
178
- function dayKey(ms) {
179
- const d = new Date(ms)
180
- return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
181
- }
182
- function dayKeyUtc(ms) {
183
- const d = new Date(ms)
184
- return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
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
- }
192
- function num(v) {
193
- return typeof v === 'number' && Number.isFinite(v) ? v : 0
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
- }
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
- }
276
- function markStatsChanged() {
277
- snapshotCache = null
278
- recordsQueryCache.clear()
279
- if (disposed || statsDirtyScheduled) return
280
- statsDirtyScheduled = true
281
- if (typeof queueMicrotask === 'function') queueMicrotask(commitPendingStats)
282
- else Promise.resolve().then(commitPendingStats)
283
- }
284
- function resetSyncState() {
285
- sync.lastStartedAt = 0
286
- sync.lastCompletedAt = 0
287
- sync.lastErrorAt = 0
288
- sync.lastErrorCode = null
289
- sync.persistenceSnapshotsAvailable = false
290
- sync.sessionsTotal = 0
291
- sync.sessionsRead = 0
292
- sync.sessionsSkippedByRevision = 0
293
- sync.sessionsRestoredFromLedger = 0
294
- sync.sessionsFailed = 0
295
- }
296
- function beginSync() {
297
- resetSyncState()
298
- sync.lastStartedAt = Date.now()
299
- markStatsChanged()
300
- }
301
- function noteSyncError(code) {
302
- sync.lastErrorAt = Date.now()
303
- sync.lastErrorCode = code
304
- markStatsChanged()
305
- }
306
- function syncSnapshot() {
307
- return {
308
- lastStartedAt: sync.lastStartedAt,
309
- lastCompletedAt: sync.lastCompletedAt,
310
- lastErrorAt: sync.lastErrorAt,
311
- lastErrorCode: sync.lastErrorCode,
312
- persistenceSnapshotsAvailable: sync.persistenceSnapshotsAvailable,
313
- sessionsTotal: sync.sessionsTotal,
314
- sessionsRead: sync.sessionsRead,
315
- sessionsSkippedByRevision: sync.sessionsSkippedByRevision,
316
- sessionsRestoredFromLedger: sync.sessionsRestoredFromLedger,
317
- sessionsFailed: sync.sessionsFailed,
318
- }
319
- }
320
- async function safeContextTimeout(ms) {
321
- if (disposed) return false
322
- try {
323
- await ctx.timeout(ms)
324
- return !disposed
325
- } catch (err) {
326
- if (!disposed) console.error('[all-usage] context timer unavailable:', err)
327
- return false
328
- }
329
- }
330
- function resetAggregationState() {
331
- aggregationGeneration += 1
332
- wsMeta.clear()
333
- pathIndex.clear()
334
- memberOf.clear()
335
- byDay.clear()
336
- byDayUtc.clear()
337
- perWorkspace.clear()
338
- perModel.clear()
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()
357
- sessionModel.clear()
358
- sessionCount.clear()
359
- sessionSeq.clear()
360
- chains.clear()
361
- knownSessionIds.clear()
362
- totals.turns = 0
363
- totals.input = 0
364
- totals.output = 0
365
- totals.cacheRead = 0
366
- totals.cacheWrite = 0
367
- totals.reasoning = 0
368
- Object.assign(totals.cost, emptyCostAggregate())
369
- scan.started = false
370
- scan.done = false
371
- scan.scanned = 0
372
- scan.total = 0
373
- scan.failed = 0
374
- resetSyncState()
375
- baselineRetryDelay = 1000
376
- markStatsChanged()
377
- return aggregationGeneration
378
- }
379
- function ensureDay(dayMap, date) {
380
- let day = dayMap.get(date)
381
- if (day === undefined) {
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() }
383
- dayMap.set(date, day)
384
- }
385
- return day
386
- }
387
- function ensureWs(wsId) {
388
- let ws = perWorkspace.get(wsId)
389
- if (ws === undefined) {
390
- ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
391
- perWorkspace.set(wsId, ws)
392
- }
393
- return ws
394
- }
395
- function ensureDayWs(day, wsId) {
396
- let w = day.byWs.get(wsId)
397
- if (w === undefined) {
398
- w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: emptyCostAggregate() }
399
- day.byWs.set(wsId, w)
400
- }
401
- return w
402
- }
403
- function ensureModel(value) {
404
- const identity = coerceIdentity(value)
405
- let item = perModel.get(identity.identityKey)
406
- if (item === undefined) {
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)
409
- }
410
- return item
411
- }
412
- function ensureDayModel(day, value) {
413
- const identity = coerceIdentity(value)
414
- let item = day.byModel.get(identity.identityKey)
415
- if (item === undefined) {
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)
418
- }
419
- return item
420
- }
421
- function usageValues(usage) {
422
- return {
423
- input: num(usage && usage.inputTokens),
424
- output: num(usage && usage.outputTokens),
425
- cacheRead: num(usage && usage.cacheReadTokens),
426
- cacheWrite: num(usage && usage.cacheWriteTokens),
427
- reasoning: num(usage && usage.reasoningTokens),
428
- }
429
- }
430
- function adjustValues(target, values, direction) {
431
- target.input += values.input * direction
432
- target.output += values.output * direction
433
- target.cacheRead += values.cacheRead * direction
434
- target.cacheWrite += values.cacheWrite * direction
435
- target.reasoning += values.reasoning * direction
436
- }
437
- function noValues(target) {
438
- return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
439
- }
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) {
453
- const day = ensureDay(dayMap, date)
454
- adjustDaySession(day, sid, direction)
455
- adjustValues(day.tokens, values, direction)
456
- addCostAggregateDirection(day.cost, cost, direction)
457
- const dayWs = ensureDayWs(day, wsId)
458
- adjustValues(dayWs, values, direction)
459
- addCostAggregateDirection(dayWs.cost, cost, direction)
460
- if (noValues(dayWs)) day.byWs.delete(wsId)
461
- const dayModel = ensureDayModel(day, identity)
462
- dayModel.calls += direction
463
- adjustValues(dayModel, values, direction)
464
- addCostAggregateDirection(dayModel.cost, cost, direction)
465
- if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
466
- }
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)
490
- modelTotals.calls += direction
491
- adjustValues(modelTotals, values, direction)
492
- addCostAggregateDirection(modelTotals.cost, cost, direction)
493
- if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(normalized.identityKey)
494
- adjustValues(totals, values, direction)
495
- addCostAggregateDirection(totals.cost, cost, direction)
496
- const ws = ensureWs(wsId)
497
- adjustValues(ws, values, direction)
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)
501
- }
502
- function usageStepKey(sid, data, seq, fallback) {
503
- const turn = data && typeof data.turn === 'number' ? data.turn : null
504
- const step = data && typeof data.step === 'number' ? data.step : null
505
- if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
506
- if (typeof seq === 'number') return sid + ':event:' + seq
507
- return sid + ':event:' + (fallback === undefined ? JSON.stringify(data || {}) : String(fallback))
508
- }
509
- function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live') {
510
- if (!validEventTime(time)) return
511
- const values = usageValues(usage)
512
- const identity = coerceIdentity(model)
513
- const eventSeq = typeof seq === 'number' ? seq : -1
514
- // v1.0.7: a usage event carrying no billable token in any bucket must not add a
515
- // meaningless row nor wipe previously recorded real usage (cc-switch
516
- // has_billable_tokens parity). Pure cache-read requests are billable and pass.
517
- if (noValues(values)) return
518
- const dates = dateKeys(time)
519
- const key = usageStepKey(sid, data, seq)
520
- const previous = usageByStep.get(key)
521
- // A late replay of an older raw event cannot replace the canonical later step.
522
- if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
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 }
529
- usageByStep.set(key, next)
530
- indexUsage(next)
531
- adjustUsage(wsId, time, values, identity, 1, sid, dates, cost)
532
- markStatsChanged()
533
- }
534
- function addDayTurn(dayMap, date, wsId, sid) {
535
- const day = ensureDay(dayMap, date)
536
- adjustDaySession(day, sid, 1)
537
- day.turns += 1
538
- day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
539
- }
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)
554
- ensureWs(wsId).turns += 1
555
- totals.turns += 1
556
- addDayTurn(byDay, dates.local, wsId, sid)
557
- addDayTurn(byDayUtc, dates.utc, wsId, sid)
558
- markStatsChanged()
559
- }
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)
614
- }
615
- function modelFromRoute(data) {
616
- const identity = identityFromRoute(data)
617
- return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
618
- }
619
- function modelFromMessage(data, fallback) {
620
- return identityFromMessage(data, fallback).label
621
- }
622
- function nextLedgerRevision() {
623
- ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
624
- return ledgerRevision
625
- }
626
- function ledgerEventKey(event, index) {
627
- return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
628
- }
629
- function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord) {
630
- const sid = session && typeof session.id === 'string' ? session.id : ''
631
- const events = session && Array.isArray(session.events) ? session.events : []
632
- if (sid === '' || workspaceId === undefined) return null
633
- const turns = new Map()
634
- const usage = new Map()
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)
637
- for (let index = 0; index < events.length; index += 1) {
638
- const event = events[index]
639
- if (event === null || typeof event !== 'object') continue
640
- const data = event.data
641
- if (event.type === 'request/context' || event.type === 'request/header') {
642
- currentIdentity = identityFromRoute(data, currentIdentity)
643
- continue
644
- }
645
- if (event.type === 'turn/end') {
646
- if (!validEventTime(event.time)) continue
647
- const key = ledgerEventKey(event, index)
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 })
650
- continue
651
- }
652
- if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined || !validEventTime(event.time)) continue
653
- const values = usageValues(data.usage)
654
- // v1.0.7: all-zero usage rows carry no billable tokens and stay out of the
655
- // durable ledger (cc-switch has_billable_tokens parity).
656
- if (noValues(values)) continue
657
- const identity = identityFromMessage(data, currentIdentity)
658
- const eventSeq = typeof event.seq === 'number' ? event.seq : -1
659
- const key = usageStepKey(sid, data, event.seq, index)
660
- const previous = usage.get(key)
661
- if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
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
678
- }
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()) }
680
- }
681
- function normalizeLedgerRecord(raw, key) {
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
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' })
685
- const turnMap = new Map()
686
- for (const turn of raw.turns) {
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) })
688
- }
689
- const usageMap = new Map()
690
- for (const item of raw.usage) {
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 }) }
695
- const previous = usageMap.get(normalized.key)
696
- if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
697
- }
698
- const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
699
- ledgerRevision = Math.max(ledgerRevision, updatedAt)
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
724
- }
725
- function applyLedgerRecord(record, materialization = 'ledger-reuse') {
726
- if (record === null || record === undefined) return
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
- }
743
- if (record.turns.length > 0 || record.usage.length > 0) {
744
- sessionCount.add(record.sessionId)
745
- if (record.usage.length > 0) markStatsChanged()
746
- }
747
- }
748
- function ledgerRank(record) {
749
- return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
750
- }
751
- function replaceLedgerRecord(record) {
752
- ledgerRecords.set(record.sessionId, record)
753
- return record
754
- }
755
- function storeLedgerRecord(record) {
756
- const current = ledgerRecords.get(record.sessionId)
757
- if (current !== undefined) {
758
- const nextRank = ledgerRank(record)
759
- const currentRank = ledgerRank(current)
760
- if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
761
- }
762
- ledgerRecords.set(record.sessionId, record)
763
- return record
764
- }
765
- function persistLedgerRecord(record) {
766
- if (disposed || ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
767
- const write = ledgerWriteChain.then(async () => {
768
- if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
769
- })
770
- ledgerWriteChain = write.catch((err) => {
771
- console.error('[all-usage] usage ledger write failed:', err)
772
- })
773
- return ledgerWriteChain
774
- }
775
- function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
776
- if ((type === 'turn/end' || type === 'assistant/message') && !validEventTime(time)) return
777
- if (type === 'request/context' || type === 'request/header') {
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
- }
786
- }
787
- function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
788
- for (const ev of events) {
789
- if (fromSeq !== undefined) {
790
- const s = typeof ev.seq === 'number' ? ev.seq : -1
791
- if (s <= fromSeq) continue
792
- }
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)
794
- }
795
- }
796
- function lastSeqOf(events) {
797
- let last = -1
798
- for (const ev of events) {
799
- const s = typeof ev.seq === 'number' ? ev.seq : -1
800
- if (s > last) last = s
801
- }
802
- return last
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
- }
817
- function enqueue(sid, task) {
818
- const prev = chains.get(sid) || Promise.resolve()
819
- const next = prev.then(() => task(), () => task())
820
- chains.set(sid, next)
821
- const cleanup = () => { if (chains.get(sid) === next) chains.delete(sid) }
822
- void next.then(cleanup, cleanup)
823
- return next
824
- }
825
- function wsForLiveSession(session, sid) {
826
- let wsId = memberOf.get(sid)
827
- if (wsId !== undefined) return wsId
828
- const header = session && session.header
829
- const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
830
- if (cwd === '') return undefined
831
- wsId = pathIndex.get(cwd)
832
- if (wsId !== undefined) memberOf.set(sid, wsId)
833
- return wsId
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
- }
899
- async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
900
- if (disposed || generation !== aggregationGeneration) return
901
- const seq = typeof event.seq === 'number' ? event.seq : -1
902
- const last = sessionSeq.get(sid)
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)
910
- return
911
- }
912
- if (seq < 0) {
913
- foldLiveFallback(sid, wsId, event)
914
- return
915
- }
916
- if (seq <= last) return
917
- foldLiveFallback(sid, wsId, event)
918
- }
919
-
920
- // ---------- durable usage ledger ----------
921
- async function loadLedger() {
922
- if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
923
- try {
924
- const backend = storage.backend.get('json')
925
- if (backend === undefined || backend === null || backend.kv === undefined) return
926
- const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
927
- if (disposed) { await unit.close().catch(() => {}); return }
928
- ledgerUnit = unit
929
- const snapshot = await unit.loadAll()
930
- const rows = snapshot && snapshot.tables && snapshot.tables.sessions
931
- if (rows !== null && rows !== undefined && typeof rows === 'object') {
932
- for (const [key, raw] of Object.entries(rows)) {
933
- const record = normalizeLedgerRecord(raw, key)
934
- if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
935
- else ledgerRecords.set(key, record)
936
- }
937
- }
938
- } catch (err) {
939
- console.error('[all-usage] usage ledger unavailable:', err)
940
- }
941
- }
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
-
1134
- // ---------- baseline scan over durable logs ----------
1135
- function scheduleNativeBaselineRetry(generation, delay) {
1136
- if (disposed || baselineFallbackTimer !== null) return
1137
- baselineFallbackTimer = setTimeout(() => {
1138
- baselineFallbackTimer = null
1139
- if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
1140
- }, delay)
1141
- if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
1142
- }
1143
- function scheduleBaselineRetry(generation = aggregationGeneration) {
1144
- if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
1145
- baselineRetryScheduled = true
1146
- const delay = baselineRetryDelay
1147
- baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
1148
- void safeContextTimeout(delay).then((ready) => {
1149
- if (generation !== aggregationGeneration) return undefined
1150
- baselineRetryScheduled = false
1151
- if (ready && !scan.started && !scan.done) return runBaseline(generation)
1152
- if (!ready && !disposed) scheduleNativeBaselineRetry(generation, delay)
1153
- return undefined
1154
- })
1155
- }
1156
- async function runBaseline(generation = aggregationGeneration) {
1157
- if (scan.started || disposed || generation !== aggregationGeneration) return
1158
- scan.started = true
1159
- beginSync()
1160
- await Promise.all([ledgerReady, pricingReady])
1161
- if (disposed || generation !== aggregationGeneration) return
1162
- let setupFailed = false
1163
- try {
1164
- const workspaces = ctx.workspaceRegistry.list()
1165
- for (const w of workspaces) {
1166
- const id = w && w.id
1167
- const path = w && typeof w.path === 'string' ? w.path : ''
1168
- const title = w && typeof w.title === 'string' ? w.title : ''
1169
- if (id === undefined) continue
1170
- wsMeta.set(id, { id, title, path })
1171
- if (path !== '') pathIndex.set(path, id)
1172
- if (w && Array.isArray(w.sessionIds)) {
1173
- for (const sid of w.sessionIds) memberOf.set(sid, id)
1174
- }
1175
- }
1176
- } catch (err) {
1177
- console.error('[all-usage] workspace list failed:', err)
1178
- setupFailed = true
1179
- noteSyncError('workspace-list-failed')
1180
- }
1181
- let records = null
1182
- try {
1183
- records = await ctx.sessionQuery.listSessions()
1184
- } catch (err) {
1185
- console.error('[all-usage] session list failed:', err)
1186
- if (disposed || generation !== aggregationGeneration) return
1187
- noteSyncError('session-list-failed')
1188
- }
1189
- if (disposed || generation !== aggregationGeneration) return
1190
- // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
1191
- let snapshots = null
1192
- if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
1193
- try {
1194
- const rows = await sessionPersistence.listSnapshots()
1195
- if (disposed || generation !== aggregationGeneration) return
1196
- if (Array.isArray(rows)) {
1197
- sync.persistenceSnapshotsAvailable = true
1198
- snapshots = new Map()
1199
- for (const row of rows) {
1200
- const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
1201
- if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
1202
- }
1203
- }
1204
- } catch (err) {
1205
- console.error('[all-usage] session persistence snapshots unavailable:', err)
1206
- if (disposed || generation !== aggregationGeneration) return
1207
- sync.persistenceSnapshotsAvailable = false
1208
- markStatsChanged()
1209
- }
1210
- }
1211
- if (disposed || generation !== aggregationGeneration) return
1212
- if (setupFailed || !Array.isArray(records)) {
1213
- // A transient registry failure must not be reported as a completed empty scan.
1214
- scan.started = false
1215
- markStatsChanged()
1216
- scheduleBaselineRetry(generation)
1217
- return
1218
- }
1219
- scan.total = records.length
1220
- sync.sessionsTotal = records.length
1221
- markStatsChanged()
1222
- const listedSessionIds = new Set()
1223
- for (const record of records) {
1224
- if (record === undefined || record === null || record.header === undefined) continue
1225
- const sid = record.header.id
1226
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
1227
- const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
1228
- if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
1229
- }
1230
- for (const [sid, record] of ledgerRecords) {
1231
- if (!listedSessionIds.has(sid)) {
1232
- applyLedgerRecord(record, 'ledger-recovery')
1233
- if (record.turns.length > 0 || record.usage.length > 0) sync.sessionsRestoredFromLedger += 1
1234
- }
1235
- }
1236
- if (sync.sessionsRestoredFromLedger > 0) markStatsChanged()
1237
- for (const record of records) {
1238
- if (disposed || generation !== aggregationGeneration) return
1239
- if (record === undefined || record === null || record.header === undefined) {
1240
- scan.scanned += 1
1241
- markStatsChanged()
1242
- continue
1243
- }
1244
- const sid = record.header.id
1245
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
1246
- const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
1247
- if (sid === undefined || wsId === undefined) {
1248
- scan.scanned += 1
1249
- markStatsChanged()
1250
- continue
1251
- }
1252
- listedSessionIds.add(sid)
1253
- await enqueue(sid, async () => {
1254
- if (disposed || generation !== aggregationGeneration) return
1255
- try {
1256
- if (sessionSeq.has(sid) && !liveResyncPending.has(sid)) return
1257
- // v1.0.8: when the persisted log revision is unchanged since the last ledger
1258
- // write, the whole readSession (full event transfer) is skipped — the ledger
1259
- // record is applied directly and the live feed keeps catching new events.
1260
- const previousRecord = ledgerRecords.get(sid)
1261
- const revision = snapshots === null ? undefined : snapshots.get(sid)
1262
- if (!liveResyncPending.has(sid) && previousRecord !== undefined && previousRecord.needsUpgrade !== true && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
1263
- sync.sessionsSkippedByRevision += 1
1264
- applyLedgerRecord(previousRecord, 'ledger-reuse')
1265
- sessionSeq.set(sid, previousRecord.lastSeq)
1266
- sessionCount.add(sid)
1267
- markStatsChanged()
1268
- return
1269
- }
1270
- sync.sessionsRead += 1
1271
- markStatsChanged()
1272
- const snap = await ctx.sessionQuery.readSession(sid)
1273
- if (disposed || generation !== aggregationGeneration) return
1274
- if (snap && Array.isArray(snap.events)) {
1275
- // v1.0.7: incremental seed — the durable ledger doubles as a per-session
1276
- // cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
1277
- // session applies its canonical record directly and never re-folds;
1278
- // a changed session seeds the previous record once, then folds only the
1279
- // new tail (previously every listed session was re-read and fully rebuilt).
1280
- const sequence = sequenceProfile(snap.events)
1281
- const currentLastSeq = sequence.lastSeq
1282
- const previous = ledgerRecords.get(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')
1287
- } else {
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')
1291
- }
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))
1294
- if (canonical === ledger) {
1295
- void persistLedgerRecord(ledger)
1296
- }
1297
- const observedLastSeq = sessionSeq.get(sid)
1298
- const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
1299
- sessionSeq.set(sid, nextLastSeq)
1300
- sessionCount.add(sid)
1301
- if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
1302
- else scheduleLiveResync(sid, wsId, generation)
1303
- }
1304
- } catch (err) {
1305
- if (generation !== aggregationGeneration) return
1306
- sync.sessionsFailed += 1
1307
- scan.failed += 1
1308
- noteSyncError('session-read-failed')
1309
- const saved = ledgerRecords.get(sid)
1310
- if (saved !== undefined) {
1311
- applyLedgerRecord(saved, 'ledger-recovery')
1312
- if (saved.turns.length > 0 || saved.usage.length > 0) sync.sessionsRestoredFromLedger += 1
1313
- sessionSeq.set(sid, -1)
1314
- sessionCount.add(sid)
1315
- } else {
1316
- sessionSeq.set(sid, -1)
1317
- }
1318
- } finally {
1319
- if (generation === aggregationGeneration) {
1320
- scan.scanned += 1
1321
- markStatsChanged()
1322
- }
1323
- }
1324
- })
1325
- if (!(await safeContextTimeout(0))) {
1326
- scan.started = false
1327
- noteSyncError('baseline-yield-unavailable')
1328
- scheduleBaselineRetry(generation)
1329
- return
1330
- }
1331
- }
1332
- if (disposed || generation !== aggregationGeneration) return
1333
- await ledgerWriteChain
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
1341
- knownSessionIds.clear()
1342
- for (const sid of listedSessionIds) knownSessionIds.add(sid)
1343
- scan.done = true
1344
- sync.lastCompletedAt = Date.now()
1345
- if (sync.sessionsFailed === 0) {
1346
- sync.lastErrorAt = 0
1347
- sync.lastErrorCode = null
1348
- }
1349
- markStatsChanged()
1350
- if (reconcilePending) scheduleReconcileHint()
1351
- }
1352
-
1353
- function sessionIdsFromRecords(records) {
1354
- const ids = new Set()
1355
- for (const record of records) {
1356
- if (record === undefined || record === null || record.header === undefined) continue
1357
- const sid = record.header.id
1358
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
1359
- const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
1360
- if (sid !== undefined && wsId !== undefined) ids.add(sid)
1361
- }
1362
- return ids
1363
- }
1364
- async function reconcileSessions() {
1365
- if (disposed || reconcileInFlight || !scan.done) return
1366
- reconcilePending = false
1367
- reconcileInFlight = true
1368
- try {
1369
- const records = await ctx.sessionQuery.listSessions()
1370
- if (disposed || !Array.isArray(records)) return
1371
- const currentIds = sessionIdsFromRecords(records)
1372
- let removed = false
1373
- for (const sid of knownSessionIds) {
1374
- if (!currentIds.has(sid)) { removed = true; break }
1375
- }
1376
- if (removed && !disposed && scan.done) {
1377
- console.info('[all-usage] session removal detected; rebuilding usage index')
1378
- const generation = resetAggregationState()
1379
- void runBaseline(generation)
1380
- return
1381
- }
1382
- knownSessionIds.clear()
1383
- for (const sid of currentIds) knownSessionIds.add(sid)
1384
- } catch (err) {
1385
- console.error('[all-usage] session reconciliation failed:', err)
1386
- noteSyncError('session-reconcile-failed')
1387
- } finally {
1388
- reconcileInFlight = false
1389
- if (reconcilePending && !disposed) scheduleReconcileHint()
1390
- }
1391
- }
1392
- function scheduleReconcileHint() {
1393
- if (disposed) return
1394
- reconcilePending = true
1395
- if (reconcileHintScheduled || reconcileInFlight) return
1396
- reconcileHintScheduled = true
1397
- const generation = aggregationGeneration
1398
- void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
1399
- if (generation !== aggregationGeneration) return
1400
- reconcileHintScheduled = false
1401
- if (ready && !disposed) void reconcileSessions()
1402
- }, () => {
1403
- if (generation === aggregationGeneration) reconcileHintScheduled = false
1404
- })
1405
- }
1406
- function scheduleReconcileTimer() {
1407
- if (disposed || reconcileTimer !== null) return
1408
- reconcileTimer = setTimeout(() => {
1409
- reconcileTimer = null
1410
- if (!disposed) {
1411
- void reconcileSessions()
1412
- scheduleReconcileTimer()
1413
- }
1414
- }, RECONCILE_INTERVAL_MS)
1415
- if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
1416
- }
1417
-
1418
- // ---------- live feed ----------
1419
- ctx.on('session/event', (session, event) => {
1420
- if (disposed) return
1421
- if (event === undefined || event === null) return
1422
- const type = event.type
1423
- if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'request/context' && type !== 'request/header') return
1424
- const sid = session && session.id
1425
- if (typeof sid !== 'string') return
1426
- const wsId = wsForLiveSession(session, sid)
1427
- if (wsId === undefined) return
1428
- const generation = aggregationGeneration
1429
- knownSessionIds.add(sid)
1430
- enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
1431
- })
1432
- ctx.on('session/flush', async (session) => {
1433
- if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
1434
- await Promise.all([ledgerReady, pricingReady])
1435
- if (disposed) return
1436
- const wsId = wsForLiveSession(session, session.id)
1437
- if (wsId === undefined) return
1438
- const ledger = buildLedgerRecord(session, wsId, 'flush', undefined, ledgerRecords.get(session.id))
1439
- if (ledger === null) return
1440
- const canonical = storeLedgerRecord(ledger)
1441
- if (canonical === ledger) await persistLedgerRecord(ledger)
1442
- })
1443
- ctx.on('session/disposed', () => {
1444
- if (!disposed) scheduleReconcileHint()
1445
- })
1446
-
1447
- // ---------- workspace aliases (durable, schema-free KV unit) ----------
1448
- async function loadAliases() {
1449
- if (storage === undefined) return
1450
- try {
1451
- const backend = storage.backend.get('json')
1452
- if (backend === undefined || backend === null || backend.kv === undefined) return
1453
- const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
1454
- if (disposed) { await unit.close().catch(() => {}); return }
1455
- kvUnit = unit
1456
- const snap = await unit.loadAll()
1457
- const g = snap && snap.global
1458
- if (g !== null && g !== undefined && typeof g === 'object') {
1459
- for (const key of Object.keys(g)) {
1460
- const value = g[key]
1461
- if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
1462
- }
1463
- markStatsChanged()
1464
- }
1465
- } catch (err) {
1466
- console.error('[all-usage] alias storage unavailable:', err)
1467
- }
1468
- }
1469
- function persistAliases() {
1470
- if (disposed) return aliasWriteChain
1471
- const snapshotAliases = {}
1472
- for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
1473
- aliasWriteChain = aliasWriteChain.then(() => {
1474
- if (kvUnit === null || kvUnit === undefined) return undefined
1475
- return kvUnit.setGlobal(snapshotAliases).catch((err) => {
1476
- console.error('[all-usage] alias persist failed:', err)
1477
- })
1478
- })
1479
- }
1480
- function setAlias(wsId, raw) {
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)
1484
- if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
1485
- if (alias === '') delete aliases[wsId]
1486
- else aliases[wsId] = alias
1487
- persistAliases()
1488
- markStatsChanged()
1489
- return { ok: true, aliases: Object.assign({}, aliases) }
1490
- }
1491
- ctx.effect(() => async () => {
1492
- disposed = true
1493
- if (reconcileTimer !== null) {
1494
- clearTimeout(reconcileTimer)
1495
- reconcileTimer = null
1496
- }
1497
- if (baselineFallbackTimer !== null) {
1498
- clearTimeout(baselineFallbackTimer)
1499
- baselineFallbackTimer = null
1500
- }
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]
1513
- kvUnit = null
1514
- ledgerUnit = null
1515
- pricingUnit = null
1516
- await Promise.all(units.map((unit) => unit === null || unit === undefined ? undefined : unit.close().catch(() => {})))
1517
- })
1518
-
1519
- // ---------- snapshot for the client ----------
1520
- function scanSnapshot() {
1521
- return { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed }
1522
- }
1523
- function statusSnapshot() {
1524
- commitPendingStats()
1525
- return { instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scan: scanSnapshot(), sync: syncSnapshot() }
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
- }
1534
- function serializeDays(dayMap) {
1535
- const result = []
1536
- for (const pair of dayMap) {
1537
- const date = pair[0]
1538
- const day = pair[1]
1539
- result.push({
1540
- date,
1541
- turns: day.turns,
1542
- sessions: day.sessionIds.size,
1543
- sessionIds: Array.from(day.sessionIds).sort(),
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),
1546
- perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
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])),
1549
- })
1550
- }
1551
- result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
1552
- return result
1553
- }
1554
- function snapshot() {
1555
- commitPendingStats()
1556
- const generatedAt = Date.now()
1557
- if (snapshotCache !== null && snapshotCache.revision === statsRevision) return Object.assign({}, snapshotCache.value, { generatedAt })
1558
- const value = {
1559
- ...statusSnapshot(),
1560
- generatedAt,
1561
- usageSchemaVersion: 3,
1562
- costSchemaVersion: COST_SCHEMA_VERSION,
1563
- requestToken,
1564
- workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
1565
- aliases: Object.assign({}, aliases),
1566
- pricing: pricingSnapshot(),
1567
- tokenSemantics: {
1568
- processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
1569
- cacheRead: 'reused context tokens; not newly generated output',
1570
- cacheWrite: 'tokens written into a provider cache',
1571
- // v1.0.7: structured, machine-readable accounting semantics (cc-switch
1572
- // input_token_semantics parity). DSH reports input as fresh (cache read /
1573
- // cache write sit in their own buckets) — verified against real ledger data;
1574
- // reasoning is bucketed separately from output and assumed non-overlapping.
1575
- semantics: {
1576
- input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
1577
- buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
1578
- inputIncludesCache: false,
1579
- cacheBucketed: true,
1580
- reasoningSeparate: true,
1581
- gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
1582
- },
1583
- },
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)),
1597
- byDay: serializeDays(byDay),
1598
- byDayUtc: serializeDays(byDayUtc),
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 }
1861
- }
1862
-
1863
- // ---------- account balance (DeepSeek open platform) ----------
1864
- async function requestBalance(url, key) {
1865
- let controller = null
1866
- let timer = null
1867
- try {
1868
- if (typeof AbortController === 'function') {
1869
- controller = new AbortController()
1870
- timer = setTimeout(() => controller.abort(), 30000)
1871
- }
1872
- const response = await fetch(url, {
1873
- method: 'GET',
1874
- headers: { accept: 'application/json', authorization: 'Bearer ' + key },
1875
- ...(controller === null ? {} : { signal: controller.signal }),
1876
- })
1877
- return { ok: response.ok, status: response.status, text: await response.text() }
1878
- } catch (err) {
1879
- return { ok: false, status: 0, text: '', error: 'network request failed' }
1880
- } finally {
1881
- if (timer !== null) clearTimeout(timer)
1882
- }
1883
- }
1884
- function moneyOf(v) {
1885
- if (typeof v === 'number' && Number.isFinite(v)) return v
1886
- if (typeof v === 'string' && v.trim() !== '') {
1887
- const n = parseFloat(v)
1888
- if (Number.isFinite(n)) return n
1889
- }
1890
- return null
1891
- }
1892
- function parseBalance(text) {
1893
- let obj = null
1894
- try {
1895
- obj = JSON.parse(String(text).replace(/^\uFEFF/, ''))
1896
- } catch (err) {
1897
- return null
1898
- }
1899
- if (obj === null || typeof obj !== 'object') return null
1900
- if (obj.is_available === false) return { unavailable: true, currencies: [] }
1901
- const infos = (Array.isArray(obj.balance_infos) && obj.balance_infos) || (Array.isArray(obj.balance) && obj.balance) || null
1902
- if (!infos) return null
1903
- const out = []
1904
- for (const info of infos) {
1905
- if (info === null || typeof info !== 'object') continue
1906
- if (typeof info.currency !== 'string') continue
1907
- out.push({
1908
- currency: info.currency,
1909
- total: moneyOf(info.total_balance !== undefined ? info.total_balance : info.balance),
1910
- granted: moneyOf(info.granted_balance),
1911
- toppedUp: moneyOf(info.topped_up_balance),
1912
- })
1913
- }
1914
- return { unavailable: false, currencies: out }
1915
- }
1916
- async function fetchBalance(force) {
1917
- const now = Date.now()
1918
- if (force !== true && balanceCache.payload !== null && now - balanceCache.fetchedAt < 300000) return balanceCache.payload
1919
- let ref = 'DEEPSEEK_API_KEY'
1920
- if (settings !== undefined) {
1921
- try {
1922
- const section = settings.get('llm-deepseek')
1923
- if (section !== null && typeof section === 'object' && typeof section.apiKeyEnv === 'string' && section.apiKeyEnv.length > 0) ref = section.apiKeyEnv
1924
- } catch (err) { /* default ref */ }
1925
- }
1926
- let key
1927
- if (credentials !== undefined) {
1928
- try {
1929
- const hit = await credentials.resolve(ref)
1930
- if (hit !== null && hit !== undefined && typeof hit.value === 'string' && hit.value.length > 0) key = hit.value
1931
- } catch (err) { /* unconfigured */ }
1932
- }
1933
- if (key === undefined) {
1934
- const payload = { status: 'missing-key' }
1935
- balanceCache = { fetchedAt: now, payload }
1936
- return payload
1937
- }
1938
- if (typeof fetch !== 'function') {
1939
- const payload = { status: 'error', message: '当前 DSH 运行时不支持余额查询' }
1940
- balanceCache = { fetchedAt: now, payload }
1941
- return payload
1942
- }
1943
- const result = await requestBalance('https://api.deepseek.com/user/balance', key)
1944
- const body = (result.text || '').trim()
1945
- if (result.ok && body.length > 0) {
1946
- const parsed = parseBalance(body)
1947
- if (parsed !== null && parsed.unavailable) {
1948
- const payload = { status: 'unavailable', message: 'DeepSeek 接口返回余额不可用(is_available=false)' }
1949
- balanceCache = { fetchedAt: now, payload }
1950
- return payload
1951
- }
1952
- if (parsed !== null && parsed.currencies.length > 0) {
1953
- const payload = { status: 'ok', currencies: parsed.currencies, fetchedAt: now }
1954
- balanceCache = { fetchedAt: now, payload }
1955
- return payload
1956
- }
1957
- }
1958
- const detail = body.length > 0 ? body.slice(0, 300) : (result.status > 0 ? 'HTTP ' + result.status : result.error || 'network request failed')
1959
- const payload = { status: 'error', message: '余额查询失败', detail }
1960
- balanceCache = { fetchedAt: now, payload }
1961
- return payload
1962
- }
1963
-
1964
- // ---------- HTTP data routes for the client half ----------
1965
- if (webServer !== undefined) {
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
- }))
2050
- ctx.effect(() => webServer.register({
2051
- kind: 'exact',
2052
- path: '/api/all-usage/status',
2053
- handler: (req, res) => {
2054
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
2055
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
2056
- if (!scan.started) void runBaseline()
2057
- sendJson(res, 200, statusSnapshot())
2058
- },
2059
- }))
2060
- ctx.effect(() => webServer.register({
2061
- kind: 'exact',
2062
- path: '/api/all-usage',
2063
- handler: (req, res) => {
2064
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
2065
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
2066
- if (!scan.started) void runBaseline()
2067
- sendJson(res, 200, snapshot())
2068
- },
2069
- }))
2070
- ctx.effect(() => webServer.register({
2071
- kind: 'exact',
2072
- path: '/api/all-usage/balance',
2073
- handler: async (req, res) => {
2074
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
2075
- // Browsers may omit Origin on same-origin GET; the process token remains required.
2076
- if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
2077
- let force = false
2078
- try {
2079
- const url = new URL(req.url ?? '/', 'http://x')
2080
- force = url.searchParams.get('force') === '1'
2081
- } catch (err) { /* default */ }
2082
- sendJson(res, 200, await fetchBalance(force))
2083
- },
2084
- }))
2085
- ctx.effect(() => webServer.register({
2086
- kind: 'exact',
2087
- path: '/api/all-usage/alias',
2088
- handler: async (req, res) => {
2089
- if (req.method !== 'POST') {
2090
- res.statusCode = 405
2091
- res.end()
2092
- return
2093
- }
2094
- if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
2095
- const body = await readBody(req, 16 * 1024)
2096
- if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
2097
- let args = null
2098
- try {
2099
- args = JSON.parse(body.text)
2100
- } catch (err) { /* invalid json */ }
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
2103
- ? setAlias(args.workspaceId, args.alias)
2104
- : { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
2105
- sendJson(res, result.ok ? 200 : 400, result)
2106
- },
2107
- }))
2108
- }
2109
-
2110
- // ---------- start the historical backfill immediately ----------
2111
- ledgerReady = loadLedger()
2112
- pricingReady = loadPricing().then(() => { schedulePricingSync() })
2113
- void runBaseline()
2114
- scheduleReconcileTimer()
2115
- aliasesReady = loadAliases()
2116
- }
2117
-
2118
- export { name, inject, apply }
2119
- export default { name, inject, apply }
1
+ export { name, inject, apply } from './plugin.js'
2
+ export { default } from './plugin.js'