Download server/src/services/catalog-sync.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 43.2 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/services/catalog-sync.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/server/src/services/catalog-sync.ts
-
curl -L -o catalog-sync.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/services/catalog-sync.ts
43.2 kB
| import crypto from 'crypto'; | |
| import type { Db } from '../db/types.js'; | |
| import { getDb, getSetting, setSetting } from '../db/index.js'; | |
| import { hasProvider } from '../providers/index.js'; | |
| import { MEDIA_PLATFORMS, TRANSCRIPTION_PLATFORMS, VIDEO_PLATFORMS } from './media.js'; | |
| import { EMBEDDING_PLATFORMS } from './embeddings.js'; | |
| import type { Platform } from '@freellmapi/shared/types.js'; | |
| import type { Scheduler } from '../lib/scheduler.js'; | |
| import { | |
| applyAllModelOverrides, | |
| refreshModelOverrideBaselines, | |
| routableContextWindow, | |
| applyModelOverrides, | |
| deleteTombstonedCatalogModels, | |
| isCatalogModelTombstoned, | |
| reinstateUpstreamRetiredCatalogModel, | |
| } from './model-state.js'; | |
| import { ensureAllModelsInProfiles } from './profile-models.js'; | |
| // Generative-media modalities are routed into the separate media_models table | |
| // (see services/media.ts), never into the chat `models` table. | |
| const MEDIA_MODALITIES = new Set(['image', 'audio']); | |
| /** | |
| * catalog-sync β keeps the local model catalog in step with the published one. | |
| * | |
| * Twice a day (and on demand) the server pulls the signed catalog from the | |
| * catalog service. A valid Premium license key (Bearer) gets the live tier, | |
| * refreshed every 2-3 days; everyone else gets the monthly snapshot β so free | |
| * installs still self-heal, just on a slower cadence. The response is verified | |
| * against a pinned Ed25519 public key over the exact bytes received; anything | |
| * unsigned or tampered with is discarded, which means a compromised CDN or | |
| * MITM cannot inject models or quirks into the router. | |
| * | |
| * The bundled migrations remain the baseline: a fetched catalog is applied | |
| * only when it is NEWER than what the binary shipped with (MIN_CATALOG_VERSION | |
| * below), so a stale monthly snapshot can never roll back models that a newer | |
| * app version added via migrations. | |
| */ | |
| const DEFAULT_BASE_URL = 'https://api.freellmapi.co'; | |
| // The Ed25519 public key the production catalog is signed with. The private | |
| // half was generated on the catalog host and has never left it. Self-hosters | |
| // running their own catalog server can override both via env. | |
| const PINNED_CATALOG_PUBKEY = `-----BEGIN PUBLIC KEY----- | |
| MCowBQYDK2VwAyEAq9yv4+3EeyMHKsfVYBhkcz1lYgIXSUeHNnN6tNgYX3k= | |
| -----END PUBLIC KEY----- | |
| `; | |
| // Catalogs older than this are ignored. Bump to today's date whenever a model | |
| // migration lands, so the bundled DB is always the floor. | |
| export const MIN_CATALOG_VERSION = '2026.06.07'; | |
| const SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000; // twice daily | |
| const BOOT_DELAY_MS = 10 * 1000; // let the server settle before first sync | |
| const FETCH_TIMEOUT_MS = 20 * 1000; | |
| // settings table keys | |
| export const SETTING_LICENSE_KEY = 'premium_license_key'; | |
| export const SETTING_LICENSE_STATUS = 'premium_license_status'; // JSON LicenseStatus | |
| const SETTING_APPLIED_VERSION = 'catalog_applied_version'; | |
| const SETTING_APPLIED_TIER = 'catalog_applied_tier'; | |
| const SETTING_APPLIED_JSON = 'catalog_applied_json'; | |
| const SETTING_LAST_SYNC_MS = 'catalog_last_sync_ms'; | |
| const SETTING_LAST_ERROR = 'catalog_last_error'; | |
| export function catalogBaseUrl(): string { | |
| return (process.env.CATALOG_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, ''); | |
| } | |
| function catalogPublicKey(): crypto.KeyObject { | |
| const pem = process.env.CATALOG_PUBKEY ? process.env.CATALOG_PUBKEY.replace(/\\n/g, '\n') : PINNED_CATALOG_PUBKEY; | |
| return crypto.createPublicKey({ key: pem, format: 'pem' }); | |
| } | |
| export interface LicenseStatus { | |
| valid: boolean; | |
| plan: 'annual' | 'lifetime' | null; | |
| status: string | null; | |
| expiresAt: string | null; | |
| cancelAtPeriodEnd?: boolean; | |
| reason?: string; | |
| checkedAtMs: number; | |
| } | |
| interface CatalogQuirk { | |
| slug: string; | |
| title: string; | |
| body: string; | |
| severity: 'blocker' | 'warning' | 'info'; | |
| targets: { platform: string | null; modelGlob: string | null }[]; | |
| } | |
| interface CatalogModel { | |
| platform: string; | |
| modelId: string; | |
| displayName: string; | |
| intelligenceRank: number; | |
| speedRank: number; | |
| sizeLabel: string; | |
| limits: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null }; | |
| monthlyTokenBudget: string | null; | |
| contextWindow: number | null; | |
| enabled: boolean; | |
| supportsVision: boolean; | |
| supportsTools: boolean; | |
| /** 'text' (default/absent) routes to the chat `models` table; 'image'/'audio' | |
| * route to the separate `media_models` table. */ | |
| modality?: string; | |
| /** Short display note for media models (e.g. "Keyless - up to 1024x1024"). */ | |
| mediaNote?: string; | |
| /** Adapter request flavor for media rows, where one platform hosts more than | |
| * one deployment style (cloudflare images: absent/'json' = JSON body, | |
| * 'multipart' = form-data, which the FLUX.2 family requires). Mirrors the | |
| * same field on CatalogTranscriptionModel and lands in meta_json. */ | |
| requestStyle?: string | null; | |
| } | |
| interface CatalogEmbedding { | |
| family: string; | |
| platform: string; | |
| modelId: string; | |
| displayName: string; | |
| dimensions: number; | |
| maxInputTokens: number | null; | |
| priority: number; | |
| enabled: boolean; | |
| quotaLabel: string; | |
| } | |
| interface CatalogTranscriptionModel { | |
| platform: string; | |
| modelId: string; | |
| displayName: string; | |
| /** Failover order within the STT chain, lower first. */ | |
| priority: number; | |
| enabled: boolean; | |
| /** Subtitle formats the provider returns natively (e.g. ['vtt']). */ | |
| subtitleFormats?: string[]; | |
| /** Provider upload ceiling in bytes; absent = the route-wide 25 MB cap. */ | |
| maxBytes?: number | null; | |
| /** Adapter request flavor where one platform hosts more than one deployment | |
| * style (cloudflare: 'json' = base64 JSON body, 'binary' = raw bytes). */ | |
| requestStyle?: string | null; | |
| /** Short display note, mirrored into media_models.quota_label. */ | |
| quotaLabel?: string; | |
| } | |
| interface CatalogVideoModel { | |
| platform: string; | |
| modelId: string; | |
| displayName: string; | |
| /** Failover order within the video chain, lower first. */ | |
| priority: number; | |
| enabled: boolean; | |
| /** Short display note, mirrored into media_models.quota_label. */ | |
| quotaLabel?: string; | |
| /** Provider-native deployment id when it differs from the public model id | |
| * (for example Hugging Face's fal.ai mapping). */ | |
| providerModelId?: string; | |
| } | |
| interface Catalog { | |
| version: string; | |
| generatedAt: string; | |
| tier: 'live' | 'monthly'; | |
| models: CatalogModel[]; | |
| /** Optional for backward compatibility with catalogs published before the | |
| * embedding registry joined the signed freshness feed. */ | |
| embeddings?: CatalogEmbedding[]; | |
| /** Speech-to-text registry, landing in media_models with | |
| * modality='transcription'. Deliberately a NEW top-level key rather than | |
| * more `models` entries: deployed binaries that predate the transcription | |
| * modality would ingest unknown-modality `models` entries as CHAT models, | |
| * while an unknown optional key is simply ignored by their isCatalog. */ | |
| transcriptionModels?: CatalogTranscriptionModel[]; | |
| /** Text-to-video registry. Kept out of `models` so pre-video binaries ignore | |
| * it rather than routing unknown-modality rows through chat. */ | |
| videoModels?: CatalogVideoModel[]; | |
| /** Platforms the catalog service manages even when this tier ships no rows | |
| * for them (#1348): a provider still inside its Premium window, or one the | |
| * audit deliberately keeps at zero rows. Names only, never model rows, so | |
| * the monthly snapshot can carry it without leaking Premium models. Any | |
| * platform listed here is off limits to built-in model discovery. Optional: | |
| * older catalogs omit it and older binaries ignore it. */ | |
| managedPlatforms?: string[]; | |
| quirks: CatalogQuirk[]; | |
| } | |
| export interface SyncResult { | |
| ok: boolean; | |
| action: 'applied' | 'up_to_date' | 'skipped_older' | 'error'; | |
| version?: string; | |
| tier?: string; | |
| detail?: string; | |
| counts?: { updated: number; inserted: number; removed: number; skippedUnknownPlatform: number; quirks: number }; | |
| } | |
| /** Minimal structural check β enough to fail loudly on a wrong/garbled body. */ | |
| function isCatalog(value: unknown): value is Catalog { | |
| const c = value as Catalog; | |
| return ( | |
| !!c && | |
| typeof c.version === 'string' && | |
| (c.tier === 'live' || c.tier === 'monthly') && | |
| Array.isArray(c.models) && | |
| Array.isArray(c.quirks) && | |
| (c.embeddings === undefined || | |
| (Array.isArray(c.embeddings) && | |
| c.embeddings.every( | |
| (m) => | |
| typeof m?.family === 'string' && | |
| typeof m?.platform === 'string' && | |
| typeof m?.modelId === 'string' && | |
| typeof m?.displayName === 'string' && | |
| typeof m?.dimensions === 'number' && | |
| typeof m?.priority === 'number' && | |
| typeof m?.enabled === 'boolean', | |
| ))) && | |
| (c.transcriptionModels === undefined || | |
| (Array.isArray(c.transcriptionModels) && | |
| c.transcriptionModels.every( | |
| (m) => | |
| typeof m?.platform === 'string' && | |
| typeof m?.modelId === 'string' && | |
| typeof m?.displayName === 'string' && | |
| typeof m?.priority === 'number' && | |
| typeof m?.enabled === 'boolean' && | |
| (m.subtitleFormats === undefined || | |
| (Array.isArray(m.subtitleFormats) && m.subtitleFormats.every((f) => typeof f === 'string'))) && | |
| (m.maxBytes === undefined || m.maxBytes === null || typeof m.maxBytes === 'number') && | |
| (m.requestStyle === undefined || m.requestStyle === null || typeof m.requestStyle === 'string'), | |
| ))) && | |
| (c.videoModels === undefined || | |
| (Array.isArray(c.videoModels) && | |
| c.videoModels.every( | |
| (m) => | |
| typeof m?.platform === 'string' && | |
| typeof m?.modelId === 'string' && | |
| typeof m?.displayName === 'string' && | |
| typeof m?.priority === 'number' && | |
| typeof m?.enabled === 'boolean' && | |
| (m.quotaLabel === undefined || typeof m.quotaLabel === 'string') && | |
| (m.providerModelId === undefined || typeof m.providerModelId === 'string'), | |
| ))) && | |
| c.models.every( | |
| (m) => | |
| typeof m?.platform === 'string' && | |
| typeof m?.modelId === 'string' && | |
| typeof m?.displayName === 'string' && | |
| typeof m?.enabled === 'boolean' && | |
| (m.requestStyle === undefined || m.requestStyle === null || typeof m.requestStyle === 'string') && | |
| !!m?.limits && | |
| typeof m.limits === 'object', | |
| ) && | |
| (c.managedPlatforms === undefined || | |
| (Array.isArray(c.managedPlatforms) && c.managedPlatforms.every((p) => typeof p === 'string'))) && | |
| c.quirks.every((q) => typeof q?.slug === 'string' && Array.isArray(q?.targets)) | |
| ); | |
| } | |
| /** | |
| * Apply a verified catalog to the local DB inside one transaction. | |
| * | |
| * Rules of engagement with user data: | |
| * - metadata (name, ranks, limits, context, capabilities) tracks the catalog | |
| * unless the user has an explicit local override; | |
| * - catalog enabled=false force-disables (the model is dead upstream), but | |
| * enabled=true never re-enables a model the user turned off themselves; | |
| * - rows the user created (models.source = 'user': custom providers, | |
| * declarative config, admin adds) are never updated, never deleted, and | |
| * never adopted β on a platform:model_id collision the user row wins and | |
| * the catalog entry is skipped outright; | |
| * - catalog models the user deleted stay deleted via tombstones, while models | |
| * auto-retired from an upstream 410/end-of-life response (#634) are only | |
| * disabled β a catalog that still lists them lifts the retirement; | |
| * - models that vanished from the catalog are deleted, exactly like the | |
| * dead-model migrations do (fallback_config row first, FK order); | |
| * - rows found by built-in model discovery (models.source = 'discovered', | |
| * #1348) are a stopgap for platforms the catalog does not manage. The | |
| * catalog outranks them: a listed platform:model_id adopts the discovered | |
| * row as a catalog row, and once the catalog manages a platform at all, | |
| * every other discovered row on it is retired. | |
| */ | |
| /** models.source for rows written by built-in model discovery (#1348). */ | |
| export const DISCOVERED_MODEL_SOURCE = 'discovered'; | |
| /** Every platform a catalog document manages: any row in any registry, plus | |
| * the names-only managedPlatforms list. */ | |
| function catalogPlatforms(catalog: Catalog): Set<string> { | |
| const platforms = new Set<string>(); | |
| for (const m of catalog.models) platforms.add(m.platform); | |
| for (const m of catalog.embeddings ?? []) platforms.add(m.platform); | |
| for (const m of catalog.transcriptionModels ?? []) platforms.add(m.platform); | |
| for (const m of catalog.videoModels ?? []) platforms.add(m.platform); | |
| for (const p of catalog.managedPlatforms ?? []) platforms.add(p); | |
| return platforms; | |
| } | |
| let appliedPlatformsCache: { raw: string; platforms: Set<string> } | null = null; | |
| /** | |
| * Platforms the catalog applied on this install manages (see catalogPlatforms), | |
| * read from the cached verified document. Empty when no catalog has been | |
| * applied yet. Built-in model discovery treats every platform in here as off | |
| * limits, so the audited catalog, not an upstream /models list, decides what | |
| * those providers serve. | |
| */ | |
| export function appliedCatalogPlatforms(db: Db): Set<string> { | |
| const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(SETTING_APPLIED_JSON) as { value: string } | undefined; | |
| const raw = row?.value ?? ''; | |
| if (appliedPlatformsCache && appliedPlatformsCache.raw === raw) return appliedPlatformsCache.platforms; | |
| let platforms = new Set<string>(); | |
| if (raw) { | |
| try { | |
| const parsed: unknown = JSON.parse(raw); | |
| if (isCatalog(parsed)) platforms = catalogPlatforms(parsed); | |
| } catch { /* an unreadable cache manages nothing; the DB checks still apply */ } | |
| } | |
| appliedPlatformsCache = { raw, platforms }; | |
| return platforms; | |
| } | |
| export function applyCatalog(db: Db, catalog: Catalog): NonNullable<SyncResult['counts']> { | |
| // One transaction for the whole apply (#1047): the body runs hundreds of | |
| // individual statements, and under WAL each one outside a transaction is its | |
| // own commit + fsync executed on the event loop. On slow storage that made | |
| // the 12-hourly sync freeze every in-flight request β including the 3-query | |
| // profile-activate POST β for minutes. | |
| return db.transaction(() => applyCatalogInner(db, catalog))(); | |
| } | |
| function applyCatalogInner(db: Db, catalog: Catalog): NonNullable<SyncResult['counts']> { | |
| const counts = { updated: 0, inserted: 0, removed: 0, skippedUnknownPlatform: 0, quirks: 0 }; | |
| const selectModel = db.prepare('SELECT id, enabled, source FROM models WHERE platform = ? AND model_id = ?'); | |
| const updateModel = db.prepare(` | |
| UPDATE models SET | |
| display_name = @displayName, intelligence_rank = @intelligenceRank, speed_rank = @speedRank, | |
| size_label = @sizeLabel, rpm_limit = @rpm, rpd_limit = @rpd, tpm_limit = @tpm, tpd_limit = @tpd, | |
| monthly_token_budget = @monthlyTokenBudget, context_window = @contextWindow, | |
| supports_vision = @supportsVision, supports_tools = @supportsTools, | |
| enabled = @enabled | |
| WHERE id = @id | |
| `); | |
| const adoptModel = db.prepare("UPDATE models SET source = 'catalog' WHERE id = ?"); | |
| const insertModel = db.prepare(` | |
| INSERT INTO models (platform, model_id, display_name, intelligence_rank, speed_rank, size_label, | |
| rpm_limit, rpd_limit, tpm_limit, tpd_limit, monthly_token_budget, context_window, | |
| enabled, supports_vision, supports_tools, source) | |
| VALUES (@platform, @modelId, @displayName, @intelligenceRank, @speedRank, @sizeLabel, | |
| @rpm, @rpd, @tpm, @tpd, @monthlyTokenBudget, @contextWindow, | |
| @enabled, @supportsVision, @supportsTools, 'catalog') | |
| `); | |
| // Generative-media models go to their own table (never the chat router's pool). | |
| const selectMedia = db.prepare('SELECT id, enabled FROM media_models WHERE platform = ? AND model_id = ?'); | |
| const updateMedia = db.prepare(` | |
| UPDATE media_models SET | |
| display_name = @displayName, modality = @modality, priority = @priority, | |
| quota_label = @quotaLabel, enabled = @enabled, meta_json = @metaJson | |
| WHERE id = @id | |
| `); | |
| const insertMedia = db.prepare(` | |
| INSERT INTO media_models (platform, model_id, display_name, modality, priority, enabled, quota_label, meta_json) | |
| VALUES (@platform, @modelId, @displayName, @modality, @priority, @enabled, @quotaLabel, @metaJson) | |
| `); | |
| // Transcription rows share media_models but carry adapter metadata in | |
| // meta_json (subtitle capability, upload ceiling, request flavor). | |
| const updateTranscription = db.prepare(` | |
| UPDATE media_models SET | |
| display_name = @displayName, modality = 'transcription', priority = @priority, | |
| quota_label = @quotaLabel, enabled = @enabled, meta_json = @metaJson | |
| WHERE id = @id | |
| `); | |
| const insertTranscription = db.prepare(` | |
| INSERT INTO media_models (platform, model_id, display_name, modality, priority, enabled, quota_label, meta_json) | |
| VALUES (@platform, @modelId, @displayName, 'transcription', @priority, @enabled, @quotaLabel, @metaJson) | |
| `); | |
| const selectEmbedding = db.prepare( | |
| 'SELECT id, enabled FROM embedding_models WHERE platform = ? AND model_id = ?', | |
| ); | |
| const updateEmbedding = db.prepare(` | |
| UPDATE embedding_models SET | |
| family = @family, display_name = @displayName, dimensions = @dimensions, | |
| max_input_tokens = @maxInputTokens, priority = @priority, | |
| quota_label = @quotaLabel, enabled = @enabled | |
| WHERE id = @id | |
| `); | |
| const insertEmbedding = db.prepare(` | |
| INSERT INTO embedding_models | |
| (family, platform, model_id, display_name, dimensions, max_input_tokens, | |
| priority, enabled, quota_label) | |
| VALUES | |
| (@family, @platform, @modelId, @displayName, @dimensions, @maxInputTokens, | |
| @priority, @enabled, @quotaLabel) | |
| `); | |
| const apply = db.transaction(() => { | |
| const inCatalog = new Set<string>(); | |
| const inMediaCatalog = new Set<string>(); | |
| const inEmbeddingCatalog = new Set<string>(); | |
| const inTranscriptionCatalog = new Set<string>(); | |
| const inVideoCatalog = new Set<string>(); | |
| for (const m of catalog.models) { | |
| // Media modalities are gated on MEDIA_PLATFORMS (decoupled from the chat | |
| // provider registry) and routed to media_models, then skip the chat path. | |
| const modality = m.modality ?? 'text'; | |
| if (MEDIA_MODALITIES.has(modality)) { | |
| if (!MEDIA_PLATFORMS.has(m.platform)) { | |
| counts.skippedUnknownPlatform++; | |
| continue; | |
| } | |
| if (isCatalogModelTombstoned(db, 'media', m.platform, m.modelId)) continue; | |
| inMediaCatalog.add(`${m.platform}:${m.modelId}`); | |
| const mrow = selectMedia.get(m.platform, m.modelId) as { id: number; enabled: number } | undefined; | |
| // Generative-media meta carries only the adapter request flavor today; | |
| // a row without one stores NULL so the adapter keeps its default. | |
| const mmeta: Record<string, unknown> = {}; | |
| if (typeof m.requestStyle === 'string') mmeta.requestStyle = m.requestStyle; | |
| const mfields = { | |
| displayName: m.displayName, | |
| modality, | |
| priority: m.intelligenceRank ?? 0, | |
| quotaLabel: m.mediaNote ?? '', | |
| metaJson: Object.keys(mmeta).length > 0 ? JSON.stringify(mmeta) : null, | |
| }; | |
| if (mrow) { | |
| const enabled = m.enabled ? mrow.enabled : 0; // catalog disable wins; local disable wins | |
| updateMedia.run({ ...mfields, id: mrow.id, enabled }); | |
| counts.updated++; | |
| } else { | |
| insertMedia.run({ ...mfields, platform: m.platform, modelId: m.modelId, enabled: m.enabled ? 1 : 0 }); | |
| counts.inserted++; | |
| } | |
| continue; | |
| } | |
| if (m.platform === 'custom' || !hasProvider(m.platform as Platform)) { | |
| // An older binary may receive models for providers it cannot route yet; | |
| // skip them β they will appear after the user updates the app. | |
| counts.skippedUnknownPlatform++; | |
| continue; | |
| } | |
| if (isCatalogModelTombstoned(db, 'chat', m.platform, m.modelId)) continue; | |
| // A model auto-retired from a 410/end-of-life response (#634) is disabled, | |
| // not deleted. A catalog that STILL lists it β and lists it enabled β is | |
| // newer evidence than that one provider response, so lift the retirement. | |
| if (m.enabled) reinstateUpstreamRetiredCatalogModel(db, m.platform, m.modelId); | |
| inCatalog.add(`${m.platform}:${m.modelId}`); | |
| const row = selectModel.get(m.platform, m.modelId) as | |
| | { id: number; enabled: number; source: string } | |
| | undefined; | |
| // Collision rule: if the user hand-added a model and the catalog later | |
| // ships the same platform:model_id, the user row wins β the catalog | |
| // neither clobbers its metadata nor adopts it (same spirit as the | |
| // never-touch rule for custom-provider models). The row also survives | |
| // the prune below because the delete pass only considers source='catalog'. | |
| if (row && row.source === 'user') continue; | |
| // #1348: a DISCOVERED row is the opposite case. Discovery only fills a | |
| // gap the catalog left, so once the catalog lists the model it takes the | |
| // row over: the metadata below replaces discovery's guesses and the row | |
| // becomes catalog-owned (pruned with the catalog from then on). Local | |
| // disables survive, like on any catalog row. | |
| if (row && row.source === DISCOVERED_MODEL_SOURCE) adoptModel.run(row.id); | |
| const fields = { | |
| displayName: m.displayName, | |
| intelligenceRank: m.intelligenceRank, | |
| speedRank: m.speedRank, | |
| sizeLabel: m.sizeLabel, | |
| rpm: m.limits.rpm, | |
| rpd: m.limits.rpd, | |
| tpm: m.limits.tpm, | |
| tpd: m.limits.tpd, | |
| monthlyTokenBudget: m.monthlyTokenBudget, | |
| contextWindow: routableContextWindow(m.platform, m.modelId, m.contextWindow), | |
| supportsVision: m.supportsVision ? 1 : 0, | |
| supportsTools: m.supportsTools ? 1 : 0, | |
| }; | |
| if (row) { | |
| // Catalog disable wins (dead upstream); local disable also wins. | |
| const enabled = m.enabled ? row.enabled : 0; | |
| updateModel.run({ ...fields, id: row.id, enabled }); | |
| refreshModelOverrideBaselines(db, m.platform, m.modelId); | |
| applyModelOverrides(db, m.platform, m.modelId); | |
| counts.updated++; | |
| } else { | |
| insertModel.run({ ...fields, platform: m.platform, modelId: m.modelId, enabled: m.enabled ? 1 : 0 }); | |
| refreshModelOverrideBaselines(db, m.platform, m.modelId); | |
| applyModelOverrides(db, m.platform, m.modelId); | |
| counts.inserted++; | |
| } | |
| } | |
| // Video models use their own optional full snapshot. Older catalogs omit | |
| // the key and leave existing video rows untouched; older binaries ignore | |
| // the key entirely, which is why these rows must not live in models[]. | |
| if (catalog.videoModels) { | |
| for (const m of catalog.videoModels) { | |
| if (!VIDEO_PLATFORMS.has(m.platform)) { | |
| counts.skippedUnknownPlatform++; | |
| continue; | |
| } | |
| if (isCatalogModelTombstoned(db, 'media', m.platform, m.modelId)) continue; | |
| inVideoCatalog.add(`${m.platform}:${m.modelId}`); | |
| const meta = typeof m.providerModelId === 'string' | |
| ? JSON.stringify({ providerModelId: m.providerModelId }) | |
| : null; | |
| const fields = { | |
| displayName: m.displayName, | |
| modality: 'video', | |
| priority: m.priority, | |
| quotaLabel: m.quotaLabel ?? '', | |
| metaJson: meta, | |
| }; | |
| const row = selectMedia.get(m.platform, m.modelId) as { id: number; enabled: number } | undefined; | |
| if (row) { | |
| const enabled = m.enabled ? row.enabled : 0; | |
| updateMedia.run({ ...fields, id: row.id, enabled }); | |
| counts.updated++; | |
| } else { | |
| insertMedia.run({ ...fields, platform: m.platform, modelId: m.modelId, enabled: m.enabled ? 1 : 0 }); | |
| counts.inserted++; | |
| } | |
| } | |
| } | |
| // Embeddings are their own full snapshot. Older catalogs omit this field; | |
| // in that case retain the app's bundled embedding baseline untouched. | |
| if (catalog.embeddings) { | |
| for (const m of catalog.embeddings) { | |
| if (!EMBEDDING_PLATFORMS.has(m.platform)) { | |
| counts.skippedUnknownPlatform++; | |
| continue; | |
| } | |
| inEmbeddingCatalog.add(`${m.platform}:${m.modelId}`); | |
| const row = selectEmbedding.get(m.platform, m.modelId) as { id: number; enabled: number } | undefined; | |
| const fields = { | |
| family: m.family, | |
| displayName: m.displayName, | |
| dimensions: m.dimensions, | |
| maxInputTokens: m.maxInputTokens, | |
| priority: m.priority, | |
| quotaLabel: m.quotaLabel, | |
| }; | |
| if (row) { | |
| const enabled = m.enabled ? row.enabled : 0; // catalog and local disables both win | |
| updateEmbedding.run({ ...fields, id: row.id, enabled }); | |
| counts.updated++; | |
| } else { | |
| insertEmbedding.run({ | |
| ...fields, | |
| platform: m.platform, | |
| modelId: m.modelId, | |
| enabled: m.enabled ? 1 : 0, | |
| }); | |
| counts.inserted++; | |
| } | |
| } | |
| } | |
| // Transcription models are their own full snapshot, routed into | |
| // media_models with modality='transcription' and gated on | |
| // TRANSCRIPTION_PLATFORMS the way MEDIA_PLATFORMS gates the generative | |
| // rows. Older catalogs omit this key; keep existing rows untouched then. | |
| if (catalog.transcriptionModels) { | |
| for (const m of catalog.transcriptionModels) { | |
| if (!TRANSCRIPTION_PLATFORMS.has(m.platform)) { | |
| counts.skippedUnknownPlatform++; | |
| continue; | |
| } | |
| if (isCatalogModelTombstoned(db, 'media', m.platform, m.modelId)) continue; | |
| inTranscriptionCatalog.add(`${m.platform}:${m.modelId}`); | |
| const meta: Record<string, unknown> = {}; | |
| if (m.subtitleFormats?.length) meta.subtitleFormats = m.subtitleFormats; | |
| if (typeof m.maxBytes === 'number') meta.maxBytes = m.maxBytes; | |
| if (typeof m.requestStyle === 'string') meta.requestStyle = m.requestStyle; | |
| const fields = { | |
| displayName: m.displayName, | |
| priority: m.priority, | |
| quotaLabel: m.quotaLabel ?? '', | |
| metaJson: Object.keys(meta).length > 0 ? JSON.stringify(meta) : null, | |
| }; | |
| const row = selectMedia.get(m.platform, m.modelId) as { id: number; enabled: number } | undefined; | |
| if (row) { | |
| const enabled = m.enabled ? row.enabled : 0; // catalog and local disables both win | |
| updateTranscription.run({ ...fields, id: row.id, enabled }); | |
| counts.updated++; | |
| } else { | |
| insertTranscription.run({ ...fields, platform: m.platform, modelId: m.modelId, enabled: m.enabled ? 1 : 0 }); | |
| counts.inserted++; | |
| } | |
| } | |
| } | |
| counts.removed += deleteTombstonedCatalogModels(db); | |
| applyAllModelOverrides(db); | |
| // Ensure every model has a fallback_config row (same invariant migrations keep). | |
| const missingFb = db | |
| .prepare( | |
| `SELECT m.id FROM models m LEFT JOIN fallback_config f ON m.id = f.model_db_id WHERE f.id IS NULL`, | |
| ) | |
| .all() as { id: number }[]; | |
| if (missingFb.length > 0) { | |
| const maxPriority = (db.prepare('SELECT COALESCE(MAX(priority), 0) AS mx FROM fallback_config').get() as { mx: number }).mx; | |
| const addFb = db.prepare('INSERT INTO fallback_config (model_db_id, priority, enabled) VALUES (?, ?, 1)'); | |
| missingFb.forEach((r, i) => addFb.run(r.id, maxPriority + 1 + i)); | |
| } | |
| ensureAllModelsInProfiles(db); | |
| // Remove catalog-managed models that the catalog no longer lists. | |
| // Ownership is decided by the `source` provenance column: only rows the | |
| // catalog itself created are prune candidates. Rows with source='user' | |
| // (declarative config, admin adds, custom endpoints) are never deleted | |
| // here, no matter what their size_label or platform says β that replaces | |
| // the old size_label NOT IN ('User','Custom') heuristic, which lost user | |
| // rows whose label didn't follow the convention. The platform/key_id | |
| // predicates stay as belt and braces. | |
| const candidates = db | |
| .prepare(` | |
| SELECT id, platform, model_id | |
| FROM models | |
| WHERE platform != 'custom' | |
| AND key_id IS NULL | |
| AND source = 'catalog' | |
| `) | |
| .all() as { id: number; platform: string; model_id: string }[]; | |
| const deleteFb = db.prepare('DELETE FROM fallback_config WHERE model_db_id = ?'); | |
| const deleteModel = db.prepare('DELETE FROM models WHERE id = ?'); | |
| for (const c of candidates) { | |
| if (!hasProvider(c.platform as Platform)) continue; // not catalog-managed by this binary | |
| if (!inCatalog.has(`${c.platform}:${c.model_id}`)) { | |
| deleteFb.run(c.id); | |
| deleteModel.run(c.id); | |
| counts.removed++; | |
| } | |
| } | |
| // #1348: retire discovered rows on every platform the catalog now | |
| // manages. A platform counts as managed when this catalog carries ANY row | |
| // for it (chat, media, embedding, STT or video; enabled or disabled, since | |
| // a disabled row is still an audit verdict) or names it in | |
| // managedPlatforms. Rows the catalog listed were adopted above and are | |
| // source='catalog' by now, so what is left is exactly the discovered | |
| // stopgap the catalog has superseded. | |
| const managed = catalogPlatforms(catalog); | |
| const discoveredRows = db | |
| .prepare('SELECT id, platform FROM models WHERE source = ?') | |
| .all(DISCOVERED_MODEL_SOURCE) as { id: number; platform: string }[]; | |
| for (const d of discoveredRows) { | |
| if (!managed.has(d.platform)) continue; | |
| deleteFb.run(d.id); | |
| deleteModel.run(d.id); | |
| counts.removed++; | |
| } | |
| // Remove media models the catalog no longer lists (own table, no | |
| // fallback_config). Deliberately an ALLOWLIST of the two modalities that | |
| // `models[]` maintains, not "everything except transcription": video and | |
| // transcription rows come from their own optional snapshots below, so a | |
| // catalog that omits those keys must leave them alone. Widening this back | |
| // to a `!=` filter would silently delete every video row on the first sync | |
| // from an older catalog. | |
| const mediaCandidates = db | |
| .prepare("SELECT id, platform, model_id FROM media_models WHERE modality IN ('image', 'audio')") | |
| .all() as { id: number; platform: string; model_id: string }[]; | |
| const deleteMedia = db.prepare('DELETE FROM media_models WHERE id = ?'); | |
| for (const c of mediaCandidates) { | |
| if (!MEDIA_PLATFORMS.has(c.platform)) continue; // not media-managed by this binary | |
| if (!inMediaCatalog.has(`${c.platform}:${c.model_id}`)) { | |
| deleteMedia.run(c.id); | |
| counts.removed++; | |
| } | |
| } | |
| // Prune video rows only when this catalog actually carries the dedicated | |
| // snapshot. An older catalog cannot know whether a video row was retired. | |
| if (catalog.videoModels) { | |
| const videoCandidates = db | |
| .prepare("SELECT id, platform, model_id FROM media_models WHERE modality = 'video'") | |
| .all() as { id: number; platform: string; model_id: string }[]; | |
| for (const c of videoCandidates) { | |
| if (!VIDEO_PLATFORMS.has(c.platform)) continue; | |
| if (!inVideoCatalog.has(`${c.platform}:${c.model_id}`)) { | |
| deleteMedia.run(c.id); | |
| counts.removed++; | |
| } | |
| } | |
| } | |
| // Prune transcription rows only when the catalog actually carries the | |
| // snapshot (mirrors the embeddings rule), scoped to the modality so | |
| // image/audio rows are never touched by it. | |
| if (catalog.transcriptionModels) { | |
| const sttCandidates = db | |
| .prepare("SELECT id, platform, model_id FROM media_models WHERE modality = 'transcription'") | |
| .all() as { id: number; platform: string; model_id: string }[]; | |
| for (const c of sttCandidates) { | |
| if (!TRANSCRIPTION_PLATFORMS.has(c.platform)) continue; | |
| if (!inTranscriptionCatalog.has(`${c.platform}:${c.model_id}`)) { | |
| deleteMedia.run(c.id); | |
| counts.removed++; | |
| } | |
| } | |
| } | |
| // Embeddings are their own full snapshot. Older catalogs omit this field, | |
| // AND catalogs may publish `embeddings: []` while still shipping model | |
| // rows β both cases mean "retain the app's bundled embedding baseline | |
| // untouched". The JS truthy check on a non-empty array object would | |
| // misfire on `[]`, wiping the seeded rows; gate on length instead. | |
| if (catalog.embeddings && catalog.embeddings.length > 0) { | |
| const embeddingCandidates = db | |
| .prepare(` | |
| SELECT id, platform, model_id | |
| FROM embedding_models | |
| WHERE platform != 'custom' AND key_id IS NULL | |
| `) | |
| .all() as { id: number; platform: string; model_id: string }[]; | |
| const deleteEmbedding = db.prepare('DELETE FROM embedding_models WHERE id = ?'); | |
| for (const c of embeddingCandidates) { | |
| if (!EMBEDDING_PLATFORMS.has(c.platform)) continue; | |
| if (!inEmbeddingCatalog.has(`${c.platform}:${c.model_id}`)) { | |
| deleteEmbedding.run(c.id); | |
| counts.removed++; | |
| } | |
| } | |
| } | |
| // Quirks are pure content: replace wholesale. | |
| db.prepare('DELETE FROM quirk_targets').run(); | |
| db.prepare('DELETE FROM quirks').run(); | |
| const insertQuirk = db.prepare( | |
| `INSERT INTO quirks (slug, title, body, severity, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?)`, | |
| ); | |
| const insertTarget = db.prepare( | |
| `INSERT INTO quirk_targets (quirk_id, platform, model_glob) VALUES (?, ?, ?)`, | |
| ); | |
| const now = Date.now(); | |
| for (const q of catalog.quirks) { | |
| const info = insertQuirk.run(q.slug, q.title, q.body, q.severity, now, now); | |
| for (const t of q.targets) insertTarget.run(info.lastInsertRowid, t.platform ?? null, t.modelGlob ?? null); | |
| counts.quirks++; | |
| } | |
| }); | |
| apply(); | |
| return counts; | |
| } | |
| /** | |
| * Fetch the catalog, verify its signature, and apply it if it moves us forward. | |
| * `force` skips the `since` short-circuit β used right after a license key is | |
| * added or removed, where the tier can change without the version changing. | |
| */ | |
| export async function syncCatalog(force = false): Promise<SyncResult> { | |
| const db = getDb(); | |
| const key = getSetting(SETTING_LICENSE_KEY); | |
| const applied = getSetting(SETTING_APPLIED_VERSION); | |
| try { | |
| const headers: Record<string, string> = {}; | |
| if (key) headers.Authorization = `Bearer ${key}`; | |
| const url = new URL(`${catalogBaseUrl()}/v1/latest`); | |
| if (applied && !force) url.searchParams.set('since', applied); | |
| const res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); | |
| if (res.status === 304) { | |
| setSetting(SETTING_LAST_SYNC_MS, String(Date.now())); | |
| setSetting(SETTING_LAST_ERROR, ''); | |
| return { ok: true, action: 'up_to_date', version: applied }; | |
| } | |
| if (!res.ok) throw new Error(`catalog fetch failed: HTTP ${res.status}`); | |
| const signature = res.headers.get('x-catalog-signature'); | |
| if (!signature) throw new Error('catalog response missing signature'); | |
| const bytes = Buffer.from(await res.arrayBuffer()); | |
| const verified = crypto.verify(null, bytes, catalogPublicKey(), Buffer.from(signature, 'base64')); | |
| if (!verified) throw new Error('catalog signature verification FAILED β discarding response'); | |
| const parsed: unknown = JSON.parse(bytes.toString('utf8')); | |
| if (!isCatalog(parsed)) throw new Error('catalog payload has unexpected shape'); | |
| const catalog = parsed; | |
| if (catalog.version < MIN_CATALOG_VERSION) { | |
| // Older than the bundled baseline (e.g. monthly snapshot lagging a fresh | |
| // app release) β applying it would roll back migrations. Wait it out. | |
| setSetting(SETTING_LAST_SYNC_MS, String(Date.now())); | |
| setSetting(SETTING_LAST_ERROR, ''); | |
| return { ok: true, action: 'skipped_older', version: catalog.version, tier: catalog.tier }; | |
| } | |
| const sameAsApplied = applied === catalog.version && getSetting(SETTING_APPLIED_TIER) === catalog.tier; | |
| if (!sameAsApplied) { | |
| const counts = applyCatalog(db, catalog); | |
| setSetting(SETTING_APPLIED_VERSION, catalog.version); | |
| setSetting(SETTING_APPLIED_TIER, catalog.tier); | |
| // Cache the verified document so boots can re-apply it offline (see | |
| // reapplyCachedCatalog). Stored post-verification: anything that could | |
| // tamper this row could tamper the models table directly, so the cache | |
| // adds no new trust surface. | |
| setSetting(SETTING_APPLIED_JSON, bytes.toString('utf8')); | |
| console.log( | |
| `[catalog-sync] applied ${catalog.tier} v${catalog.version}: ` + | |
| `${counts.updated} updated, ${counts.inserted} new, ${counts.removed} removed, ` + | |
| `${counts.quirks} quirks` + | |
| (counts.skippedUnknownPlatform ? `, ${counts.skippedUnknownPlatform} skipped (unknown platform)` : ''), | |
| ); | |
| setSetting(SETTING_LAST_SYNC_MS, String(Date.now())); | |
| setSetting(SETTING_LAST_ERROR, ''); | |
| return { ok: true, action: 'applied', version: catalog.version, tier: catalog.tier, counts }; | |
| } | |
| setSetting(SETTING_LAST_SYNC_MS, String(Date.now())); | |
| setSetting(SETTING_LAST_ERROR, ''); | |
| return { ok: true, action: 'up_to_date', version: catalog.version, tier: catalog.tier }; | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| console.warn(`[catalog-sync] ${message}`); | |
| setSetting(SETTING_LAST_ERROR, message); | |
| return { ok: false, action: 'error', detail: message }; | |
| } | |
| } | |
| /** Raw response from the catalog service's license activation endpoint. */ | |
| export interface LicenseActivation { | |
| valid: boolean; | |
| plan: string | null; | |
| status: string | null; | |
| expiresAt: string | null; | |
| reason?: string; | |
| } | |
| /** | |
| * Validate a key with the license service. Returns null when the service is | |
| * unreachable β distinguishable from a rejected key, so a transient outage can | |
| * be warned about instead of reported as a bad key. Shared by the dashboard's | |
| * POST /api/premium/key and declarative `license` config. | |
| */ | |
| export async function validateLicenseKey(key: string, timeoutMs = FETCH_TIMEOUT_MS): Promise<LicenseActivation | null> { | |
| try { | |
| const res = await fetch(`${catalogBaseUrl()}/v1/license/activate`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ key }), | |
| signal: AbortSignal.timeout(timeoutMs), | |
| }); | |
| return (await res.json()) as LicenseActivation; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** Revalidate the stored license against the catalog service and cache the result. */ | |
| export async function refreshLicenseStatus(): Promise<LicenseStatus | null> { | |
| const key = getSetting(SETTING_LICENSE_KEY); | |
| if (!key) return null; | |
| try { | |
| const res = await fetch(`${catalogBaseUrl()}/v1/license/check`, { | |
| headers: { Authorization: `Bearer ${key}` }, | |
| signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), | |
| }); | |
| if (!res.ok && res.status !== 401) throw new Error(`HTTP ${res.status}`); | |
| const body = (await res.json()) as Omit<LicenseStatus, 'checkedAtMs'>; | |
| const status: LicenseStatus = { ...body, checkedAtMs: Date.now() }; | |
| setSetting(SETTING_LICENSE_STATUS, JSON.stringify(status)); | |
| return status; | |
| } catch (err) { | |
| // Offline or service down: keep the cached status. Entitlement is enforced | |
| // server-side at /v1/latest anyway β this cache is informational UI state. | |
| console.warn(`[catalog-sync] license check unreachable: ${err instanceof Error ? err.message : err}`); | |
| return getCachedLicenseStatus(); | |
| } | |
| } | |
| export function getCachedLicenseStatus(): LicenseStatus | null { | |
| const raw = getSetting(SETTING_LICENSE_STATUS); | |
| if (!raw) return null; | |
| try { | |
| return JSON.parse(raw) as LicenseStatus; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export interface CatalogSyncState { | |
| baseUrl: string; | |
| appliedVersion: string | null; | |
| appliedTier: string | null; | |
| lastSyncMs: number | null; | |
| lastError: string | null; | |
| } | |
| export function getSyncState(): CatalogSyncState { | |
| return { | |
| baseUrl: catalogBaseUrl(), | |
| appliedVersion: getSetting(SETTING_APPLIED_VERSION) ?? null, | |
| appliedTier: getSetting(SETTING_APPLIED_TIER) ?? null, | |
| lastSyncMs: Number(getSetting(SETTING_LAST_SYNC_MS)) || null, | |
| lastError: getSetting(SETTING_LAST_ERROR) || null, | |
| }; | |
| } | |
| /** | |
| * Re-apply the cached (already signature-verified) catalog after boot. | |
| * | |
| * Migrations run on every boot and re-assert the bundled baseline β they | |
| * INSERT OR IGNORE baseline models the catalog may have deleted and re-run | |
| * the family-rule resets β while the boot-time network sync 304s on an | |
| * unchanged version and so would NOT re-apply. Without this step every | |
| * restart drifts the DB back toward the baseline until the next catalog | |
| * version bump. Re-applying from the local cache is synchronous, needs no | |
| * network, and keeps the catalog authoritative even offline. | |
| * | |
| * Legacy upgrade path: installs that applied a catalog before the cache | |
| * existed have an applied-version setting but no cached document. Clearing | |
| * the applied version makes the next poll fetch the full catalog (no `since` | |
| * short-circuit), which re-applies it and populates the cache. | |
| */ | |
| export function reapplyCachedCatalog(): { reapplied: boolean; version?: string } { | |
| try { | |
| const raw = getSetting(SETTING_APPLIED_JSON); | |
| if (!raw) { | |
| if (getSetting(SETTING_APPLIED_VERSION)) { | |
| getDb().prepare('DELETE FROM settings WHERE key = ?').run(SETTING_APPLIED_VERSION); | |
| } | |
| return { reapplied: false }; | |
| } | |
| const parsed: unknown = JSON.parse(raw); | |
| if (!isCatalog(parsed) || parsed.version < MIN_CATALOG_VERSION) return { reapplied: false }; | |
| applyCatalog(getDb(), parsed); | |
| console.log(`[catalog-sync] re-applied cached ${parsed.tier} v${parsed.version} after boot`); | |
| return { reapplied: true, version: parsed.version }; | |
| } catch (err) { | |
| console.warn(`[catalog-sync] cached catalog re-apply failed: ${err instanceof Error ? err.message : err}`); | |
| return { reapplied: false }; | |
| } | |
| } | |
| let cancelBootTimer: (() => void) | null = null; | |
| let cancelInterval: (() => void) | null = null; | |
| export function startCatalogSync(scheduler: Scheduler): void { | |
| if (cancelInterval) return; | |
| if (process.env.CATALOG_SYNC_DISABLED === '1') { | |
| console.log('[catalog-sync] disabled via CATALOG_SYNC_DISABLED=1'); | |
| return; | |
| } | |
| reapplyCachedCatalog(); | |
| const run = () => { | |
| void refreshLicenseStatus(); | |
| void syncCatalog(); | |
| }; | |
| cancelBootTimer = scheduler.after(BOOT_DELAY_MS, run); | |
| cancelInterval = scheduler.every(SYNC_INTERVAL_MS, run); | |
| console.log(`[catalog-sync] polling ${catalogBaseUrl()} every ${SYNC_INTERVAL_MS / 3600000}h`); | |
| } | |
| export function stopCatalogSync(): void { | |
| if (cancelBootTimer) { | |
| cancelBootTimer(); | |
| cancelBootTimer = null; | |
| } | |
| if (cancelInterval) { | |
| cancelInterval(); | |
| cancelInterval = null; | |
| } | |
| } | |