/** * modelsDevSync.ts — Fetch model specs, pricing, and capabilities from models.dev * * models.dev (https://github.com/anomalyco/models.dev) is an open-source database * of AI model specifications maintained by the SST/OpenCode team (MIT license). * * API: https://models.dev/api.json * - 109 providers, 4,146+ models * - Data: pricing, capabilities, limits, modalities, metadata * * Resolution order (highest → lowest): * 1. User overrides (`pricing` namespace) * 2. models.dev sync (`models_dev_pricing` namespace) * 3. LiteLLM sync (`pricing_synced` namespace) * 4. Hardcoded defaults (`pricing.ts`) * * Opt-in via MODELS_DEV_SYNC_ENABLED=true (default: false). */ import { getDbInstance } from "./db/core"; import { invalidateDbCache } from "./db/readCache"; import { backupDbFile } from "./db/backup"; import { transformModelsDevToPricing, transformModelsDevToCapabilities, } from "./modelsDevSync/transform"; import type { PricingModels, PricingByProvider, ModelCapabilityEntry, CapabilitiesByProvider, ModelsDevData, } from "./modelsDevSync/transform"; // Re-export the pure transform layer (moved to ./modelsDevSync/transform) // so this module's public API is unchanged. export { mapProviderId, transformModelsDevToPricing, transformModelsDevToCapabilities, } from "./modelsDevSync/transform"; export type { ModelCapabilityEntry, CapabilitiesByProvider } from "./modelsDevSync/transform"; // ─── Types ─────────────────────────────────────────────── interface SyncStatus { enabled: boolean; lastSync: string | null; lastSyncModelCount: number; lastSyncCapabilityCount: number; nextSync: string | null; intervalMs: number; } interface SyncResult { success: boolean; modelCount: number; providerCount: number; capabilityCount: number; dryRun: boolean; data?: { pricing: PricingByProvider; capabilities: CapabilitiesByProvider }; error?: string; } // ─── Configuration ─────────────────────────────────────── const MODELS_DEV_API_URL = "https://models.dev/api.json"; const parsedInterval = parseInt(process.env.MODELS_DEV_SYNC_INTERVAL || "86400", 10); const SYNC_INTERVAL_MS = Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval * 1000 : 86400 * 1000; // ─── Periodic sync state ───────────────────────────────── let syncTimer: ReturnType | null = null; let activeSyncAbortController: AbortController | null = null; let activeSyncPromise: Promise | null = null; let activePeriodicSyncToken: { stopped: boolean } | null = null; let lastSyncTime: string | null = null; let lastSyncModelCount = 0; let lastSyncCapabilityCount = 0; let activeSyncIntervalMs = SYNC_INTERVAL_MS; let cachedData: ModelsDevData | null = null; let cacheTime = 0; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours let cachedCapabilities: CapabilitiesByProvider | null = null; let cachedCapabilitiesLoadedAll = false; const MODELS_DEV_ABORT_ERROR = "AbortError"; function createAbortError(): Error { const error = new Error("models.dev sync aborted"); error.name = MODELS_DEV_ABORT_ERROR; return error; } function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === MODELS_DEV_ABORT_ERROR; } function createAbortedSyncResult(dryRun: boolean): SyncResult { return { success: false, modelCount: 0, providerCount: 0, capabilityCount: 0, dryRun, error: "aborted", }; } async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise { if (signal?.aborted) { throw createAbortError(); } await new Promise((resolve, reject) => { const timeout = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(timeout); reject(createAbortError()); }; signal?.addEventListener("abort", onAbort, { once: true }); }); } // ─── Core: Fetch ───────────────────────────────────────── /** * Fetch raw data from models.dev API. * Uses in-memory cache with 24h TTL to avoid repeated fetches. */ export async function fetchModelsDev(signal?: AbortSignal): Promise { // Return cached data if still fresh if (cachedData && Date.now() - cacheTime < CACHE_TTL_MS) { return cachedData; } const response = await fetch(MODELS_DEV_API_URL, { signal: signal ?? AbortSignal.timeout(30000), }); if (!response.ok) { throw new Error(`models.dev fetch failed [${response.status}]: ${response.statusText}`); } const text = await response.text(); try { const data = JSON.parse(text) as ModelsDevData; cachedData = data; cacheTime = Date.now(); return data; } catch { throw new Error(`models.dev returned invalid JSON (${text.slice(0, 100)}...)`); } } // ─── DB: models.dev pricing namespace ──────────────────── function toRecord(value: unknown): Record { return value && typeof value === "object" ? (value as Record) : {}; } function mapCapabilityRecord(record: Record): ModelCapabilityEntry { return { tool_call: record.tool_call === 1 ? true : record.tool_call === 0 ? false : null, reasoning: record.reasoning === 1 ? true : record.reasoning === 0 ? false : null, attachment: record.attachment === 1 ? true : record.attachment === 0 ? false : null, structured_output: record.structured_output === 1 ? true : record.structured_output === 0 ? false : null, temperature: record.temperature === 1 ? true : record.temperature === 0 ? false : null, modalities_input: typeof record.modalities_input === "string" ? record.modalities_input : "[]", modalities_output: typeof record.modalities_output === "string" ? record.modalities_output : "[]", knowledge_cutoff: typeof record.knowledge_cutoff === "string" ? record.knowledge_cutoff : null, release_date: typeof record.release_date === "string" ? record.release_date : null, last_updated: typeof record.last_updated === "string" ? record.last_updated : null, status: typeof record.status === "string" ? record.status : null, family: typeof record.family === "string" ? record.family : null, open_weights: record.open_weights === 1 ? true : record.open_weights === 0 ? false : null, limit_context: typeof record.limit_context === "number" ? record.limit_context : null, limit_input: typeof record.limit_input === "number" ? record.limit_input : null, limit_output: typeof record.limit_output === "number" ? record.limit_output : null, interleaved_field: typeof record.interleaved_field === "string" ? record.interleaved_field : null, }; } /** * Read synced pricing from `models_dev_pricing` namespace. */ export function getModelsDevPricing(): PricingByProvider { const db = getDbInstance(); const rows = db .prepare("SELECT key, value FROM key_value WHERE namespace = 'models_dev_pricing'") .all(); const synced: PricingByProvider = {}; for (const row of rows) { const record = toRecord(row); const key = typeof record.key === "string" ? record.key : null; const rawValue = typeof record.value === "string" ? record.value : null; if (!key || rawValue === null) continue; try { synced[key] = JSON.parse(rawValue) as PricingModels; } catch { console.warn(`[MODELS_DEV] Corrupted pricing data for provider "${key}", skipping`); } } return synced; } /** * Save synced pricing to `models_dev_pricing` namespace (full replace). */ export function saveModelsDevPricing(data: PricingByProvider): void { const db = getDbInstance(); const del = db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'"); const insert = db.prepare( "INSERT INTO key_value (namespace, key, value) VALUES ('models_dev_pricing', ?, ?)" ); const tx = db.transaction(() => { del.run(); for (const [provider, models] of Object.entries(data)) { insert.run(provider, JSON.stringify(models)); } }); tx(); backupDbFile("pre-write"); invalidateDbCache("pricing"); } /** * Clear all models.dev synced pricing data. */ export function clearModelsDevPricing(): void { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run(); backupDbFile("pre-write"); invalidateDbCache("pricing"); } // ─── DB: model_capabilities table ──────────────────────── /** * Ensure the model_capabilities table exists. * Call this before any capability operations. */ export function ensureCapabilitiesTable(): void { const db = getDbInstance(); db.exec(` CREATE TABLE IF NOT EXISTS model_capabilities ( provider TEXT NOT NULL, model_id TEXT NOT NULL, tool_call BOOLEAN, reasoning BOOLEAN, attachment BOOLEAN, structured_output BOOLEAN, temperature BOOLEAN, modalities_input TEXT, modalities_output TEXT, knowledge_cutoff TEXT, release_date TEXT, last_updated TEXT, status TEXT, family TEXT, open_weights BOOLEAN, limit_context INTEGER, limit_input INTEGER, limit_output INTEGER, interleaved_field TEXT, last_synced TEXT, PRIMARY KEY (provider, model_id) ) `); } /** * Read synced capabilities from `model_capabilities` table. */ export function getSyncedCapabilities(provider?: string, modelId?: string): CapabilitiesByProvider { if (cachedCapabilitiesLoadedAll) { if (!provider) { return cachedCapabilities || {}; } if (!modelId) { return cachedCapabilities?.[provider] ? { [provider]: cachedCapabilities[provider] } : {}; } const providerCaps = cachedCapabilities?.[provider]; return providerCaps?.[modelId] ? { [provider]: { [modelId]: providerCaps[modelId] } } : {}; } const db = getDbInstance(); ensureCapabilitiesTable(); let query = "SELECT * FROM model_capabilities"; const params: (string | number)[] = []; if (provider) { query += " WHERE provider = ?"; params.push(provider); if (modelId) { query += " AND model_id = ?"; params.push(modelId); } } const rows = db.prepare(query).all(...params); const result: CapabilitiesByProvider = {}; for (const row of rows) { const record = toRecord(row); const prov = typeof record.provider === "string" ? record.provider : null; const mid = typeof record.model_id === "string" ? record.model_id : null; if (!prov || !mid) continue; if (!result[prov]) result[prov] = {}; result[prov][mid] = mapCapabilityRecord(record); } if (!provider && !modelId) { cachedCapabilities = result; cachedCapabilitiesLoadedAll = true; } return result; } /** * Resolved providers/aliases to also try when looking up a synced capability. * Required because models.dev has historically stored capability rows under the * alias side of an alias pair (e.g. "opencode-zen") while the catalog & combo * targets reference the canonical id (e.g. "opencode"). Without this fallback, * combos whose targets use the canonical id (e.g. "Opencode FREE Omni" → all * `opencode/...` models) end up with `context_length: null` in the catalog. */ const SYNCED_CAPABILITY_FALLBACK_ALIASES: Record = { opencode: ["opencode-zen"], "opencode-zen": ["opencode"], "opencode-go": ["opencode-zen"], }; export function getSyncedCapability( provider: string, modelId: string ): ModelCapabilityEntry | null { if (!provider || !modelId) return null; // Fast path: every provider is in the in-memory cache, skip SQLite entirely. if (cachedCapabilitiesLoadedAll) { const lookupCached = (p: string) => cachedCapabilities?.[p]?.[modelId] ?? null; const directCached = lookupCached(provider); if (directCached) return directCached; const fallbacks = SYNCED_CAPABILITY_FALLBACK_ALIASES[provider]; if (fallbacks) { for (const alt of fallbacks) { const found = lookupCached(alt); if (found) return found; } } return null; } // Cold path: hit SQLite. Prepare the statement once, reuse for every alias. const db = getDbInstance(); ensureCapabilitiesTable(); const stmt = db.prepare( "SELECT * FROM model_capabilities WHERE provider = ? AND model_id = ? LIMIT 1" ); const lookupDb = (p: string): ModelCapabilityEntry | null => { const row = stmt.get(p, modelId); if (!row) return null; return mapCapabilityRecord(toRecord(row)); }; const direct = lookupDb(provider); if (direct) return direct; const fallbacks = SYNCED_CAPABILITY_FALLBACK_ALIASES[provider]; if (fallbacks) { for (const alt of fallbacks) { const found = lookupDb(alt); if (found) return found; } } return null; } /** * Save synced capabilities to `model_capabilities` table (full replace). */ export function saveModelsDevCapabilities(data: CapabilitiesByProvider): void { const db = getDbInstance(); ensureCapabilitiesTable(); const del = db.prepare("DELETE FROM model_capabilities"); const insert = db.prepare(` INSERT INTO model_capabilities ( provider, model_id, tool_call, reasoning, attachment, structured_output, temperature, modalities_input, modalities_output, knowledge_cutoff, release_date, last_updated, status, family, open_weights, limit_context, limit_input, limit_output, interleaved_field, last_synced ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const now = new Date().toISOString(); const tx = db.transaction(() => { del.run(); for (const [provider, models] of Object.entries(data)) { for (const [modelId, cap] of Object.entries(models)) { insert.run( provider, modelId, cap.tool_call === null ? null : cap.tool_call ? 1 : 0, cap.reasoning === null ? null : cap.reasoning ? 1 : 0, cap.attachment === null ? null : cap.attachment ? 1 : 0, cap.structured_output === null ? null : cap.structured_output ? 1 : 0, cap.temperature === null ? null : cap.temperature ? 1 : 0, cap.modalities_input, cap.modalities_output, cap.knowledge_cutoff, cap.release_date, cap.last_updated, cap.status, cap.family, cap.open_weights === null ? null : cap.open_weights ? 1 : 0, cap.limit_context, cap.limit_input, cap.limit_output, cap.interleaved_field, now ); } } }); tx(); backupDbFile("pre-write"); cachedCapabilities = data; cachedCapabilitiesLoadedAll = true; } /** * Clear all synced capability data. */ export function clearModelsDevCapabilities(): void { const db = getDbInstance(); ensureCapabilitiesTable(); db.prepare("DELETE FROM model_capabilities").run(); backupDbFile("pre-write"); cachedCapabilities = {}; cachedCapabilitiesLoadedAll = true; } // ─── Main sync function ────────────────────────────────── /** * Fetch, transform, and save pricing + capabilities from models.dev. */ export async function syncModelsDev(opts?: { dryRun?: boolean; syncCapabilities?: boolean; maxRetries?: number; signal?: AbortSignal; }): Promise { const dryRun = opts?.dryRun ?? false; const syncCapabilities = opts?.syncCapabilities ?? true; const maxRetries = opts?.maxRetries ?? 3; const signal = opts?.signal; let lastError: Error | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { if (signal?.aborted) { return createAbortedSyncResult(dryRun); } try { const raw = await fetchModelsDev(signal); const pricing = transformModelsDevToPricing(raw); const capabilities = syncCapabilities ? transformModelsDevToCapabilities(raw) : {}; const modelCount = Object.values(pricing).reduce( (sum, models) => sum + Object.keys(models).length, 0 ); const providerCount = Object.keys(pricing).length; const capabilityCount = syncCapabilities ? Object.values(capabilities).reduce((sum, models) => sum + Object.keys(models).length, 0) : 0; if (signal?.aborted) { return createAbortedSyncResult(dryRun); } if (!dryRun) { saveModelsDevPricing(pricing); if (syncCapabilities) { ensureCapabilitiesTable(); saveModelsDevCapabilities(capabilities); } lastSyncTime = new Date().toISOString(); lastSyncModelCount = modelCount; lastSyncCapabilityCount = capabilityCount; } return { success: true, modelCount, providerCount, capabilityCount, dryRun, ...(dryRun ? { data: { pricing, capabilities } } : {}), }; } catch (err) { if (signal?.aborted || isAbortError(err)) { return createAbortedSyncResult(dryRun); } lastError = err instanceof Error ? err : new Error(String(err)); if (attempt < maxRetries) { const delayMs = Math.pow(2, attempt) * 1000; // Exponential backoff: 1s, 2s, 4s console.warn( `[MODELS_DEV] Sync attempt ${attempt + 1} failed, retrying in ${delayMs}ms:`, lastError.message ); try { await sleepWithAbort(delayMs, signal); } catch (sleepError) { if (signal?.aborted || isAbortError(sleepError)) { return createAbortedSyncResult(dryRun); } throw sleepError; } } } } const message = lastError?.message || "Unknown error"; console.warn(`[MODELS_DEV] Sync failed after ${maxRetries + 1} attempts:`, message); return { success: false, modelCount: 0, providerCount: 0, capabilityCount: 0, dryRun, error: message, }; } // ─── Periodic sync ─────────────────────────────────────── /** * Start periodic models.dev sync (non-blocking). */ export function startPeriodicSync(intervalMs?: number): void { if (syncTimer) return; // Already running const interval = intervalMs ?? SYNC_INTERVAL_MS; activeSyncIntervalMs = interval; const syncToken = { stopped: false }; activePeriodicSyncToken = syncToken; console.log(`[MODELS_DEV] Starting periodic sync every ${interval / 1000}s`); const launchSync = () => { if (syncToken.stopped) { return Promise.resolve(createAbortedSyncResult(false)); } if (activeSyncPromise) return activeSyncPromise; const controller = new AbortController(); activeSyncAbortController = controller; const promise = syncModelsDev({ signal: controller.signal }).finally(() => { if (activeSyncAbortController === controller) { activeSyncAbortController = null; } if (activeSyncPromise === promise) { activeSyncPromise = null; } }); activeSyncPromise = promise; return promise; }; // Initial sync (non-blocking) launchSync() .then((result) => { if (result.success) { console.log( `[MODELS_DEV] Initial sync complete: ${result.modelCount} pricing entries, ${result.capabilityCount} capabilities from ${result.providerCount} providers` ); } }) .catch((err) => { console.warn("[MODELS_DEV] Initial sync error:", err instanceof Error ? err.message : err); }); syncTimer = setInterval(() => { launchSync() .then((result) => { if (result.success) { console.log(`[MODELS_DEV] Periodic sync complete: ${result.modelCount} pricing entries`); } }) .catch((err) => { console.warn("[MODELS_DEV] Periodic sync error:", err instanceof Error ? err.message : err); }); }, interval); if (syncTimer && typeof syncTimer === "object" && "unref" in syncTimer) { (syncTimer as { unref?: () => void }).unref?.(); } } /** * Stop periodic sync and cleanup timer. */ export function stopPeriodicSync(): void { if (activePeriodicSyncToken) { activePeriodicSyncToken.stopped = true; activePeriodicSyncToken = null; } if (activeSyncAbortController) { activeSyncAbortController.abort(); activeSyncAbortController = null; } if (syncTimer) { clearInterval(syncTimer); syncTimer = null; console.log("[MODELS_DEV] Periodic sync stopped"); } } /** * Get current sync status. */ export function getSyncStatus(): SyncStatus { // If the sync timer is active, it's enabled. const enabled = syncTimer !== null; return { enabled, lastSync: lastSyncTime, lastSyncModelCount, lastSyncCapabilityCount, nextSync: syncTimer && lastSyncTime ? new Date(new Date(lastSyncTime).getTime() + activeSyncIntervalMs).toISOString() : null, intervalMs: activeSyncIntervalMs, }; } // ─── Init (called from server-init.ts) ─────────────────── /** * Initialize models.dev sync if enabled. */ export async function initModelsDevSync(): Promise { const { getSettings } = await import("./localDb"); const settings = await getSettings(); if (settings.modelsDevSyncEnabled !== true) { console.log("[MODELS_DEV] Disabled (enable via Settings > AI)"); return; } const interval = settings.modelsDevSyncInterval as number | undefined; startPeriodicSync(interval); }