@tzyc/dsh-model-visibility 1.9.1 → 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.
- package/README.md +50 -30
- package/client-src/catalog-store.mjs +464 -0
- package/client-src/checker-view.mjs +221 -0
- package/client-src/composer-filter.mjs +153 -0
- package/client-src/constants.mjs +20 -0
- package/client-src/css.mjs +248 -0
- package/client-src/dict.mjs +427 -0
- package/client-src/format.mjs +154 -0
- package/client-src/hooks.mjs +25 -0
- package/client-src/index.mjs +101 -0
- package/client-src/override-editor.mjs +286 -0
- package/client-src/pure.mjs +136 -0
- package/client-src/settings-section.mjs +581 -0
- package/client-src/tags.mjs +197 -0
- package/client-src/usage-view.mjs +112 -0
- package/client.js +3228 -2258
- package/index.js +178 -0
- package/lib/capability-catalog.js +5 -69
- package/lib/capability-corrections.js +1 -1
- package/lib/check-catalog.js +575 -0
- package/lib/export-csv.js +69 -0
- package/lib/learned-catalog.js +294 -0
- package/lib/model-id.js +50 -0
- package/lib/model-info.js +94 -52
- package/lib/model-overrides.js +1 -1
- package/lib/usage-ledger.js +574 -0
- package/package.json +65 -64
- package/lib/model-catalog.js +0 -670
|
@@ -0,0 +1,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
|
+
}
|