@tzyc/dsh-model-visibility 1.10.2 → 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.
@@ -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
- /** Public catalog endpoints, fetched without credentials. */
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
- modelsDev: 'https://models.dev/api.json',
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's `api.json` into a fact map.
134
+ * Parse any models.dev endpoint into a fact map.
45
135
  *
46
- * The shape is `{ <provider>: { models: { <id>: { modalities, limit, reasoning … } } } }`;
47
- * every field is optional because the site evolves.
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 normalized id → fact entry `{ id, input, output, reasoning, contextWindow, maxTokens, name }`.
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 (typeof id !== 'string' || id === '' || fact === undefined) return
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
- for (const entry of Object.values(body)) {
61
- const models = entry !== null && typeof entry === 'object' ? entry.models : undefined
62
- if (models === null || typeof models !== 'object') continue
63
- for (const [id, raw] of Object.entries(models)) {
64
- if (raw === null || typeof raw !== 'object') continue
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
- /** Merge the two public sources; a second source fills what the first missed. */
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 object from a possibly fenced or chatty checker reply.
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
- const start = candidate.indexOf('{')
133
- const end = candidate.lastIndexOf('}')
134
- if (start === -1 || end <= start) continue
135
- try {
136
- const parsed = JSON.parse(candidate.slice(start, end + 1))
137
- if (parsed !== null && typeof parsed === 'object') return parsed
138
- } catch (_error) {
139
- // Fall through to the next candidate.
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 publicFact - resolved public facts for the batch, keyed by variant.
154
- * @returns array of `{ id, fact, origins }`; `fact` is null when the checker
155
- * said `unknown` for that model in a usable way.
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, publicFact) {
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 fact = {
166
- id,
167
- ...(MODEL_KINDS.includes(raw.kind) ? { type: raw.kind } : {}),
168
- ...(typeof raw.type === 'string' && MODEL_KINDS.includes(raw.type) ? { type: raw.type } : {}),
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) fact.input = input
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) fact.output = output
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 (publicFact !== undefined) origins.push('public')
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 = Number.isFinite(options.batchSize) && options.batchSize > 0 ? options.batchSize : 6
256
- const concurrencyLimit = Math.max(1, Math.min(3, Number.isFinite(options.concurrency) ? options.concurrency : 2))
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
- /** Fetch one public source; a failure is an empty map, never a throw. */
302
- const readPublicSource = async (url, parser, headers) => {
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(), 15_000)
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) return new Map()
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
- return parser(body)
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 sources = await Promise.all([
330
- readPublicSource(PUBLIC_SOURCES.modelsDev, readModelsDev),
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(...sources)
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 = 2048
810
+ const maxTokens = defaultCheckerMaxTokens
811
+ const effort = defaultCheckerEffort
342
812
  const call = await llm.prepareCall(
343
- { provider: checker.provider, model: checker.model, maxTokens },
813
+ {
814
+ provider: checker.provider,
815
+ model: checker.model,
816
+ maxTokens,
817
+ ...(effort === undefined || effort === null ? {} : { reasoningEffort: effort }),
818
+ },
344
819
  signal,
345
- (controls, modelInfo) => controls,
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
- const checkerSystemPrompt =
372
- 'You catalog LLM API capabilities. For each model you answer only JSON. ' +
373
- 'If you have web search, verify against vendor docs; otherwise answer from knowledge. ' +
374
- 'Never invent a number: use null for anything unknown, and only report what the vendor actually documents.'
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
- 'Report the facts for each model below as a JSON array of objects with keys ' +
378
- '"id", "kind" ("chat" | "image" | "video" | "embedding" | "rerank" | "audio" | "classifier" | "translation"), ' +
379
- '"input" (array from "text"/"image"/"audio"/"video"/"file"), "output", "contextWindow" (input tokens), ' +
380
- '"maxTokens" (max output tokens), "reasoning" (boolean), "reasoningEfforts" (array of level ids such as ' +
381
- '"minimal"/"low"/"medium"/"high" or vendor ids) and "defaultEffort". Use null for anything unknown. Models:\n' +
382
- batch.map((item) => `- ${item}${item === null ? '' : ''}`).join('\n')
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 reply = await askChecker(
387
- checker,
388
- checkerSystemPrompt,
389
- batchPrompt(batchText),
390
- signal,
391
- )
392
- const parsed = extractJsonBlock(reply.text)
393
- if (parsed === undefined) throw new Error('the checker reply contained no JSON object')
394
- return parseCheckerAnswer(parsed, ids, publicFacts)
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
- /** The sweep itself; run once at a time. */
432
- const startVerification = async ({ checker, writeBack = defaultWriteBack } = {}) => {
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
- models = Array.isArray(await llm.listModels(id)) ? await llm.listModels(id) : []
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
- // De-duplicate across routes by canonical id: one sweep per model.
476
- if (!targets.some((target) => target.key === key)) targets.push({ key, id, provider: route.id })
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
- if (isChecker) {
486
- state.done += 1
487
- continue
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 || signal.aborted) return
1222
+ if (index >= batches.length) return
503
1223
  next += 1
504
1224
  const ids = batches[index]
505
1225
  state.batches[index].status = 'running'
506
- try {
507
- if (signal.aborted) throw new Error('cancelled')
508
- const results = await runBatch(ids, ids, publicFacts, state.checker, signal)
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
- learned.upsertEntries(
518
- [{ ...sanitized.entry, id: sanitized.id }],
519
- { origins: sanitized.entry.origins, verifiedAt: now(), checker: { provider, model } },
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({ provider: route.id, model: result.id, fact: sanitized.entry })
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 = [...(state.batches[index].written ?? []), `${route.id}/${result.id}`]
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
- state.done += ids.length
533
- state.batches[index].status = 'ok'
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) state.errors.push(`batch ${index + 1}: ${state.batches[index].error}`)
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: concurrencyLimit }, () => worker()))
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
- state.running = false
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,