@tzyc/dsh-model-visibility 0.0.0-stage → 1.9.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,786 @@
1
+ /**
2
+ * Host-side model capability probe.
3
+ *
4
+ * The shipped browser catalog (`session.modelCatalog`) deliberately carries only
5
+ * the identity and the reasoning ladder of every routable model. The model
6
+ * visibility page wants the rest of the story:
7
+ *
8
+ * - the context window (`LlmResolvedModelInfo.context.contextWindow`),
9
+ * - the accepted input modalities (`inputModalities`, e.g. text + image),
10
+ * - the output-token default (`defaultMaxTokens`), used as a tooltip only,
11
+ * - the model kind (chat / image / video / embedding / …), which the adapter
12
+ * contract does not carry at all.
13
+ *
14
+ * Everything resolves through `ctx.llm` — the same registry the official model
15
+ * menu uses — but an adapter declaration is not always trustworthy. A custom
16
+ * relay configured with `input: []` falls back to the provider's `defaultInput`
17
+ * (usually `["text"]`), so the registry reports "text only" for a model that is
18
+ * plainly multimodal. This module therefore merges four evidence layers and
19
+ * records which one supplied each fact:
20
+ *
21
+ * 1. `config` — the model entry in the settings document (explicit input,
22
+ * context window, or a provider default that names a
23
+ * non-text modality). This is the strongest evidence.
24
+ * 2. `adapter` — `resolveModelInfo`. Trusted when the provider is not
25
+ * configured through settings (built-in adapters) or when the
26
+ * configured model declares the field.
27
+ * 3. `provider` — capability fields found by probing the relay's own
28
+ * `/v1/models` (see `provider-probe.js`).
29
+ * 4. `catalog` — the pi-ai / models.dev snapshot (see `capability-catalog.js`).
30
+ * 5. `inferred` — conservative name rules for well-known model families.
31
+ *
32
+ * The browser renders `catalog`/`inferred` facts with a dashed tag and explains
33
+ * the source in the tooltip; a missing fact stays "unknown" instead of becoming
34
+ * a false "text only".
35
+ *
36
+ * Nothing in this module imports DSH internals: the only contract is the small
37
+ * slice of `ctx.llm` used by {@link describeCatalog}, which keeps the probe
38
+ * unit-testable with a plain object.
39
+ *
40
+ * @module lib/model-info
41
+ */
42
+
43
+ import {
44
+ CATALOG_GENERATED_AT,
45
+ CATALOG_PI_AI_VERSION,
46
+ MODEL_CATALOG,
47
+ MODEL_KINDS,
48
+ inferInputModalities,
49
+ inferModelKind,
50
+ lookupCatalogModel,
51
+ modelIdVariants,
52
+ } from './capability-catalog.js'
53
+ import { lookupCorrection } from './capability-corrections.js'
54
+
55
+ export { CATALOG_GENERATED_AT, CATALOG_PI_AI_VERSION }
56
+
57
+ /** Default lifetime of one catalog snapshot, in milliseconds. */
58
+ export const DEFAULT_TTL_MS = 15_000
59
+
60
+ /** Render an unknown thrown value without losing its message. */
61
+ function messageOf(error) {
62
+ return error instanceof Error ? error.message : String(error)
63
+ }
64
+
65
+ /** Keep only positive safe integers; the adapter contract allows nothing else. */
66
+ function positiveInteger(value) {
67
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined
68
+ }
69
+
70
+ /** Read a non-empty string, or `undefined`. */
71
+ function nonEmptyString(value) {
72
+ return typeof value === 'string' && value !== '' ? value : undefined
73
+ }
74
+
75
+ /** Detach one modality list (first occurrence wins), or undefined. */
76
+ function modalityList(value) {
77
+ if (!Array.isArray(value)) return undefined
78
+ const out = []
79
+ for (const item of value) {
80
+ if (typeof item !== 'string' || item === '' || out.includes(item)) continue
81
+ out.push(item)
82
+ }
83
+ return out.length > 0 ? out : undefined
84
+ }
85
+
86
+ /** Read a Map entry by key, tolerating a plain object in tests. */
87
+ function mapGet(map, key) {
88
+ if (map === null || map === undefined) return undefined
89
+ if (typeof map.get === 'function') return map.get(key)
90
+ return map[key]
91
+ }
92
+
93
+ /**
94
+ * Detach a reasoning ladder into JSON-safe plain objects.
95
+ *
96
+ * Invalid efforts are dropped rather than rejecting the whole model: a single
97
+ * malformed catalog row must not blank the settings page. An empty ladder is
98
+ * treated as "no reasoning metadata", because the composer hides the effort row
99
+ * in that case too.
100
+ * @param reasoning - adapter-owned reasoning metadata, possibly malformed.
101
+ * @returns `{ efforts, defaultEffort? }`, or undefined.
102
+ */
103
+ export function detachReasoning(reasoning) {
104
+ if (reasoning === null || typeof reasoning !== 'object') return undefined
105
+ const source = Array.isArray(reasoning.efforts) ? reasoning.efforts : []
106
+ const efforts = []
107
+ const seen = new Set()
108
+ for (const effort of source) {
109
+ if (effort === null || typeof effort !== 'object') continue
110
+ const id = nonEmptyString(effort.id)
111
+ const name = nonEmptyString(effort.name)
112
+ if (id === undefined || name === undefined || seen.has(id)) continue
113
+ seen.add(id)
114
+ const description = nonEmptyString(effort.description)
115
+ efforts.push({
116
+ id,
117
+ name,
118
+ ...(description === undefined ? {} : { description }),
119
+ })
120
+ }
121
+ if (efforts.length === 0) return undefined
122
+ const fallback = nonEmptyString(reasoning.defaultEffort)
123
+ return {
124
+ efforts,
125
+ ...(fallback === undefined || !seen.has(fallback) ? {} : { defaultEffort: fallback }),
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Detach a modality list: strings only, first occurrence wins.
131
+ * @param value - adapter-owned modality array, possibly malformed.
132
+ * @returns a fresh array, or undefined when nothing valid is left.
133
+ */
134
+ export function detachModalities(value) {
135
+ if (!Array.isArray(value)) return undefined
136
+ const out = []
137
+ for (const item of value) {
138
+ if (typeof item !== 'string' || item === '' || out.includes(item)) continue
139
+ out.push(item)
140
+ }
141
+ return out.length > 0 ? out : undefined
142
+ }
143
+
144
+ /**
145
+ * Project one resolved model into the adapter-only wire shape.
146
+ *
147
+ * `info` is whatever `llm.resolveModelInfo` returned; every field is optional
148
+ * because adapters may know only part of a model's capabilities.
149
+ * @param model - the catalog entry the resolver was called for.
150
+ * @param info - `LlmResolvedModelInfo`, or undefined.
151
+ * @returns a detached model record.
152
+ */
153
+ export function describeResolvedModel(model, info) {
154
+ const description =
155
+ nonEmptyString(info?.description) ?? nonEmptyString(model?.description)
156
+ const contextWindow = positiveInteger(info?.context?.contextWindow)
157
+ const defaultMaxTokens = positiveInteger(info?.defaultMaxTokens)
158
+ const inputModalities =
159
+ detachModalities(info?.inputModalities) ?? detachModalities(model?.inputModalities)
160
+ const reasoning = detachReasoning(info?.reasoning)
161
+ return {
162
+ id: model.id,
163
+ name: model.name,
164
+ ...(description === undefined ? {} : { description }),
165
+ ...(contextWindow === undefined ? {} : { contextWindow }),
166
+ ...(defaultMaxTokens === undefined ? {} : { defaultMaxTokens }),
167
+ ...(inputModalities === undefined ? {} : { inputModalities }),
168
+ ...(reasoning === undefined ? {} : { reasoning }),
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Build a lookup over one provider's discovered models.
174
+ *
175
+ * Relays spell ids slightly differently from the configured model id
176
+ * (case, `:free` suffix, vendor prefix), so both the exact id and every
177
+ * normalized variant resolve to the same entry.
178
+ * @param entries - Map of advertised id -> discovered facts.
179
+ * @returns a Map of lookup key -> discovered facts.
180
+ */
181
+ export function indexDiscovered(entries) {
182
+ const index = new Map()
183
+ if (entries === null || entries === undefined) return index
184
+ const source = entries instanceof Map ? entries : new Map(Object.entries(entries))
185
+ for (const [id, entry] of source) {
186
+ if (!index.has(id)) index.set(id, entry)
187
+ const lower = id.toLowerCase()
188
+ if (!index.has(lower)) index.set(lower, entry)
189
+ for (const variant of modelIdVariants(id)) {
190
+ if (!index.has(variant)) index.set(variant, entry)
191
+ }
192
+ }
193
+ return index
194
+ }
195
+
196
+ /** Find one discovered entry by exact id, lowercase, then normalized variants. */
197
+ function findDiscovered(index, id) {
198
+ if (index === null || index === undefined) return undefined
199
+ const exact = index.get(id)
200
+ if (exact !== undefined) return exact
201
+ const lower = index.get(id.toLowerCase())
202
+ if (lower !== undefined) return lower
203
+ for (const variant of modelIdVariants(id)) {
204
+ const found = index.get(variant)
205
+ if (found !== undefined) return found
206
+ }
207
+ return undefined
208
+ }
209
+
210
+ /** Does a provider default declare a non-text input modality? */
211
+ function multiModalDefault(input) {
212
+ return Array.isArray(input) && input.some((item) => item !== 'text')
213
+ }
214
+
215
+ /**
216
+ * Merge every evidence layer into the record the settings page consumes.
217
+ *
218
+ * Exported for unit tests: it is pure and takes plain values.
219
+ * @param base - `{ id, name, description? }`.
220
+ * @param context - `{ adapter?, infoError?, profile?, configured?, discovered?, catalogEntry?, override? }`.
221
+ * @returns a detached model record with per-field sources.
222
+ */
223
+ export function mergeCapabilities(base, context = {}) {
224
+ const adapter = context.adapter
225
+ const profile = context.profile
226
+ const configured = context.configured ?? mapGet(profile?.models, base.id)
227
+ const discovered = context.discovered
228
+ const override = context.override
229
+ const catalogEntry = context.catalogEntry ?? lookupCatalogModel(base.id)
230
+ const sources = {}
231
+ let catalogUsed = false
232
+
233
+ const useCatalog = () => {
234
+ catalogUsed = true
235
+ }
236
+
237
+ /* ---------------------------------------------------------------- kind */
238
+
239
+ const inferredKind = inferModelKind(base.id, base.name)
240
+ let kind
241
+ if (discovered?.kind !== undefined) {
242
+ kind = discovered.kind
243
+ sources.kind = 'provider'
244
+ } else if (catalogEntry?.type !== undefined) {
245
+ kind = catalogEntry.type
246
+ sources.kind = 'catalog'
247
+ useCatalog()
248
+ } else if (inferredKind !== undefined) {
249
+ kind = inferredKind
250
+ sources.kind = 'inferred'
251
+ } else {
252
+ kind = 'chat'
253
+ sources.kind = 'unknown'
254
+ }
255
+
256
+ // A manual kind is the user's own statement about the route, so it outranks
257
+ // every automatic layer. It is applied before the modality step because the
258
+ // name heuristic only runs for chat models.
259
+ if (typeof override?.kind === 'string' && MODEL_KINDS.includes(override.kind)) {
260
+ kind = override.kind
261
+ sources.kind = 'manual'
262
+ }
263
+
264
+ /* --------------------------------------------------------- modalities */
265
+
266
+ let inputModalities
267
+ let modalitySource = 'unknown'
268
+ const configuredInput = modalityList(configured?.input)
269
+ const adapterInput = detachModalities(adapter?.inputModalities)
270
+ const profileExists = profile !== undefined
271
+
272
+ if (configuredInput !== undefined) {
273
+ inputModalities = configuredInput
274
+ modalitySource = 'config'
275
+ } else if (!profileExists && adapterInput !== undefined) {
276
+ // Built-in adapter: `inputModalities` is the adapter's own declaration.
277
+ inputModalities = adapterInput
278
+ modalitySource = 'adapter'
279
+ } else if (profileExists && configured === undefined && adapterInput !== undefined) {
280
+ // The route serves a model outside its configured list, so the adapter
281
+ // resolved it from its installed catalog rather than a `defaultInput`.
282
+ inputModalities = adapterInput
283
+ modalitySource = 'adapter'
284
+ } else if (profileExists && configuredInput === undefined && multiModalDefault(profile?.defaultInput)) {
285
+ // An explicit multi-modal provider default is a declaration, unlike the
286
+ // implicit text-only fallback that causes the "everything is text" bug.
287
+ inputModalities = profile.defaultInput
288
+ modalitySource = 'config'
289
+ }
290
+
291
+ if (inputModalities === undefined && discovered?.inputModalities !== undefined) {
292
+ inputModalities = modalityList(discovered.inputModalities)
293
+ if (inputModalities !== undefined) modalitySource = 'provider'
294
+ }
295
+ if (inputModalities === undefined && catalogEntry?.input !== undefined) {
296
+ inputModalities = modalityList(catalogEntry.input)
297
+ if (inputModalities !== undefined) {
298
+ modalitySource = 'catalog'
299
+ useCatalog()
300
+ }
301
+ }
302
+ if (inputModalities === undefined && kind === 'chat') {
303
+ const inferred = inferInputModalities(base.id, base.name)
304
+ if (inferred !== undefined) {
305
+ inputModalities = inferred
306
+ modalitySource = 'inferred'
307
+ }
308
+ }
309
+
310
+ // A vendor-documented correction outranks a wrong snapshot row and a relay
311
+ // listing (relays commonly mirror the snapshot), but never an explicit
312
+ // provider declaration or a built-in adapter: those describe the route the
313
+ // user actually configured.
314
+ const correction = lookupCorrection(base.id)
315
+ let correctionApplied = false
316
+ if (
317
+ correction?.input !== undefined &&
318
+ (modalitySource === 'catalog' ||
319
+ modalitySource === 'inferred' ||
320
+ modalitySource === 'unknown' ||
321
+ modalitySource === 'provider')
322
+ ) {
323
+ const corrected = modalityList(correction.input)
324
+ if (corrected !== undefined) {
325
+ inputModalities = corrected
326
+ modalitySource = 'official'
327
+ correctionApplied = true
328
+ }
329
+ }
330
+
331
+ if (override?.input !== undefined) {
332
+ const manualInput = modalityList(override.input)
333
+ if (manualInput !== undefined) {
334
+ inputModalities = manualInput
335
+ modalitySource = 'manual'
336
+ }
337
+ }
338
+
339
+ /* ------------------------------------------------------------- context */
340
+
341
+ let contextWindow
342
+ let contextSource = 'unknown'
343
+ const configuredContext = positiveInteger(configured?.contextWindow)
344
+ const adapterContext = positiveInteger(adapter?.contextWindow)
345
+ if (configuredContext !== undefined) {
346
+ contextWindow = configuredContext
347
+ contextSource = 'config'
348
+ } else if (!profileExists && adapterContext !== undefined) {
349
+ contextWindow = adapterContext
350
+ contextSource = 'adapter'
351
+ } else if (profileExists && configured === undefined && adapterContext !== undefined) {
352
+ contextWindow = adapterContext
353
+ contextSource = 'adapter'
354
+ }
355
+ if (contextWindow === undefined && discovered?.contextWindow !== undefined) {
356
+ contextWindow = positiveInteger(discovered.contextWindow)
357
+ if (contextWindow !== undefined) contextSource = 'provider'
358
+ }
359
+ if (contextWindow === undefined && catalogEntry?.contextWindow !== undefined) {
360
+ contextWindow = positiveInteger(catalogEntry.contextWindow)
361
+ if (contextWindow !== undefined) {
362
+ contextSource = 'catalog'
363
+ useCatalog()
364
+ }
365
+ }
366
+ const manualContext = positiveInteger(override?.contextWindow)
367
+ if (manualContext !== undefined) {
368
+ contextWindow = manualContext
369
+ contextSource = 'manual'
370
+ }
371
+
372
+ /* ----------------------------------------------------------- reasoning */
373
+
374
+ let reasoning = detachReasoning(adapter?.reasoning)
375
+ let reasoningSupported
376
+ let reasoningSource = 'unknown'
377
+ if (reasoning !== undefined) {
378
+ reasoningSupported = true
379
+ reasoningSource = configured?.reasoningEfforts !== undefined ? 'config' : 'adapter'
380
+ } else if (discovered?.reasoning !== undefined) {
381
+ reasoningSupported = discovered.reasoning === true
382
+ reasoningSource = 'provider'
383
+ } else if (catalogEntry?.reasoning !== undefined) {
384
+ reasoningSupported = catalogEntry.reasoning === true
385
+ reasoningSource = 'catalog'
386
+ useCatalog()
387
+ }
388
+
389
+ // A manual ladder is the requested fix for relays that publish no effort
390
+ // levels at all. An explicit "no reasoning" declaration is the other half of
391
+ // the same switch.
392
+ if (override?.efforts !== undefined) {
393
+ const manualReasoning = detachReasoning({
394
+ efforts: override.efforts,
395
+ defaultEffort: override.defaultEffort,
396
+ })
397
+ if (manualReasoning !== undefined) {
398
+ reasoning = manualReasoning
399
+ reasoningSupported = true
400
+ reasoningSource = 'manual'
401
+ }
402
+ } else if (override?.reasoningSupported === false) {
403
+ reasoning = undefined
404
+ reasoningSupported = false
405
+ reasoningSource = 'manual'
406
+ }
407
+
408
+ /* ----------------------------------------------------------- max tokens */
409
+
410
+ let defaultMaxTokens
411
+ const configuredMaxTokens = positiveInteger(configured?.maxTokens)
412
+ const adapterMaxTokens = positiveInteger(adapter?.defaultMaxTokens)
413
+ if (configuredMaxTokens !== undefined) defaultMaxTokens = configuredMaxTokens
414
+ else if (!profileExists && adapterMaxTokens !== undefined) defaultMaxTokens = adapterMaxTokens
415
+ else if (profileExists && configured === undefined && adapterMaxTokens !== undefined) {
416
+ defaultMaxTokens = adapterMaxTokens
417
+ }
418
+ if (defaultMaxTokens === undefined && discovered?.maxTokens !== undefined) {
419
+ defaultMaxTokens = positiveInteger(discovered.maxTokens)
420
+ }
421
+ if (defaultMaxTokens === undefined && catalogEntry?.maxTokens !== undefined) {
422
+ defaultMaxTokens = positiveInteger(catalogEntry.maxTokens)
423
+ }
424
+
425
+ /* --------------------------------------------------------- output side */
426
+
427
+ const outputModalities =
428
+ modalityList(discovered?.outputModalities) ??
429
+ modalityList(catalogEntry?.output) ??
430
+ (kind === 'image'
431
+ ? ['image']
432
+ : kind === 'video'
433
+ ? ['video']
434
+ : kind === 'audio'
435
+ ? ['audio']
436
+ : undefined)
437
+
438
+ return {
439
+ ...base,
440
+ kind,
441
+ ...(inputModalities === undefined ? {} : { inputModalities }),
442
+ ...(outputModalities === undefined ? {} : { outputModalities }),
443
+ ...(contextWindow === undefined ? {} : { contextWindow }),
444
+ ...(defaultMaxTokens === undefined ? {} : { defaultMaxTokens }),
445
+ ...(reasoning === undefined ? {} : { reasoning }),
446
+ ...(reasoningSupported === undefined ? {} : { reasoningSupported }),
447
+ sources: {
448
+ kind: sources.kind,
449
+ modalities: modalitySource,
450
+ context: contextSource,
451
+ reasoning: reasoningSource,
452
+ },
453
+ ...(correctionApplied
454
+ ? {
455
+ correction: {
456
+ id: correction.id,
457
+ code: correction.code,
458
+ note: correction.note,
459
+ ...(correction.source === undefined ? {} : { source: correction.source }),
460
+ },
461
+ }
462
+ : {}),
463
+ ...(catalogUsed
464
+ ? {
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 }),
471
+ },
472
+ }
473
+ : {}),
474
+ ...(context.infoError === undefined ? {} : { infoError: context.infoError }),
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Decide whether the composer may attach images to one relay model.
480
+ *
481
+ * This is the request-side counterpart of the modality tag: it runs the same
482
+ * evidence merge (without the adapter info, which is what the caller is about
483
+ * to override) and reports whether the displayed capability says "image
484
+ * input". Only relay profiles are eligible: a built-in adapter already
485
+ * declares its own modalities and must not be overridden from a catalog row.
486
+ *
487
+ * @param providerId - provider route the model belongs to.
488
+ * @param modelId - exact model id.
489
+ * @param context - `{ profile?, configured?, discovered? }`; `discovered` is
490
+ * either one provider's id->entry Map or one entry.
491
+ * @returns `{ allowed, record?, reason }`; pure and synchronous.
492
+ */
493
+ export function imageInputDecision(providerId, modelId, context = {}) {
494
+ const profile = context.profile
495
+ if (profile === undefined) return { allowed: false, reason: 'no-profile' }
496
+ const configured = context.configured ?? mapGet(profile.models, modelId)
497
+ const discoveredIndex =
498
+ context.discovered instanceof Map
499
+ ? context.discovered
500
+ : context.discovered === undefined
501
+ ? new Map()
502
+ : indexDiscovered(new Map([[context.discovered.id, context.discovered]]))
503
+ const discovered = findDiscovered(discoveredIndex, modelId)
504
+ const record = mergeCapabilities(
505
+ { id: modelId, name: modelId },
506
+ { profile, configured, discovered, override: context.override },
507
+ )
508
+ const modalities = Array.isArray(record.inputModalities) ? record.inputModalities : []
509
+ if (record.kind !== 'chat') return { allowed: false, record, reason: 'non-chat' }
510
+ if (modalities.includes('image')) return { allowed: true, record, reason: 'image' }
511
+ return {
512
+ allowed: false,
513
+ record,
514
+ reason: modalities.length === 0 ? 'unknown' : 'text-only',
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Describe one catalog model through the registry plus every evidence layer.
520
+ *
521
+ * A resolver failure is reported as `infoError` on the model instead of
522
+ * removing it: the visibility switch must stay usable even when an adapter
523
+ * cannot answer, and the catalog layers can still fill the capability fields.
524
+ * @param llm - the Host `ctx.llm` registry (or a test double).
525
+ * @param providerId - provider route the model belongs to.
526
+ * @param model - catalog entry `{ id, name?, description?, inputModalities? }`.
527
+ * @param context - `{ profile?, discovered?, override? }` enrichment for this provider.
528
+ * @returns a detached model record.
529
+ */
530
+ export async function describeModel(llm, providerId, model, context = {}) {
531
+ const base = {
532
+ id: model.id,
533
+ name: nonEmptyString(model.name) ?? model.id,
534
+ ...(nonEmptyString(model.description) === undefined ? {} : { description: model.description }),
535
+ }
536
+ let info
537
+ let infoError
538
+ try {
539
+ info = await llm.resolveModelInfo(providerId, model.id)
540
+ } catch (error) {
541
+ infoError = messageOf(error)
542
+ }
543
+ const adapter = info === undefined ? undefined : describeResolvedModel(base, info)
544
+ const profile = context.profile
545
+ const discovered = findDiscovered(
546
+ context.discoveredIndex ?? indexDiscovered(context.discovered),
547
+ model.id,
548
+ )
549
+ return mergeCapabilities(base, {
550
+ adapter,
551
+ infoError,
552
+ profile,
553
+ configured: mapGet(profile?.models, model.id),
554
+ discovered,
555
+ override: context.override,
556
+ })
557
+ }
558
+
559
+ /** Resolve the enrichment snapshot for one sweep. */
560
+ async function resolveEnrichment(options, fresh) {
561
+ if (typeof options.enrichment !== 'function') return options.enrichment
562
+ try {
563
+ return await options.enrichment({ fresh })
564
+ } catch (_error) {
565
+ // Capability enrichment is best-effort; the adapter registry still works.
566
+ return undefined
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Build the whole capability catalog.
572
+ *
573
+ * Shape (wire-compatible with `session.modelCatalog` plus the extra fields):
574
+ *
575
+ * ```json
576
+ * {
577
+ * "groups": [{ "id": "route", "name": "Route", "models": [{ "id", "name",
578
+ * "kind", "contextWindow", "inputModalities", "reasoning", "sources" }] }],
579
+ * "failures": [{ "id", "name", "message" }],
580
+ * "providers": [{ "id", "status", "models", "capabilities" }],
581
+ * "catalog": { "generatedAt", "piAiVersion", "models" },
582
+ * "generatedAt": 1700000000000
583
+ * }
584
+ * ```
585
+ *
586
+ * Provider failures are isolated exactly like the shipped catalog. Empty
587
+ * provider groups are dropped so the page never shows a header with no rows.
588
+ * @param llm - the Host `ctx.llm` registry (or a test double).
589
+ * @param options - `{ enrichment?, fresh? }`.
590
+ * @returns the catalog value.
591
+ */
592
+ export async function describeCatalog(llm, options = {}) {
593
+ const generatedAt = Date.now()
594
+ const groups = []
595
+ const failures = []
596
+ const enrichment = await resolveEnrichment(options, options.fresh === true)
597
+ const profiles = enrichment?.profiles instanceof Map ? enrichment.profiles : new Map()
598
+ const discovered = enrichment?.discovered instanceof Map ? enrichment.discovered : new Map()
599
+ const overrides = options.overrides
600
+
601
+ let providers
602
+ try {
603
+ providers = llm.listProviders()
604
+ } catch (error) {
605
+ return {
606
+ groups,
607
+ failures: [{ id: '*', name: 'providers', message: messageOf(error) }],
608
+ providers: [],
609
+ catalog: catalogMeta(),
610
+ generatedAt,
611
+ }
612
+ }
613
+
614
+ // Providers are probed concurrently but reported in catalog order; a slow
615
+ // adapter must not delay the page behind every other provider.
616
+ const results = await Promise.all(
617
+ (Array.isArray(providers) ? providers : []).map(async (provider) => {
618
+ if (provider === null || typeof provider !== 'object') return null
619
+ const id = nonEmptyString(provider.id)
620
+ if (id === undefined) return null
621
+ const name = nonEmptyString(provider.name) ?? id
622
+ try {
623
+ const models = await llm.listModels(id)
624
+ const listed = Array.isArray(models) ? models : []
625
+ const profile = profiles.get(id)
626
+ const discoveredIndex = indexDiscovered(discovered.get(id))
627
+ const entries = await Promise.all(
628
+ listed
629
+ .filter((model) => model !== null && typeof model === 'object' && nonEmptyString(model.id) !== undefined)
630
+ .map((model) =>
631
+ describeModel(llm, id, model, {
632
+ profile,
633
+ discoveredIndex,
634
+ override: typeof overrides?.get === 'function' ? overrides.get(id, model.id) : undefined,
635
+ }),
636
+ ),
637
+ )
638
+ return entries.length === 0 ? null : { kind: 'group', group: { id, name, models: entries } }
639
+ } catch (error) {
640
+ return { kind: 'failure', failure: { id, name, message: messageOf(error) } }
641
+ }
642
+ }),
643
+ )
644
+
645
+ for (const result of results) {
646
+ if (result === null) continue
647
+ if (result.kind === 'group') groups.push(result.group)
648
+ else failures.push(result.failure)
649
+ }
650
+
651
+ return {
652
+ groups,
653
+ failures,
654
+ providers: Array.isArray(enrichment?.diagnostics) ? enrichment.diagnostics : [],
655
+ catalog: catalogMeta(),
656
+ generatedAt,
657
+ }
658
+ }
659
+
660
+ /** Non-secret provenance of the bundled snapshot, for the page footer. */
661
+ function catalogMeta() {
662
+ return {
663
+ generatedAt: CATALOG_GENERATED_AT ?? null,
664
+ piAiVersion: CATALOG_PI_AI_VERSION ?? null,
665
+ models: Object.keys(MODEL_CATALOG).length,
666
+ // Lets a newer browser half detect an older Host half after an upgrade.
667
+ // `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'],
670
+ }
671
+ }
672
+
673
+ /**
674
+ * A cached reader around {@link describeCatalog}.
675
+ *
676
+ * One shared promise backs concurrent reads, so a page open cannot fan out into
677
+ * one registry sweep per widget. `read({ fresh: true })` bypasses the TTL but
678
+ * still joins an in-flight sweep.
679
+ * @param llm - the Host `ctx.llm` registry.
680
+ * @param options - `{ ttlMs?, now?, enrichment?, context? }`; `now` is
681
+ * injectable for tests and `context()` supplies `{ profiles, discovered }`
682
+ * synchronously for the request-side image decision.
683
+ * @returns `{ read, invalidate, imageInputAllowed }`.
684
+ */
685
+ export function createModelInfoService(llm, options = {}) {
686
+ const ttlMs =
687
+ Number.isFinite(options.ttlMs) && options.ttlMs >= 0 ? options.ttlMs : DEFAULT_TTL_MS
688
+ const now = typeof options.now === 'function' ? options.now : Date.now
689
+ const contextSource =
690
+ typeof options.context === 'function' ? options.context : () => options.context
691
+
692
+ let cached = null
693
+ let cachedAt = -Infinity
694
+ let inflight = null
695
+ let generation = 0
696
+ // Request-side image decisions share the same evidence merge as the page.
697
+ // They are cached briefly because `read()` resolves every model once and each
698
+ // resolution must not re-read the settings document.
699
+ const imageCache = new Map()
700
+ let imageCacheAt = 0
701
+ const IMAGE_CACHE_MS = 30_000
702
+
703
+ const read = (request = {}) => {
704
+ const fresh = request !== null && typeof request === 'object' && request.fresh === true
705
+ const stamp = now()
706
+ if (!fresh && cached !== null && stamp - cachedAt < ttlMs) {
707
+ return Promise.resolve(cached)
708
+ }
709
+ if (inflight !== null) return inflight
710
+ const startedAt = generation
711
+ const run = describeCatalog(llm, {
712
+ enrichment: options.enrichment,
713
+ overrides: options.overrides,
714
+ fresh,
715
+ })
716
+ .then((value) => {
717
+ // A sweep that was invalidated while running must not repopulate the
718
+ // cache with facts the invalidation was meant to discard.
719
+ if (generation === startedAt) {
720
+ cached = value
721
+ cachedAt = now()
722
+ // The sweep resolved every model; decisions taken before the probe
723
+ // finished may have missed relay-advertised capabilities.
724
+ imageCache.clear()
725
+ imageCacheAt = now()
726
+ }
727
+ return value
728
+ })
729
+ .finally(() => {
730
+ // `invalidate()` may have started a newer sweep while this one ran;
731
+ // only clear the slot this promise owns.
732
+ if (inflight === run) inflight = null
733
+ })
734
+ inflight = run
735
+ return run
736
+ }
737
+
738
+ /**
739
+ * Whether image input should be admitted for one relay model.
740
+ *
741
+ * The answer comes from the same evidence the page renders, so the composer
742
+ * and the tag cannot disagree. Only configured relays are eligible; built-in
743
+ * adapters already declare their own modalities.
744
+ * @param providerId - provider route id.
745
+ * @param modelId - exact model id.
746
+ * @returns true when the model is displayed as accepting images.
747
+ */
748
+ const imageInputAllowed = (providerId, modelId) => {
749
+ const stamp = now()
750
+ if (stamp - imageCacheAt > IMAGE_CACHE_MS) {
751
+ imageCache.clear()
752
+ imageCacheAt = stamp
753
+ }
754
+ const key = String(providerId) + '\u0000' + String(modelId)
755
+ const known = imageCache.get(key)
756
+ if (known !== undefined) return known
757
+ let allowed = false
758
+ try {
759
+ const source = contextSource() ?? {}
760
+ const profiles = source.profiles instanceof Map ? source.profiles : undefined
761
+ const discovered = source.discovered instanceof Map ? source.discovered : undefined
762
+ const overrides = source.overrides
763
+ allowed = imageInputDecision(providerId, modelId, {
764
+ profile: profiles?.get(providerId),
765
+ discovered: discovered?.get(providerId),
766
+ override: typeof overrides?.get === 'function' ? overrides.get(providerId, modelId) : undefined,
767
+ }).allowed
768
+ } catch (_error) {
769
+ // A capability read must never break the request path.
770
+ allowed = false
771
+ }
772
+ imageCache.set(key, allowed)
773
+ return allowed
774
+ }
775
+
776
+ const invalidate = () => {
777
+ generation += 1
778
+ cached = null
779
+ cachedAt = -Infinity
780
+ inflight = null
781
+ imageCache.clear()
782
+ imageCacheAt = now()
783
+ }
784
+
785
+ return { read, invalidate, imageInputAllowed }
786
+ }