@sixzjd/dsh-usage-dashboard 0.0.0-stage → 0.6.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/plugin.js ADDED
@@ -0,0 +1,939 @@
1
+ /**
2
+ * dsh-usage-dashboard — host half.
3
+ *
4
+ * Folds the durable session log into daily token / request / cache-hit / cost
5
+ * figures and serves them to a native settings section.
6
+ *
7
+ * The data source is the host's public `sessionQuery` service, so nothing here
8
+ * decodes session files or touches `$DSH_HOME/sessions`: the host already owns
9
+ * decompression, ordering and interrupted-turn balancing.
10
+ *
11
+ * Unlike cc-switch-style dashboards, every session the host lists is counted,
12
+ * including workspaces that were never registered. The question this panel
13
+ * answers is "how much did I use today", and dropping an unregistered folder
14
+ * would silently under-report the answer.
15
+ */
16
+
17
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
18
+ import { createAggregator } from './aggregate.js'
19
+ import { readBackfillRows } from './backfill.js'
20
+ import { createAliasResolver, loadModelAliases } from './catalog.js'
21
+ import { createHistoryStore } from './history.js'
22
+ import { DEFAULT_PRICES, addCosts, buildPriceIndex, emptyCost, normalizeModelId } from './pricing.js'
23
+
24
+ export const name = 'dsh-usage-dashboard'
25
+ export const inject = ['sessionQuery', 'storage', 'webServer', 'timer']
26
+
27
+ const KV_NAME = 'dsh_usage_dashboard_prices'
28
+ const KV_VERSION = 0
29
+ /**
30
+ * Durable per-call usage history. Kept in its own unit so a history write
31
+ * never rewrites the price table (and vice versa); both live in the same JSON
32
+ * storage root, so one backend serves them.
33
+ */
34
+ const HISTORY_KV_NAME = 'dsh_usage_dashboard_history'
35
+ const HISTORY_KV_VERSION = 0
36
+ const RECONCILE_INTERVAL_MS = 120000
37
+ const LIVE_DEBOUNCE_MS = 1500
38
+ /** Yield periodically so a long history cannot block the event loop. */
39
+ const BASELINE_CHUNK_MS = 8000
40
+ /**
41
+ * Route-id -> display-name catalog refresh. The owning plugin rewrites it on
42
+ * demand and it changes rarely, so this only needs to catch up after an
43
+ * upgrade or an account switch; a faster poll would just re-read 27KB.
44
+ */
45
+ const ALIAS_REFRESH_MS = 600000
46
+
47
+ function isRecord(value) {
48
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
49
+ }
50
+
51
+ function localDay(atMs) {
52
+ const d = new Date(atMs)
53
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
54
+ }
55
+
56
+ function dayOffset(days) {
57
+ const d = new Date()
58
+ d.setHours(0, 0, 0, 0)
59
+ d.setDate(d.getDate() - days)
60
+ return localDay(d.getTime())
61
+ }
62
+
63
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
64
+
65
+ function csvParam(params, ...names) {
66
+ const values = []
67
+ for (const name of names) {
68
+ const raw = params?.[name]
69
+ if (typeof raw !== 'string') continue
70
+ for (const value of raw.split(',')) {
71
+ const normalized = value.trim()
72
+ if (normalized !== '' && !values.includes(normalized)) values.push(normalized)
73
+ }
74
+ }
75
+ return values
76
+ }
77
+
78
+ /**
79
+ * Parse `start` / `end` (YYYY-MM-DD); default to the last 30 days.
80
+ *
81
+ * `days` also accepts two NAMES next to the day counts: `today` (a single
82
+ * calendar day, which the chart then renders per hour) and `all`. `all` starts
83
+ * at the oldest record the aggregator has seen, so the caller has to hand that
84
+ * in — a missing `earliest` falls back to today rather than to 30 days, because
85
+ * reporting a 30-day window under an "all time" label would be a lie.
86
+ */
87
+ export function scopeFromParams(params = {}, options = {}) {
88
+ const rawStart = typeof params.start === 'string' ? params.start.trim() : ''
89
+ const rawEnd = typeof params.end === 'string' ? params.end.trim() : ''
90
+ const end = DATE_PATTERN.test(rawEnd) ? rawEnd : localDay(Date.now())
91
+ const named = typeof params.days === 'string' ? params.days.trim().toLowerCase() : ''
92
+ let start = DATE_PATTERN.test(rawStart) ? rawStart : ''
93
+ if (start === '') {
94
+ if (named === 'today') {
95
+ start = end
96
+ } else if (named === 'all') {
97
+ start = Number.isFinite(options.earliest) ? localDay(options.earliest) : end
98
+ } else {
99
+ const days = Number(params.days)
100
+ start = dayOffset(Number.isFinite(days) && days > 0 ? Math.min(days, 3660) - 1 : 29)
101
+ }
102
+ }
103
+ const dates = start > end ? { start: end, end: start } : { start, end }
104
+ // Hourly only makes sense for a single calendar day. `all` over an empty
105
+ // history also collapses to one day, but 24 hourly buckets under an "all
106
+ // time" label would be nonsense — so the REQUEST decides, not the span.
107
+ const granularity = dates.start === dates.end && named !== 'all' ? 'hour' : 'day'
108
+ return {
109
+ ...dates,
110
+ granularity,
111
+ providers: csvParam(params, 'provider', 'providers'),
112
+ models: csvParam(params, 'model', 'models'),
113
+ }
114
+ }
115
+
116
+ function sumCosts(costs) {
117
+ const out = emptyCost()
118
+ for (const cost of costs) if (isRecord(cost)) addCosts(out, cost)
119
+ return out
120
+ }
121
+
122
+ function addTokenValues(target, tokens) {
123
+ for (const bucket of ['input', 'output', 'cacheRead', 'cacheWrite']) {
124
+ target[bucket] += Number(tokens?.[bucket]) || 0
125
+ }
126
+ }
127
+
128
+ function tokenValues() {
129
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
130
+ }
131
+
132
+ function rowMatches(value, selected) {
133
+ return selected.length === 0 || selected.includes(value ?? 'unknown')
134
+ }
135
+
136
+ /**
137
+ * Attach display names to the rows a client renders.
138
+ *
139
+ * Deliberately a post-pass over an already-filtered result rather than a field
140
+ * inside the fold:
141
+ *
142
+ * - Grouping, totals and pricing key off the route id, so a label can never
143
+ * move a request between rows or change what is counted. Two accounts whose
144
+ * catalogs disagree about the same route id still aggregate together.
145
+ * - The catalog can be re-read on a timer without replaying every session.
146
+ * - filterSnapshot stays a pure function of (snapshot, scope), which is what
147
+ * its tests rely on.
148
+ *
149
+ * Rows keep `modelId` untouched and gain `displayName` only when a name is
150
+ * known and differs from the id, so a client can fall back without checking.
151
+ */
152
+ export function applyDisplayNames(result, resolveName) {
153
+ if (typeof resolveName !== 'function') return result
154
+ const label = (row) => {
155
+ const name = resolveName(row.modelId)
156
+ if (typeof name !== 'string' || name === '' || name === row.modelId) return row
157
+ return { ...row, displayName: name }
158
+ }
159
+ return {
160
+ ...result,
161
+ requests: (result.requests ?? []).map(label),
162
+ models: (result.models ?? []).map(label),
163
+ }
164
+ }
165
+
166
+ export function filterSnapshot(snapshot, scope) {
167
+ const { start, end } = scope
168
+ const providers = Array.isArray(scope.providers) ? scope.providers : []
169
+ const models = Array.isArray(scope.models) ? scope.models : []
170
+ const requests = (Array.isArray(snapshot.requests) ? snapshot.requests : []).filter((request) => {
171
+ return request.date !== 'unknown' && request.date >= start && request.date <= end
172
+ && rowMatches(request.provider, providers) && rowMatches(request.modelId, models)
173
+ })
174
+ const days = new Map()
175
+ // Hourly buckets are built only for a single-day scope, which is the one case
176
+ // the chart plots in hours (今天, or a one-day custom range). The axis gets all
177
+ // 24 slots even when nothing happened at 03:00, so a quiet hour keeps its
178
+ // place instead of the bars silently closing the gap. A caller may pin the
179
+ // granularity (scopeFromParams does, to keep 全部时间 daily); without it, a
180
+ // one-day scope is the signal.
181
+ const hourly = (scope.granularity ?? (start === end ? 'hour' : 'day')) === 'hour'
182
+ const hours = hourly ? Array.from({ length: 24 }, (_, hour) => ({
183
+ hour, date: start, calls: 0, tokens: tokenValues(), cost: emptyCost(),
184
+ })) : null
185
+ const modelGroups = new Map()
186
+ const providerGroups = new Map()
187
+ const tokens = tokenValues()
188
+ const sessions = new Set()
189
+ const turns = new Set()
190
+ let earliest = null
191
+ let latest = null
192
+
193
+ for (const request of requests) {
194
+ const requestTokens = request.tokens ?? tokenValues()
195
+ const day = days.get(request.date) ?? {
196
+ date: request.date, calls: 0, tokens: tokenValues(), cost: emptyCost(),
197
+ sessions: new Set(), turns: new Set(),
198
+ }
199
+ day.calls += 1
200
+ addTokenValues(day.tokens, requestTokens)
201
+ addCosts(day.cost, request.cost ?? emptyCost())
202
+ day.sessions.add(request.sessionId)
203
+ const turn = String(request.id ?? '').split(':').slice(0, -1).join(':')
204
+ day.turns.add(turn)
205
+ days.set(request.date, day)
206
+
207
+ if (hours !== null && Number.isFinite(request.time)) {
208
+ const slot = hours[new Date(request.time).getHours()]
209
+ slot.calls += 1
210
+ addTokenValues(slot.tokens, requestTokens)
211
+ addCosts(slot.cost, request.cost ?? emptyCost())
212
+ }
213
+
214
+ const providerKey = request.provider ?? 'unknown'
215
+ const modelKey = `${providerKey}\u0000${request.modelId ?? 'unknown'}`
216
+ const provider = providerGroups.get(providerKey) ?? {
217
+ provider: request.provider ?? null, calls: 0, tokens: tokenValues(), cost: emptyCost(),
218
+ }
219
+ provider.calls += 1
220
+ addTokenValues(provider.tokens, requestTokens)
221
+ addCosts(provider.cost, request.cost ?? emptyCost())
222
+ providerGroups.set(providerKey, provider)
223
+
224
+ const model = modelGroups.get(modelKey) ?? {
225
+ modelId: request.modelId ?? 'unknown', provider: request.provider ?? null,
226
+ calls: 0, tokens: tokenValues(), cost: emptyCost(),
227
+ }
228
+ model.calls += 1
229
+ addTokenValues(model.tokens, requestTokens)
230
+ addCosts(model.cost, request.cost ?? emptyCost())
231
+ modelGroups.set(modelKey, model)
232
+
233
+ addTokenValues(tokens, requestTokens)
234
+ sessions.add(request.sessionId)
235
+ turns.add(turn)
236
+ if (request.time !== null && request.time !== undefined) {
237
+ if (earliest === null || request.time < earliest) earliest = request.time
238
+ if (latest === null || request.time > latest) latest = request.time
239
+ }
240
+ }
241
+ const real = tokens.input + tokens.output + tokens.cacheWrite + tokens.cacheRead
242
+ const cacheable = tokens.input + tokens.cacheWrite + tokens.cacheRead
243
+ const dayList = [...days.values()].sort((a, b) => a.date.localeCompare(b.date)).map((day) => ({
244
+ date: day.date,
245
+ calls: day.calls,
246
+ sessions: day.sessions.size,
247
+ turns: day.turns.size,
248
+ tokens: day.tokens,
249
+ realTokens: day.tokens.input + day.tokens.output + day.tokens.cacheWrite + day.tokens.cacheRead,
250
+ hitRate: (day.tokens.input + day.tokens.cacheWrite + day.tokens.cacheRead) > 0
251
+ ? day.tokens.cacheRead / (day.tokens.input + day.tokens.cacheWrite + day.tokens.cacheRead) : null,
252
+ cost: day.cost,
253
+ }))
254
+ // Same shape as a day bucket so the chart can plot either one, plus `label`
255
+ // for the axis: the client never has to know how an hour is spelled.
256
+ const hourList = hours === null ? null : hours.map((slot) => ({
257
+ hour: slot.hour,
258
+ date: slot.date,
259
+ label: `${String(slot.hour).padStart(2, '0')}:00`,
260
+ calls: slot.calls,
261
+ tokens: slot.tokens,
262
+ realTokens: slot.tokens.input + slot.tokens.output + slot.tokens.cacheWrite + slot.tokens.cacheRead,
263
+ hitRate: (slot.tokens.input + slot.tokens.cacheWrite + slot.tokens.cacheRead) > 0
264
+ ? slot.tokens.cacheRead / (slot.tokens.input + slot.tokens.cacheWrite + slot.tokens.cacheRead) : null,
265
+ cost: slot.cost,
266
+ }))
267
+ const modelList = [...modelGroups.values()].map((model) => ({
268
+ ...model,
269
+ realTokens: model.tokens.input + model.tokens.output + model.tokens.cacheWrite + model.tokens.cacheRead,
270
+ hitRate: (model.tokens.input + model.tokens.cacheWrite + model.tokens.cacheRead) > 0
271
+ ? model.tokens.cacheRead / (model.tokens.input + model.tokens.cacheWrite + model.tokens.cacheRead) : null,
272
+ priced: model.cost.unpriced === 0,
273
+ })).sort((a, b) => b.realTokens - a.realTokens)
274
+ const providerList = [...providerGroups.values()].map((provider) => ({
275
+ ...provider,
276
+ realTokens: provider.tokens.input + provider.tokens.output + provider.tokens.cacheWrite + provider.tokens.cacheRead,
277
+ hitRate: (provider.tokens.input + provider.tokens.cacheWrite + provider.tokens.cacheRead) > 0
278
+ ? provider.tokens.cacheRead / (provider.tokens.input + provider.tokens.cacheWrite + provider.tokens.cacheRead) : null,
279
+ })).sort((a, b) => b.realTokens - a.realTokens)
280
+ return {
281
+ scope: { start, end, providers, models },
282
+ totals: {
283
+ calls: requests.length,
284
+ turns: turns.size,
285
+ sessions: sessions.size,
286
+ tokens,
287
+ realTokens: real,
288
+ hitRate: cacheable > 0 ? tokens.cacheRead / cacheable : null,
289
+ cost: sumCosts(requests.map((request) => request.cost ?? emptyCost())),
290
+ },
291
+ days: dayList,
292
+ // `granularity` is what the chart switches on; `hours` is null unless the
293
+ // scope is a single day.
294
+ granularity: hourly ? 'hour' : 'day',
295
+ hours: hourList,
296
+ providers: providerList,
297
+ models: modelList,
298
+ requests,
299
+ allTime: {
300
+ // Read the UNFILTERED snapshot, not the scoped rows above. Deriving this
301
+ // from `requests` made "all time" shrink with the selected range and with
302
+ // the provider/model filters, so a 7-day window reported it as 7 days of
303
+ // usage and the label contradicted its own name.
304
+ calls: Number.isFinite(snapshot.calls) ? snapshot.calls : requests.length,
305
+ realTokens: Number.isFinite(snapshot.realTokens) ? snapshot.realTokens : real,
306
+ earliest: Number.isFinite(snapshot.earliest) ? snapshot.earliest : earliest,
307
+ latest: Number.isFinite(snapshot.latest) ? snapshot.latest : latest,
308
+ sessions: Number.isFinite(snapshot.sessions) ? snapshot.sessions : sessions.size,
309
+ },
310
+ }
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Loopback guard. These routes only ever expose this machine's own usage, but a
315
+ // DNS-rebound page must not be able to read them, so the socket peer AND the
316
+ // Host header both have to say loopback. Writes additionally need the capability
317
+ // token handed out by GET /pricing, which a cross-origin page cannot read.
318
+ // ---------------------------------------------------------------------------
319
+
320
+ function requestHeader(req, header) {
321
+ const headers = isRecord(req) ? req.headers : null
322
+ if (!isRecord(headers)) return undefined
323
+ const value = headers[header]
324
+ if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
325
+ return typeof value === 'string' ? value : undefined
326
+ }
327
+
328
+ function isLoopbackAddress(address) {
329
+ if (typeof address !== 'string') return false
330
+ const value = address.trim().toLowerCase()
331
+ if (value === '::1' || value === 'localhost') return true
332
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)) return true
333
+ return /^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)
334
+ }
335
+
336
+ function isTrustedLocalRequest(req) {
337
+ if (!isLoopbackAddress(req?.socket?.remoteAddress)) return false
338
+ const host = requestHeader(req, 'host')
339
+ if (typeof host !== 'string' || host === '') return false
340
+ let hostUrl
341
+ try {
342
+ hostUrl = new URL(`http://${host}`)
343
+ } catch {
344
+ return false
345
+ }
346
+ if (!(hostUrl.hostname === 'localhost' || isLoopbackAddress(hostUrl.hostname))) return false
347
+ if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
348
+ const origin = requestHeader(req, 'origin')
349
+ // The desktop shell forwards requests with Origin stripped, so an absent
350
+ // Origin is allowed; a present one must still be this same loopback origin.
351
+ if (origin === undefined || origin === '') return true
352
+ if (origin.startsWith('dsh-app://')) return true
353
+ try {
354
+ const originUrl = new URL(origin)
355
+ return originUrl.protocol === 'http:' && originUrl.host === host
356
+ } catch {
357
+ return false
358
+ }
359
+ }
360
+
361
+ function tokenEquals(actual, expected) {
362
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false
363
+ const a = Buffer.from(actual)
364
+ const b = Buffer.from(expected)
365
+ return a.length === b.length && timingSafeEqual(a, b)
366
+ }
367
+
368
+ function sendJson(res, status, payload) {
369
+ res.statusCode = status
370
+ res.setHeader('content-type', 'application/json; charset=utf-8')
371
+ res.setHeader('cache-control', 'no-store')
372
+ res.end(JSON.stringify(payload))
373
+ }
374
+
375
+ async function readJsonBody(req, limit = 256 * 1024) {
376
+ const chunks = []
377
+ let size = 0
378
+ for await (const chunk of req) {
379
+ size += chunk.length
380
+ if (size > limit) return { tooLarge: true }
381
+ chunks.push(chunk)
382
+ }
383
+ try {
384
+ return { body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
385
+ } catch {
386
+ return { invalid: true }
387
+ }
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Prices: the seed table plus user edits, keyed by model id.
392
+ // ---------------------------------------------------------------------------
393
+
394
+ function sanitizeRates(raw) {
395
+ const out = {}
396
+ for (const bucket of ['input', 'output', 'cacheRead', 'cacheWrite']) {
397
+ const n = Number(raw?.[bucket])
398
+ if (!Number.isFinite(n) || n < 0) return null
399
+ out[bucket] = String(n)
400
+ }
401
+ return out
402
+ }
403
+
404
+ /** Merge stored edits onto the seed table. Later rows win, so edits override. */
405
+ function mergePrices(stored) {
406
+ const index = buildPriceIndex(DEFAULT_PRICES)
407
+ if (!Array.isArray(stored)) return [...index.values()]
408
+ for (const raw of stored) {
409
+ if (!isRecord(raw) || typeof raw.modelId !== 'string') continue
410
+ const id = raw.modelId.trim()
411
+ const key = id.toLowerCase()
412
+ const short = normalizeModelId(id)
413
+ if (key === '') continue
414
+ if (raw.removed === true) { index.delete(key); if (short !== '') index.delete(short); continue }
415
+ const previous = index.get(key) ?? (short === '' ? undefined : index.get(short))
416
+ const next = { ...(previous ?? {}), modelId: id }
417
+ if (typeof raw.label === 'string' && raw.label !== '') next.label = raw.label
418
+ const offPeak = sanitizeRates(raw.offPeak)
419
+ if (offPeak !== null) next.offPeak = offPeak
420
+ if (raw.peak === null) delete next.peak
421
+ else { const peak = sanitizeRates(raw.peak); if (peak !== null) next.peak = peak }
422
+ if (Array.isArray(raw.holidays)) {
423
+ next.holidays = raw.holidays.filter((d) => typeof d === 'string' && DATE_PATTERN.test(d))
424
+ }
425
+ if (Number.isFinite(raw.effectiveFrom)) next.effectiveFrom = raw.effectiveFrom
426
+ index.set(key, next)
427
+ if (short !== '' && short !== key) index.set(short, next)
428
+ }
429
+ return [...index.values()]
430
+ }
431
+
432
+ /**
433
+ * Collapse fold rows into one editable pricing candidate per price key.
434
+ *
435
+ * The key mirrors buildPriceIndex exactly — trimmed, lower-cased model id — so
436
+ * a row can never address a slot the lookup would not read back. Rows that
437
+ * differ only by provider therefore share one form row, and the totals shown
438
+ * on it are the sum across every route that reached the model.
439
+ */
440
+ export function pricingCandidates(models, resolver) {
441
+ // Each stored row writes every slot lookupPrice would later read for it:
442
+ // the full id, plus the trailing segment when that differs. Two fold rows
443
+ // whose slot sets intersect drive the same index entry, so they must share
444
+ // one form row however different their full ids look.
445
+ const slotsOf = (modelId) => {
446
+ const full = modelId.trim().toLowerCase()
447
+ const short = normalizeModelId(modelId)
448
+ return short === '' || short === full ? [full] : [full, short]
449
+ }
450
+ const groups = []
451
+ const ownerOf = new Map()
452
+ for (const model of Array.isArray(models) ? models : []) {
453
+ const modelId = typeof model?.modelId === 'string' ? model.modelId : ''
454
+ if (modelId.trim() === '') continue
455
+ const slots = slotsOf(modelId)
456
+ let group = null
457
+ for (const slot of slots) {
458
+ const found = ownerOf.get(slot)
459
+ if (found !== undefined && group === null) group = found
460
+ else if (found !== undefined && found !== group) group = mergeGroups(groups, group, found, ownerOf, slotsOf)
461
+ }
462
+ if (group === null) {
463
+ const name = typeof resolver?.resolve === 'function' ? resolver.resolve(modelId) : null
464
+ group = {
465
+ modelId,
466
+ displayName: typeof name === 'string' && name !== '' ? name : undefined,
467
+ providers: [],
468
+ calls: 0,
469
+ realTokens: 0,
470
+ priced: true,
471
+ slots,
472
+ }
473
+ groups.push(group)
474
+ for (const slot of slots) ownerOf.set(slot, group)
475
+ }
476
+ group.calls += Number(model.calls) || 0
477
+ group.realTokens += Number(model.realTokens) || 0
478
+ // Any route still missing a rate leaves the shared row unpriced: the price
479
+ // is one index entry, so it is either set for every route or for none.
480
+ if (model.priced === false) group.priced = false
481
+ const provider = typeof model.provider === 'string' && model.provider !== '' ? model.provider : null
482
+ if (provider !== null && !group.providers.includes(provider)) group.providers.push(provider)
483
+ }
484
+ return groups
485
+ .map(({ slots, providers, ...rest }) => ({
486
+ ...rest,
487
+ // The row edits the full id; the short alias follows automatically
488
+ // because mergePrices writes both from one entry.
489
+ provider: providers.length === 1 ? providers[0] : (providers.length > 1 ? providers.join('+') : null),
490
+ }))
491
+ .sort((a, b) => b.realTokens - a.realTokens)
492
+ }
493
+
494
+ /** Join two groups that turned out to address the same index slot. */
495
+ function mergeGroups(groups, keep, drop, ownerOf, slotsOf) {
496
+ keep.calls += drop.calls
497
+ keep.realTokens += drop.realTokens
498
+ if (!drop.priced) keep.priced = false
499
+ for (const provider of drop.providers) if (!keep.providers.includes(provider)) keep.providers.push(provider)
500
+ if (keep.displayName === undefined && drop.displayName !== undefined) keep.displayName = drop.displayName
501
+ const merged = [...new Set([...keep.slots, ...drop.slots])]
502
+ keep.slots = merged
503
+ for (const slot of merged) ownerOf.set(slot, keep)
504
+ const at = groups.indexOf(drop)
505
+ if (at >= 0) groups.splice(at, 1)
506
+ return keep
507
+ }
508
+
509
+
510
+ function publicPrice(entry) {
511
+ return {
512
+ modelId: entry.modelId,
513
+ label: entry.label ?? null,
514
+ offPeak: entry.offPeak ?? null,
515
+ peak: entry.peak ?? null,
516
+ holidays: Array.isArray(entry.holidays) ? entry.holidays : [],
517
+ effectiveFrom: Number.isFinite(entry.effectiveFrom) ? entry.effectiveFrom : null,
518
+ }
519
+ }
520
+
521
+ export function apply(ctx) {
522
+ const storage = ctx.get('storage')
523
+ const webServer = ctx.get('webServer')
524
+ const sessionQuery = ctx.get('sessionQuery')
525
+
526
+ const writeToken = randomBytes(32).toString('base64url')
527
+ let prices = mergePrices(null)
528
+ let aggregator = createAggregator({ prices })
529
+ let kvUnit = null
530
+ // Durable usage history: the only record that survives a deleted session.
531
+ let historyKvUnit = null
532
+ let history = createHistoryStore()
533
+ let historyReady = false
534
+ let historyDirty = false
535
+ let backfillDone = false
536
+ let backfill = null
537
+ let revision = 0
538
+ let cached = null
539
+ let cachedRevision = -1
540
+ // Display labels only: grouping, totals and pricing never consult this.
541
+ const aliases = createAliasResolver(new Map())
542
+ let aliasSources = []
543
+ let aliasRefreshedAt = 0
544
+ let scan = { started: false, done: false, total: 0, loaded: 0, failed: 0 }
545
+ let baselineRunning = false
546
+ let baselinePromise = null
547
+ let disposed = false
548
+ const livePending = new Set()
549
+ let liveScheduled = false
550
+
551
+ function bump() {
552
+ revision += 1
553
+ cached = null
554
+ cachedRevision = -1
555
+ }
556
+
557
+ function snapshot() {
558
+ if (cached !== null && cachedRevision === revision) return cached
559
+ cached = aggregator.snapshot()
560
+ cachedRevision = revision
561
+ return cached
562
+ }
563
+
564
+ function payload(scope) {
565
+ return {
566
+ ok: true,
567
+ revision,
568
+ generatedAt: Date.now(),
569
+ scan,
570
+ // Provenance for the labels below. Without this a stale or missing
571
+ // catalog is indistinguishable from a model that has no alias, and the
572
+ // panel would quietly show route ids as if they were real names.
573
+ catalog: {
574
+ aliasCount: aliases.size(),
575
+ sources: aliasSources.map((path) => path.split(/[\\/]/).pop()).filter(Boolean),
576
+ refreshedAt: aliasRefreshedAt,
577
+ },
578
+ // Route ids are labelled after filtering, never inside the fold, so a
579
+ // catalog that arrives late (or changes) only affects display.
580
+ ...applyDisplayNames(filterSnapshot(snapshot(), scope), (modelId) => aliases.resolve(modelId)),
581
+ }
582
+ }
583
+
584
+ async function refreshAliases() {
585
+ try {
586
+ const loaded = await loadModelAliases()
587
+ aliases.replace(loaded.aliases)
588
+ aliasSources = loaded.sources
589
+ aliasRefreshedAt = Date.now()
590
+ } catch (error) {
591
+ // A missing or unreadable catalog is the normal state on a machine
592
+ // without the Qoder plugin: keep the previous map and say nothing.
593
+ console.warn('[dsh-usage-dashboard] model catalog unavailable:', error)
594
+ }
595
+ }
596
+
597
+ // --- persistence ---------------------------------------------------------
598
+
599
+ async function loadPrices() {
600
+ try {
601
+ const backend = storage?.backend?.get?.('json')
602
+ if (backend?.kv === undefined) return
603
+ kvUnit = await backend.kv.open({ name: KV_NAME, version: KV_VERSION, tables: [], hasGlobal: true })
604
+ if (disposed) { await kvUnit.close().catch(() => {}); kvUnit = null; return }
605
+ const snap = await kvUnit.loadAll()
606
+ const stored = snap?.global?.prices
607
+ if (Array.isArray(stored)) {
608
+ prices = mergePrices(stored)
609
+ aggregator = createAggregator({ prices })
610
+ bump()
611
+ }
612
+ } catch (error) {
613
+ console.error('[dsh-usage-dashboard] price storage unavailable:', error)
614
+ kvUnit = null
615
+ }
616
+ }
617
+
618
+ let writeChain = Promise.resolve()
619
+ function persistPrices() {
620
+ if (kvUnit === null || kvUnit === undefined) return writeChain
621
+ const rows = prices.map(publicPrice)
622
+ writeChain = writeChain
623
+ .then(() => kvUnit.setGlobal({ prices: rows }))
624
+ .catch((error) => console.error('[dsh-usage-dashboard] price persist failed:', error))
625
+ return writeChain
626
+ }
627
+
628
+ // --- durable usage history ------------------------------------------------
629
+
630
+ async function loadHistory() {
631
+ try {
632
+ const backend = storage?.backend?.get?.('json')
633
+ if (backend?.kv === undefined) return
634
+ historyKvUnit = await backend.kv.open({
635
+ name: HISTORY_KV_NAME, version: HISTORY_KV_VERSION, tables: [], hasGlobal: true,
636
+ })
637
+ if (disposed) { await historyKvUnit.close().catch(() => {}); historyKvUnit = null; return }
638
+ const snap = await historyKvUnit.loadAll()
639
+ const stored = snap?.global?.history
640
+ if (isRecord(stored)) history = createHistoryStore(stored)
641
+ historyReady = true
642
+ } catch (error) {
643
+ console.error('[dsh-usage-dashboard] history storage unavailable:', error)
644
+ historyKvUnit = null
645
+ // Without durable history the panel still works, it just cannot outlive
646
+ // a deletion. Keep folding live logs rather than refusing to start.
647
+ historyReady = true
648
+ }
649
+ }
650
+
651
+ let historyWriteChain = Promise.resolve()
652
+ function persistHistory() {
653
+ if (historyKvUnit === null || historyKvUnit === undefined) return historyWriteChain
654
+ const body = history.toJSON()
655
+ historyDirty = false
656
+ historyWriteChain = historyWriteChain
657
+ .then(() => historyKvUnit.setGlobal({ history: body }))
658
+ .catch((error) => {
659
+ historyDirty = true
660
+ console.error('[dsh-usage-dashboard] history persist failed:', error)
661
+ })
662
+ return historyWriteChain
663
+ }
664
+
665
+ /**
666
+ * Fold one finished scan into the durable history.
667
+ *
668
+ * Runs AFTER a complete baseline: absorbing a partial scan would leave the
669
+ * store without rows for sessions this pass happened to skip, and the next
670
+ * restart would then report less history than the previous one.
671
+ */
672
+ function absorbHistory(target = aggregator) {
673
+ if (!historyReady) return
674
+ const changed = history.absorb(target.snapshot().requests)
675
+ if (changed > 0) historyDirty = true
676
+ if (historyDirty) void persistHistory()
677
+ }
678
+
679
+ /**
680
+ * Recover usage of sessions deleted before this feature existed.
681
+ *
682
+ * Runs once. `known` is what the scan could actually read, so every other
683
+ * checkpoint belongs to a session that is already gone. After the first
684
+ * success the durable store itself is authoritative about them and this
685
+ * never needs to run again.
686
+ *
687
+ * Returns rows to fold; it does not touch `aggregator`, because the caller
688
+ * owns the in-progress scan and folding them here would publish a view
689
+ * missing the live logs.
690
+ */
691
+ async function backfillRows(known) {
692
+ if (!historyReady || backfillDone) return []
693
+ backfillDone = true
694
+ const root = storage?.backend?.get?.('json')?.root
695
+ if (typeof root !== 'string' || root === '') return []
696
+ try {
697
+ const { rows, failed } = await readBackfillRows(root, known)
698
+ backfill = { at: Date.now(), sessions: rows.length, failed }
699
+ if (rows.length > 0) {
700
+ console.info(`[dsh-usage-dashboard] recovered ${rows.length} deleted session(s) from checkpoints`)
701
+ }
702
+ return rows
703
+ } catch (error) {
704
+ // A backfill is an optimisation, never a startup requirement.
705
+ console.error('[dsh-usage-dashboard] history backfill failed:', error)
706
+ return []
707
+ }
708
+ }
709
+
710
+ // --- scanning ------------------------------------------------------------
711
+
712
+ async function listSessionIds() {
713
+ const records = await sessionQuery.listSessions()
714
+ const ids = []
715
+ if (!Array.isArray(records)) return null
716
+ for (const record of records) {
717
+ const sid = record?.header?.id
718
+ if (typeof sid === 'string') ids.push(sid)
719
+ }
720
+ return ids
721
+ }
722
+
723
+ async function loadOne(sid, target = aggregator) {
724
+ const snap = await sessionQuery.readSession(sid)
725
+ if (disposed) return
726
+ if (!isRecord(snap) || !Array.isArray(snap.events)) {
727
+ throw new Error(`sessionQuery.readSession(${sid}) returned malformed data`)
728
+ }
729
+ const events = snap.events
730
+ target.loadSession(sid, events)
731
+ }
732
+
733
+ async function runBaseline() {
734
+ // A second caller waits for the run in flight rather than returning early:
735
+ // the pricing route answers only once the NEW price table has been folded,
736
+ // otherwise it would report numbers from the table the user just replaced.
737
+ if (baselineRunning && baselinePromise !== null) return baselinePromise
738
+ if (disposed) return
739
+ baselineRunning = true
740
+ baselinePromise = (async () => {
741
+ scan = { started: true, done: false, total: 0, loaded: 0, failed: 0 }
742
+ bump()
743
+ const nextAggregator = createAggregator({ prices })
744
+ let complete = false
745
+ try {
746
+ // Retained rows go in FIRST: they describe calls whose sessions were
747
+ // deleted, and folding the live logs afterwards lets an existing
748
+ // session override any (possibly stale) retained copy of itself.
749
+ if (historyReady) nextAggregator.loadRetained(history.rows())
750
+ const ids = await listSessionIds()
751
+ if (ids === null) {
752
+ scan = { ...scan, failed: 1 }
753
+ throw new Error('sessionQuery.listSessions() returned a non-array')
754
+ }
755
+ scan = { ...scan, total: ids.length }
756
+ let chunkStart = Date.now()
757
+ for (const sid of ids) {
758
+ if (disposed) return
759
+ try {
760
+ await loadOne(sid, nextAggregator)
761
+ scan = { ...scan, loaded: scan.loaded + 1 }
762
+ } catch (error) {
763
+ scan = { ...scan, failed: scan.failed + 1 }
764
+ console.error(`[dsh-usage-dashboard] session read failed (${sid}):`, error)
765
+ }
766
+ if (Date.now() - chunkStart > BASELINE_CHUNK_MS) {
767
+ chunkStart = Date.now()
768
+ // `timeout` rejects when the fiber is disposed, which is a normal end.
769
+ await ctx.timeout(0).catch(() => {})
770
+ }
771
+ }
772
+ complete = scan.failed === 0
773
+ // Recover sessions deleted before this feature existed. Done here (not
774
+ // at startup) because it can only recognise them AFTER a scan knows
775
+ // which ids the host still lists. The rows join the fold the snapshot
776
+ // is built from, so the published view is never partial.
777
+ if (complete && !disposed) {
778
+ const recovered = await backfillRows(new Set(ids))
779
+ if (recovered.length > 0) {
780
+ // Only the recovered rows: `loadRetained` never overwrites a slot
781
+ // the live fold already filled, so re-loading everything is wasted
782
+ // work while the live logs stay authoritative.
783
+ nextAggregator.loadRetained(recovered)
784
+ history.absorb(recovered)
785
+ historyDirty = true
786
+ }
787
+ }
788
+ } catch (error) {
789
+ console.error('[dsh-usage-dashboard] baseline failed:', error)
790
+ } finally {
791
+ if (complete && !disposed) {
792
+ aggregator = nextAggregator
793
+ // Record what this scan just measured, so the next scan still knows
794
+ // about it after the session is deleted.
795
+ absorbHistory(nextAggregator)
796
+ }
797
+ scan = { ...scan, done: true }
798
+ baselineRunning = false
799
+ baselinePromise = null
800
+ bump()
801
+ }
802
+ })()
803
+ return baselinePromise
804
+ }
805
+
806
+ // Live updates are debounced and reload the touched session from the host
807
+ // rather than folding the event here: the durable log is the single truth, and
808
+ // a partial fold could disagree with what the next baseline produces.
809
+ function scheduleLive(sid) {
810
+ livePending.add(sid)
811
+ if (liveScheduled) return
812
+ liveScheduled = true
813
+ ctx.timeout(async () => {
814
+ liveScheduled = false
815
+ const pending = [...livePending]
816
+ livePending.clear()
817
+ for (const id of pending) {
818
+ if (disposed) return
819
+ try {
820
+ await loadOne(id)
821
+ bump()
822
+ } catch (error) {
823
+ console.error(`[dsh-usage-dashboard] live reload failed (${id}):`, error)
824
+ }
825
+ }
826
+ // The reloaded session now has fresh rows; keep the durable copy in step
827
+ // so deleting it later keeps everything it spent up to this moment.
828
+ if (pending.length > 0) absorbHistory()
829
+ }, LIVE_DEBOUNCE_MS)
830
+ }
831
+
832
+ // --- routes --------------------------------------------------------------
833
+
834
+ ctx.effect(() => webServer.register({
835
+ kind: 'exact',
836
+ path: '/api/dsh-usage-dashboard/summary',
837
+ handler: (req, res) => {
838
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
839
+ if (!isTrustedLocalRequest(req)) { sendJson(res, 403, { ok: false, message: 'untrusted' }); return }
840
+ const params = Object.fromEntries(new URL(req.url ?? '/', 'http://x').searchParams)
841
+ // `days=all` needs the oldest record, which only the aggregator knows.
842
+ sendJson(res, 200, payload(scopeFromParams(params, { earliest: snapshot().earliest })))
843
+ },
844
+ }))
845
+
846
+ ctx.effect(() => webServer.register({
847
+ kind: 'exact',
848
+ path: '/api/dsh-usage-dashboard/pricing',
849
+ handler: async (req, res) => {
850
+ if (!isTrustedLocalRequest(req)) { sendJson(res, 403, { ok: false, message: 'untrusted' }); return }
851
+ if (req.method === 'GET') {
852
+ sendJson(res, 200, {
853
+ ok: true,
854
+ token: writeToken,
855
+ prices: prices.map(publicPrice),
856
+ // Fold rows are keyed by provider + route id, so one model reached
857
+ // over two routes arrives twice here. Prices key off the MODEL alone
858
+ // (see pricing.js), so two rows would edit the same index slot: the
859
+ // form would show a value in one row and blank in the other, and the
860
+ // later write would silently win. Collapse on the price key first.
861
+ models: pricingCandidates(snapshot().models, aliases),
862
+ })
863
+ return
864
+ }
865
+ if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
866
+ if (!tokenEquals(requestHeader(req, 'x-dsh-usage-dashboard-token'), writeToken)) {
867
+ sendJson(res, 403, { ok: false, message: 'bad-token' })
868
+ return
869
+ }
870
+ const parsed = await readJsonBody(req)
871
+ if (parsed.tooLarge === true || parsed.invalid === true) {
872
+ sendJson(res, 400, { ok: false, message: parsed.tooLarge ? 'too-large' : 'bad-body' })
873
+ return
874
+ }
875
+ const rows = isRecord(parsed.body) && Array.isArray(parsed.body.prices) ? parsed.body.prices : null
876
+ if (rows === null) { sendJson(res, 400, { ok: false, message: 'bad-prices' }); return }
877
+ prices = mergePrices(rows)
878
+ // The baseline publishes a fresh aggregator only after every session has
879
+ // been read successfully, so keep the current view until then.
880
+ await persistPrices()
881
+ await runBaseline()
882
+ sendJson(res, 200, { ok: true, revision, count: prices.length })
883
+ },
884
+ }))
885
+
886
+ // --- lifecycle -----------------------------------------------------------
887
+
888
+ ctx.on('session/event', (session, event) => {
889
+ if (disposed || !scan.done) return
890
+ const type = event?.type
891
+ if (type !== 'assistant/message' && type !== 'assistant/chunk' && type !== 'request/header' && type !== 'request/context') return
892
+ const sid = session?.id
893
+ if (typeof sid !== 'string') return
894
+ scheduleLive(sid)
895
+ })
896
+
897
+ ctx.on('session/flush', (session) => {
898
+ if (disposed || !scan.done) return
899
+ const sid = session?.id
900
+ if (typeof sid === 'string') scheduleLive(sid)
901
+ })
902
+
903
+ ctx.effect(() => {
904
+ const stop = ctx.interval(() => { void runBaseline() }, RECONCILE_INTERVAL_MS)
905
+ return stop
906
+ })
907
+
908
+ ctx.effect(() => {
909
+ const stop = ctx.interval(() => { void refreshAliases() }, ALIAS_REFRESH_MS)
910
+ return stop
911
+ })
912
+
913
+ ctx.effect(() => async () => {
914
+ disposed = true
915
+ const unit = kvUnit
916
+ const historyUnit = historyKvUnit
917
+ kvUnit = null
918
+ historyKvUnit = null
919
+ await writeChain.catch(() => {})
920
+ if (historyDirty) await persistHistory().catch(() => {})
921
+ await historyWriteChain.catch(() => {})
922
+ await unit?.close().catch(() => {})
923
+ await historyUnit?.close().catch(() => {})
924
+ })
925
+
926
+ void (async () => {
927
+ // Before the baseline so the first render already carries real names
928
+ // rather than flashing route ids and correcting itself.
929
+ await refreshAliases()
930
+ await loadPrices()
931
+ // Before the baseline so the first fold already includes the sessions
932
+ // that no longer exist. Loading it later would publish a first snapshot
933
+ // that is missing every deleted session's usage.
934
+ await loadHistory()
935
+ await runBaseline()
936
+ })()
937
+ }
938
+
939
+ export default { name, inject, apply }