@tzyc/dsh-model-visibility 1.9.0 → 1.10.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/index.js CHANGED
@@ -27,7 +27,11 @@ import path from 'node:path'
27
27
  import z from '@deepseek-ai/schemastery'
28
28
 
29
29
  import { createModelInfoService } from './lib/model-info.js'
30
+ import { createLearnedCatalog } from './lib/learned-catalog.js'
30
31
  import { createOverrideStore } from './lib/model-overrides.js'
32
+ import { createCapabilityVerifier } from './lib/check-catalog.js'
33
+ import { toModelListCsv, toUsageCsv } from './lib/export-csv.js'
34
+ import { createUsageLedger } from './lib/usage-ledger.js'
31
35
  import { createProviderProbe, readProviderProfiles } from './lib/provider-probe.js'
32
36
  import { stripWrittenFields, writeCapabilities } from './lib/provider-write.js'
33
37
  import { installImageInputGate } from './lib/image-input-gate.js'
@@ -111,6 +115,16 @@ export function apply(ctx, config) {
111
115
  // needs a synchronous read. See `lib/model-overrides.js`.
112
116
  const overrides = createOverrideStore({ logger })
113
117
 
118
+ // The learned capability snapshot replaces the abandoned static catalog:
119
+ // the online verification run seeds it, and every same-named model served
120
+ // later resolves against the cached rows. See `lib/learned-catalog.js`.
121
+ // Tests may inject a store through the inject scope (`scope.get('learned')`).
122
+ let learned = null
123
+ const learnedFor = () => {
124
+ if (learned !== null) return learned
125
+ learned = createLearnedCatalog({ logger })
126
+ return learned
127
+ }
114
128
  /** Current hidden keys, read through the stable volatile reference. */
115
129
  const liveList = () => {
116
130
  const value = config?.[FIELD]
@@ -237,15 +251,20 @@ export function apply(ctx, config) {
237
251
  fallbackProfiles = null
238
252
  }
239
253
 
254
+ const learnedService =
255
+ (typeof scope.get === 'function' ? scope.get('learned') : undefined) ?? learnedFor()
256
+
240
257
  const service = createModelInfoService(scope.llm, {
241
258
  enrichment: probe === null ? undefined : ({ fresh }) => probe.read({ fresh }),
242
259
  overrides,
260
+ learned: learnedService,
243
261
  // Synchronous evidence for the request-side image gate: provider profiles
244
262
  // from the settings document plus the last completed relay probe.
245
263
  context: () => ({
246
264
  profiles: probe === null ? profilesForGate() : probe.profiles(),
247
265
  discovered: probe?.peek()?.discovered,
248
266
  overrides,
267
+ learned: learnedService,
249
268
  }),
250
269
  })
251
270
 
@@ -264,6 +283,36 @@ export function apply(ctx, config) {
264
283
  })
265
284
  scope.effect(() => () => imageInput.dispose(), 'model-visibility: image input gate')
266
285
 
286
+ // Token-usage ledger behind the page's stats strip: session logs are read
287
+ // incrementally in a background pass, the check sweep records its own
288
+ // spend into the same buckets.
289
+ const usageLedger = createUsageLedger({ logger })
290
+ let lastRescanAt = -Infinity
291
+ const nowMs = () => Date.now()
292
+ const maybeRescan = () => {
293
+ if (usageLedger.isScanning()) return
294
+ if (nowMs() - lastRescanAt < 60_000) return
295
+ lastRescanAt = nowMs()
296
+ usageLedger.rescan().catch(() => {})
297
+ }
298
+
299
+ // The online capability verifier: the user's chosen checker model plus
300
+ // two public catalogs fill the learned snapshot and (optionally) write
301
+ // back into the provider settings documents.
302
+ const verifier = createCapabilityVerifier({
303
+ llm: scope.llm,
304
+ settings,
305
+ learned: learnedService,
306
+ ledger: usageLedger,
307
+ logger,
308
+ onApplied: () => {
309
+ clearFallbackProfiles()
310
+ probe?.noteSettingsChanged()
311
+ service.invalidate()
312
+ imageInput.refresh()
313
+ },
314
+ })
315
+
267
316
  /** Answer with JSON; the route is diagnostics-friendly on purpose. */
268
317
  const json = (value, status = 200) =>
269
318
  new Response(JSON.stringify(value), {
@@ -300,6 +349,7 @@ export function apply(ctx, config) {
300
349
  probe?.invalidate()
301
350
  clearFallbackProfiles()
302
351
  overrides.invalidate()
352
+ learnedService.invalidate()
303
353
  imageInput.refresh()
304
354
  }
305
355
  try {
@@ -434,6 +484,134 @@ export function apply(ctx, config) {
434
484
  'model-visibility: override route',
435
485
  )
436
486
 
487
+ // Token-usage read for the page's stats strip; the background scan runs
488
+ // whenever the ledger could have grown and the page asks for a read.
489
+ scope.effect(
490
+ () =>
491
+ connection.fetch.register({
492
+ path: `${API}/usage`,
493
+ methods: ['GET', 'POST'],
494
+ requestBody: 'buffered',
495
+ async fetch(request) {
496
+ if (String(request.method || 'GET').toUpperCase() === 'POST') {
497
+ lastRescanAt = -Infinity
498
+ }
499
+ maybeRescan()
500
+ try {
501
+ return json({ ok: true, value: usageLedger.read() })
502
+ } catch (error) {
503
+ return json(
504
+ {
505
+ ok: false,
506
+ error: {
507
+ code: 'usage/failed',
508
+ message: error instanceof Error ? error.message : String(error),
509
+ },
510
+ },
511
+ 500,
512
+ )
513
+ }
514
+ },
515
+ }),
516
+ 'model-visibility: usage route',
517
+ )
518
+
519
+ // One-click exports: the token ledger and the visible-model capability
520
+ // list, both as CSV downloads through the same-origin route.
521
+ scope.effect(
522
+ () =>
523
+ connection.fetch.register({
524
+ path: `${API}/export`,
525
+ methods: ['GET'],
526
+ requestBody: 'buffered',
527
+ async fetch(request) {
528
+ const params = new URL(request.url).searchParams
529
+ const which = params.get('type') === 'models' ? 'models' : 'usage'
530
+ let body
531
+ let name
532
+ try {
533
+ if (which === 'usage') {
534
+ body = toUsageCsv(usageLedger.read())
535
+ name = `model-visibility-usage-${new Date().toISOString().slice(0, 10)}.csv`
536
+ } else {
537
+ const catalog = await service.read()
538
+ body = toModelListCsv(catalog, { hidden: liveList() })
539
+ name = `model-visibility-models-${new Date().toISOString().slice(0, 10)}.csv`
540
+ }
541
+ } catch (error) {
542
+ return json(
543
+ {
544
+ ok: false,
545
+ error: {
546
+ code: 'export/failed',
547
+ message: error instanceof Error ? error.message : String(error),
548
+ },
549
+ },
550
+ 500,
551
+ )
552
+ }
553
+ return new Response(body, {
554
+ status: 200,
555
+ headers: {
556
+ 'Content-Type': 'text/csv; charset=utf-8',
557
+ 'Content-Disposition': `attachment; filename="${name}"`,
558
+ 'Cache-Control': 'no-store',
559
+ },
560
+ })
561
+ },
562
+ }),
563
+ 'model-visibility: export route',
564
+ )
565
+
566
+ // The verify sweep: state reads are instant, a start posts and the Host
567
+ // runs batches in the background without holding the HTTP call open.
568
+ scope.effect(
569
+ () =>
570
+ connection.fetch.register({
571
+ path: `${API}/verify`,
572
+ methods: ['GET', 'POST'],
573
+ requestBody: 'buffered',
574
+ async fetch(request) {
575
+ const method = String(request.method || 'GET').toUpperCase()
576
+ if (method === 'GET') {
577
+ return json({ ok: true, value: { state: verifier.snapshot(), learned: learnedService.meta() } })
578
+ }
579
+ let body
580
+ try {
581
+ body = await request.json()
582
+ } catch (_error) {
583
+ body = {}
584
+ }
585
+ if (body !== null && typeof body === 'object' && body.cancel === true) {
586
+ verifier.cancel()
587
+ return json({ ok: true, value: { state: verifier.snapshot() } })
588
+ }
589
+ const provider = typeof body?.provider === 'string' ? body.provider : undefined
590
+ const model = typeof body?.model === 'string' ? body.model : undefined
591
+ if (provider === undefined || model === undefined) {
592
+ return json(
593
+ { ok: false, error: { code: 'verify/params', message: 'provider and model are required' } },
594
+ 400,
595
+ )
596
+ }
597
+ // The sweep runs in the background: the response only reports that
598
+ // it was accepted, and polling GET observes the progress state.
599
+ if (!verifier.isRunning()) {
600
+ verifier
601
+ .start({ checker: { provider, model }, writeBack: body?.writeBack !== false })
602
+ .then(() => {
603
+ // Learned facts (and possibly provider settings) changed.
604
+ service.invalidate()
605
+ imageInput.refresh()
606
+ })
607
+ .catch(() => {})
608
+ }
609
+ return json({ ok: true, value: { state: verifier.snapshot(), accepted: true } })
610
+ },
611
+ }),
612
+ 'model-visibility: verify route',
613
+ )
614
+
437
615
  // A route/credential change invalidates both the capability cache and (when
438
616
  // it actually changed routing facts) the relay-probe cache. Ordinary
439
617
  // settings writes — hiding one model — only invalidate the capability
@@ -1,14 +1,12 @@
1
1
  /**
2
- * Catalog matching and family heuristics.
2
+ * Model-kind matching and family heuristics.
3
3
  *
4
4
  * The adapter contract only carries `inputModalities`, and for a custom relay
5
5
  * that field is usually the provider's `defaultInput` (text) rather than a fact
6
- * about the model. This module fills that gap from two places, in order:
7
- *
8
- * 1. the pi-ai / models.dev snapshot in `model-catalog.js` (exact id match
9
- * after normalizing aggregator prefixes and request suffixes),
10
- * 2. conservative name rules for model families whose capabilities are common
11
- * knowledge (Claude, GPT-4o+, Gemini, Grok 4+, VL/vision variants).
6
+ * about the model. When neither the adapter nor a verified learned fact knows
7
+ * better, this module answers from conservative name rules for model families
8
+ * whose capabilities are common knowledge (Claude, GPT-4o+, Gemini, Grok 4+,
9
+ * VL/vision variants).
12
10
  *
13
11
  * Everything returned here is *inferred*: callers must label it as such so the
14
12
  * page never presents a guess as an adapter declaration.
@@ -16,10 +14,6 @@
16
14
  * @module lib/capability-catalog
17
15
  */
18
16
 
19
- import { CATALOG_GENERATED_AT, CATALOG_PI_AI_VERSION, MODEL_CATALOG } from './model-catalog.js'
20
-
21
- export { CATALOG_GENERATED_AT, CATALOG_PI_AI_VERSION, MODEL_CATALOG }
22
-
23
17
  /** Kinds the page knows how to label. */
24
18
  export const MODEL_KINDS = Object.freeze([
25
19
  'chat',
@@ -42,64 +36,6 @@ const TRANSLATION_WORDS = /(^|[-_./\s])(translate|translation|translator)([-_./\
42
36
 
43
37
  const VISION_WORDS = /(^|[-_./\s])(vl|vision|visual|omni|multimodal|mm)([-_./\s]|$)|llava|pixtral|internvl|molmo|idefics|qwen[\d.]*-?vl|glm-[\d.]+v|kimi-.*-vl/
44
38
 
45
- /** Strip aggregator namespaces, request suffixes and resolution variants. */
46
- export function normalizeModelId(value) {
47
- let id = String(value ?? '').trim().toLowerCase()
48
- if (id === '') return ''
49
- id = id.replace(/^~/, '')
50
- // Providers may stack request variants (for example `:free:thinking`).
51
- // Strip them one at a time so aliases remain stable without broad folding.
52
- let previous
53
- do {
54
- previous = id
55
- id = id.replace(/:(batch|free|extended|thinking|online|nitro)$/, '')
56
- id = id.replace(/-(free|batch)$/, '')
57
- } while (id !== previous)
58
- const slash = id.lastIndexOf('/')
59
- if (slash !== -1) id = id.slice(slash + 1)
60
- id = id.replace(/^(global|us|eu|apac|jp|au|ca)\./, '')
61
- id = id.replace(/^(anthropic|openai|google|xai|x-ai|meta|mistralai|moonshotai|minimax|nvidia|qwen|z-ai|zai|deepseek|bytedance-seed)\./, '')
62
- return id
63
- }
64
-
65
- /** Lookup keys for one model id, most specific first. */
66
- export function modelIdVariants(value) {
67
- const base = normalizeModelId(value)
68
- if (base === '') return []
69
- const variants = [base]
70
- const withoutResolution = base.replace(/-(4k|2k|8k|hd|uhd)$/, '')
71
- if (withoutResolution !== base) variants.push(withoutResolution)
72
- const withoutSuffix = withoutResolution.replace(/-(latest|preview|new)$/, '')
73
- if (!variants.includes(withoutSuffix)) variants.push(withoutSuffix)
74
- const compact = base.replace(/[^a-z0-9]+/g, '')
75
- if (compact !== '' && !variants.includes(compact)) variants.push(compact)
76
- return variants
77
- }
78
-
79
- /** Exact/alias index over the generated snapshot. */
80
- const INDEX = (() => {
81
- const index = new Map()
82
- for (const [id, entry] of Object.entries(MODEL_CATALOG)) {
83
- for (const variant of modelIdVariants(id)) {
84
- if (!index.has(variant)) index.set(variant, { id, ...entry })
85
- }
86
- }
87
- return index
88
- })()
89
-
90
- /**
91
- * Find the snapshot entry for one model id.
92
- * @param value - model id as any provider spells it.
93
- * @returns `{ id, type, input?, output?, reasoning?, contextWindow?, maxTokens?, provider? }` or undefined.
94
- */
95
- export function lookupCatalogModel(value) {
96
- for (const variant of modelIdVariants(value)) {
97
- const found = INDEX.get(variant)
98
- if (found !== undefined) return found
99
- }
100
- return undefined
101
- }
102
-
103
39
  /** Coarse kind from the id/name alone. */
104
40
  export function inferModelKind(id, name = '') {
105
41
  const text = `${String(id ?? '')} ${String(name ?? '')}`.toLowerCase()
@@ -20,7 +20,7 @@
20
20
  * @module lib/capability-corrections
21
21
  */
22
22
 
23
- import { modelIdVariants } from './capability-catalog.js'
23
+ import { modelIdVariants } from './model-id.js'
24
24
 
25
25
  /**
26
26
  * Corrected facts keyed by the normalized catalog id.