Capability tables are hand-maintained, so a model gains vision or a wider context only when someone notices and edits the file. This adds a daily sync that fills the gap for models already in the registry. How it decides: - Modalities (vision/pdf/audio/video) belong to the MODEL — every gateway serving glm-5.3-flash serves the same weights — so they are keyed by model id and shared. A majority of sources must declare one, which keeps out lone mis-declarations: minimax-m2.5 (1 of 45), glm-4.7 (1 of 44) and gpt-oss-120b (2 of 76) are text-only despite a reseller claiming vision. - Context/output limits belong to the GATEWAY — each truncates differently (glm-5 ships as 202752/16384 on one host and 204800/131072 on another) — so they are keyed by provider + model and only the matching provider's own numbers are trusted. Both layers are strictly additive and sit BELOW the hand-written tables, which short-circuit first. A capability already true stays true. Mechanics: worker thread (the 4MB parse would block the loop ~20ms), ETag so an unchanged catalog costs one empty request, 60s startup delay, 30min backoff on failure, MODEL_CATALOG_SYNC=off to disable. Only the ~57KB delta is kept; lookups cost ~0.1us via an mtime-guarded cache. capabilities.js is bundled into the browser through useModelCaps, so it cannot import node:fs — the server injects the reader via setCatalogSource() from instrumentation. visionPatterns.js is the last resort: a model nobody has catalogued yet still accepts images when its id says so (qwen3-vl-plus, glm-4.6v, llava), with image-generation and embedding ids excluded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
133 lines
5.0 KiB
JavaScript
133 lines
5.0 KiB
JavaScript
// Downloads models.dev and writes the capability deltas 9router reads.
|
|
// Runs in a worker thread: the 4MB parse would otherwise block requests for ~20ms.
|
|
|
|
import { parentPort, workerData } from "node:worker_threads";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const FETCH_TIMEOUT_MS = 60000;
|
|
const MODALITY_BY_INPUT = { image: "vision", pdf: "pdf", audio: "audioInput", video: "videoInput" };
|
|
// Gateways disagree about the same model, so a modality needs a majority of
|
|
// them to declare it — one reseller mislabelling a text model must not win.
|
|
const MIN_SHARE = 0.5;
|
|
// Ignore limit differences below this: gateways round 200000 vs 202752.
|
|
const LIMIT_TOLERANCE = 0.1;
|
|
|
|
// "zai-org/GLM-4.6V:free" -> "glm-4.6v"
|
|
function baseId(modelId) {
|
|
const withoutVendor = modelId.includes("/") ? modelId.split("/").pop() : modelId;
|
|
return withoutVendor.toLowerCase().split(":")[0];
|
|
}
|
|
|
|
function build(catalog, entries, providerAliases) {
|
|
// Index the catalog once: per provider for limits, and tallied for modalities.
|
|
const byProvider = {};
|
|
const tally = {};
|
|
for (const [providerId, provider] of Object.entries(catalog)) {
|
|
const models = {};
|
|
for (const [modelId, model] of Object.entries(provider?.models || {})) {
|
|
const id = baseId(modelId);
|
|
models[id] = model;
|
|
const counts = tally[id] || (tally[id] = { total: 0 });
|
|
counts.total++;
|
|
for (const input of model?.modalities?.input || []) {
|
|
const key = MODALITY_BY_INPUT[input];
|
|
if (key) counts[key] = (counts[key] || 0) + 1;
|
|
}
|
|
}
|
|
byProvider[providerId] = models;
|
|
}
|
|
|
|
// Modalities belong to the model — every gateway serving it has the same
|
|
// weights — so they are keyed by model id and shared across providers.
|
|
const models = {};
|
|
for (const [id, counts] of Object.entries(tally)) {
|
|
const declared = {};
|
|
for (const key of Object.values(MODALITY_BY_INPUT)) {
|
|
if ((counts[key] || 0) / counts.total >= MIN_SHARE) declared[key] = true;
|
|
}
|
|
if (Object.keys(declared).length) models[id] = declared;
|
|
}
|
|
|
|
// Limits belong to the gateway — each truncates differently — so only the
|
|
// matching provider's own numbers are used, keyed by provider + model.
|
|
const providers = {};
|
|
for (const { provider, model, contextLength, current } of entries) {
|
|
const alias = providerAliases[provider];
|
|
const upstream = catalog[provider] ? provider : (alias && catalog[alias] ? alias : null);
|
|
const entry = upstream && byProvider[upstream]?.[baseId(model)];
|
|
if (!entry) continue;
|
|
|
|
const delta = {};
|
|
const { context, output } = entry.limit || {};
|
|
if (context > 0 && !contextLength
|
|
&& Math.abs(context - current.contextWindow) / current.contextWindow > LIMIT_TOLERANCE) {
|
|
delta.contextWindow = context;
|
|
}
|
|
if (output > 0
|
|
&& Math.abs(output - current.maxOutput) / current.maxOutput > LIMIT_TOLERANCE) {
|
|
delta.maxOutput = output;
|
|
}
|
|
if (Object.keys(delta).length) (providers[provider] || (providers[provider] = {}))[model] = delta;
|
|
}
|
|
|
|
return { models, providers };
|
|
}
|
|
|
|
// Trimmed copy of the upstream catalog, kept for the add-models skill: same
|
|
// 7348 models, 470KB instead of 4.3MB, so a scan reads it in ~5ms.
|
|
function slim(catalog) {
|
|
const out = {};
|
|
for (const [providerId, provider] of Object.entries(catalog)) {
|
|
const models = {};
|
|
for (const [modelId, model] of Object.entries(provider?.models || {})) {
|
|
models[modelId] = {
|
|
i: (model?.modalities?.input || []).filter((x) => x !== "text"),
|
|
c: model?.limit?.context,
|
|
o: model?.limit?.output,
|
|
r: model?.reasoning || undefined,
|
|
};
|
|
}
|
|
out[providerId] = models;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function writeAtomic(file, contents) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(`${file}.tmp`, contents, "utf8");
|
|
fs.renameSync(`${file}.tmp`, file);
|
|
}
|
|
|
|
async function run() {
|
|
const { url, etag, outFile, rawFile, entries, providerAliases } = workerData;
|
|
|
|
const headers = { accept: "application/json" };
|
|
if (etag) headers["if-none-match"] = etag;
|
|
const response = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
|
|
if (response.status === 304) return { status: "unchanged" };
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
|
|
const catalog = await response.json();
|
|
const nextEtag = response.headers.get("etag") || null;
|
|
const { models, providers } = build(catalog, entries, providerAliases);
|
|
const serialized = JSON.stringify({ v: 1, etag: nextEtag, syncedAt: Date.now(), models, providers });
|
|
|
|
writeAtomic(outFile, serialized);
|
|
if (rawFile) writeAtomic(rawFile, JSON.stringify(slim(catalog)));
|
|
|
|
return {
|
|
status: "updated",
|
|
etag: nextEtag,
|
|
bytes: Buffer.byteLength(serialized),
|
|
models: Object.keys(models).length,
|
|
providers: Object.keys(providers).length,
|
|
};
|
|
}
|
|
|
|
run().then(
|
|
(result) => parentPort?.postMessage({ ok: true, result }),
|
|
(error) => parentPort?.postMessage({ ok: false, error: error?.message || String(error) })
|
|
);
|