@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.
- package/README.md +50 -30
- package/client-src/catalog-store.mjs +464 -0
- package/client-src/checker-view.mjs +221 -0
- package/client-src/composer-filter.mjs +153 -0
- package/client-src/constants.mjs +20 -0
- package/client-src/css.mjs +248 -0
- package/client-src/dict.mjs +427 -0
- package/client-src/format.mjs +154 -0
- package/client-src/hooks.mjs +25 -0
- package/client-src/index.mjs +101 -0
- package/client-src/override-editor.mjs +286 -0
- package/client-src/pure.mjs +136 -0
- package/client-src/settings-section.mjs +582 -0
- package/client-src/tags.mjs +197 -0
- package/client-src/usage-view.mjs +112 -0
- package/client.js +3229 -2258
- package/index.js +178 -0
- package/lib/capability-catalog.js +5 -69
- package/lib/capability-corrections.js +1 -1
- package/lib/check-catalog.js +575 -0
- package/lib/export-csv.js +69 -0
- package/lib/learned-catalog.js +294 -0
- package/lib/model-id.js +50 -0
- package/lib/model-info.js +94 -52
- package/lib/model-overrides.js +1 -1
- package/lib/usage-ledger.js +574 -0
- package/package.json +65 -64
- package/lib/model-catalog.js +0 -670
|
@@ -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
|
+
}
|
package/lib/model-id.js
ADDED
|
@@ -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. `
|
|
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 `
|
|
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
|
-
|
|
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?,
|
|
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
|
|
242
|
+
const learned = context.learned ?? lookupLearned(base.id)
|
|
230
243
|
const sources = {}
|
|
231
|
-
let
|
|
244
|
+
let learnedUsed = false
|
|
245
|
+
let learnedRecord = null
|
|
232
246
|
|
|
233
|
-
const
|
|
234
|
-
|
|
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 (
|
|
245
|
-
kind =
|
|
246
|
-
sources.kind = '
|
|
247
|
-
|
|
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 &&
|
|
296
|
-
inputModalities = modalityList(
|
|
310
|
+
if (inputModalities === undefined && learned?.input !== undefined) {
|
|
311
|
+
inputModalities = modalityList(learned.input)
|
|
297
312
|
if (inputModalities !== undefined) {
|
|
298
|
-
modalitySource = '
|
|
299
|
-
|
|
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 === '
|
|
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 &&
|
|
360
|
-
contextWindow = positiveInteger(
|
|
374
|
+
if (contextWindow === undefined && learned?.contextWindow !== undefined) {
|
|
375
|
+
contextWindow = positiveInteger(learned.contextWindow)
|
|
361
376
|
if (contextWindow !== undefined) {
|
|
362
|
-
contextSource = '
|
|
363
|
-
|
|
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 (
|
|
384
|
-
reasoningSupported =
|
|
385
|
-
|
|
386
|
-
|
|
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 &&
|
|
422
|
-
defaultMaxTokens = positiveInteger(
|
|
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(
|
|
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
|
-
...(
|
|
484
|
+
...(learnedUsed && learnedRecord !== null
|
|
464
485
|
? {
|
|
465
|
-
|
|
466
|
-
id:
|
|
467
|
-
...(
|
|
468
|
-
? {}
|
|
469
|
-
: {
|
|
470
|
-
...(
|
|
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
|
-
* "
|
|
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
|
-
|
|
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
|
-
|
|
687
|
+
learned: catalogMeta(),
|
|
656
688
|
generatedAt,
|
|
657
689
|
}
|
|
658
690
|
}
|
|
659
691
|
|
|
660
|
-
/** Non-secret provenance of the
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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.
|
package/lib/model-overrides.js
CHANGED
|
@@ -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
|