@tzyc/dsh-model-visibility 1.9.1 → 1.10.1

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,294 @@
1
+ /**
2
+ * The learned local capability snapshot.
3
+ *
4
+ * The plugin used to bundle a static pi-ai / models.dev snapshot; most relay
5
+ * providers publish neither reasoning ladders nor context windows, so that
6
+ * snapshot aged quickly and had to be regenerated by hand. It is gone: the
7
+ * replacement is this store, seeded by the settings page's "verify all
8
+ * models" run (public catalogs plus the checker model the user picked) and
9
+ * persisted in `$DSH_HOME/dsh-model-visibility/learned.json`.
10
+ *
11
+ * A verified fact is keyed by *normalized model id* rather than by provider,
12
+ * so the same model served under a second route, or a `:free` variant its
13
+ * relay invents later, resolves to the cached row without another sweep.
14
+ * Fields mirror the provider schema where possible so writes into provider
15
+ * settings and learned facts stay one transform apart.
16
+ *
17
+ * The Host reads the file synchronously and re-reads it when the mtime
18
+ * changes, following the same pattern as `lib/model-overrides.js`: a hand
19
+ * edit of `learned.json` is enough to apply a fix.
20
+ *
21
+ * @module lib/learned-catalog
22
+ */
23
+ import fs from 'node:fs'
24
+ import os from 'node:os'
25
+ import path from 'node:path'
26
+
27
+ import { MODEL_KINDS } from './capability-catalog.js'
28
+ import { modelIdVariants } from './model-id.js'
29
+
30
+ import { sanitizeEfforts, sanitizeModalities, text } from './model-overrides.js'
31
+
32
+ /** Absolute path of the learned snapshot below `$DSH_HOME`. */
33
+ export function learnedFile(home) {
34
+ const base = home || process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
35
+ return path.join(base, 'dsh-model-visibility', 'learned.json')
36
+ }
37
+
38
+ /**
39
+ * Normalize one verified fact into the store's detached record.
40
+ * @param input - a check result entry, possibly malformed.
41
+ * @returns `{ id, idSource, entry }` or undefined when nothing usable remains.
42
+ */
43
+ export function sanitizeLearned(input) {
44
+ if (input === null || typeof input !== 'object') return undefined
45
+ const id =
46
+ text(input.id) ??
47
+ (input.provider !== undefined && input.model !== undefined
48
+ ? text(input.model)
49
+ : undefined)
50
+ if (id === undefined || text(id) === '') return undefined
51
+ const entry = {}
52
+ if (typeof input.type === 'string' && MODEL_KINDS.includes(input.type)) entry.type = input.type
53
+ const inputModalities = sanitizeModalities(input.input)
54
+ if (inputModalities !== undefined) entry.input = inputModalities
55
+ const output = sanitizeModalities(input.output)
56
+ if (output !== undefined) entry.output = output
57
+ if (Number.isSafeInteger(input.contextWindow) && input.contextWindow > 0) {
58
+ entry.contextWindow = input.contextWindow
59
+ }
60
+ if (Number.isSafeInteger(input.maxTokens) && input.maxTokens > 0) entry.maxTokens = input.maxTokens
61
+ const efforts = sanitizeEfforts(input.reasoningEfforts ?? input.efforts)
62
+ if (efforts !== undefined) entry.reasoningEfforts = efforts
63
+ const defaultEffort = text(input.defaultEffort)
64
+ if (defaultEffort !== undefined) entry.defaultEffort = defaultEffort
65
+ if (typeof input.reasoning === 'boolean') entry.reasoning = input.reasoning
66
+ const provider = text(input.provider)
67
+ if (provider !== undefined) entry.provider = provider
68
+ const declared =
69
+ entry.type !== undefined ||
70
+ entry.input !== undefined ||
71
+ entry.output !== undefined ||
72
+ entry.contextWindow !== undefined ||
73
+ entry.maxTokens !== undefined ||
74
+ entry.reasoningEfforts !== undefined ||
75
+ entry.reasoning !== undefined
76
+ if (!declared) return undefined
77
+ const origins = Array.isArray(input.origins)
78
+ ? [...new Set(input.origins.filter((item) => item === 'public' || item === 'checker' || item === 'manual'))]
79
+ : []
80
+ const verifiedAt = Number(input.verifiedAt)
81
+ return {
82
+ id,
83
+ idSource: typeof input.idSource === 'string' ? input.idSource : 'id',
84
+ entry: {
85
+ ...entry,
86
+ verifiedAt: Number.isSafeInteger(verifiedAt) && verifiedAt > 0 ? verifiedAt : Date.now(),
87
+ ...(origins.length > 0 ? { origins } : { origins: [] }),
88
+ ...(input.checker !== null && typeof input.checker === 'object'
89
+ ? { checker: input.checker }
90
+ : {}),
91
+ },
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A file-backed store of verified capability facts, keyed by normalized id.
97
+ *
98
+ * @param options - `{ file?, home?, logger?, now? }`.
99
+ * @returns `{ file, meta, list, get, upsertEntries, invalidate }`.
100
+ */
101
+ export function createLearnedCatalog(options = {}) {
102
+ const file = options.file || learnedFile(options.home)
103
+ const logger = options.logger ?? console
104
+ const now = typeof options.now === 'function' ? options.now : Date.now
105
+ /** `variants -> canonical id`, rebuilt whenever the file changes. */
106
+ let index = new Map()
107
+ /** canonical id -> detached record (entry keys, provider shape). */
108
+ let records = new Map()
109
+ let store = null
110
+ let loaded = false
111
+ let loadedMtime = -Infinity
112
+
113
+ const warn = (message, error) => {
114
+ if (typeof logger.warn === 'function') {
115
+ logger.warn(message)
116
+ if (error !== undefined) logger.warn(error)
117
+ }
118
+ }
119
+
120
+ const mtimeOf = () => {
121
+ try {
122
+ return fs.statSync(file).mtimeMs
123
+ } catch (_error) {
124
+ return -Infinity
125
+ }
126
+ }
127
+
128
+ const buildIndex = () => {
129
+ index = new Map()
130
+ for (const [canonical, record] of records) {
131
+ for (const variant of modelIdVariants(record.id)) {
132
+ if (!index.has(variant)) index.set(variant, canonical)
133
+ }
134
+ if (!index.has(canonical)) index.set(canonical, canonical)
135
+ }
136
+ }
137
+
138
+ const readRecord = (raw) => {
139
+ const sanitized = sanitizeLearned(raw)
140
+ if (sanitized === undefined) return undefined
141
+ const record = sanitized.entry
142
+ return { id: sanitized.id, ...record }
143
+ }
144
+
145
+ const load = (force = false) => {
146
+ const mtime = mtimeOf()
147
+ if (!force && loaded && mtime === loadedMtime) return
148
+ loaded = true
149
+ loadedMtime = mtime
150
+ records = new Map()
151
+ store = null
152
+ if (mtime === -Infinity) {
153
+ buildIndex()
154
+ return
155
+ }
156
+ try {
157
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
158
+ store = parsed !== null && typeof parsed === 'object' ? parsed : null
159
+ const source =
160
+ store !== null && store.models !== null && typeof store.models === 'object'
161
+ ? store.models
162
+ : null
163
+ if (source !== null) {
164
+ for (const [key, raw] of Object.entries(source)) {
165
+ const record = readRecord(raw)
166
+ if (record !== undefined && !records.has(key)) records.set(key, record)
167
+ }
168
+ }
169
+ } catch (error) {
170
+ warn('model-visibility: could not read the learned snapshot; starting empty', error)
171
+ store = null
172
+ }
173
+ buildIndex()
174
+ }
175
+
176
+ const persistLocked = () => {
177
+ fs.mkdirSync(path.dirname(file), { recursive: true })
178
+ const body = {
179
+ version: 1,
180
+ ...(store?.checker !== undefined ? { checker: store.checker } : {}),
181
+ updatedAt: now(),
182
+ models: Object.fromEntries([...records.entries()].map(([key, record]) => [key, record])),
183
+ }
184
+ const temp = `${file}.${process.pid}.tmp`
185
+ fs.writeFileSync(temp, `${JSON.stringify(body, null, 2)}\n`)
186
+ fs.renameSync(temp, file)
187
+ loadedMtime = mtimeOf()
188
+ }
189
+
190
+ /** Footer facts for the settings page (full file read). */
191
+ const meta = () => {
192
+ load()
193
+ return {
194
+ file,
195
+ models: records.size,
196
+ updatedAt:
197
+ store !== null && Number.isSafeInteger(store.updatedAt) ? store.updatedAt : undefined,
198
+ checker: store !== null && store.checker !== null && typeof store.checker === 'object' ? store.checker : undefined,
199
+ }
200
+ }
201
+
202
+ /** Detached copies of every fact, in normalized-key order. */
203
+ const list = () => {
204
+ load()
205
+ return [...records.values()].map((record) => ({ ...record }))
206
+ }
207
+
208
+ /** One verified fact for any spelling of the model id, or undefined. */
209
+ const get = (value) => {
210
+ if (typeof value !== 'string') return undefined
211
+ const variants = modelIdVariants(value)
212
+ if (variants.length === 0) return undefined
213
+ load()
214
+ let fallback
215
+ for (const variant of variants) {
216
+ const canonical = index.get(variant)
217
+ if (canonical === undefined) {
218
+ if (fallback === undefined && records.has(variant)) fallback = records.get(variant)
219
+ continue
220
+ }
221
+ const record = records.get(canonical)
222
+ if (record === undefined) continue
223
+ return { ...record }
224
+ }
225
+ return fallback === undefined ? undefined : { ...fallback }
226
+ }
227
+
228
+ /**
229
+ * Merge verified facts in; later entries win per field and `verifiedAt`.
230
+ *
231
+ * The same model may arrive twice (its id from one relay, a corrected id
232
+ * from the checker), so records merge instead of replacing: newer facts
233
+ * overwrite older ones field by field and the freshest `verifiedAt` stays.
234
+ * @param inputs - check results, each `{ id | provider+model, …facts }`.
235
+ */
236
+ const upsertEntries = (inputs, extra = {}) => {
237
+ load(true)
238
+ const source = Array.isArray(inputs) ? inputs : [inputs]
239
+ let changed = false
240
+ for (const raw of source) {
241
+ const sanitized = sanitizeLearned(raw)
242
+ if (sanitized === undefined) continue
243
+ // Call-side provenance beats the entry's own stamps: the orchestrator
244
+ // knows the run that produced the fact.
245
+ const provenance = {}
246
+ if (extra?.verifiedAt !== undefined) provenance.verifiedAt = extra.verifiedAt
247
+ if (extra?.origins !== undefined) provenance.origins = extra.origins
248
+ if (extra?.checker !== undefined) provenance.checker = extra.checker
249
+ const record = { provider: raw?.provider, ...sanitized.entry, ...provenance, id: sanitized.id }
250
+ const key = modelIdVariants(record.id)[0] ?? sanitized.id
251
+ const existing = records.get(key)
252
+ if (existing === undefined) {
253
+ records.set(key, record)
254
+ changed = true
255
+ continue
256
+ }
257
+ const next = { ...existing }
258
+ for (const [field, value] of Object.entries(record)) {
259
+ if (value === undefined || value === null) continue
260
+ if (field === 'verifiedAt') {
261
+ next.verifiedAt = Math.max(existing.verifiedAt ?? 0, value)
262
+ continue
263
+ }
264
+ if (field === 'origins' && Array.isArray(existing.origins)) {
265
+ const union = [...new Set([...existing.origins, ...value])]
266
+ if (JSON.stringify(union) === JSON.stringify(existing.origins)) continue
267
+ next.origins = union
268
+ continue
269
+ }
270
+ next[field] = value
271
+ }
272
+ if (JSON.stringify(next) !== JSON.stringify(existing)) {
273
+ records.set(key, next)
274
+ changed = true
275
+ }
276
+ }
277
+ if (changed) {
278
+ buildIndex()
279
+ try {
280
+ persistLocked()
281
+ } catch (error) {
282
+ warn('model-visibility: could not write the learned snapshot', error)
283
+ }
284
+ }
285
+ }
286
+
287
+ /** Forget the memoized copy; the next read consults the file again. */
288
+ const invalidate = () => {
289
+ loaded = false
290
+ loadedMtime = -Infinity
291
+ }
292
+
293
+ return { file, meta, list, get, upsertEntries, invalidate }
294
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Model-id normalization shared by every matching layer.
3
+ *
4
+ * Split out of `capability-catalog.js` so the relay aggregator rules stay in
5
+ * one place after the bundled static catalog was replaced by the learned
6
+ * local snapshot (`lib/learned-catalog.js`).
7
+ *
8
+ * Relays spell ids slightly differently than the canonical model name: an
9
+ * aggregator namespaces them (`openai/gpt-4o`), request suffixes are stacked
10
+ * (`glm-5.3:free:batch`), a leading `~` marks a route prefix, and resolution
11
+ * twins exist (`gpt-5.1` vs `gpt-5-1`). `modelIdVariants` deliberately stays
12
+ * conservative — over-folding would merge two real models — but the first
13
+ * variant always matches the canonical catalog row.
14
+ *
15
+ * @module lib/model-id
16
+ */
17
+
18
+ /** Strip aggregator namespaces, request suffixes and resolution variants. */
19
+ export function normalizeModelId(value) {
20
+ let id = String(value ?? '').trim().toLowerCase()
21
+ if (id === '') return ''
22
+ id = id.replace(/^~/, '')
23
+ // Providers may stack request variants (for example `:free:thinking`).
24
+ // Strip them one at a time so aliases remain stable without broad folding.
25
+ let previous
26
+ do {
27
+ previous = id
28
+ id = id.replace(/:(batch|free|extended|thinking|online|nitro)$/, '')
29
+ id = id.replace(/-(free|batch)$/, '')
30
+ } while (id !== previous)
31
+ const slash = id.lastIndexOf('/')
32
+ if (slash !== -1) id = id.slice(slash + 1)
33
+ id = id.replace(/^(global|us|eu|apac|jp|au|ca)\./, '')
34
+ id = id.replace(/^(anthropic|openai|google|xai|x-ai|meta|mistralai|moonshotai|minimax|nvidia|qwen|z-ai|zai|deepseek|bytedance-seed)\./, '')
35
+ return id
36
+ }
37
+
38
+ /** Lookup keys for one model id, most specific first. */
39
+ export function modelIdVariants(value) {
40
+ const base = normalizeModelId(value)
41
+ if (base === '') return []
42
+ const variants = [base]
43
+ const withoutResolution = base.replace(/-(4k|2k|8k|hd|uhd)$/, '')
44
+ if (withoutResolution !== base) variants.push(withoutResolution)
45
+ const withoutSuffix = withoutResolution.replace(/-(latest|preview|new)$/, '')
46
+ if (!variants.includes(withoutSuffix)) variants.push(withoutSuffix)
47
+ const compact = base.replace(/[^a-z0-9]+/g, '')
48
+ if (compact !== '' && !variants.includes(compact)) variants.push(compact)
49
+ return variants
50
+ }
package/lib/model-info.js CHANGED
@@ -26,10 +26,12 @@
26
26
  * configured model declares the field.
27
27
  * 3. `provider` — capability fields found by probing the relay's own
28
28
  * `/v1/models` (see `provider-probe.js`).
29
- * 4. `catalog` — the pi-ai / models.dev snapshot (see `capability-catalog.js`).
29
+ * 4. `snapshot` — a verified fact from the learned local snapshot
30
+ * (`lib/learned-catalog.js`), seeded by the settings page's
31
+ * online verification run.
30
32
  * 5. `inferred` — conservative name rules for well-known model families.
31
33
  *
32
- * The browser renders `catalog`/`inferred` facts with a dashed tag and explains
34
+ * The browser renders `snapshot`/`inferred` facts with a dashed tag and explains
33
35
  * the source in the tooltip; a missing fact stays "unknown" instead of becoming
34
36
  * a false "text only".
35
37
  *
@@ -41,18 +43,29 @@
41
43
  */
42
44
 
43
45
  import {
44
- CATALOG_GENERATED_AT,
45
- CATALOG_PI_AI_VERSION,
46
- MODEL_CATALOG,
47
46
  MODEL_KINDS,
48
47
  inferInputModalities,
49
48
  inferModelKind,
50
- lookupCatalogModel,
51
- modelIdVariants,
52
49
  } from './capability-catalog.js'
53
50
  import { lookupCorrection } from './capability-corrections.js'
51
+ import { modelIdVariants } from './model-id.js'
52
+ import { createLearnedCatalog } from './learned-catalog.js'
54
53
 
55
- export { CATALOG_GENERATED_AT, CATALOG_PI_AI_VERSION }
54
+ /**
55
+ * Fallback learned snapshot used when the caller does not inject one. The
56
+ * Host constructs its own store so the settings page and the request-side
57
+ * image gate share one mtime-memoized read; a plain consumer of this module
58
+ * (a test double, another tool) still gets learned facts without wiring.
59
+ */
60
+ let defaultLearned = null
61
+ function lookupLearned(value) {
62
+ try {
63
+ if (defaultLearned === null) defaultLearned = createLearnedCatalog({})
64
+ return defaultLearned.get(value)
65
+ } catch (_error) {
66
+ return undefined
67
+ }
68
+ }
56
69
 
57
70
  /** Default lifetime of one catalog snapshot, in milliseconds. */
58
71
  export const DEFAULT_TTL_MS = 15_000
@@ -217,7 +230,7 @@ function multiModalDefault(input) {
217
230
  *
218
231
  * Exported for unit tests: it is pure and takes plain values.
219
232
  * @param base - `{ id, name, description? }`.
220
- * @param context - `{ adapter?, infoError?, profile?, configured?, discovered?, catalogEntry?, override? }`.
233
+ * @param context - `{ adapter?, infoError?, profile?, configured?, discovered?, learned?, override? }`
221
234
  * @returns a detached model record with per-field sources.
222
235
  */
223
236
  export function mergeCapabilities(base, context = {}) {
@@ -226,12 +239,14 @@ export function mergeCapabilities(base, context = {}) {
226
239
  const configured = context.configured ?? mapGet(profile?.models, base.id)
227
240
  const discovered = context.discovered
228
241
  const override = context.override
229
- const catalogEntry = context.catalogEntry ?? lookupCatalogModel(base.id)
242
+ const learned = context.learned ?? lookupLearned(base.id)
230
243
  const sources = {}
231
- let catalogUsed = false
244
+ let learnedUsed = false
245
+ let learnedRecord = null
232
246
 
233
- const useCatalog = () => {
234
- catalogUsed = true
247
+ const useLearned = (record) => {
248
+ learnedUsed = true
249
+ if (record !== null && typeof record === 'object') learnedRecord = record
235
250
  }
236
251
 
237
252
  /* ---------------------------------------------------------------- kind */
@@ -241,10 +256,10 @@ export function mergeCapabilities(base, context = {}) {
241
256
  if (discovered?.kind !== undefined) {
242
257
  kind = discovered.kind
243
258
  sources.kind = 'provider'
244
- } else if (catalogEntry?.type !== undefined) {
245
- kind = catalogEntry.type
246
- sources.kind = 'catalog'
247
- useCatalog()
259
+ } else if (learned?.type !== undefined) {
260
+ kind = learned.type
261
+ sources.kind = 'snapshot'
262
+ useLearned(learned)
248
263
  } else if (inferredKind !== undefined) {
249
264
  kind = inferredKind
250
265
  sources.kind = 'inferred'
@@ -292,11 +307,11 @@ export function mergeCapabilities(base, context = {}) {
292
307
  inputModalities = modalityList(discovered.inputModalities)
293
308
  if (inputModalities !== undefined) modalitySource = 'provider'
294
309
  }
295
- if (inputModalities === undefined && catalogEntry?.input !== undefined) {
296
- inputModalities = modalityList(catalogEntry.input)
310
+ if (inputModalities === undefined && learned?.input !== undefined) {
311
+ inputModalities = modalityList(learned.input)
297
312
  if (inputModalities !== undefined) {
298
- modalitySource = 'catalog'
299
- useCatalog()
313
+ modalitySource = 'snapshot'
314
+ useLearned(learned)
300
315
  }
301
316
  }
302
317
  if (inputModalities === undefined && kind === 'chat') {
@@ -315,7 +330,7 @@ export function mergeCapabilities(base, context = {}) {
315
330
  let correctionApplied = false
316
331
  if (
317
332
  correction?.input !== undefined &&
318
- (modalitySource === 'catalog' ||
333
+ (modalitySource === 'snapshot' ||
319
334
  modalitySource === 'inferred' ||
320
335
  modalitySource === 'unknown' ||
321
336
  modalitySource === 'provider')
@@ -356,11 +371,11 @@ export function mergeCapabilities(base, context = {}) {
356
371
  contextWindow = positiveInteger(discovered.contextWindow)
357
372
  if (contextWindow !== undefined) contextSource = 'provider'
358
373
  }
359
- if (contextWindow === undefined && catalogEntry?.contextWindow !== undefined) {
360
- contextWindow = positiveInteger(catalogEntry.contextWindow)
374
+ if (contextWindow === undefined && learned?.contextWindow !== undefined) {
375
+ contextWindow = positiveInteger(learned.contextWindow)
361
376
  if (contextWindow !== undefined) {
362
- contextSource = 'catalog'
363
- useCatalog()
377
+ contextSource = 'snapshot'
378
+ useLearned(learned)
364
379
  }
365
380
  }
366
381
  const manualContext = positiveInteger(override?.contextWindow)
@@ -380,10 +395,16 @@ export function mergeCapabilities(base, context = {}) {
380
395
  } else if (discovered?.reasoning !== undefined) {
381
396
  reasoningSupported = discovered.reasoning === true
382
397
  reasoningSource = 'provider'
383
- } else if (catalogEntry?.reasoning !== undefined) {
384
- reasoningSupported = catalogEntry.reasoning === true
385
- reasoningSource = 'catalog'
386
- useCatalog()
398
+ } else if (learned?.reasoning !== undefined) {
399
+ reasoningSupported = learned.reasoning === true
400
+ // A learned fact that carries a verified ladder also supplies the ladder.
401
+ const ladder = detachReasoning({
402
+ efforts: learned.reasoningEfforts,
403
+ defaultEffort: learned.defaultEffort,
404
+ })
405
+ if (ladder !== undefined) reasoning = ladder
406
+ reasoningSource = 'snapshot'
407
+ useLearned(learned)
387
408
  }
388
409
 
389
410
  // A manual ladder is the requested fix for relays that publish no effort
@@ -418,15 +439,15 @@ export function mergeCapabilities(base, context = {}) {
418
439
  if (defaultMaxTokens === undefined && discovered?.maxTokens !== undefined) {
419
440
  defaultMaxTokens = positiveInteger(discovered.maxTokens)
420
441
  }
421
- if (defaultMaxTokens === undefined && catalogEntry?.maxTokens !== undefined) {
422
- defaultMaxTokens = positiveInteger(catalogEntry.maxTokens)
442
+ if (defaultMaxTokens === undefined && learned?.maxTokens !== undefined) {
443
+ defaultMaxTokens = positiveInteger(learned.maxTokens)
423
444
  }
424
445
 
425
446
  /* --------------------------------------------------------- output side */
426
447
 
427
448
  const outputModalities =
428
449
  modalityList(discovered?.outputModalities) ??
429
- modalityList(catalogEntry?.output) ??
450
+ modalityList(learned?.output) ??
430
451
  (kind === 'image'
431
452
  ? ['image']
432
453
  : kind === 'video'
@@ -460,14 +481,17 @@ export function mergeCapabilities(base, context = {}) {
460
481
  },
461
482
  }
462
483
  : {}),
463
- ...(catalogUsed
484
+ ...(learnedUsed && learnedRecord !== null
464
485
  ? {
465
- catalog: {
466
- id: catalogEntry.id,
467
- ...(nonEmptyString(catalogEntry.provider) === undefined
468
- ? {}
469
- : { provider: catalogEntry.provider }),
470
- ...(CATALOG_GENERATED_AT === null ? {} : { generatedAt: CATALOG_GENERATED_AT }),
486
+ learned: {
487
+ id: learnedRecord.id ?? base.id,
488
+ ...(Number.isSafeInteger(learnedRecord.verifiedAt)
489
+ ? { verifiedAt: learnedRecord.verifiedAt }
490
+ : {}),
491
+ ...(Array.isArray(learnedRecord.origins) && learnedRecord.origins.length > 0
492
+ ? { origins: [...learnedRecord.origins] }
493
+ : {}),
494
+ ...(learnedRecord.checker !== undefined ? { checker: learnedRecord.checker } : {}),
471
495
  },
472
496
  }
473
497
  : {}),
@@ -503,7 +527,7 @@ export function imageInputDecision(providerId, modelId, context = {}) {
503
527
  const discovered = findDiscovered(discoveredIndex, modelId)
504
528
  const record = mergeCapabilities(
505
529
  { id: modelId, name: modelId },
506
- { profile, configured, discovered, override: context.override },
530
+ { profile, configured, discovered, override: context.override, learned: context.learned },
507
531
  )
508
532
  const modalities = Array.isArray(record.inputModalities) ? record.inputModalities : []
509
533
  if (record.kind !== 'chat') return { allowed: false, record, reason: 'non-chat' }
@@ -524,7 +548,7 @@ export function imageInputDecision(providerId, modelId, context = {}) {
524
548
  * @param llm - the Host `ctx.llm` registry (or a test double).
525
549
  * @param providerId - provider route the model belongs to.
526
550
  * @param model - catalog entry `{ id, name?, description?, inputModalities? }`.
527
- * @param context - `{ profile?, discovered?, override? }` enrichment for this provider.
551
+ * @param context - `{ profile?, discovered?, override?, learned? }` enrichment for this provider.
528
552
  * @returns a detached model record.
529
553
  */
530
554
  export async function describeModel(llm, providerId, model, context = {}) {
@@ -553,6 +577,7 @@ export async function describeModel(llm, providerId, model, context = {}) {
553
577
  configured: mapGet(profile?.models, model.id),
554
578
  discovered,
555
579
  override: context.override,
580
+ learned: context.learned ?? lookupLearned(model.id),
556
581
  })
557
582
  }
558
583
 
@@ -578,7 +603,7 @@ async function resolveEnrichment(options, fresh) {
578
603
  * "kind", "contextWindow", "inputModalities", "reasoning", "sources" }] }],
579
604
  * "failures": [{ "id", "name", "message" }],
580
605
  * "providers": [{ "id", "status", "models", "capabilities" }],
581
- * "catalog": { "generatedAt", "piAiVersion", "models" },
606
+ * "learned": { "models", "updatedAt?", "checker?" },
582
607
  * "generatedAt": 1700000000000
583
608
  * }
584
609
  * ```
@@ -586,13 +611,19 @@ async function resolveEnrichment(options, fresh) {
586
611
  * Provider failures are isolated exactly like the shipped catalog. Empty
587
612
  * provider groups are dropped so the page never shows a header with no rows.
588
613
  * @param llm - the Host `ctx.llm` registry (or a test double).
589
- * @param options - `{ enrichment?, fresh? }`.
614
+ * @param options - `{ enrichment?, fresh?, learned? }`; `learned` is the
615
+ * Host-owned learned-catalog store backing the `snapshot` evidence layer.
590
616
  * @returns the catalog value.
591
617
  */
592
618
  export async function describeCatalog(llm, options = {}) {
593
619
  const generatedAt = Date.now()
594
620
  const groups = []
595
621
  const failures = []
622
+ // Adopting the caller's store here also points the module-level fallback at
623
+ // the Host's single mtime-memoized read; direct consumers without a store
624
+ // keep their lazy default.
625
+ if (options.learned !== undefined) defaultLearned = options.learned
626
+ const learnedStore = options.learned ?? (defaultLearned ?? undefined)
596
627
  const enrichment = await resolveEnrichment(options, options.fresh === true)
597
628
  const profiles = enrichment?.profiles instanceof Map ? enrichment.profiles : new Map()
598
629
  const discovered = enrichment?.discovered instanceof Map ? enrichment.discovered : new Map()
@@ -606,7 +637,7 @@ export async function describeCatalog(llm, options = {}) {
606
637
  groups,
607
638
  failures: [{ id: '*', name: 'providers', message: messageOf(error) }],
608
639
  providers: [],
609
- catalog: catalogMeta(),
640
+ learned: catalogMeta(),
610
641
  generatedAt,
611
642
  }
612
643
  }
@@ -632,6 +663,7 @@ export async function describeCatalog(llm, options = {}) {
632
663
  profile,
633
664
  discoveredIndex,
634
665
  override: typeof overrides?.get === 'function' ? overrides.get(id, model.id) : undefined,
666
+ learned: learnedStore?.get?.(model.id),
635
667
  }),
636
668
  ),
637
669
  )
@@ -652,21 +684,24 @@ export async function describeCatalog(llm, options = {}) {
652
684
  groups,
653
685
  failures,
654
686
  providers: Array.isArray(enrichment?.diagnostics) ? enrichment.diagnostics : [],
655
- catalog: catalogMeta(),
687
+ learned: catalogMeta(),
656
688
  generatedAt,
657
689
  }
658
690
  }
659
691
 
660
- /** Non-secret provenance of the bundled snapshot, for the page footer. */
692
+ /** Non-secret provenance of the learned snapshot, for the page footer. */
661
693
  function catalogMeta() {
694
+ const meta = defaultLearned?.meta?.()
662
695
  return {
663
- generatedAt: CATALOG_GENERATED_AT ?? null,
664
- piAiVersion: CATALOG_PI_AI_VERSION ?? null,
665
- models: Object.keys(MODEL_CATALOG).length,
696
+ models: meta?.models ?? 0,
697
+ ...(meta?.updatedAt !== undefined ? { updatedAt: meta.updatedAt } : {}),
698
+ ...(meta?.checker !== undefined ? { checker: meta.checker } : {}),
666
699
  // Lets a newer browser half detect an older Host half after an upgrade.
667
700
  // `reload` means the route understands `?reload=1` and drops every cache;
668
- // `overrides` means the Host persists and serves manual capability edits.
669
- features: ['applyInput', 'reload', 'overrides'],
701
+ // `overrides` means the Host persists and serves manual capability edits;
702
+ // `verify` means the Host can sweep every model online; `usage` means the
703
+ // Host serves the token-usage ledger.
704
+ features: ['applyInput', 'reload', 'overrides', 'verify', 'usage'],
670
705
  }
671
706
  }
672
707
 
@@ -711,6 +746,7 @@ export function createModelInfoService(llm, options = {}) {
711
746
  const run = describeCatalog(llm, {
712
747
  enrichment: options.enrichment,
713
748
  overrides: options.overrides,
749
+ learned: options.learned,
714
750
  fresh,
715
751
  })
716
752
  .then((value) => {
@@ -764,6 +800,12 @@ export function createModelInfoService(llm, options = {}) {
764
800
  profile: profiles?.get(providerId),
765
801
  discovered: discovered?.get(providerId),
766
802
  override: typeof overrides?.get === 'function' ? overrides.get(providerId, modelId) : undefined,
803
+ learned:
804
+ typeof source.learned === 'function'
805
+ ? source.learned(modelId)
806
+ : typeof source.learned?.get === 'function'
807
+ ? source.learned.get(modelId)
808
+ : source.learned,
767
809
  }).allowed
768
810
  } catch (_error) {
769
811
  // A capability read must never break the request path.
@@ -52,7 +52,7 @@ const EFFORT_NAMES = {
52
52
  const MAX_EFFORTS = 16
53
53
 
54
54
  /** Read a trimmed non-empty string, or undefined. */
55
- function text(value) {
55
+ export function text(value) {
56
56
  if (typeof value !== 'string') return undefined
57
57
  const trimmed = value.trim()
58
58
  return trimmed === '' ? undefined : trimmed