Files
OmniRoute/src/lib/modelsDevSync.ts
Dizzle e50f2329dc fix(lib): memoize catalog pricing/capability lookups to fix cold /v1/models freeze (#8697) (#8987)
Root cause: a cold GET /v1/models catalog rebuild froze the entire server 41-54s.
node --prof profiling found a systemic missing-memoization pattern — a per-model
function rescanning a static or synced data structure with Object.entries()/
Object.keys() (or hitting SQLite) on every call instead of once per rebuild. Fixed
6 instances of the same pattern, found by iteratively re-profiling the full catalog
sweep after each fix (plus a whitebox review pass) until no further hotspot of this
shape remained:

1. getModelsDevPricing() (modelsDevSync.ts) — re-ran a synchronous SQLite query and
   re-JSON.parse'd ~180 blobs on every call (up to ~6091x instead of once per
   request). Memoized via the existing modelCatalogCacheVersion invalidation signal
   (same pattern as getCachedRawProviderConnections/getCachedProviderNodes in
   db/readCache.ts). Dominant cost of the original 41-54s freeze.

2. findInsensitive() (modelMetadataRegistry.ts, resolveCatalogPricing) — rebuilt a
   full Object.entries() scan on every case-insensitive lookup miss, twice per
   model. Replaced with a lowercase-key index built once per distinct pricing
   object and cached by identity (WeakMap). Warns once at index-build time on a
   case-insensitive key collision instead of silently discarding the second value.

3. getSyncedCapability() (modelsDevSync.ts) — ran a per-model SQLite SELECT on cold
   cache instead of self-warming the whole-table cache; no caller in the
   /v1/models build path ever primed it, so a cold rebuild ran one SQLite
   round-trip per model per call site. Now self-warms via the existing bulk
   getSyncedCapabilities() on first miss. Measured as the dominant remaining cost
   after fixes 1-2 (~70% of a full catalog sweep).

4. getCanonicalModelSpecId() (shared/constants/modelSpecs.ts) — up to 3 separate
   linear scans over the static MODEL_SPECS table per call (exact ci, alias ci,
   prefix). Replaced with a lazy, lowercase-key index built once (MODEL_SPECS never
   changes at runtime); prefix-match iteration order preserved exactly so
   resolution outcomes are unchanged.

5. getStaticSpecCanonicalModelId() (modelCapabilities.ts) — duplicated the same
   exact+alias scan as (4) in a second, separate rescan. Now reuses the shared
   index via a new exported helper (findModelSpecIdByExactOrAlias) instead of
   maintaining a second cache over the same static table.
   reverseModelsDevProviders() (modelCapabilities.ts) — rescanned
   Object.entries(MODELS_DEV_PROVIDER_MAP) (also static) on every call; memoized
   by provider key. Result is frozen (readonly) since it is now shared across
   calls instead of freshly allocated each time.

6. resolveModelAlias() (shared/constants/modelSpecs.ts) — rescanned
   Object.entries(MODEL_SPECS) unconditionally once per model (verified 1:1 call
   ratio, no short-circuit). Case-sensitive exact match (Array.includes(), no
   .toLowerCase()) — uses a dedicated exact-match index, deliberately not the
   case-insensitive alias index from fix 4/5 (would silently broaden matches).

Measured on a 1940-pair real-catalog sample (static PROVIDER_MODELS registry):
cold sweep 828ms -> 356ms after fixes 3-5 on top of 1-2, extrapolating to roughly
1s on the real ~6091-model catalog, down from the original 41-54s freeze.

Complementary to the stale-serve fix in #8801 (upstream) — neither alone
eliminates the freeze.

Tests: call-count regression guards for every fix (DB prepare / Object.entries /
Object.keys call counts staying constant instead of scaling with iteration count),
plus correctness coverage for case-insensitive/case-sensitive resolution. All
pre-existing consumer suites re-verified passing (96 tests total across 19 files).

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-04 14:34:10 -03:00

681 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, getModelCatalogCacheVersion } 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,
PricingByProvider,
} 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,
};
}
// #8697: getModelsDevPricing() re-ran the SELECT + JSON.parse of ~180 blobs on
// every call — called once per catalog model (up to ~6091x) instead of once per
// request, freezing the whole server 41-54s on a cold /v1/models rebuild.
// Memoized here, invalidated via the same modelCatalogCacheVersion signal
// save/clearModelsDevPricing already bump through invalidateDbCache("pricing") —
// reusing the existing pattern (getCachedRawProviderConnections et al. in
// db/readCache.ts) instead of introducing a new invalidation mechanism.
let pricingMemo: PricingByProvider | null = null;
let pricingMemoVersion = -1; // -1: never equals a real cacheVersion (starts at 0), guarantees a miss on the first call
/**
* Read synced pricing from `models_dev_pricing` namespace.
*/
export function getModelsDevPricing(): PricingByProvider {
const currentVersion = getModelCatalogCacheVersion();
if (pricingMemo !== null && pricingMemoVersion === currentVersion) {
return pricingMemo;
}
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`);
}
}
pricingMemo = synced;
pricingMemoVersion = currentVersion;
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;
// #8697-adjacent: this used to hit SQLite with a per-model SELECT on every cold
// call, relying on some other caller (getSyncedCapabilities() with no args) to have
// already warmed the whole-table cache first — no such caller sits in the /v1/models
// catalog build path, so a cold rebuild ran one SQLite round-trip per model per call
// site instead of one bulk read for the whole rebuild. Self-warm here instead of
// depending on an external caller.
if (!cachedCapabilitiesLoadedAll) {
getSyncedCapabilities();
}
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;
}
/**
* 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);
}