@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.
@@ -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
+ }
@@ -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
- } catch {
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 ? { provider: entry.provider, name, metrics, syncedAt: Date.now() } : null
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
- return { ok: true, quotas: quotas.filter(Boolean) }
491
+ const rows = quotas.filter(Boolean)
492
+ return { ok: true, quotas: rows, partial: rows.some((row) => !!row.refreshError) }
472
493
  }
473
494
  }
474
495
 
@@ -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
- if (flights.has(key)) return flights.get(key)
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 queriedProviders = key === 'providers' ? configuredQuotaEntries().map((entry) => entry.provider) : []
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 { return null }
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
- body = { ok: true, quotas: (body?.quotas || []).concat(custom.filter(Boolean)), queriedProviders }
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(key) === flight) flights.delete(key) })
342
- flights.set(key, flight)
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 statistics with range filters, an incremental session corpus, and connected quota panels. Desktop profile only.",
4
- "version": "0.1.2",
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"
Binary file
Binary file
Binary file
Binary file
Binary file