@sligqoer/dsh-usage-stats 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -1
- package/NOTICE +39 -0
- package/README.en.md +187 -0
- package/README.md +93 -263
- package/lib/bridge.js +33 -0
- package/lib/client.js +2321 -1579
- package/lib/index.js +173 -197
- package/lib/lm-fingerprint-engine.js +166 -0
- package/lib/lm-fingerprint-source.js +134 -0
- package/lib/lm-fingerprint-worker.js +31 -0
- package/lib/{model-test-history.js → model-detection-history.js} +23 -30
- package/lib/model-detection.js +255 -0
- package/lib/quota-controls.js +64 -23
- package/lib/supplemental-usage.js +77 -0
- package/lib/usage-preferences.js +51 -0
- package/locale/en.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +28 -25
- package/screenshots/controls.png +0 -0
- package/screenshots/detection.png +0 -0
- package/screenshots/quotas.png +0 -0
- package/screenshots/usage.png +0 -0
- package/third_party/brand-icons/CHANNEL-ICONS-LICENSE +21 -0
- package/third_party/brand-icons/LICENSE +59 -0
- package/third_party/brand-icons/NOTICE.txt +5 -0
- package/third_party/brand-icons/REACT-ICONS-LICENSE +104 -0
- package/third_party/brand-icons/channel-icons/LICENSE +21 -0
- package/third_party/brand-icons/channel-icons/UPSTREAM.json +6 -0
- package/third_party/brand-icons/lobe-icons/LICENSE +21 -0
- package/third_party/brand-icons/lobe-icons/UPSTREAM.json +16 -0
- package/third_party/brand-icons/react-icons/LICENSE +13 -0
- package/third_party/brand-icons/react-icons/UPSTREAM.json +6 -0
- package/third_party/lm-fingerprint/LICENSE +21 -0
- package/third_party/lm-fingerprint/UPSTREAM.json +32 -0
- package/third_party/lm-fingerprint/core/challenge-browser.mjs +210 -0
- package/third_party/lm-fingerprint/core/detector-codec.mjs +20 -0
- package/third_party/lm-fingerprint/core/fingerprint-core.mjs +121 -0
- package/third_party/lm-fingerprint/core/positional.mjs +64 -0
- package/third_party/lm-fingerprint/core/shared-detector.mjs +132 -0
- package/third_party/lm-fingerprint/data/shared_detector.bin +0 -0
- package/third_party/lm-fingerprint/data/unified_bank.json +826820 -0
- package/README.zh-CN.md +0 -357
- package/lib/fingerprints/KBF-LICENSE.txt +0 -201
- package/lib/fingerprints/NOTICE.txt +0 -6
- package/lib/fingerprints/kbf-202609.json +0 -1
- package/lib/model-capabilities.js +0 -41
- package/lib/model-fingerprints.js +0 -146
- package/lib/model-match.js +0 -407
- package/screenshots/match.png +0 -0
- 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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
624
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
1091
|
-
|
|
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(
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
workbuddy:
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
|
1275
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1297
|
-
|
|
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
|
-
|
|
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('
|
|
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,
|
|
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
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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(() => () =>
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
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',
|