mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
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>
681 lines
22 KiB
TypeScript
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);
|
|
}
|