@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +135 -1
  2. package/NOTICE +39 -0
  3. package/README.en.md +187 -0
  4. package/README.md +93 -224
  5. package/lib/bridge.js +33 -0
  6. package/lib/client.js +2587 -1159
  7. package/lib/index.js +420 -150
  8. package/lib/lm-fingerprint-engine.js +166 -0
  9. package/lib/lm-fingerprint-source.js +134 -0
  10. package/lib/lm-fingerprint-worker.js +31 -0
  11. package/lib/model-detection-history.js +86 -0
  12. package/lib/model-detection.js +255 -0
  13. package/lib/provider-quotas.js +26 -5
  14. package/lib/quota-controls.js +112 -30
  15. package/lib/supplemental-usage.js +77 -0
  16. package/lib/usage-preferences.js +51 -0
  17. package/locale/en.json +6 -0
  18. package/locale/ja.json +6 -0
  19. package/locale/zh.json +6 -0
  20. package/package.json +28 -18
  21. package/screenshots/controls.png +0 -0
  22. package/screenshots/detection.png +0 -0
  23. package/screenshots/quotas.png +0 -0
  24. package/screenshots/usage.png +0 -0
  25. package/third_party/brand-icons/CHANNEL-ICONS-LICENSE +21 -0
  26. package/third_party/brand-icons/LICENSE +59 -0
  27. package/third_party/brand-icons/NOTICE.txt +5 -0
  28. package/third_party/brand-icons/REACT-ICONS-LICENSE +104 -0
  29. package/third_party/brand-icons/channel-icons/LICENSE +21 -0
  30. package/third_party/brand-icons/channel-icons/UPSTREAM.json +6 -0
  31. package/third_party/brand-icons/lobe-icons/LICENSE +21 -0
  32. package/third_party/brand-icons/lobe-icons/UPSTREAM.json +16 -0
  33. package/third_party/brand-icons/react-icons/LICENSE +13 -0
  34. package/third_party/brand-icons/react-icons/UPSTREAM.json +6 -0
  35. package/third_party/lm-fingerprint/LICENSE +21 -0
  36. package/third_party/lm-fingerprint/UPSTREAM.json +32 -0
  37. package/third_party/lm-fingerprint/core/challenge-browser.mjs +210 -0
  38. package/third_party/lm-fingerprint/core/detector-codec.mjs +20 -0
  39. package/third_party/lm-fingerprint/core/fingerprint-core.mjs +121 -0
  40. package/third_party/lm-fingerprint/core/positional.mjs +64 -0
  41. package/third_party/lm-fingerprint/core/shared-detector.mjs +132 -0
  42. package/third_party/lm-fingerprint/data/shared_detector.bin +0 -0
  43. package/third_party/lm-fingerprint/data/unified_bank.json +826820 -0
  44. 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
- * Performance model (fixed for slow page loads):
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. No dsh source changes.
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
- for (const ev of events || []) {
245
- if (!ev || ev.type !== 'assistant/message') continue
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
- * 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.
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), fp: s.fp, retained: s.retained, syncedAt: s.syncedAt }
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
- * 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.
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
- // 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.
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 foldResponse(state, days, modelKey, tz, now) {
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 sid of Object.keys(state.sessions)) {
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 sid of Object.keys(state.sessions)) {
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
- return { totals, messages, sessionsWithUsage, byDay, byDayModels, byWeek, byHour, byHourModels, byModel, overview, firstTime, lastTime }
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(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
- },
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
- 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
- )
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 (req) => {
1045
- if (!String(req.headers['content-type'] || '').startsWith('application/json')) throw new QuotaControlError('请使用 JSON 请求。')
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 > 32_768) throw new QuotaControlError('请求内容超过 32 KB。')
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 QuotaControlError('请求内容不是有效 JSON。') }
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) { writeJson(res, error instanceof QuotaControlError ? 400 : 500, { ok: false, error: error instanceof QuotaControlError ? error.message : '操作失败,请稍后重试。' }) }
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 === 'GET') writeJson(res, 200, await quotaControls.get())
1064
- else if (guard(req, res, 'POST')) writeJson(res, 200, await quotaControls.update(await readControlBody(req)))
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
- if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.providers())
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('quota-test', async (req, res) => {
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, await quotaControls.test(body.provider, body.template))
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
- 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')
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',