@sligqoer/dsh-usage-stats 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/LICENSE +177 -0
- package/README.md +318 -0
- package/README.zh-CN.md +318 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +4074 -0
- package/lib/index.js +1105 -0
- package/lib/provider-quotas.js +526 -0
- package/lib/quota-controls.js +479 -0
- package/package.json +58 -0
- package/screenshots/controls.png +0 -0
- package/screenshots/icon.svg +5 -0
- package/screenshots/quotas.png +0 -0
- package/screenshots/usage.png +0 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-stats — host half. Aggregates token usage from every session's
|
|
3
|
+
* durable log (the `assistant/message` events carry `usage` accounting plus
|
|
4
|
+
* provider/model provenance) and serves statistics, quota and control routes
|
|
5
|
+
* under /api/dsh-usage-stats for the desktop settings page (用量统计). The renderer half
|
|
6
|
+
* (./client) registers the settings section with the heatmap, the 24-hour
|
|
7
|
+
* token chart, and the per-model breakdown.
|
|
8
|
+
*
|
|
9
|
+
* Provider quotas use the credentials configured in DSH. Optional local
|
|
10
|
+
* plugins are registered as quota adapters, with display-only cache data.
|
|
11
|
+
*
|
|
12
|
+
* Performance model (fixed for slow page loads):
|
|
13
|
+
* - The corpus is aggregated into per-session contributions that are kept
|
|
14
|
+
* in memory AND persisted to <DSH_HOME>/storages/usage-stats-corpus.json,
|
|
15
|
+
* so a server restart does not force a cold full scan.
|
|
16
|
+
* - Refresh uses the SQLite session `f_head_sequence` or legacy JSONL file
|
|
17
|
+
* metadata as a fingerprint when available. Sessions without either
|
|
18
|
+
* fingerprint are re-read on every background refresh.
|
|
19
|
+
* - A background interval keeps the corpus warm in the last requested timezone.
|
|
20
|
+
* - A request waits at most MAX_WAIT_MS for a scan. An unfinished scan or
|
|
21
|
+
* failed session read marks the current snapshot `stale:true`.
|
|
22
|
+
*
|
|
23
|
+
* The desktop renderer sends its system UTC offset. Sessions with source events are
|
|
24
|
+
* re-bucketed exactly; old retained sessions use their stored hourly buckets.
|
|
25
|
+
* `?fresh=1` forces a rescan. No dsh source changes.
|
|
26
|
+
*/
|
|
27
|
+
import { mkdir, readFile, rename, stat, writeFile, readdir } from 'node:fs/promises'
|
|
28
|
+
import { dirname, join } from 'node:path'
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import { createProviderQuotaReader } from './provider-quotas.js'
|
|
31
|
+
import { createQuotaControls, workBuddyQuotaSource, QuotaControlError } from './quota-controls.js'
|
|
32
|
+
|
|
33
|
+
/** Stable cordis plugin name (row id `usage-stats`). */
|
|
34
|
+
export const name = 'usage-stats'
|
|
35
|
+
|
|
36
|
+
/** Services required before the surfaces can mount (timer for the warm loop). */
|
|
37
|
+
export const inject = ['profileContext', 'sessionQuery', 'webServer', 'timer']
|
|
38
|
+
|
|
39
|
+
/** Serving a snapshot older than this counts as stale (ms). */
|
|
40
|
+
const CACHE_TTL_MS = 60 * 1000
|
|
41
|
+
/** Background keep-fresh cadence (ms). */
|
|
42
|
+
const SYNC_INTERVAL_MS = 30 * 1000
|
|
43
|
+
/** Upper bound a request waits on a refresh before serving current data (ms). */
|
|
44
|
+
const MAX_WAIT_MS = 1500
|
|
45
|
+
/** Concurrency for reading sessions during a refresh. */
|
|
46
|
+
const SCAN_CONCURRENCY = 6
|
|
47
|
+
/** Persisted corpus file name under <DSH_HOME>/storages. */
|
|
48
|
+
const STORE_FILE = 'usage-stats-corpus.json'
|
|
49
|
+
|
|
50
|
+
/** Fallback for requests without a desktop timezone. */
|
|
51
|
+
const SYSTEM_TZ = new Date().getTimezoneOffset()
|
|
52
|
+
|
|
53
|
+
let sqlitePromise = null
|
|
54
|
+
|
|
55
|
+
/** Lazy `node:sqlite` loader — null when the runtime lacks the builtin. */
|
|
56
|
+
function loadSqlite() {
|
|
57
|
+
if (sqlitePromise === null) {
|
|
58
|
+
sqlitePromise = import('node:sqlite').catch(() => null)
|
|
59
|
+
}
|
|
60
|
+
return sqlitePromise
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ------------------------------------------------------------------ */
|
|
64
|
+
/* Loopback trust fence for /api/dsh-usage-stats (family shared helper) */
|
|
65
|
+
/* ------------------------------------------------------------------ */
|
|
66
|
+
function isLoopbackRequest(req) {
|
|
67
|
+
const addr = req.socket && req.socket.remoteAddress
|
|
68
|
+
if (typeof addr !== 'string') return false
|
|
69
|
+
const normalized = addr.toLowerCase()
|
|
70
|
+
const isLoopback = normalized === '::1' || normalized.startsWith('::ffff:127.') || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized)
|
|
71
|
+
if (!isLoopback) return false
|
|
72
|
+
const host = req.headers.host
|
|
73
|
+
if (typeof host !== 'string') return false
|
|
74
|
+
let hostname
|
|
75
|
+
try {
|
|
76
|
+
hostname = new URL('http://' + host).hostname
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
const hostOk = hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)
|
|
81
|
+
if (!hostOk) return false
|
|
82
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
83
|
+
const origin = req.headers.origin
|
|
84
|
+
if (origin === undefined) return true
|
|
85
|
+
try {
|
|
86
|
+
return new URL(origin).host === hostUrlHost(req)
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function hostUrlHost(req) {
|
|
92
|
+
try {
|
|
93
|
+
return new URL('http://' + req.headers.host).host
|
|
94
|
+
} catch {
|
|
95
|
+
return ''
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ------------------------------------------------------------------ */
|
|
100
|
+
/* Buckets */
|
|
101
|
+
/* ------------------------------------------------------------------ */
|
|
102
|
+
|
|
103
|
+
function emptyBucket() {
|
|
104
|
+
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, billed: 0, calls: 0 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addUsage(bucket, usage) {
|
|
108
|
+
const u = usage || {}
|
|
109
|
+
const input = Number(u.inputTokens) || 0
|
|
110
|
+
const output = Number(u.outputTokens) || 0
|
|
111
|
+
const cacheRead = Number(u.cacheReadTokens) || 0
|
|
112
|
+
const cacheWrite = Number(u.cacheWriteTokens) || 0
|
|
113
|
+
const reasoning = Number(u.reasoningTokens) || 0
|
|
114
|
+
bucket.inputTokens += input
|
|
115
|
+
bucket.outputTokens += output
|
|
116
|
+
bucket.cacheReadTokens += cacheRead
|
|
117
|
+
bucket.cacheWriteTokens += cacheWrite
|
|
118
|
+
bucket.reasoningTokens += reasoning
|
|
119
|
+
bucket.billed += input + output + cacheRead + cacheWrite
|
|
120
|
+
bucket.calls += 1
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mergeBucket(into, b) {
|
|
124
|
+
into.inputTokens += b.inputTokens || 0
|
|
125
|
+
into.outputTokens += b.outputTokens || 0
|
|
126
|
+
into.cacheReadTokens += b.cacheReadTokens || 0
|
|
127
|
+
into.cacheWriteTokens += b.cacheWriteTokens || 0
|
|
128
|
+
into.reasoningTokens += b.reasoningTokens || 0
|
|
129
|
+
into.billed += b.billed || 0
|
|
130
|
+
into.calls += b.calls || 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Independent copy of a bucket (so folding never mutates the stored corpus). */
|
|
134
|
+
function copyBucket(b) {
|
|
135
|
+
return {
|
|
136
|
+
inputTokens: b.inputTokens || 0,
|
|
137
|
+
outputTokens: b.outputTokens || 0,
|
|
138
|
+
cacheReadTokens: b.cacheReadTokens || 0,
|
|
139
|
+
cacheWriteTokens: b.cacheWriteTokens || 0,
|
|
140
|
+
reasoningTokens: b.reasoningTokens || 0,
|
|
141
|
+
billed: b.billed || 0,
|
|
142
|
+
calls: b.calls || 0,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function serializeBucket(b) {
|
|
147
|
+
return {
|
|
148
|
+
inputTokens: b.inputTokens,
|
|
149
|
+
outputTokens: b.outputTokens,
|
|
150
|
+
cacheReadTokens: b.cacheReadTokens,
|
|
151
|
+
cacheWriteTokens: b.cacheWriteTokens,
|
|
152
|
+
reasoningTokens: b.reasoningTokens,
|
|
153
|
+
billed: b.billed,
|
|
154
|
+
calls: b.calls,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Simple concurrency-limited async map (failures become undefined). */
|
|
159
|
+
async function mapLimit(items, limit, fn) {
|
|
160
|
+
const results = new Array(items.length)
|
|
161
|
+
let next = 0
|
|
162
|
+
async function worker() {
|
|
163
|
+
for (;;) {
|
|
164
|
+
const i = next++
|
|
165
|
+
if (i >= items.length) return
|
|
166
|
+
try {
|
|
167
|
+
results[i] = await fn(items[i])
|
|
168
|
+
} catch {
|
|
169
|
+
results[i] = undefined
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const workers = []
|
|
174
|
+
for (let w = 0; w < Math.min(limit, items.length); w++) workers.push(worker())
|
|
175
|
+
await Promise.all(workers)
|
|
176
|
+
return results
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ------------------------------------------------------------------ */
|
|
180
|
+
/* Date keys under a system UTC offset (minutes: UTC - local) */
|
|
181
|
+
/* ------------------------------------------------------------------ */
|
|
182
|
+
|
|
183
|
+
function clampTz(tz) {
|
|
184
|
+
const n = Number(tz)
|
|
185
|
+
if (!Number.isFinite(n)) return SYSTEM_TZ
|
|
186
|
+
return Math.max(-840, Math.min(840, Math.round(n)))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function localDayKey(timeMs, tzOffsetMin) {
|
|
190
|
+
return new Date(timeMs - tzOffsetMin * 60000).toISOString().slice(0, 10)
|
|
191
|
+
}
|
|
192
|
+
export function localMondayKey(timeMs, tzOffsetMin) {
|
|
193
|
+
const shifted = timeMs - tzOffsetMin * 60000
|
|
194
|
+
const day = (new Date(shifted).getUTCDay() + 6) % 7 // 0 = Monday
|
|
195
|
+
return new Date(shifted - day * 86400000).toISOString().slice(0, 10)
|
|
196
|
+
}
|
|
197
|
+
/** Local hour (0..23) of a timestamp under a system UTC offset. */
|
|
198
|
+
export function localHourKey(timeMs, tzOffsetMin) {
|
|
199
|
+
return new Date(timeMs - tzOffsetMin * 60000).getUTCHours()
|
|
200
|
+
}
|
|
201
|
+
/** Milliseconds of a local date key's midnight. */
|
|
202
|
+
function keyMidnight(dateKey, tzOffsetMin) {
|
|
203
|
+
return Date.parse(dateKey + 'T00:00:00Z') + tzOffsetMin * 60000
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function byDate(a, b) {
|
|
207
|
+
return a.date < b.date ? -1 : a.date > b.date ? 1 : 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* ------------------------------------------------------------------ */
|
|
211
|
+
/* Per-session aggregation */
|
|
212
|
+
/* ------------------------------------------------------------------ */
|
|
213
|
+
|
|
214
|
+
function putBucket(map, key, usage) {
|
|
215
|
+
let b = map[key]
|
|
216
|
+
if (b === undefined) {
|
|
217
|
+
b = emptyBucket()
|
|
218
|
+
map[key] = b
|
|
219
|
+
}
|
|
220
|
+
addUsage(b, usage)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Fold one session's events into a per-session contribution keyed by local
|
|
225
|
+
* date (day/week), date|modelKey, and modelKey. Uses plain object maps so the
|
|
226
|
+
* result JSON-serializes cleanly for persistence.
|
|
227
|
+
* @param events - the session's raw event log.
|
|
228
|
+
* @param tz - UTC offset (minutes) used for day/week keys.
|
|
229
|
+
*/
|
|
230
|
+
export function scanSessionEvents(events, tz) {
|
|
231
|
+
const s = {
|
|
232
|
+
messages: 0,
|
|
233
|
+
withUsage: false,
|
|
234
|
+
totals: emptyBucket(),
|
|
235
|
+
byDay: Object.create(null),
|
|
236
|
+
byWeek: Object.create(null),
|
|
237
|
+
byHour: Object.create(null),
|
|
238
|
+
byDayModel: Object.create(null),
|
|
239
|
+
byWeekModel: Object.create(null),
|
|
240
|
+
byHourModel: Object.create(null),
|
|
241
|
+
byModel: Object.create(null),
|
|
242
|
+
usageEvents: [],
|
|
243
|
+
}
|
|
244
|
+
for (const ev of events || []) {
|
|
245
|
+
if (!ev || ev.type !== 'assistant/message') continue
|
|
246
|
+
const usage = ev.data && ev.data.usage
|
|
247
|
+
if (!usage) continue
|
|
248
|
+
const time = ev.time
|
|
249
|
+
if (!(time > 0)) continue
|
|
250
|
+
s.withUsage = true
|
|
251
|
+
s.messages += 1
|
|
252
|
+
|
|
253
|
+
const source = ev.data.message && ev.data.message.source
|
|
254
|
+
const provider = source && source.provider ? String(source.provider) : 'unknown'
|
|
255
|
+
const model = source && source.model ? String(source.model) : 'unknown'
|
|
256
|
+
const modelKey = provider + '/' + model
|
|
257
|
+
|
|
258
|
+
// Persist only the fields needed to re-bucket retained sessions exactly.
|
|
259
|
+
s.usageEvents.push([time, provider, model,
|
|
260
|
+
Number(usage.inputTokens) || 0, Number(usage.outputTokens) || 0,
|
|
261
|
+
Number(usage.cacheReadTokens) || 0, Number(usage.cacheWriteTokens) || 0,
|
|
262
|
+
Number(usage.reasoningTokens) || 0])
|
|
263
|
+
|
|
264
|
+
const dayKey = localDayKey(time, tz)
|
|
265
|
+
const hour = localHourKey(time, tz)
|
|
266
|
+
const hourKey = dayKey + '|' + hour
|
|
267
|
+
|
|
268
|
+
addUsage(s.totals, usage)
|
|
269
|
+
putBucket(s.byDay, dayKey, usage)
|
|
270
|
+
putBucket(s.byWeek, localMondayKey(time, tz), usage)
|
|
271
|
+
putBucket(s.byHour, hourKey, usage)
|
|
272
|
+
putBucket(s.byDayModel, dayKey + '|' + modelKey, usage)
|
|
273
|
+
putBucket(s.byWeekModel, localMondayKey(time, tz) + '|' + modelKey, usage)
|
|
274
|
+
putBucket(s.byHourModel, hourKey + '|' + modelKey, usage)
|
|
275
|
+
|
|
276
|
+
let mb = s.byModel[modelKey]
|
|
277
|
+
if (mb === undefined) {
|
|
278
|
+
mb = { provider, model, lastTime: 0, bucket: emptyBucket() }
|
|
279
|
+
s.byModel[modelKey] = mb
|
|
280
|
+
}
|
|
281
|
+
addUsage(mb.bucket, usage)
|
|
282
|
+
if (time > mb.lastTime) mb.lastTime = time
|
|
283
|
+
}
|
|
284
|
+
return s
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Fingerprint of an unchanged session log: size + mtimeMs. */
|
|
288
|
+
export function fpKey(fp) {
|
|
289
|
+
if (!fp) return null
|
|
290
|
+
return String(fp.size) + ':' + Math.round(fp.mtimeMs)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ------------------------------------------------------------------ */
|
|
294
|
+
/* Corpus state + persistence */
|
|
295
|
+
/* ------------------------------------------------------------------ */
|
|
296
|
+
|
|
297
|
+
export function defaultState() {
|
|
298
|
+
return { version: 3, tz: SYSTEM_TZ, lastSessionsCount: 0, syncedAt: 0, partial: false, sessions: {} }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function resolveHome() {
|
|
302
|
+
return (process.env.DSH_HOME && process.env.DSH_HOME.trim().length > 0) ? process.env.DSH_HOME : join(homedir(), '.dsh')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function resolveStorePath() {
|
|
306
|
+
return join(resolveHome(), 'storages', STORE_FILE)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function persistState(state, storePath) {
|
|
310
|
+
try {
|
|
311
|
+
await mkdir(dirname(storePath), { recursive: true })
|
|
312
|
+
const tmp = storePath + '.tmp'
|
|
313
|
+
await writeFile(tmp, JSON.stringify(state), 'utf8')
|
|
314
|
+
await rename(tmp, storePath)
|
|
315
|
+
} catch (error) {
|
|
316
|
+
// Persistence is best-effort: aggregation still works, it just costs a
|
|
317
|
+
// cold scan after the next restart.
|
|
318
|
+
console.warn('[dsh-usage-stats] persist corpus failed:', String(error && error.message || error))
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Validate + normalize a stored contribution so a corrupt file cannot crash. */
|
|
323
|
+
function normalizeSession(s) {
|
|
324
|
+
if (!s || typeof s !== 'object') return null
|
|
325
|
+
const base = emptyBucket()
|
|
326
|
+
const totals = {
|
|
327
|
+
inputTokens: s.totals && s.totals.inputTokens || 0,
|
|
328
|
+
outputTokens: s.totals && s.totals.outputTokens || 0,
|
|
329
|
+
cacheReadTokens: s.totals && s.totals.cacheReadTokens || 0,
|
|
330
|
+
cacheWriteTokens: s.totals && s.totals.cacheWriteTokens || 0,
|
|
331
|
+
reasoningTokens: s.totals && s.totals.reasoningTokens || 0,
|
|
332
|
+
billed: s.totals && s.totals.billed || 0,
|
|
333
|
+
calls: s.totals && s.totals.calls || 0,
|
|
334
|
+
}
|
|
335
|
+
void base
|
|
336
|
+
const norm = {
|
|
337
|
+
fp: (s.fp && Number.isFinite(s.fp.size) && Number.isFinite(s.fp.mtimeMs)) ? { size: s.fp.size, mtimeMs: s.fp.mtimeMs } : null,
|
|
338
|
+
retained: !!s.retained,
|
|
339
|
+
syncedAt: Number.isFinite(s.syncedAt) ? s.syncedAt : 0,
|
|
340
|
+
messages: Number.isFinite(s.messages) ? Math.max(0, Math.round(s.messages)) : 0,
|
|
341
|
+
withUsage: !!s.withUsage,
|
|
342
|
+
totals,
|
|
343
|
+
byDay: s.byDay && typeof s.byDay === 'object' ? s.byDay : {},
|
|
344
|
+
byWeek: s.byWeek && typeof s.byWeek === 'object' ? s.byWeek : {},
|
|
345
|
+
byHour: s.byHour && typeof s.byHour === 'object' ? s.byHour : {},
|
|
346
|
+
byDayModel: s.byDayModel && typeof s.byDayModel === 'object' ? s.byDayModel : {},
|
|
347
|
+
byWeekModel: s.byWeekModel && typeof s.byWeekModel === 'object' ? s.byWeekModel : {},
|
|
348
|
+
byHourModel: s.byHourModel && typeof s.byHourModel === 'object' ? s.byHourModel : {},
|
|
349
|
+
byModel: s.byModel && typeof s.byModel === 'object' ? s.byModel : {},
|
|
350
|
+
usageEvents: Array.isArray(s.usageEvents) ? s.usageEvents.filter((e) => Array.isArray(e) && e.length === 8 && Number.isFinite(e[0])) : null,
|
|
351
|
+
}
|
|
352
|
+
// Recompute per-session totals from day buckets if they are missing (older snapshots).
|
|
353
|
+
if (!(s.totals && typeof s.totals === 'object' && 'billed' in s.totals)) {
|
|
354
|
+
const recomputed = emptyBucket()
|
|
355
|
+
for (const k of Object.keys(norm.byDay)) mergeBucket(recomputed, norm.byDay[k])
|
|
356
|
+
norm.totals = recomputed
|
|
357
|
+
}
|
|
358
|
+
return norm
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function loadState(storePath) {
|
|
362
|
+
const state = defaultState()
|
|
363
|
+
try {
|
|
364
|
+
const raw = await readFile(storePath, 'utf8')
|
|
365
|
+
const j = JSON.parse(raw)
|
|
366
|
+
if (j && j.version === 3 && j.sessions && typeof j.sessions === 'object') {
|
|
367
|
+
state.tz = clampTz(j.tz)
|
|
368
|
+
state.lastSessionsCount = Number.isFinite(j.lastSessionsCount) ? Math.max(0, Math.round(j.lastSessionsCount)) : 0
|
|
369
|
+
state.syncedAt = Number.isFinite(j.syncedAt) ? j.syncedAt : (Number.isFinite(j.savedAt) ? j.savedAt : 0)
|
|
370
|
+
state.partial = !!j.partial
|
|
371
|
+
for (const id of Object.keys(j.sessions)) {
|
|
372
|
+
const norm = normalizeSession(j.sessions[id])
|
|
373
|
+
if (norm) state.sessions[id] = norm
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
// No snapshot yet (first run) or unreadable — start empty; a background
|
|
378
|
+
// sync will rebuild it.
|
|
379
|
+
}
|
|
380
|
+
return state
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* ------------------------------------------------------------------ */
|
|
384
|
+
/* Filesystem fingerprinting of persisted session logs */
|
|
385
|
+
/* ------------------------------------------------------------------ */
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Cheap staleness detector. The persisted logs live at
|
|
389
|
+
* <DSH_HOME>/sessions/<encoded-cwd>/<sessionId>/session.jsonl.zstd
|
|
390
|
+
* (dsh-session-persistence-jsonl). We enumerate those files and key them by
|
|
391
|
+
* the owning session id (the immediate parent directory name). Any id not
|
|
392
|
+
* found here is either live-only or behind an unexpected layout — the caller
|
|
393
|
+
* treats it as needing a re-read, which stays correct.
|
|
394
|
+
*/
|
|
395
|
+
export async function collectFileFps(sessionsDir) {
|
|
396
|
+
const out = new Map()
|
|
397
|
+
if (!sessionsDir) sessionsDir = join(resolveHome(), 'sessions')
|
|
398
|
+
let workspaces
|
|
399
|
+
try {
|
|
400
|
+
workspaces = await readdir(sessionsDir, { withFileTypes: true })
|
|
401
|
+
} catch {
|
|
402
|
+
return out
|
|
403
|
+
}
|
|
404
|
+
for (const ws of workspaces) {
|
|
405
|
+
if (!ws.isDirectory()) continue
|
|
406
|
+
let entries
|
|
407
|
+
try {
|
|
408
|
+
entries = await readdir(join(sessionsDir, ws.name), { withFileTypes: true })
|
|
409
|
+
} catch {
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
for (const entry of entries) {
|
|
413
|
+
if (!entry.isDirectory()) continue
|
|
414
|
+
const file = join(sessionsDir, ws.name, entry.name, 'session.jsonl.zstd')
|
|
415
|
+
try {
|
|
416
|
+
const st = await stat(file)
|
|
417
|
+
if (st.isFile()) out.set(entry.name, { size: st.size, mtimeMs: st.mtimeMs })
|
|
418
|
+
} catch {
|
|
419
|
+
// not a persisted session dir
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return out
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* SQLite-persistence fingerprint source.
|
|
428
|
+
*
|
|
429
|
+
* DSH switched its durable session store from per-session
|
|
430
|
+
* `session.jsonl.zstd` files (dsh-session-persistence-jsonl) to a single
|
|
431
|
+
* `sessions/sessions.sqlite` database (dsh-session-persistence). New sessions
|
|
432
|
+
* therefore have NO jsonl file at all, so `collectFileFps` returns nothing for
|
|
433
|
+
* them and the incremental scan would read each fresh session exactly once —
|
|
434
|
+
* while it is still empty — and never again (its file fingerprint stays null
|
|
435
|
+
* forever). That is why recent usage stopped being recorded.
|
|
436
|
+
*
|
|
437
|
+
* The SQLite store keeps an authoritative, monotonically increasing
|
|
438
|
+
* `f_head_sequence` per session (it equals MAX(f_sequence) of that session's
|
|
439
|
+
* events and is flushed live, even for the running session). We fold it into
|
|
440
|
+
* the same {size, mtimeMs} fingerprint shape the incremental scan already
|
|
441
|
+
* understands, so a session is re-read exactly when its log grows.
|
|
442
|
+
*
|
|
443
|
+
* Returns an empty Map when the DB is missing/unreadable (older jsonl-only
|
|
444
|
+
* deployments, or a transient lock) so the caller can fall back to files.
|
|
445
|
+
*/
|
|
446
|
+
export async function collectSqliteFps(sessionsDir) {
|
|
447
|
+
const out = new Map()
|
|
448
|
+
if (!sessionsDir) sessionsDir = join(resolveHome(), 'sessions')
|
|
449
|
+
const dbPath = join(sessionsDir, 'sessions.sqlite')
|
|
450
|
+
const sqlite = await loadSqlite()
|
|
451
|
+
if (!sqlite || typeof sqlite.DatabaseSync !== 'function') return out
|
|
452
|
+
let db
|
|
453
|
+
try {
|
|
454
|
+
db = new sqlite.DatabaseSync(dbPath, { readOnly: true, timeout: 5000 })
|
|
455
|
+
} catch {
|
|
456
|
+
return out
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const rows = db.prepare('SELECT f_session_id AS id, f_head_sequence AS seq FROM t_sessions').all()
|
|
460
|
+
for (const r of rows) {
|
|
461
|
+
const seq = Number(r.seq)
|
|
462
|
+
if (!r.id || !Number.isFinite(seq)) continue
|
|
463
|
+
// size + mtimeMs both carry head_sequence so fpKey changes whenever the
|
|
464
|
+
// session's event log grows.
|
|
465
|
+
out.set(r.id, { size: seq, mtimeMs: seq })
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
// Schema mismatch / query failure — leave whatever we collected.
|
|
469
|
+
} finally {
|
|
470
|
+
try {
|
|
471
|
+
db.close()
|
|
472
|
+
} catch {
|
|
473
|
+
// already closed
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return out
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* ------------------------------------------------------------------ */
|
|
480
|
+
/* Incremental sync + in-memory fold for the response */
|
|
481
|
+
/* ------------------------------------------------------------------ */
|
|
482
|
+
|
|
483
|
+
/** Move an old aggregate to another offset when its source log is gone. */
|
|
484
|
+
function rekeyLegacySession(s, fromTz, toTz) {
|
|
485
|
+
if (Array.isArray(s.usageEvents)) {
|
|
486
|
+
const events = s.usageEvents.map((e) => ({
|
|
487
|
+
type: 'assistant/message', time: e[0],
|
|
488
|
+
data: { message: { source: { provider: e[1], model: e[2] } },
|
|
489
|
+
usage: { inputTokens: e[3], outputTokens: e[4], cacheReadTokens: e[5], cacheWriteTokens: e[6], reasoningTokens: e[7] } },
|
|
490
|
+
}))
|
|
491
|
+
return { ...scanSessionEvents(events, toTz), fp: s.fp, retained: s.retained, syncedAt: s.syncedAt }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const next = {
|
|
495
|
+
...s,
|
|
496
|
+
byDay: Object.create(null), byWeek: Object.create(null), byHour: Object.create(null),
|
|
497
|
+
byDayModel: Object.create(null), byWeekModel: Object.create(null), byHourModel: Object.create(null),
|
|
498
|
+
}
|
|
499
|
+
const move = (source, withModel, hourly) => {
|
|
500
|
+
for (const key of Object.keys(source || {})) {
|
|
501
|
+
const parts = key.split('|')
|
|
502
|
+
const date = parts[0]
|
|
503
|
+
const hour = hourly ? Number(parts[1]) : 12
|
|
504
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isInteger(hour) || hour < 0 || hour > 23) continue
|
|
505
|
+
// At whole-hour offset differences this is exact. Old hourly aggregates
|
|
506
|
+
// cannot resolve which side of a fractional-hour boundary each event fell.
|
|
507
|
+
const time = Date.parse(date + 'T00:00:00Z') + (hour + (hourly ? 0.5 : 0)) * 3600000 + fromTz * 60000
|
|
508
|
+
const newDay = localDayKey(time, toTz)
|
|
509
|
+
const newWeek = localMondayKey(time, toTz)
|
|
510
|
+
const newHour = localHourKey(time, toTz)
|
|
511
|
+
const model = withModel ? parts.slice(hourly ? 2 : 1).join('|') : ''
|
|
512
|
+
const suffix = withModel ? '|' + model : ''
|
|
513
|
+
const b = source[key]
|
|
514
|
+
putMergedBucket(withModel ? next.byDayModel : next.byDay, newDay + suffix, b)
|
|
515
|
+
putMergedBucket(withModel ? next.byWeekModel : next.byWeek, newWeek + suffix, b)
|
|
516
|
+
if (hourly) putMergedBucket(withModel ? next.byHourModel : next.byHour, newDay + '|' + newHour + suffix, b)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
move(Object.keys(s.byHour || {}).length ? s.byHour : s.byDay, false, Object.keys(s.byHour || {}).length > 0)
|
|
520
|
+
move(Object.keys(s.byHourModel || {}).length ? s.byHourModel : s.byDayModel, true, Object.keys(s.byHourModel || {}).length > 0)
|
|
521
|
+
return next
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function putMergedBucket(map, key, bucket) {
|
|
525
|
+
if (!map[key]) map[key] = emptyBucket()
|
|
526
|
+
mergeBucket(map[key], bucket)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* One incremental refresh pass. Only changed/new sessions are re-read; the
|
|
531
|
+
* rest keep their previously measured contribution. Deleted sessions are
|
|
532
|
+
* dropped. The per-session contributions then fold into the response in
|
|
533
|
+
* memory.
|
|
534
|
+
*/
|
|
535
|
+
export async function syncCorpus(ctx, reqTz, state) {
|
|
536
|
+
const query = ctx.get('sessionQuery')
|
|
537
|
+
if (query === undefined) throw new Error('sessionQuery service is unavailable — cannot aggregate usage statistics')
|
|
538
|
+
const tz = clampTz(reqTz)
|
|
539
|
+
const tzChanged = state.tz !== tz
|
|
540
|
+
|
|
541
|
+
const records = await query.listSessions()
|
|
542
|
+
const ids = new Set()
|
|
543
|
+
for (const rec of records || []) {
|
|
544
|
+
const id = rec && rec.header && rec.header.id
|
|
545
|
+
if (id) ids.add(id)
|
|
546
|
+
}
|
|
547
|
+
// Sessions that no longer exist on disk or in the live corpus KEEP their
|
|
548
|
+
// measured contribution forever — deleting or archiving a conversation must
|
|
549
|
+
// not erase the usage it already generated (real token spend stays real).
|
|
550
|
+
// The session stays in state.sessions with a `retained` flag so it is still
|
|
551
|
+
// folded into every response.
|
|
552
|
+
const nextSessions = { ...state.sessions }
|
|
553
|
+
for (const id of Object.keys(nextSessions)) {
|
|
554
|
+
if (!ids.has(id)) {
|
|
555
|
+
nextSessions[id] = { ...nextSessions[id], retained: true }
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (tzChanged) {
|
|
559
|
+
for (const id of Object.keys(nextSessions)) nextSessions[id] = rekeyLegacySession(nextSessions[id], state.tz, tz)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Fingerprint source: SQLite persistence is authoritative now (every current
|
|
563
|
+
// session has a row whose head_sequence grows live); the jsonl scan is kept as
|
|
564
|
+
// a fallback for older deployments and for any id SQLite does not cover. The
|
|
565
|
+
// SQLite map is overlaid last so it wins wherever both exist.
|
|
566
|
+
const fps = await collectFileFps()
|
|
567
|
+
const sqlFps = await collectSqliteFps()
|
|
568
|
+
for (const [id, fp] of sqlFps) fps.set(id, fp)
|
|
569
|
+
const toRead = []
|
|
570
|
+
for (const rec of records || []) {
|
|
571
|
+
const id = rec && rec.header && rec.header.id
|
|
572
|
+
if (!id) continue
|
|
573
|
+
const fp = fps.get(id) || null
|
|
574
|
+
const prev = state.sessions[id]
|
|
575
|
+
if (prev !== undefined && prev.retained) nextSessions[id] = { ...nextSessions[id], retained: false }
|
|
576
|
+
if (tzChanged) {
|
|
577
|
+
toRead.push(id)
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
if (prev === undefined) {
|
|
581
|
+
toRead.push(id)
|
|
582
|
+
continue
|
|
583
|
+
}
|
|
584
|
+
// A listed session without a supported persistence fingerprint may still
|
|
585
|
+
// change. Re-read it on each refresh; absent sessions are not in records.
|
|
586
|
+
if (fp === null || fpKey(fp) !== fpKey(prev.fp || null)) toRead.push(id)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const readStart = Date.now()
|
|
590
|
+
let changed = 0
|
|
591
|
+
let failed = 0
|
|
592
|
+
if (toRead.length > 0) {
|
|
593
|
+
await mapLimit(toRead, SCAN_CONCURRENCY, async (id) => {
|
|
594
|
+
try {
|
|
595
|
+
const snap = await query.readSession(id)
|
|
596
|
+
const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz)
|
|
597
|
+
contribution.fp = fps.get(id) || null
|
|
598
|
+
contribution.syncedAt = Date.now()
|
|
599
|
+
nextSessions[id] = contribution
|
|
600
|
+
changed += 1
|
|
601
|
+
} catch {
|
|
602
|
+
// Keep the previous contribution (or absence) so one bad read never
|
|
603
|
+
// wipes a session from the stats.
|
|
604
|
+
failed += 1
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
state.sessions = nextSessions
|
|
609
|
+
state.lastSessionsCount = ids.size
|
|
610
|
+
state.tz = tz
|
|
611
|
+
state.partial = failed > 0
|
|
612
|
+
if (!state.partial) state.syncedAt = Date.now()
|
|
613
|
+
return { tz, tzChanged, changed, failed, readMs: Date.now() - readStart, sessionCount: ids.size }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Fold the in-memory per-session contributions into the API response body
|
|
618
|
+
* (windowed by `days`, optionally filtered to one `modelKey`). Pure and cheap:
|
|
619
|
+
* O(number of distinct usage dates / models), no I/O.
|
|
620
|
+
*/
|
|
621
|
+
export function foldResponse(state, days, modelKey, tz, now) {
|
|
622
|
+
// syncCorpus has already re-keyed all contributions to the requested offset.
|
|
623
|
+
const bucketTz = state.tz
|
|
624
|
+
void tz
|
|
625
|
+
const filterModel = modelKey
|
|
626
|
+
const fromKey = (() => {
|
|
627
|
+
if (!(days > 0)) return null
|
|
628
|
+
const fromMidnight = now - (days - 1) * 86400000
|
|
629
|
+
return localDayKey(fromMidnight, bucketTz)
|
|
630
|
+
})()
|
|
631
|
+
const todayKey = localDayKey(now, bucketTz)
|
|
632
|
+
// Keep enough day buckets for the heatmap even when the selected range is short.
|
|
633
|
+
const heatmapCutoff = localDayKey(now - 364 * 86400000, bucketTz)
|
|
634
|
+
const byDayCutoff = fromKey === null ? null : (fromKey > heatmapCutoff ? heatmapCutoff : fromKey)
|
|
635
|
+
|
|
636
|
+
const daySum = new Map()
|
|
637
|
+
const dayModelSum = new Map()
|
|
638
|
+
const weekSum = new Map()
|
|
639
|
+
const hourSum = new Map()
|
|
640
|
+
const modelSum = new Map()
|
|
641
|
+
const totals = emptyBucket()
|
|
642
|
+
const overviewTotals = emptyBucket()
|
|
643
|
+
const overviewDays = new Set()
|
|
644
|
+
const overviewModels = new Map()
|
|
645
|
+
let overviewTodayBilled = 0
|
|
646
|
+
let messages = 0
|
|
647
|
+
let sessionsWithUsage = 0
|
|
648
|
+
let firstTime = null
|
|
649
|
+
let lastTime = null
|
|
650
|
+
|
|
651
|
+
const bucketInto = (map, date, b) => {
|
|
652
|
+
const cur = map.get(date)
|
|
653
|
+
if (cur === undefined) map.set(date, copyBucket(b))
|
|
654
|
+
else mergeBucket(cur, b)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const addModelBucket = (mk, bucket, meta) => {
|
|
658
|
+
const acc = modelSum.get(mk)
|
|
659
|
+
if (acc === undefined) {
|
|
660
|
+
const slash = mk.indexOf('/')
|
|
661
|
+
modelSum.set(mk, {
|
|
662
|
+
provider: meta && meta.provider || (slash >= 0 ? mk.slice(0, slash) : 'unknown'),
|
|
663
|
+
model: meta && meta.model || (slash >= 0 ? mk.slice(slash + 1) : mk),
|
|
664
|
+
lastTime: meta && meta.lastTime || 0,
|
|
665
|
+
bucket: copyBucket(bucket),
|
|
666
|
+
})
|
|
667
|
+
} else {
|
|
668
|
+
mergeBucket(acc.bucket, bucket)
|
|
669
|
+
if (meta && meta.lastTime > acc.lastTime) acc.lastTime = meta.lastTime
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (const sid of Object.keys(state.sessions)) {
|
|
674
|
+
const s = state.sessions[sid]
|
|
675
|
+
if (!s) continue
|
|
676
|
+
mergeBucket(overviewTotals, s.totals || emptyBucket())
|
|
677
|
+
for (const date of Object.keys(s.byDay || {})) {
|
|
678
|
+
const bucket = s.byDay[date]
|
|
679
|
+
if ((bucket.calls || 0) > 0) overviewDays.add(date)
|
|
680
|
+
if (date === todayKey) overviewTodayBilled += bucket.billed || 0
|
|
681
|
+
}
|
|
682
|
+
for (const mk of Object.keys(s.byModel || {})) {
|
|
683
|
+
const model = s.byModel[mk]
|
|
684
|
+
const current = overviewModels.get(mk)
|
|
685
|
+
if (current) current.billed += model.bucket && model.bucket.billed || 0
|
|
686
|
+
else overviewModels.set(mk, { key: mk, provider: model.provider, model: model.model, billed: model.bucket && model.bucket.billed || 0 })
|
|
687
|
+
}
|
|
688
|
+
let sessionMessages = 0
|
|
689
|
+
const daySource = filterModel ? (s.byDayModel || {}) : (s.byDay || {})
|
|
690
|
+
for (const key of Object.keys(daySource)) {
|
|
691
|
+
let date = key
|
|
692
|
+
if (filterModel) {
|
|
693
|
+
const sep = key.indexOf('|')
|
|
694
|
+
if (sep < 0 || key.slice(sep + 1) !== filterModel) continue
|
|
695
|
+
date = key.slice(0, sep)
|
|
696
|
+
}
|
|
697
|
+
const bucket = daySource[key]
|
|
698
|
+
if (byDayCutoff === null || date >= byDayCutoff) bucketInto(daySum, date, bucket)
|
|
699
|
+
if (fromKey !== null && date < fromKey) continue
|
|
700
|
+
mergeBucket(totals, bucket)
|
|
701
|
+
sessionMessages += bucket.calls || 0
|
|
702
|
+
const mid = keyMidnight(date, bucketTz) + 43200000
|
|
703
|
+
bucketInto(weekSum, localMondayKey(mid, bucketTz), bucket)
|
|
704
|
+
if (firstTime === null || mid < firstTime) firstTime = mid
|
|
705
|
+
if (lastTime === null || mid > lastTime) lastTime = mid
|
|
706
|
+
}
|
|
707
|
+
for (const key of Object.keys(s.byDayModel || {})) {
|
|
708
|
+
const sep = key.indexOf('|')
|
|
709
|
+
if (sep < 0) continue
|
|
710
|
+
const date = key.slice(0, sep)
|
|
711
|
+
const mk = key.slice(sep + 1)
|
|
712
|
+
if (filterModel && mk !== filterModel) continue
|
|
713
|
+
if (byDayCutoff !== null && date < byDayCutoff) continue
|
|
714
|
+
let perDay = dayModelSum.get(date)
|
|
715
|
+
if (perDay === undefined) {
|
|
716
|
+
perDay = new Map()
|
|
717
|
+
dayModelSum.set(date, perDay)
|
|
718
|
+
}
|
|
719
|
+
bucketInto(perDay, mk, s.byDayModel[key])
|
|
720
|
+
}
|
|
721
|
+
messages += sessionMessages
|
|
722
|
+
if (sessionMessages > 0) sessionsWithUsage += 1
|
|
723
|
+
|
|
724
|
+
if (fromKey === null) {
|
|
725
|
+
for (const mk of Object.keys(s.byModel || {})) {
|
|
726
|
+
if (filterModel && mk !== filterModel) continue
|
|
727
|
+
const mb = s.byModel[mk]
|
|
728
|
+
addModelBucket(mk, mb.bucket || emptyBucket(), mb)
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
for (const key of Object.keys(s.byDayModel || {})) {
|
|
732
|
+
const sep = key.indexOf('|')
|
|
733
|
+
if (sep < 0 || key.slice(0, sep) < fromKey) continue
|
|
734
|
+
const mk = key.slice(sep + 1)
|
|
735
|
+
if (filterModel && mk !== filterModel) continue
|
|
736
|
+
addModelBucket(mk, s.byDayModel[key], s.byModel && s.byModel[mk])
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Hour-of-day sums for the current local day (keys: `date|hour`, or
|
|
741
|
+
// `date|hour|modelKey` when filtered). Each of the 24 bars is one hour of
|
|
742
|
+
// today; hours not yet elapsed (or without usage) stay zero.
|
|
743
|
+
const hourSource = filterModel ? (s.byHourModel || {}) : (s.byHour || {})
|
|
744
|
+
for (const key of Object.keys(hourSource)) {
|
|
745
|
+
let date
|
|
746
|
+
let hour
|
|
747
|
+
if (filterModel) {
|
|
748
|
+
const lastSep = key.lastIndexOf('|')
|
|
749
|
+
if (lastSep < 0 || key.slice(lastSep + 1) !== filterModel) continue
|
|
750
|
+
const midSep = key.lastIndexOf('|', lastSep - 1)
|
|
751
|
+
if (midSep < 0) continue
|
|
752
|
+
date = key.slice(0, midSep)
|
|
753
|
+
hour = key.slice(midSep + 1, lastSep)
|
|
754
|
+
} else {
|
|
755
|
+
const sep = key.lastIndexOf('|')
|
|
756
|
+
if (sep < 0) continue
|
|
757
|
+
date = key.slice(0, sep)
|
|
758
|
+
hour = key.slice(sep + 1)
|
|
759
|
+
}
|
|
760
|
+
if (date !== todayKey) continue
|
|
761
|
+
bucketInto(hourSum, hour, hourSource[key])
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const byDay = []
|
|
766
|
+
for (const [date, bucket] of daySum) if (byDayCutoff === null || date >= byDayCutoff) byDay.push({ date, bucket })
|
|
767
|
+
byDay.sort(byDate)
|
|
768
|
+
const byDayModels = [...dayModelSum].map(([date, perDay]) => ({
|
|
769
|
+
date,
|
|
770
|
+
models: [...perDay].map(([key, bucket]) => {
|
|
771
|
+
const meta = overviewModels.get(key)
|
|
772
|
+
const slash = key.indexOf('/')
|
|
773
|
+
return {
|
|
774
|
+
key,
|
|
775
|
+
provider: meta && meta.provider || (slash >= 0 ? key.slice(0, slash) : 'unknown'),
|
|
776
|
+
model: meta && meta.model || (slash >= 0 ? key.slice(slash + 1) : key),
|
|
777
|
+
billed: bucket.billed,
|
|
778
|
+
calls: bucket.calls,
|
|
779
|
+
}
|
|
780
|
+
}).sort((a, b) => b.billed - a.billed),
|
|
781
|
+
})).sort(byDate)
|
|
782
|
+
const byWeek = []
|
|
783
|
+
for (const [date, bucket] of weekSum) byWeek.push({ date, bucket })
|
|
784
|
+
byWeek.sort(byDate)
|
|
785
|
+
|
|
786
|
+
// Always 24 bars (0..23) so the chart shows a full day even for empty hours.
|
|
787
|
+
const byHour = []
|
|
788
|
+
for (let h = 0; h < 24; h++) {
|
|
789
|
+
const bucket = hourSum.get(String(h))
|
|
790
|
+
byHour.push({ hour: h, bucket: bucket === undefined ? emptyBucket() : bucket })
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Per-model hour sums for the current local day, for the stacked
|
|
794
|
+
// 24-hour chart (keys in the corpus: `date|hour|modelKey`). Colours are
|
|
795
|
+
// assigned client-side per model and persisted (localStorage), so a model
|
|
796
|
+
// keeps one fixed colour across the donut and the bars regardless of rank.
|
|
797
|
+
const hourModelSum = new Map() // hour -> Map(modelKey -> bucket)
|
|
798
|
+
for (const sid of Object.keys(state.sessions)) {
|
|
799
|
+
const s = state.sessions[sid]
|
|
800
|
+
if (!s) continue
|
|
801
|
+
const hm = s.byHourModel || {}
|
|
802
|
+
for (const key of Object.keys(hm)) {
|
|
803
|
+
const lastSep = key.lastIndexOf('|')
|
|
804
|
+
if (lastSep < 0) continue
|
|
805
|
+
const midSep = key.lastIndexOf('|', lastSep - 1)
|
|
806
|
+
if (midSep < 0) continue
|
|
807
|
+
const date = key.slice(0, midSep)
|
|
808
|
+
const hour = key.slice(midSep + 1, lastSep)
|
|
809
|
+
const mk = key.slice(lastSep + 1)
|
|
810
|
+
if (date !== todayKey) continue
|
|
811
|
+
if (filterModel && mk !== filterModel) continue
|
|
812
|
+
let per = hourModelSum.get(hour)
|
|
813
|
+
if (per === undefined) {
|
|
814
|
+
per = new Map()
|
|
815
|
+
hourModelSum.set(hour, per)
|
|
816
|
+
}
|
|
817
|
+
const cur = per.get(mk)
|
|
818
|
+
if (cur === undefined) per.set(mk, copyBucket(hm[key]))
|
|
819
|
+
else mergeBucket(cur, hm[key])
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const byHourModels = []
|
|
823
|
+
for (let h = 0; h < 24; h++) {
|
|
824
|
+
const per = hourModelSum.get(String(h))
|
|
825
|
+
const models = []
|
|
826
|
+
if (per !== undefined) {
|
|
827
|
+
for (const [mk, b] of per) {
|
|
828
|
+
const ref = modelSum.get(mk)
|
|
829
|
+
models.push({ key: mk, provider: ref ? ref.provider : 'unknown', model: ref ? ref.model : mk, billed: b.billed })
|
|
830
|
+
}
|
|
831
|
+
models.sort((a, b) => b.billed - a.billed)
|
|
832
|
+
// Same visual cap as the donut: top 8 + "其他".
|
|
833
|
+
if (models.length > 8) {
|
|
834
|
+
let restBilled = 0
|
|
835
|
+
for (let k = 8; k < models.length; k++) restBilled += models[k].billed
|
|
836
|
+
models.length = 8
|
|
837
|
+
models.push({ key: '__rest__', provider: '', model: '其他', billed: restBilled })
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
byHourModels.push({ hour: h, models })
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const byModel = []
|
|
844
|
+
for (const [key, m] of modelSum) byModel.push({ key, provider: m.provider, model: m.model, lastTime: m.lastTime, bucket: m.bucket })
|
|
845
|
+
byModel.sort((a, b) => b.bucket.billed - a.bucket.billed)
|
|
846
|
+
|
|
847
|
+
const overview = {
|
|
848
|
+
todayBilled: overviewTodayBilled,
|
|
849
|
+
usedDays: overviewDays.size,
|
|
850
|
+
totalBilled: overviewTotals.billed,
|
|
851
|
+
models: [...overviewModels.values()].sort((a, b) => b.billed - a.billed),
|
|
852
|
+
}
|
|
853
|
+
return { totals, messages, sessionsWithUsage, byDay, byDayModels, byWeek, byHour, byHourModels, byModel, overview, firstTime, lastTime }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/* ------------------------------------------------------------------ */
|
|
857
|
+
/* Cordis plugin `apply` */
|
|
858
|
+
/* ------------------------------------------------------------------ */
|
|
859
|
+
|
|
860
|
+
export function apply(ctx) {
|
|
861
|
+
if (ctx.get('profileContext')?.name !== 'desktop') return
|
|
862
|
+
|
|
863
|
+
const readProviderQuotas = createProviderQuotaReader(ctx)
|
|
864
|
+
const quotaControls = createQuotaControls(ctx, {
|
|
865
|
+
path: join(resolveHome(), 'storages', 'usage-stats-controls.json'),
|
|
866
|
+
sources: {
|
|
867
|
+
providers: (excluded) => readProviderQuotas(true, excluded),
|
|
868
|
+
workbuddy: workBuddyQuotaSource(ctx),
|
|
869
|
+
'workbuddy-ai': workBuddyQuotaSource(ctx, undefined, 'workbuddy-ai'),
|
|
870
|
+
},
|
|
871
|
+
})
|
|
872
|
+
const storePath = resolveStorePath()
|
|
873
|
+
let state = defaultState()
|
|
874
|
+
let syncPromise = null
|
|
875
|
+
let syncTz = null
|
|
876
|
+
|
|
877
|
+
let loadPromise = null
|
|
878
|
+
const ensureLoaded = () => {
|
|
879
|
+
if (loadPromise === null) {
|
|
880
|
+
loadPromise = loadState(storePath).then((loaded) => {
|
|
881
|
+
state = loaded
|
|
882
|
+
return loaded
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
return loadPromise
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Single-flight refresh; a different offset waits for the active pass. */
|
|
889
|
+
const sync = (tz = state.tz) => {
|
|
890
|
+
if (syncPromise !== null) {
|
|
891
|
+
if (syncTz === tz) return syncPromise
|
|
892
|
+
return syncPromise.then(() => sync(tz))
|
|
893
|
+
}
|
|
894
|
+
syncTz = tz
|
|
895
|
+
syncPromise = syncCorpus(ctx, tz, state)
|
|
896
|
+
.then(async (result) => {
|
|
897
|
+
if (result.changed > 0 || result.tzChanged) await persistState(state, storePath)
|
|
898
|
+
return result
|
|
899
|
+
})
|
|
900
|
+
.catch((error) => {
|
|
901
|
+
state.partial = true
|
|
902
|
+
console.warn('[dsh-usage-stats] sync failed:', String(error && error.message || error))
|
|
903
|
+
return { tz: state.tz, changed: 0, failed: 1, readMs: 0, sessionCount: state.lastSessionsCount, error: String(error && error.message || error) }
|
|
904
|
+
})
|
|
905
|
+
.finally(() => {
|
|
906
|
+
syncPromise = null
|
|
907
|
+
syncTz = null
|
|
908
|
+
})
|
|
909
|
+
return syncPromise
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Start a refresh and wait at most MAX_WAIT_MS before serving current data. */
|
|
913
|
+
const syncWithBudget = (tz) => {
|
|
914
|
+
const p = sync(tz)
|
|
915
|
+
return new Promise((resolve) => {
|
|
916
|
+
let settled = false
|
|
917
|
+
const finish = () => {
|
|
918
|
+
if (settled) return
|
|
919
|
+
settled = true
|
|
920
|
+
clearTimeout(timer)
|
|
921
|
+
resolve()
|
|
922
|
+
}
|
|
923
|
+
const timer = setTimeout(finish, MAX_WAIT_MS)
|
|
924
|
+
timer.unref?.()
|
|
925
|
+
p.then(finish, finish)
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const writeJson = (res, status, body) => {
|
|
930
|
+
const payload = JSON.stringify(body)
|
|
931
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
|
|
932
|
+
res.end(payload)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const guard = (req, res, method) => {
|
|
936
|
+
if (!isLoopbackRequest(req)) {
|
|
937
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
938
|
+
return false
|
|
939
|
+
}
|
|
940
|
+
if ((req.method || 'GET') !== method) {
|
|
941
|
+
writeJson(res, 405, { error: 'method not allowed: ' + req.method })
|
|
942
|
+
return false
|
|
943
|
+
}
|
|
944
|
+
return true
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const readQuery = (req) => {
|
|
948
|
+
try {
|
|
949
|
+
return new URL(req.url, 'http://x').searchParams
|
|
950
|
+
} catch {
|
|
951
|
+
return new URLSearchParams('')
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const webServer = ctx.get('webServer')
|
|
956
|
+
if (webServer === undefined) return
|
|
957
|
+
|
|
958
|
+
ctx.effect(
|
|
959
|
+
() => webServer.register({
|
|
960
|
+
kind: 'exact',
|
|
961
|
+
path: '/api/dsh-usage-stats/stats',
|
|
962
|
+
handler: async (req, res) => {
|
|
963
|
+
if (!guard(req, res, 'GET')) return
|
|
964
|
+
const t0 = Date.now()
|
|
965
|
+
const params = readQuery(req)
|
|
966
|
+
const daysParam = Number(params.get('days'))
|
|
967
|
+
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(Math.round(daysParam), 3650) : 0
|
|
968
|
+
const modelKey = (params.get('model') || '').trim() || null
|
|
969
|
+
const tzParam = params.get('tz')
|
|
970
|
+
const requestedTz = tzParam === null || tzParam === '' ? SYSTEM_TZ : clampTz(tzParam)
|
|
971
|
+
const fresh = params.get('fresh') === '1'
|
|
972
|
+
try {
|
|
973
|
+
await ensureLoaded()
|
|
974
|
+
const staleByTtl = Date.now() - (state.syncedAt || 0) > CACHE_TTL_MS
|
|
975
|
+
if (state.tz !== requestedTz) {
|
|
976
|
+
// A timezone switch must finish before the snapshot is served.
|
|
977
|
+
await sync(requestedTz)
|
|
978
|
+
} else if (fresh || staleByTtl || state.partial) {
|
|
979
|
+
await syncWithBudget(requestedTz)
|
|
980
|
+
}
|
|
981
|
+
if (state.tz !== requestedTz) throw new Error('requested timezone is not ready')
|
|
982
|
+
const now = Date.now()
|
|
983
|
+
const data = foldResponse(state, days, modelKey, state.tz, now)
|
|
984
|
+
const stale = syncPromise !== null || state.partial || now - (state.syncedAt || 0) > CACHE_TTL_MS
|
|
985
|
+
writeJson(res, 200, {
|
|
986
|
+
ok: true,
|
|
987
|
+
days,
|
|
988
|
+
model: modelKey,
|
|
989
|
+
tz: state.tz,
|
|
990
|
+
totals: serializeBucket(data.totals),
|
|
991
|
+
overview: data.overview,
|
|
992
|
+
messages: data.messages,
|
|
993
|
+
sessions: state.lastSessionsCount,
|
|
994
|
+
sessionsWithUsage: data.sessionsWithUsage,
|
|
995
|
+
firstTime: data.firstTime,
|
|
996
|
+
lastTime: data.lastTime,
|
|
997
|
+
byDay: data.byDay.map((d) => ({ date: d.date, ...serializeBucket(d.bucket) })),
|
|
998
|
+
byDayModels: data.byDayModels,
|
|
999
|
+
byWeek: data.byWeek.map((w) => ({ date: w.date, ...serializeBucket(w.bucket) })),
|
|
1000
|
+
byHour: data.byHour.map((h) => ({ hour: h.hour, ...serializeBucket(h.bucket) })),
|
|
1001
|
+
byHourModels: data.byHourModels,
|
|
1002
|
+
byModel: data.byModel.map((m) => ({ key: m.key, provider: m.provider, model: m.model, lastTime: m.lastTime, ...serializeBucket(m.bucket) })),
|
|
1003
|
+
syncedAt: state.syncedAt || 0,
|
|
1004
|
+
stale,
|
|
1005
|
+
partial: state.partial,
|
|
1006
|
+
scanMs: Date.now() - t0,
|
|
1007
|
+
})
|
|
1008
|
+
} catch {
|
|
1009
|
+
writeJson(res, 500, { ok: false, error: '用量统计暂时不可用,请稍后重试。' })
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
}),
|
|
1013
|
+
'dsh-usage-stats: routes',
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
ctx.effect(
|
|
1017
|
+
() => webServer.register({
|
|
1018
|
+
kind: 'exact',
|
|
1019
|
+
path: '/api/dsh-usage-stats/provider-quotas',
|
|
1020
|
+
handler: async (req, res) => {
|
|
1021
|
+
if (!guard(req, res, 'GET')) return
|
|
1022
|
+
const fresh = readQuery(req).get('fresh') === '1'
|
|
1023
|
+
try {
|
|
1024
|
+
writeJson(res, 200, await quotaControls.read('providers', fresh))
|
|
1025
|
+
} catch {
|
|
1026
|
+
// This route never sends credential or upstream error text to the renderer.
|
|
1027
|
+
writeJson(res, 200, { ok: true, quotas: [] })
|
|
1028
|
+
}
|
|
1029
|
+
},
|
|
1030
|
+
}),
|
|
1031
|
+
'dsh-usage-stats: official provider quota route',
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
const trustedControlRequest = (req, res) => {
|
|
1035
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' }); return false }
|
|
1036
|
+
try {
|
|
1037
|
+
const host = new URL('http://' + req.headers.host).hostname
|
|
1038
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(host)) throw new Error()
|
|
1039
|
+
if (req.headers.origin && new URL(req.headers.origin).host !== req.headers.host) throw new Error()
|
|
1040
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') throw new Error()
|
|
1041
|
+
} catch { writeJson(res, 403, { ok: false, error: '请求来源不受信任。' }); return false }
|
|
1042
|
+
return true
|
|
1043
|
+
}
|
|
1044
|
+
const readControlBody = async (req) => {
|
|
1045
|
+
if (!String(req.headers['content-type'] || '').startsWith('application/json')) throw new QuotaControlError('请使用 JSON 请求。')
|
|
1046
|
+
let bytes = 0
|
|
1047
|
+
const chunks = []
|
|
1048
|
+
for await (const chunk of req) {
|
|
1049
|
+
bytes += Buffer.byteLength(chunk)
|
|
1050
|
+
if (bytes > 32_768) throw new QuotaControlError('请求内容超过 32 KB。')
|
|
1051
|
+
chunks.push(Buffer.from(chunk))
|
|
1052
|
+
}
|
|
1053
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new QuotaControlError('请求内容不是有效 JSON。') }
|
|
1054
|
+
}
|
|
1055
|
+
const registerControl = (path, handler) => ctx.effect(() => webServer.register({
|
|
1056
|
+
kind: 'exact', path: '/api/dsh-usage-stats/' + path,
|
|
1057
|
+
handler: async (req, res) => {
|
|
1058
|
+
if (!trustedControlRequest(req, res)) return
|
|
1059
|
+
try { await handler(req, res) } catch (error) { writeJson(res, error instanceof QuotaControlError ? 400 : 500, { ok: false, error: error instanceof QuotaControlError ? error.message : '操作失败,请稍后重试。' }) }
|
|
1060
|
+
},
|
|
1061
|
+
}), 'dsh-usage-stats: ' + path)
|
|
1062
|
+
registerControl('controls', async (req, res) => {
|
|
1063
|
+
if (req.method === 'GET') writeJson(res, 200, await quotaControls.get())
|
|
1064
|
+
else if (guard(req, res, 'POST')) writeJson(res, 200, await quotaControls.update(await readControlBody(req)))
|
|
1065
|
+
})
|
|
1066
|
+
registerControl('quota-providers', async (req, res) => {
|
|
1067
|
+
if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.providers())
|
|
1068
|
+
})
|
|
1069
|
+
registerControl('quota-test', async (req, res) => {
|
|
1070
|
+
if (!guard(req, res, 'POST')) return
|
|
1071
|
+
const body = await readControlBody(req)
|
|
1072
|
+
writeJson(res, 200, await quotaControls.test(body.provider, body.template))
|
|
1073
|
+
})
|
|
1074
|
+
for (const source of ['workbuddy', 'workbuddy-ai']) {
|
|
1075
|
+
registerControl(source, async (req, res) => {
|
|
1076
|
+
if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.read(source, readQuery(req).get('fresh') === '1'))
|
|
1077
|
+
})
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
void quotaControls.tick().catch(() => {})
|
|
1081
|
+
ctx.effect(() => ctx.interval(() => { void quotaControls.tick().catch(() => {}) }, 30_000), 'dsh-usage-stats: automatic quota refresh')
|
|
1082
|
+
ctx.effect(() => () => quotaControls.dispose(), 'dsh-usage-stats: quota controls cleanup')
|
|
1083
|
+
|
|
1084
|
+
// Background keep-fresh: build + persist corpus shortly after boot so the
|
|
1085
|
+
// first page open is served from warm memory, and keep it warm thereafter.
|
|
1086
|
+
void ensureLoaded().then(() => {
|
|
1087
|
+
return sync().catch(() => {})
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
ctx.effect(
|
|
1091
|
+
() => ctx.interval(() => {
|
|
1092
|
+
void sync().catch(() => {})
|
|
1093
|
+
}, SYNC_INTERVAL_MS),
|
|
1094
|
+
'dsh-usage-stats: keep-fresh',
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
// Persist once at shutdown so the freshest snapshot survives restarts.
|
|
1098
|
+
ctx.effect(() => {
|
|
1099
|
+
return () => {
|
|
1100
|
+
if (Object.keys(state.sessions).length > 0) void persistState(state, storePath)
|
|
1101
|
+
}
|
|
1102
|
+
}, 'dsh-usage-stats: shutdown persist')
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export default { name, inject, apply }
|