mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
Move the models.dev data-model types, the provider-id mapping table (MODELS_DEV_PROVIDER_MAP + mapProviderId), and the raw->OmniRoute transforms (transformModelsDevToPricing, transformModelsDevToCapabilities) into the pure leaf src/lib/modelsDevSync/transform.ts (zero imports, no DB, no module state). modelsDevSync.ts keeps all sync orchestration, DB access, caches and the periodic-sync timer; it imports the transforms for internal use and re-exports mapProviderId/transformModelsDevToPricing/transformModelsDevToCapabilities plus the ModelCapabilityEntry/CapabilitiesByProvider types, so the public API is unchanged. Host 924->677 LOC; leaf 279 LOC (< 800 cap); host was frozen-satisfied (934), so this is debt reduction. 238 moved lines are byte-identical. New split-guard test characterizes the provider map + both transforms and proves the host re-exports them.
678 lines
22 KiB
TypeScript
678 lines
22 KiB
TypeScript
/**
|
|
* 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<typeof setInterval> | null = null;
|
|
let activeSyncAbortController: AbortController | null = null;
|
|
let activeSyncPromise: Promise<SyncResult> | 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<void> {
|
|
if (signal?.aborted) {
|
|
throw createAbortError();
|
|
}
|
|
|
|
await new Promise<void>((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<ModelsDevData> {
|
|
// 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<string, unknown> {
|
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function mapCapabilityRecord(record: Record<string, unknown>): 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<string, string[]> = {
|
|
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<SyncResult> {
|
|
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<void> {
|
|
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);
|
|
}
|