@sligqoer/dsh-usage-stats 0.2.0 → 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 (51) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/NOTICE +39 -0
  3. package/README.en.md +187 -0
  4. package/README.md +93 -263
  5. package/lib/bridge.js +33 -0
  6. package/lib/client.js +2321 -1579
  7. package/lib/index.js +173 -197
  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-test-history.js → model-detection-history.js} +23 -30
  12. package/lib/model-detection.js +255 -0
  13. package/lib/quota-controls.js +64 -23
  14. package/lib/supplemental-usage.js +77 -0
  15. package/lib/usage-preferences.js +51 -0
  16. package/locale/en.json +6 -0
  17. package/locale/ja.json +6 -0
  18. package/locale/zh.json +6 -0
  19. package/package.json +28 -25
  20. package/screenshots/controls.png +0 -0
  21. package/screenshots/detection.png +0 -0
  22. package/screenshots/quotas.png +0 -0
  23. package/screenshots/usage.png +0 -0
  24. package/third_party/brand-icons/CHANNEL-ICONS-LICENSE +21 -0
  25. package/third_party/brand-icons/LICENSE +59 -0
  26. package/third_party/brand-icons/NOTICE.txt +5 -0
  27. package/third_party/brand-icons/REACT-ICONS-LICENSE +104 -0
  28. package/third_party/brand-icons/channel-icons/LICENSE +21 -0
  29. package/third_party/brand-icons/channel-icons/UPSTREAM.json +6 -0
  30. package/third_party/brand-icons/lobe-icons/LICENSE +21 -0
  31. package/third_party/brand-icons/lobe-icons/UPSTREAM.json +16 -0
  32. package/third_party/brand-icons/react-icons/LICENSE +13 -0
  33. package/third_party/brand-icons/react-icons/UPSTREAM.json +6 -0
  34. package/third_party/lm-fingerprint/LICENSE +21 -0
  35. package/third_party/lm-fingerprint/UPSTREAM.json +32 -0
  36. package/third_party/lm-fingerprint/core/challenge-browser.mjs +210 -0
  37. package/third_party/lm-fingerprint/core/detector-codec.mjs +20 -0
  38. package/third_party/lm-fingerprint/core/fingerprint-core.mjs +121 -0
  39. package/third_party/lm-fingerprint/core/positional.mjs +64 -0
  40. package/third_party/lm-fingerprint/core/shared-detector.mjs +132 -0
  41. package/third_party/lm-fingerprint/data/shared_detector.bin +0 -0
  42. package/third_party/lm-fingerprint/data/unified_bank.json +826820 -0
  43. package/README.zh-CN.md +0 -357
  44. package/lib/fingerprints/KBF-LICENSE.txt +0 -201
  45. package/lib/fingerprints/NOTICE.txt +0 -6
  46. package/lib/fingerprints/kbf-202609.json +0 -1
  47. package/lib/model-capabilities.js +0 -41
  48. package/lib/model-fingerprints.js +0 -146
  49. package/lib/model-match.js +0 -407
  50. package/screenshots/match.png +0 -0
  51. package/screenshots/token-speed.png +0 -0
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,17 +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
- import { captureModelEvidence, normalizeEvidence, analyzeModelEvidence, createModelMatchRunner, ModelMatchError, modelSelection } from './model-match.js'
33
- import { createModelTestHistory } from './model-test-history.js'
34
30
 
35
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
+
36
40
  export const name = 'usage-stats'
37
41
 
38
42
  /** Services required before the surfaces can mount (timer for the warm loop). */
@@ -270,7 +274,6 @@ export function scanSessionEvents(events, tz, inheritedEventCount = 0) {
270
274
  speedVersion: SPEED_VERSION,
271
275
  taskVersion: 1,
272
276
  longestTaskMs: null,
273
- evidence: captureModelEvidence(events, inheritedEventCount),
274
277
  messages: 0,
275
278
  withUsage: false,
276
279
  totals: emptyBucket(),
@@ -419,7 +422,6 @@ function normalizeSession(s) {
419
422
  title: safeSessionText(s.title, 300),
420
423
  cwd: safeSessionText(s.cwd, 1024),
421
424
  createdAt: Number.isFinite(s.createdAt) ? s.createdAt : 0,
422
- evidence: normalizeEvidence(s.evidence),
423
425
  fp: (s.fp && Number.isFinite(s.fp.size) && Number.isFinite(s.fp.mtimeMs)) ? { size: s.fp.size, mtimeMs: s.fp.mtimeMs } : null,
424
426
  retained: !!s.retained,
425
427
  syncedAt: Number.isFinite(s.syncedAt) ? s.syncedAt : 0,
@@ -513,24 +515,10 @@ export async function collectFileFps(sessionsDir) {
513
515
  }
514
516
 
515
517
  /**
516
- * SQLite-persistence fingerprint source.
517
- *
518
- * DSH switched its durable session store from per-session
519
- * `session.jsonl.zstd` files (dsh-session-persistence-jsonl) to a single
520
- * `sessions/sessions.sqlite` database (dsh-session-persistence). New sessions
521
- * therefore have NO jsonl file at all, so `collectFileFps` returns nothing for
522
- * them and the incremental scan would read each fresh session exactly once —
523
- * while it is still empty — and never again (its file fingerprint stays null
524
- * forever). That is why recent usage stopped being recorded.
525
- *
526
- * The SQLite store keeps an authoritative, monotonically increasing
527
- * `f_head_sequence` per session (it equals MAX(f_sequence) of that session's
528
- * events and is flushed live, even for the running session). We fold it into
529
- * the same {size, mtimeMs} fingerprint shape the incremental scan already
530
- * understands, so a session is re-read exactly when its log grows.
531
- *
532
- * Returns an empty Map when the DB is missing/unreadable (older jsonl-only
533
- * 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.
534
522
  */
535
523
  export async function collectSqliteFps(sessionsDir) {
536
524
  const out = new Map()
@@ -577,7 +565,7 @@ function rekeyLegacySession(s, fromTz, toTz) {
577
565
  data: { message: { source: { provider: e[1], model: e[2] } },
578
566
  usage: { inputTokens: e[3], outputTokens: e[4], cacheReadTokens: e[5], cacheWriteTokens: e[6], reasoningTokens: e[7] } },
579
567
  }))
580
- return { ...s, ...scanSessionEvents(events, toTz), detailsVersion: s.detailsVersion, evidence: s.evidence,
568
+ return { ...s, ...scanSessionEvents(events, toTz), detailsVersion: s.detailsVersion,
581
569
  ownUsageVersion: s.ownUsageVersion,
582
570
  speedVersion: s.speedVersion, usageEvents: s.usageEvents, ttftEvents: s.ttftEvents,
583
571
  taskVersion: s.taskVersion, longestTaskMs: s.longestTaskMs,
@@ -620,10 +608,8 @@ function putMergedBucket(map, key, bucket) {
620
608
  }
621
609
 
622
610
  /**
623
- * One incremental refresh pass. Only changed/new sessions are re-read; the
624
- * rest keep their previously measured contribution. Deleted sessions are
625
- * dropped. The per-session contributions then fold into the response in
626
- * memory.
611
+ * Re-read changed or new sessions during an incremental refresh. Unchanged
612
+ * and deleted sessions retain their measured contributions for aggregation.
627
613
  */
628
614
  export async function syncCorpus(ctx, reqTz, state) {
629
615
  const query = ctx.get('sessionQuery')
@@ -652,10 +638,7 @@ export async function syncCorpus(ctx, reqTz, state) {
652
638
  for (const id of Object.keys(nextSessions)) nextSessions[id] = rekeyLegacySession(nextSessions[id], state.tz, tz)
653
639
  }
654
640
 
655
- // Fingerprint source: SQLite persistence is authoritative now (every current
656
- // session has a row whose head_sequence grows live); the jsonl scan is kept as
657
- // a fallback for older deployments and for any id SQLite does not cover. The
658
- // SQLite map is overlaid last so it wins wherever both exist.
641
+ // SQLite fingerprints take precedence; JSONL covers sessions absent from SQLite.
659
642
  const fps = await collectFileFps()
660
643
  const sqlFps = await collectSqliteFps()
661
644
  for (const [id, fp] of sqlFps) fps.set(id, fp)
@@ -687,7 +670,6 @@ export async function syncCorpus(ctx, reqTz, state) {
687
670
  try {
688
671
  const snap = await query.readSession(id)
689
672
  const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz, snap?.inheritedEventCount)
690
- contribution.evidence = captureModelEvidence(snap?.events || [], snap?.inheritedEventCount)
691
673
  const header = snap?.header || snap?.session || records.find(rec => rec?.header?.id === id)?.header
692
674
  contribution.cwd = safeSessionText(header?.cwd, 1024)
693
675
  contribution.createdAt = Number.isFinite(header?.createdAt) ? header.createdAt : 0
@@ -719,11 +701,11 @@ export async function syncCorpus(ctx, reqTz, state) {
719
701
  * (windowed by `days`, optionally filtered to one `modelKey`). Pure and cheap:
720
702
  * O(number of distinct usage dates / models), no I/O.
721
703
  */
722
- export function modelTestContribution(history, tz) {
704
+ export function modelDetectionContribution(history, tz) {
723
705
  const rows = history.usage().filter(r => r.time > 0 && r.selection)
724
706
  const reported = rows.filter(r => r.usageReported)
725
707
  const contribution = scanSessionEvents(reported.map(r => ({ type: 'assistant/message', time: r.time, data: { usage: r.usage, message: { source: r.selection } } })), tz)
726
- contribution.audit = true
708
+ contribution.detection = true
727
709
  reported.forEach((r, i) => { contribution.usageEvents[i][8] = r.decodeMs > 0 && ['stop', 'max-tokens'].includes(r.stop) ? r.decodeMs : 0 })
728
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])
729
711
  return contribution
@@ -870,7 +852,7 @@ export function foldResponse(state, days, modelKey, tz, now, additional = []) {
870
852
  bucketInto(perDay, mk, s.byDayModel[key])
871
853
  }
872
854
  messages += sessionMessages
873
- if (sessionMessages > 0 && !s.audit) sessionsWithUsage += 1
855
+ if (sessionMessages > 0 && !s.detection) sessionsWithUsage += 1
874
856
 
875
857
  if (fromKey === null) {
876
858
  for (const mk of Object.keys(s.byModel || {})) {
@@ -1086,16 +1068,25 @@ export function foldSessionRows(state, options = {}, now = Date.now()) {
1086
1068
  export function apply(ctx) {
1087
1069
  if (ctx.get('profileContext')?.name !== 'desktop') return
1088
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() } })
1089
1077
  const readProviderQuotas = createProviderQuotaReader(ctx)
1090
- const modelTestHistory = createModelTestHistory(join(resolveHome(), 'storages', 'usage-stats-model-tests.json'))
1091
- const modelMatchRunner = createModelMatchRunner(ctx, { path: join(resolveHome(), 'storages', 'usage-stats-model-baselines.json'), onUpdate: job => modelTestHistory.record(job) })
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
+ }
1092
1085
  const quotaControls = createQuotaControls(ctx, {
1093
- path: join(resolveHome(), 'storages', 'usage-stats-controls.json'),
1094
- sources: {
1095
- providers: (excluded, provider) => readProviderQuotas(true, excluded, provider),
1096
- workbuddy: workBuddyQuotaSource(ctx),
1097
- 'workbuddy-ai': workBuddyQuotaSource(ctx, undefined, 'workbuddy-ai'),
1098
- },
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') },
1099
1090
  })
1100
1091
  const storePath = resolveStorePath()
1101
1092
  let state = defaultState()
@@ -1181,85 +1172,59 @@ export function apply(ctx) {
1181
1172
  }
1182
1173
 
1183
1174
  const webServer = ctx.get('webServer')
1184
- if (webServer === undefined) return
1185
-
1186
- ctx.effect(
1187
- () => webServer.register({
1188
- kind: 'exact',
1189
- path: '/api/dsh-usage-stats/stats',
1190
- handler: async (req, res) => {
1191
- if (!guard(req, res, 'GET')) return
1192
- const t0 = Date.now()
1193
- const params = readQuery(req)
1194
- const daysParam = Number(params.get('days'))
1195
- const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(Math.round(daysParam), 3650) : 0
1196
- const modelKey = (params.get('model') || '').trim() || null
1197
- const tzParam = params.get('tz')
1198
- const requestedTz = tzParam === null || tzParam === '' ? SYSTEM_TZ : clampTz(tzParam)
1199
- const fresh = params.get('fresh') === '1'
1200
- try {
1201
- await ensureLoaded()
1202
- const staleByTtl = Date.now() - (state.syncedAt || 0) > CACHE_TTL_MS
1203
- if (state.tz !== requestedTz) {
1204
- // A timezone switch must finish before the snapshot is served.
1205
- await sync(requestedTz)
1206
- } else if (fresh || staleByTtl || state.partial) {
1207
- await syncWithBudget(requestedTz)
1208
- }
1209
- if (state.tz !== requestedTz) throw new Error('requested timezone is not ready')
1210
- const now = Date.now()
1211
- const data = foldResponse(state, days, modelKey, state.tz, now, [modelTestContribution(modelTestHistory, state.tz)])
1212
- const stale = syncPromise !== null || state.partial || now - (state.syncedAt || 0) > CACHE_TTL_MS
1213
- writeJson(res, 200, {
1214
- ok: true,
1215
- days,
1216
- model: modelKey,
1217
- tz: state.tz,
1218
- totals: serializeBucket(data.totals),
1219
- overview: data.overview,
1220
- messages: data.messages,
1221
- sessions: state.lastSessionsCount,
1222
- sessionsWithUsage: data.sessionsWithUsage,
1223
- firstTime: data.firstTime,
1224
- lastTime: data.lastTime,
1225
- byDay: data.byDay.map((d) => ({ date: d.date, ...serializeBucket(d.bucket) })),
1226
- byDayModels: data.byDayModels,
1227
- byWeek: data.byWeek.map((w) => ({ date: w.date, ...serializeBucket(w.bucket) })),
1228
- byHour: data.byHour.map((h) => ({ hour: h.hour, ...serializeBucket(h.bucket) })),
1229
- byHourModels: data.byHourModels,
1230
- byModel: data.byModel.map((m) => ({ key: m.key, provider: m.provider, model: m.model, lastTime: m.lastTime, ...serializeBucket(m.bucket) })),
1231
- tokenSpeeds: data.tokenSpeeds,
1232
- syncedAt: state.syncedAt || 0,
1233
- stale,
1234
- partial: state.partial,
1235
- auditUsageWarning: modelTestHistory.warning(),
1236
- scanMs: Date.now() - t0,
1237
- })
1238
- } catch {
1239
- writeJson(res, 500, { ok: false, error: '用量统计暂时不可用,请稍后重试。' })
1240
- }
1241
- },
1242
- }),
1243
- 'dsh-usage-stats: routes',
1244
- )
1245
-
1246
- ctx.effect(
1247
- () => webServer.register({
1248
- kind: 'exact',
1249
- path: '/api/dsh-usage-stats/provider-quotas',
1250
- handler: async (req, res) => {
1251
- if (!guard(req, res, 'GET')) return
1252
- const fresh = readQuery(req).get('fresh') === '1'
1253
- try {
1254
- writeJson(res, 200, await quotaControls.read('providers', fresh, readQuery(req).get('provider')))
1255
- } catch (error) {
1256
- // This route never sends credential or upstream error text to the renderer.
1257
- writeJson(res, error instanceof QuotaControlError ? 400 : 500, { ok: false, error: error instanceof QuotaControlError ? error.message : '配额查询暂时不可用,请稍后重试。' })
1258
- }
1259
- },
1260
- }),
1261
- 'dsh-usage-stats: official provider quota route',
1262
- )
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')
1263
1228
 
1264
1229
  const trustedControlRequest = (req, res) => {
1265
1230
  if (!isLoopbackRequest(req)) { writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' }); return false }
@@ -1271,39 +1236,93 @@ export function apply(ctx) {
1271
1236
  } catch { writeJson(res, 403, { ok: false, error: '请求来源不受信任。' }); return false }
1272
1237
  return true
1273
1238
  }
1274
- const readControlBody = async (req, limit = 32_768) => {
1275
- 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 请求。')
1276
1242
  let bytes = 0
1277
1243
  const chunks = []
1278
1244
  for await (const chunk of req) {
1279
1245
  bytes += Buffer.byteLength(chunk)
1280
- if (bytes > limit) throw new QuotaControlError(limit === 32_768 ? '请求内容超过 32 KB。' : '基线文件超过 1 MB。')
1246
+ if (bytes > limit) throw new PreferenceError('请求内容超过 32 KB。')
1281
1247
  chunks.push(Buffer.from(chunk))
1282
1248
  }
1283
- 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。') }
1284
1250
  }
1285
1251
  const registerControl = (path, handler) => ctx.effect(() => webServer.register({
1286
1252
  kind: 'exact', path: '/api/dsh-usage-stats/' + path,
1287
1253
  handler: async (req, res) => {
1288
1254
  if (!trustedControlRequest(req, res)) return
1289
1255
  try { await handler(req, res) } catch (error) {
1290
- const expected = error instanceof QuotaControlError || error instanceof ModelMatchError
1291
- writeJson(res, expected ? 400 : 500, { ok: false, error: expected ? error.message : '操作失败,请稍后重试。' })
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 : '操作失败,请稍后重试。' })
1292
1258
  }
1293
1259
  },
1294
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
+ })
1295
1265
  registerControl('controls', async (req, res) => {
1296
- if (req.method === 'GET') writeJson(res, 200, await quotaControls.get())
1297
- 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')))
1298
1281
  })
1299
- registerControl('quota-providers', async (req, res) => {
1300
- 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'))
1301
1286
  })
1302
- 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
+ }
1303
1298
  if (!guard(req, res, 'POST')) return
1304
1299
  const body = await readControlBody(req)
1305
- 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('检测操作无效。')
1306
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) })
1324
+ })
1325
+
1307
1326
  registerControl('sessions', async (req, res) => {
1308
1327
  if (!guard(req, res, 'GET')) return
1309
1328
  const params = readQuery(req), tzParam = params.get('tz')
@@ -1314,75 +1333,32 @@ export function apply(ctx) {
1314
1333
  writeJson(res, 200, { ok: true, ...foldSessionRows(state, Object.fromEntries(params)), syncedAt: state.syncedAt,
1315
1334
  stale: syncPromise !== null || state.partial || Date.now() - state.syncedAt > CACHE_TTL_MS, partial: state.partial })
1316
1335
  })
1317
- registerControl('model-match', async (req, res) => {
1318
- if (!guard(req, res, 'GET')) return
1319
- const params = readQuery(req), sessionId = params.get('session') || ''
1320
- const latestJob = () => {
1321
- const id = params.get('job') || undefined
1322
- try {
1323
- return modelMatchRunner.status(id, sessionId) || modelTestHistory.latest(sessionId)
1324
- || (!id && (modelMatchRunner.status() || modelTestHistory.latest())) || null
1325
- }
1326
- catch (error) { const stored = id && modelTestHistory.get(id); if (stored) return stored; throw error }
1327
- }
1328
- if (params.get('jobOnly') === '1') { writeJson(res, 200, { ok: true, job: latestJob() }); return }
1329
- await ensureLoaded()
1330
- // Analyze a fresh local snapshot for this one session. No inference calls.
1331
- if (sessionId && !state.sessions[sessionId]?.retained) {
1332
- try {
1333
- const snapshot = await ctx.get('sessionQuery').readSession(sessionId)
1334
- const evidence = captureModelEvidence(snapshot?.events || [], snapshot?.inheritedEventCount)
1335
- const requested = modelSelection({ provider: params.get('provider'), model: params.get('model'), reasoningEffort: params.get('reasoningEffort') })
1336
- writeJson(res, 200, { ok: true, sessionId, analysis: analyzeModelEvidence(evidence, requested), job: latestJob() })
1337
- return
1338
- } catch (error) { if (error instanceof ModelMatchError) throw error }
1339
- }
1340
- const requested = modelSelection({ provider: params.get('provider'), model: params.get('model'), reasoningEffort: params.get('reasoningEffort') })
1341
- writeJson(res, 200, { ok: true, sessionId, retained: !!state.sessions[sessionId]?.retained,
1342
- analysis: analyzeModelEvidence(state.sessions[sessionId]?.evidence, requested), job: latestJob() })
1343
- })
1344
- registerControl('model-test', async (req, res) => {
1345
- if (!guard(req, res, 'POST')) return
1346
- const body = await readControlBody(req)
1347
- // Starting requires a real readable session; cancel never sends a request.
1348
- if (body.action === 'cancel') { writeJson(res, 200, { ok: true, job: modelMatchRunner.cancel(body.id) }); return }
1349
- const records = await ctx.get('sessionQuery').listSessions()
1350
- if (!(records || []).some(rec => rec?.header?.id === body.sessionId)) throw new ModelMatchError('会话已不可用,请重新选择。')
1351
- if (body.action === 'plan') { writeJson(res, 200, { ok: true, quote: await modelMatchRunner.plan(body) }); return }
1352
- writeJson(res, 200, { ok: true, job: modelMatchRunner.start(body) })
1353
- })
1354
- registerControl('model-baselines', async (req, res) => {
1355
- if (req.method === 'GET') {
1356
- const id = readQuery(req).get('export')
1357
- writeJson(res, 200, id ? { ok: true, reference: modelMatchRunner.exportReference(id) } : { ok: true, ...modelMatchRunner.catalog() })
1358
- return
1359
- }
1360
- if (!guard(req, res, 'POST')) return
1361
- const body = await readControlBody(req, 1_048_576)
1362
- if (body.action === 'remove') await modelMatchRunner.removeReference(body.id)
1363
- else if (body.action === 'import') await modelMatchRunner.importReference(body.reference)
1364
- else throw new ModelMatchError('请选择导入或移除基线。')
1365
- writeJson(res, 200, { ok: true, ...modelMatchRunner.catalog() })
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() }),
1366
1343
  })
1367
- ctx.effect(() => () => modelMatchRunner.dispose(), 'dsh-usage-stats: model test cleanup')
1368
- for (const source of ['workbuddy', 'workbuddy-ai']) {
1369
- registerControl(source, async (req, res) => {
1370
- if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.read(source, readQuery(req).get('fresh') === '1'))
1371
- })
1372
- }
1373
-
1374
- void quotaControls.tick().catch(() => {})
1375
- ctx.effect(() => ctx.interval(() => { void quotaControls.tick().catch(() => {}) }, 30_000), 'dsh-usage-stats: automatic quota refresh')
1376
- ctx.effect(() => () => quotaControls.dispose(), 'dsh-usage-stats: quota controls cleanup')
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')
1377
1349
 
1378
1350
  // Background keep-fresh: build + persist corpus shortly after boot so the
1379
1351
  // first page open is served from warm memory, and keep it warm thereafter.
1380
1352
  void ensureLoaded().then(() => {
1353
+ void modelDetectionHistory.refresh()
1381
1354
  return sync().catch(() => {})
1382
1355
  })
1356
+ void quotaControls.tick().catch(() => {})
1357
+ ctx.effect(() => ctx.interval(() => { void quotaControls.tick().catch(() => {}) }, SYNC_INTERVAL_MS), 'dsh-usage-stats: automatic quota refresh')
1383
1358
 
1384
1359
  ctx.effect(
1385
1360
  () => ctx.interval(() => {
1361
+ void modelDetectionHistory.refresh()
1386
1362
  void sync().catch(() => {})
1387
1363
  }, SYNC_INTERVAL_MS),
1388
1364
  'dsh-usage-stats: keep-fresh',