@tzyc/dsh-model-visibility 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,575 @@
1
+ /**
2
+ * The online capability verifier behind the settings page's "verify all
3
+ * models" run.
4
+ *
5
+ * The user picks one route as the *checker model*; the Host then sweeps every
6
+ * other routable model in two evidence passes:
7
+ *
8
+ * 1. **Public catalogs** — models.dev's `api.json` and OpenRouter's public
9
+ * `/api/v1/models` are fetched without credentials and matched by
10
+ * normalized id. They give declared-labeled facts for well-known models.
11
+ * 2. **The checker model** — batches of models whose facts are still missing
12
+ * (or that a public row contradicts) go through `llm.prepareCall` + a
13
+ * strict JSON prompt. The checker is expected to know vendor specs (or to
14
+ * be able to search); it answers `unknown` freely, and an unknown answer
15
+ * never becomes a fact.
16
+ *
17
+ * Merged results land in the learned snapshot (`lib/learned-catalog.js`), and
18
+ * — when the write-back switch is on — the fields the provider schema can
19
+ * express are mirrored into the route's own settings through
20
+ * `lib/provider-write.js`, exactly like a manual override with
21
+ * "write into provider settings" checked. The checker's own spend is recorded
22
+ * into the token ledger so the page's usage numbers include it.
23
+ *
24
+ * @module lib/check-catalog
25
+ */
26
+
27
+ import { normalizeModalities, readModelEntry } from './provider-probe.js'
28
+ import { stripWrittenFields, writeCapabilities } from './provider-write.js'
29
+ import { sanitizeLearned } from './learned-catalog.js'
30
+ import { MODEL_KINDS } from './capability-catalog.js'
31
+ import { modelIdVariants } from './model-id.js'
32
+
33
+ /** Public catalog endpoints, fetched without credentials. */
34
+ export const PUBLIC_SOURCES = {
35
+ modelsDev: 'https://models.dev/api.json',
36
+ openRouter: 'https://openrouter.ai/api/v1/models',
37
+ }
38
+
39
+ function nonEmpty(value) {
40
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
41
+ }
42
+
43
+ /**
44
+ * Parse models.dev's `api.json` into a fact map.
45
+ *
46
+ * The shape is `{ <provider>: { models: { <id>: { modalities, limit, reasoning … } } } }`;
47
+ * every field is optional because the site evolves.
48
+ * @param body - parsed JSON of the endpoint.
49
+ * @returns Map of normalized id → fact entry `{ id, input, output, reasoning, contextWindow, maxTokens, name }`.
50
+ */
51
+ export function readModelsDev(body) {
52
+ const out = new Map()
53
+ if (body === null || typeof body !== 'object') return out
54
+ const push = (id, fact) => {
55
+ if (typeof id !== 'string' || id === '' || fact === undefined) return
56
+ for (const variant of modelIdVariants(id)) {
57
+ if (!out.has(variant)) out.set(variant, fact)
58
+ }
59
+ }
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)
77
+ }
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * Parse OpenRouter's public listing using the relay dialect reader.
84
+ * @param body - parsed JSON.
85
+ * @returns Map of variant id → relay fact entry.
86
+ */
87
+ export function readOpenRouter(body) {
88
+ const out = new Map()
89
+ const listed = body !== null && typeof body === 'object' && Array.isArray(body.data) ? body.data : []
90
+ for (const raw of listed) {
91
+ const entry = readModelEntry(raw)
92
+ if (entry === undefined) continue
93
+ for (const variant of modelIdVariants(entry.id)) {
94
+ if (!out.has(variant)) out.set(variant, entry)
95
+ }
96
+ }
97
+ return out
98
+ }
99
+
100
+ /** Merge the two public sources; a second source fills what the first missed. */
101
+ export function mergePublicSources(...maps) {
102
+ const merged = new Map()
103
+ for (const map of maps) {
104
+ for (const [variant, fact] of map) {
105
+ const current = merged.get(variant)
106
+ if (current === undefined) merged.set(variant, fact)
107
+ else {
108
+ const next = { ...current }
109
+ for (const [key, value] of Object.entries(fact)) {
110
+ if (value === undefined || value === null) continue
111
+ if (next[key] === undefined || next[key] === null) next[key] = value
112
+ }
113
+ merged.set(variant, next)
114
+ }
115
+ }
116
+ }
117
+ return merged
118
+ }
119
+
120
+ /**
121
+ * Extract a JSON object from a possibly fenced or chatty checker reply.
122
+ * @param text - raw assistant text.
123
+ * @returns the parsed object, or undefined when nothing parseable remains.
124
+ */
125
+ export function extractJsonBlock(text) {
126
+ if (typeof text !== 'string') return undefined
127
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
128
+ const candidates = []
129
+ if (fenced !== null) candidates.push(fenced[1])
130
+ candidates.push(text)
131
+ 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.
140
+ }
141
+ }
142
+ return undefined
143
+ }
144
+
145
+ /**
146
+ * Validate one checker answer against the identifiers of its batch.
147
+ *
148
+ * Only plain facts survive: modalities are filtered through the host's
149
+ * modality list, kinds through `MODEL_KINDS`, numbers guarded, and every
150
+ * entry stays attached to an id the batch actually asked about.
151
+ * @param parsed - the checker's parsed JSON (object or `{models:[...]}`).
152
+ * @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.
156
+ */
157
+ export function parseCheckerAnswer(parsed, ids, publicFact) {
158
+ const source = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.models) ? parsed.models : Object.values(parsed ?? {})
159
+ const wanted = new Set(ids.map((id) => id.toLowerCase()))
160
+ const out = []
161
+ for (const raw of source) {
162
+ if (raw === null || typeof raw !== 'object') continue
163
+ const id = nonEmpty(raw.id)
164
+ 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 } : {}),
169
+ }
170
+ const input = normalizeModalities(raw.input ?? raw.inputModalities)
171
+ if (input !== undefined) fact.input = input
172
+ const output = normalizeModalities(raw.output ?? raw.outputModalities)
173
+ if (output !== undefined) fact.output = output
174
+ if (Number.isSafeInteger(raw.contextWindow) && raw.contextWindow > 0) {
175
+ fact.contextWindow = raw.contextWindow
176
+ }
177
+ if (Number.isSafeInteger(raw.maxTokens) && raw.maxTokens > 0) fact.maxTokens = raw.maxTokens
178
+ if (typeof raw.reasoning === 'boolean') fact.reasoning = raw.reasoning
179
+ if (Array.isArray(raw.reasoningEfforts)) {
180
+ const efforts = raw.reasoningEfforts
181
+ .map((effort) => (typeof effort === 'string' ? effort : effort?.id))
182
+ .filter((effort) => typeof effort === 'string' && effort.trim() !== '')
183
+ .map((effort) => ({ id: effort.trim() }))
184
+ if (efforts.length > 0) fact.reasoningEfforts = efforts
185
+ const defaultEffort = nonEmpty(raw.defaultEffort)
186
+ if (defaultEffort !== undefined && efforts.some((effort) => effort.id === defaultEffort)) {
187
+ fact.defaultEffort = defaultEffort
188
+ }
189
+ }
190
+ const origins = []
191
+ if (publicFact !== undefined) origins.push('public')
192
+ origins.push('checker')
193
+ const declared =
194
+ fact.type !== undefined || fact.input !== undefined || fact.output !== undefined ||
195
+ fact.contextWindow !== undefined || fact.maxTokens !== undefined ||
196
+ fact.reasoning !== undefined || fact.reasoningEfforts !== undefined
197
+ if (declared) out.push({ id, fact, origins })
198
+ else out.push({ id, fact: null, origins })
199
+ }
200
+ return out
201
+ }
202
+
203
+ /** Renderable progress state between sweeps. */
204
+ function idleState() {
205
+ return {
206
+ phase: 'idle',
207
+ running: false,
208
+ total: 0,
209
+ done: 0,
210
+ checker: undefined,
211
+ writeBack: undefined,
212
+ tokenUsage: [0, 0, 0, 0],
213
+ batches: [],
214
+ errors: [],
215
+ }
216
+ }
217
+
218
+ /** Chunk-type consumer over one auxiliary completion, minimal and text-only. */
219
+ class TextAssembler {
220
+ constructor() {
221
+ this.parts = []
222
+ this.usage = undefined
223
+ this.finish = undefined
224
+ }
225
+
226
+ push(chunk) {
227
+ if (chunk === null || typeof chunk !== 'object') return
228
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') this.parts.push(chunk.text)
229
+ else if (chunk.type === 'usage' && chunk.usage !== null && typeof chunk.usage === 'object') this.usage = chunk.usage
230
+ else if (chunk.type === 'finish') this.finish = chunk.reason
231
+ }
232
+
233
+ get text() {
234
+ return this.parts.join('')
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Build the sweep.
240
+ *
241
+ * @param options - `{ llm, settings, learned, ledger?, logger?, onApplied?,
242
+ * fetch?, now?, timeoutMs?, batchSize?, concurrency?, writeBack? }`.
243
+ * `onApplied(providerId, modelId)` lets the Host clear its caches whenever a
244
+ * write-back touched the settings document.
245
+ */
246
+ export function createCapabilityVerifier(options = {}) {
247
+ const llm = options.llm
248
+ const settings = options.settings
249
+ const learned = options.learned
250
+ const ledger = options.ledger
251
+ const logger = options.logger ?? console
252
+ const fetchImpl = typeof options.fetch === 'function' ? options.fetch : fetch
253
+ const now = typeof options.now === 'function' ? options.now : Date.now
254
+ 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))
257
+ const defaultWriteBack = options.writeBack !== false
258
+ const onApplied = typeof options.onApplied === 'function' ? options.onApplied : () => {}
259
+
260
+ let state = idleState()
261
+ let abort = null
262
+
263
+ const warn = (message, error) => {
264
+ if (typeof logger.warn === 'function') {
265
+ logger.warn(message)
266
+ if (error !== undefined) logger.warn(error)
267
+ }
268
+ }
269
+
270
+ const snapshot = () => ({
271
+ ...state,
272
+ tokenizer: undefined,
273
+ tokenUsage: [...state.tokenUsage],
274
+ batches: state.batches.map((batch) => ({ ...batch })),
275
+ errors: [...state.errors],
276
+ })
277
+
278
+ const noteUsage = (entry) => {
279
+ const usage = entry.usage
280
+ if (usage === undefined) return
281
+ const values = [
282
+ Math.max(0, Math.trunc(usage.inputTokens ?? 0)),
283
+ Math.max(0, Math.trunc(usage.outputTokens ?? 0)),
284
+ Math.max(0, Math.trunc(usage.cacheReadTokens ?? 0)),
285
+ Math.max(0, Math.trunc(usage.cacheWriteTokens ?? 0)),
286
+ ]
287
+ for (let index = 0; index < 4; index += 1) state.tokenUsage[index] += values[index]
288
+ ledger?.notePluginUsage({
289
+ time: now(),
290
+ provider: state.checker?.provider,
291
+ model: state.checker?.model,
292
+ usage: {
293
+ inputTokens: values[0],
294
+ outputTokens: values[1],
295
+ cacheReadTokens: values[2],
296
+ cacheWriteTokens: values[3],
297
+ },
298
+ })
299
+ }
300
+
301
+ /** Fetch one public source; a failure is an empty map, never a throw. */
302
+ const readPublicSource = async (url, parser, headers) => {
303
+ const controller = new AbortController()
304
+ const timer = setTimeout(() => controller.abort(), 15_000)
305
+ try {
306
+ const response = await fetchImpl(url, {
307
+ headers: { accept: 'application/json', ...(headers ?? {}) },
308
+ signal: controller.signal,
309
+ redirect: 'error',
310
+ })
311
+ if (!response.ok) return new Map()
312
+ const text = await response.text()
313
+ let body
314
+ try {
315
+ body = JSON.parse(text)
316
+ } catch (_error) {
317
+ return new Map()
318
+ }
319
+ return parser(body)
320
+ } catch (error) {
321
+ warn(`model-visibility: public catalog ${url} unavailable`, error)
322
+ return new Map()
323
+ } finally {
324
+ clearTimeout(timer)
325
+ }
326
+ }
327
+
328
+ const publicIndex = async () => {
329
+ const sources = await Promise.all([
330
+ readPublicSource(PUBLIC_SOURCES.modelsDev, readModelsDev),
331
+ readPublicSource(PUBLIC_SOURCES.openRouter, readOpenRouter),
332
+ ])
333
+ return mergePublicSources(...sources)
334
+ }
335
+
336
+ /** One auxiliary completion for a batch; answers text or fails loudly. */
337
+ const askChecker = async (checker, system, input, signal) => {
338
+ if (typeof llm?.prepareCall !== 'function') {
339
+ throw new Error('llm service unavailable for the verify sweep')
340
+ }
341
+ const maxTokens = 2048
342
+ const call = await llm.prepareCall(
343
+ { provider: checker.provider, model: checker.model, maxTokens },
344
+ signal,
345
+ (controls, modelInfo) => controls,
346
+ )
347
+ const options2 = {
348
+ ...call.config,
349
+ system,
350
+ messages: [
351
+ {
352
+ role: 'user',
353
+ content: [{ type: 'text', text: input }],
354
+ source: { kind: 'dsh-model-visibility' },
355
+ },
356
+ ],
357
+ signal,
358
+ }
359
+ const assembler = new TextAssembler()
360
+ for await (const chunk of call.stream(options2)) {
361
+ assembler.push(chunk)
362
+ }
363
+ noteUsage({ usage: assembler.usage })
364
+ const finish = assembler.finish
365
+ if (finish?.kind === 'error' || finish?.kind === 'aborted') {
366
+ throw new Error(finish?.failure?.message ?? 'verify request failed')
367
+ }
368
+ return { text: assembler.text, usage: assembler.usage }
369
+ }
370
+
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.'
375
+
376
+ 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')
383
+
384
+ /** Execute one batch; returns the sanitized results (or throws). */
385
+ 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)
395
+ }
396
+
397
+ /** Write the writable subset into the provider settings document. */
398
+ const writeBackEntry = async (entry) => {
399
+ if (typeof settings?.mutate !== 'function' || typeof settings?.describe !== 'function') {
400
+ return { fields: [] }
401
+ }
402
+ const provider = nonEmpty(entry.provider)
403
+ if (provider === undefined) return { fields: [] }
404
+ const override = stripWrittenFields(
405
+ {
406
+ provider,
407
+ model: entry.model,
408
+ ...(entry.fact.type !== undefined ? { kind: entry.fact.type } : {}),
409
+ ...(entry.fact.input !== undefined ? { input: entry.fact.input } : {}),
410
+ ...(entry.fact.contextWindow !== undefined ? { contextWindow: entry.fact.contextWindow } : {}),
411
+ ...(entry.fact.reasoningEfforts !== undefined ? { efforts: entry.fact.reasoningEfforts } : {}),
412
+ ...(entry.fact.defaultEffort !== undefined ? { defaultEffort: entry.fact.defaultEffort } : {}),
413
+ ...(entry.fact.reasoning === false ? { reasoningSupported: false } : {}),
414
+ },
415
+ [],
416
+ )
417
+ // A false "no reasoning" plus nothing else has no provider shape: skip.
418
+ if (
419
+ override?.input === undefined &&
420
+ override?.contextWindow === undefined &&
421
+ override?.efforts === undefined &&
422
+ override?.reasoningSupported !== false
423
+ ) {
424
+ return { fields: [] }
425
+ }
426
+ const result = await writeCapabilities(settings, override)
427
+ if (result?.written) onApplied(provider, entry.model)
428
+ return { written: result?.written === true, fields: result?.fields ?? [], reason: result?.reason }
429
+ }
430
+
431
+ /** The sweep itself; run once at a time. */
432
+ const startVerification = async ({ checker, writeBack = defaultWriteBack } = {}) => {
433
+ if (state.running) return snapshot()
434
+ const provider = nonEmpty(checker?.provider)
435
+ const model = nonEmpty(checker?.model)
436
+ if (provider === undefined || model === undefined) {
437
+ state = { ...idleState(), phase: 'error', errors: ['choose the checking provider/model first'] }
438
+ return snapshot()
439
+ }
440
+ state = {
441
+ ...idleState(),
442
+ running: true,
443
+ phase: 'public',
444
+ startedAt: now(),
445
+ checker: { provider, model },
446
+ writeBack,
447
+ }
448
+ abort = new AbortController()
449
+ const signal = abort.signal
450
+ try {
451
+ // Target list: every model the registry offers, grouped per route.
452
+ const targetProviders = []
453
+ const providerList = Array.isArray(llm.listProviders()) ? llm.listProviders() : []
454
+ for (const route of providerList) {
455
+ if (route === null || typeof route !== 'object') continue
456
+ const id = nonEmpty(route.id)
457
+ if (id === undefined) continue
458
+ let models = []
459
+ try {
460
+ models = Array.isArray(await llm.listModels(id)) ? await llm.listModels(id) : []
461
+ } catch (error) {
462
+ state.errors.push(`route ${id}: ${error instanceof Error ? error.message : String(error)}`)
463
+ continue
464
+ }
465
+ const ids = models.map((item) => nonEmpty(item?.id)).filter(Boolean)
466
+ if (ids.length > 0) targetProviders.push({ id, ids: [...new Set(ids)] })
467
+ }
468
+ state.phase = 'checker'
469
+ const publicFacts = await publicIndex()
470
+ state.phase = 'verifying'
471
+ const targets = []
472
+ for (const route of targetProviders) {
473
+ for (const id of route.ids) {
474
+ 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 })
477
+ }
478
+ }
479
+ state.total = Math.max(0, targets.length - 1) // minus the checker itself
480
+ // Batches of batchSize distinct ids, checker's own models excluded.
481
+ const batches = []
482
+ let current = []
483
+ for (const target of targets) {
484
+ const isChecker = target.provider === provider && normalizeId(target.id) === normalizeId(model)
485
+ if (isChecker) {
486
+ state.done += 1
487
+ continue
488
+ }
489
+ current.push(target.id)
490
+ if (current.length >= batchSize) {
491
+ batches.push([...current])
492
+ current = []
493
+ }
494
+ }
495
+ if (current.length > 0) batches.push(current)
496
+ state.batches = batches.map((ids) => ({ ids, status: 'pending' }))
497
+ // Concurrent batches, results merged into the learned store.
498
+ let next = 0
499
+ const worker = async () => {
500
+ for (;;) {
501
+ const index = next
502
+ if (index >= batches.length || signal.aborted) return
503
+ next += 1
504
+ const ids = batches[index]
505
+ 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)
509
+ for (const result of results) {
510
+ if (result.fact === null) continue
511
+ const sanitized = sanitizeLearned({
512
+ ...result.fact,
513
+ origins: result.origins,
514
+ provider: undefined,
515
+ })
516
+ if (sanitized === undefined) continue
517
+ learned.upsertEntries(
518
+ [{ ...sanitized.entry, id: sanitized.id }],
519
+ { origins: sanitized.entry.origins, verifiedAt: now(), checker: { provider, model } },
520
+ )
521
+ if (writeBack) {
522
+ // Mirror the writable subset into every route serving the id.
523
+ for (const route of targetProviders) {
524
+ if (!route.ids.includes(result.id)) continue
525
+ const applied = await writeBackEntry({ provider: route.id, model: result.id, fact: sanitized.entry })
526
+ if (applied?.written) {
527
+ state.batches[index].written = [...(state.batches[index].written ?? []), `${route.id}/${result.id}`]
528
+ }
529
+ }
530
+ }
531
+ }
532
+ state.done += ids.length
533
+ state.batches[index].status = 'ok'
534
+ } catch (error) {
535
+ state.batches[index].status = signal.aborted ? 'cancelled' : 'failed'
536
+ 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}`)
538
+ }
539
+ }
540
+ }
541
+ await Promise.all(Array.from({ length: concurrencyLimit }, () => worker()))
542
+ state.phase = signal.aborted ? 'cancelled' : 'done'
543
+ state.running = false
544
+ state.finishedAt = now()
545
+ if (!signal.aborted) {
546
+ learned.meta()
547
+ }
548
+ } catch (error) {
549
+ state.running = false
550
+ state.phase = signal?.aborted ? 'cancelled' : 'error'
551
+ state.finishedAt = now()
552
+ state.errors.push(error instanceof Error ? error.message : String(error))
553
+ warn('model-visibility: verify sweep failed', error)
554
+ } finally {
555
+ abort = null
556
+ }
557
+ return snapshot()
558
+ }
559
+
560
+ /** Normalized spelling used to skip the checker's own models. */
561
+ function normalizeId(value) {
562
+ return String(modelIdVariants(value)[0] ?? value)
563
+ }
564
+
565
+ return {
566
+ snapshot,
567
+ start: startVerification,
568
+ cancel() {
569
+ if (abort !== null) abort.abort()
570
+ state.running = false
571
+ state.phase = 'cancelled'
572
+ },
573
+ isRunning: () => state.running,
574
+ }
575
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * CSV builders behind the one-click exports.
3
+ *
4
+ * Both functions are pure string builders so the route layer only wires
5
+ * response headers, and the unit suite pins the dialect (RFC 4180 quoting,
6
+ * CRLF rows) independent of the Host.
7
+ *
8
+ * @module lib/export-csv
9
+ */
10
+
11
+ /** Quote one CSV field, RFC 4180 style. */
12
+ function csvField(value) {
13
+ if (value === null || value === undefined) return ''
14
+ let text = String(value)
15
+ if (/[",\r\n]/.test(text)) text = `"${text.replace(/"/g, '""')}"`
16
+ return text
17
+ }
18
+
19
+ /** Prefix the sheet with the window totals, then one (day, route) row each. */
20
+ export function toUsageCsv(summary) {
21
+ const fmt = (buckets) => `${buckets?.[0] ?? 0},${buckets?.[1] ?? 0},${buckets?.[2] ?? 0},${buckets?.[3] ?? 0}`
22
+ const rows = []
23
+ rows.push('section,range,input,output,cacheRead,cacheWrite')
24
+ const ranges = summary?.ranges ?? {}
25
+ for (const name of ['today', 'week', 'month', 'total']) {
26
+ rows.push(`sum,${name},${fmt(ranges[name])}`)
27
+ }
28
+ rows.push('')
29
+ rows.push('day,provider/model,input,output,cacheRead,cacheWrite')
30
+ const days = summary?.days ?? {}
31
+ const dayKeys = Object.keys(days).sort()
32
+ for (const dayKey of dayKeys) {
33
+ const byRoute = days[dayKey] ?? {}
34
+ for (const [route, values] of Object.entries(byRoute)) {
35
+ // `*` is the day's own total; the per-route rows already carry it.
36
+ if (route === '*' || values.every((n) => n === 0)) continue
37
+ rows.push(`${dayKey},${route},${fmt(values)}`)
38
+ }
39
+ }
40
+ return `${rows.join('\r\n')}\r\n`
41
+ }
42
+
43
+ /** One row per model: visibility, kind, context window, modalities, sources. */
44
+ export function toModelListCsv(catalog, { hidden = [] } = {}) {
45
+ const hiddenKeys = new Set(hidden)
46
+ const rows = ['provider,model,hidden,kind,inputModalities,contextWindow,reasoning,reasoningSupported,sources']
47
+ for (const group of catalog?.groups ?? []) {
48
+ for (const model of group?.models ?? []) {
49
+ const sources = model.sources ?? {}
50
+ const reasoning = model.reasoning?.efforts?.map((effort) => effort.id).join('|') ?? []
51
+ rows.push(
52
+ [
53
+ group.id,
54
+ model.id,
55
+ hiddenKeys.has(`${group.id}/${model.id}`) ? 1 : 0,
56
+ model.kind ?? '',
57
+ (model.inputModalities ?? []).join('|'),
58
+ model.contextWindow ?? '',
59
+ reasoning.length > 0 ? reasoning.join('|') : '',
60
+ model.reasoningSupported === true ? 'yes' : model.reasoningSupported === false ? 'no' : '',
61
+ `${sources.modalities ?? 'unknown'};${sources.context ?? 'unknown'};${sources.reasoning ?? 'unknown'}`,
62
+ ]
63
+ .map(csvField)
64
+ .join(','),
65
+ )
66
+ }
67
+ }
68
+ return `${rows.join('\r\n')}\r\n`
69
+ }