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>
141 lines
4.6 KiB
JavaScript
141 lines
4.6 KiB
JavaScript
// 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);
|
|
}
|