dsh-all-usage 1.0.8 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,1039 +1,1572 @@
1
- // dsh-all-usage 插件 Host 半(永久版)
2
- // 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
3
- import { randomBytes, timingSafeEqual } from 'node:crypto'
4
-
5
- const name = 'dsh-all-usage'
6
- const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
7
-
8
- // webServer route handlers do not inherit the connection API fence; keep this plugin
9
- // local and require a browser-originated capability for state-changing reads/writes.
10
- function requestHeader(req, name) {
11
- const headers = req && req.headers
12
- if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
13
- const value = headers[name.toLowerCase()]
14
- if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
15
- return typeof value === 'string' ? value : undefined
16
- }
17
-
18
- function isLoopbackHostname(hostname) {
19
- if (hostname === 'localhost' || hostname === '[::1]') return true
20
- const parts = hostname.split('.')
21
- return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
- }
23
-
24
- function isTrustedLocalApiRequest(req, requireOrigin) {
25
- const host = requestHeader(req, 'host')
26
- if (host === undefined) return false
27
- let hostUrl
28
- try {
29
- hostUrl = new URL('http://' + host)
30
- } catch (err) {
31
- return false
32
- }
33
- if (!isLoopbackHostname(hostUrl.hostname)) return false
34
- if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
35
- const origin = requestHeader(req, 'origin')
36
- if (origin === undefined) return requireOrigin !== true
37
- try {
38
- const originUrl = new URL(origin)
39
- return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
40
- } catch (err) {
41
- return false
42
- }
43
- }
44
-
45
- function hasWriteToken(req, expected) {
46
- const actual = requestHeader(req, 'x-all-usage-request-token')
47
- if (typeof actual !== 'string' || typeof expected !== 'string') return false
48
- const actualBytes = Buffer.from(actual)
49
- const expectedBytes = Buffer.from(expected)
50
- return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
51
- }
52
-
53
- function sendJson(res, code, value) {
54
- res.statusCode = code
55
- res.setHeader('content-type', 'application/json; charset=utf-8')
56
- res.setHeader('cache-control', 'no-store')
57
- res.end(JSON.stringify(value))
58
- }
59
-
60
- function readBody(req, maxBytes) {
61
- return new Promise((resolve) => {
62
- const chunks = []
63
- let size = 0
64
- req.on('data', (chunk) => {
65
- size += chunk.length
66
- if (size <= maxBytes) chunks.push(chunk)
67
- })
68
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
69
- req.on('error', () => resolve(''))
70
- })
71
- }
72
-
73
- function apply(ctx) {
74
- const credentials = ctx.get('credentials')
75
- const settings = ctx.get('settings')
76
- const storage = ctx.get('storage')
77
- const webServer = ctx.get('webServer')
78
- // v1.0.8: optional sessionPersistence exposes listSnapshots() — a cheap per-session
79
- // revision (header line + stat, no full-log read) that lets the baseline skip
80
- // re-reading unchanged sessions after a DSH restart.
81
- const sessionPersistence = ctx.get('sessionPersistence')
82
-
83
- // ---------- owned aggregation state ----------
84
- const wsMeta = new Map()
85
- const pathIndex = new Map()
86
- const memberOf = new Map()
87
- const byDay = new Map()
88
- const byDayUtc = new Map()
89
- const perWorkspace = new Map()
90
- const perModel = new Map()
91
- // One canonical usage contribution per session turn/step. This makes retries and
92
- // replacement messages update a logical model call instead of double-counting it.
93
- const usageByStep = new Map()
94
- const sessionModel = new Map()
95
- const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
96
- const sessionCount = new Set()
97
- const sessionSeq = new Map()
98
- const chains = new Map()
99
- const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
100
- const aliases = {}
101
- let kvUnit = null
102
- let aliasWriteChain = Promise.resolve()
103
- let balanceCache = { fetchedAt: 0, payload: null }
104
- const requestToken = randomBytes(32).toString('base64url')
105
- const ledgerRecords = new Map()
106
- let ledgerUnit = null
107
- let ledgerReady = Promise.resolve()
108
- let ledgerWriteChain = Promise.resolve()
109
- const LEDGER_VERSION = 1
110
- let ledgerRevision = Date.now()
111
- let disposed = false
112
- let baselineRetryDelay = 1000
113
- let baselineRetryScheduled = false
114
- let baselineFallbackTimer = null
115
- const knownSessionIds = new Set()
116
- let aggregationGeneration = 0
117
- let reconcileHintScheduled = false
118
- let reconcilePending = false
119
- let reconcileInFlight = false
120
- let reconcileTimer = null
121
- const RECONCILE_INTERVAL_MS = 120000
122
- const RECONCILE_HINT_DELAY_MS = 3000
123
-
124
- function dayKey(ms) {
125
- const d = new Date(ms)
126
- return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
127
- }
128
- function dayKeyUtc(ms) {
129
- const d = new Date(ms)
130
- return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
131
- }
132
- function num(v) {
133
- return typeof v === 'number' && Number.isFinite(v) ? v : 0
134
- }
135
- async function safeContextTimeout(ms) {
136
- if (disposed) return false
137
- try {
138
- await ctx.timeout(ms)
139
- return !disposed
140
- } catch (err) {
141
- if (!disposed) console.error('[all-usage] context timer unavailable:', err)
142
- return false
143
- }
144
- }
145
- function resetAggregationState() {
146
- aggregationGeneration += 1
147
- wsMeta.clear()
148
- pathIndex.clear()
149
- memberOf.clear()
150
- byDay.clear()
151
- byDayUtc.clear()
152
- perWorkspace.clear()
153
- perModel.clear()
154
- usageByStep.clear()
155
- sessionModel.clear()
156
- sessionCount.clear()
157
- sessionSeq.clear()
158
- chains.clear()
159
- knownSessionIds.clear()
160
- totals.turns = 0
161
- totals.input = 0
162
- totals.output = 0
163
- totals.cacheRead = 0
164
- totals.cacheWrite = 0
165
- totals.reasoning = 0
166
- scan.started = false
167
- scan.done = false
168
- scan.scanned = 0
169
- scan.total = 0
170
- scan.failed = 0
171
- baselineRetryDelay = 1000
172
- return aggregationGeneration
173
- }
174
- function ensureDay(dayMap, date) {
175
- let day = dayMap.get(date)
176
- if (day === undefined) {
177
- day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set() }
178
- dayMap.set(date, day)
179
- }
180
- return day
181
- }
182
- function ensureWs(wsId) {
183
- let ws = perWorkspace.get(wsId)
184
- if (ws === undefined) {
185
- ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
186
- perWorkspace.set(wsId, ws)
187
- }
188
- return ws
189
- }
190
- function ensureDayWs(day, wsId) {
191
- let w = day.byWs.get(wsId)
192
- if (w === undefined) {
193
- w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
194
- day.byWs.set(wsId, w)
195
- }
196
- return w
197
- }
198
- function ensureModel(model) {
199
- let item = perModel.get(model)
200
- if (item === undefined) {
201
- item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
202
- perModel.set(model, item)
203
- }
204
- return item
205
- }
206
- function ensureDayModel(day, model) {
207
- let item = day.byModel.get(model)
208
- if (item === undefined) {
209
- item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
210
- day.byModel.set(model, item)
211
- }
212
- return item
213
- }
214
- function usageValues(usage) {
215
- return {
216
- input: num(usage && usage.inputTokens),
217
- output: num(usage && usage.outputTokens),
218
- cacheRead: num(usage && usage.cacheReadTokens),
219
- cacheWrite: num(usage && usage.cacheWriteTokens),
220
- reasoning: num(usage && usage.reasoningTokens),
221
- }
222
- }
223
- function adjustValues(target, values, direction) {
224
- target.input += values.input * direction
225
- target.output += values.output * direction
226
- target.cacheRead += values.cacheRead * direction
227
- target.cacheWrite += values.cacheWrite * direction
228
- target.reasoning += values.reasoning * direction
229
- }
230
- function noValues(target) {
231
- return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
232
- }
233
- function adjustDay(dayMap, date, wsId, values, modelId, direction, sid) {
234
- const day = ensureDay(dayMap, date)
235
- if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
236
- adjustValues(day.tokens, values, direction)
237
- const dayWs = ensureDayWs(day, wsId)
238
- adjustValues(dayWs, values, direction)
239
- if (noValues(dayWs)) day.byWs.delete(wsId)
240
- const dayModel = ensureDayModel(day, modelId)
241
- dayModel.calls += direction
242
- adjustValues(dayModel, values, direction)
243
- if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(modelId)
244
- }
245
- function adjustUsage(wsId, time, values, modelId, direction, sid) {
246
- const modelTotals = ensureModel(modelId)
247
- modelTotals.calls += direction
248
- adjustValues(modelTotals, values, direction)
249
- if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(modelId)
250
- adjustValues(totals, values, direction)
251
- const ws = ensureWs(wsId)
252
- adjustValues(ws, values, direction)
253
- adjustDay(byDay, dayKey(time), wsId, values, modelId, direction, sid)
254
- adjustDay(byDayUtc, dayKeyUtc(time), wsId, values, modelId, direction, sid)
255
- }
256
- function usageStepKey(sid, data, seq) {
257
- const turn = data && typeof data.turn === 'number' ? data.turn : null
258
- const step = data && typeof data.step === 'number' ? data.step : null
259
- if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
260
- return sid + ':event:' + (typeof seq === 'number' ? seq : String(Date.now()))
261
- }
262
- function addUsage(wsId, time, usage, model, sid, data, seq) {
263
- const values = usageValues(usage)
264
- const modelId = typeof model === 'string' && model !== '' ? model : '未知模型(历史记录缺少路由)'
265
- const eventSeq = typeof seq === 'number' ? seq : -1
266
- // v1.0.7: a usage event carrying no billable token in any bucket must not add a
267
- // meaningless row nor wipe previously recorded real usage (cc-switch
268
- // has_billable_tokens parity). Pure cache-read requests are billable and pass.
269
- if (noValues(values)) return
270
- const key = usageStepKey(sid, data, seq)
271
- const previous = usageByStep.get(key)
272
- // A late replay of an older raw event cannot replace the canonical later step.
273
- if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
274
- if (previous !== undefined) adjustUsage(previous.wsId, previous.time, previous.values, previous.modelId, -1, previous.sid)
275
- const next = { seq: eventSeq, wsId, time, values, modelId, sid }
276
- usageByStep.set(key, next)
277
- adjustUsage(wsId, time, values, modelId, 1, sid)
278
- }
279
- function addDayTurn(dayMap, date, wsId, sid) {
280
- const day = ensureDay(dayMap, date)
281
- if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
282
- day.turns += 1
283
- day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
284
- }
285
- function addTurn(wsId, time, sid) {
286
- ensureWs(wsId).turns += 1
287
- totals.turns += 1
288
- addDayTurn(byDay, dayKey(time), wsId, sid)
289
- addDayTurn(byDayUtc, dayKeyUtc(time), wsId, sid)
290
- }
291
- function routeLabel(route) {
292
- if (route && typeof route.model === 'string' && route.model !== '') return (typeof route.provider === 'string' && route.provider !== '' ? route.provider + ' / ' : '') + route.model
293
- return undefined
294
- }
295
- function modelFromRoute(data) {
296
- return routeLabel(data) || routeLabel(data && data.header && data.header.config)
297
- }
298
- function modelFromMessage(data, fallback) {
299
- return routeLabel(data && data.message && data.message.source) || fallback
300
- }
301
- function nextLedgerRevision() {
302
- ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
303
- return ledgerRevision
304
- }
305
- function ledgerEventKey(event, index) {
306
- return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
307
- }
308
- function buildLedgerRecord(session, workspaceId, source = 'scan', revision) {
309
- const sid = session && typeof session.id === 'string' ? session.id : ''
310
- const events = session && Array.isArray(session.events) ? session.events : []
311
- if (sid === '' || workspaceId === undefined) return null
312
- const turns = new Map()
313
- const usage = new Map()
314
- let currentModel
315
- for (let index = 0; index < events.length; index += 1) {
316
- const event = events[index]
317
- if (event === null || typeof event !== 'object') continue
318
- const data = event.data
319
- if (event.type === 'request/context' || event.type === 'request/header') {
320
- const model = modelFromRoute(data)
321
- if (model !== undefined) currentModel = model
322
- continue
323
- }
324
- if (event.type === 'turn/end') {
325
- const key = ledgerEventKey(event, index)
326
- turns.set(key, { key, time: event.time, workspaceId })
327
- continue
328
- }
329
- if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
330
- const values = usageValues(data.usage)
331
- // v1.0.7: all-zero usage rows carry no billable tokens and stay out of the
332
- // durable ledger (cc-switch has_billable_tokens parity).
333
- if (noValues(values)) continue
334
- const modelId = typeof modelFromMessage(data, currentModel) === 'string' && modelFromMessage(data, currentModel) !== ''
335
- ? modelFromMessage(data, currentModel) : '未知模型(历史记录缺少路由)'
336
- const eventSeq = typeof event.seq === 'number' ? event.seq : -1
337
- const key = usageStepKey(sid, data, event.seq)
338
- const previous = usage.get(key)
339
- if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
340
- usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
341
- }
342
- return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
343
- }
344
- function normalizeLedgerRecord(raw, key) {
345
- if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
346
- if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
347
- const turnMap = new Map()
348
- for (const turn of raw.turns) {
349
- if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, time: turn.time, workspaceId: turn.workspaceId })
350
- }
351
- const usageMap = new Map()
352
- for (const item of raw.usage) {
353
- if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || typeof item.modelId !== 'string' || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
354
- const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, modelId: item.modelId, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
355
- const previous = usageMap.get(normalized.key)
356
- if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
357
- }
358
- const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
359
- ledgerRevision = Math.max(ledgerRevision, updatedAt)
360
- return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
361
- }
362
- function applyLedgerRecord(record) {
363
- if (record === null || record === undefined) return
364
- for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
365
- for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
366
- if (record.turns.length > 0 || record.usage.length > 0) sessionCount.add(record.sessionId)
367
- }
368
- function ledgerRank(record) {
369
- return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
370
- }
371
- function storeLedgerRecord(record) {
372
- const current = ledgerRecords.get(record.sessionId)
373
- if (current !== undefined) {
374
- const nextRank = ledgerRank(record)
375
- const currentRank = ledgerRank(current)
376
- if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
377
- }
378
- ledgerRecords.set(record.sessionId, record)
379
- return record
380
- }
381
- function persistLedgerRecord(record) {
382
- if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
383
- const write = ledgerWriteChain.then(async () => {
384
- if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
385
- })
386
- ledgerWriteChain = write.catch((err) => {
387
- console.error('[all-usage] usage ledger write failed:', err)
388
- })
389
- return ledgerWriteChain
390
- }
391
- function foldEvent(wsId, time, type, data, sid, seq) {
392
- if (type === 'request/context' || type === 'request/header') {
393
- const model = modelFromRoute(data)
394
- if (model !== undefined) sessionModel.set(sid, model)
395
- } else if (type === 'turn/end') addTurn(wsId, time, sid)
396
- else if (type === 'assistant/message' && data && data.usage) addUsage(wsId, time, data.usage, modelFromMessage(data, sessionModel.get(sid)), sid, data, seq)
397
- }
398
- function foldEvents(wsId, events, fromSeq, sid) {
399
- for (const ev of events) {
400
- if (fromSeq !== undefined) {
401
- const s = typeof ev.seq === 'number' ? ev.seq : -1
402
- if (s <= fromSeq) continue
403
- }
404
- 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)
405
- }
406
- }
407
- function lastSeqOf(events) {
408
- let last = 0
409
- for (const ev of events) {
410
- const s = typeof ev.seq === 'number' ? ev.seq : -1
411
- if (s > last) last = s
412
- }
413
- return last
414
- }
415
- function enqueue(sid, task) {
416
- const prev = chains.get(sid) || Promise.resolve()
417
- const next = prev.then(() => task(), () => task())
418
- chains.set(sid, next)
419
- return next
420
- }
421
- function wsForLiveSession(session, sid) {
422
- let wsId = memberOf.get(sid)
423
- if (wsId !== undefined) return wsId
424
- const header = session && session.header
425
- const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
426
- if (cwd === '') return undefined
427
- wsId = pathIndex.get(cwd)
428
- if (wsId !== undefined) memberOf.set(sid, wsId)
429
- return wsId
430
- }
431
- async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
432
- if (disposed || generation !== aggregationGeneration) return
433
- const seq = typeof event.seq === 'number' ? event.seq : -1
434
- const last = sessionSeq.get(sid)
435
- if (last === undefined) {
436
- try {
437
- const snap = await ctx.sessionQuery.readSession(sid)
438
- if (disposed || generation !== aggregationGeneration) return
439
- if (snap && Array.isArray(snap.events)) {
440
- foldEvents(wsId, snap.events, undefined, sid)
441
- sessionSeq.set(sid, lastSeqOf(snap.events))
442
- sessionCount.add(sid)
443
- }
444
- } catch (err) { /* retry on the next event */ }
445
- return
446
- }
447
- if (seq <= last) return
448
- if (seq > last + 1) {
449
- try {
450
- const snap = await ctx.sessionQuery.readSession(sid)
451
- if (disposed || generation !== aggregationGeneration) return
452
- if (snap && Array.isArray(snap.events)) {
453
- foldEvents(wsId, snap.events, last, sid)
454
- sessionSeq.set(sid, lastSeqOf(snap.events))
455
- }
456
- } catch (err) { /* keep last; retry later */ }
457
- return
458
- }
459
- foldEvent(wsId, event.time, event.type, event.data, sid, event.seq)
460
- sessionSeq.set(sid, seq)
461
- sessionCount.add(sid)
462
- }
463
-
464
- // ---------- durable usage ledger ----------
465
- async function loadLedger() {
466
- if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
467
- try {
468
- const backend = storage.backend.get('json')
469
- if (backend === undefined || backend === null || backend.kv === undefined) return
470
- const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
471
- if (disposed) { await unit.close().catch(() => {}); return }
472
- ledgerUnit = unit
473
- const snapshot = await unit.loadAll()
474
- const rows = snapshot && snapshot.tables && snapshot.tables.sessions
475
- if (rows !== null && rows !== undefined && typeof rows === 'object') {
476
- for (const [key, raw] of Object.entries(rows)) {
477
- const record = normalizeLedgerRecord(raw, key)
478
- if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
479
- else ledgerRecords.set(key, record)
480
- }
481
- }
482
- } catch (err) {
483
- console.error('[all-usage] usage ledger unavailable:', err)
484
- }
485
- }
486
-
487
- // ---------- baseline scan over durable logs ----------
488
- function scheduleNativeBaselineRetry(generation, delay) {
489
- if (disposed || baselineFallbackTimer !== null) return
490
- baselineFallbackTimer = setTimeout(() => {
491
- baselineFallbackTimer = null
492
- if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
493
- }, delay)
494
- if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
495
- }
496
- function scheduleBaselineRetry(generation = aggregationGeneration) {
497
- if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
498
- baselineRetryScheduled = true
499
- const delay = baselineRetryDelay
500
- baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
501
- void safeContextTimeout(delay).then((ready) => {
502
- baselineRetryScheduled = false
503
- if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
504
- if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
505
- return undefined
506
- })
507
- }
508
- async function runBaseline(generation = aggregationGeneration) {
509
- if (scan.started || disposed || generation !== aggregationGeneration) return
510
- scan.started = true
511
- await ledgerReady
512
- if (disposed || generation !== aggregationGeneration) return
513
- let setupFailed = false
514
- try {
515
- const workspaces = ctx.workspaceRegistry.list()
516
- for (const w of workspaces) {
517
- const id = w && w.id
518
- const path = w && typeof w.path === 'string' ? w.path : ''
519
- const title = w && typeof w.title === 'string' ? w.title : ''
520
- if (id === undefined) continue
521
- wsMeta.set(id, { id, title, path })
522
- if (path !== '') pathIndex.set(path, id)
523
- if (w && Array.isArray(w.sessionIds)) {
524
- for (const sid of w.sessionIds) memberOf.set(sid, id)
525
- }
526
- }
527
- } catch (err) {
528
- console.error('[all-usage] workspace list failed:', err)
529
- setupFailed = true
530
- }
531
- let records = null
532
- try {
533
- records = await ctx.sessionQuery.listSessions()
534
- } catch (err) {
535
- console.error('[all-usage] session list failed:', err)
536
- }
537
- // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
538
- let snapshots = null
539
- if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
540
- try {
541
- const rows = await sessionPersistence.listSnapshots()
542
- if (Array.isArray(rows)) {
543
- snapshots = new Map()
544
- for (const row of rows) {
545
- const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
546
- if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
547
- }
548
- }
549
- } catch (err) {
550
- console.error('[all-usage] session persistence snapshots unavailable:', err)
551
- }
552
- }
553
- if (disposed || generation !== aggregationGeneration) return
554
- if (setupFailed || !Array.isArray(records)) {
555
- // A transient registry failure must not be reported as a completed empty scan.
556
- scan.started = false
557
- scheduleBaselineRetry(generation)
558
- return
559
- }
560
- scan.total = records.length
561
- const listedSessionIds = new Set()
562
- for (const record of records) {
563
- if (record === undefined || record === null || record.header === undefined) continue
564
- const sid = record.header.id
565
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
566
- const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
567
- if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
568
- }
569
- for (const [sid, record] of ledgerRecords) {
570
- if (!listedSessionIds.has(sid)) applyLedgerRecord(record)
571
- }
572
- for (const record of records) {
573
- if (disposed || generation !== aggregationGeneration) return
574
- if (record === undefined || record === null || record.header === undefined) {
575
- scan.scanned += 1
576
- continue
577
- }
578
- const sid = record.header.id
579
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
580
- const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
581
- if (sid === undefined || wsId === undefined) {
582
- scan.scanned += 1
583
- continue
584
- }
585
- listedSessionIds.add(sid)
586
- await enqueue(sid, async () => {
587
- if (disposed || generation !== aggregationGeneration) return
588
- try {
589
- if (sessionSeq.has(sid)) return
590
- // v1.0.8: when the persisted log revision is unchanged since the last ledger
591
- // write, the whole readSession (full event transfer) is skipped — the ledger
592
- // record is applied directly and the live feed keeps catching new events.
593
- const previousRecord = ledgerRecords.get(sid)
594
- const revision = snapshots === null ? undefined : snapshots.get(sid)
595
- if (previousRecord !== undefined && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
596
- applyLedgerRecord(previousRecord)
597
- for (const item of previousRecord.usage) {
598
- usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
599
- }
600
- sessionSeq.set(sid, previousRecord.lastSeq)
601
- sessionCount.add(sid)
602
- return
603
- }
604
- const snap = await ctx.sessionQuery.readSession(sid)
605
- if (disposed || generation !== aggregationGeneration) return
606
- if (snap && Array.isArray(snap.events)) {
607
- // v1.0.7: incremental seed — the durable ledger doubles as a per-session
608
- // cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
609
- // session applies its canonical record directly and never re-folds;
610
- // a changed session seeds the previous record once, then folds only the
611
- // new tail (previously every listed session was re-read and fully rebuilt).
612
- const currentLastSeq = lastSeqOf(snap.events)
613
- const previous = ledgerRecords.get(sid)
614
- if (previous !== undefined) {
615
- applyLedgerRecord(previous)
616
- // Seed usageByStep so a later live retry of an already-recorded step
617
- // reverses the ledger contribution instead of double-counting it.
618
- for (const item of previous.usage) {
619
- usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
620
- }
621
- if (previous.lastSeq >= currentLastSeq) {
622
- // Content unchanged despite a revision change (rare: ctime-only churn):
623
- // refresh the stored revision so future restarts can skip the read.
624
- if (revision !== undefined && previous.lastRevision !== revision) {
625
- previous.lastRevision = revision
626
- void persistLedgerRecord(previous)
627
- }
628
- sessionSeq.set(sid, previous.lastSeq)
629
- sessionCount.add(sid)
630
- return
631
- }
632
- foldEvents(wsId, snap.events, previous.lastSeq, sid)
633
- } else {
634
- foldEvents(wsId, snap.events, undefined, sid)
635
- }
636
- const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision)
637
- const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
638
- if (canonical === ledger) {
639
- void persistLedgerRecord(ledger)
640
- } else if (canonical !== undefined) {
641
- applyLedgerRecord(canonical)
642
- }
643
- sessionSeq.set(sid, currentLastSeq)
644
- sessionCount.add(sid)
645
- }
646
- } catch (err) {
647
- const saved = ledgerRecords.get(sid)
648
- if (saved !== undefined) {
649
- applyLedgerRecord(saved)
650
- sessionSeq.set(sid, -1)
651
- sessionCount.add(sid)
652
- } else if (generation === aggregationGeneration) {
653
- sessionSeq.set(sid, -1)
654
- scan.failed += 1
655
- }
656
- } finally {
657
- if (generation === aggregationGeneration) scan.scanned += 1
658
- }
659
- })
660
- if (!(await safeContextTimeout(0))) {
661
- scan.started = false
662
- scheduleBaselineRetry(generation)
663
- return
664
- }
665
- }
666
- if (disposed || generation !== aggregationGeneration) return
667
- await ledgerWriteChain
668
- if (disposed || generation !== aggregationGeneration) return
669
- knownSessionIds.clear()
670
- for (const sid of listedSessionIds) knownSessionIds.add(sid)
671
- scan.done = true
672
- if (reconcilePending) scheduleReconcileHint()
673
- }
674
-
675
- function sessionIdsFromRecords(records) {
676
- const ids = new Set()
677
- for (const record of records) {
678
- if (record === undefined || record === null || record.header === undefined) continue
679
- const sid = record.header.id
680
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
681
- const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
682
- if (sid !== undefined && wsId !== undefined) ids.add(sid)
683
- }
684
- return ids
685
- }
686
- async function reconcileSessions() {
687
- if (disposed || reconcileInFlight || !scan.done) return
688
- reconcilePending = false
689
- reconcileInFlight = true
690
- try {
691
- const records = await ctx.sessionQuery.listSessions()
692
- if (disposed || !Array.isArray(records)) return
693
- const currentIds = sessionIdsFromRecords(records)
694
- let removed = false
695
- for (const sid of knownSessionIds) {
696
- if (!currentIds.has(sid)) { removed = true; break }
697
- }
698
- if (removed && !disposed && scan.done) {
699
- console.info('[all-usage] session removal detected; rebuilding usage index')
700
- const generation = resetAggregationState()
701
- void runBaseline(generation)
702
- return
703
- }
704
- knownSessionIds.clear()
705
- for (const sid of currentIds) knownSessionIds.add(sid)
706
- } catch (err) {
707
- console.error('[all-usage] session reconciliation failed:', err)
708
- } finally {
709
- reconcileInFlight = false
710
- if (reconcilePending && !disposed) scheduleReconcileHint()
711
- }
712
- }
713
- function scheduleReconcileHint() {
714
- if (disposed) return
715
- reconcilePending = true
716
- if (reconcileHintScheduled || reconcileInFlight) return
717
- reconcileHintScheduled = true
718
- void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
719
- reconcileHintScheduled = false
720
- if (ready && !disposed) void reconcileSessions()
721
- }, () => {
722
- reconcileHintScheduled = false
723
- })
724
- }
725
- function scheduleReconcileTimer() {
726
- if (disposed || reconcileTimer !== null) return
727
- reconcileTimer = setTimeout(() => {
728
- reconcileTimer = null
729
- if (!disposed) {
730
- void reconcileSessions()
731
- scheduleReconcileTimer()
732
- }
733
- }, RECONCILE_INTERVAL_MS)
734
- if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
735
- }
736
-
737
- // ---------- live feed ----------
738
- ctx.on('session/event', (session, event) => {
739
- if (disposed) return
740
- if (event === undefined || event === null) return
741
- const type = event.type
742
- if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'request/context' && type !== 'request/header') return
743
- const sid = session && session.id
744
- if (typeof sid !== 'string') return
745
- const wsId = wsForLiveSession(session, sid)
746
- if (wsId === undefined) return
747
- const generation = aggregationGeneration
748
- knownSessionIds.add(sid)
749
- enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
750
- })
751
- ctx.on('session/flush', async (session) => {
752
- if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
753
- await ledgerReady
754
- if (disposed) return
755
- const wsId = wsForLiveSession(session, session.id)
756
- if (wsId === undefined) return
757
- const ledger = buildLedgerRecord(session, wsId, 'flush')
758
- if (ledger === null) return
759
- const canonical = storeLedgerRecord(ledger)
760
- if (canonical === ledger) await persistLedgerRecord(ledger)
761
- })
762
- ctx.on('session/disposed', () => {
763
- if (!disposed) scheduleReconcileHint()
764
- })
765
-
766
- // ---------- workspace aliases (durable, schema-free KV unit) ----------
767
- async function loadAliases() {
768
- if (storage === undefined) return
769
- try {
770
- const backend = storage.backend.get('json')
771
- if (backend === undefined || backend === null || backend.kv === undefined) return
772
- const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
773
- kvUnit = unit
774
- const snap = await unit.loadAll()
775
- const g = snap && snap.global
776
- if (g !== null && g !== undefined && typeof g === 'object') {
777
- for (const key of Object.keys(g)) {
778
- const value = g[key]
779
- if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
780
- }
781
- }
782
- } catch (err) {
783
- console.error('[all-usage] alias storage unavailable:', err)
784
- }
785
- }
786
- function persistAliases() {
787
- const snapshotAliases = {}
788
- for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
789
- aliasWriteChain = aliasWriteChain.then(() => {
790
- if (kvUnit === null || kvUnit === undefined) return undefined
791
- return kvUnit.setGlobal(snapshotAliases).catch((err) => {
792
- console.error('[all-usage] alias persist failed:', err)
793
- })
794
- })
795
- }
796
- function setAlias(wsId, raw) {
797
- const alias = typeof raw === 'string' ? raw.trim().slice(0, 80) : ''
798
- if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
799
- if (alias === '') delete aliases[wsId]
800
- else aliases[wsId] = alias
801
- persistAliases()
802
- return { ok: true, aliases: Object.assign({}, aliases) }
803
- }
804
- ctx.effect(() => () => {
805
- disposed = true
806
- if (reconcileTimer !== null) {
807
- clearTimeout(reconcileTimer)
808
- reconcileTimer = null
809
- }
810
- if (baselineFallbackTimer !== null) {
811
- clearTimeout(baselineFallbackTimer)
812
- baselineFallbackTimer = null
813
- }
814
- })
815
- ctx.effect(() => () => {
816
- const unit = kvUnit
817
- kvUnit = null
818
- if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
819
- })
820
- ctx.effect(() => async () => {
821
- const unit = ledgerUnit
822
- await ledgerWriteChain
823
- if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
824
- if (ledgerUnit === unit) ledgerUnit = null
825
- })
826
-
827
- // ---------- snapshot for the client ----------
828
- function serializeDays(dayMap) {
829
- const result = []
830
- for (const pair of dayMap) {
831
- const date = pair[0]
832
- const day = pair[1]
833
- result.push({
834
- date,
835
- turns: day.turns,
836
- sessions: day.sessionIds.size,
837
- sessionIds: Array.from(day.sessionIds).sort(),
838
- tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
839
- perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
840
- 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 })),
841
- byModel: Array.from(day.byModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
842
- })
843
- }
844
- result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
845
- return result
846
- }
847
- function snapshot() {
848
- return {
849
- scan: { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed },
850
- generatedAt: Date.now(),
851
- requestToken,
852
- workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
853
- aliases: Object.assign({}, aliases),
854
- tokenSemantics: {
855
- processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
856
- cacheRead: 'reused context tokens; not newly generated output',
857
- cacheWrite: 'tokens written into a provider cache',
858
- // v1.0.7: structured, machine-readable accounting semantics (cc-switch
859
- // input_token_semantics parity). DSH reports input as fresh (cache read /
860
- // cache write sit in their own buckets) — verified against real ledger data;
861
- // reasoning is bucketed separately from output and assumed non-overlapping.
862
- semantics: {
863
- input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
864
- buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
865
- inputIncludesCache: false,
866
- cacheBucketed: true,
867
- reasoningSeparate: true,
868
- gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
869
- },
870
- },
871
- totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
872
- 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 })),
873
- perModel: Array.from(perModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
874
- byDay: serializeDays(byDay),
875
- byDayUtc: serializeDays(byDayUtc),
876
- }
877
- }
878
-
879
- // ---------- account balance (DeepSeek open platform) ----------
880
- async function requestBalance(url, key) {
881
- let controller = null
882
- let timer = null
883
- try {
884
- if (typeof AbortController === 'function') {
885
- controller = new AbortController()
886
- timer = setTimeout(() => controller.abort(), 30000)
887
- }
888
- const response = await fetch(url, {
889
- method: 'GET',
890
- headers: { accept: 'application/json', authorization: 'Bearer ' + key },
891
- ...(controller === null ? {} : { signal: controller.signal }),
892
- })
893
- return { ok: response.ok, status: response.status, text: await response.text() }
894
- } catch (err) {
895
- return { ok: false, status: 0, text: '', error: 'network request failed' }
896
- } finally {
897
- if (timer !== null) clearTimeout(timer)
898
- }
899
- }
900
- function moneyOf(v) {
901
- if (typeof v === 'number' && Number.isFinite(v)) return v
902
- if (typeof v === 'string' && v.trim() !== '') {
903
- const n = parseFloat(v)
904
- if (Number.isFinite(n)) return n
905
- }
906
- return null
907
- }
908
- function parseBalance(text) {
909
- let obj = null
910
- try {
911
- obj = JSON.parse(String(text).replace(/^\uFEFF/, ''))
912
- } catch (err) {
913
- return null
914
- }
915
- if (obj === null || typeof obj !== 'object') return null
916
- if (obj.is_available === false) return { unavailable: true, currencies: [] }
917
- const infos = (Array.isArray(obj.balance_infos) && obj.balance_infos) || (Array.isArray(obj.balance) && obj.balance) || null
918
- if (!infos) return null
919
- const out = []
920
- for (const info of infos) {
921
- if (info === null || typeof info !== 'object') continue
922
- if (typeof info.currency !== 'string') continue
923
- out.push({
924
- currency: info.currency,
925
- total: moneyOf(info.total_balance !== undefined ? info.total_balance : info.balance),
926
- granted: moneyOf(info.granted_balance),
927
- toppedUp: moneyOf(info.topped_up_balance),
928
- })
929
- }
930
- return { unavailable: false, currencies: out }
931
- }
932
- async function fetchBalance(force) {
933
- const now = Date.now()
934
- if (force !== true && balanceCache.payload !== null && now - balanceCache.fetchedAt < 300000) return balanceCache.payload
935
- let ref = 'DEEPSEEK_API_KEY'
936
- if (settings !== undefined) {
937
- try {
938
- const section = settings.get('llm-deepseek')
939
- if (section !== null && typeof section === 'object' && typeof section.apiKeyEnv === 'string' && section.apiKeyEnv.length > 0) ref = section.apiKeyEnv
940
- } catch (err) { /* default ref */ }
941
- }
942
- let key
943
- if (credentials !== undefined) {
944
- try {
945
- const hit = await credentials.resolve(ref)
946
- if (hit !== null && hit !== undefined && typeof hit.value === 'string' && hit.value.length > 0) key = hit.value
947
- } catch (err) { /* unconfigured */ }
948
- }
949
- if (key === undefined) {
950
- const payload = { status: 'missing-key' }
951
- balanceCache = { fetchedAt: now, payload }
952
- return payload
953
- }
954
- if (typeof fetch !== 'function') {
955
- const payload = { status: 'error', message: '当前 DSH 运行时不支持余额查询' }
956
- balanceCache = { fetchedAt: now, payload }
957
- return payload
958
- }
959
- const result = await requestBalance('https://api.deepseek.com/user/balance', key)
960
- const body = (result.text || '').trim()
961
- if (result.ok && body.length > 0) {
962
- const parsed = parseBalance(body)
963
- if (parsed !== null && parsed.unavailable) {
964
- const payload = { status: 'unavailable', message: 'DeepSeek 接口返回余额不可用(is_available=false)' }
965
- balanceCache = { fetchedAt: now, payload }
966
- return payload
967
- }
968
- if (parsed !== null && parsed.currencies.length > 0) {
969
- const payload = { status: 'ok', currencies: parsed.currencies, fetchedAt: now }
970
- balanceCache = { fetchedAt: now, payload }
971
- return payload
972
- }
973
- }
974
- const detail = body.length > 0 ? body.slice(0, 300) : (result.status > 0 ? 'HTTP ' + result.status : result.error || 'network request failed')
975
- const payload = { status: 'error', message: '余额查询失败', detail }
976
- balanceCache = { fetchedAt: now, payload }
977
- return payload
978
- }
979
-
980
- // ---------- HTTP data routes for the client half ----------
981
- if (webServer !== undefined) {
982
- const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
983
- ctx.effect(() => webServer.register({
984
- kind: 'exact',
985
- path: '/api/all-usage',
986
- handler: (req, res) => {
987
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
988
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
989
- if (!scan.started) void runBaseline()
990
- sendJson(res, 200, snapshot())
991
- },
992
- }))
993
- ctx.effect(() => webServer.register({
994
- kind: 'exact',
995
- path: '/api/all-usage/balance',
996
- handler: async (req, res) => {
997
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
998
- // Browsers may omit Origin on same-origin GET; the process token remains required.
999
- if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
1000
- let force = false
1001
- try {
1002
- const url = new URL(req.url ?? '/', 'http://x')
1003
- force = url.searchParams.get('force') === '1'
1004
- } catch (err) { /* default */ }
1005
- sendJson(res, 200, await fetchBalance(force))
1006
- },
1007
- }))
1008
- ctx.effect(() => webServer.register({
1009
- kind: 'exact',
1010
- path: '/api/all-usage/alias',
1011
- handler: async (req, res) => {
1012
- if (req.method !== 'POST') {
1013
- res.statusCode = 405
1014
- res.end()
1015
- return
1016
- }
1017
- if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
1018
- const body = await readBody(req, 16 * 1024)
1019
- let args = null
1020
- try {
1021
- args = JSON.parse(body)
1022
- } catch (err) { /* invalid json */ }
1023
- const result = args !== null && args !== undefined && typeof args.workspaceId === 'string'
1024
- ? setAlias(args.workspaceId, args.alias)
1025
- : { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
1026
- sendJson(res, 200, result)
1027
- },
1028
- }))
1029
- }
1030
-
1031
- // ---------- start the historical backfill immediately ----------
1032
- ledgerReady = loadLedger()
1033
- void runBaseline()
1034
- scheduleReconcileTimer()
1035
- void loadAliases()
1036
- }
1037
-
1038
- export { name, inject, apply }
1039
- export default { name, inject, apply }
1
+ // dsh-all-usage 插件 Host 半(永久版)
2
+ // 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
3
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
4
+
5
+ const name = 'dsh-all-usage'
6
+ const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
7
+
8
+ // webServer route handlers do not inherit the connection API fence; keep this plugin
9
+ // local and require a browser-originated capability for state-changing reads/writes.
10
+ function requestHeader(req, name) {
11
+ const headers = req && req.headers
12
+ if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
13
+ const value = headers[name.toLowerCase()]
14
+ if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
15
+ return typeof value === 'string' ? value : undefined
16
+ }
17
+
18
+ function isLoopbackHostname(hostname) {
19
+ if (hostname === 'localhost' || hostname === '[::1]') return true
20
+ const parts = hostname.split('.')
21
+ return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ function isTrustedLocalApiRequest(req, requireOrigin) {
25
+ const host = requestHeader(req, 'host')
26
+ if (host === undefined) return false
27
+ let hostUrl
28
+ try {
29
+ hostUrl = new URL('http://' + host)
30
+ } catch (err) {
31
+ return false
32
+ }
33
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
34
+ if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
35
+ const origin = requestHeader(req, 'origin')
36
+ if (origin === undefined) return requireOrigin !== true
37
+ try {
38
+ const originUrl = new URL(origin)
39
+ return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
40
+ } catch (err) {
41
+ return false
42
+ }
43
+ }
44
+
45
+ function hasWriteToken(req, expected) {
46
+ const actual = requestHeader(req, 'x-all-usage-request-token')
47
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false
48
+ const actualBytes = Buffer.from(actual)
49
+ const expectedBytes = Buffer.from(expected)
50
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
51
+ }
52
+
53
+ function sendJson(res, code, value) {
54
+ res.statusCode = code
55
+ res.setHeader('content-type', 'application/json; charset=utf-8')
56
+ res.setHeader('cache-control', 'no-store')
57
+ res.end(JSON.stringify(value))
58
+ }
59
+
60
+ function readBody(req, maxBytes) {
61
+ return new Promise((resolve) => {
62
+ const chunks = []
63
+ let size = 0
64
+ req.on('data', (chunk) => {
65
+ size += chunk.length
66
+ if (size <= maxBytes) chunks.push(chunk)
67
+ })
68
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
69
+ req.on('error', () => resolve(''))
70
+ })
71
+ }
72
+
73
+ function apply(ctx) {
74
+ const credentials = ctx.get('credentials')
75
+ const settings = ctx.get('settings')
76
+ const storage = ctx.get('storage')
77
+ const webServer = ctx.get('webServer')
78
+ // v1.0.8: optional sessionPersistence exposes listSnapshots() — a cheap per-session
79
+ // revision (header line + stat, no full-log read) that lets the baseline skip
80
+ // re-reading unchanged sessions after a DSH restart.
81
+ const sessionPersistence = ctx.get('sessionPersistence')
82
+
83
+ // ---------- owned aggregation state ----------
84
+ const wsMeta = new Map()
85
+ const pathIndex = new Map()
86
+ const memberOf = new Map()
87
+ const byDay = new Map()
88
+ const byDayUtc = new Map()
89
+ const perWorkspace = new Map()
90
+ const perModel = new Map()
91
+ // One canonical usage contribution per session turn/step. This makes retries and
92
+ // replacement messages update a logical model call instead of double-counting it.
93
+ const usageByStep = new Map()
94
+ const turnRecords = new Map()
95
+ const sessionModel = new Map()
96
+ const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
97
+ const sessionCount = new Set()
98
+ const sessionSeq = new Map()
99
+ const chains = new Map()
100
+ const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
101
+ const aliases = {}
102
+ let kvUnit = null
103
+ let aliasWriteChain = Promise.resolve()
104
+ let balanceCache = { fetchedAt: 0, payload: null }
105
+ const requestToken = randomBytes(32).toString('base64url')
106
+ // A non-secret identity distinguishes HMR/restart revision resets from a stale page.
107
+ const instanceId = randomBytes(12).toString('base64url')
108
+ let statsRevision = 0
109
+ let statsUpdatedAt = Date.now()
110
+ let statsDirtyScheduled = false
111
+ const sync = {
112
+ lastStartedAt: 0,
113
+ lastCompletedAt: 0,
114
+ lastErrorAt: 0,
115
+ lastErrorCode: null,
116
+ persistenceSnapshotsAvailable: false,
117
+ sessionsTotal: 0,
118
+ sessionsRead: 0,
119
+ sessionsSkippedByRevision: 0,
120
+ sessionsRestoredFromLedger: 0,
121
+ sessionsFailed: 0,
122
+ }
123
+ const ledgerRecords = new Map()
124
+ const queryCache = new Map()
125
+ let recordsIndex = null
126
+ let ledgerUnit = null
127
+ let ledgerReady = Promise.resolve()
128
+ let ledgerWriteChain = Promise.resolve()
129
+ const LEDGER_VERSION = 2
130
+ const LEGACY_LEDGER_VERSION = 1
131
+ let ledgerRevision = Date.now()
132
+ let disposed = false
133
+ let baselineRetryDelay = 1000
134
+ let baselineRetryScheduled = false
135
+ let baselineFallbackTimer = null
136
+ const knownSessionIds = new Set()
137
+ let aggregationGeneration = 0
138
+ let reconcileHintScheduled = false
139
+ let reconcilePending = false
140
+ let reconcileInFlight = false
141
+ let reconcileTimer = null
142
+ const RECONCILE_INTERVAL_MS = 120000
143
+ const RECONCILE_HINT_DELAY_MS = 3000
144
+
145
+ function dayKey(ms) {
146
+ const d = new Date(ms)
147
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
148
+ }
149
+ function dayKeyUtc(ms) {
150
+ const d = new Date(ms)
151
+ return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
152
+ }
153
+ function dateKeys(ms) {
154
+ const d = new Date(ms)
155
+ const local = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
156
+ const utc = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
157
+ return { local, utc }
158
+ }
159
+ function num(v) {
160
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0
161
+ }
162
+ // Coalesce synchronous aggregation writes without adding a timer lifecycle.
163
+ function markStatsChanged() {
164
+ recordsIndex = null
165
+ if (disposed || statsDirtyScheduled) return
166
+ statsDirtyScheduled = true
167
+ const flush = () => {
168
+ statsDirtyScheduled = false
169
+ if (disposed) return
170
+ statsRevision += 1
171
+ statsUpdatedAt = Date.now()
172
+ }
173
+ if (typeof queueMicrotask === 'function') queueMicrotask(flush)
174
+ else Promise.resolve().then(flush)
175
+ }
176
+ function resetSyncState() {
177
+ sync.lastStartedAt = 0
178
+ sync.lastCompletedAt = 0
179
+ sync.lastErrorAt = 0
180
+ sync.lastErrorCode = null
181
+ sync.persistenceSnapshotsAvailable = false
182
+ sync.sessionsTotal = 0
183
+ sync.sessionsRead = 0
184
+ sync.sessionsSkippedByRevision = 0
185
+ sync.sessionsRestoredFromLedger = 0
186
+ sync.sessionsFailed = 0
187
+ }
188
+ function beginSync() {
189
+ resetSyncState()
190
+ sync.lastStartedAt = Date.now()
191
+ markStatsChanged()
192
+ }
193
+ function noteSyncError(code) {
194
+ sync.lastErrorAt = Date.now()
195
+ sync.lastErrorCode = code
196
+ markStatsChanged()
197
+ }
198
+ function syncSnapshot() {
199
+ return {
200
+ lastStartedAt: sync.lastStartedAt,
201
+ lastCompletedAt: sync.lastCompletedAt,
202
+ lastErrorAt: sync.lastErrorAt,
203
+ lastErrorCode: sync.lastErrorCode,
204
+ persistenceSnapshotsAvailable: sync.persistenceSnapshotsAvailable,
205
+ sessionsTotal: sync.sessionsTotal,
206
+ sessionsRead: sync.sessionsRead,
207
+ sessionsSkippedByRevision: sync.sessionsSkippedByRevision,
208
+ sessionsRestoredFromLedger: sync.sessionsRestoredFromLedger,
209
+ sessionsFailed: sync.sessionsFailed,
210
+ }
211
+ }
212
+ async function safeContextTimeout(ms) {
213
+ if (disposed) return false
214
+ try {
215
+ await ctx.timeout(ms)
216
+ return !disposed
217
+ } catch (err) {
218
+ if (!disposed) console.error('[all-usage] context timer unavailable:', err)
219
+ return false
220
+ }
221
+ }
222
+ function resetAggregationState() {
223
+ aggregationGeneration += 1
224
+ wsMeta.clear()
225
+ pathIndex.clear()
226
+ memberOf.clear()
227
+ byDay.clear()
228
+ byDayUtc.clear()
229
+ perWorkspace.clear()
230
+ perModel.clear()
231
+ usageByStep.clear()
232
+ turnRecords.clear()
233
+ queryCache.clear()
234
+ recordsIndex = null
235
+ sessionModel.clear()
236
+ sessionCount.clear()
237
+ sessionSeq.clear()
238
+ chains.clear()
239
+ knownSessionIds.clear()
240
+ totals.turns = 0
241
+ totals.input = 0
242
+ totals.output = 0
243
+ totals.cacheRead = 0
244
+ totals.cacheWrite = 0
245
+ totals.reasoning = 0
246
+ scan.started = false
247
+ scan.done = false
248
+ scan.scanned = 0
249
+ scan.total = 0
250
+ scan.failed = 0
251
+ resetSyncState()
252
+ baselineRetryDelay = 1000
253
+ markStatsChanged()
254
+ return aggregationGeneration
255
+ }
256
+ function ensureDay(dayMap, date) {
257
+ let day = dayMap.get(date)
258
+ if (day === undefined) {
259
+ day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set() }
260
+ dayMap.set(date, day)
261
+ }
262
+ return day
263
+ }
264
+ function ensureWs(wsId) {
265
+ let ws = perWorkspace.get(wsId)
266
+ if (ws === undefined) {
267
+ ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
268
+ perWorkspace.set(wsId, ws)
269
+ }
270
+ return ws
271
+ }
272
+ function ensureDayWs(day, wsId) {
273
+ let w = day.byWs.get(wsId)
274
+ if (w === undefined) {
275
+ w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
276
+ day.byWs.set(wsId, w)
277
+ }
278
+ return w
279
+ }
280
+ function ensureModel(value) {
281
+ const identity = coerceIdentity(value)
282
+ let item = perModel.get(identity.identityKey)
283
+ if (item === undefined) {
284
+ item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
285
+ perModel.set(identity.identityKey, item)
286
+ }
287
+ return item
288
+ }
289
+ function ensureDayModel(day, value) {
290
+ const identity = coerceIdentity(value)
291
+ let item = day.byModel.get(identity.identityKey)
292
+ if (item === undefined) {
293
+ item = { ...identity, model: identity.label, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
294
+ day.byModel.set(identity.identityKey, item)
295
+ }
296
+ return item
297
+ }
298
+ function usageValues(usage) {
299
+ return {
300
+ input: num(usage && usage.inputTokens),
301
+ output: num(usage && usage.outputTokens),
302
+ cacheRead: num(usage && usage.cacheReadTokens),
303
+ cacheWrite: num(usage && usage.cacheWriteTokens),
304
+ reasoning: num(usage && usage.reasoningTokens),
305
+ }
306
+ }
307
+ function adjustValues(target, values, direction) {
308
+ target.input += values.input * direction
309
+ target.output += values.output * direction
310
+ target.cacheRead += values.cacheRead * direction
311
+ target.cacheWrite += values.cacheWrite * direction
312
+ target.reasoning += values.reasoning * direction
313
+ }
314
+ function noValues(target) {
315
+ return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
316
+ }
317
+ function adjustDay(dayMap, date, wsId, values, identity, direction, sid) {
318
+ const day = ensureDay(dayMap, date)
319
+ if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
320
+ adjustValues(day.tokens, values, direction)
321
+ const dayWs = ensureDayWs(day, wsId)
322
+ adjustValues(dayWs, values, direction)
323
+ if (noValues(dayWs)) day.byWs.delete(wsId)
324
+ const dayModel = ensureDayModel(day, identity)
325
+ dayModel.calls += direction
326
+ adjustValues(dayModel, values, direction)
327
+ if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(dayModel.identityKey)
328
+ }
329
+ function adjustUsage(wsId, time, values, identity, direction, sid, cachedDates) {
330
+ const normalized = coerceIdentity(identity)
331
+ const dates = cachedDates && typeof cachedDates.local === 'string' && typeof cachedDates.utc === 'string' ? cachedDates : dateKeys(time)
332
+ const modelTotals = ensureModel(normalized)
333
+ modelTotals.calls += direction
334
+ adjustValues(modelTotals, values, direction)
335
+ if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(normalized.identityKey)
336
+ adjustValues(totals, values, direction)
337
+ const ws = ensureWs(wsId)
338
+ adjustValues(ws, values, direction)
339
+ adjustDay(byDay, dates.local, wsId, values, normalized, direction, sid)
340
+ adjustDay(byDayUtc, dates.utc, wsId, values, normalized, direction, sid)
341
+ }
342
+ function usageStepKey(sid, data, seq) {
343
+ const turn = data && typeof data.turn === 'number' ? data.turn : null
344
+ const step = data && typeof data.step === 'number' ? data.step : null
345
+ if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
346
+ return sid + ':event:' + (typeof seq === 'number' ? seq : String(Date.now()))
347
+ }
348
+ function addUsage(wsId, time, usage, model, sid, data, seq, materialization = 'live') {
349
+ const values = usageValues(usage)
350
+ const identity = coerceIdentity(model)
351
+ const eventSeq = typeof seq === 'number' ? seq : -1
352
+ // v1.0.7: a usage event carrying no billable token in any bucket must not add a
353
+ // meaningless row nor wipe previously recorded real usage (cc-switch
354
+ // has_billable_tokens parity). Pure cache-read requests are billable and pass.
355
+ if (noValues(values)) return
356
+ const dates = dateKeys(time)
357
+ const key = usageStepKey(sid, data, seq)
358
+ const previous = usageByStep.get(key)
359
+ // A late replay of an older raw event cannot replace the canonical later step.
360
+ if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
361
+ if (previous !== undefined) adjustUsage(previous.wsId, previous.time, previous.values, previous.identity || previous.modelId, -1, previous.sid, { local: previous.date, utc: previous.dateUtc })
362
+ const next = { seq: eventSeq, wsId, time, date: dates.local, dateUtc: dates.utc, values, identity, modelId: identity.label, turn: data && typeof data.turn === 'number' ? data.turn : null, step: data && typeof data.step === 'number' ? data.step : null, materialization, sid }
363
+ usageByStep.set(key, next)
364
+ adjustUsage(wsId, time, values, identity, 1, sid, dates)
365
+ markStatsChanged()
366
+ }
367
+ function addDayTurn(dayMap, date, wsId, sid) {
368
+ const day = ensureDay(dayMap, date)
369
+ if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
370
+ day.turns += 1
371
+ day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
372
+ }
373
+ function turnRecordKey(sid, turn, seq, time) {
374
+ if (typeof turn === 'number' && Number.isFinite(turn)) return sid + ':turn:' + turn
375
+ if (typeof seq === 'number' && Number.isFinite(seq)) return sid + ':event:' + seq
376
+ return sid + ':time:' + String(time)
377
+ }
378
+ function addTurn(wsId, time, sid, turn, identity, materialization = 'live', seq) {
379
+ const key = turnRecordKey(sid, turn, seq, time)
380
+ if (turnRecords.has(key)) return
381
+ const normalized = coerceIdentity(identity)
382
+ const dates = dateKeys(time)
383
+ turnRecords.set(key, { key, sid, wsId, time, date: dates.local, dateUtc: dates.utc, turn: typeof turn === 'number' ? turn : null, identity: normalized, materialization })
384
+ ensureWs(wsId).turns += 1
385
+ totals.turns += 1
386
+ addDayTurn(byDay, dates.local, wsId, sid)
387
+ addDayTurn(byDayUtc, dates.utc, wsId, sid)
388
+ markStatsChanged()
389
+ }
390
+ const UNKNOWN_MODEL_LABEL = '未知模型(历史记录缺少路由)'
391
+ function textOrNull(value) {
392
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
393
+ }
394
+ function identityLabel(provider, requestedModel, actualModel, legacyLabel) {
395
+ const model = actualModel || requestedModel
396
+ if (model !== null) return provider === null ? model : provider + ' / ' + model
397
+ return legacyLabel || UNKNOWN_MODEL_LABEL
398
+ }
399
+ function makeIdentity(provider, requestedModel, actualModel, legacyLabel) {
400
+ const normalizedProvider = textOrNull(provider)
401
+ const normalizedRequested = textOrNull(requestedModel)
402
+ const normalizedActual = textOrNull(actualModel)
403
+ const normalizedLegacy = textOrNull(legacyLabel)
404
+ const key = JSON.stringify([normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy])
405
+ return {
406
+ identityKey: key,
407
+ provider: normalizedProvider,
408
+ requestedModel: normalizedRequested,
409
+ actualModel: normalizedActual,
410
+ label: identityLabel(normalizedProvider, normalizedRequested, normalizedActual, normalizedLegacy),
411
+ legacy: normalizedLegacy !== null && normalizedProvider === null && normalizedRequested === null && normalizedActual === null,
412
+ }
413
+ }
414
+ function identityFromLegacy(label) {
415
+ return makeIdentity(null, null, null, textOrNull(label) || UNKNOWN_MODEL_LABEL)
416
+ }
417
+ function isCanonicalIdentity(value) {
418
+ 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'
419
+ }
420
+ function coerceIdentity(value) {
421
+ if (isCanonicalIdentity(value)) return value
422
+ if (value !== null && typeof value === 'object') {
423
+ return makeIdentity(value.provider, value.requestedModel, value.actualModel, value.legacyLabel || (value.legacy === true ? value.label : null))
424
+ }
425
+ if (typeof value === 'string' && value !== '') return identityFromLegacy(value)
426
+ return makeIdentity(null, null, null, UNKNOWN_MODEL_LABEL)
427
+ }
428
+ function routeObject(data) {
429
+ if (data && typeof data === 'object' && (data.provider !== undefined || data.model !== undefined)) return data
430
+ const config = data && data.header && data.header.config
431
+ return config && typeof config === 'object' ? config : null
432
+ }
433
+ function identityFromRoute(data, fallback) {
434
+ const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
435
+ const route = routeObject(data)
436
+ if (route === null) return base
437
+ return makeIdentity(route.provider === undefined ? base.provider : route.provider, route.model === undefined ? base.requestedModel : route.model, null, base.legacy ? base.label : null)
438
+ }
439
+ function identityFromMessage(data, fallback) {
440
+ const base = fallback === undefined ? makeIdentity(null, null, null, null) : coerceIdentity(fallback)
441
+ const source = data && data.message && data.message.source
442
+ if (source === null || typeof source !== 'object') return base
443
+ return makeIdentity(source.provider === undefined ? base.provider : source.provider, base.requestedModel || source.model, source.model, base.legacy ? base.label : null)
444
+ }
445
+ function modelFromRoute(data) {
446
+ const identity = identityFromRoute(data)
447
+ return identity.label === UNKNOWN_MODEL_LABEL ? undefined : identity.label
448
+ }
449
+ function modelFromMessage(data, fallback) {
450
+ return identityFromMessage(data, fallback).label
451
+ }
452
+ function nextLedgerRevision() {
453
+ ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
454
+ return ledgerRevision
455
+ }
456
+ function ledgerEventKey(event, index) {
457
+ return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
458
+ }
459
+ function buildLedgerRecord(session, workspaceId, source = 'scan', revision) {
460
+ const sid = session && typeof session.id === 'string' ? session.id : ''
461
+ const events = session && Array.isArray(session.events) ? session.events : []
462
+ if (sid === '' || workspaceId === undefined) return null
463
+ const turns = new Map()
464
+ const usage = new Map()
465
+ let currentIdentity = makeIdentity(null, null, null, null)
466
+ for (let index = 0; index < events.length; index += 1) {
467
+ const event = events[index]
468
+ if (event === null || typeof event !== 'object') continue
469
+ const data = event.data
470
+ if (event.type === 'request/context' || event.type === 'request/header') {
471
+ currentIdentity = identityFromRoute(data, currentIdentity)
472
+ continue
473
+ }
474
+ if (event.type === 'turn/end') {
475
+ const key = ledgerEventKey(event, index)
476
+ const turn = data && typeof data.turn === 'number' ? data.turn : null
477
+ turns.set(key, { key, seq: typeof event.seq === 'number' ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
478
+ continue
479
+ }
480
+ if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
481
+ const values = usageValues(data.usage)
482
+ // v1.0.7: all-zero usage rows carry no billable tokens and stay out of the
483
+ // durable ledger (cc-switch has_billable_tokens parity).
484
+ if (noValues(values)) continue
485
+ const identity = identityFromMessage(data, currentIdentity)
486
+ const eventSeq = typeof event.seq === 'number' ? event.seq : -1
487
+ const key = usageStepKey(sid, data, event.seq)
488
+ const previous = usage.get(key)
489
+ if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
490
+ usage.set(key, {
491
+ key,
492
+ seq: eventSeq,
493
+ time: event.time,
494
+ workspaceId,
495
+ identity,
496
+ modelId: identity.label,
497
+ turn: data && typeof data.turn === 'number' ? data.turn : null,
498
+ step: data && typeof data.step === 'number' ? data.step : null,
499
+ values,
500
+ })
501
+ currentIdentity = identity
502
+ }
503
+ 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()) }
504
+ }
505
+ function normalizeLedgerRecord(raw, key) {
506
+ if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
507
+ if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
508
+ const needsUpgrade = raw.version !== LEDGER_VERSION || raw.usage.some((item) => item === null || typeof item !== 'object' || item.identity === undefined)
509
+ const turnMap = new Map()
510
+ for (const turn of raw.turns) {
511
+ 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) })
512
+ }
513
+ const usageMap = new Map()
514
+ for (const item of raw.usage) {
515
+ if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
516
+ const identity = coerceIdentity(item.identity || item.modelId)
517
+ const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, 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 }) }
518
+ const previous = usageMap.get(normalized.key)
519
+ if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
520
+ }
521
+ const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
522
+ ledgerRevision = Math.max(ledgerRevision, updatedAt)
523
+ const lastUsage = Array.from(usageMap.values()).at(-1)
524
+ const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
525
+ 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()) }
526
+ Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
527
+ return normalizedRecord
528
+ }
529
+ function applyLedgerRecord(record, materialization = 'ledger-reuse') {
530
+ if (record === null || record === undefined) return
531
+ if (record.lastIdentity !== undefined) sessionModel.set(record.sessionId, record.lastIdentity)
532
+ for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
533
+ for (const item of record.usage) {
534
+ const identity = item.identity || identityFromLegacy(item.modelId)
535
+ const dates = dateKeys(item.time)
536
+ adjustUsage(item.workspaceId, item.time, item.values, identity, 1, record.sessionId, dates)
537
+ usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, date: dates.local, dateUtc: dates.utc, values: item.values, identity, modelId: identity.label, turn: item.turn, step: item.step, materialization, sid: record.sessionId })
538
+ }
539
+ if (record.turns.length > 0 || record.usage.length > 0) {
540
+ sessionCount.add(record.sessionId)
541
+ if (record.usage.length > 0) markStatsChanged()
542
+ }
543
+ }
544
+ function ledgerRank(record) {
545
+ return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
546
+ }
547
+ function replaceLedgerRecord(record) {
548
+ ledgerRecords.set(record.sessionId, record)
549
+ return record
550
+ }
551
+ function storeLedgerRecord(record) {
552
+ const current = ledgerRecords.get(record.sessionId)
553
+ if (current !== undefined) {
554
+ const nextRank = ledgerRank(record)
555
+ const currentRank = ledgerRank(current)
556
+ if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
557
+ }
558
+ ledgerRecords.set(record.sessionId, record)
559
+ return record
560
+ }
561
+ function persistLedgerRecord(record) {
562
+ if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
563
+ const write = ledgerWriteChain.then(async () => {
564
+ if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
565
+ })
566
+ ledgerWriteChain = write.catch((err) => {
567
+ console.error('[all-usage] usage ledger write failed:', err)
568
+ })
569
+ return ledgerWriteChain
570
+ }
571
+ function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
572
+ if (type === 'request/context' || type === 'request/header') {
573
+ sessionModel.set(sid, identityFromRoute(data, sessionModel.get(sid)))
574
+ } else if (type === 'turn/end') {
575
+ addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, sessionModel.get(sid), materialization, seq)
576
+ } else if (type === 'assistant/message' && data && data.usage) {
577
+ const identity = identityFromMessage(data, sessionModel.get(sid))
578
+ sessionModel.set(sid, identity)
579
+ addUsage(wsId, time, data.usage, identity, sid, data, seq, materialization)
580
+ }
581
+ }
582
+ function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
583
+ for (const ev of events) {
584
+ if (fromSeq !== undefined) {
585
+ const s = typeof ev.seq === 'number' ? ev.seq : -1
586
+ if (s <= fromSeq) continue
587
+ }
588
+ 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)
589
+ }
590
+ }
591
+ function lastSeqOf(events) {
592
+ let last = 0
593
+ for (const ev of events) {
594
+ const s = typeof ev.seq === 'number' ? ev.seq : -1
595
+ if (s > last) last = s
596
+ }
597
+ return last
598
+ }
599
+ function sequenceProfile(events) {
600
+ let previous = -1
601
+ let last = 0
602
+ let nonMonotonic = false
603
+ for (const ev of events) {
604
+ const seq = ev && typeof ev.seq === 'number' ? ev.seq : -1
605
+ if (seq < 0) continue
606
+ if (seq <= previous) nonMonotonic = true
607
+ previous = seq
608
+ if (seq > last) last = seq
609
+ }
610
+ return { lastSeq: last, nonMonotonic }
611
+ }
612
+ function enqueue(sid, task) {
613
+ const prev = chains.get(sid) || Promise.resolve()
614
+ const next = prev.then(() => task(), () => task())
615
+ chains.set(sid, next)
616
+ return next
617
+ }
618
+ function wsForLiveSession(session, sid) {
619
+ let wsId = memberOf.get(sid)
620
+ if (wsId !== undefined) return wsId
621
+ const header = session && session.header
622
+ const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
623
+ if (cwd === '') return undefined
624
+ wsId = pathIndex.get(cwd)
625
+ if (wsId !== undefined) memberOf.set(sid, wsId)
626
+ return wsId
627
+ }
628
+ async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
629
+ if (disposed || generation !== aggregationGeneration) return
630
+ const seq = typeof event.seq === 'number' ? event.seq : -1
631
+ const last = sessionSeq.get(sid)
632
+ if (last === undefined) {
633
+ try {
634
+ const snap = await ctx.sessionQuery.readSession(sid)
635
+ if (disposed || generation !== aggregationGeneration) return
636
+ if (snap && Array.isArray(snap.events)) {
637
+ foldEvents(wsId, snap.events, undefined, sid, 'live')
638
+ sessionSeq.set(sid, lastSeqOf(snap.events))
639
+ sessionCount.add(sid)
640
+ }
641
+ } catch (err) { /* retry on the next event */ }
642
+ return
643
+ }
644
+ if (seq <= last) return
645
+ if (seq > last + 1) {
646
+ try {
647
+ const snap = await ctx.sessionQuery.readSession(sid)
648
+ if (disposed || generation !== aggregationGeneration) return
649
+ if (snap && Array.isArray(snap.events)) {
650
+ foldEvents(wsId, snap.events, last, sid, 'live')
651
+ sessionSeq.set(sid, lastSeqOf(snap.events))
652
+ }
653
+ } catch (err) { /* keep last; retry later */ }
654
+ return
655
+ }
656
+ foldEvent(wsId, event.time, event.type, event.data, sid, event.seq, 'live')
657
+ sessionSeq.set(sid, seq)
658
+ sessionCount.add(sid)
659
+ }
660
+
661
+ // ---------- durable usage ledger ----------
662
+ async function loadLedger() {
663
+ if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
664
+ try {
665
+ const backend = storage.backend.get('json')
666
+ if (backend === undefined || backend === null || backend.kv === undefined) return
667
+ const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
668
+ if (disposed) { await unit.close().catch(() => {}); return }
669
+ ledgerUnit = unit
670
+ const snapshot = await unit.loadAll()
671
+ const rows = snapshot && snapshot.tables && snapshot.tables.sessions
672
+ if (rows !== null && rows !== undefined && typeof rows === 'object') {
673
+ for (const [key, raw] of Object.entries(rows)) {
674
+ const record = normalizeLedgerRecord(raw, key)
675
+ if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
676
+ else ledgerRecords.set(key, record)
677
+ }
678
+ }
679
+ } catch (err) {
680
+ console.error('[all-usage] usage ledger unavailable:', err)
681
+ }
682
+ }
683
+
684
+ // ---------- baseline scan over durable logs ----------
685
+ function scheduleNativeBaselineRetry(generation, delay) {
686
+ if (disposed || baselineFallbackTimer !== null) return
687
+ baselineFallbackTimer = setTimeout(() => {
688
+ baselineFallbackTimer = null
689
+ if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
690
+ }, delay)
691
+ if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
692
+ }
693
+ function scheduleBaselineRetry(generation = aggregationGeneration) {
694
+ if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
695
+ baselineRetryScheduled = true
696
+ const delay = baselineRetryDelay
697
+ baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
698
+ void safeContextTimeout(delay).then((ready) => {
699
+ baselineRetryScheduled = false
700
+ if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
701
+ if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
702
+ return undefined
703
+ })
704
+ }
705
+ async function runBaseline(generation = aggregationGeneration) {
706
+ if (scan.started || disposed || generation !== aggregationGeneration) return
707
+ scan.started = true
708
+ beginSync()
709
+ await ledgerReady
710
+ if (disposed || generation !== aggregationGeneration) return
711
+ let setupFailed = false
712
+ try {
713
+ const workspaces = ctx.workspaceRegistry.list()
714
+ for (const w of workspaces) {
715
+ const id = w && w.id
716
+ const path = w && typeof w.path === 'string' ? w.path : ''
717
+ const title = w && typeof w.title === 'string' ? w.title : ''
718
+ if (id === undefined) continue
719
+ wsMeta.set(id, { id, title, path })
720
+ if (path !== '') pathIndex.set(path, id)
721
+ if (w && Array.isArray(w.sessionIds)) {
722
+ for (const sid of w.sessionIds) memberOf.set(sid, id)
723
+ }
724
+ }
725
+ } catch (err) {
726
+ console.error('[all-usage] workspace list failed:', err)
727
+ setupFailed = true
728
+ noteSyncError('workspace-list-failed')
729
+ }
730
+ let records = null
731
+ try {
732
+ records = await ctx.sessionQuery.listSessions()
733
+ } catch (err) {
734
+ console.error('[all-usage] session list failed:', err)
735
+ if (disposed || generation !== aggregationGeneration) return
736
+ noteSyncError('session-list-failed')
737
+ }
738
+ if (disposed || generation !== aggregationGeneration) return
739
+ // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
740
+ let snapshots = null
741
+ if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
742
+ try {
743
+ const rows = await sessionPersistence.listSnapshots()
744
+ if (disposed || generation !== aggregationGeneration) return
745
+ if (Array.isArray(rows)) {
746
+ sync.persistenceSnapshotsAvailable = true
747
+ snapshots = new Map()
748
+ for (const row of rows) {
749
+ const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
750
+ if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
751
+ }
752
+ }
753
+ } catch (err) {
754
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
755
+ if (disposed || generation !== aggregationGeneration) return
756
+ sync.persistenceSnapshotsAvailable = false
757
+ markStatsChanged()
758
+ }
759
+ }
760
+ if (disposed || generation !== aggregationGeneration) return
761
+ if (setupFailed || !Array.isArray(records)) {
762
+ // A transient registry failure must not be reported as a completed empty scan.
763
+ scan.started = false
764
+ markStatsChanged()
765
+ scheduleBaselineRetry(generation)
766
+ return
767
+ }
768
+ scan.total = records.length
769
+ sync.sessionsTotal = records.length
770
+ markStatsChanged()
771
+ const listedSessionIds = new Set()
772
+ for (const record of records) {
773
+ if (record === undefined || record === null || record.header === undefined) continue
774
+ const sid = record.header.id
775
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
776
+ const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
777
+ if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
778
+ }
779
+ for (const [sid, record] of ledgerRecords) {
780
+ if (!listedSessionIds.has(sid)) {
781
+ applyLedgerRecord(record, 'ledger-recovery')
782
+ if (record.turns.length > 0 || record.usage.length > 0) sync.sessionsRestoredFromLedger += 1
783
+ }
784
+ }
785
+ if (sync.sessionsRestoredFromLedger > 0) markStatsChanged()
786
+ for (const record of records) {
787
+ if (disposed || generation !== aggregationGeneration) return
788
+ if (record === undefined || record === null || record.header === undefined) {
789
+ scan.scanned += 1
790
+ markStatsChanged()
791
+ continue
792
+ }
793
+ const sid = record.header.id
794
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
795
+ const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
796
+ if (sid === undefined || wsId === undefined) {
797
+ scan.scanned += 1
798
+ markStatsChanged()
799
+ continue
800
+ }
801
+ listedSessionIds.add(sid)
802
+ await enqueue(sid, async () => {
803
+ if (disposed || generation !== aggregationGeneration) return
804
+ try {
805
+ if (sessionSeq.has(sid)) return
806
+ // v1.0.8: when the persisted log revision is unchanged since the last ledger
807
+ // write, the whole readSession (full event transfer) is skipped — the ledger
808
+ // record is applied directly and the live feed keeps catching new events.
809
+ const previousRecord = ledgerRecords.get(sid)
810
+ const revision = snapshots === null ? undefined : snapshots.get(sid)
811
+ if (previousRecord !== undefined && previousRecord.needsUpgrade !== true && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
812
+ sync.sessionsSkippedByRevision += 1
813
+ applyLedgerRecord(previousRecord, 'ledger-reuse')
814
+ sessionSeq.set(sid, previousRecord.lastSeq)
815
+ sessionCount.add(sid)
816
+ markStatsChanged()
817
+ return
818
+ }
819
+ sync.sessionsRead += 1
820
+ markStatsChanged()
821
+ const snap = await ctx.sessionQuery.readSession(sid)
822
+ if (disposed || generation !== aggregationGeneration) return
823
+ if (snap && Array.isArray(snap.events)) {
824
+ // v1.0.7: incremental seed — the durable ledger doubles as a per-session
825
+ // cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
826
+ // session applies its canonical record directly and never re-folds;
827
+ // a changed session seeds the previous record once, then folds only the
828
+ // new tail (previously every listed session was re-read and fully rebuilt).
829
+ const sequence = sequenceProfile(snap.events)
830
+ const currentLastSeq = sequence.lastSeq
831
+ const previous = ledgerRecords.get(sid)
832
+ const canFoldTail = previous !== undefined && previous.needsUpgrade !== true && !sequence.nonMonotonic && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
833
+ if (canFoldTail) {
834
+ applyLedgerRecord(previous, 'ledger-reuse')
835
+ foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
836
+ } else {
837
+ // A changed revision with no new tail may still contain a replacement;
838
+ // rebuild from the complete read instead of trusting lastSeq alone.
839
+ foldEvents(wsId, snap.events, undefined, sid, 'scan')
840
+ }
841
+ const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision)
842
+ const canonical = ledger === null ? ledgerRecords.get(sid) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
843
+ if (canonical === ledger) {
844
+ void persistLedgerRecord(ledger)
845
+ }
846
+ sessionSeq.set(sid, currentLastSeq)
847
+ sessionCount.add(sid)
848
+ }
849
+ } catch (err) {
850
+ if (generation !== aggregationGeneration) return
851
+ sync.sessionsFailed += 1
852
+ noteSyncError('session-read-failed')
853
+ const saved = ledgerRecords.get(sid)
854
+ if (saved !== undefined) {
855
+ applyLedgerRecord(saved, 'ledger-recovery')
856
+ if (saved.turns.length > 0 || saved.usage.length > 0) sync.sessionsRestoredFromLedger += 1
857
+ sessionSeq.set(sid, -1)
858
+ sessionCount.add(sid)
859
+ } else {
860
+ sessionSeq.set(sid, -1)
861
+ scan.failed += 1
862
+ }
863
+ } finally {
864
+ if (generation === aggregationGeneration) {
865
+ scan.scanned += 1
866
+ markStatsChanged()
867
+ }
868
+ }
869
+ })
870
+ if (!(await safeContextTimeout(0))) {
871
+ scan.started = false
872
+ noteSyncError('baseline-yield-unavailable')
873
+ scheduleBaselineRetry(generation)
874
+ return
875
+ }
876
+ }
877
+ if (disposed || generation !== aggregationGeneration) return
878
+ await ledgerWriteChain
879
+ if (disposed || generation !== aggregationGeneration) return
880
+ knownSessionIds.clear()
881
+ for (const sid of listedSessionIds) knownSessionIds.add(sid)
882
+ scan.done = true
883
+ sync.lastCompletedAt = Date.now()
884
+ if (sync.sessionsFailed === 0) {
885
+ sync.lastErrorAt = 0
886
+ sync.lastErrorCode = null
887
+ }
888
+ markStatsChanged()
889
+ if (reconcilePending) scheduleReconcileHint()
890
+ }
891
+
892
+ function sessionIdsFromRecords(records) {
893
+ const ids = new Set()
894
+ for (const record of records) {
895
+ if (record === undefined || record === null || record.header === undefined) continue
896
+ const sid = record.header.id
897
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
898
+ const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
899
+ if (sid !== undefined && wsId !== undefined) ids.add(sid)
900
+ }
901
+ return ids
902
+ }
903
+ async function reconcileSessions() {
904
+ if (disposed || reconcileInFlight || !scan.done) return
905
+ reconcilePending = false
906
+ reconcileInFlight = true
907
+ try {
908
+ const records = await ctx.sessionQuery.listSessions()
909
+ if (disposed || !Array.isArray(records)) return
910
+ const currentIds = sessionIdsFromRecords(records)
911
+ let removed = false
912
+ for (const sid of knownSessionIds) {
913
+ if (!currentIds.has(sid)) { removed = true; break }
914
+ }
915
+ if (removed && !disposed && scan.done) {
916
+ console.info('[all-usage] session removal detected; rebuilding usage index')
917
+ const generation = resetAggregationState()
918
+ void runBaseline(generation)
919
+ return
920
+ }
921
+ knownSessionIds.clear()
922
+ for (const sid of currentIds) knownSessionIds.add(sid)
923
+ } catch (err) {
924
+ console.error('[all-usage] session reconciliation failed:', err)
925
+ noteSyncError('session-reconcile-failed')
926
+ } finally {
927
+ reconcileInFlight = false
928
+ if (reconcilePending && !disposed) scheduleReconcileHint()
929
+ }
930
+ }
931
+ function scheduleReconcileHint() {
932
+ if (disposed) return
933
+ reconcilePending = true
934
+ if (reconcileHintScheduled || reconcileInFlight) return
935
+ reconcileHintScheduled = true
936
+ void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
937
+ reconcileHintScheduled = false
938
+ if (ready && !disposed) void reconcileSessions()
939
+ }, () => {
940
+ reconcileHintScheduled = false
941
+ })
942
+ }
943
+ function scheduleReconcileTimer() {
944
+ if (disposed || reconcileTimer !== null) return
945
+ reconcileTimer = setTimeout(() => {
946
+ reconcileTimer = null
947
+ if (!disposed) {
948
+ void reconcileSessions()
949
+ scheduleReconcileTimer()
950
+ }
951
+ }, RECONCILE_INTERVAL_MS)
952
+ if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
953
+ }
954
+
955
+ // ---------- live feed ----------
956
+ ctx.on('session/event', (session, event) => {
957
+ if (disposed) return
958
+ if (event === undefined || event === null) return
959
+ const type = event.type
960
+ if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'request/context' && type !== 'request/header') return
961
+ const sid = session && session.id
962
+ if (typeof sid !== 'string') return
963
+ const wsId = wsForLiveSession(session, sid)
964
+ if (wsId === undefined) return
965
+ const generation = aggregationGeneration
966
+ knownSessionIds.add(sid)
967
+ enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
968
+ })
969
+ ctx.on('session/flush', async (session) => {
970
+ if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
971
+ await ledgerReady
972
+ if (disposed) return
973
+ const wsId = wsForLiveSession(session, session.id)
974
+ if (wsId === undefined) return
975
+ const ledger = buildLedgerRecord(session, wsId, 'flush')
976
+ if (ledger === null) return
977
+ const canonical = storeLedgerRecord(ledger)
978
+ if (canonical === ledger) await persistLedgerRecord(ledger)
979
+ })
980
+ ctx.on('session/disposed', () => {
981
+ if (!disposed) scheduleReconcileHint()
982
+ })
983
+
984
+ // ---------- workspace aliases (durable, schema-free KV unit) ----------
985
+ async function loadAliases() {
986
+ if (storage === undefined) return
987
+ try {
988
+ const backend = storage.backend.get('json')
989
+ if (backend === undefined || backend === null || backend.kv === undefined) return
990
+ const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
991
+ kvUnit = unit
992
+ const snap = await unit.loadAll()
993
+ const g = snap && snap.global
994
+ if (g !== null && g !== undefined && typeof g === 'object') {
995
+ for (const key of Object.keys(g)) {
996
+ const value = g[key]
997
+ if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
998
+ }
999
+ markStatsChanged()
1000
+ }
1001
+ } catch (err) {
1002
+ console.error('[all-usage] alias storage unavailable:', err)
1003
+ }
1004
+ }
1005
+ function persistAliases() {
1006
+ const snapshotAliases = {}
1007
+ for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
1008
+ aliasWriteChain = aliasWriteChain.then(() => {
1009
+ if (kvUnit === null || kvUnit === undefined) return undefined
1010
+ return kvUnit.setGlobal(snapshotAliases).catch((err) => {
1011
+ console.error('[all-usage] alias persist failed:', err)
1012
+ })
1013
+ })
1014
+ }
1015
+ function setAlias(wsId, raw) {
1016
+ const alias = typeof raw === 'string' ? raw.trim().slice(0, 80) : ''
1017
+ if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
1018
+ if (alias === '') delete aliases[wsId]
1019
+ else aliases[wsId] = alias
1020
+ persistAliases()
1021
+ markStatsChanged()
1022
+ return { ok: true, aliases: Object.assign({}, aliases) }
1023
+ }
1024
+ ctx.effect(() => () => {
1025
+ disposed = true
1026
+ if (reconcileTimer !== null) {
1027
+ clearTimeout(reconcileTimer)
1028
+ reconcileTimer = null
1029
+ }
1030
+ if (baselineFallbackTimer !== null) {
1031
+ clearTimeout(baselineFallbackTimer)
1032
+ baselineFallbackTimer = null
1033
+ }
1034
+ })
1035
+ ctx.effect(() => () => {
1036
+ const unit = kvUnit
1037
+ kvUnit = null
1038
+ if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
1039
+ })
1040
+ ctx.effect(() => async () => {
1041
+ const unit = ledgerUnit
1042
+ await ledgerWriteChain
1043
+ if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
1044
+ if (ledgerUnit === unit) ledgerUnit = null
1045
+ })
1046
+
1047
+ // ---------- snapshot for the client ----------
1048
+ function scanSnapshot() {
1049
+ return { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed }
1050
+ }
1051
+ function statusSnapshot() {
1052
+ return { instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scan: scanSnapshot(), sync: syncSnapshot() }
1053
+ }
1054
+ function serializeIdentity(identity) {
1055
+ const value = coerceIdentity(identity)
1056
+ return { identityKey: value.identityKey, provider: value.provider, requestedModel: value.requestedModel, actualModel: value.actualModel, model: value.label, legacy: value.legacy }
1057
+ }
1058
+ function serializeModelAggregate(item) {
1059
+ return { ...serializeIdentity(item), calls: item.calls, input: item.input, output: item.output, cacheRead: item.cacheRead, cacheWrite: item.cacheWrite, reasoning: item.reasoning }
1060
+ }
1061
+ function serializeDays(dayMap) {
1062
+ const result = []
1063
+ for (const pair of dayMap) {
1064
+ const date = pair[0]
1065
+ const day = pair[1]
1066
+ result.push({
1067
+ date,
1068
+ turns: day.turns,
1069
+ sessions: day.sessionIds.size,
1070
+ sessionIds: Array.from(day.sessionIds).sort(),
1071
+ tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
1072
+ perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
1073
+ 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 })),
1074
+ byModel: Array.from(day.byModel, (p) => serializeModelAggregate(p[1])),
1075
+ })
1076
+ }
1077
+ result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
1078
+ return result
1079
+ }
1080
+ function snapshot() {
1081
+ return {
1082
+ ...statusSnapshot(),
1083
+ generatedAt: Date.now(),
1084
+ usageSchemaVersion: 2,
1085
+ requestToken,
1086
+ workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
1087
+ aliases: Object.assign({}, aliases),
1088
+ tokenSemantics: {
1089
+ processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
1090
+ cacheRead: 'reused context tokens; not newly generated output',
1091
+ cacheWrite: 'tokens written into a provider cache',
1092
+ // v1.0.7: structured, machine-readable accounting semantics (cc-switch
1093
+ // input_token_semantics parity). DSH reports input as fresh (cache read /
1094
+ // cache write sit in their own buckets) — verified against real ledger data;
1095
+ // reasoning is bucketed separately from output and assumed non-overlapping.
1096
+ semantics: {
1097
+ input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
1098
+ buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
1099
+ inputIncludesCache: false,
1100
+ cacheBucketed: true,
1101
+ reasoningSeparate: true,
1102
+ gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
1103
+ },
1104
+ },
1105
+ totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
1106
+ 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 })),
1107
+ perModel: Array.from(perModel.values(), (item) => serializeModelAggregate(item)),
1108
+ byDay: serializeDays(byDay),
1109
+ byDayUtc: serializeDays(byDayUtc),
1110
+ }
1111
+ }
1112
+
1113
+ function validDateText(value) {
1114
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
1115
+ const year = Number(value.slice(0, 4))
1116
+ const month = Number(value.slice(5, 7))
1117
+ const day = Number(value.slice(8, 10))
1118
+ const date = new Date(Date.UTC(year, month - 1, day))
1119
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
1120
+ }
1121
+ function shiftDateText(value, days, utc) {
1122
+ const parts = value.split('-').map(Number)
1123
+ 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)
1124
+ return utc ? dayKeyUtc(date.getTime()) : dayKey(date.getTime())
1125
+ }
1126
+ function queryScopeFromRequest(req) {
1127
+ let url
1128
+ try { url = new URL(req.url || '/', 'http://all-usage.local') } catch (err) { return { ok: false, message: 'bad-query' } }
1129
+ const rawUtc = url.searchParams.get('utc')
1130
+ if (rawUtc !== null && rawUtc !== '' && rawUtc !== '0' && rawUtc !== '1') return { ok: false, message: 'invalid-timezone' }
1131
+ const utc = rawUtc === '1'
1132
+ const today = utc ? dayKeyUtc(Date.now()) : dayKey(Date.now())
1133
+ const start = url.searchParams.get('start') || today
1134
+ const end = url.searchParams.get('end') || today
1135
+ if (!validDateText(start) || !validDateText(end) || start > end) return { ok: false, message: 'invalid-date-range' }
1136
+ const readParam = (name, max) => {
1137
+ const value = url.searchParams.get(name)
1138
+ if (value === null || value === '') return undefined
1139
+ return value.length <= max ? value : null
1140
+ }
1141
+ const workspaceId = readParam('workspaceId', 256)
1142
+ const provider = readParam('provider', 256)
1143
+ const modelKey = readParam('modelKey', 1024)
1144
+ if (workspaceId === null || provider === null || modelKey === null) return { ok: false, message: 'query-too-long' }
1145
+ return { ok: true, scope: { start, end, utc, workspaceId, provider, modelKey } }
1146
+ }
1147
+ function scopeFingerprint(scope) {
1148
+ 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 })
1149
+ }
1150
+ function dateInScope(date, scope) {
1151
+ return date >= scope.start && date <= scope.end
1152
+ }
1153
+ function modelNameOfIdentity(identity) {
1154
+ const normalized = coerceIdentity(identity)
1155
+ const structured = normalized.actualModel || normalized.requestedModel
1156
+ if (structured !== null) return structured
1157
+ if (normalized.legacy && typeof normalized.label === 'string') {
1158
+ const separator = normalized.label.indexOf(' / ')
1159
+ if (separator > 0) return normalized.label.slice(separator + 3)
1160
+ }
1161
+ return normalized.label
1162
+ }
1163
+ function identityMatchesScope(identity, scope) {
1164
+ const normalized = coerceIdentity(identity)
1165
+ if (scope.provider !== undefined && scope.provider !== null && normalized.provider !== scope.provider) return false
1166
+ if (scope.modelKey !== undefined && scope.modelKey !== null && normalized.identityKey !== scope.modelKey && modelNameOfIdentity(normalized) !== scope.modelKey) return false
1167
+ return true
1168
+ }
1169
+ function queryMetric() {
1170
+ return { turns: 0, calls: 0, sessions: new Set(), turnKeys: new Set(), input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
1171
+ }
1172
+ function queryAggregate() {
1173
+ return { totals: queryMetric(), days: new Map(), workspaces: new Map(), models: new Map() }
1174
+ }
1175
+ function queryDay(aggregate, date) {
1176
+ let day = aggregate.days.get(date)
1177
+ if (day === undefined) { day = queryMetric(); day.date = date; aggregate.days.set(date, day) }
1178
+ return day
1179
+ }
1180
+ function queryWorkspace(aggregate, workspaceId) {
1181
+ let row = aggregate.workspaces.get(workspaceId)
1182
+ if (row === undefined) { row = queryMetric(); row.workspaceId = workspaceId; aggregate.workspaces.set(workspaceId, row) }
1183
+ return row
1184
+ }
1185
+ function queryModel(aggregate, identity) {
1186
+ const normalized = coerceIdentity(identity)
1187
+ let row = aggregate.models.get(normalized.identityKey)
1188
+ if (row === undefined) { row = queryMetric(); Object.assign(row, serializeIdentity(normalized)); aggregate.models.set(normalized.identityKey, row) }
1189
+ return row
1190
+ }
1191
+ function addQueryTokens(metric, values) {
1192
+ metric.input += values.input
1193
+ metric.output += values.output
1194
+ metric.cacheRead += values.cacheRead
1195
+ metric.cacheWrite += values.cacheWrite
1196
+ metric.reasoning += values.reasoning
1197
+ }
1198
+ function addQueryTurn(aggregate, turn, date) {
1199
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, turn.wsId)]
1200
+ const model = queryModel(aggregate, turn.identity)
1201
+ targets.push(model)
1202
+ const key = turn.key
1203
+ for (const target of targets) {
1204
+ if (target.turnKeys.has(key)) continue
1205
+ target.turnKeys.add(key)
1206
+ target.turns += 1
1207
+ target.sessions.add(turn.sid)
1208
+ }
1209
+ }
1210
+ function addQueryUsage(aggregate, item, date) {
1211
+ const targets = [aggregate.totals, queryDay(aggregate, date), queryWorkspace(aggregate, item.wsId)]
1212
+ const model = queryModel(aggregate, item.identity || item.modelId)
1213
+ targets.push(model)
1214
+ for (const target of targets) {
1215
+ target.calls += 1
1216
+ target.sessions.add(item.sid)
1217
+ addQueryTokens(target, item.values)
1218
+ }
1219
+ }
1220
+ function finalizeQueryMetric(metric) {
1221
+ 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 }
1222
+ }
1223
+ function finalizeQueryAggregate(aggregate) {
1224
+ 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 } }))
1225
+ const perWorkspace = Array.from(aggregate.workspaces.values()).map((row) => ({ workspaceId: row.workspaceId, ...finalizeQueryMetric(row) }))
1226
+ 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) }))
1227
+ return { totals: finalizeQueryMetric(aggregate.totals), daily, perWorkspace, perModel }
1228
+ }
1229
+ const HOUR_MS = 60 * 60 * 1000
1230
+ function hourStartOf(time, utc) {
1231
+ const date = new Date(time)
1232
+ return utc ? Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours()) : new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours()).getTime()
1233
+ }
1234
+ function calendarStartOf(dateText, utc) {
1235
+ const parts = dateText.split('-').map(Number)
1236
+ return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2]) : new Date(parts[0], parts[1] - 1, parts[2]).getTime()
1237
+ }
1238
+ function nextCalendarStartOf(dateText, utc) {
1239
+ const parts = dateText.split('-').map(Number)
1240
+ return utc ? Date.UTC(parts[0], parts[1] - 1, parts[2] + 1) : new Date(parts[0], parts[1] - 1, parts[2] + 1).getTime()
1241
+ }
1242
+ function hourlyRangeOf(scope, nowMs) {
1243
+ if (scope.start !== scope.end) return null
1244
+ const start = calendarStartOf(scope.start, scope.utc)
1245
+ const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
1246
+ const end = scope.start === today ? nowMs : nextCalendarStartOf(scope.start, scope.utc)
1247
+ return { start, count: Math.max(1, Math.ceil(Math.max(0, end - start) / HOUR_MS)) }
1248
+ }
1249
+ function serializeTrendMetric(time, metric) {
1250
+ const value = finalizeQueryMetric(metric || queryMetric())
1251
+ return { time, date: new Date(time).toISOString(), ...value, tokens: { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite, reasoning: value.reasoning } }
1252
+ }
1253
+ function queryHourlyTrend(matchingUsage, matchingTurns, scope, nowMs) {
1254
+ const range = hourlyRangeOf(scope, nowMs)
1255
+ if (range === null) return []
1256
+ const buckets = new Map()
1257
+ const metricFor = (time) => {
1258
+ const index = Math.floor((hourStartOf(time, scope.utc) - range.start) / HOUR_MS)
1259
+ if (index < 0 || index >= range.count) return null
1260
+ let metric = buckets.get(index)
1261
+ if (metric === undefined) { metric = queryMetric(); buckets.set(index, metric) }
1262
+ return metric
1263
+ }
1264
+ for (const entry of matchingUsage) {
1265
+ if (!dateInScope(entry.date, scope)) continue
1266
+ const metric = metricFor(entry.item.time)
1267
+ if (metric === null) continue
1268
+ metric.calls += 1
1269
+ metric.sessions.add(entry.item.sid)
1270
+ addQueryTokens(metric, entry.item.values)
1271
+ }
1272
+ for (const entry of matchingTurns) {
1273
+ if (!dateInScope(entry.date, scope)) continue
1274
+ const metric = metricFor(entry.turn.time)
1275
+ if (metric === null || metric.turnKeys.has(entry.turn.key)) continue
1276
+ metric.turnKeys.add(entry.turn.key)
1277
+ metric.turns += 1
1278
+ metric.sessions.add(entry.turn.sid)
1279
+ }
1280
+ return Array.from({ length: range.count }, (_, index) => serializeTrendMetric(range.start + index * HOUR_MS, buckets.get(index)))
1281
+ }
1282
+ function queryUsageScope(scope) {
1283
+ const nowMs = Date.now()
1284
+ const today = scope.utc ? dayKeyUtc(nowMs) : dayKey(nowMs)
1285
+ const hourlyCacheKey = scope.start === scope.end ? ':' + (scope.start === today ? hourStartOf(nowMs, scope.utc) : 'fixed') : ''
1286
+ const key = statsRevision + ':' + scopeFingerprint(scope) + hourlyCacheKey
1287
+ const cached = queryCache.get(key)
1288
+ if (cached !== undefined) return cached
1289
+ const now = new Date(nowMs)
1290
+ const weekday = scope.utc ? now.getUTCDay() : now.getDay()
1291
+ const sunday = shiftDateText(today, -weekday, scope.utc)
1292
+ const heatStart = shiftDateText(sunday, -52 * 7, scope.utc)
1293
+ const matchingUsage = []
1294
+ const matchingTurnKeys = new Set()
1295
+ for (const item of usageByStep.values()) {
1296
+ const date = scope.utc ? (item.dateUtc || dayKeyUtc(item.time)) : (item.date || dayKey(item.time))
1297
+ if (!dateInScope(date, scope) && (date < heatStart || date > today)) continue
1298
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) continue
1299
+ if (!identityMatchesScope(item.identity || item.modelId, scope)) continue
1300
+ matchingUsage.push({ item, date })
1301
+ if (item.turn !== null && item.turn !== undefined) matchingTurnKeys.add(item.sid + ':turn:' + item.turn)
1302
+ }
1303
+ const matchingTurns = []
1304
+ for (const turn of turnRecords.values()) {
1305
+ const date = scope.utc ? (turn.dateUtc || dayKeyUtc(turn.time)) : (turn.date || dayKey(turn.time))
1306
+ if (!dateInScope(date, scope) && (date < heatStart || date > today)) continue
1307
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && turn.wsId !== scope.workspaceId) continue
1308
+ if ((scope.provider !== undefined && scope.provider !== null) || (scope.modelKey !== undefined && scope.modelKey !== null)) {
1309
+ if (!identityMatchesScope(turn.identity, scope) && !matchingTurnKeys.has(turn.key)) continue
1310
+ }
1311
+ matchingTurns.push({ turn, date })
1312
+ }
1313
+ const selected = queryAggregate()
1314
+ const heatmap = queryAggregate()
1315
+ for (const entry of matchingUsage) {
1316
+ if (dateInScope(entry.date, scope)) addQueryUsage(selected, entry.item, entry.date)
1317
+ if (entry.date >= heatStart && entry.date <= today) addQueryUsage(heatmap, entry.item, entry.date)
1318
+ }
1319
+ for (const entry of matchingTurns) {
1320
+ if (dateInScope(entry.date, scope)) addQueryTurn(selected, entry.turn, entry.date)
1321
+ if (entry.date >= heatStart && entry.date <= today) addQueryTurn(heatmap, entry.turn, entry.date)
1322
+ }
1323
+ const hourly = queryHourlyTrend(matchingUsage, matchingTurns, scope, nowMs)
1324
+ const result = { schemaVersion: 1, usageSchemaVersion: 2, 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 }
1325
+ queryCache.set(key, result)
1326
+ while (queryCache.size > 20) queryCache.delete(queryCache.keys().next().value)
1327
+ return result
1328
+ }
1329
+ function opaqueRecordId(item) {
1330
+ return createHash('sha256').update(item.sid + '\0' + item.key).digest('hex').slice(0, 20)
1331
+ }
1332
+ function recordOrder(a, b) {
1333
+ 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)
1334
+ }
1335
+ function recordIndexItems() {
1336
+ if (recordsIndex !== null && recordsIndex.revision === statsRevision) return recordsIndex.items
1337
+ const items = Array.from(usageByStep.values())
1338
+ items.sort(recordOrder)
1339
+ recordsIndex = { revision: statsRevision, items }
1340
+ return items
1341
+ }
1342
+ function queryRecords(scope, cursor, limit) {
1343
+ const fingerprint = scopeFingerprint(scope)
1344
+ let offset = 0
1345
+ if (cursor !== undefined && cursor !== '') {
1346
+ try {
1347
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
1348
+ if (decoded.revision !== statsRevision || decoded.scope !== fingerprint || !Number.isInteger(decoded.offset) || decoded.offset < 0) return { error: 'stale-cursor' }
1349
+ offset = decoded.offset
1350
+ } catch (err) { return { error: 'bad-cursor' } }
1351
+ }
1352
+ const rows = []
1353
+ for (const item of recordIndexItems()) {
1354
+ const date = scope.utc ? (item.dateUtc || dayKeyUtc(item.time)) : (item.date || dayKey(item.time))
1355
+ if (!dateInScope(date, scope)) continue
1356
+ if (scope.workspaceId !== undefined && scope.workspaceId !== null && item.wsId !== scope.workspaceId) continue
1357
+ if (!identityMatchesScope(item.identity || item.modelId, scope)) continue
1358
+ rows.push({ item, date })
1359
+ }
1360
+ const page = rows.slice(offset, offset + limit)
1361
+ const items = page.map(({ item, date }) => {
1362
+ const identity = coerceIdentity(item.identity || item.modelId)
1363
+ 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, materialization: item.materialization || 'unknown' }
1364
+ })
1365
+ const nextOffset = offset + items.length
1366
+ return { schemaVersion: 1, usageSchemaVersion: 2, 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 }
1367
+ }
1368
+
1369
+ // ---------- account balance (DeepSeek open platform) ----------
1370
+ async function requestBalance(url, key) {
1371
+ let controller = null
1372
+ let timer = null
1373
+ try {
1374
+ if (typeof AbortController === 'function') {
1375
+ controller = new AbortController()
1376
+ timer = setTimeout(() => controller.abort(), 30000)
1377
+ }
1378
+ const response = await fetch(url, {
1379
+ method: 'GET',
1380
+ headers: { accept: 'application/json', authorization: 'Bearer ' + key },
1381
+ ...(controller === null ? {} : { signal: controller.signal }),
1382
+ })
1383
+ return { ok: response.ok, status: response.status, text: await response.text() }
1384
+ } catch (err) {
1385
+ return { ok: false, status: 0, text: '', error: 'network request failed' }
1386
+ } finally {
1387
+ if (timer !== null) clearTimeout(timer)
1388
+ }
1389
+ }
1390
+ function moneyOf(v) {
1391
+ if (typeof v === 'number' && Number.isFinite(v)) return v
1392
+ if (typeof v === 'string' && v.trim() !== '') {
1393
+ const n = parseFloat(v)
1394
+ if (Number.isFinite(n)) return n
1395
+ }
1396
+ return null
1397
+ }
1398
+ function parseBalance(text) {
1399
+ let obj = null
1400
+ try {
1401
+ obj = JSON.parse(String(text).replace(/^\uFEFF/, ''))
1402
+ } catch (err) {
1403
+ return null
1404
+ }
1405
+ if (obj === null || typeof obj !== 'object') return null
1406
+ if (obj.is_available === false) return { unavailable: true, currencies: [] }
1407
+ const infos = (Array.isArray(obj.balance_infos) && obj.balance_infos) || (Array.isArray(obj.balance) && obj.balance) || null
1408
+ if (!infos) return null
1409
+ const out = []
1410
+ for (const info of infos) {
1411
+ if (info === null || typeof info !== 'object') continue
1412
+ if (typeof info.currency !== 'string') continue
1413
+ out.push({
1414
+ currency: info.currency,
1415
+ total: moneyOf(info.total_balance !== undefined ? info.total_balance : info.balance),
1416
+ granted: moneyOf(info.granted_balance),
1417
+ toppedUp: moneyOf(info.topped_up_balance),
1418
+ })
1419
+ }
1420
+ return { unavailable: false, currencies: out }
1421
+ }
1422
+ async function fetchBalance(force) {
1423
+ const now = Date.now()
1424
+ if (force !== true && balanceCache.payload !== null && now - balanceCache.fetchedAt < 300000) return balanceCache.payload
1425
+ let ref = 'DEEPSEEK_API_KEY'
1426
+ if (settings !== undefined) {
1427
+ try {
1428
+ const section = settings.get('llm-deepseek')
1429
+ if (section !== null && typeof section === 'object' && typeof section.apiKeyEnv === 'string' && section.apiKeyEnv.length > 0) ref = section.apiKeyEnv
1430
+ } catch (err) { /* default ref */ }
1431
+ }
1432
+ let key
1433
+ if (credentials !== undefined) {
1434
+ try {
1435
+ const hit = await credentials.resolve(ref)
1436
+ if (hit !== null && hit !== undefined && typeof hit.value === 'string' && hit.value.length > 0) key = hit.value
1437
+ } catch (err) { /* unconfigured */ }
1438
+ }
1439
+ if (key === undefined) {
1440
+ const payload = { status: 'missing-key' }
1441
+ balanceCache = { fetchedAt: now, payload }
1442
+ return payload
1443
+ }
1444
+ if (typeof fetch !== 'function') {
1445
+ const payload = { status: 'error', message: '当前 DSH 运行时不支持余额查询' }
1446
+ balanceCache = { fetchedAt: now, payload }
1447
+ return payload
1448
+ }
1449
+ const result = await requestBalance('https://api.deepseek.com/user/balance', key)
1450
+ const body = (result.text || '').trim()
1451
+ if (result.ok && body.length > 0) {
1452
+ const parsed = parseBalance(body)
1453
+ if (parsed !== null && parsed.unavailable) {
1454
+ const payload = { status: 'unavailable', message: 'DeepSeek 接口返回余额不可用(is_available=false)' }
1455
+ balanceCache = { fetchedAt: now, payload }
1456
+ return payload
1457
+ }
1458
+ if (parsed !== null && parsed.currencies.length > 0) {
1459
+ const payload = { status: 'ok', currencies: parsed.currencies, fetchedAt: now }
1460
+ balanceCache = { fetchedAt: now, payload }
1461
+ return payload
1462
+ }
1463
+ }
1464
+ const detail = body.length > 0 ? body.slice(0, 300) : (result.status > 0 ? 'HTTP ' + result.status : result.error || 'network request failed')
1465
+ const payload = { status: 'error', message: '余额查询失败', detail }
1466
+ balanceCache = { fetchedAt: now, payload }
1467
+ return payload
1468
+ }
1469
+
1470
+ // ---------- HTTP data routes for the client half ----------
1471
+ if (webServer !== undefined) {
1472
+ const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
1473
+ ctx.effect(() => webServer.register({
1474
+ kind: 'exact',
1475
+ path: '/api/all-usage/query',
1476
+ handler: (req, res) => {
1477
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
1478
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
1479
+ const parsed = queryScopeFromRequest(req)
1480
+ if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
1481
+ sendJson(res, 200, queryUsageScope(parsed.scope))
1482
+ },
1483
+ }))
1484
+ ctx.effect(() => webServer.register({
1485
+ kind: 'exact',
1486
+ path: '/api/all-usage/records',
1487
+ handler: (req, res) => {
1488
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
1489
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
1490
+ const parsed = queryScopeFromRequest(req)
1491
+ if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
1492
+ let limit = 50
1493
+ let cursor
1494
+ try {
1495
+ const url = new URL(req.url || '/', 'http://all-usage.local')
1496
+ const rawLimit = url.searchParams.get('limit')
1497
+ if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
1498
+ cursor = url.searchParams.get('cursor') || undefined
1499
+ } catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
1500
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) { sendJson(res, 400, { ok: false, message: 'invalid-limit' }); return }
1501
+ const result = queryRecords(parsed.scope, cursor, limit)
1502
+ if (result.error !== undefined) { sendJson(res, result.error === 'stale-cursor' ? 409 : 400, { ok: false, message: result.error }); return }
1503
+ sendJson(res, 200, result)
1504
+ },
1505
+ }))
1506
+ ctx.effect(() => webServer.register({
1507
+ kind: 'exact',
1508
+ path: '/api/all-usage/status',
1509
+ handler: (req, res) => {
1510
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
1511
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
1512
+ if (!scan.started) void runBaseline()
1513
+ sendJson(res, 200, statusSnapshot())
1514
+ },
1515
+ }))
1516
+ ctx.effect(() => webServer.register({
1517
+ kind: 'exact',
1518
+ path: '/api/all-usage',
1519
+ handler: (req, res) => {
1520
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
1521
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
1522
+ if (!scan.started) void runBaseline()
1523
+ sendJson(res, 200, snapshot())
1524
+ },
1525
+ }))
1526
+ ctx.effect(() => webServer.register({
1527
+ kind: 'exact',
1528
+ path: '/api/all-usage/balance',
1529
+ handler: async (req, res) => {
1530
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
1531
+ // Browsers may omit Origin on same-origin GET; the process token remains required.
1532
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
1533
+ let force = false
1534
+ try {
1535
+ const url = new URL(req.url ?? '/', 'http://x')
1536
+ force = url.searchParams.get('force') === '1'
1537
+ } catch (err) { /* default */ }
1538
+ sendJson(res, 200, await fetchBalance(force))
1539
+ },
1540
+ }))
1541
+ ctx.effect(() => webServer.register({
1542
+ kind: 'exact',
1543
+ path: '/api/all-usage/alias',
1544
+ handler: async (req, res) => {
1545
+ if (req.method !== 'POST') {
1546
+ res.statusCode = 405
1547
+ res.end()
1548
+ return
1549
+ }
1550
+ if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
1551
+ const body = await readBody(req, 16 * 1024)
1552
+ let args = null
1553
+ try {
1554
+ args = JSON.parse(body)
1555
+ } catch (err) { /* invalid json */ }
1556
+ const result = args !== null && args !== undefined && typeof args.workspaceId === 'string'
1557
+ ? setAlias(args.workspaceId, args.alias)
1558
+ : { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
1559
+ sendJson(res, 200, result)
1560
+ },
1561
+ }))
1562
+ }
1563
+
1564
+ // ---------- start the historical backfill immediately ----------
1565
+ ledgerReady = loadLedger()
1566
+ void runBaseline()
1567
+ scheduleReconcileTimer()
1568
+ void loadAliases()
1569
+ }
1570
+
1571
+ export { name, inject, apply }
1572
+ export default { name, inject, apply }