@sligqoer/dsh-usage-stats 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -1
- package/NOTICE +39 -0
- package/README.en.md +187 -0
- package/README.md +93 -224
- package/lib/bridge.js +33 -0
- package/lib/client.js +2587 -1159
- package/lib/index.js +420 -150
- package/lib/lm-fingerprint-engine.js +166 -0
- package/lib/lm-fingerprint-source.js +134 -0
- package/lib/lm-fingerprint-worker.js +31 -0
- package/lib/model-detection-history.js +86 -0
- package/lib/model-detection.js +255 -0
- package/lib/provider-quotas.js +26 -5
- package/lib/quota-controls.js +112 -30
- package/lib/supplemental-usage.js +77 -0
- package/lib/usage-preferences.js +51 -0
- package/locale/en.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +28 -18
- package/screenshots/controls.png +0 -0
- package/screenshots/detection.png +0 -0
- package/screenshots/quotas.png +0 -0
- package/screenshots/usage.png +0 -0
- package/third_party/brand-icons/CHANNEL-ICONS-LICENSE +21 -0
- package/third_party/brand-icons/LICENSE +59 -0
- package/third_party/brand-icons/NOTICE.txt +5 -0
- package/third_party/brand-icons/REACT-ICONS-LICENSE +104 -0
- package/third_party/brand-icons/channel-icons/LICENSE +21 -0
- package/third_party/brand-icons/channel-icons/UPSTREAM.json +6 -0
- package/third_party/brand-icons/lobe-icons/LICENSE +21 -0
- package/third_party/brand-icons/lobe-icons/UPSTREAM.json +16 -0
- package/third_party/brand-icons/react-icons/LICENSE +13 -0
- package/third_party/brand-icons/react-icons/UPSTREAM.json +6 -0
- package/third_party/lm-fingerprint/LICENSE +21 -0
- package/third_party/lm-fingerprint/UPSTREAM.json +32 -0
- package/third_party/lm-fingerprint/core/challenge-browser.mjs +210 -0
- package/third_party/lm-fingerprint/core/detector-codec.mjs +20 -0
- package/third_party/lm-fingerprint/core/fingerprint-core.mjs +121 -0
- package/third_party/lm-fingerprint/core/positional.mjs +64 -0
- package/third_party/lm-fingerprint/core/shared-detector.mjs +132 -0
- package/third_party/lm-fingerprint/data/shared_detector.bin +0 -0
- package/third_party/lm-fingerprint/data/unified_bank.json +826820 -0
- package/README.zh-CN.md +0 -318
package/lib/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Provider quotas use the credentials configured in DSH. Optional local
|
|
10
10
|
* plugins are registered as quota adapters, with display-only cache data.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Statistics cache:
|
|
13
13
|
* - The corpus is aggregated into per-session contributions that are kept
|
|
14
14
|
* in memory AND persisted to <DSH_HOME>/storages/usage-stats-corpus.json,
|
|
15
15
|
* so a server restart does not force a cold full scan.
|
|
@@ -22,15 +22,21 @@
|
|
|
22
22
|
*
|
|
23
23
|
* The desktop renderer sends its system UTC offset. Sessions with source events are
|
|
24
24
|
* re-bucketed exactly; old retained sessions use their stored hourly buckets.
|
|
25
|
-
* `?fresh=1` forces a rescan.
|
|
25
|
+
* `?fresh=1` forces a rescan.
|
|
26
26
|
*/
|
|
27
27
|
import { mkdir, readFile, rename, stat, writeFile, readdir } from 'node:fs/promises'
|
|
28
28
|
import { dirname, join } from 'node:path'
|
|
29
29
|
import { homedir } from 'node:os'
|
|
30
|
-
import { createProviderQuotaReader } from './provider-quotas.js'
|
|
31
|
-
import { createQuotaControls, workBuddyQuotaSource, QuotaControlError } from './quota-controls.js'
|
|
32
30
|
|
|
33
31
|
/** Stable cordis plugin name (row id `usage-stats`). */
|
|
32
|
+
import { createUsagePreferences, validateDisplayPatch, PreferenceError } from './usage-preferences.js'
|
|
33
|
+
import { createSupplementalUsage } from './supplemental-usage.js'
|
|
34
|
+
import { createProviderQuotaReader } from './provider-quotas.js'
|
|
35
|
+
import { createQuotaControls, QuotaControlError, workBuddyQuotaSource } from './quota-controls.js'
|
|
36
|
+
import { createModelDetectionRunner, readDetectionModels, ModelDetectionError } from './model-detection.js'
|
|
37
|
+
import { createModelDetectionHistory } from './model-detection-history.js'
|
|
38
|
+
import { getBridge, requireBridge, publishBridge, HUB_SERVICE, USAGE_SERVICE, HUB_UPDATED, IntegrationError } from './bridge.js'
|
|
39
|
+
|
|
34
40
|
export const name = 'usage-stats'
|
|
35
41
|
|
|
36
42
|
/** Services required before the surfaces can mount (timer for the warm loop). */
|
|
@@ -46,6 +52,8 @@ const MAX_WAIT_MS = 1500
|
|
|
46
52
|
const SCAN_CONCURRENCY = 6
|
|
47
53
|
/** Persisted corpus file name under <DSH_HOME>/storages. */
|
|
48
54
|
const STORE_FILE = 'usage-stats-corpus.json'
|
|
55
|
+
/** Rescan live logs when persisted speed timing fields change. */
|
|
56
|
+
const SPEED_VERSION = 2
|
|
49
57
|
|
|
50
58
|
/** Fallback for requests without a desktop timezone. */
|
|
51
59
|
const SYSTEM_TZ = new Date().getTimezoneOffset()
|
|
@@ -220,6 +228,38 @@ function putBucket(map, key, usage) {
|
|
|
220
228
|
addUsage(b, usage)
|
|
221
229
|
}
|
|
222
230
|
|
|
231
|
+
/** Mirror dsh-llm's assistantStreamFirstTokenTime for packed and raw records.
|
|
232
|
+
* Empty deltas do not start decoding; a tool name does. Never retain text. */
|
|
233
|
+
function firstOutputTime(stream) {
|
|
234
|
+
if (!Array.isArray(stream)) return null
|
|
235
|
+
for (const item of stream) {
|
|
236
|
+
if (!item) continue
|
|
237
|
+
if (item.type === 'chunk') {
|
|
238
|
+
const chunk = item.chunk
|
|
239
|
+
const hasToken = chunk && (['text-delta', 'reasoning-delta'].includes(chunk.type)
|
|
240
|
+
? typeof chunk.text === 'string' && chunk.text !== ''
|
|
241
|
+
: chunk.type === 'tool-call-delta' && (typeof chunk.argumentsDelta === 'string' && chunk.argumentsDelta !== '' || chunk.name !== undefined))
|
|
242
|
+
if (hasToken) return Number.isFinite(item.time) && item.time > 0 ? item.time : null
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if (!['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].includes(item.type)) continue
|
|
246
|
+
const parts = item.type === 'tool-call-chunks' ? item.args : item.texts
|
|
247
|
+
if (!Array.isArray(parts) || !parts.length) continue
|
|
248
|
+
if (!Number.isFinite(item.time0) || item.time0 <= 0 || !Array.isArray(item.dt) || item.dt.length !== parts.length - 1) return null
|
|
249
|
+
let time = item.time0
|
|
250
|
+
if (item.type === 'tool-call-chunks' && item.name !== undefined) return time
|
|
251
|
+
for (let i = 0; i < parts.length; i++) {
|
|
252
|
+
if (i > 0) {
|
|
253
|
+
const delta = item.dt[i - 1]
|
|
254
|
+
if (!Number.isFinite(delta) || delta < 0) return null
|
|
255
|
+
time += delta
|
|
256
|
+
}
|
|
257
|
+
if (typeof parts[i] === 'string' && parts[i] !== '') return Number.isFinite(time) ? time : null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
223
263
|
/**
|
|
224
264
|
* Fold one session's events into a per-session contribution keyed by local
|
|
225
265
|
* date (day/week), date|modelKey, and modelKey. Uses plain object maps so the
|
|
@@ -227,8 +267,13 @@ function putBucket(map, key, usage) {
|
|
|
227
267
|
* @param events - the session's raw event log.
|
|
228
268
|
* @param tz - UTC offset (minutes) used for day/week keys.
|
|
229
269
|
*/
|
|
230
|
-
export function scanSessionEvents(events, tz) {
|
|
270
|
+
export function scanSessionEvents(events, tz, inheritedEventCount = 0) {
|
|
231
271
|
const s = {
|
|
272
|
+
detailsVersion: 1,
|
|
273
|
+
ownUsageVersion: 1,
|
|
274
|
+
speedVersion: SPEED_VERSION,
|
|
275
|
+
taskVersion: 1,
|
|
276
|
+
longestTaskMs: null,
|
|
232
277
|
messages: 0,
|
|
233
278
|
withUsage: false,
|
|
234
279
|
totals: emptyBucket(),
|
|
@@ -240,9 +285,47 @@ export function scanSessionEvents(events, tz) {
|
|
|
240
285
|
byHourModel: Object.create(null),
|
|
241
286
|
byModel: Object.create(null),
|
|
242
287
|
usageEvents: [],
|
|
288
|
+
ttftEvents: [],
|
|
243
289
|
}
|
|
244
|
-
|
|
245
|
-
|
|
290
|
+
let openStep = null
|
|
291
|
+
const turns = new Map()
|
|
292
|
+
const inherited = Number.isInteger(inheritedEventCount) && inheritedEventCount > 0 ? inheritedEventCount : 0
|
|
293
|
+
for (const [index, ev] of (events || []).entries()) {
|
|
294
|
+
// A fork borrows history; only its child-owned events incurred new spend.
|
|
295
|
+
if (!ev || index < inherited) continue
|
|
296
|
+
if (ev.type === 'turn/start' && ev.data?.turn !== undefined && Number.isFinite(ev.time) && ev.time > 0) {
|
|
297
|
+
// One processing round; idle gaps between user messages never enter it.
|
|
298
|
+
if (!turns.has(ev.data.turn)) turns.set(ev.data.turn, ev.time)
|
|
299
|
+
}
|
|
300
|
+
if (ev.type === 'turn/end') {
|
|
301
|
+
const start = turns.get(ev.data?.turn)
|
|
302
|
+
if (Number.isFinite(start) && Number.isFinite(ev.time) && ev.time >= start) s.longestTaskMs = Math.max(s.longestTaskMs ?? 0, ev.time - start)
|
|
303
|
+
turns.delete(ev.data?.turn)
|
|
304
|
+
}
|
|
305
|
+
if (ev.type === 'step/start') {
|
|
306
|
+
openStep = { turn: ev.data?.turn, step: ev.data?.step, startTime: ev.time, firstTokenTime: null }
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
if (ev.type === 'step/end' || ev.type === 'turn/end') { openStep = null; continue }
|
|
310
|
+
const sameStep = openStep && openStep.turn === ev.data?.turn && openStep.step === ev.data?.step
|
|
311
|
+
if (ev.type === 'assistant/attempt') {
|
|
312
|
+
if (sameStep && openStep.firstTokenTime === null) openStep.firstTokenTime = firstOutputTime(ev.data?.stream)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
if (ev.type !== 'assistant/message') continue
|
|
316
|
+
// DSH's sessionStats projection decodes from the first non-empty token to
|
|
317
|
+
// the assembled message, retaining an earlier attempt's first token on retry.
|
|
318
|
+
const firstToken = sameStep ? (openStep.firstTokenTime ?? firstOutputTime(ev.data?.stream)) : null
|
|
319
|
+
const hasTiming = sameStep && Number.isFinite(openStep.startTime) && Number.isFinite(ev.time) && ev.time > 0
|
|
320
|
+
&& firstToken !== null && firstToken >= openStep.startTime && firstToken <= ev.time
|
|
321
|
+
const outputMs = hasTiming && Number.isFinite(ev.data?.usage?.outputTokens) && ev.data.usage.outputTokens >= 0 ? ev.time - firstToken : 0
|
|
322
|
+
const source = ev.data?.message?.source
|
|
323
|
+
const provider = source && source.provider ? String(source.provider) : 'unknown'
|
|
324
|
+
const model = source && source.model ? String(source.model) : 'unknown'
|
|
325
|
+
// DSH counts TTFT for completed, timed messages even without token usage.
|
|
326
|
+
// Keep these samples separate from TPS and never retain stream content.
|
|
327
|
+
if (hasTiming) s.ttftEvents.push([ev.time, provider, model, firstToken - openStep.startTime])
|
|
328
|
+
if (sameStep) openStep = null
|
|
246
329
|
const usage = ev.data && ev.data.usage
|
|
247
330
|
if (!usage) continue
|
|
248
331
|
const time = ev.time
|
|
@@ -250,16 +333,13 @@ export function scanSessionEvents(events, tz) {
|
|
|
250
333
|
s.withUsage = true
|
|
251
334
|
s.messages += 1
|
|
252
335
|
|
|
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
336
|
const modelKey = provider + '/' + model
|
|
257
337
|
|
|
258
338
|
// Persist only the fields needed to re-bucket retained sessions exactly.
|
|
259
339
|
s.usageEvents.push([time, provider, model,
|
|
260
340
|
Number(usage.inputTokens) || 0, Number(usage.outputTokens) || 0,
|
|
261
341
|
Number(usage.cacheReadTokens) || 0, Number(usage.cacheWriteTokens) || 0,
|
|
262
|
-
Number(usage.reasoningTokens) || 0])
|
|
342
|
+
Number(usage.reasoningTokens) || 0, outputMs])
|
|
263
343
|
|
|
264
344
|
const dayKey = localDayKey(time, tz)
|
|
265
345
|
const hour = localHourKey(time, tz)
|
|
@@ -334,6 +414,14 @@ function normalizeSession(s) {
|
|
|
334
414
|
}
|
|
335
415
|
void base
|
|
336
416
|
const norm = {
|
|
417
|
+
detailsVersion: s.detailsVersion === 1 ? 1 : 0,
|
|
418
|
+
ownUsageVersion: s.ownUsageVersion === 1 ? 1 : 0,
|
|
419
|
+
speedVersion: s.speedVersion === SPEED_VERSION ? SPEED_VERSION : s.speedVersion === 1 ? 1 : 0,
|
|
420
|
+
taskVersion: s.taskVersion === 1 ? 1 : 0,
|
|
421
|
+
longestTaskMs: s.taskVersion === 1 && Number.isFinite(s.longestTaskMs) && s.longestTaskMs >= 0 ? s.longestTaskMs : null,
|
|
422
|
+
title: safeSessionText(s.title, 300),
|
|
423
|
+
cwd: safeSessionText(s.cwd, 1024),
|
|
424
|
+
createdAt: Number.isFinite(s.createdAt) ? s.createdAt : 0,
|
|
337
425
|
fp: (s.fp && Number.isFinite(s.fp.size) && Number.isFinite(s.fp.mtimeMs)) ? { size: s.fp.size, mtimeMs: s.fp.mtimeMs } : null,
|
|
338
426
|
retained: !!s.retained,
|
|
339
427
|
syncedAt: Number.isFinite(s.syncedAt) ? s.syncedAt : 0,
|
|
@@ -347,7 +435,10 @@ function normalizeSession(s) {
|
|
|
347
435
|
byWeekModel: s.byWeekModel && typeof s.byWeekModel === 'object' ? s.byWeekModel : {},
|
|
348
436
|
byHourModel: s.byHourModel && typeof s.byHourModel === 'object' ? s.byHourModel : {},
|
|
349
437
|
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,
|
|
438
|
+
usageEvents: Array.isArray(s.usageEvents) ? s.usageEvents.filter((e) => Array.isArray(e) && (e.length === 8 || e.length === 9) && Number.isFinite(e[0])) : null,
|
|
439
|
+
ttftEvents: Array.isArray(s.ttftEvents) ? s.ttftEvents.filter((e) => Array.isArray(e) && e.length === 4
|
|
440
|
+
&& Number.isFinite(e[0]) && e[0] > 0 && typeof e[1] === 'string' && typeof e[2] === 'string'
|
|
441
|
+
&& Number.isFinite(e[3]) && e[3] >= 0) : [],
|
|
351
442
|
}
|
|
352
443
|
// Recompute per-session totals from day buckets if they are missing (older snapshots).
|
|
353
444
|
if (!(s.totals && typeof s.totals === 'object' && 'billed' in s.totals)) {
|
|
@@ -424,24 +515,10 @@ export async function collectFileFps(sessionsDir) {
|
|
|
424
515
|
}
|
|
425
516
|
|
|
426
517
|
/**
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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.
|
|
518
|
+
* Read fingerprints from sessions/sessions.sqlite. Each session's
|
|
519
|
+
* `f_head_sequence` equals its maximum event sequence and grows with live
|
|
520
|
+
* writes. Map it to {size, mtimeMs} so incremental scans detect new events.
|
|
521
|
+
* Missing or unreadable databases return an empty Map for JSONL fallback.
|
|
445
522
|
*/
|
|
446
523
|
export async function collectSqliteFps(sessionsDir) {
|
|
447
524
|
const out = new Map()
|
|
@@ -488,7 +565,11 @@ function rekeyLegacySession(s, fromTz, toTz) {
|
|
|
488
565
|
data: { message: { source: { provider: e[1], model: e[2] } },
|
|
489
566
|
usage: { inputTokens: e[3], outputTokens: e[4], cacheReadTokens: e[5], cacheWriteTokens: e[6], reasoningTokens: e[7] } },
|
|
490
567
|
}))
|
|
491
|
-
return { ...scanSessionEvents(events, toTz),
|
|
568
|
+
return { ...s, ...scanSessionEvents(events, toTz), detailsVersion: s.detailsVersion,
|
|
569
|
+
ownUsageVersion: s.ownUsageVersion,
|
|
570
|
+
speedVersion: s.speedVersion, usageEvents: s.usageEvents, ttftEvents: s.ttftEvents,
|
|
571
|
+
taskVersion: s.taskVersion, longestTaskMs: s.longestTaskMs,
|
|
572
|
+
fp: s.fp, retained: s.retained, syncedAt: s.syncedAt }
|
|
492
573
|
}
|
|
493
574
|
|
|
494
575
|
const next = {
|
|
@@ -527,10 +608,8 @@ function putMergedBucket(map, key, bucket) {
|
|
|
527
608
|
}
|
|
528
609
|
|
|
529
610
|
/**
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* dropped. The per-session contributions then fold into the response in
|
|
533
|
-
* memory.
|
|
611
|
+
* Re-read changed or new sessions during an incremental refresh. Unchanged
|
|
612
|
+
* and deleted sessions retain their measured contributions for aggregation.
|
|
534
613
|
*/
|
|
535
614
|
export async function syncCorpus(ctx, reqTz, state) {
|
|
536
615
|
const query = ctx.get('sessionQuery')
|
|
@@ -559,10 +638,7 @@ export async function syncCorpus(ctx, reqTz, state) {
|
|
|
559
638
|
for (const id of Object.keys(nextSessions)) nextSessions[id] = rekeyLegacySession(nextSessions[id], state.tz, tz)
|
|
560
639
|
}
|
|
561
640
|
|
|
562
|
-
//
|
|
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.
|
|
641
|
+
// SQLite fingerprints take precedence; JSONL covers sessions absent from SQLite.
|
|
566
642
|
const fps = await collectFileFps()
|
|
567
643
|
const sqlFps = await collectSqliteFps()
|
|
568
644
|
for (const [id, fp] of sqlFps) fps.set(id, fp)
|
|
@@ -577,7 +653,7 @@ export async function syncCorpus(ctx, reqTz, state) {
|
|
|
577
653
|
toRead.push(id)
|
|
578
654
|
continue
|
|
579
655
|
}
|
|
580
|
-
if (prev === undefined) {
|
|
656
|
+
if (prev === undefined || prev.detailsVersion !== 1 || prev.ownUsageVersion !== 1 || prev.speedVersion !== SPEED_VERSION || prev.taskVersion !== 1) {
|
|
581
657
|
toRead.push(id)
|
|
582
658
|
continue
|
|
583
659
|
}
|
|
@@ -593,7 +669,14 @@ export async function syncCorpus(ctx, reqTz, state) {
|
|
|
593
669
|
await mapLimit(toRead, SCAN_CONCURRENCY, async (id) => {
|
|
594
670
|
try {
|
|
595
671
|
const snap = await query.readSession(id)
|
|
596
|
-
const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz)
|
|
672
|
+
const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz, snap?.inheritedEventCount)
|
|
673
|
+
const header = snap?.header || snap?.session || records.find(rec => rec?.header?.id === id)?.header
|
|
674
|
+
contribution.cwd = safeSessionText(header?.cwd, 1024)
|
|
675
|
+
contribution.createdAt = Number.isFinite(header?.createdAt) ? header.createdAt : 0
|
|
676
|
+
contribution.title = ''
|
|
677
|
+
for (const event of snap?.events || []) {
|
|
678
|
+
if (event?.type === 'session/title') contribution.title = safeSessionText(event.data?.title, 300) || contribution.title
|
|
679
|
+
}
|
|
597
680
|
contribution.fp = fps.get(id) || null
|
|
598
681
|
contribution.syncedAt = Date.now()
|
|
599
682
|
nextSessions[id] = contribution
|
|
@@ -618,11 +701,22 @@ export async function syncCorpus(ctx, reqTz, state) {
|
|
|
618
701
|
* (windowed by `days`, optionally filtered to one `modelKey`). Pure and cheap:
|
|
619
702
|
* O(number of distinct usage dates / models), no I/O.
|
|
620
703
|
*/
|
|
621
|
-
export function
|
|
704
|
+
export function modelDetectionContribution(history, tz) {
|
|
705
|
+
const rows = history.usage().filter(r => r.time > 0 && r.selection)
|
|
706
|
+
const reported = rows.filter(r => r.usageReported)
|
|
707
|
+
const contribution = scanSessionEvents(reported.map(r => ({ type: 'assistant/message', time: r.time, data: { usage: r.usage, message: { source: r.selection } } })), tz)
|
|
708
|
+
contribution.detection = true
|
|
709
|
+
reported.forEach((r, i) => { contribution.usageEvents[i][8] = r.decodeMs > 0 && ['stop', 'max-tokens'].includes(r.stop) ? r.decodeMs : 0 })
|
|
710
|
+
contribution.ttftEvents = rows.filter(r => r.ttftMs !== null && ['stop', 'max-tokens'].includes(r.stop)).map(r => [r.time, r.selection.provider, r.selection.model, r.ttftMs])
|
|
711
|
+
return contribution
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function foldResponse(state, days, modelKey, tz, now, additional = []) {
|
|
622
715
|
// syncCorpus has already re-keyed all contributions to the requested offset.
|
|
623
716
|
const bucketTz = state.tz
|
|
624
717
|
void tz
|
|
625
718
|
const filterModel = modelKey
|
|
719
|
+
const contributions = [...Object.values(state.sessions), ...additional]
|
|
626
720
|
const fromKey = (() => {
|
|
627
721
|
if (!(days > 0)) return null
|
|
628
722
|
const fromMidnight = now - (days - 1) * 86400000
|
|
@@ -638,10 +732,15 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
638
732
|
const weekSum = new Map()
|
|
639
733
|
const hourSum = new Map()
|
|
640
734
|
const modelSum = new Map()
|
|
735
|
+
const speedSum = new Map()
|
|
736
|
+
const ttftSum = new Map()
|
|
641
737
|
const totals = emptyBucket()
|
|
642
738
|
const overviewTotals = emptyBucket()
|
|
643
739
|
const overviewDays = new Set()
|
|
644
740
|
const overviewModels = new Map()
|
|
741
|
+
const overviewDaily = new Map(), recentModels = new Map()
|
|
742
|
+
const recentFrom = localDayKey(now - 6 * 86400000, bucketTz)
|
|
743
|
+
let longestTaskMs = null
|
|
645
744
|
let overviewTodayBilled = 0
|
|
646
745
|
let messages = 0
|
|
647
746
|
let sessionsWithUsage = 0
|
|
@@ -670,14 +769,48 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
670
769
|
}
|
|
671
770
|
}
|
|
672
771
|
|
|
673
|
-
for (const
|
|
674
|
-
const s = state.sessions[sid]
|
|
772
|
+
for (const s of contributions) {
|
|
675
773
|
if (!s) continue
|
|
774
|
+
for (const event of s.usageEvents || []) {
|
|
775
|
+
const outputTokens = event[4], outputMs = event[8]
|
|
776
|
+
if (!Number.isFinite(outputTokens) || outputTokens < 0 || !Number.isFinite(outputMs) || outputMs <= 0) continue
|
|
777
|
+
const key = event[1] + '/' + event[2]
|
|
778
|
+
if (filterModel && key !== filterModel || fromKey && localDayKey(event[0], bucketTz) < fromKey) continue
|
|
779
|
+
let speed = speedSum.get(key)
|
|
780
|
+
if (!speed) {
|
|
781
|
+
speed = { key, provider: event[1], model: event[2], outputTokens: 0, outputMs: 0, samples: 0 }
|
|
782
|
+
speedSum.set(key, speed)
|
|
783
|
+
}
|
|
784
|
+
speed.outputTokens += outputTokens
|
|
785
|
+
speed.outputMs += outputMs
|
|
786
|
+
speed.samples++
|
|
787
|
+
}
|
|
788
|
+
for (const event of s.ttftEvents || []) {
|
|
789
|
+
if (!Number.isFinite(event[3]) || event[3] < 0) continue
|
|
790
|
+
const key = event[1] + '/' + event[2]
|
|
791
|
+
if (filterModel && key !== filterModel || fromKey && localDayKey(event[0], bucketTz) < fromKey) continue
|
|
792
|
+
let timing = ttftSum.get(key)
|
|
793
|
+
if (!timing) { timing = { ttftMs: 0, ttftSamples: 0 }; ttftSum.set(key, timing) }
|
|
794
|
+
timing.ttftMs += event[3]
|
|
795
|
+
timing.ttftSamples++
|
|
796
|
+
}
|
|
676
797
|
mergeBucket(overviewTotals, s.totals || emptyBucket())
|
|
798
|
+
if (Number.isFinite(s.longestTaskMs) && s.longestTaskMs >= 0) longestTaskMs = Math.max(longestTaskMs ?? 0, s.longestTaskMs)
|
|
677
799
|
for (const date of Object.keys(s.byDay || {})) {
|
|
678
800
|
const bucket = s.byDay[date]
|
|
679
801
|
if ((bucket.calls || 0) > 0) overviewDays.add(date)
|
|
680
802
|
if (date === todayKey) overviewTodayBilled += bucket.billed || 0
|
|
803
|
+
if (date <= todayKey) bucketInto(overviewDaily, date, bucket)
|
|
804
|
+
}
|
|
805
|
+
for (const [key, bucket] of Object.entries(s.byDayModel || {})) {
|
|
806
|
+
const sep = key.indexOf('|'), date = key.slice(0, sep)
|
|
807
|
+
if (sep < 0 || date < recentFrom || date > todayKey || !(bucket.calls > 0)) continue
|
|
808
|
+
const mk = key.slice(sep + 1), meta = s.byModel?.[mk], slash = mk.indexOf('/')
|
|
809
|
+
const model = (meta?.model || mk.slice(slash + 1)).split('/').pop(), id = model.toLowerCase()
|
|
810
|
+
let row = recentModels.get(id)
|
|
811
|
+
if (!row) { row = { model, calls: 0, providers: new Set() }; recentModels.set(id, row) }
|
|
812
|
+
row.calls += bucket.calls
|
|
813
|
+
row.providers.add(meta?.provider || mk.slice(0, slash))
|
|
681
814
|
}
|
|
682
815
|
for (const mk of Object.keys(s.byModel || {})) {
|
|
683
816
|
const model = s.byModel[mk]
|
|
@@ -719,7 +852,7 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
719
852
|
bucketInto(perDay, mk, s.byDayModel[key])
|
|
720
853
|
}
|
|
721
854
|
messages += sessionMessages
|
|
722
|
-
if (sessionMessages > 0) sessionsWithUsage += 1
|
|
855
|
+
if (sessionMessages > 0 && !s.detection) sessionsWithUsage += 1
|
|
723
856
|
|
|
724
857
|
if (fromKey === null) {
|
|
725
858
|
for (const mk of Object.keys(s.byModel || {})) {
|
|
@@ -795,8 +928,7 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
795
928
|
// assigned client-side per model and persisted (localStorage), so a model
|
|
796
929
|
// keeps one fixed colour across the donut and the bars regardless of rank.
|
|
797
930
|
const hourModelSum = new Map() // hour -> Map(modelKey -> bucket)
|
|
798
|
-
for (const
|
|
799
|
-
const s = state.sessions[sid]
|
|
931
|
+
for (const s of contributions) {
|
|
800
932
|
if (!s) continue
|
|
801
933
|
const hm = s.byHourModel || {}
|
|
802
934
|
for (const key of Object.keys(hm)) {
|
|
@@ -844,30 +976,117 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
844
976
|
for (const [key, m] of modelSum) byModel.push({ key, provider: m.provider, model: m.model, lastTime: m.lastTime, bucket: m.bucket })
|
|
845
977
|
byModel.sort((a, b) => b.bucket.billed - a.bucket.billed)
|
|
846
978
|
|
|
979
|
+
// Compare calendar dates rather than elapsed local hours (DST and leap days).
|
|
980
|
+
const activeDays = [...overviewDays].filter(d => d <= todayKey).map(d => Date.parse(d + 'T00:00:00Z') / 86400000).filter(Number.isFinite).sort((a, b) => a - b)
|
|
981
|
+
let longestStreak = 0, streak = 0, previousDay = null
|
|
982
|
+
for (const day of activeDays) { streak = day === previousDay + 1 ? streak + 1 : 1; longestStreak = Math.max(longestStreak, streak); previousDay = day }
|
|
983
|
+
const activeSet = new Set(activeDays), todayOrdinal = Date.parse(todayKey + 'T00:00:00Z') / 86400000
|
|
984
|
+
let currentStreak = 0, day = activeSet.has(todayOrdinal) ? todayOrdinal : todayOrdinal - 1
|
|
985
|
+
while (activeSet.has(day)) { currentStreak++; day-- }
|
|
986
|
+
let peakDailyBilled = 0, peakDay = null
|
|
987
|
+
for (const [date, bucket] of [...overviewDaily.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
988
|
+
if (bucket.calls > 0 && bucket.billed >= peakDailyBilled) { peakDailyBilled = bucket.billed; peakDay = date }
|
|
989
|
+
}
|
|
990
|
+
const recentModel = [...recentModels.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model))[0]
|
|
991
|
+
const allInput = overviewTotals.inputTokens + overviewTotals.cacheReadTokens + overviewTotals.cacheWriteTokens
|
|
847
992
|
const overview = {
|
|
848
993
|
todayBilled: overviewTodayBilled,
|
|
849
994
|
usedDays: overviewDays.size,
|
|
850
995
|
totalBilled: overviewTotals.billed,
|
|
996
|
+
totals: serializeBucket(overviewTotals),
|
|
851
997
|
models: [...overviewModels.values()].sort((a, b) => b.billed - a.billed),
|
|
998
|
+
cacheHitRate: allInput > 0 ? overviewTotals.cacheReadTokens / allInput : null,
|
|
999
|
+
peakDailyBilled, peakDay, longestTaskMs, currentStreak, longestStreak,
|
|
1000
|
+
recentModel: recentModel ? { ...recentModel, providers: [...recentModel.providers].sort(), days: 7 } : null,
|
|
852
1001
|
}
|
|
853
|
-
|
|
1002
|
+
const tokenSpeeds = [...speedSum.values()].map((speed) => {
|
|
1003
|
+
const timing = ttftSum.get(speed.key) || { ttftMs: 0, ttftSamples: 0 }
|
|
1004
|
+
return { ...speed, tokensPerSecond: speed.outputTokens * 1000 / speed.outputMs, ...timing,
|
|
1005
|
+
averageTtftMs: timing.ttftSamples ? timing.ttftMs / timing.ttftSamples : null }
|
|
1006
|
+
})
|
|
1007
|
+
.sort((a, b) => b.tokensPerSecond - a.tokensPerSecond || b.samples - a.samples || a.key.localeCompare(b.key))
|
|
1008
|
+
return { totals, messages, sessionsWithUsage, byDay, byDayModels, byWeek, byHour, byHourModels, byModel, tokenSpeeds, overview, firstTime, lastTime }
|
|
854
1009
|
}
|
|
855
1010
|
|
|
856
1011
|
/* ------------------------------------------------------------------ */
|
|
857
1012
|
/* Cordis plugin `apply` */
|
|
858
1013
|
/* ------------------------------------------------------------------ */
|
|
859
1014
|
|
|
1015
|
+
function safeSessionText(value, max) {
|
|
1016
|
+
return typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : ''
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Session-level totals obey the same date/model filters as usage buckets.
|
|
1020
|
+
* Only explicit title/path metadata and measured amounts reach the renderer. */
|
|
1021
|
+
export function foldSessionRows(state, options = {}, now = Date.now()) {
|
|
1022
|
+
const days = Math.max(0, Math.min(3650, Math.round(Number(options.days) || 0)))
|
|
1023
|
+
const from = days ? localDayKey(now - (days - 1) * 86400000, state.tz) : ''
|
|
1024
|
+
const query = safeSessionText(options.q, 200).toLowerCase()
|
|
1025
|
+
const model = options.model || ''
|
|
1026
|
+
const selectedDay = /^\d{4}-\d{2}-\d{2}$/.test(options.day || '') ? options.day : ''
|
|
1027
|
+
const projects = new Set(), rows = []
|
|
1028
|
+
for (const [id, session] of Object.entries(state.sessions)) {
|
|
1029
|
+
if (!session.withUsage) continue
|
|
1030
|
+
const cwd = safeSessionText(session.cwd, 1024)
|
|
1031
|
+
if (cwd) projects.add(cwd)
|
|
1032
|
+
if (options.project && options.project !== cwd) continue
|
|
1033
|
+
const title = safeSessionText(session.title, 300)
|
|
1034
|
+
if (query && !(title + ' ' + cwd + ' ' + id).toLowerCase().includes(query)) continue
|
|
1035
|
+
const totals = emptyBucket()
|
|
1036
|
+
if (from || selectedDay) {
|
|
1037
|
+
const buckets = model ? session.byDayModel : session.byDay
|
|
1038
|
+
for (const [key, bucket] of Object.entries(buckets || {})) {
|
|
1039
|
+
const date = key.slice(0, 10)
|
|
1040
|
+
if (from && date < from || selectedDay && date !== selectedDay || model && key.slice(11) !== model) continue
|
|
1041
|
+
mergeBucket(totals, bucket)
|
|
1042
|
+
}
|
|
1043
|
+
} else if (model) mergeBucket(totals, session.byModel?.[model]?.bucket || emptyBucket())
|
|
1044
|
+
else mergeBucket(totals, session.totals)
|
|
1045
|
+
if (!totals.calls) continue
|
|
1046
|
+
const models = Object.entries(session.byModel || {}).filter(([key]) => !model || key === model)
|
|
1047
|
+
.map(([key, value]) => ({ key, provider: value.provider, model: value.model }))
|
|
1048
|
+
let lastTime = Math.max(0, ...Object.entries(session.byModel || {}).filter(([key]) => !model || key === model).map(([, value]) => value.lastTime || 0))
|
|
1049
|
+
if (Array.isArray(session.usageEvents)) {
|
|
1050
|
+
lastTime = 0
|
|
1051
|
+
for (const event of session.usageEvents) {
|
|
1052
|
+
const day = localDayKey(event[0], state.tz)
|
|
1053
|
+
if ((!from || day >= from) && (!selectedDay || day === selectedDay) && (!model || event[1] + '/' + event[2] === model)) lastTime = Math.max(lastTime, event[0])
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
rows.push({ id, title, cwd, createdAt: session.createdAt || 0, lastTime,
|
|
1057
|
+
retained: !!session.retained, models, ...serializeBucket(totals) })
|
|
1058
|
+
}
|
|
1059
|
+
const sort = options.sort === 'tokens' ? 'tokens' : 'recent'
|
|
1060
|
+
rows.sort((a, b) => (sort === 'tokens' ? b.billed - a.billed : b.lastTime - a.lastTime) || a.id.localeCompare(b.id))
|
|
1061
|
+
const pageSize = Math.min(100, Math.max(1, Math.round(Number(options.pageSize) || 20)))
|
|
1062
|
+
const pages = Math.max(1, Math.ceil(rows.length / pageSize))
|
|
1063
|
+
const page = Math.min(pages, Math.max(1, Math.round(Number(options.page) || 1)))
|
|
1064
|
+
return { rows: rows.slice((page - 1) * pageSize, page * pageSize), total: rows.length, page, pageSize, pages,
|
|
1065
|
+
projects: [...projects].sort(), sort, model: model || null, day: selectedDay || null }
|
|
1066
|
+
}
|
|
1067
|
+
|
|
860
1068
|
export function apply(ctx) {
|
|
861
1069
|
if (ctx.get('profileContext')?.name !== 'desktop') return
|
|
862
1070
|
|
|
1071
|
+
const storage = join(resolveHome(), 'storages')
|
|
1072
|
+
const preferences = createUsagePreferences(join(storage, 'usage-stats-display.json'), join(storage, 'usage-stats-controls.json'))
|
|
1073
|
+
const history = createModelDetectionHistory(join(storage, 'usage-stats-detections.json'))
|
|
1074
|
+
const modelDetectionHistory = createSupplementalUsage(ctx, join(storage, 'usage-stats-supplemental.json'), join(storage, 'usage-stats-detections.json'), history)
|
|
1075
|
+
const runner = createModelDetectionRunner(ctx, { enginePath: join(storage, 'usage-stats-lm-fingerprint'),
|
|
1076
|
+
onUpdate: async job => { await history.record(job); await modelDetectionHistory.refresh() } })
|
|
863
1077
|
const readProviderQuotas = createProviderQuotaReader(ctx)
|
|
1078
|
+
const workBuddySource = provider => {
|
|
1079
|
+
const legacy = workBuddyQuotaSource(ctx, fetch, provider)
|
|
1080
|
+
return { ...legacy,
|
|
1081
|
+
available: () => getBridge(ctx, HUB_SERVICE)?.workBuddyAvailable?.(provider) === true || legacy.available(),
|
|
1082
|
+
read: () => { const hub = getBridge(ctx, HUB_SERVICE); return hub?.workBuddyAvailable?.(provider) === true ? hub.readWorkBuddyQuota(provider) : legacy.read() },
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
864
1085
|
const quotaControls = createQuotaControls(ctx, {
|
|
865
|
-
path: join(
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
workbuddy:
|
|
869
|
-
'workbuddy-ai': workBuddyQuotaSource(ctx, undefined, 'workbuddy-ai'),
|
|
870
|
-
},
|
|
1086
|
+
path: join(storage, 'usage-stats-quota-controls.json'),
|
|
1087
|
+
legacyPath: [join(storage, 'models-hub-controls.json'), join(storage, 'usage-stats-controls.json')],
|
|
1088
|
+
sources: { providers: (excluded, provider) => readProviderQuotas(true, excluded, provider),
|
|
1089
|
+
workbuddy: workBuddySource('workbuddy'), 'workbuddy-ai': workBuddySource('workbuddy-ai') },
|
|
871
1090
|
})
|
|
872
1091
|
const storePath = resolveStorePath()
|
|
873
1092
|
let state = defaultState()
|
|
@@ -953,83 +1172,59 @@ export function apply(ctx) {
|
|
|
953
1172
|
}
|
|
954
1173
|
|
|
955
1174
|
const webServer = ctx.get('webServer')
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
)
|
|
1175
|
+
const readStats = async (options = {}) => {
|
|
1176
|
+
const t0 = Date.now()
|
|
1177
|
+
const params = options instanceof URLSearchParams ? options : new URLSearchParams(Object.entries(options).filter(([key, value]) => ['days', 'model', 'tz', 'fresh'].includes(key) && value != null))
|
|
1178
|
+
const daysParam = Number(params.get('days'))
|
|
1179
|
+
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(Math.round(daysParam), 3650) : 0
|
|
1180
|
+
const modelKey = (params.get('model') || '').trim() || null
|
|
1181
|
+
const tzParam = params.get('tz')
|
|
1182
|
+
const requestedTz = tzParam === null || tzParam === '' ? SYSTEM_TZ : clampTz(tzParam)
|
|
1183
|
+
const fresh = params.get('fresh') === '1'
|
|
1184
|
+
await ensureLoaded()
|
|
1185
|
+
const staleByTtl = Date.now() - (state.syncedAt || 0) > CACHE_TTL_MS
|
|
1186
|
+
if (state.tz !== requestedTz) {
|
|
1187
|
+
// A timezone switch must finish before the snapshot is served.
|
|
1188
|
+
await sync(requestedTz)
|
|
1189
|
+
} else if (fresh || staleByTtl || state.partial) {
|
|
1190
|
+
await syncWithBudget(requestedTz)
|
|
1191
|
+
}
|
|
1192
|
+
if (state.tz !== requestedTz) throw new Error('requested timezone is not ready')
|
|
1193
|
+
const now = Date.now()
|
|
1194
|
+
await modelDetectionHistory.refresh()
|
|
1195
|
+
const data = foldResponse(state, days, modelKey, state.tz, now, [modelDetectionContribution(modelDetectionHistory, state.tz)])
|
|
1196
|
+
const stale = syncPromise !== null || state.partial || now - (state.syncedAt || 0) > CACHE_TTL_MS
|
|
1197
|
+
return {
|
|
1198
|
+
ok: true,
|
|
1199
|
+
days,
|
|
1200
|
+
model: modelKey,
|
|
1201
|
+
tz: state.tz,
|
|
1202
|
+
totals: serializeBucket(data.totals),
|
|
1203
|
+
overview: data.overview,
|
|
1204
|
+
messages: data.messages,
|
|
1205
|
+
sessions: state.lastSessionsCount,
|
|
1206
|
+
sessionsWithUsage: data.sessionsWithUsage,
|
|
1207
|
+
firstTime: data.firstTime,
|
|
1208
|
+
lastTime: data.lastTime,
|
|
1209
|
+
byDay: data.byDay.map((d) => ({ date: d.date, ...serializeBucket(d.bucket) })),
|
|
1210
|
+
byDayModels: data.byDayModels,
|
|
1211
|
+
byWeek: data.byWeek.map((w) => ({ date: w.date, ...serializeBucket(w.bucket) })),
|
|
1212
|
+
byHour: data.byHour.map((h) => ({ hour: h.hour, ...serializeBucket(h.bucket) })),
|
|
1213
|
+
byHourModels: data.byHourModels,
|
|
1214
|
+
byModel: data.byModel.map((m) => ({ key: m.key, provider: m.provider, model: m.model, lastTime: m.lastTime, ...serializeBucket(m.bucket) })),
|
|
1215
|
+
tokenSpeeds: data.tokenSpeeds,
|
|
1216
|
+
syncedAt: state.syncedAt || 0,
|
|
1217
|
+
stale,
|
|
1218
|
+
partial: state.partial,
|
|
1219
|
+
detectionUsageWarning: modelDetectionHistory.warning(),
|
|
1220
|
+
scanMs: Date.now() - t0,
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/dsh-usage-stats/stats', handler: async (req, res) => {
|
|
1224
|
+
if (!guard(req, res, 'GET')) return
|
|
1225
|
+
try { writeJson(res, 200, await readStats(readQuery(req))) }
|
|
1226
|
+
catch { writeJson(res, 500, { ok: false, error: '用量统计暂时不可用,请稍后重试。' }) }
|
|
1227
|
+
} }), 'dsh-usage-stats: statistics')
|
|
1033
1228
|
|
|
1034
1229
|
const trustedControlRequest = (req, res) => {
|
|
1035
1230
|
if (!isLoopbackRequest(req)) { writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' }); return false }
|
|
@@ -1041,54 +1236,129 @@ export function apply(ctx) {
|
|
|
1041
1236
|
} catch { writeJson(res, 403, { ok: false, error: '请求来源不受信任。' }); return false }
|
|
1042
1237
|
return true
|
|
1043
1238
|
}
|
|
1044
|
-
const readControlBody = async
|
|
1045
|
-
|
|
1239
|
+
const readControlBody = async req => {
|
|
1240
|
+
const limit = 32_768
|
|
1241
|
+
if (!String(req.headers['content-type'] || '').startsWith('application/json')) throw new PreferenceError('请使用 JSON 请求。')
|
|
1046
1242
|
let bytes = 0
|
|
1047
1243
|
const chunks = []
|
|
1048
1244
|
for await (const chunk of req) {
|
|
1049
1245
|
bytes += Buffer.byteLength(chunk)
|
|
1050
|
-
if (bytes >
|
|
1246
|
+
if (bytes > limit) throw new PreferenceError('请求内容超过 32 KB。')
|
|
1051
1247
|
chunks.push(Buffer.from(chunk))
|
|
1052
1248
|
}
|
|
1053
|
-
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new
|
|
1249
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new PreferenceError('请求内容不是有效 JSON。') }
|
|
1054
1250
|
}
|
|
1055
1251
|
const registerControl = (path, handler) => ctx.effect(() => webServer.register({
|
|
1056
1252
|
kind: 'exact', path: '/api/dsh-usage-stats/' + path,
|
|
1057
1253
|
handler: async (req, res) => {
|
|
1058
1254
|
if (!trustedControlRequest(req, res)) return
|
|
1059
|
-
try { await handler(req, res) } catch (error) {
|
|
1255
|
+
try { await handler(req, res) } catch (error) {
|
|
1256
|
+
const expected = error instanceof PreferenceError || error instanceof IntegrationError || error instanceof QuotaControlError || error instanceof ModelDetectionError
|
|
1257
|
+
writeJson(res, error instanceof IntegrationError ? 503 : expected ? 400 : 500, { ok: false, error: expected ? error.message : '操作失败,请稍后重试。' })
|
|
1258
|
+
}
|
|
1060
1259
|
},
|
|
1061
1260
|
}), 'dsh-usage-stats: ' + path)
|
|
1261
|
+
registerControl('display-settings', async (req, res) => {
|
|
1262
|
+
if (req.method === 'GET') writeJson(res, 200, await preferences.get())
|
|
1263
|
+
else if (guard(req, res, 'POST')) writeJson(res, 200, await preferences.update(await readControlBody(req)))
|
|
1264
|
+
})
|
|
1062
1265
|
registerControl('controls', async (req, res) => {
|
|
1063
|
-
if (req.method
|
|
1064
|
-
|
|
1266
|
+
if (!['GET', 'POST'].includes(req.method || 'GET')) { writeJson(res, 405, { ok: false, error: 'method not allowed' }); return }
|
|
1267
|
+
const hub = getBridge(ctx, HUB_SERVICE)
|
|
1268
|
+
if (req.method === 'POST') {
|
|
1269
|
+
const body = await readControlBody(req), display = {}, model = {}
|
|
1270
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new PreferenceError('设置应为 JSON 对象。')
|
|
1271
|
+
for (const [key, value] of Object.entries(body)) (['showModelDetails', 'showTokenSpeed'].includes(key) ? display : model)[key] = value
|
|
1272
|
+
validateDisplayPatch(display)
|
|
1273
|
+
if (Object.keys(model).length) await quotaControls.update(model)
|
|
1274
|
+
if (Object.keys(display).length) await preferences.update(display)
|
|
1275
|
+
}
|
|
1276
|
+
const display = await preferences.get(), model = await quotaControls.get()
|
|
1277
|
+
writeJson(res, 200, { ...model, ...display, settings: { ...model.settings, ...display.settings }, connected: !!hub })
|
|
1278
|
+
})
|
|
1279
|
+
registerControl('provider-quotas', async (req, res) => {
|
|
1280
|
+
if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.read('providers', readQuery(req).get('fresh') === '1', readQuery(req).get('provider')))
|
|
1065
1281
|
})
|
|
1066
|
-
registerControl('quota-providers', async (req, res) => {
|
|
1067
|
-
|
|
1282
|
+
registerControl('quota-providers', async (req, res) => { if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.providers()) })
|
|
1283
|
+
registerControl('quota-test', async (req, res) => { if (guard(req, res, 'POST')) { const body = await readControlBody(req); writeJson(res, 200, await quotaControls.test(body.provider, body.template)) } })
|
|
1284
|
+
for (const source of ['workbuddy', 'workbuddy-ai']) registerControl(source, async (req, res) => {
|
|
1285
|
+
if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.read(source, readQuery(req).get('fresh') === '1'))
|
|
1068
1286
|
})
|
|
1069
|
-
registerControl('
|
|
1287
|
+
registerControl('model-detection', async (req, res) => {
|
|
1288
|
+
if (req.method === 'GET') {
|
|
1289
|
+
const params = readQuery(req), sessionId = params.get('session') || '', id = params.get('job') || undefined
|
|
1290
|
+
let job
|
|
1291
|
+
try {
|
|
1292
|
+
const latest = !id && runner.status()
|
|
1293
|
+
job = (latest?.status === 'running' ? latest : null) || runner.status(id, sessionId) || history.latest(sessionId)
|
|
1294
|
+
|| (!id && (runner.status() || history.latest())) || null
|
|
1295
|
+
} catch (error) { job = id && history.get(id); if (!job) throw error }
|
|
1296
|
+
writeJson(res, 200, { ok: true, job }); return
|
|
1297
|
+
}
|
|
1070
1298
|
if (!guard(req, res, 'POST')) return
|
|
1071
1299
|
const body = await readControlBody(req)
|
|
1072
|
-
writeJson(res, 200,
|
|
1300
|
+
if (body.action === 'cancel') writeJson(res, 200, { ok: true, job: runner.cancel(body.id) })
|
|
1301
|
+
else if (body.action === 'plan') writeJson(res, 200, { ok: true, quote: await runner.plan(body) })
|
|
1302
|
+
else if (body.action === 'start') writeJson(res, 200, { ok: true, job: runner.start(body) })
|
|
1303
|
+
else throw new ModelDetectionError('检测操作无效。')
|
|
1304
|
+
})
|
|
1305
|
+
registerControl('model-detection-models', async (req, res) => {
|
|
1306
|
+
if (guard(req, res, 'GET')) writeJson(res, 200, { ok: true, ...await readDetectionModels(ctx, readQuery(req).get('provider') || '') })
|
|
1307
|
+
})
|
|
1308
|
+
registerControl('model-fingerprint-engine', async (req, res) => {
|
|
1309
|
+
if (req.method === 'GET') { writeJson(res, 200, { ok: true, ...await runner.catalog() }); return }
|
|
1310
|
+
if (!guard(req, res, 'POST')) return
|
|
1311
|
+
if ((await readControlBody(req)).action !== 'update') throw new ModelDetectionError('请选择更新指纹引擎。')
|
|
1312
|
+
await runner.updateEngine(); writeJson(res, 200, { ok: true, ...await runner.catalog() })
|
|
1313
|
+
})
|
|
1314
|
+
registerControl('account-quotas', async (req, res) => {
|
|
1315
|
+
if (!guard(req, res, 'GET')) return
|
|
1316
|
+
const hub = getBridge(ctx, HUB_SERVICE)
|
|
1317
|
+
writeJson(res, 200, hub ? { ok: true, connected: true, ...await hub.readAccountQuotas() } : { ok: true, connected: false, channels: [], accounts: [], quotas: [] })
|
|
1318
|
+
})
|
|
1319
|
+
registerControl('account-quotas/refresh', async (req, res) => {
|
|
1320
|
+
if (guard(req, res, 'POST')) writeJson(res, 200, { ok: true, ...await requireBridge(ctx).refreshAccountQuota(await readControlBody(req)) })
|
|
1321
|
+
})
|
|
1322
|
+
registerControl('integration', async (req, res) => {
|
|
1323
|
+
if (guard(req, res, 'GET')) writeJson(res, 200, { ok: true, protocolVersion: 1, modelsHub: !!getBridge(ctx, HUB_SERVICE) })
|
|
1073
1324
|
})
|
|
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
1325
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1326
|
+
registerControl('sessions', async (req, res) => {
|
|
1327
|
+
if (!guard(req, res, 'GET')) return
|
|
1328
|
+
const params = readQuery(req), tzParam = params.get('tz')
|
|
1329
|
+
const tz = tzParam === null ? SYSTEM_TZ : clampTz(tzParam)
|
|
1330
|
+
await ensureLoaded()
|
|
1331
|
+
if (state.tz !== tz) await sync(tz)
|
|
1332
|
+
else if (params.get('fresh') === '1' || state.partial || Date.now() - state.syncedAt > CACHE_TTL_MS) await syncWithBudget(tz)
|
|
1333
|
+
writeJson(res, 200, { ok: true, ...foldSessionRows(state, Object.fromEntries(params)), syncedAt: state.syncedAt,
|
|
1334
|
+
stale: syncPromise !== null || state.partial || Date.now() - state.syncedAt > CACHE_TTL_MS, partial: state.partial })
|
|
1335
|
+
})
|
|
1336
|
+
publishBridge(ctx, USAGE_SERVICE, {
|
|
1337
|
+
capabilities: ['usage-snapshot', 'model-speed', 'provider-quota', 'model-detection', 'model-controls'],
|
|
1338
|
+
readStats,
|
|
1339
|
+
readControls: async () => { const quota = await quotaControls.get(), display = await preferences.get(); return { ...quota, settings: { ...quota.settings, ...display.settings } } },
|
|
1340
|
+
updateControls: patch => quotaControls.update(patch),
|
|
1341
|
+
readProviderQuotas: ({ fresh = false, provider = null } = {}) => quotaControls.read('providers', fresh, provider),
|
|
1342
|
+
readSupplementalUsage: () => ({ protocolVersion: 1, records: history.usageRecords(), warning: history.warning() }),
|
|
1343
|
+
})
|
|
1344
|
+
ctx.effect(() => async () => { runner.dispose(); await quotaControls.dispose(); await Promise.all([preferences.flush(), modelDetectionHistory.flush(), history.flush()]) }, 'dsh-usage-stats: integration cleanup')
|
|
1345
|
+
ctx.effect(() => {
|
|
1346
|
+
if (typeof ctx.on !== 'function') return
|
|
1347
|
+
return ctx.on(HUB_UPDATED, () => { void modelDetectionHistory.refresh() })
|
|
1348
|
+
}, 'dsh-usage-stats: model hub notifications')
|
|
1083
1349
|
|
|
1084
1350
|
// Background keep-fresh: build + persist corpus shortly after boot so the
|
|
1085
1351
|
// first page open is served from warm memory, and keep it warm thereafter.
|
|
1086
1352
|
void ensureLoaded().then(() => {
|
|
1353
|
+
void modelDetectionHistory.refresh()
|
|
1087
1354
|
return sync().catch(() => {})
|
|
1088
1355
|
})
|
|
1356
|
+
void quotaControls.tick().catch(() => {})
|
|
1357
|
+
ctx.effect(() => ctx.interval(() => { void quotaControls.tick().catch(() => {}) }, SYNC_INTERVAL_MS), 'dsh-usage-stats: automatic quota refresh')
|
|
1089
1358
|
|
|
1090
1359
|
ctx.effect(
|
|
1091
1360
|
() => ctx.interval(() => {
|
|
1361
|
+
void modelDetectionHistory.refresh()
|
|
1092
1362
|
void sync().catch(() => {})
|
|
1093
1363
|
}, SYNC_INTERVAL_MS),
|
|
1094
1364
|
'dsh-usage-stats: keep-fresh',
|