@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/CHANGELOG.md +64 -0
- package/README.md +48 -9
- package/README.zh-CN.md +48 -9
- package/lib/client.js +717 -31
- package/lib/fingerprints/KBF-LICENSE.txt +201 -0
- package/lib/fingerprints/NOTICE.txt +6 -0
- package/lib/fingerprints/kbf-202609.json +1 -0
- package/lib/index.js +320 -26
- package/lib/model-capabilities.js +41 -0
- package/lib/model-fingerprints.js +146 -0
- package/lib/model-match.js +407 -0
- package/lib/model-test-history.js +93 -0
- package/lib/provider-quotas.js +26 -5
- package/lib/quota-controls.js +60 -19
- package/package.json +10 -3
- package/screenshots/controls.png +0 -0
- package/screenshots/match.png +0 -0
- package/screenshots/quotas.png +0 -0
- package/screenshots/token-speed.png +0 -0
- package/screenshots/usage.png +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Durable reported usage and recent audit results. No prompts, answers or credentials. */
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { mkdir, writeFile, rename } from 'node:fs/promises'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
import { modelSelection, safeUsage, summarizeModelTest, modelTestScore } from './model-match.js'
|
|
6
|
+
|
|
7
|
+
const clean = v => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 256) : ''
|
|
8
|
+
const number = v => Number.isFinite(v) && v >= 0 ? v : 0
|
|
9
|
+
const timing = v => Number.isFinite(v) && v >= 0 ? v : null
|
|
10
|
+
const modes = ['audit', 'calibrate', 'capability']
|
|
11
|
+
const states = ['running', 'complete', 'failed', 'cancelled', 'budget-exhausted', 'interrupted']
|
|
12
|
+
const outcomes = ['scored', 'pass', 'fail', 'unparseable', 'unavailable', 'timeout', 'incomplete', 'cancelled', 'parameters-ignored']
|
|
13
|
+
const fields = (value, keys) => Object.fromEntries(keys.filter(k => Number.isFinite(value?.[k]) && value[k] >= 0).map(k => [k, value[k]]))
|
|
14
|
+
function result(raw, job) {
|
|
15
|
+
const selection = modelSelection(raw.selection) || (raw.phase === 'capabilities' ? job.capabilitySelection || job.selection : job.testSelection || job.selection)
|
|
16
|
+
return { time: number(raw.time) || job.finishedAt || job.startedAt, selection,
|
|
17
|
+
phase: clean(raw.phase), outcome: outcomes.includes(raw.outcome) ? raw.outcome : 'unavailable', stop: clean(raw.stop),
|
|
18
|
+
usage: safeUsage(raw.usage), usageReported: raw.usageReported === true || (raw.usageReported === undefined && Object.values(safeUsage(raw.usage)).some(v => v > 0)),
|
|
19
|
+
valid: number(raw.valid), mismatches: number(raw.mismatches), ttftMs: timing(raw.ttftMs), decodeMs: timing(raw.decodeMs) }
|
|
20
|
+
}
|
|
21
|
+
function jobSnapshot(raw) {
|
|
22
|
+
const selection = modelSelection(raw?.selection)
|
|
23
|
+
if (!selection || !clean(raw.id) || !modes.includes(raw.mode) || !states.includes(raw.status)) return null
|
|
24
|
+
const job = { id: clean(raw.id), sessionId: clean(raw.sessionId), selection, testSelection: modelSelection(raw.testSelection), capabilitySelection: modelSelection(raw.capabilitySelection),
|
|
25
|
+
mode: raw.mode, status: raw.status, phase: clean(raw.phase), startedAt: number(raw.startedAt), finishedAt: number(raw.finishedAt), baselineId: clean(raw.baselineId) || null,
|
|
26
|
+
plan: fields(raw.plan, ['requests', 'probes', 'maxOutputTokens', 'estimatedInputTokens', 'budgetTokens', 'capabilityTasks']),
|
|
27
|
+
usage: safeUsage(raw.usage), usageReported: raw.usageReported === true, usageIncomplete: raw.usageIncomplete === true, usageSaved: true,
|
|
28
|
+
message: clean(raw.message), channel: { tps: timing(raw.channel?.tps), ttftMs: timing(raw.channel?.ttftMs) } }
|
|
29
|
+
const metrics = (value, codes, keys) => value && codes.includes(value.verdict) ? { ...fields(value, keys), verdict: value.verdict, reason: clean(value.reason), pValue: timing(value.pValue) } : null
|
|
30
|
+
job.identity = metrics(raw.identity, ['significant-difference', 'no-significant-difference', 'insufficient-evidence'], ['count', 'valid', 'mismatches', 'coverage', 'selfTotal', 'selfMismatches', 'selfCoverage', 'alpha', 'confidence', 'p0'])
|
|
31
|
+
job.capability = metrics(raw.capability, ['current-sample', 'significant-decline', 'no-significant-decline', 'insufficient-evidence'], ['count', 'valid', 'passed', 'paired', 'losses', 'gains'])
|
|
32
|
+
if (raw.reference) {
|
|
33
|
+
const r = raw.reference
|
|
34
|
+
job.reference = { id: clean(r.id), model: clean(r.model), provider: clean(r.provider), origin: ['public', 'imported', 'calibrated'].includes(r.origin) ? r.origin : 'imported', date: clean(r.date),
|
|
35
|
+
...fields(r, ['count', 'selfTotal', 'selfMismatches']), effort: clean(r.effort), hasCapabilities: r.hasCapabilities === true,
|
|
36
|
+
...(typeof r.source === 'string' && r.source.startsWith('https://github.com/Ooo0ption/KBF/blob/') ? { source: r.source.slice(0, 1024) } : {}) }
|
|
37
|
+
}
|
|
38
|
+
job.results = (Array.isArray(raw.results) ? raw.results : []).map(r => result(r, job))
|
|
39
|
+
job.progress = job.results.length
|
|
40
|
+
job.conclusion = summarizeModelTest(job)
|
|
41
|
+
job.score = modelTestScore(job)
|
|
42
|
+
return job
|
|
43
|
+
}
|
|
44
|
+
export function createModelTestHistory(path) {
|
|
45
|
+
const usage = new Map(), jobs = new Map()
|
|
46
|
+
let queue = Promise.resolve(), warning = ''
|
|
47
|
+
try {
|
|
48
|
+
const document = JSON.parse(readFileSync(path, 'utf8'))
|
|
49
|
+
if (document.version !== 1 || !Array.isArray(document.usage) || !Array.isArray(document.jobs)) throw new Error('invalid')
|
|
50
|
+
for (const raw of document.usage) {
|
|
51
|
+
const job = { selection: modelSelection(raw.selection), startedAt: number(raw.startedAt), finishedAt: number(raw.finishedAt) }
|
|
52
|
+
if (!clean(raw.id) || !job.selection || !Array.isArray(raw.results)) throw new Error('invalid')
|
|
53
|
+
usage.set(clean(raw.id), { id: clean(raw.id), ...job, results: raw.results.map(r => result(r, job)) })
|
|
54
|
+
}
|
|
55
|
+
for (const raw of document.jobs.slice(-10)) {
|
|
56
|
+
const job = jobSnapshot(raw)
|
|
57
|
+
if (!job) continue
|
|
58
|
+
if (job.status === 'running') {
|
|
59
|
+
job.status = 'interrupted'; job.finishedAt = Date.now(); job.message = ''
|
|
60
|
+
if (job.identity) job.identity = { ...job.identity, verdict: 'insufficient-evidence', reason: 'unfinished', pValue: null }
|
|
61
|
+
if (job.capability) job.capability = { ...job.capability, verdict: 'insufficient-evidence', pValue: null }
|
|
62
|
+
job.conclusion = summarizeModelTest(job)
|
|
63
|
+
job.score = null
|
|
64
|
+
}
|
|
65
|
+
jobs.set(job.id, job)
|
|
66
|
+
}
|
|
67
|
+
} catch (error) { if (error.code !== 'ENOENT') warning = '检测记录无法读取,请检查本地记录后重新加载插件。' }
|
|
68
|
+
return {
|
|
69
|
+
warning: () => warning,
|
|
70
|
+
latest(sessionId) { return [...jobs.values()].reverse().find(j => !sessionId || j.sessionId === sessionId) || null },
|
|
71
|
+
get(id) { return jobs.get(id) || null },
|
|
72
|
+
usage() { return [...usage.values()].flatMap(j => j.results) },
|
|
73
|
+
async record(raw) {
|
|
74
|
+
if (warning) throw new Error(warning)
|
|
75
|
+
const job = jobSnapshot(raw)
|
|
76
|
+
if (!job) throw new Error('invalid audit record')
|
|
77
|
+
const prior = usage.get(job.id)
|
|
78
|
+
// A late poll or repeated migration must never shrink or duplicate spend.
|
|
79
|
+
if (prior && job.results.length < prior.results.length) return
|
|
80
|
+
usage.set(job.id, { id: job.id, selection: job.selection, startedAt: job.startedAt, finishedAt: job.finishedAt, results: job.results })
|
|
81
|
+
jobs.set(job.id, job)
|
|
82
|
+
while (jobs.size > 10) jobs.delete(jobs.keys().next().value)
|
|
83
|
+
const document = JSON.stringify({ version: 1, usage: [...usage.values()], jobs: [...jobs.values()] })
|
|
84
|
+
const next = queue.catch(() => {}).then(async () => {
|
|
85
|
+
await mkdir(dirname(path), { recursive: true })
|
|
86
|
+
await writeFile(path + '.tmp', document, { mode: 0o600 })
|
|
87
|
+
await rename(path + '.tmp', path)
|
|
88
|
+
})
|
|
89
|
+
queue = next
|
|
90
|
+
await next
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
package/lib/provider-quotas.js
CHANGED
|
@@ -27,6 +27,18 @@ const PROVIDERS = {
|
|
|
27
27
|
|
|
28
28
|
const TTL_MS = 60_000
|
|
29
29
|
|
|
30
|
+
/** Only bounded reason codes and HTTP status may cross the host boundary. */
|
|
31
|
+
export function quotaErrorCode(error, status) {
|
|
32
|
+
if (status === 401) return 'authentication'
|
|
33
|
+
if (status === 403) return 'forbidden'
|
|
34
|
+
if (status === 429) return 'rate-limit'
|
|
35
|
+
if (status >= 500) return 'service'
|
|
36
|
+
if (status >= 400) return 'http'
|
|
37
|
+
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return 'timeout'
|
|
38
|
+
if (error instanceof SyntaxError) return 'parse'
|
|
39
|
+
return 'network'
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
function nonnegative(value) {
|
|
31
43
|
if (typeof value !== 'number' && typeof value !== 'string') return null
|
|
32
44
|
if (typeof value === 'string' && !/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim())) return null
|
|
@@ -396,14 +408,14 @@ export async function providerKey(credentials, profile, provider, defaultEnv) {
|
|
|
396
408
|
/** Resolve official catalog entries and make bounded, single-flight reads. */
|
|
397
409
|
export function createProviderQuotaReader(ctx, request = fetch) {
|
|
398
410
|
const cache = new Map()
|
|
399
|
-
return async function readProviderQuotas(fresh = false, excluded = []) {
|
|
411
|
+
return async function readProviderQuotas(fresh = false, excluded = [], onlyProvider = null) {
|
|
400
412
|
const llm = ctx.get('llm')
|
|
401
413
|
const settings = ctx.get('settings')
|
|
402
414
|
const credentials = ctx.get('credentials')
|
|
403
415
|
if (!llm || typeof llm.listConfigurableProviders !== 'function' || typeof settings?.describe !== 'function') return { ok: true, quotas: [] }
|
|
404
416
|
const directory = llm.listConfigurableProviders()
|
|
405
417
|
const descriptors = settings.describe({ redactSecrets: true })
|
|
406
|
-
const entries = Array.isArray(directory) ? directory.filter((entry) => entry && typeof entry.provider === 'string' && !excluded.includes(entry.provider)) : []
|
|
418
|
+
const entries = Array.isArray(directory) ? directory.filter((entry) => entry && typeof entry.provider === 'string' && !excluded.includes(entry.provider) && (!onlyProvider || entry.provider === onlyProvider)) : []
|
|
407
419
|
const quotas = await Promise.all(entries.map(async (entry) => {
|
|
408
420
|
const descriptor = Array.isArray(descriptors) ? descriptors.find((row) => row.ns === entry.settingsNs) : null
|
|
409
421
|
if (!descriptor) return null
|
|
@@ -421,6 +433,7 @@ export function createProviderQuotaReader(ctx, request = fetch) {
|
|
|
421
433
|
if (cached?.identity === identity && cached.promise) return cached.promise
|
|
422
434
|
const promise = (async () => {
|
|
423
435
|
let metrics = []
|
|
436
|
+
let refreshError = { code: 'parse' }
|
|
424
437
|
try {
|
|
425
438
|
const headers = spec.auth === 'alibaba'
|
|
426
439
|
? signAlibabaRequest({ url: spec.url, action: 'GetSubscriptionStats', version: '2026-02-10', id: key, secret: auth.secret, token: auth.token, date: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), nonce: randomUUID() })
|
|
@@ -432,7 +445,12 @@ export function createProviderQuotaReader(ctx, request = fetch) {
|
|
|
432
445
|
signal: AbortSignal.timeout(8000),
|
|
433
446
|
})
|
|
434
447
|
if (response.ok) metrics = spec.parse(await response.json())
|
|
435
|
-
|
|
448
|
+
else {
|
|
449
|
+
refreshError = { code: quotaErrorCode(null, response.status), ...(Number.isInteger(response.status) ? { httpStatus: response.status } : {}) }
|
|
450
|
+
await response.body?.cancel().catch(() => {})
|
|
451
|
+
}
|
|
452
|
+
} catch (error) {
|
|
453
|
+
refreshError = { code: quotaErrorCode(error) }
|
|
436
454
|
// An optional account-credit read can still succeed for OpenRouter.
|
|
437
455
|
}
|
|
438
456
|
if (entry.provider === 'openrouter') {
|
|
@@ -452,7 +470,9 @@ export function createProviderQuotaReader(ctx, request = fetch) {
|
|
|
452
470
|
}
|
|
453
471
|
}
|
|
454
472
|
const name = entry.displayName && entry.displayName !== entry.provider ? entry.displayName : spec.name
|
|
455
|
-
return metrics.length
|
|
473
|
+
return metrics.length
|
|
474
|
+
? { provider: entry.provider, name, metrics, syncedAt: Date.now() }
|
|
475
|
+
: { provider: entry.provider, name, metrics: [], status: 'unavailable', refreshError, lastAttemptAt: Date.now() }
|
|
456
476
|
})()
|
|
457
477
|
cache.set(entry.provider, { identity, at: 0, result: null, promise })
|
|
458
478
|
try {
|
|
@@ -468,7 +488,8 @@ export function createProviderQuotaReader(ctx, request = fetch) {
|
|
|
468
488
|
return null
|
|
469
489
|
}
|
|
470
490
|
}))
|
|
471
|
-
|
|
491
|
+
const rows = quotas.filter(Boolean)
|
|
492
|
+
return { ok: true, quotas: rows, partial: rows.some((row) => !!row.refreshError) }
|
|
472
493
|
}
|
|
473
494
|
}
|
|
474
495
|
|
package/lib/quota-controls.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFile, mkdir, writeFile, rename } from 'node:fs/promises'
|
|
3
3
|
import { dirname } from 'node:path'
|
|
4
4
|
import { randomUUID } from 'node:crypto'
|
|
5
|
-
import { quotaTemplate, hasBuiltinQuotaQuery, officialQuotaSpec, customQuotaAuth } from './provider-quotas.js'
|
|
5
|
+
import { quotaTemplate, hasBuiltinQuotaQuery, officialQuotaSpec, customQuotaAuth, quotaErrorCode } from './provider-quotas.js'
|
|
6
6
|
|
|
7
7
|
export const QUOTA_INTERVALS = [10, 60, 300, 1440]
|
|
8
8
|
const MAX_BYTES = 1_048_576
|
|
@@ -10,15 +10,17 @@ const plain = (value) => value !== null && typeof value === 'object' && !Array.i
|
|
|
10
10
|
const clone = (value) => structuredClone(value)
|
|
11
11
|
/** Only deliberate, credential-free validation messages may reach the renderer. */
|
|
12
12
|
export class QuotaControlError extends Error {
|
|
13
|
-
constructor(message) {
|
|
13
|
+
constructor(message, code = 'invalid', httpStatus) {
|
|
14
14
|
super(message)
|
|
15
15
|
this.name = 'QuotaControlError'
|
|
16
|
+
this.code = code
|
|
17
|
+
if (Number.isInteger(httpStatus)) this.httpStatus = httpStatus
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
|
-
const fail = (message) => { throw new QuotaControlError(message) }
|
|
20
|
+
const fail = (message, code, status) => { throw new QuotaControlError(message, code, status) }
|
|
19
21
|
|
|
20
22
|
function defaults() {
|
|
21
|
-
return { autoQuota: false, intervalMinutes: 60, showModelDetails: true, advancedModelSelect: false, customQueries: [] }
|
|
23
|
+
return { autoQuota: false, intervalMinutes: 60, showModelDetails: true, showModelMatch: false, showTokenSpeed: true, advancedModelSelect: false, customQueries: [] }
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -189,7 +191,7 @@ export async function executeQuotaQuery(ctx, provider, rawTemplate, request = fe
|
|
|
189
191
|
if (template.auth === 'provider') {
|
|
190
192
|
if (!allowedOrigins(entry.profile, spec).has(target.origin)) fail('认证查询地址需与所选供应商的地址或官方配额地址同源。')
|
|
191
193
|
const auth = await customQuotaAuth(ctx, entry.profile, provider, url, template.method, template.body == null ? '' : JSON.stringify(template.body)).catch(() => null)
|
|
192
|
-
if (!auth) fail('未找到可用的供应商凭据,请先在模型设置中配置或登录。')
|
|
194
|
+
if (!auth) fail('未找到可用的供应商凭据,请先在模型设置中配置或登录。', 'authentication')
|
|
193
195
|
if (url.includes('{{teamId}}')) url = auth.url || fail('未配置 xAI 管理凭据和团队 ID。')
|
|
194
196
|
headers = { ...headers, ...auth.headers }
|
|
195
197
|
}
|
|
@@ -197,16 +199,16 @@ export async function executeQuotaQuery(ctx, provider, rawTemplate, request = fe
|
|
|
197
199
|
let response
|
|
198
200
|
try {
|
|
199
201
|
response = await request(url, { method: template.method, headers, ...(template.body == null ? {} : { body: JSON.stringify(template.body) }), redirect: 'error', signal: AbortSignal.timeout(8000) })
|
|
200
|
-
} catch {
|
|
201
|
-
fail('查询失败,请检查地址和网络(8 秒超时,不跟随重定向)。')
|
|
202
|
+
} catch (error) {
|
|
203
|
+
fail('查询失败,请检查地址和网络(8 秒超时,不跟随重定向)。', quotaErrorCode(error))
|
|
202
204
|
}
|
|
203
205
|
if (!response.ok) {
|
|
204
206
|
await response.body?.cancel().catch(() => {})
|
|
205
|
-
fail('查询返回 HTTP ' + response.status + ',请检查地址和供应商凭据。')
|
|
207
|
+
fail('查询返回 HTTP ' + response.status + ',请检查地址和供应商凭据。', quotaErrorCode(null, response.status), response.status)
|
|
206
208
|
}
|
|
207
209
|
const body = await readBoundedJson(response)
|
|
208
210
|
const metrics = template.response.format === 'official' ? spec.parse(body) : mappedMetrics(body, template.response)
|
|
209
|
-
if (!metrics.length) fail('响应中未找到有效配额,请检查 response 的字段路径。')
|
|
211
|
+
if (!metrics.length) fail('响应中未找到有效配额,请检查 response 的字段路径。', 'parse')
|
|
210
212
|
return { provider, name: entry.name, metrics, syncedAt: Date.now(), custom: true }
|
|
211
213
|
}
|
|
212
214
|
|
|
@@ -221,6 +223,8 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
|
|
|
221
223
|
let allFlight = null
|
|
222
224
|
const flights = new Map()
|
|
223
225
|
const tests = new Map()
|
|
226
|
+
const providerTickets = new Map()
|
|
227
|
+
let readTicket = 0
|
|
224
228
|
let queryRevision = 0
|
|
225
229
|
let disposed = false
|
|
226
230
|
|
|
@@ -247,6 +251,8 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
|
|
|
247
251
|
if (plain(saved)) {
|
|
248
252
|
settings.autoQuota = saved.autoQuota === true
|
|
249
253
|
settings.showModelDetails = saved.showModelDetails !== false
|
|
254
|
+
settings.showModelMatch = saved.showModelMatch === true
|
|
255
|
+
settings.showTokenSpeed = saved.showTokenSpeed !== false
|
|
250
256
|
settings.advancedModelSelect = saved.advancedModelSelect === true
|
|
251
257
|
if (QUOTA_INTERVALS.includes(saved.intervalMinutes)) settings.intervalMinutes = saved.intervalMinutes
|
|
252
258
|
if (Array.isArray(saved.customQueries)) {
|
|
@@ -308,38 +314,65 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
|
|
|
308
314
|
const balance = mapping && mapping.format !== 'official' ? mapping.metrics.every((metric) => metric.kind === 'amount') : spec?.kind === 'balance'
|
|
309
315
|
quotas.push({ provider: entry.provider, name: entry.name, kind: balance ? 'balance' : 'subscription', status: queried.has(entry.provider) ? 'unavailable' : 'unqueried', metrics: [] })
|
|
310
316
|
}
|
|
311
|
-
return clone({ ...body, quotas })
|
|
317
|
+
return clone({ ...body, quotas, partial: quotas.some((row) => !!row.refreshError) })
|
|
312
318
|
}
|
|
313
319
|
|
|
314
|
-
const read = async (key, fresh = false) => {
|
|
320
|
+
const read = async (key, fresh = false, provider = null) => {
|
|
315
321
|
await load()
|
|
316
322
|
if (!Object.hasOwn(sources, key)) fail('未知配额来源。')
|
|
317
323
|
const source = sources[key]
|
|
318
324
|
const adapter = plain(source)
|
|
325
|
+
if (provider && (key !== 'providers' || !directory(ctx).some((entry) => entry.provider === provider))) fail('所选供应商已不存在,请重新选择。')
|
|
319
326
|
if (!sourceEnabled(key)) return { ok: true, available: false, status: 'not-configured', cached: true }
|
|
320
327
|
if (!fresh) {
|
|
321
328
|
const body = snapshots[key] || (key === 'providers' ? { ok: true, quotas: [], cached: true } : { ok: true, available: adapter, status: adapter ? 'unqueried' : 'unavailable', cached: true })
|
|
322
329
|
return displaySnapshot(key, adapter ? { ...body, available: true } : body)
|
|
323
330
|
}
|
|
324
|
-
|
|
331
|
+
const flightKey = key + ':' + (provider || '*')
|
|
332
|
+
if (flights.has(flightKey)) return flights.get(flightKey)
|
|
325
333
|
const revision = queryRevision
|
|
326
334
|
const queries = clone(settings.customQueries)
|
|
327
|
-
const
|
|
335
|
+
const targeted = key === 'providers' ? directory(ctx).filter((entry) => !adapterFor(entry.provider) && (!provider || entry.provider === provider)).map((entry) => entry.provider) : []
|
|
336
|
+
const ticket = ++readTicket
|
|
337
|
+
for (const id of targeted) providerTickets.set(id, ticket)
|
|
328
338
|
const flight = (async () => {
|
|
329
|
-
let body = await (adapter ? source.read() : source(queries.map((query) => query.provider)))
|
|
339
|
+
let body = await (adapter ? source.read() : source(queries.map((query) => query.provider), provider))
|
|
330
340
|
if (key === 'providers') {
|
|
331
|
-
const custom = await Promise.all(queries.filter((query) => !adapterFor(query.provider)).map(async (query) => {
|
|
332
|
-
try { return await executeQuotaQuery(ctx, query.provider, query.template, request) } catch {
|
|
341
|
+
const custom = await Promise.all(queries.filter((query) => !adapterFor(query.provider) && (!provider || query.provider === provider)).map(async (query) => {
|
|
342
|
+
try { return await executeQuotaQuery(ctx, query.provider, query.template, request) } catch (error) {
|
|
343
|
+
return { provider: query.provider, name: directory(ctx).find((entry) => entry.provider === query.provider)?.name || query.provider, metrics: [], status: 'unavailable', refreshError: { code: error instanceof QuotaControlError ? error.code : 'network', ...(error instanceof QuotaControlError && error.httpStatus ? { httpStatus: error.httpStatus } : {}) }, lastAttemptAt: now(), custom: true }
|
|
344
|
+
}
|
|
333
345
|
}))
|
|
334
|
-
|
|
346
|
+
const incoming = (body?.quotas || []).concat(custom)
|
|
347
|
+
const previous = snapshots.providers || {}
|
|
348
|
+
const rows = new Map((previous.quotas || []).map((row) => [row.provider, row]))
|
|
349
|
+
const attempted = new Set(targeted)
|
|
350
|
+
for (const row of incoming) {
|
|
351
|
+
if (provider && row.provider !== provider) continue
|
|
352
|
+
// A newer single-provider read wins over an older full refresh.
|
|
353
|
+
if (targeted.includes(row.provider) && providerTickets.get(row.provider) !== ticket) continue
|
|
354
|
+
attempted.delete(row.provider)
|
|
355
|
+
const prior = rows.get(row.provider)
|
|
356
|
+
rows.set(row.provider, row.refreshError && prior?.metrics?.length
|
|
357
|
+
? { ...prior, stale: true, refreshError: row.refreshError, lastAttemptAt: row.lastAttemptAt || now() }
|
|
358
|
+
: row)
|
|
359
|
+
}
|
|
360
|
+
for (const id of attempted) {
|
|
361
|
+
if (providerTickets.get(id) !== ticket) continue
|
|
362
|
+
const prior = rows.get(id)
|
|
363
|
+
if (prior?.metrics?.length) rows.set(id, { ...prior, stale: true, refreshError: { code: 'authentication' }, lastAttemptAt: now() })
|
|
364
|
+
}
|
|
365
|
+
body = { ok: true, quotas: [...rows.values()], queriedProviders: [...new Set([...(previous.queriedProviders || []), ...targeted])] }
|
|
366
|
+
} else if (['unavailable', 'error'].includes(body?.status) && snapshots[key]?.status === 'signed-in') {
|
|
367
|
+
body = { ...snapshots[key], stale: true, refreshError: { code: 'service' }, lastAttemptAt: now() }
|
|
335
368
|
}
|
|
336
369
|
if (key !== 'providers' || revision === queryRevision) {
|
|
337
370
|
snapshots[key] = body
|
|
338
371
|
await persist()
|
|
339
372
|
}
|
|
340
373
|
return displaySnapshot(key, snapshots[key] || body)
|
|
341
|
-
})().finally(() => { if (flights.get(
|
|
342
|
-
flights.set(
|
|
374
|
+
})().finally(() => { if (flights.get(flightKey) === flight) flights.delete(flightKey) })
|
|
375
|
+
flights.set(flightKey, flight)
|
|
343
376
|
return flight
|
|
344
377
|
}
|
|
345
378
|
|
|
@@ -397,6 +430,14 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
|
|
|
397
430
|
if (typeof patch.showModelDetails !== 'boolean') fail('模型明细开关值无效。')
|
|
398
431
|
next.showModelDetails = patch.showModelDetails
|
|
399
432
|
}
|
|
433
|
+
if (patch.showModelMatch != null) {
|
|
434
|
+
if (typeof patch.showModelMatch !== 'boolean') fail('检测模型匹配开关值无效。')
|
|
435
|
+
next.showModelMatch = patch.showModelMatch
|
|
436
|
+
}
|
|
437
|
+
if (patch.showTokenSpeed != null) {
|
|
438
|
+
if (typeof patch.showTokenSpeed !== 'boolean') fail('Token 输出速度开关值无效。')
|
|
439
|
+
next.showTokenSpeed = patch.showTokenSpeed
|
|
440
|
+
}
|
|
400
441
|
if (patch.advancedModelSelect != null) {
|
|
401
442
|
if (typeof patch.advancedModelSelect !== 'boolean') fail('高级模型选择器开关值无效。')
|
|
402
443
|
next.advancedModelSelect = patch.advancedModelSelect
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sligqoer/dsh-usage-stats",
|
|
3
|
-
"description": "DSH Desktop token
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "DSH Desktop token and session statistics, resilient quota panels, and evidence-based model matching. Desktop profile only.",
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
"lib/client.js",
|
|
23
23
|
"lib/provider-quotas.js",
|
|
24
24
|
"lib/quota-controls.js",
|
|
25
|
+
"lib/model-match.js",
|
|
26
|
+
"lib/model-fingerprints.js",
|
|
27
|
+
"lib/model-capabilities.js",
|
|
28
|
+
"lib/model-test-history.js",
|
|
29
|
+
"lib/fingerprints/",
|
|
25
30
|
"cordis.patch.yml",
|
|
26
31
|
"README.md",
|
|
27
32
|
"README.zh-CN.md",
|
|
@@ -29,6 +34,8 @@
|
|
|
29
34
|
"screenshots/usage.png",
|
|
30
35
|
"screenshots/quotas.png",
|
|
31
36
|
"screenshots/controls.png",
|
|
37
|
+
"screenshots/token-speed.png",
|
|
38
|
+
"screenshots/match.png",
|
|
32
39
|
"screenshots/icon.svg",
|
|
33
40
|
"LICENSE"
|
|
34
41
|
],
|
|
@@ -51,7 +58,7 @@
|
|
|
51
58
|
"prepack": "npm run build",
|
|
52
59
|
"release": "node scripts/build-release.mjs",
|
|
53
60
|
"publish:release": "node scripts/publish-npm.mjs",
|
|
54
|
-
"test": "node test/consistency.test.mjs && node --test test/desktop.test.mjs test/usage.test.mjs test/client.test.mjs test/client-locale-quota.test.mjs test/provider-quotas.test.mjs test/quota-controls.test.mjs test/privacy.test.mjs",
|
|
61
|
+
"test": "node test/consistency.test.mjs && node --test test/desktop.test.mjs test/usage.test.mjs test/client.test.mjs test/client-locale-quota.test.mjs test/provider-quotas.test.mjs test/quota-controls.test.mjs test/model-match.test.mjs test/privacy.test.mjs",
|
|
55
62
|
"preview:heatmap": "node scripts/build-heatmap-preview.mjs"
|
|
56
63
|
},
|
|
57
64
|
"license": "Apache-2.0"
|
package/screenshots/controls.png
CHANGED
|
Binary file
|
|
Binary file
|
package/screenshots/quotas.png
CHANGED
|
Binary file
|
|
Binary file
|
package/screenshots/usage.png
CHANGED
|
Binary file
|