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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9c650e1d54
commit
0532f00d84
@@ -0,0 +1,140 @@
|
||||
// Background refresh of model capabilities from models.dev.
|
||||
//
|
||||
// Failures are swallowed on purpose: a stale or missing catalog just means the
|
||||
// hand-written capability tables keep deciding on their own.
|
||||
|
||||
import path from "node:path";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { CATALOG_FILE, CATALOG_RAW_FILE, invalidateCatalog, installCatalogSource } from "open-sse/providers/catalogOverride.js";
|
||||
|
||||
const CATALOG_URL = "https://models.dev/api.json";
|
||||
const WORKER_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), "worker.js");
|
||||
|
||||
// 9router provider id -> models.dev provider id, for context/maxOutput only.
|
||||
// Providers absent here keep whatever the local pattern table resolves; the
|
||||
// names that already match are resolved automatically.
|
||||
const PROVIDER_ALIASES = {
|
||||
"glm": "zai",
|
||||
"glm-cn": "zhipuai",
|
||||
"claude": "anthropic",
|
||||
"gemini": "google",
|
||||
"kimi": "moonshotai",
|
||||
"kimi-cn": "moonshotai-cn",
|
||||
"qwen": "alibaba",
|
||||
"qwen-cn": "alibaba-cn",
|
||||
"zhipu": "zhipuai",
|
||||
"hunyuan": "tencent",
|
||||
"doubao": "volcengine",
|
||||
"cloudflare-ai": "cloudflare-workers-ai",
|
||||
};
|
||||
|
||||
export const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const STARTUP_DELAY_MS = 60 * 1000; // let the server boot and serve first requests
|
||||
const RETRY_DELAY_MS = 30 * 60 * 1000;
|
||||
const WORKER_TIMEOUT_MS = 120000;
|
||||
|
||||
let state = { running: false, lastSync: null, lastError: null, lastResult: null, etag: null };
|
||||
let timer = null;
|
||||
|
||||
export function getSyncState() {
|
||||
return { ...state, file: CATALOG_FILE, url: CATALOG_URL, intervalMs: SYNC_INTERVAL_MS };
|
||||
}
|
||||
|
||||
// Snapshot every registered model with its currently resolved capabilities, so
|
||||
// the worker can compute a delta without importing app modules (it cannot
|
||||
// resolve the bundler-only "open-sse/*" alias).
|
||||
async function collectEntries() {
|
||||
const [{ default: registry }, { getCapabilitiesForModel }] = await Promise.all([
|
||||
import("open-sse/providers/registry/index.js"),
|
||||
import("open-sse/providers/capabilities.js"),
|
||||
]);
|
||||
await installCatalogSource();
|
||||
|
||||
const entries = [];
|
||||
for (const provider of registry) {
|
||||
for (const model of provider.models || []) {
|
||||
entries.push({
|
||||
provider: provider.id,
|
||||
model: model.id,
|
||||
contextLength: model.contextLength,
|
||||
current: getCapabilitiesForModel(provider.id, model.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function runWorker(entries) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(WORKER_FILE, {
|
||||
workerData: {
|
||||
url: CATALOG_URL,
|
||||
etag: state.etag,
|
||||
outFile: CATALOG_FILE,
|
||||
rawFile: CATALOG_RAW_FILE,
|
||||
entries,
|
||||
providerAliases: PROVIDER_ALIASES,
|
||||
},
|
||||
resourceLimits: { maxOldGenerationSizeMb: 512 },
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const finish = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
fn(value);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
worker.terminate();
|
||||
finish(reject, new Error("sync timed out"));
|
||||
}, WORKER_TIMEOUT_MS);
|
||||
|
||||
worker.on("message", (msg) => {
|
||||
if (msg?.ok) finish(resolve, msg.result);
|
||||
else finish(reject, new Error(msg?.error || "sync failed"));
|
||||
});
|
||||
worker.on("error", (err) => finish(reject, err));
|
||||
worker.on("exit", (code) => finish(reject, new Error(`worker exited with ${code}`)));
|
||||
});
|
||||
}
|
||||
|
||||
// Run one sync. Returns the worker summary, or null when it could not complete.
|
||||
export async function syncModelCatalog() {
|
||||
if (state.running) return null;
|
||||
state.running = true;
|
||||
try {
|
||||
const result = await runWorker(await collectEntries());
|
||||
if (result.status === "updated") {
|
||||
state.etag = result.etag;
|
||||
invalidateCatalog();
|
||||
console.log(`[modelCatalog] ${result.models} models, ${result.providers} providers, ${(result.bytes / 1024).toFixed(1)}KB`);
|
||||
}
|
||||
state.lastSync = Date.now();
|
||||
state.lastError = null;
|
||||
state.lastResult = result;
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.lastError = error?.message || String(error);
|
||||
console.log(`[modelCatalog] sync failed: ${state.lastError}`);
|
||||
return null;
|
||||
} finally {
|
||||
state.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the recurring sync. Disable entirely with MODEL_CATALOG_SYNC=off.
|
||||
export function startModelCatalogSync() {
|
||||
if (timer) return;
|
||||
if (String(process.env.MODEL_CATALOG_SYNC || "").toLowerCase() === "off") return;
|
||||
|
||||
const schedule = (delay) => {
|
||||
timer = setTimeout(async () => {
|
||||
const result = await syncModelCatalog();
|
||||
schedule(result ? SYNC_INTERVAL_MS : RETRY_DELAY_MS);
|
||||
}, delay);
|
||||
timer.unref?.();
|
||||
};
|
||||
schedule(STARTUP_DELAY_MS);
|
||||
}
|
||||
Reference in New Issue
Block a user