@sligqoer/dsh-usage-stats 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -29,6 +29,8 @@ import { dirname, join } from 'node:path'
29
29
  import { homedir } from 'node:os'
30
30
  import { createProviderQuotaReader } from './provider-quotas.js'
31
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'
32
34
 
33
35
  /** Stable cordis plugin name (row id `usage-stats`). */
34
36
  export const name = 'usage-stats'
@@ -46,6 +48,8 @@ const MAX_WAIT_MS = 1500
46
48
  const SCAN_CONCURRENCY = 6
47
49
  /** Persisted corpus file name under <DSH_HOME>/storages. */
48
50
  const STORE_FILE = 'usage-stats-corpus.json'
51
+ /** Rescan live logs when persisted speed timing fields change. */
52
+ const SPEED_VERSION = 2
49
53
 
50
54
  /** Fallback for requests without a desktop timezone. */
51
55
  const SYSTEM_TZ = new Date().getTimezoneOffset()
@@ -220,6 +224,38 @@ function putBucket(map, key, usage) {
220
224
  addUsage(b, usage)
221
225
  }
222
226
 
227
+ /** Mirror dsh-llm's assistantStreamFirstTokenTime for packed and raw records.
228
+ * Empty deltas do not start decoding; a tool name does. Never retain text. */
229
+ function firstOutputTime(stream) {
230
+ if (!Array.isArray(stream)) return null
231
+ for (const item of stream) {
232
+ if (!item) continue
233
+ if (item.type === 'chunk') {
234
+ const chunk = item.chunk
235
+ const hasToken = chunk && (['text-delta', 'reasoning-delta'].includes(chunk.type)
236
+ ? typeof chunk.text === 'string' && chunk.text !== ''
237
+ : chunk.type === 'tool-call-delta' && (typeof chunk.argumentsDelta === 'string' && chunk.argumentsDelta !== '' || chunk.name !== undefined))
238
+ if (hasToken) return Number.isFinite(item.time) && item.time > 0 ? item.time : null
239
+ continue
240
+ }
241
+ if (!['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].includes(item.type)) continue
242
+ const parts = item.type === 'tool-call-chunks' ? item.args : item.texts
243
+ if (!Array.isArray(parts) || !parts.length) continue
244
+ if (!Number.isFinite(item.time0) || item.time0 <= 0 || !Array.isArray(item.dt) || item.dt.length !== parts.length - 1) return null
245
+ let time = item.time0
246
+ if (item.type === 'tool-call-chunks' && item.name !== undefined) return time
247
+ for (let i = 0; i < parts.length; i++) {
248
+ if (i > 0) {
249
+ const delta = item.dt[i - 1]
250
+ if (!Number.isFinite(delta) || delta < 0) return null
251
+ time += delta
252
+ }
253
+ if (typeof parts[i] === 'string' && parts[i] !== '') return Number.isFinite(time) ? time : null
254
+ }
255
+ }
256
+ return null
257
+ }
258
+
223
259
  /**
224
260
  * Fold one session's events into a per-session contribution keyed by local
225
261
  * date (day/week), date|modelKey, and modelKey. Uses plain object maps so the
@@ -227,8 +263,14 @@ function putBucket(map, key, usage) {
227
263
  * @param events - the session's raw event log.
228
264
  * @param tz - UTC offset (minutes) used for day/week keys.
229
265
  */
230
- export function scanSessionEvents(events, tz) {
266
+ export function scanSessionEvents(events, tz, inheritedEventCount = 0) {
231
267
  const s = {
268
+ detailsVersion: 1,
269
+ ownUsageVersion: 1,
270
+ speedVersion: SPEED_VERSION,
271
+ taskVersion: 1,
272
+ longestTaskMs: null,
273
+ evidence: captureModelEvidence(events, inheritedEventCount),
232
274
  messages: 0,
233
275
  withUsage: false,
234
276
  totals: emptyBucket(),
@@ -240,9 +282,47 @@ export function scanSessionEvents(events, tz) {
240
282
  byHourModel: Object.create(null),
241
283
  byModel: Object.create(null),
242
284
  usageEvents: [],
285
+ ttftEvents: [],
243
286
  }
244
- for (const ev of events || []) {
245
- if (!ev || ev.type !== 'assistant/message') continue
287
+ let openStep = null
288
+ const turns = new Map()
289
+ const inherited = Number.isInteger(inheritedEventCount) && inheritedEventCount > 0 ? inheritedEventCount : 0
290
+ for (const [index, ev] of (events || []).entries()) {
291
+ // A fork borrows history; only its child-owned events incurred new spend.
292
+ if (!ev || index < inherited) continue
293
+ if (ev.type === 'turn/start' && ev.data?.turn !== undefined && Number.isFinite(ev.time) && ev.time > 0) {
294
+ // One processing round; idle gaps between user messages never enter it.
295
+ if (!turns.has(ev.data.turn)) turns.set(ev.data.turn, ev.time)
296
+ }
297
+ if (ev.type === 'turn/end') {
298
+ const start = turns.get(ev.data?.turn)
299
+ if (Number.isFinite(start) && Number.isFinite(ev.time) && ev.time >= start) s.longestTaskMs = Math.max(s.longestTaskMs ?? 0, ev.time - start)
300
+ turns.delete(ev.data?.turn)
301
+ }
302
+ if (ev.type === 'step/start') {
303
+ openStep = { turn: ev.data?.turn, step: ev.data?.step, startTime: ev.time, firstTokenTime: null }
304
+ continue
305
+ }
306
+ if (ev.type === 'step/end' || ev.type === 'turn/end') { openStep = null; continue }
307
+ const sameStep = openStep && openStep.turn === ev.data?.turn && openStep.step === ev.data?.step
308
+ if (ev.type === 'assistant/attempt') {
309
+ if (sameStep && openStep.firstTokenTime === null) openStep.firstTokenTime = firstOutputTime(ev.data?.stream)
310
+ continue
311
+ }
312
+ if (ev.type !== 'assistant/message') continue
313
+ // DSH's sessionStats projection decodes from the first non-empty token to
314
+ // the assembled message, retaining an earlier attempt's first token on retry.
315
+ const firstToken = sameStep ? (openStep.firstTokenTime ?? firstOutputTime(ev.data?.stream)) : null
316
+ const hasTiming = sameStep && Number.isFinite(openStep.startTime) && Number.isFinite(ev.time) && ev.time > 0
317
+ && firstToken !== null && firstToken >= openStep.startTime && firstToken <= ev.time
318
+ const outputMs = hasTiming && Number.isFinite(ev.data?.usage?.outputTokens) && ev.data.usage.outputTokens >= 0 ? ev.time - firstToken : 0
319
+ const source = ev.data?.message?.source
320
+ const provider = source && source.provider ? String(source.provider) : 'unknown'
321
+ const model = source && source.model ? String(source.model) : 'unknown'
322
+ // DSH counts TTFT for completed, timed messages even without token usage.
323
+ // Keep these samples separate from TPS and never retain stream content.
324
+ if (hasTiming) s.ttftEvents.push([ev.time, provider, model, firstToken - openStep.startTime])
325
+ if (sameStep) openStep = null
246
326
  const usage = ev.data && ev.data.usage
247
327
  if (!usage) continue
248
328
  const time = ev.time
@@ -250,16 +330,13 @@ export function scanSessionEvents(events, tz) {
250
330
  s.withUsage = true
251
331
  s.messages += 1
252
332
 
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
333
  const modelKey = provider + '/' + model
257
334
 
258
335
  // Persist only the fields needed to re-bucket retained sessions exactly.
259
336
  s.usageEvents.push([time, provider, model,
260
337
  Number(usage.inputTokens) || 0, Number(usage.outputTokens) || 0,
261
338
  Number(usage.cacheReadTokens) || 0, Number(usage.cacheWriteTokens) || 0,
262
- Number(usage.reasoningTokens) || 0])
339
+ Number(usage.reasoningTokens) || 0, outputMs])
263
340
 
264
341
  const dayKey = localDayKey(time, tz)
265
342
  const hour = localHourKey(time, tz)
@@ -334,6 +411,15 @@ function normalizeSession(s) {
334
411
  }
335
412
  void base
336
413
  const norm = {
414
+ detailsVersion: s.detailsVersion === 1 ? 1 : 0,
415
+ ownUsageVersion: s.ownUsageVersion === 1 ? 1 : 0,
416
+ speedVersion: s.speedVersion === SPEED_VERSION ? SPEED_VERSION : s.speedVersion === 1 ? 1 : 0,
417
+ taskVersion: s.taskVersion === 1 ? 1 : 0,
418
+ longestTaskMs: s.taskVersion === 1 && Number.isFinite(s.longestTaskMs) && s.longestTaskMs >= 0 ? s.longestTaskMs : null,
419
+ title: safeSessionText(s.title, 300),
420
+ cwd: safeSessionText(s.cwd, 1024),
421
+ createdAt: Number.isFinite(s.createdAt) ? s.createdAt : 0,
422
+ evidence: normalizeEvidence(s.evidence),
337
423
  fp: (s.fp && Number.isFinite(s.fp.size) && Number.isFinite(s.fp.mtimeMs)) ? { size: s.fp.size, mtimeMs: s.fp.mtimeMs } : null,
338
424
  retained: !!s.retained,
339
425
  syncedAt: Number.isFinite(s.syncedAt) ? s.syncedAt : 0,
@@ -347,7 +433,10 @@ function normalizeSession(s) {
347
433
  byWeekModel: s.byWeekModel && typeof s.byWeekModel === 'object' ? s.byWeekModel : {},
348
434
  byHourModel: s.byHourModel && typeof s.byHourModel === 'object' ? s.byHourModel : {},
349
435
  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,
436
+ usageEvents: Array.isArray(s.usageEvents) ? s.usageEvents.filter((e) => Array.isArray(e) && (e.length === 8 || e.length === 9) && Number.isFinite(e[0])) : null,
437
+ ttftEvents: Array.isArray(s.ttftEvents) ? s.ttftEvents.filter((e) => Array.isArray(e) && e.length === 4
438
+ && Number.isFinite(e[0]) && e[0] > 0 && typeof e[1] === 'string' && typeof e[2] === 'string'
439
+ && Number.isFinite(e[3]) && e[3] >= 0) : [],
351
440
  }
352
441
  // Recompute per-session totals from day buckets if they are missing (older snapshots).
353
442
  if (!(s.totals && typeof s.totals === 'object' && 'billed' in s.totals)) {
@@ -488,7 +577,11 @@ function rekeyLegacySession(s, fromTz, toTz) {
488
577
  data: { message: { source: { provider: e[1], model: e[2] } },
489
578
  usage: { inputTokens: e[3], outputTokens: e[4], cacheReadTokens: e[5], cacheWriteTokens: e[6], reasoningTokens: e[7] } },
490
579
  }))
491
- return { ...scanSessionEvents(events, toTz), fp: s.fp, retained: s.retained, syncedAt: s.syncedAt }
580
+ return { ...s, ...scanSessionEvents(events, toTz), detailsVersion: s.detailsVersion, evidence: s.evidence,
581
+ ownUsageVersion: s.ownUsageVersion,
582
+ speedVersion: s.speedVersion, usageEvents: s.usageEvents, ttftEvents: s.ttftEvents,
583
+ taskVersion: s.taskVersion, longestTaskMs: s.longestTaskMs,
584
+ fp: s.fp, retained: s.retained, syncedAt: s.syncedAt }
492
585
  }
493
586
 
494
587
  const next = {
@@ -577,7 +670,7 @@ export async function syncCorpus(ctx, reqTz, state) {
577
670
  toRead.push(id)
578
671
  continue
579
672
  }
580
- if (prev === undefined) {
673
+ if (prev === undefined || prev.detailsVersion !== 1 || prev.ownUsageVersion !== 1 || prev.speedVersion !== SPEED_VERSION || prev.taskVersion !== 1) {
581
674
  toRead.push(id)
582
675
  continue
583
676
  }
@@ -593,7 +686,15 @@ export async function syncCorpus(ctx, reqTz, state) {
593
686
  await mapLimit(toRead, SCAN_CONCURRENCY, async (id) => {
594
687
  try {
595
688
  const snap = await query.readSession(id)
596
- const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz)
689
+ const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz, snap?.inheritedEventCount)
690
+ contribution.evidence = captureModelEvidence(snap?.events || [], snap?.inheritedEventCount)
691
+ const header = snap?.header || snap?.session || records.find(rec => rec?.header?.id === id)?.header
692
+ contribution.cwd = safeSessionText(header?.cwd, 1024)
693
+ contribution.createdAt = Number.isFinite(header?.createdAt) ? header.createdAt : 0
694
+ contribution.title = ''
695
+ for (const event of snap?.events || []) {
696
+ if (event?.type === 'session/title') contribution.title = safeSessionText(event.data?.title, 300) || contribution.title
697
+ }
597
698
  contribution.fp = fps.get(id) || null
598
699
  contribution.syncedAt = Date.now()
599
700
  nextSessions[id] = contribution
@@ -618,11 +719,22 @@ export async function syncCorpus(ctx, reqTz, state) {
618
719
  * (windowed by `days`, optionally filtered to one `modelKey`). Pure and cheap:
619
720
  * O(number of distinct usage dates / models), no I/O.
620
721
  */
621
- export function foldResponse(state, days, modelKey, tz, now) {
722
+ export function modelTestContribution(history, tz) {
723
+ const rows = history.usage().filter(r => r.time > 0 && r.selection)
724
+ const reported = rows.filter(r => r.usageReported)
725
+ 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
727
+ reported.forEach((r, i) => { contribution.usageEvents[i][8] = r.decodeMs > 0 && ['stop', 'max-tokens'].includes(r.stop) ? r.decodeMs : 0 })
728
+ 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
+ return contribution
730
+ }
731
+
732
+ export function foldResponse(state, days, modelKey, tz, now, additional = []) {
622
733
  // syncCorpus has already re-keyed all contributions to the requested offset.
623
734
  const bucketTz = state.tz
624
735
  void tz
625
736
  const filterModel = modelKey
737
+ const contributions = [...Object.values(state.sessions), ...additional]
626
738
  const fromKey = (() => {
627
739
  if (!(days > 0)) return null
628
740
  const fromMidnight = now - (days - 1) * 86400000
@@ -638,10 +750,15 @@ export function foldResponse(state, days, modelKey, tz, now) {
638
750
  const weekSum = new Map()
639
751
  const hourSum = new Map()
640
752
  const modelSum = new Map()
753
+ const speedSum = new Map()
754
+ const ttftSum = new Map()
641
755
  const totals = emptyBucket()
642
756
  const overviewTotals = emptyBucket()
643
757
  const overviewDays = new Set()
644
758
  const overviewModels = new Map()
759
+ const overviewDaily = new Map(), recentModels = new Map()
760
+ const recentFrom = localDayKey(now - 6 * 86400000, bucketTz)
761
+ let longestTaskMs = null
645
762
  let overviewTodayBilled = 0
646
763
  let messages = 0
647
764
  let sessionsWithUsage = 0
@@ -670,14 +787,48 @@ export function foldResponse(state, days, modelKey, tz, now) {
670
787
  }
671
788
  }
672
789
 
673
- for (const sid of Object.keys(state.sessions)) {
674
- const s = state.sessions[sid]
790
+ for (const s of contributions) {
675
791
  if (!s) continue
792
+ for (const event of s.usageEvents || []) {
793
+ const outputTokens = event[4], outputMs = event[8]
794
+ if (!Number.isFinite(outputTokens) || outputTokens < 0 || !Number.isFinite(outputMs) || outputMs <= 0) continue
795
+ const key = event[1] + '/' + event[2]
796
+ if (filterModel && key !== filterModel || fromKey && localDayKey(event[0], bucketTz) < fromKey) continue
797
+ let speed = speedSum.get(key)
798
+ if (!speed) {
799
+ speed = { key, provider: event[1], model: event[2], outputTokens: 0, outputMs: 0, samples: 0 }
800
+ speedSum.set(key, speed)
801
+ }
802
+ speed.outputTokens += outputTokens
803
+ speed.outputMs += outputMs
804
+ speed.samples++
805
+ }
806
+ for (const event of s.ttftEvents || []) {
807
+ if (!Number.isFinite(event[3]) || event[3] < 0) continue
808
+ const key = event[1] + '/' + event[2]
809
+ if (filterModel && key !== filterModel || fromKey && localDayKey(event[0], bucketTz) < fromKey) continue
810
+ let timing = ttftSum.get(key)
811
+ if (!timing) { timing = { ttftMs: 0, ttftSamples: 0 }; ttftSum.set(key, timing) }
812
+ timing.ttftMs += event[3]
813
+ timing.ttftSamples++
814
+ }
676
815
  mergeBucket(overviewTotals, s.totals || emptyBucket())
816
+ if (Number.isFinite(s.longestTaskMs) && s.longestTaskMs >= 0) longestTaskMs = Math.max(longestTaskMs ?? 0, s.longestTaskMs)
677
817
  for (const date of Object.keys(s.byDay || {})) {
678
818
  const bucket = s.byDay[date]
679
819
  if ((bucket.calls || 0) > 0) overviewDays.add(date)
680
820
  if (date === todayKey) overviewTodayBilled += bucket.billed || 0
821
+ if (date <= todayKey) bucketInto(overviewDaily, date, bucket)
822
+ }
823
+ for (const [key, bucket] of Object.entries(s.byDayModel || {})) {
824
+ const sep = key.indexOf('|'), date = key.slice(0, sep)
825
+ if (sep < 0 || date < recentFrom || date > todayKey || !(bucket.calls > 0)) continue
826
+ const mk = key.slice(sep + 1), meta = s.byModel?.[mk], slash = mk.indexOf('/')
827
+ const model = (meta?.model || mk.slice(slash + 1)).split('/').pop(), id = model.toLowerCase()
828
+ let row = recentModels.get(id)
829
+ if (!row) { row = { model, calls: 0, providers: new Set() }; recentModels.set(id, row) }
830
+ row.calls += bucket.calls
831
+ row.providers.add(meta?.provider || mk.slice(0, slash))
681
832
  }
682
833
  for (const mk of Object.keys(s.byModel || {})) {
683
834
  const model = s.byModel[mk]
@@ -719,7 +870,7 @@ export function foldResponse(state, days, modelKey, tz, now) {
719
870
  bucketInto(perDay, mk, s.byDayModel[key])
720
871
  }
721
872
  messages += sessionMessages
722
- if (sessionMessages > 0) sessionsWithUsage += 1
873
+ if (sessionMessages > 0 && !s.audit) sessionsWithUsage += 1
723
874
 
724
875
  if (fromKey === null) {
725
876
  for (const mk of Object.keys(s.byModel || {})) {
@@ -795,8 +946,7 @@ export function foldResponse(state, days, modelKey, tz, now) {
795
946
  // assigned client-side per model and persisted (localStorage), so a model
796
947
  // keeps one fixed colour across the donut and the bars regardless of rank.
797
948
  const hourModelSum = new Map() // hour -> Map(modelKey -> bucket)
798
- for (const sid of Object.keys(state.sessions)) {
799
- const s = state.sessions[sid]
949
+ for (const s of contributions) {
800
950
  if (!s) continue
801
951
  const hm = s.byHourModel || {}
802
952
  for (const key of Object.keys(hm)) {
@@ -844,27 +994,105 @@ export function foldResponse(state, days, modelKey, tz, now) {
844
994
  for (const [key, m] of modelSum) byModel.push({ key, provider: m.provider, model: m.model, lastTime: m.lastTime, bucket: m.bucket })
845
995
  byModel.sort((a, b) => b.bucket.billed - a.bucket.billed)
846
996
 
997
+ // Compare calendar dates rather than elapsed local hours (DST and leap days).
998
+ const activeDays = [...overviewDays].filter(d => d <= todayKey).map(d => Date.parse(d + 'T00:00:00Z') / 86400000).filter(Number.isFinite).sort((a, b) => a - b)
999
+ let longestStreak = 0, streak = 0, previousDay = null
1000
+ for (const day of activeDays) { streak = day === previousDay + 1 ? streak + 1 : 1; longestStreak = Math.max(longestStreak, streak); previousDay = day }
1001
+ const activeSet = new Set(activeDays), todayOrdinal = Date.parse(todayKey + 'T00:00:00Z') / 86400000
1002
+ let currentStreak = 0, day = activeSet.has(todayOrdinal) ? todayOrdinal : todayOrdinal - 1
1003
+ while (activeSet.has(day)) { currentStreak++; day-- }
1004
+ let peakDailyBilled = 0, peakDay = null
1005
+ for (const [date, bucket] of [...overviewDaily.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1006
+ if (bucket.calls > 0 && bucket.billed >= peakDailyBilled) { peakDailyBilled = bucket.billed; peakDay = date }
1007
+ }
1008
+ const recentModel = [...recentModels.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model))[0]
1009
+ const allInput = overviewTotals.inputTokens + overviewTotals.cacheReadTokens + overviewTotals.cacheWriteTokens
847
1010
  const overview = {
848
1011
  todayBilled: overviewTodayBilled,
849
1012
  usedDays: overviewDays.size,
850
1013
  totalBilled: overviewTotals.billed,
1014
+ totals: serializeBucket(overviewTotals),
851
1015
  models: [...overviewModels.values()].sort((a, b) => b.billed - a.billed),
1016
+ cacheHitRate: allInput > 0 ? overviewTotals.cacheReadTokens / allInput : null,
1017
+ peakDailyBilled, peakDay, longestTaskMs, currentStreak, longestStreak,
1018
+ recentModel: recentModel ? { ...recentModel, providers: [...recentModel.providers].sort(), days: 7 } : null,
852
1019
  }
853
- return { totals, messages, sessionsWithUsage, byDay, byDayModels, byWeek, byHour, byHourModels, byModel, overview, firstTime, lastTime }
1020
+ const tokenSpeeds = [...speedSum.values()].map((speed) => {
1021
+ const timing = ttftSum.get(speed.key) || { ttftMs: 0, ttftSamples: 0 }
1022
+ return { ...speed, tokensPerSecond: speed.outputTokens * 1000 / speed.outputMs, ...timing,
1023
+ averageTtftMs: timing.ttftSamples ? timing.ttftMs / timing.ttftSamples : null }
1024
+ })
1025
+ .sort((a, b) => b.tokensPerSecond - a.tokensPerSecond || b.samples - a.samples || a.key.localeCompare(b.key))
1026
+ return { totals, messages, sessionsWithUsage, byDay, byDayModels, byWeek, byHour, byHourModels, byModel, tokenSpeeds, overview, firstTime, lastTime }
854
1027
  }
855
1028
 
856
1029
  /* ------------------------------------------------------------------ */
857
1030
  /* Cordis plugin `apply` */
858
1031
  /* ------------------------------------------------------------------ */
859
1032
 
1033
+ function safeSessionText(value, max) {
1034
+ return typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : ''
1035
+ }
1036
+
1037
+ /** Session-level totals obey the same date/model filters as usage buckets.
1038
+ * Only explicit title/path metadata and measured amounts reach the renderer. */
1039
+ export function foldSessionRows(state, options = {}, now = Date.now()) {
1040
+ const days = Math.max(0, Math.min(3650, Math.round(Number(options.days) || 0)))
1041
+ const from = days ? localDayKey(now - (days - 1) * 86400000, state.tz) : ''
1042
+ const query = safeSessionText(options.q, 200).toLowerCase()
1043
+ const model = options.model || ''
1044
+ const selectedDay = /^\d{4}-\d{2}-\d{2}$/.test(options.day || '') ? options.day : ''
1045
+ const projects = new Set(), rows = []
1046
+ for (const [id, session] of Object.entries(state.sessions)) {
1047
+ if (!session.withUsage) continue
1048
+ const cwd = safeSessionText(session.cwd, 1024)
1049
+ if (cwd) projects.add(cwd)
1050
+ if (options.project && options.project !== cwd) continue
1051
+ const title = safeSessionText(session.title, 300)
1052
+ if (query && !(title + ' ' + cwd + ' ' + id).toLowerCase().includes(query)) continue
1053
+ const totals = emptyBucket()
1054
+ if (from || selectedDay) {
1055
+ const buckets = model ? session.byDayModel : session.byDay
1056
+ for (const [key, bucket] of Object.entries(buckets || {})) {
1057
+ const date = key.slice(0, 10)
1058
+ if (from && date < from || selectedDay && date !== selectedDay || model && key.slice(11) !== model) continue
1059
+ mergeBucket(totals, bucket)
1060
+ }
1061
+ } else if (model) mergeBucket(totals, session.byModel?.[model]?.bucket || emptyBucket())
1062
+ else mergeBucket(totals, session.totals)
1063
+ if (!totals.calls) continue
1064
+ const models = Object.entries(session.byModel || {}).filter(([key]) => !model || key === model)
1065
+ .map(([key, value]) => ({ key, provider: value.provider, model: value.model }))
1066
+ let lastTime = Math.max(0, ...Object.entries(session.byModel || {}).filter(([key]) => !model || key === model).map(([, value]) => value.lastTime || 0))
1067
+ if (Array.isArray(session.usageEvents)) {
1068
+ lastTime = 0
1069
+ for (const event of session.usageEvents) {
1070
+ const day = localDayKey(event[0], state.tz)
1071
+ if ((!from || day >= from) && (!selectedDay || day === selectedDay) && (!model || event[1] + '/' + event[2] === model)) lastTime = Math.max(lastTime, event[0])
1072
+ }
1073
+ }
1074
+ rows.push({ id, title, cwd, createdAt: session.createdAt || 0, lastTime,
1075
+ retained: !!session.retained, models, ...serializeBucket(totals) })
1076
+ }
1077
+ const sort = options.sort === 'tokens' ? 'tokens' : 'recent'
1078
+ rows.sort((a, b) => (sort === 'tokens' ? b.billed - a.billed : b.lastTime - a.lastTime) || a.id.localeCompare(b.id))
1079
+ const pageSize = Math.min(100, Math.max(1, Math.round(Number(options.pageSize) || 20)))
1080
+ const pages = Math.max(1, Math.ceil(rows.length / pageSize))
1081
+ const page = Math.min(pages, Math.max(1, Math.round(Number(options.page) || 1)))
1082
+ return { rows: rows.slice((page - 1) * pageSize, page * pageSize), total: rows.length, page, pageSize, pages,
1083
+ projects: [...projects].sort(), sort, model: model || null, day: selectedDay || null }
1084
+ }
1085
+
860
1086
  export function apply(ctx) {
861
1087
  if (ctx.get('profileContext')?.name !== 'desktop') return
862
1088
 
863
1089
  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) })
864
1092
  const quotaControls = createQuotaControls(ctx, {
865
1093
  path: join(resolveHome(), 'storages', 'usage-stats-controls.json'),
866
1094
  sources: {
867
- providers: (excluded) => readProviderQuotas(true, excluded),
1095
+ providers: (excluded, provider) => readProviderQuotas(true, excluded, provider),
868
1096
  workbuddy: workBuddyQuotaSource(ctx),
869
1097
  'workbuddy-ai': workBuddyQuotaSource(ctx, undefined, 'workbuddy-ai'),
870
1098
  },
@@ -980,7 +1208,7 @@ export function apply(ctx) {
980
1208
  }
981
1209
  if (state.tz !== requestedTz) throw new Error('requested timezone is not ready')
982
1210
  const now = Date.now()
983
- const data = foldResponse(state, days, modelKey, state.tz, now)
1211
+ const data = foldResponse(state, days, modelKey, state.tz, now, [modelTestContribution(modelTestHistory, state.tz)])
984
1212
  const stale = syncPromise !== null || state.partial || now - (state.syncedAt || 0) > CACHE_TTL_MS
985
1213
  writeJson(res, 200, {
986
1214
  ok: true,
@@ -1000,9 +1228,11 @@ export function apply(ctx) {
1000
1228
  byHour: data.byHour.map((h) => ({ hour: h.hour, ...serializeBucket(h.bucket) })),
1001
1229
  byHourModels: data.byHourModels,
1002
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,
1003
1232
  syncedAt: state.syncedAt || 0,
1004
1233
  stale,
1005
1234
  partial: state.partial,
1235
+ auditUsageWarning: modelTestHistory.warning(),
1006
1236
  scanMs: Date.now() - t0,
1007
1237
  })
1008
1238
  } catch {
@@ -1021,10 +1251,10 @@ export function apply(ctx) {
1021
1251
  if (!guard(req, res, 'GET')) return
1022
1252
  const fresh = readQuery(req).get('fresh') === '1'
1023
1253
  try {
1024
- writeJson(res, 200, await quotaControls.read('providers', fresh))
1025
- } catch {
1254
+ writeJson(res, 200, await quotaControls.read('providers', fresh, readQuery(req).get('provider')))
1255
+ } catch (error) {
1026
1256
  // This route never sends credential or upstream error text to the renderer.
1027
- writeJson(res, 200, { ok: true, quotas: [] })
1257
+ writeJson(res, error instanceof QuotaControlError ? 400 : 500, { ok: false, error: error instanceof QuotaControlError ? error.message : '配额查询暂时不可用,请稍后重试。' })
1028
1258
  }
1029
1259
  },
1030
1260
  }),
@@ -1041,13 +1271,13 @@ export function apply(ctx) {
1041
1271
  } catch { writeJson(res, 403, { ok: false, error: '请求来源不受信任。' }); return false }
1042
1272
  return true
1043
1273
  }
1044
- const readControlBody = async (req) => {
1274
+ const readControlBody = async (req, limit = 32_768) => {
1045
1275
  if (!String(req.headers['content-type'] || '').startsWith('application/json')) throw new QuotaControlError('请使用 JSON 请求。')
1046
1276
  let bytes = 0
1047
1277
  const chunks = []
1048
1278
  for await (const chunk of req) {
1049
1279
  bytes += Buffer.byteLength(chunk)
1050
- if (bytes > 32_768) throw new QuotaControlError('请求内容超过 32 KB。')
1280
+ if (bytes > limit) throw new QuotaControlError(limit === 32_768 ? '请求内容超过 32 KB。' : '基线文件超过 1 MB。')
1051
1281
  chunks.push(Buffer.from(chunk))
1052
1282
  }
1053
1283
  try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new QuotaControlError('请求内容不是有效 JSON。') }
@@ -1056,7 +1286,10 @@ export function apply(ctx) {
1056
1286
  kind: 'exact', path: '/api/dsh-usage-stats/' + path,
1057
1287
  handler: async (req, res) => {
1058
1288
  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 : '操作失败,请稍后重试。' }) }
1289
+ 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 : '操作失败,请稍后重试。' })
1292
+ }
1060
1293
  },
1061
1294
  }), 'dsh-usage-stats: ' + path)
1062
1295
  registerControl('controls', async (req, res) => {
@@ -1071,6 +1304,67 @@ export function apply(ctx) {
1071
1304
  const body = await readControlBody(req)
1072
1305
  writeJson(res, 200, await quotaControls.test(body.provider, body.template))
1073
1306
  })
1307
+ registerControl('sessions', async (req, res) => {
1308
+ if (!guard(req, res, 'GET')) return
1309
+ const params = readQuery(req), tzParam = params.get('tz')
1310
+ const tz = tzParam === null ? SYSTEM_TZ : clampTz(tzParam)
1311
+ await ensureLoaded()
1312
+ if (state.tz !== tz) await sync(tz)
1313
+ else if (params.get('fresh') === '1' || state.partial || Date.now() - state.syncedAt > CACHE_TTL_MS) await syncWithBudget(tz)
1314
+ writeJson(res, 200, { ok: true, ...foldSessionRows(state, Object.fromEntries(params)), syncedAt: state.syncedAt,
1315
+ stale: syncPromise !== null || state.partial || Date.now() - state.syncedAt > CACHE_TTL_MS, partial: state.partial })
1316
+ })
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() })
1366
+ })
1367
+ ctx.effect(() => () => modelMatchRunner.dispose(), 'dsh-usage-stats: model test cleanup')
1074
1368
  for (const source of ['workbuddy', 'workbuddy-ai']) {
1075
1369
  registerControl(source, async (req, res) => {
1076
1370
  if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.read(source, readQuery(req).get('fresh') === '1'))
@@ -0,0 +1,41 @@
1
+ /** Seeded, locally graded tasks. Scores describe these tasks only, never model identity. */
2
+ import { binomialTail } from './model-fingerprints.js'
3
+ export function capabilityTasks(seed) {
4
+ let state = seed >>> 0
5
+ const rand = max => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state % max }
6
+ const tasks = []
7
+ const add = (domain, prompt, answer) => tasks.push({ id: `${domain}-${tasks.length}`, domain,
8
+ prompt: prompt + '\nReturn only JSON with exactly one key "answer". Do not use tools.',
9
+ check(text) { const value = JSON.parse(text.trim()); return Object.keys(value).length === 1 && JSON.stringify(value.answer) === JSON.stringify(answer) } })
10
+ for (let i = 0; i < 4; i++) {
11
+ const a = rand(17) + 2, b = rand(23) + 1, turns = rand(16) + 30
12
+ let x = rand(300), initial = x, sum = 0
13
+ for (let j = 0; j < turns; j++) { x = (x * a + b + j) % 997; sum += j % 3 === 0 ? -x : x }
14
+ add('code', `Evaluate this JavaScript without executing it. What is sum?\nlet x=${initial}, sum=0; for(let j=0;j<${turns};j++){x=(x*${a}+${b}+j)%997;sum+=j%3===0?-x:x;}`, sum)
15
+ const edges = [], costs = Array(11).fill(Infinity); costs[0] = 0
16
+ for (let u = 0; u < 10; u++) for (let v = u + 1; v <= Math.min(10, u + 4); v++) edges.push([u, v, rand(31) + 1])
17
+ for (let u = 0; u < 10; u++) for (const [from, to, weight] of edges) if (from === u) costs[to] = Math.min(costs[to], costs[from] + weight)
18
+ add('reasoning', `Find the minimum total weight of a directed path from node 0 to node 10. Edges [from,to,weight]: ${JSON.stringify(edges)}`, costs[10])
19
+ const entries = Array.from({ length: 160 }, (_, n) => ({ key: `record_${n}`, priority: rand(1000), value: rand(100_000) }))
20
+ const chosen = rand(150) + 5, key = entries[chosen].key
21
+ add('context', `Read the following records. For key ${key}, return the value in the LAST occurrence. Ignore priorities.\n${JSON.stringify(entries.concat({ key, priority: 9999, value: entries[chosen].value + 17 }))}`, entries[chosen].value + 17)
22
+ const intervals = Array.from({ length: 16 }, () => { const start = rand(30); return [start, start + rand(9) + 1, rand(40) + 1] }).sort((a, b) => a[1] - b[1])
23
+ const dp = [0]
24
+ intervals.forEach(([start, , reward], j) => { let previous = j - 1; while (previous >= 0 && intervals[previous][1] > start) previous--; dp.push(Math.max(dp[j], reward + dp[previous + 1])) })
25
+ add('constraints', `Choose non-overlapping jobs to maximize reward. A job ending at t is compatible with a job starting at t. Return the maximum reward. Jobs [start,end,reward]: ${JSON.stringify(intervals)}`, dp.at(-1))
26
+ }
27
+ return tasks
28
+ }
29
+ export function scoreCapabilities(outcomes, reference, complete = true) {
30
+ const valid = outcomes.filter(v => v === 0 || v === 1), passed = valid.filter(v => v === 1).length
31
+ const result = { verdict: 'current-sample', valid: valid.length, passed, count: 16, paired: 0, losses: 0, gains: 0, pValue: null }
32
+ if (!complete || valid.length < 12) return { ...result, verdict: 'insufficient-evidence' }
33
+ if (!reference) return result
34
+ outcomes.forEach((value, i) => { const ref = reference[i]; if ((value === 0 || value === 1) && (ref === 0 || ref === 1)) {
35
+ result.paired++; if (ref === 1 && value === 0) result.losses++; if (ref === 0 && value === 1) result.gains++
36
+ } })
37
+ if (result.paired < 12) return { ...result, verdict: 'insufficient-evidence' }
38
+ const discordant = result.losses + result.gains
39
+ result.pValue = discordant ? binomialTail(result.losses, discordant, 0.5) : 1
40
+ return { ...result, verdict: result.pValue < 0.05 ? 'significant-decline' : 'no-significant-decline' }
41
+ }