@sixzjd/dsh-usage-dashboard 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.en.md +212 -0
- package/README.md +194 -2
- package/client.js +352 -0
- package/cordis.patch.yml +11 -0
- package/lib/aggregate.js +422 -0
- package/lib/backfill.js +116 -0
- package/lib/catalog.js +230 -0
- package/lib/history.js +150 -0
- package/lib/index.js +2 -0
- package/lib/plugin.js +939 -0
- package/lib/pricing.js +232 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
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 }
|