@tzyc/dsh-model-visibility 1.10.3 → 1.12.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 +64 -15
- package/client-src/catalog-store.mjs +90 -16
- package/client-src/checker-view.mjs +158 -65
- package/client-src/css.mjs +153 -221
- package/client-src/dict.mjs +119 -81
- package/client-src/format.mjs +9 -2
- package/client-src/pure.mjs +24 -7
- package/client-src/settings-section.mjs +242 -137
- package/client-src/usage-view.mjs +69 -65
- package/client.js +864 -594
- package/index.js +4 -2
- package/lib/check-catalog.js +868 -108
- package/lib/export-csv.js +48 -14
- package/lib/learned-catalog.js +10 -2
- package/lib/model-info.js +4 -2
- package/lib/model-overrides.js +9 -4
- package/lib/provider-probe.js +138 -22
- package/lib/provider-write.js +31 -7
- package/package.json +2 -2
package/lib/check-catalog.js
CHANGED
|
@@ -30,52 +30,164 @@ import { sanitizeLearned } from './learned-catalog.js'
|
|
|
30
30
|
import { MODEL_KINDS } from './capability-catalog.js'
|
|
31
31
|
import { modelIdVariants } from './model-id.js'
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Public catalog endpoints, fetched without credentials.
|
|
35
|
+
*
|
|
36
|
+
* models.dev publishes three shapes and the plugin used to read only the first
|
|
37
|
+
* one through a parser that also silently ignored the other two:
|
|
38
|
+
*
|
|
39
|
+
* api.json `{ <provider>: { models: { <id>: … } } }` (provider-qualified)
|
|
40
|
+
* models.json `{ <id>: { modalities, limit, … } }` (provider-free)
|
|
41
|
+
* catalog.json `{ providers: {…}, models: {…} }` (both, merged)
|
|
42
|
+
*
|
|
43
|
+
* `api.json` is ~6 MB and only lists models a *specific provider* serves, so a
|
|
44
|
+
* relay model such as `glm-5.3` may be absent from it while being present in
|
|
45
|
+
* `models.json`. Reading the provider-free endpoint first is both cheaper and
|
|
46
|
+
* a better match for what a relay actually serves.
|
|
47
|
+
*
|
|
48
|
+
* Hugging Face publishes the open-weight side of the same story: something
|
|
49
|
+
* self-hosted through vLLM / LM Studio / Ollama is usually NOT in models.dev
|
|
50
|
+
* at all, but it always has a Hub card that names its pipeline and library.
|
|
51
|
+
*/
|
|
34
52
|
export const PUBLIC_SOURCES = {
|
|
35
|
-
|
|
53
|
+
/** Provider-free model metadata; the best match for a relay's model ids. */
|
|
54
|
+
modelsDevModels: 'https://models.dev/models.json',
|
|
55
|
+
/** Provider-qualified catalog; used as a second pass for anything missing. */
|
|
56
|
+
modelsDevApi: 'https://models.dev/api.json',
|
|
36
57
|
openRouter: 'https://openrouter.ai/api/v1/models',
|
|
37
58
|
}
|
|
38
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Hugging Face Hub model listing, tried in order until one answers.
|
|
62
|
+
*
|
|
63
|
+
* `huggingface.co` is frequently unreachable from mainland China, so the two
|
|
64
|
+
* well-known read-only mirrors come next. All three serve the same
|
|
65
|
+
* `/api/models` contract; only the host differs. The list is ordered by
|
|
66
|
+
* reliability for this audience, not by preference for the canonical host.
|
|
67
|
+
*/
|
|
68
|
+
export const HUGGING_FACE_HOSTS = [
|
|
69
|
+
'https://huggingface.co',
|
|
70
|
+
'https://hf-mirror.com',
|
|
71
|
+
'https://hugging-face.cn',
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
/** Build one Hub listing URL for a host. */
|
|
75
|
+
export function huggingFaceUrl(host) {
|
|
76
|
+
return (
|
|
77
|
+
`${String(host).replace(/\/+$/, '')}/api/models?filter=text-generation&limit=1000` +
|
|
78
|
+
'&expand[]=pipeline_tag&expand[]=library_name&expand[]=tags'
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Refuse a public catalog larger than this.
|
|
84
|
+
*
|
|
85
|
+
* The models.dev payload is ~6 MB today and the relay probe already caps its
|
|
86
|
+
* own responses at 4 MB; buffering an unbounded body here would let a redirect
|
|
87
|
+
* target or a bad day on the CDN exhaust the Host heap.
|
|
88
|
+
*/
|
|
89
|
+
export const PUBLIC_BODY_LIMIT = 16 * 1024 * 1024
|
|
90
|
+
|
|
39
91
|
function nonEmpty(value) {
|
|
40
92
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
41
93
|
}
|
|
42
94
|
|
|
95
|
+
/** Read one string list, keeping only strings (duplicates preserved by caller). */
|
|
96
|
+
function stringList(value) {
|
|
97
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item !== '') : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convert one models.dev model record into a fact entry.
|
|
102
|
+
*
|
|
103
|
+
* Shared by every models.dev shape: the per-model record is identical whether
|
|
104
|
+
* it arrived through `api.json`, `models.json` or `catalog.json`.
|
|
105
|
+
* @param id - the key the record was stored under.
|
|
106
|
+
* @param raw - the record.
|
|
107
|
+
* @returns a detached fact, or undefined when nothing useful is present.
|
|
108
|
+
*/
|
|
109
|
+
export function readModelsDevRecord(id, raw) {
|
|
110
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
111
|
+
const recordId = nonEmpty(raw.id) ?? id
|
|
112
|
+
if (recordId === undefined) return undefined
|
|
113
|
+
const modalities = raw.modalities !== null && typeof raw.modalities === 'object' ? raw.modalities : undefined
|
|
114
|
+
const limit = raw.limit !== null && typeof raw.limit === 'object' ? raw.limit : undefined
|
|
115
|
+
const fact = {
|
|
116
|
+
id: recordId,
|
|
117
|
+
...(nonEmpty(raw.name) !== undefined ? { name: raw.name.trim() } : {}),
|
|
118
|
+
...(stringList(modalities?.input) !== undefined ? { input: stringList(modalities.input) } : {}),
|
|
119
|
+
...(stringList(modalities?.output) !== undefined ? { output: stringList(modalities.output) } : {}),
|
|
120
|
+
// `attachment` is models.dev's "accepts file/image attachments" flag; it is
|
|
121
|
+
// the closest analogue to an input modality list when modalities are absent.
|
|
122
|
+
...(typeof raw.attachment === 'boolean' && stringList(modalities?.input) === undefined
|
|
123
|
+
? { attachment: raw.attachment }
|
|
124
|
+
: {}),
|
|
125
|
+
...(typeof raw.reasoning === 'boolean' ? { reasoning: raw.reasoning } : {}),
|
|
126
|
+
...(Number.isSafeInteger(limit?.context) && limit.context > 0 ? { contextWindow: limit.context } : {}),
|
|
127
|
+
...(Number.isSafeInteger(limit?.output) && limit.output > 0 ? { maxTokens: limit.output } : {}),
|
|
128
|
+
}
|
|
129
|
+
// `id` alone is not a fact: require at least one capability field.
|
|
130
|
+
return Object.keys(fact).length > 1 ? fact : undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
43
133
|
/**
|
|
44
|
-
* Parse models.dev
|
|
134
|
+
* Parse any models.dev endpoint into a fact map.
|
|
45
135
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
136
|
+
* Accepts all three published shapes, plus a bare `{ <id>: record }` map. The
|
|
137
|
+
* old parser only understood `api.json`, so pointing it at `models.json` or
|
|
138
|
+
* `catalog.json` silently produced zero facts — which is what made the verify
|
|
139
|
+
* sweep look like "the public catalogs returned no model data".
|
|
48
140
|
* @param body - parsed JSON of the endpoint.
|
|
49
|
-
* @returns Map of
|
|
141
|
+
* @returns Map of variant id → fact `{ id, name?, input?, output?, reasoning?,
|
|
142
|
+
* attachment?, contextWindow?, maxTokens? }`.
|
|
50
143
|
*/
|
|
51
144
|
export function readModelsDev(body) {
|
|
52
145
|
const out = new Map()
|
|
53
|
-
if (body === null || typeof body !== 'object') return out
|
|
146
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) return out
|
|
54
147
|
const push = (id, fact) => {
|
|
55
|
-
if (
|
|
148
|
+
if (fact === undefined) return
|
|
56
149
|
for (const variant of modelIdVariants(id)) {
|
|
57
150
|
if (!out.has(variant)) out.set(variant, fact)
|
|
58
151
|
}
|
|
59
152
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (models === null || typeof models !== 'object')
|
|
63
|
-
for (const [
|
|
64
|
-
|
|
65
|
-
const modalities = raw.modalities !== null && typeof raw.modalities === 'object' ? raw.modalities : undefined
|
|
66
|
-
const limit = raw.limit !== null && typeof raw.limit === 'object' ? raw.limit : undefined
|
|
67
|
-
const fact = {
|
|
68
|
-
id,
|
|
69
|
-
...(nonEmpty(raw.name) !== undefined ? { name: raw.name.trim() } : {}),
|
|
70
|
-
...(Array.isArray(modalities?.input) ? { input: modalities.input.filter((item) => typeof item === 'string') } : {}),
|
|
71
|
-
...(Array.isArray(modalities?.output) ? { output: modalities.output.filter((item) => typeof item === 'string') } : {}),
|
|
72
|
-
...(typeof raw.reasoning === 'boolean' ? { reasoning: raw.reasoning } : {}),
|
|
73
|
-
...(Number.isSafeInteger(limit?.context) && limit.context > 0 ? { contextWindow: limit.context } : {}),
|
|
74
|
-
...(Number.isSafeInteger(limit?.output) && limit.output > 0 ? { maxTokens: limit.output } : {}),
|
|
75
|
-
}
|
|
76
|
-
if (Object.keys(fact).length > 1) push(id, fact)
|
|
153
|
+
/** Collect a `{ <id>: record }` dict. */
|
|
154
|
+
const collectRecords = (models) => {
|
|
155
|
+
if (models === null || typeof models !== 'object' || Array.isArray(models)) return
|
|
156
|
+
for (const [key, raw] of Object.entries(models)) {
|
|
157
|
+
push(key, readModelsDevRecord(key, raw))
|
|
77
158
|
}
|
|
78
159
|
}
|
|
160
|
+
|
|
161
|
+
// catalog.json: { providers, models }
|
|
162
|
+
if (body.models !== null && typeof body.models === 'object' && !Array.isArray(body.models)) {
|
|
163
|
+
collectRecords(body.models)
|
|
164
|
+
}
|
|
165
|
+
if (body.providers !== null && typeof body.providers === 'object' && !Array.isArray(body.providers)) {
|
|
166
|
+
for (const provider of Object.values(body.providers)) {
|
|
167
|
+
if (provider === null || typeof provider !== 'object') continue
|
|
168
|
+
collectRecords(provider.models)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// api.json: { <providerId>: { models } } — and it also carries `models` at the
|
|
173
|
+
// top level for some deployments, which the branch above already consumed.
|
|
174
|
+
for (const [key, value] of Object.entries(body)) {
|
|
175
|
+
if (key === 'providers' || key === 'models') continue
|
|
176
|
+
if (value === null || typeof value !== 'object') continue
|
|
177
|
+
const models = value.models
|
|
178
|
+
if (models === null || typeof models !== 'object') continue
|
|
179
|
+
collectRecords(models)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// models.json: { <id>: record } — a bare record map with no wrapper.
|
|
183
|
+
for (const [key, value] of Object.entries(body)) {
|
|
184
|
+
if (key === 'providers' || key === 'models') continue
|
|
185
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
|
|
186
|
+
if (value.models !== undefined) continue
|
|
187
|
+
// Only treat it as a record when it looks like one.
|
|
188
|
+
if (value.limit === undefined && value.modalities === undefined && value.reasoning === undefined) continue
|
|
189
|
+
push(key, readModelsDevRecord(key, value))
|
|
190
|
+
}
|
|
79
191
|
return out
|
|
80
192
|
}
|
|
81
193
|
|
|
@@ -97,7 +209,70 @@ export function readOpenRouter(body) {
|
|
|
97
209
|
return out
|
|
98
210
|
}
|
|
99
211
|
|
|
100
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* Map a Hugging Face `pipeline_tag` to a model kind.
|
|
214
|
+
* @param tag - the Hub pipeline tag, e.g. `text-generation`.
|
|
215
|
+
* @returns a MODEL_KINDS value, or undefined when the task is unknown.
|
|
216
|
+
*/
|
|
217
|
+
export function kindOfPipelineTag(tag) {
|
|
218
|
+
if (typeof tag !== 'string' || tag === '') return undefined
|
|
219
|
+
const id = tag.toLowerCase()
|
|
220
|
+
if (id === 'text-generation' || id === 'text2text-generation' || id === 'conversational') return 'chat'
|
|
221
|
+
if (id.startsWith('image-') || id === 'text-to-image' || id === 'unconditional-image-generation') return 'image'
|
|
222
|
+
if (id.startsWith('video-') || id === 'text-to-video') return 'video'
|
|
223
|
+
if (id === 'feature-extraction' || id === 'sentence-similarity') return 'embedding'
|
|
224
|
+
if (id === 'text-classification' || id === 'zero-shot-classification') return 'classifier'
|
|
225
|
+
if (id.startsWith('audio') || id === 'text-to-speech' || id === 'automatic-speech-recognition') return 'audio'
|
|
226
|
+
if (id === 'translation') return 'translation'
|
|
227
|
+
if (id === 'reranking' || id === 'text-ranking') return 'rerank'
|
|
228
|
+
return undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Read Hugging Face's model list into a fact map.
|
|
233
|
+
*
|
|
234
|
+
* The Hub tells us a model's task and library, not its context window or
|
|
235
|
+
* reasoning ladder. It is still the only source for weights that a relay
|
|
236
|
+
* serves under the checkpoint's own name (`Qwen/Qwen3.5-9B`, `unsloth/…`),
|
|
237
|
+
* which no hosted catalog lists.
|
|
238
|
+
*
|
|
239
|
+
* Only a small, high-signal tag set is translated: Hub tags are free-form and
|
|
240
|
+
* an unknown tag must not turn into a capability claim.
|
|
241
|
+
* @param body - parsed JSON array of Hub model entries.
|
|
242
|
+
* @returns Map of variant id → fact `{ id, name?, kind?, reasoning?, toolCall? }`.
|
|
243
|
+
*/
|
|
244
|
+
export function readHuggingFace(body) {
|
|
245
|
+
const out = new Map()
|
|
246
|
+
const listed = Array.isArray(body) ? body : Array.isArray(body?.models) ? body.models : []
|
|
247
|
+
for (const raw of listed) {
|
|
248
|
+
if (raw === null || typeof raw !== 'object') continue
|
|
249
|
+
const id = nonEmpty(raw.id) ?? nonEmpty(raw.modelId)
|
|
250
|
+
if (id === undefined) continue
|
|
251
|
+
const tags = stringList(raw.tags) ?? []
|
|
252
|
+
const lowerTags = tags.map((tag) => tag.toLowerCase())
|
|
253
|
+
const kind = kindOfPipelineTag(raw.pipeline_tag)
|
|
254
|
+
const fact = {
|
|
255
|
+
id,
|
|
256
|
+
...(nonEmpty(raw.name) !== undefined ? { name: raw.name.trim() } : {}),
|
|
257
|
+
...(kind === undefined ? {} : { kind }),
|
|
258
|
+
// A Hub tag is a declaration by the model publisher, so it counts.
|
|
259
|
+
...(lowerTags.includes('reasoning') ? { reasoning: true } : {}),
|
|
260
|
+
...(lowerTags.includes('tool-use') || lowerTags.includes('function-calling')
|
|
261
|
+
? { toolCall: true }
|
|
262
|
+
: {}),
|
|
263
|
+
...(nonEmpty(raw.library_name) !== undefined ? { library: raw.library_name.trim() } : {}),
|
|
264
|
+
}
|
|
265
|
+
if (Object.keys(fact).length <= 1) continue
|
|
266
|
+
for (const variant of modelIdVariants(id)) {
|
|
267
|
+
if (!out.has(variant)) out.set(variant, fact)
|
|
268
|
+
}
|
|
269
|
+
// A checkpoint is often served under its bare name: `Qwen/Qwen3.5-9B`
|
|
270
|
+
// is reached as `Qwen3.5-9B`, which `modelIdVariants` already covers.
|
|
271
|
+
}
|
|
272
|
+
return out
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Merge public sources; earlier sources win, later ones fill only gaps. */
|
|
101
276
|
export function mergePublicSources(...maps) {
|
|
102
277
|
const merged = new Map()
|
|
103
278
|
for (const map of maps) {
|
|
@@ -118,9 +293,13 @@ export function mergePublicSources(...maps) {
|
|
|
118
293
|
}
|
|
119
294
|
|
|
120
295
|
/**
|
|
121
|
-
* Extract a JSON
|
|
296
|
+
* Extract a JSON value from a possibly fenced or chatty checker reply.
|
|
297
|
+
*
|
|
298
|
+
* Both `{…}` and `[…]` are accepted: the contract asks for an array, and a
|
|
299
|
+
* model that answers `[]` ("I know none of these") used to fall through to
|
|
300
|
+
* "contained no JSON" because only braces were searched.
|
|
122
301
|
* @param text - raw assistant text.
|
|
123
|
-
* @returns the parsed object, or undefined when nothing parseable remains.
|
|
302
|
+
* @returns the parsed object/array, or undefined when nothing parseable remains.
|
|
124
303
|
*/
|
|
125
304
|
export function extractJsonBlock(text) {
|
|
126
305
|
if (typeof text !== 'string') return undefined
|
|
@@ -129,32 +308,89 @@ export function extractJsonBlock(text) {
|
|
|
129
308
|
if (fenced !== null) candidates.push(fenced[1])
|
|
130
309
|
candidates.push(text)
|
|
131
310
|
for (const candidate of candidates) {
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
311
|
+
// Prefer the outermost balanced span of whichever bracket opens first.
|
|
312
|
+
const spans = [
|
|
313
|
+
{ open: '{', close: '}' },
|
|
314
|
+
{ open: '[', close: ']' },
|
|
315
|
+
]
|
|
316
|
+
.map(({ open, close }) => {
|
|
317
|
+
const start = candidate.indexOf(open)
|
|
318
|
+
const end = candidate.lastIndexOf(close)
|
|
319
|
+
return start === -1 || end <= start ? undefined : { start, end }
|
|
320
|
+
})
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
.sort((left, right) => left.start - right.start)
|
|
323
|
+
for (const span of spans) {
|
|
324
|
+
try {
|
|
325
|
+
const parsed = JSON.parse(candidate.slice(span.start, span.end + 1))
|
|
326
|
+
if (parsed !== null && typeof parsed === 'object') return parsed
|
|
327
|
+
} catch (_error) {
|
|
328
|
+
// Try the next span, then the next candidate.
|
|
329
|
+
}
|
|
140
330
|
}
|
|
141
331
|
}
|
|
142
332
|
return undefined
|
|
143
333
|
}
|
|
144
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Translate one public catalog row into the fact vocabulary the store uses.
|
|
337
|
+
*
|
|
338
|
+
* Shared by {@link parseCheckerAnswer} and {@link publicOnlyFacts} so a fact
|
|
339
|
+
* means the same thing whichever path produced it.
|
|
340
|
+
* @param publicFact - one merged public catalog entry.
|
|
341
|
+
* @returns a partial fact; empty when the row declared nothing usable.
|
|
342
|
+
*/
|
|
343
|
+
export function publicFactToCapability(publicFact) {
|
|
344
|
+
if (publicFact === null || publicFact === undefined || typeof publicFact !== 'object') return {}
|
|
345
|
+
const fact = {}
|
|
346
|
+
const type = nonEmpty(publicFact.kind) ?? nonEmpty(publicFact.type)
|
|
347
|
+
if (type !== undefined && MODEL_KINDS.includes(type)) fact.type = type
|
|
348
|
+
// `input` is the models.dev / OpenRouter spelling; `inputModalities` is the
|
|
349
|
+
// relay dialect spelling.
|
|
350
|
+
const input = normalizeModalities(publicFact.input ?? publicFact.inputModalities)
|
|
351
|
+
if (input !== undefined) fact.input = input
|
|
352
|
+
const output = normalizeModalities(publicFact.output ?? publicFact.outputModalities)
|
|
353
|
+
if (output !== undefined) fact.output = output
|
|
354
|
+
if (Number.isSafeInteger(publicFact.contextWindow) && publicFact.contextWindow > 0) {
|
|
355
|
+
fact.contextWindow = publicFact.contextWindow
|
|
356
|
+
}
|
|
357
|
+
if (Number.isSafeInteger(publicFact.maxTokens) && publicFact.maxTokens > 0) {
|
|
358
|
+
fact.maxTokens = publicFact.maxTokens
|
|
359
|
+
}
|
|
360
|
+
if (typeof publicFact.reasoning === 'boolean') fact.reasoning = publicFact.reasoning
|
|
361
|
+
return fact
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Look one model up in the merged public catalog by every id variant. */
|
|
365
|
+
export function lookupPublicFact(publicFacts, id) {
|
|
366
|
+
if (publicFacts === undefined || publicFacts === null) return undefined
|
|
367
|
+
if (!(publicFacts instanceof Map)) return publicFacts
|
|
368
|
+
for (const variant of modelIdVariants(id)) {
|
|
369
|
+
const found = publicFacts.get(variant)
|
|
370
|
+
if (found !== undefined) return found
|
|
371
|
+
}
|
|
372
|
+
return undefined
|
|
373
|
+
}
|
|
374
|
+
|
|
145
375
|
/**
|
|
146
376
|
* Validate one checker answer against the identifiers of its batch.
|
|
147
377
|
*
|
|
148
378
|
* Only plain facts survive: modalities are filtered through the host's
|
|
149
379
|
* modality list, kinds through `MODEL_KINDS`, numbers guarded, and every
|
|
150
380
|
* entry stays attached to an id the batch actually asked about.
|
|
381
|
+
*
|
|
382
|
+
* Public catalog facts are *merged in*, not just named: the discovered public
|
|
383
|
+
* row provides a base and the checker overrides it field by field. Before this
|
|
384
|
+
* the catalogs were downloaded, parsed and then thrown away except for an
|
|
385
|
+
* `origins` label — which is why the sweep looked like "the public catalogs
|
|
386
|
+
* returned no model data" even though the endpoints answered fine.
|
|
151
387
|
* @param parsed - the checker's parsed JSON (object or `{models:[...]}`).
|
|
152
388
|
* @param ids - the batch ids verbatim.
|
|
153
|
-
* @param
|
|
154
|
-
* @returns array of `{ id, fact, origins }`; `fact` is null when the
|
|
155
|
-
*
|
|
389
|
+
* @param publicFacts - the merged public catalog (variant id -> fact).
|
|
390
|
+
* @returns array of `{ id, fact, origins }`; `fact` is null when neither the
|
|
391
|
+
* public catalogs nor the checker declared anything for that model.
|
|
156
392
|
*/
|
|
157
|
-
export function parseCheckerAnswer(parsed, ids,
|
|
393
|
+
export function parseCheckerAnswer(parsed, ids, publicFacts) {
|
|
158
394
|
const source = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.models) ? parsed.models : Object.values(parsed ?? {})
|
|
159
395
|
const wanted = new Set(ids.map((id) => id.toLowerCase()))
|
|
160
396
|
const out = []
|
|
@@ -162,20 +398,43 @@ export function parseCheckerAnswer(parsed, ids, publicFact) {
|
|
|
162
398
|
if (raw === null || typeof raw !== 'object') continue
|
|
163
399
|
const id = nonEmpty(raw.id)
|
|
164
400
|
if (id === undefined || !wanted.has(id.toLowerCase())) continue
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
401
|
+
const publicFact = lookupPublicFact(publicFacts, id)
|
|
402
|
+
// Public row first, checker answers on top: the checker saw this specific
|
|
403
|
+
// route, the catalog only knows the model family.
|
|
404
|
+
const fromPublic = publicFactToCapability(publicFact)
|
|
405
|
+
const fact = { id, ...fromPublic }
|
|
406
|
+
/** Fields the checker itself supplied (as opposed to the catalog). */
|
|
407
|
+
let checkerContributed = false
|
|
408
|
+
if (MODEL_KINDS.includes(raw.kind)) {
|
|
409
|
+
fact.type = raw.kind
|
|
410
|
+
checkerContributed = true
|
|
411
|
+
}
|
|
412
|
+
if (typeof raw.type === 'string' && MODEL_KINDS.includes(raw.type)) {
|
|
413
|
+
fact.type = raw.type
|
|
414
|
+
checkerContributed = true
|
|
169
415
|
}
|
|
170
416
|
const input = normalizeModalities(raw.input ?? raw.inputModalities)
|
|
171
|
-
if (input !== undefined)
|
|
417
|
+
if (input !== undefined) {
|
|
418
|
+
fact.input = input
|
|
419
|
+
checkerContributed = true
|
|
420
|
+
}
|
|
172
421
|
const output = normalizeModalities(raw.output ?? raw.outputModalities)
|
|
173
|
-
if (output !== undefined)
|
|
422
|
+
if (output !== undefined) {
|
|
423
|
+
fact.output = output
|
|
424
|
+
checkerContributed = true
|
|
425
|
+
}
|
|
174
426
|
if (Number.isSafeInteger(raw.contextWindow) && raw.contextWindow > 0) {
|
|
175
427
|
fact.contextWindow = raw.contextWindow
|
|
428
|
+
checkerContributed = true
|
|
429
|
+
}
|
|
430
|
+
if (Number.isSafeInteger(raw.maxTokens) && raw.maxTokens > 0) {
|
|
431
|
+
fact.maxTokens = raw.maxTokens
|
|
432
|
+
checkerContributed = true
|
|
433
|
+
}
|
|
434
|
+
if (typeof raw.reasoning === 'boolean') {
|
|
435
|
+
fact.reasoning = raw.reasoning
|
|
436
|
+
checkerContributed = true
|
|
176
437
|
}
|
|
177
|
-
if (Number.isSafeInteger(raw.maxTokens) && raw.maxTokens > 0) fact.maxTokens = raw.maxTokens
|
|
178
|
-
if (typeof raw.reasoning === 'boolean') fact.reasoning = raw.reasoning
|
|
179
438
|
if (Array.isArray(raw.reasoningEfforts)) {
|
|
180
439
|
const efforts = raw.reasoningEfforts
|
|
181
440
|
.map((effort) => (typeof effort === 'string' ? effort : effort?.id))
|
|
@@ -186,10 +445,14 @@ export function parseCheckerAnswer(parsed, ids, publicFact) {
|
|
|
186
445
|
if (defaultEffort !== undefined && efforts.some((effort) => effort.id === defaultEffort)) {
|
|
187
446
|
fact.defaultEffort = defaultEffort
|
|
188
447
|
}
|
|
448
|
+
if (efforts.length > 0) checkerContributed = true
|
|
189
449
|
}
|
|
450
|
+
// `origins` names the layers that actually contributed a field. Before this
|
|
451
|
+
// "checker" was pushed unconditionally, so a model the checker skipped but a
|
|
452
|
+
// catalog knew looked like a checker answer.
|
|
190
453
|
const origins = []
|
|
191
|
-
if (
|
|
192
|
-
origins.push('checker')
|
|
454
|
+
if (Object.keys(fromPublic).length > 0) origins.push('public')
|
|
455
|
+
if (checkerContributed) origins.push('checker')
|
|
193
456
|
const declared =
|
|
194
457
|
fact.type !== undefined || fact.input !== undefined || fact.output !== undefined ||
|
|
195
458
|
fact.contextWindow !== undefined || fact.maxTokens !== undefined ||
|
|
@@ -200,6 +463,30 @@ export function parseCheckerAnswer(parsed, ids, publicFact) {
|
|
|
200
463
|
return out
|
|
201
464
|
}
|
|
202
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Build facts for models the checker never answered.
|
|
468
|
+
*
|
|
469
|
+
* A public catalog hit is a real fact even when the checker skipped the model
|
|
470
|
+
* (it said "unknown", or the batch failed). Emitting these separately keeps
|
|
471
|
+
* `origins` honest: they are `['public']`, not `['public','checker']`.
|
|
472
|
+
* @param ids - batch ids.
|
|
473
|
+
* @param answered - ids the checker actually produced a verdict for.
|
|
474
|
+
* @param publicFacts - the merged public catalog.
|
|
475
|
+
* @returns array of `{ id, fact, origins }` for the un-answered ones.
|
|
476
|
+
*/
|
|
477
|
+
export function publicOnlyFacts(ids, answered, publicFacts) {
|
|
478
|
+
if (publicFacts === undefined || publicFacts === null) return []
|
|
479
|
+
const done = new Set((answered ?? []).map((id) => String(id).toLowerCase()))
|
|
480
|
+
const out = []
|
|
481
|
+
for (const id of ids) {
|
|
482
|
+
if (done.has(String(id).toLowerCase())) continue
|
|
483
|
+
const fields = publicFactToCapability(lookupPublicFact(publicFacts, id))
|
|
484
|
+
if (Object.keys(fields).length === 0) continue
|
|
485
|
+
out.push({ id, fact: { id, ...fields }, origins: ['public'] })
|
|
486
|
+
}
|
|
487
|
+
return out
|
|
488
|
+
}
|
|
489
|
+
|
|
203
490
|
/** Renderable progress state between sweeps. */
|
|
204
491
|
function idleState() {
|
|
205
492
|
return {
|
|
@@ -212,20 +499,94 @@ function idleState() {
|
|
|
212
499
|
tokenUsage: [0, 0, 0, 0],
|
|
213
500
|
batches: [],
|
|
214
501
|
errors: [],
|
|
502
|
+
/** Per-source outcome of the public catalog pass. */
|
|
503
|
+
sources: [],
|
|
504
|
+
/** How many models were resolved from a public catalog alone. */
|
|
505
|
+
publicOnly: 0,
|
|
506
|
+
concurrency: undefined,
|
|
215
507
|
}
|
|
216
508
|
}
|
|
217
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Turn a transport failure into something a user can act on.
|
|
512
|
+
*
|
|
513
|
+
* The common real-world causes here are a dead host, a captive portal, and a
|
|
514
|
+
* TLS-intercepting proxy whose root CA is not trusted — the last one shows up
|
|
515
|
+
* as `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` and reads like a plugin bug unless it
|
|
516
|
+
* is named.
|
|
517
|
+
* @param error - whatever the fetch rejected with.
|
|
518
|
+
* @returns one human sentence.
|
|
519
|
+
*/
|
|
520
|
+
export function transportMessageOf(error) {
|
|
521
|
+
const code = error?.cause?.code ?? error?.code
|
|
522
|
+
if (typeof code === 'string') {
|
|
523
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'DNS 解析失败(域名不存在或无法解析)'
|
|
524
|
+
if (code === 'ECONNREFUSED') return '连接被拒绝'
|
|
525
|
+
if (code === 'ECONNRESET') return '连接被重置'
|
|
526
|
+
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') return '连接超时'
|
|
527
|
+
if (code === 'CERT_HAS_EXPIRED') return 'TLS 证书已过期'
|
|
528
|
+
if (code === 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' || code === 'SELF_SIGNED_CERT_IN_CHAIN') {
|
|
529
|
+
return 'TLS 证书链不受信任(可能被本地代理拦截;把该代理的根证书装进系统信任库)'
|
|
530
|
+
}
|
|
531
|
+
if (code.startsWith('ERR_TLS') || code.startsWith('CERT_')) return `TLS 校验失败(${code})`
|
|
532
|
+
}
|
|
533
|
+
const message = error?.message ?? String(error)
|
|
534
|
+
if (/abort|timeout/i.test(message)) return '请求超时'
|
|
535
|
+
return message
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Module-level alias so the fetch wrapper can use it before its own scope. */
|
|
539
|
+
function transportMessageOfShim(error) {
|
|
540
|
+
return transportMessageOf(error)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Output-token budget for one checker completion.
|
|
545
|
+
*
|
|
546
|
+
* A batch asks for six short JSON objects, which needs well under 1 000 output
|
|
547
|
+
* tokens. The budget is deliberately generous because *reasoning models spend
|
|
548
|
+
* output tokens before they emit a single text delta*: with a tight cap the
|
|
549
|
+
* whole allowance went to the thinking channel, the reply was empty or cut
|
|
550
|
+
* mid-object, and every batch failed with "the reply contained no JSON".
|
|
551
|
+
*/
|
|
552
|
+
export const CHECKER_MAX_TOKENS = 8192
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Pinned reasoning effort on the checker call when the caller does not choose
|
|
556
|
+
* one. Verification is a lookup, not a reasoning task; the lowest level keeps
|
|
557
|
+
* the thinking channel from crowding out the JSON answer on models that
|
|
558
|
+
* otherwise think for thousands of tokens.
|
|
559
|
+
*/
|
|
560
|
+
export const CHECKER_REASONING_EFFORT = 'low'
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Upper bound on simultaneous checker calls.
|
|
564
|
+
*
|
|
565
|
+
* Each worker holds one HTTP request and one batch of ids. Beyond this a relay
|
|
566
|
+
* starts answering with rate limits, which the sweep reports as batch failures
|
|
567
|
+
* and which costs more than the extra parallelism saves. The user picks the
|
|
568
|
+
* actual number per run from the panel.
|
|
569
|
+
*/
|
|
570
|
+
export const MAX_CONCURRENCY = 8
|
|
571
|
+
|
|
572
|
+
/** Default batch size and concurrency when the caller says nothing. */
|
|
573
|
+
export const DEFAULT_BATCH_SIZE = 6
|
|
574
|
+
export const DEFAULT_CONCURRENCY = 2
|
|
575
|
+
|
|
218
576
|
/** Chunk-type consumer over one auxiliary completion, minimal and text-only. */
|
|
219
577
|
class TextAssembler {
|
|
220
578
|
constructor() {
|
|
221
579
|
this.parts = []
|
|
222
580
|
this.usage = undefined
|
|
223
581
|
this.finish = undefined
|
|
582
|
+
/** True when the model produced reasoning text (a thinking checker). */
|
|
583
|
+
this.sawReasoning = false
|
|
224
584
|
}
|
|
225
585
|
|
|
226
586
|
push(chunk) {
|
|
227
587
|
if (chunk === null || typeof chunk !== 'object') return
|
|
228
588
|
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') this.parts.push(chunk.text)
|
|
589
|
+
else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') this.sawReasoning = true
|
|
229
590
|
else if (chunk.type === 'usage' && chunk.usage !== null && typeof chunk.usage === 'object') this.usage = chunk.usage
|
|
230
591
|
else if (chunk.type === 'finish') this.finish = chunk.reason
|
|
231
592
|
}
|
|
@@ -252,13 +613,43 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
252
613
|
const fetchImpl = typeof options.fetch === 'function' ? options.fetch : fetch
|
|
253
614
|
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
254
615
|
const defaultTimeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 180_000
|
|
255
|
-
const batchSize =
|
|
256
|
-
|
|
616
|
+
const batchSize =
|
|
617
|
+
Number.isFinite(options.batchSize) && options.batchSize > 0
|
|
618
|
+
? options.batchSize
|
|
619
|
+
: DEFAULT_BATCH_SIZE
|
|
620
|
+
const concurrencyLimit = Math.max(
|
|
621
|
+
1,
|
|
622
|
+
Math.min(
|
|
623
|
+
MAX_CONCURRENCY,
|
|
624
|
+
Number.isFinite(options.concurrency) ? Math.trunc(options.concurrency) : DEFAULT_CONCURRENCY,
|
|
625
|
+
),
|
|
626
|
+
)
|
|
257
627
|
const defaultWriteBack = options.writeBack !== false
|
|
258
628
|
const onApplied = typeof options.onApplied === 'function' ? options.onApplied : () => {}
|
|
629
|
+
/** Output budget per checker call; 0/undefined keeps the default. */
|
|
630
|
+
const defaultCheckerMaxTokens =
|
|
631
|
+
Number.isFinite(options.checkerMaxTokens) && options.checkerMaxTokens > 0
|
|
632
|
+
? options.checkerMaxTokens
|
|
633
|
+
: CHECKER_MAX_TOKENS
|
|
634
|
+
/** Pinned reasoning effort; `null` disables pinning, `undefined` keeps the default. */
|
|
635
|
+
const defaultCheckerEffort =
|
|
636
|
+
options.checkerReasoningEffort === null
|
|
637
|
+
? undefined
|
|
638
|
+
: options.checkerReasoningEffort === undefined
|
|
639
|
+
? CHECKER_REASONING_EFFORT
|
|
640
|
+
: options.checkerReasoningEffort
|
|
641
|
+
/** Hugging Face hosts tried in order; overridable for a private mirror. */
|
|
642
|
+
const huggingFaceHosts =
|
|
643
|
+
Array.isArray(options.huggingFaceHosts) && options.huggingFaceHosts.length > 0
|
|
644
|
+
? options.huggingFaceHosts.filter((host) => typeof host === 'string' && host !== '')
|
|
645
|
+
: HUGGING_FACE_HOSTS
|
|
259
646
|
|
|
260
647
|
let state = idleState()
|
|
261
648
|
let abort = null
|
|
649
|
+
/** The in-flight sweep promise, so a restart can join it instead of racing. */
|
|
650
|
+
let running = null
|
|
651
|
+
/** Per-source outcome of the current/last public catalog pass. */
|
|
652
|
+
let publicReport = []
|
|
262
653
|
|
|
263
654
|
const warn = (message, error) => {
|
|
264
655
|
if (typeof logger.warn === 'function') {
|
|
@@ -273,6 +664,7 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
273
664
|
tokenUsage: [...state.tokenUsage],
|
|
274
665
|
batches: state.batches.map((batch) => ({ ...batch })),
|
|
275
666
|
errors: [...state.errors],
|
|
667
|
+
sources: state.sources.map((entry) => ({ ...entry })),
|
|
276
668
|
})
|
|
277
669
|
|
|
278
670
|
const noteUsage = (entry) => {
|
|
@@ -298,26 +690,60 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
298
690
|
})
|
|
299
691
|
}
|
|
300
692
|
|
|
301
|
-
/**
|
|
302
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Fetch one public source; a failure is an empty map, never a throw.
|
|
695
|
+
*
|
|
696
|
+
* Every outcome is recorded in `publicReport` so the panel can say *which*
|
|
697
|
+
* source failed and why. Silence here was the reason "the catalogs return no
|
|
698
|
+
* model data" was indistinguishable from "the catalogs were never read".
|
|
699
|
+
*/
|
|
700
|
+
const readPublicSource = async (name, url, parser, headers) => {
|
|
701
|
+
const entry = { name, url, status: 'pending', models: 0 }
|
|
702
|
+
publicReport.push(entry)
|
|
303
703
|
const controller = new AbortController()
|
|
304
|
-
const timer = setTimeout(() => controller.abort(),
|
|
704
|
+
const timer = setTimeout(() => controller.abort(), 20_000)
|
|
305
705
|
try {
|
|
306
706
|
const response = await fetchImpl(url, {
|
|
307
707
|
headers: { accept: 'application/json', ...(headers ?? {}) },
|
|
308
708
|
signal: controller.signal,
|
|
309
709
|
redirect: 'error',
|
|
310
710
|
})
|
|
311
|
-
if (!response.ok)
|
|
711
|
+
if (!response.ok) {
|
|
712
|
+
entry.status = 'http'
|
|
713
|
+
entry.message = `HTTP ${response.status}`
|
|
714
|
+
return new Map()
|
|
715
|
+
}
|
|
716
|
+
// Read with a ceiling: `response.text()` would buffer whatever the CDN
|
|
717
|
+
// decides to send.
|
|
718
|
+
const declared = Number(response.headers?.get?.('content-length'))
|
|
719
|
+
if (Number.isFinite(declared) && declared > PUBLIC_BODY_LIMIT) {
|
|
720
|
+
entry.status = 'too-large'
|
|
721
|
+
entry.message = `${declared} bytes`
|
|
722
|
+
warn(`model-visibility: public catalog ${url} is too large (${declared} bytes)`)
|
|
723
|
+
return new Map()
|
|
724
|
+
}
|
|
312
725
|
const text = await response.text()
|
|
726
|
+
if (text.length > PUBLIC_BODY_LIMIT) {
|
|
727
|
+
entry.status = 'too-large'
|
|
728
|
+
entry.message = `${text.length} bytes`
|
|
729
|
+
warn(`model-visibility: public catalog ${url} is too large (${text.length} bytes)`)
|
|
730
|
+
return new Map()
|
|
731
|
+
}
|
|
313
732
|
let body
|
|
314
733
|
try {
|
|
315
734
|
body = JSON.parse(text)
|
|
316
735
|
} catch (_error) {
|
|
736
|
+
entry.status = 'bad-json'
|
|
317
737
|
return new Map()
|
|
318
738
|
}
|
|
319
|
-
|
|
739
|
+
const facts = parser(body)
|
|
740
|
+
entry.models = facts.size
|
|
741
|
+
entry.bytes = text.length
|
|
742
|
+
entry.status = facts.size > 0 ? 'ok' : 'empty'
|
|
743
|
+
return facts
|
|
320
744
|
} catch (error) {
|
|
745
|
+
entry.status = 'failed'
|
|
746
|
+
entry.message = transportMessageOfShim(error)
|
|
321
747
|
warn(`model-visibility: public catalog ${url} unavailable`, error)
|
|
322
748
|
return new Map()
|
|
323
749
|
} finally {
|
|
@@ -325,12 +751,55 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
325
751
|
}
|
|
326
752
|
}
|
|
327
753
|
|
|
754
|
+
/**
|
|
755
|
+
* Read every public catalog.
|
|
756
|
+
*
|
|
757
|
+
* Order matters: the provider-free models.dev list and OpenRouter are the
|
|
758
|
+
* cheapest good matches for a relay's ids, so they are merged first and the
|
|
759
|
+
* provider-qualified `api.json` only fills gaps. A failure in one source
|
|
760
|
+
* never removes the others. Hugging Face is tried across its mirrors, so a
|
|
761
|
+
* blocked canonical host does not remove open-weight coverage.
|
|
762
|
+
*/
|
|
328
763
|
const publicIndex = async () => {
|
|
329
|
-
const
|
|
330
|
-
readPublicSource(PUBLIC_SOURCES.
|
|
331
|
-
readPublicSource(PUBLIC_SOURCES.openRouter, readOpenRouter),
|
|
764
|
+
const [modelsDevFree, openRouter, huggingFace, modelsDevAll] = await Promise.all([
|
|
765
|
+
readPublicSource('models.dev/models.json', PUBLIC_SOURCES.modelsDevModels, readModelsDev),
|
|
766
|
+
readPublicSource('openrouter', PUBLIC_SOURCES.openRouter, readOpenRouter),
|
|
767
|
+
readHuggingFaceAcrossMirrors(),
|
|
768
|
+
readPublicSource('models.dev/api.json', PUBLIC_SOURCES.modelsDevApi, readModelsDev),
|
|
332
769
|
])
|
|
333
|
-
return mergePublicSources(
|
|
770
|
+
return mergePublicSources(modelsDevFree, openRouter, huggingFace, modelsDevAll)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Try each Hugging Face host until one returns a usable listing.
|
|
775
|
+
*
|
|
776
|
+
* Only the successful host is reported; the others are recorded as skipped
|
|
777
|
+
* so the panel can show "canonical host blocked, mirror used" instead of a
|
|
778
|
+
* mute failure.
|
|
779
|
+
*/
|
|
780
|
+
const readHuggingFaceAcrossMirrors = async () => {
|
|
781
|
+
const tried = []
|
|
782
|
+
for (const host of huggingFaceHosts) {
|
|
783
|
+
const before = publicReport.length
|
|
784
|
+
const facts = await readPublicSource(`huggingface ${host.replace(/^https?:\/\//, '')}`, huggingFaceUrl(host), readHuggingFace)
|
|
785
|
+
const entry = publicReport[before]
|
|
786
|
+
tried.push({ host, status: entry?.status ?? 'unknown' })
|
|
787
|
+
if (facts.size > 0) {
|
|
788
|
+
// Collapse the failed attempts into the one that worked, so the source
|
|
789
|
+
// list stays short and the reason the canonical host was skipped is
|
|
790
|
+
// still visible in `message`.
|
|
791
|
+
if (tried.length > 1) {
|
|
792
|
+
const skipped = tried.slice(0, -1).map((item) => item.host.replace(/^https?:\/\//, ''))
|
|
793
|
+
if (entry !== undefined) {
|
|
794
|
+
entry.message = [entry.message, `已回退(${skipped.join(' / ')} 不可用)`]
|
|
795
|
+
.filter(Boolean)
|
|
796
|
+
.join(';')
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return facts
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return new Map()
|
|
334
803
|
}
|
|
335
804
|
|
|
336
805
|
/** One auxiliary completion for a batch; answers text or fails loudly. */
|
|
@@ -338,11 +807,20 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
338
807
|
if (typeof llm?.prepareCall !== 'function') {
|
|
339
808
|
throw new Error('llm service unavailable for the verify sweep')
|
|
340
809
|
}
|
|
341
|
-
const maxTokens =
|
|
810
|
+
const maxTokens = defaultCheckerMaxTokens
|
|
811
|
+
const effort = defaultCheckerEffort
|
|
342
812
|
const call = await llm.prepareCall(
|
|
343
|
-
{
|
|
813
|
+
{
|
|
814
|
+
provider: checker.provider,
|
|
815
|
+
model: checker.model,
|
|
816
|
+
maxTokens,
|
|
817
|
+
...(effort === undefined || effort === null ? {} : { reasoningEffort: effort }),
|
|
818
|
+
},
|
|
344
819
|
signal,
|
|
345
|
-
|
|
820
|
+
// Pin the chosen effort over whatever the adapter would default to; a
|
|
821
|
+
// thinking checker that spends its whole budget thinking never answers.
|
|
822
|
+
(controls) =>
|
|
823
|
+
effort === undefined || effort === null ? controls : { ...controls, reasoningEffort: effort },
|
|
346
824
|
)
|
|
347
825
|
const options2 = {
|
|
348
826
|
...call.config,
|
|
@@ -365,33 +843,206 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
365
843
|
if (finish?.kind === 'error' || finish?.kind === 'aborted') {
|
|
366
844
|
throw new Error(finish?.failure?.message ?? 'verify request failed')
|
|
367
845
|
}
|
|
846
|
+
// A truncated completion is a failed completion. Without this check the
|
|
847
|
+
// JSON parse simply reports "no JSON object", which hides the real cause
|
|
848
|
+
// (a too-small output budget) from the user.
|
|
849
|
+
if (finish?.kind === 'max-tokens') {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`the checker reply was truncated at ${maxTokens} output tokens` +
|
|
852
|
+
(assembler.sawReasoning ? ' (the model spent them on reasoning)' : ''),
|
|
853
|
+
)
|
|
854
|
+
}
|
|
368
855
|
return { text: assembler.text, usage: assembler.usage }
|
|
369
856
|
}
|
|
370
857
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
858
|
+
/**
|
|
859
|
+
* The checker contract, stated as a schema rather than as prose.
|
|
860
|
+
*
|
|
861
|
+
* The previous prompt described the keys in a sentence and then hoped for
|
|
862
|
+
* JSON. Models that like to chat, fence their answers, or narrate their
|
|
863
|
+
* lookup would produce something unparseable, and the batch died. Stating
|
|
864
|
+
* one exact output shape — and naming the forbidden forms — is what makes a
|
|
865
|
+
* strict validator possible at all.
|
|
866
|
+
*/
|
|
867
|
+
const checkerSystemPrompt = [
|
|
868
|
+
'You are a capability database for LLM API models. You answer with JSON only.',
|
|
869
|
+
'',
|
|
870
|
+
'OUTPUT CONTRACT (violating any rule fails the whole answer):',
|
|
871
|
+
'- Return exactly one JSON array. No prose, no markdown fence, no comments.',
|
|
872
|
+
'- Emit one object per requested model, in the same order.',
|
|
873
|
+
'- Every object has exactly the keys listed below. Use null when unknown.',
|
|
874
|
+
'- Never invent a number. Unknown is null, never a guess.',
|
|
875
|
+
'',
|
|
876
|
+
'OBJECT SCHEMA',
|
|
877
|
+
'{',
|
|
878
|
+
' "id": string, // the id exactly as given to you',
|
|
879
|
+
' "kind": "chat" | "image" | "video" | "embedding" | "rerank" | "audio" | "classifier" | "translation" | null,',
|
|
880
|
+
' "input": array of "text" | "image" | "audio" | "video" | "file" | null,',
|
|
881
|
+
' "output": array of "text" | "image" | "audio" | "video" | null,',
|
|
882
|
+
' "contextWindow": integer or null, // input context window in tokens',
|
|
883
|
+
' "maxTokens": integer or null, // max output tokens',
|
|
884
|
+
' "reasoning": boolean or null, // does it expose reasoning effort?',
|
|
885
|
+
' "reasoningEfforts": array of "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | null,',
|
|
886
|
+
' "defaultEffort": one of those levels or null',
|
|
887
|
+
'}',
|
|
888
|
+
'',
|
|
889
|
+
'Do not add fields. Do not drop the array wrapper. Do not explain your reasoning.',
|
|
890
|
+
].join('\n')
|
|
375
891
|
|
|
892
|
+
/**
|
|
893
|
+
* The user turn: the ids to describe, one per line, with the same rules
|
|
894
|
+
* restated compactly (long system prompts get ignored more often than a
|
|
895
|
+
* short reminder next to the data).
|
|
896
|
+
*/
|
|
376
897
|
const batchPrompt = (batch) =>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
898
|
+
[
|
|
899
|
+
'Return {"...":null} equivalents for each of these model ids.',
|
|
900
|
+
'Reply with only the JSON array described in the system prompt.',
|
|
901
|
+
'',
|
|
902
|
+
batch.map((item) => `- ${item}`).join('\n'),
|
|
903
|
+
].join('\n')
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Strict format validation of one checker reply.
|
|
907
|
+
*
|
|
908
|
+
* Distinct from {@link parseCheckerAnswer}, which *sanitizes*: this gate
|
|
909
|
+
* decides whether the reply honoured the contract at all, so a mangled
|
|
910
|
+
* answer gets one targeted retry instead of silently becoming a batch of
|
|
911
|
+
* "unknown" facts (the old behaviour, which is why a broken checker looked
|
|
912
|
+
* like "the models have no capabilities").
|
|
913
|
+
* @param parsed - the JSON extracted from the reply.
|
|
914
|
+
* @param ids - the ids that were asked for.
|
|
915
|
+
* @returns `{ ok, retryable, missing, extra, reasons, empty }`. `ok` means the
|
|
916
|
+
* reply honours the contract; `retryable` says whether another attempt could
|
|
917
|
+
* plausibly fix it (an unknown id in the reply is harmless and must not be
|
|
918
|
+
* retried, a missing one may be).
|
|
919
|
+
*/
|
|
920
|
+
const validateReplyShape = (parsed, ids) => {
|
|
921
|
+
const reasons = []
|
|
922
|
+
const list = Array.isArray(parsed)
|
|
923
|
+
? parsed
|
|
924
|
+
: Array.isArray(parsed?.models)
|
|
925
|
+
? parsed.models
|
|
926
|
+
: Array.isArray(parsed?.results)
|
|
927
|
+
? parsed.results
|
|
928
|
+
: undefined
|
|
929
|
+
if (list === undefined) {
|
|
930
|
+
return { ok: false, retryable: true, missing: ids, extra: [], reasons: ['expected a JSON array at the top level'] }
|
|
931
|
+
}
|
|
932
|
+
// `{models:[…]}` and `{results:[…]}` are accepted wrappers: models add them
|
|
933
|
+
// unprompted often enough that rejecting them would waste a retry on a
|
|
934
|
+
// reply that is otherwise perfectly usable.
|
|
935
|
+
const seen = new Set()
|
|
936
|
+
const extra = []
|
|
937
|
+
const wanted = new Set(ids.map((id) => id.toLowerCase()))
|
|
938
|
+
let malformed = false
|
|
939
|
+
for (const entry of list) {
|
|
940
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
941
|
+
reasons.push('an array element is not an object')
|
|
942
|
+
malformed = true
|
|
943
|
+
continue
|
|
944
|
+
}
|
|
945
|
+
const id = nonEmpty(entry.id ?? entry.model)
|
|
946
|
+
if (id === undefined) {
|
|
947
|
+
reasons.push('an object has no "id"')
|
|
948
|
+
malformed = true
|
|
949
|
+
continue
|
|
950
|
+
}
|
|
951
|
+
const key = id.toLowerCase()
|
|
952
|
+
if (!wanted.has(key)) {
|
|
953
|
+
// Harmless: filter it out, do not spend a second call on it.
|
|
954
|
+
extra.push(id)
|
|
955
|
+
continue
|
|
956
|
+
}
|
|
957
|
+
seen.add(key)
|
|
958
|
+
}
|
|
959
|
+
const missing = ids.filter((id) => !seen.has(id.toLowerCase()))
|
|
960
|
+
if (extra.length > 0) reasons.push(`${extra.length} id(s) were not requested (ignored)`)
|
|
961
|
+
// An EMPTY array is not a violation: it is a model saying "I know none of
|
|
962
|
+
// these". Treating it as a format error meant the batch died before the
|
|
963
|
+
// public catalogs could fill in, which is the opposite of the intent.
|
|
964
|
+
const empty = list.length === 0
|
|
965
|
+
if (missing.length > 0 && !empty) reasons.push(`${missing.length} requested id(s) are missing`)
|
|
966
|
+
// Only a structurally broken element or a missing id is worth retrying;
|
|
967
|
+
// extra ids and an empty array are legitimate answers.
|
|
968
|
+
const retryable = malformed || (missing.length > 0 && !empty)
|
|
969
|
+
return { ok: reasons.length === 0, retryable, missing, extra, reasons, empty }
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Parse one reply, and on failure retry with the batch halved.
|
|
974
|
+
*
|
|
975
|
+
* A truncated or malformed answer used to lose the whole batch. Splitting it
|
|
976
|
+
* costs one extra call but salvages the models the checker can still answer,
|
|
977
|
+
* and eventually isolates the single model it chokes on. When the halves fail
|
|
978
|
+
* too the *original* error is rethrown: a downstream "no JSON" from a retry
|
|
979
|
+
* would otherwise hide the real cause (truncation, auth, upstream error).
|
|
980
|
+
* @param ids - ids of this batch, in prompt order.
|
|
981
|
+
* @returns parsed entries; never throws for a per-batch failure.
|
|
982
|
+
*/
|
|
983
|
+
const runBatchResilient = async (ids, publicFacts, checker, signal) => {
|
|
984
|
+
let firstError
|
|
985
|
+
try {
|
|
986
|
+
return await runBatch(ids, ids, publicFacts, checker, signal)
|
|
987
|
+
} catch (error) {
|
|
988
|
+
firstError = error
|
|
989
|
+
if (signal.aborted || ids.length <= 1) throw error
|
|
990
|
+
}
|
|
991
|
+
const half = Math.ceil(ids.length / 2)
|
|
992
|
+
try {
|
|
993
|
+
const left = await runBatchResilient(ids.slice(0, half), publicFacts, checker, signal)
|
|
994
|
+
const right = await runBatchResilient(ids.slice(half), publicFacts, checker, signal)
|
|
995
|
+
return [...left, ...right]
|
|
996
|
+
} catch (error) {
|
|
997
|
+
// Report the clearest cause: the original failure, unless a half failed
|
|
998
|
+
// for a different and more specific reason.
|
|
999
|
+
throw firstError ?? error
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
383
1002
|
|
|
384
1003
|
/** Execute one batch; returns the sanitized results (or throws). */
|
|
385
1004
|
const runBatch = async (batchText, ids, publicFacts, checker, signal) => {
|
|
386
|
-
const
|
|
387
|
-
checker,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1005
|
+
const ask = async () => {
|
|
1006
|
+
const reply = await askChecker(checker, checkerSystemPrompt, batchPrompt(batchText), signal)
|
|
1007
|
+
const parsed = extractJsonBlock(reply.text)
|
|
1008
|
+
if (parsed === undefined) {
|
|
1009
|
+
const seen = reply.text.trim()
|
|
1010
|
+
return {
|
|
1011
|
+
parsed: undefined,
|
|
1012
|
+
problem:
|
|
1013
|
+
seen === ''
|
|
1014
|
+
? 'the checker returned an empty reply'
|
|
1015
|
+
: `the checker reply contained no JSON (${seen.slice(0, 80)}…)`,
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const shape = validateReplyShape(parsed, ids)
|
|
1019
|
+
return { parsed, shape, problem: shape.ok ? undefined : shape.reasons.join('; ') }
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
let attempt = await ask()
|
|
1023
|
+
// One targeted retry, and only when another attempt could plausibly help:
|
|
1024
|
+
// unusable text or a broken/missing entry. A legitimate answer — even an
|
|
1025
|
+
// empty array — is never retried, because a second call costs tokens and
|
|
1026
|
+
// cannot improve "I know none of these".
|
|
1027
|
+
const retryable = attempt.problem !== undefined && attempt.shape?.retryable !== false && !signal.aborted
|
|
1028
|
+
if (retryable) {
|
|
1029
|
+
attempt = await ask()
|
|
1030
|
+
}
|
|
1031
|
+
if (attempt.parsed === undefined) throw new Error(attempt.problem)
|
|
1032
|
+
if (attempt.shape !== undefined && !attempt.shape.ok) {
|
|
1033
|
+
// A reply that parses but omits ids is not a hard failure: the public
|
|
1034
|
+
// catalogs may still describe those models. Return what was answered and
|
|
1035
|
+
// let `publicOnlyFacts` fill the rest; only a reply with no usable entry
|
|
1036
|
+
// AND no public coverage should surface as a batch error.
|
|
1037
|
+
const usable = parseCheckerAnswer(attempt.parsed, ids, publicFacts)
|
|
1038
|
+
const answered = usable.filter((entry) => entry.fact !== null).length
|
|
1039
|
+
const fillable = publicOnlyFacts(ids, [], publicFacts).length
|
|
1040
|
+
if (answered === 0 && fillable === 0) {
|
|
1041
|
+
throw new Error(`checker format violation: ${attempt.problem}`)
|
|
1042
|
+
}
|
|
1043
|
+
return usable
|
|
1044
|
+
}
|
|
1045
|
+
return parseCheckerAnswer(attempt.parsed, ids, publicFacts)
|
|
395
1046
|
}
|
|
396
1047
|
|
|
397
1048
|
/** Write the writable subset into the provider settings document. */
|
|
@@ -428,8 +1079,18 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
428
1079
|
return { written: result?.written === true, fields: result?.fields ?? [], reason: result?.reason }
|
|
429
1080
|
}
|
|
430
1081
|
|
|
431
|
-
/**
|
|
432
|
-
|
|
1082
|
+
/**
|
|
1083
|
+
* The sweep itself; run once at a time.
|
|
1084
|
+
* @param options.checker - `{ provider, model }` to use as the checker.
|
|
1085
|
+
* @param options.writeBack - mirror confirmed facts into provider settings.
|
|
1086
|
+
* @param options.concurrency - simultaneous checker calls for THIS run
|
|
1087
|
+
* (clamped to {@link MAX_CONCURRENCY}); omitted keeps the constructor value.
|
|
1088
|
+
*/
|
|
1089
|
+
const startVerification = async ({ checker, writeBack = defaultWriteBack, concurrency } = {}) => {
|
|
1090
|
+
// A cancelled sweep keeps its workers alive until their in-flight call
|
|
1091
|
+
// settles. Joining that run instead of racing it is what stops two workers
|
|
1092
|
+
// from writing the same `state` and reporting done > total.
|
|
1093
|
+
if (running !== null) await running.catch(() => {})
|
|
433
1094
|
if (state.running) return snapshot()
|
|
434
1095
|
const provider = nonEmpty(checker?.provider)
|
|
435
1096
|
const model = nonEmpty(checker?.model)
|
|
@@ -437,6 +1098,21 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
437
1098
|
state = { ...idleState(), phase: 'error', errors: ['choose the checking provider/model first'] }
|
|
438
1099
|
return snapshot()
|
|
439
1100
|
}
|
|
1101
|
+
const run = runSweep({ provider, model, writeBack, concurrency })
|
|
1102
|
+
running = run
|
|
1103
|
+
try {
|
|
1104
|
+
return await run
|
|
1105
|
+
} finally {
|
|
1106
|
+
if (running === run) running = null
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** One sweep against the checker route, from catalog read to flush. */
|
|
1111
|
+
const runSweep = async ({ provider, model, writeBack, concurrency }) => {
|
|
1112
|
+
const workers = Number.isFinite(concurrency)
|
|
1113
|
+
? Math.max(1, Math.min(MAX_CONCURRENCY, Math.trunc(concurrency)))
|
|
1114
|
+
: concurrencyLimit
|
|
1115
|
+
publicReport = []
|
|
440
1116
|
state = {
|
|
441
1117
|
...idleState(),
|
|
442
1118
|
running: true,
|
|
@@ -444,6 +1120,7 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
444
1120
|
startedAt: now(),
|
|
445
1121
|
checker: { provider, model },
|
|
446
1122
|
writeBack,
|
|
1123
|
+
concurrency: workers,
|
|
447
1124
|
}
|
|
448
1125
|
abort = new AbortController()
|
|
449
1126
|
const signal = abort.signal
|
|
@@ -457,7 +1134,10 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
457
1134
|
if (id === undefined) continue
|
|
458
1135
|
let models = []
|
|
459
1136
|
try {
|
|
460
|
-
|
|
1137
|
+
// One call: the old ternary awaited `listModels` twice (once for the
|
|
1138
|
+
// Array check, once for the value), doubling registry work per route.
|
|
1139
|
+
const listed = await llm.listModels(id)
|
|
1140
|
+
models = Array.isArray(listed) ? listed : []
|
|
461
1141
|
} catch (error) {
|
|
462
1142
|
state.errors.push(`route ${id}: ${error instanceof Error ? error.message : String(error)}`)
|
|
463
1143
|
continue
|
|
@@ -467,25 +1147,40 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
467
1147
|
}
|
|
468
1148
|
state.phase = 'checker'
|
|
469
1149
|
const publicFacts = await publicIndex()
|
|
1150
|
+
// Publish the per-source outcome: "the catalogs returned nothing" needs
|
|
1151
|
+
// to be distinguishable from "a catalog errored".
|
|
1152
|
+
state.sources = publicReport.map((entry) => ({ ...entry }))
|
|
1153
|
+
if (publicFacts.size === 0 && state.sources.every((entry) => entry.status !== 'ok')) {
|
|
1154
|
+
state.errors.push(
|
|
1155
|
+
'no public catalog could be read (' +
|
|
1156
|
+
state.sources.map((entry) => `${entry.name}: ${entry.status}${entry.message ? ' ' + entry.message : ''}`).join(', ') +
|
|
1157
|
+
')',
|
|
1158
|
+
)
|
|
1159
|
+
}
|
|
470
1160
|
state.phase = 'verifying'
|
|
1161
|
+
// De-duplicate across routes by canonical id: one sweep per model. A Set
|
|
1162
|
+
// keeps this linear; the old `targets.some(…)` scan was O(n²) in 400+
|
|
1163
|
+
// models and ran before every network call.
|
|
1164
|
+
const seenTargets = new Set()
|
|
471
1165
|
const targets = []
|
|
472
1166
|
for (const route of targetProviders) {
|
|
473
1167
|
for (const id of route.ids) {
|
|
474
1168
|
const key = String(modelIdVariants(id)[0] ?? id)
|
|
475
|
-
|
|
476
|
-
|
|
1169
|
+
if (seenTargets.has(key)) continue
|
|
1170
|
+
seenTargets.add(key)
|
|
1171
|
+
targets.push({ key, id, provider: route.id })
|
|
477
1172
|
}
|
|
478
1173
|
}
|
|
479
|
-
state.total = Math.max(0, targets.length - 1) // minus the checker itself
|
|
480
1174
|
// Batches of batchSize distinct ids, checker's own models excluded.
|
|
481
1175
|
const batches = []
|
|
482
1176
|
let current = []
|
|
483
1177
|
for (const target of targets) {
|
|
484
1178
|
const isChecker = target.provider === provider && normalizeId(target.id) === normalizeId(model)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1179
|
+
// The checker never verifies itself and never appears in a batch, so
|
|
1180
|
+
// `total` is derived from the batches below rather than from
|
|
1181
|
+
// `targets.length - 1` (which undercounted when the checker was not
|
|
1182
|
+
// routable and let `done` exceed `total`).
|
|
1183
|
+
if (isChecker) continue
|
|
489
1184
|
current.push(target.id)
|
|
490
1185
|
if (current.length >= batchSize) {
|
|
491
1186
|
batches.push([...current])
|
|
@@ -493,52 +1188,115 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
493
1188
|
}
|
|
494
1189
|
}
|
|
495
1190
|
if (current.length > 0) batches.push(current)
|
|
1191
|
+
state.total = batches.reduce((sum, ids) => sum + ids.length, 0)
|
|
496
1192
|
state.batches = batches.map((ids) => ({ ids, status: 'pending' }))
|
|
497
1193
|
// Concurrent batches, results merged into the learned store.
|
|
498
1194
|
let next = 0
|
|
1195
|
+
/**
|
|
1196
|
+
* Verified facts are buffered and flushed once per worker step: writing
|
|
1197
|
+
* the learned snapshot per model rewrote the whole file N times, which is
|
|
1198
|
+
* both slow and leaves a half-written store if the sweep dies.
|
|
1199
|
+
*/
|
|
1200
|
+
let pending = []
|
|
1201
|
+
let flushChain = Promise.resolve()
|
|
1202
|
+
const flush = () => {
|
|
1203
|
+
if (pending.length === 0) return flushChain
|
|
1204
|
+
const batch = pending
|
|
1205
|
+
pending = []
|
|
1206
|
+
flushChain = flushChain.then(() => {
|
|
1207
|
+
learned.upsertEntries(
|
|
1208
|
+
// Each entry keeps its own provenance (`public`, `checker`, or both).
|
|
1209
|
+
// Stamping one blanket origin here used to overwrite the per-model
|
|
1210
|
+
// one, so a public-only fact was recorded as if the checker said it.
|
|
1211
|
+
batch.map((entry) => ({ ...entry.fact, id: entry.id, origins: entry.origins })),
|
|
1212
|
+
{ verifiedAt: now(), checker: { provider, model } },
|
|
1213
|
+
)
|
|
1214
|
+
})
|
|
1215
|
+
return flushChain
|
|
1216
|
+
}
|
|
1217
|
+
|
|
499
1218
|
const worker = async () => {
|
|
500
1219
|
for (;;) {
|
|
1220
|
+
if (signal.aborted) return
|
|
501
1221
|
const index = next
|
|
502
|
-
if (index >= batches.length
|
|
1222
|
+
if (index >= batches.length) return
|
|
503
1223
|
next += 1
|
|
504
1224
|
const ids = batches[index]
|
|
505
1225
|
state.batches[index].status = 'running'
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1226
|
+
let verified = 0
|
|
1227
|
+
/** Ids the checker actually returned a verdict for. */
|
|
1228
|
+
let answered = []
|
|
1229
|
+
const ingest = async (results) => {
|
|
509
1230
|
for (const result of results) {
|
|
1231
|
+
if (signal.aborted) break
|
|
1232
|
+
// `fact === null` and "an object with no usable field" are the
|
|
1233
|
+
// same thing: the checker had no verdict for this model.
|
|
510
1234
|
if (result.fact === null) continue
|
|
1235
|
+
answered.push(result.id)
|
|
511
1236
|
const sanitized = sanitizeLearned({
|
|
512
1237
|
...result.fact,
|
|
513
1238
|
origins: result.origins,
|
|
514
1239
|
provider: undefined,
|
|
515
1240
|
})
|
|
516
1241
|
if (sanitized === undefined) continue
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1242
|
+
verified += 1
|
|
1243
|
+
pending.push({
|
|
1244
|
+
id: sanitized.id,
|
|
1245
|
+
fact: { ...sanitized.entry, id: sanitized.id },
|
|
1246
|
+
origins: Array.isArray(result.origins) && result.origins.length > 0
|
|
1247
|
+
? result.origins
|
|
1248
|
+
: ['checker'],
|
|
1249
|
+
})
|
|
1250
|
+
state.batches[index].verified = verified
|
|
521
1251
|
if (writeBack) {
|
|
522
1252
|
// Mirror the writable subset into every route serving the id.
|
|
523
1253
|
for (const route of targetProviders) {
|
|
524
1254
|
if (!route.ids.includes(result.id)) continue
|
|
525
|
-
const applied = await writeBackEntry({
|
|
1255
|
+
const applied = await writeBackEntry({
|
|
1256
|
+
provider: route.id,
|
|
1257
|
+
model: result.id,
|
|
1258
|
+
fact: sanitized.entry,
|
|
1259
|
+
})
|
|
526
1260
|
if (applied?.written) {
|
|
527
|
-
state.batches[index].written = [
|
|
1261
|
+
state.batches[index].written = [
|
|
1262
|
+
...(state.batches[index].written ?? []),
|
|
1263
|
+
`${route.id}/${result.id}`,
|
|
1264
|
+
]
|
|
528
1265
|
}
|
|
529
1266
|
}
|
|
530
1267
|
}
|
|
531
1268
|
}
|
|
532
|
-
|
|
533
|
-
|
|
1269
|
+
}
|
|
1270
|
+
try {
|
|
1271
|
+
await ingest(await runBatchResilient(ids, publicFacts, state.checker, signal))
|
|
1272
|
+
// Whatever the checker skipped may still be described by a public
|
|
1273
|
+
// catalog. Those facts were downloaded already, so recording them
|
|
1274
|
+
// costs nothing and is the difference between "0 facts" and a
|
|
1275
|
+
// usable table when the checker is weak.
|
|
1276
|
+
const filled = publicOnlyFacts(ids, answered, publicFacts)
|
|
1277
|
+
if (filled.length > 0) {
|
|
1278
|
+
await ingest(filled)
|
|
1279
|
+
state.publicOnly += filled.length
|
|
1280
|
+
}
|
|
1281
|
+
await flush()
|
|
1282
|
+
state.batches[index].status = signal.aborted ? 'cancelled' : 'ok'
|
|
534
1283
|
} catch (error) {
|
|
1284
|
+
await flush()
|
|
535
1285
|
state.batches[index].status = signal.aborted ? 'cancelled' : 'failed'
|
|
536
1286
|
state.batches[index].error = error instanceof Error ? error.message : String(error)
|
|
537
|
-
if (!signal.aborted)
|
|
1287
|
+
if (!signal.aborted) {
|
|
1288
|
+
state.errors.push(`batch ${index + 1}: ${state.batches[index].error}`)
|
|
1289
|
+
}
|
|
1290
|
+
} finally {
|
|
1291
|
+
// Count what was actually verified: the checker legitimately skips
|
|
1292
|
+
// models or answers "unknown", so `done` must never assume the whole
|
|
1293
|
+
// batch landed (that produced done=36 / total=23).
|
|
1294
|
+
state.done = Math.min(state.total, state.done + verified)
|
|
538
1295
|
}
|
|
539
1296
|
}
|
|
540
1297
|
}
|
|
541
|
-
await Promise.all(Array.from({ length:
|
|
1298
|
+
await Promise.all(Array.from({ length: workers }, () => worker()))
|
|
1299
|
+
await flush()
|
|
542
1300
|
state.phase = signal.aborted ? 'cancelled' : 'done'
|
|
543
1301
|
state.running = false
|
|
544
1302
|
state.finishedAt = now()
|
|
@@ -567,7 +1325,9 @@ export function createCapabilityVerifier(options = {}) {
|
|
|
567
1325
|
start: startVerification,
|
|
568
1326
|
cancel() {
|
|
569
1327
|
if (abort !== null) abort.abort()
|
|
570
|
-
|
|
1328
|
+
// Do NOT flip `running` here: the workers are still unwinding, and a
|
|
1329
|
+
// false `running` let a second sweep start on top of them. The sweep
|
|
1330
|
+
// itself publishes the cancelled state when it finishes unwinding.
|
|
571
1331
|
state.phase = 'cancelled'
|
|
572
1332
|
},
|
|
573
1333
|
isRunning: () => state.running,
|