import "server-only"; import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { getDb, models, modelSyncRuns, userModelFavorites, userModelRecents, type ModelRow } from "@/db"; import { ids } from "@/lib/ids"; import { log } from "@/lib/log"; import { PROVIDER_IDS, type PolyModel, type ProviderId } from "@/lib/ai/core/types"; import { getAdapter } from "@/lib/ai/providers"; import { getCatalog } from "./catalog"; import { ownerKey } from "@/lib/ai/providers/env-keys"; export function rowToModel(r: ModelRow): PolyModel { return { key: r.key, id: r.modelId, provider: r.provider, displayName: r.displayName, family: r.family ?? undefined, capabilities: r.capabilities, limits: r.limits, parameters: r.parameters, status: r.status, pricing: r.pricing ?? null, metadata: { ...r.metadata, sortWeight: r.sortWeight, source: r.source, firstSeenAt: r.firstSeenAt.toISOString(), lastSeenAt: r.lastSeenAt.toISOString() }, }; } /** Merge discovered models (from the provider API) with the documented catalog (pricing, limits, quirks). */ export function mergeWithCatalog(discovered: PolyModel[], provider: ProviderId): PolyModel[] { const catalog = getCatalog(provider); const byId = new Map(discovered.map((m) => [m.id, m])); const merged: PolyModel[] = []; for (const m of discovered) { const c = catalog.get(m.id) ?? catalog.get(m.id.replace(/-\d{4}-\d{2}-\d{2}$|-\d{8}$/, "")); if (!c) { merged.push({ ...m, metadata: { ...m.metadata, source: "discovery" } }); continue; } merged.push({ ...m, displayName: c.displayName ?? m.displayName, family: m.family ?? c.family, capabilities: { ...c.capabilities, ...m.capabilities, ...(c.capabilityOverrides ?? {}) }, limits: { ...(c.limits ?? {}), ...(m.limits ?? {}) }, parameters: { ...(c.parameters ?? {}), ...m.parameters, ...(c.parameterOverrides ?? {}) }, pricing: m.pricing ?? c.pricing ?? null, status: c.status ?? m.status, metadata: { ...(c.metadata ?? {}), ...m.metadata, source: "merged", sortWeight: c.sortWeight ?? (m.metadata?.sortWeight as number | undefined) ?? 0 }, }); } // Catalog-only entries (documented but not returned by this key) are not added: the key can't use them. void byId; return merged; } export interface SyncResult { provider: ProviderId; ok: boolean; found: number; added: number; removed: number; latencyMs: number; error?: string; } /** * Sync one provider's model list into the registry. Models not seen anymore are kept but * marked `hidden` (so historical usage keeps its metadata) unless they reappear. */ export async function syncProvider(provider: ProviderId, apiKey: string, triggeredBy = "schedule"): Promise { const db = getDb(); const runId = ids.sync(); const t0 = Date.now(); await db.insert(modelSyncRuns).values({ id: runId, provider, triggeredBy }); try { const discovered = await getAdapter(provider).listModels(apiKey); const merged = mergeWithCatalog(discovered, provider); const existing = await db.select({ key: models.key, hidden: models.hidden }).from(models).where(eq(models.provider, provider)); const existingKeys = new Set(existing.map((e) => e.key)); const seen = new Set(); let added = 0; const now = new Date(); for (const m of merged) { seen.add(m.key); if (!existingKeys.has(m.key)) added++; const { sortWeight, source, ...meta } = (m.metadata ?? {}) as Record; await db .insert(models) .values({ key: m.key, provider, modelId: m.id, displayName: m.displayName, family: m.family ?? null, capabilities: m.capabilities, limits: m.limits ?? {}, parameters: m.parameters, pricing: m.pricing ?? null, status: m.status, source: (source as string) ?? "discovery", hidden: false, sortWeight: Number(sortWeight ?? 0), metadata: meta, firstSeenAt: now, lastSeenAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: models.key, set: { displayName: m.displayName, family: m.family ?? null, capabilities: m.capabilities, limits: m.limits ?? {}, parameters: m.parameters, pricing: m.pricing ?? null, status: m.status, source: (source as string) ?? "discovery", hidden: false, sortWeight: Number(sortWeight ?? 0), metadata: meta, lastSeenAt: now, updatedAt: now, }, }); } const gone = existing.filter((e) => !seen.has(e.key) && !e.hidden).map((e) => e.key); if (gone.length) await db.update(models).set({ hidden: true, updatedAt: now }).where(inArray(models.key, gone)); const latencyMs = Date.now() - t0; await db.update(modelSyncRuns).set({ finishedAt: new Date(), ok: true, modelsFound: merged.length, modelsAdded: added, modelsRemoved: gone.length, latencyMs }).where(eq(modelSyncRuns.id, runId)); log.info("model sync ok", { provider, found: merged.length, added, removed: gone.length, latencyMs, triggeredBy }); return { provider, ok: true, found: merged.length, added, removed: gone.length, latencyMs }; } catch (e) { const err = e as { code?: string; message?: string }; const latencyMs = Date.now() - t0; await db.update(modelSyncRuns).set({ finishedAt: new Date(), ok: false, latencyMs, errorCode: err.code ?? "UNKNOWN", errorMessage: (err.message ?? String(e)).slice(0, 500) }).where(eq(modelSyncRuns.id, runId)); log.warn("model sync failed", { provider, code: err.code, error: err.message, triggeredBy }); return { provider, ok: false, found: 0, added: 0, removed: 0, latencyMs, error: err.message }; } } /** Sync every provider for which an owner/dev key exists in the environment. */ export async function syncAllWithEnvKeys(triggeredBy = "schedule"): Promise { const results: SyncResult[] = []; await Promise.all( PROVIDER_IDS.map(async (p) => { const k = ownerKey(p); if (!k) return; results.push(await syncProvider(p, k, triggeredBy)); }), ); return results; } export async function listRegistryModels(opts: { provider?: ProviderId; includeHidden?: boolean; includeDeprecated?: boolean } = {}): Promise { const db = getDb(); const conds = []; if (opts.provider) conds.push(eq(models.provider, opts.provider)); if (!opts.includeHidden) conds.push(eq(models.hidden, false)); const rows = await db .select() .from(models) .where(conds.length ? and(...conds) : undefined) .orderBy(desc(models.sortWeight), models.displayName); return rows.filter((r) => opts.includeDeprecated || r.status !== "deprecated").map(rowToModel); } export async function getModel(key: string): Promise { const [row] = await getDb().select().from(models).where(eq(models.key, key)).limit(1); return row ? rowToModel(row) : null; } export async function registryStats() { const db = getDb(); const counts = await db .select({ provider: models.provider, total: sql`count(*)::int`, hidden: sql`sum(case when ${models.hidden} then 1 else 0 end)::int` }) .from(models) .groupBy(models.provider); const lastRuns = await db.select().from(modelSyncRuns).orderBy(desc(modelSyncRuns.startedAt)).limit(40); return { counts, lastRuns }; } export async function favoriteModels(userId: string): Promise { const rows = await getDb().select({ key: userModelFavorites.modelKey }).from(userModelFavorites).where(eq(userModelFavorites.userId, userId)); return rows.map((r) => r.key); } export async function toggleFavorite(userId: string, key: string): Promise { const db = getDb(); const existing = await db .select() .from(userModelFavorites) .where(and(eq(userModelFavorites.userId, userId), eq(userModelFavorites.modelKey, key))) .limit(1); if (existing.length) { await db.delete(userModelFavorites).where(and(eq(userModelFavorites.userId, userId), eq(userModelFavorites.modelKey, key))); return false; } await db.insert(userModelFavorites).values({ userId, modelKey: key }); return true; } export async function recentModels(userId: string, limit = 8): Promise { const rows = await getDb().select({ key: userModelRecents.modelKey }).from(userModelRecents).where(eq(userModelRecents.userId, userId)).orderBy(desc(userModelRecents.usedAt)).limit(limit); return rows.map((r) => r.key); } export async function touchRecent(userId: string, key: string) { await getDb() .insert(userModelRecents) .values({ userId, modelKey: key }) .onConflictDoUpdate({ target: [userModelRecents.userId, userModelRecents.modelKey], set: { usedAt: new Date(), uses: sql`${userModelRecents.uses} + 1` } }); }