Files
9router/src/lib/modelCatalog/worker.js
decolua 0532f00d84 feat(models): refresh model capabilities from models.dev in the background
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>
2026-08-27 17:53:53 +07:00

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) })
);