@tzyc/dsh-model-visibility 0.0.0-stage → 1.9.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.
- package/LICENSE +21 -0
- package/README.md +367 -2
- package/client.js +2258 -0
- package/cordis.patch.yml +7 -0
- package/docs/capability-tags.png +0 -0
- package/docs/icon-180.png +0 -0
- package/docs/settings-page.png +0 -0
- package/icon.svg +9 -0
- package/index.js +476 -0
- package/lib/capability-catalog.js +137 -0
- package/lib/capability-corrections.js +65 -0
- package/lib/image-input-gate.js +113 -0
- package/lib/model-catalog.js +670 -0
- package/lib/model-info.js +786 -0
- package/lib/model-overrides.js +262 -0
- package/lib/provider-probe.js +596 -0
- package/lib/provider-write.js +175 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -6
- package/screenshots.json +4 -0
|
@@ -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
|
+
}
|