diff --git a/changelog.d/fixes/models-dev-sync-env-killswitch.md b/changelog.d/fixes/models-dev-sync-env-killswitch.md new file mode 100644 index 0000000000..0724f52356 --- /dev/null +++ b/changelog.d/fixes/models-dev-sync-env-killswitch.md @@ -0,0 +1 @@ +- **fix(models):** honor `MODELS_DEV_SYNC_ENABLED=0` as a hard kill switch over the dashboard setting so a wedged `/healthz` / UI can be recovered without HTTP (`src/lib/modelsDevSync.ts`) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7c39edd8d1..90d5307396 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -934,7 +934,7 @@ Chrome-driven session refresh (ARP) for the Adobe Firefly web provider (`open-ss | Variable | Default | Source File | Description | | ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MODELS_DEV_SYNC_ENABLED` | `false` | `src/lib/modelsDevSync.ts` | Opt-in switch for the models.dev capability sync. Set to anything non-empty it wins over the `modelsDevSyncEnabled` setting (Dashboard > Settings > AI) in either direction, so a deployment can pin the sync on or off without depending on database state surviving a rebuild; unset, it defers to that setting. On for `1`, `true`, `yes` or `on` in any casing; any other value is off. | +| `MODELS_DEV_SYNC_ENABLED` | _(unset)_ | `src/lib/modelsDevSync.ts` | Hard override for models.dev pricing sync. Unset = honor Settings > AI (`modelsDevSyncEnabled`). `0`/`false`/`off`/`no` **wins over the DB** and skips both periodic sync and `getModelsDevPricing()` SQL/JSON scans (recovery when the dashboard is wedged on the same event loop). `1`/`true`/`on`/`yes` forces sync on. | | `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. | | `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. | diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 0ba6430434..3d1b33f5e9 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -368,6 +368,18 @@ async function buildUnifiedModelsResponseCore( return collected; }; + // Health-check exclusions (provider_specific_data.excludedModels) are enforced + // at request time in getProviderCredentials(); mirror the same rule in the + // catalog so ghost models do not appear as available. A model is hidden when + // the provider HAS connections but NONE of them is eligible for it. + const isExcludedByProviderConnections = (providerKey: string, modelId: string) => { + const providerId = aliasToProviderId[providerKey] || providerKey; + const alias = providerIdToAlias[providerId] || providerKey; + const providerConnections = getConnectionsForProvider(providerId, alias, providerKey); + if (providerConnections.length === 0) return false; // noAuth / no DB row: keep + return !hasEligibleConnectionForModel(providerConnections, modelId); + }; + const providerSupportsModel = (providerKey: string, modelId: string) => { const providerId = aliasToProviderId[providerKey] || providerKey; const alias = providerIdToAlias[providerId] || providerKey; @@ -793,6 +805,7 @@ async function buildUnifiedModelsResponseCore( if (!providerSupportsModel(canonicalProviderId, model.id)) continue; const aliasId = `${alias}/${model.id}`; if (getModelIsHidden(canonicalProviderId, model.id)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, model.id)) continue; if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing)) continue; @@ -913,6 +926,7 @@ async function buildUnifiedModelsResponseCore( continue; } if (getModelIsHidden(providerId, sm.id)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, sm.id)) continue; // #6457: some upstream discovery catalogs (e.g. HuggingFace's live // `/v1/models`) return image/diffusion models with no modality info, // so `endpoints` below would default to ["chat"] and misrepresent @@ -1307,6 +1321,7 @@ async function buildUnifiedModelsResponseCore( continue; if (model.isHidden === true) continue; if (getModelIsHidden(canonicalProviderId, modelId)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to user-defined custom rows too. // Custom entries do not carry pricing, so shouldHidePaid() decides // via FREE_MODEL_IDS_BY_PROVIDER — matches synced/PROVIDER_MODELS. @@ -1487,6 +1502,7 @@ async function buildUnifiedModelsResponseCore( } if (getModelIsHidden(canonicalProviderId, modelId)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to alias-backed rows too. Alias mappings // point at providerKey/modelId with no pricing, so shouldHidePaid() // decides via the FREE_MODEL_IDS_BY_PROVIDER catalog tier. @@ -1560,6 +1576,7 @@ async function buildUnifiedModelsResponseCore( const modelId = typeof model.id === "string" ? model.id : null; if (!modelId) continue; if (getModelIsHidden(canonicalProviderId, modelId)) continue; + if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to managed-fallback rows too. Compatible // provider fallbacks lack pricing; shouldHidePaid() decides via the // FREE_MODEL_IDS_BY_PROVIDER catalog tier. diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index 218846bfa0..dc5e9d720d 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -374,18 +374,24 @@ async function applyModelsDevSyncSection( currentSnapshot: RuntimeSettingsSnapshot, force: boolean ) { - const { startPeriodicSync, stopPeriodicSync } = await import("@/lib/modelsDevSync"); + const { + startPeriodicSync, + stopPeriodicSync, + isModelsDevSyncEnvDisabled, + isModelsDevSyncEnvForcedOn, + } = await import("@/lib/modelsDevSync"); const skipBackgroundSyncInTests = (isAutomatedTestProcess() && process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS !== "1") || isTruthyEnvFlag(process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES); - if (skipBackgroundSyncInTests) { + if (skipBackgroundSyncInTests || isModelsDevSyncEnvDisabled()) { stopPeriodicSync(); return; } const wasEnabled = previousSnapshot.modelsDevSyncEnabled === true; - const isEnabled = currentSnapshot.modelsDevSyncEnabled === true; + const isEnabled = + isModelsDevSyncEnvForcedOn() || currentSnapshot.modelsDevSyncEnabled === true; const intervalChanged = previousSnapshot.modelsDevSyncInterval !== currentSnapshot.modelsDevSyncInterval; diff --git a/src/lib/modelsDevSync.ts b/src/lib/modelsDevSync.ts index 83ff38f925..b8d16ffbad 100644 --- a/src/lib/modelsDevSync.ts +++ b/src/lib/modelsDevSync.ts @@ -14,12 +14,11 @@ * 3. LiteLLM sync (`pricing_synced` namespace) * 4. Hardcoded defaults (`pricing.ts`) * - * Opt-in, default off. Enabled either from Dashboard > Settings > AI or with - * MODELS_DEV_SYNC_ENABLED, which wins over that setting whenever it is set to - * anything non-empty, in either direction, so a deployment can pin the sync on - * or off regardless of what is stored. Unset or empty, it defers to the - * setting. On for "1", "true", "yes" or "on" in any casing; every other value - * is off. + * Settings UI (`modelsDevSyncEnabled`) controls the periodic sync by default. + * `MODELS_DEV_SYNC_ENABLED=0|false|off|no` is a hard kill switch: it wins over + * the DB setting so an operator can recover a wedged process (dashboard / + * /healthz frozen on the same event loop — #10052) without the UI. Unset = + * honor settings. `1|true|on|yes` forces sync on even if the setting is off. */ import { getDbInstance } from "./db/core"; @@ -76,12 +75,34 @@ interface SyncResult { const MODELS_DEV_API_URL = "https://models.dev/api.json"; -const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); - const parsedInterval = parseInt(process.env.MODELS_DEV_SYNC_INTERVAL || "86400", 10); const SYNC_INTERVAL_MS = Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval * 1000 : 86400 * 1000; +/** Parse MODELS_DEV_SYNC_ENABLED. Invalid / empty → unset (honor DB settings). */ +export function readModelsDevSyncEnvFlag( + value: string | undefined = process.env.MODELS_DEV_SYNC_ENABLED +): "true" | "false" | "unset" { + if (value == null) return "unset"; + const normalized = value.trim().toLowerCase(); + if (normalized === "") return "unset"; + if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") { + return "true"; + } + if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") { + return "false"; + } + return "unset"; +} + +export function isModelsDevSyncEnvDisabled(): boolean { + return readModelsDevSyncEnvFlag() === "false"; +} + +export function isModelsDevSyncEnvForcedOn(): boolean { + return readModelsDevSyncEnvFlag() === "true"; +} + // ─── Periodic sync state ───────────────────────────────── let syncTimer: ReturnType | null = null; @@ -212,8 +233,15 @@ let pricingMemoVersion = -1; // -1: never equals a real cacheVersion (starts at /** * Read synced pricing from `models_dev_pricing` namespace. + * Results are memoized until `saveModelsDevPricing` / `clearModelsDevPricing`. */ export function getModelsDevPricing(): PricingByProvider { + // Kill switch: skip the SQL + JSON.parse scan entirely so a leftover + // models_dev_pricing namespace cannot pin the event loop (#9685 / #10052). + if (isModelsDevSyncEnvDisabled()) { + return {}; + } + const currentVersion = getModelCatalogCacheVersion(); if (pricingMemo !== null && pricingMemoVersion === currentVersion) { return pricingMemo; @@ -737,35 +765,16 @@ export function getSyncStatus(): SyncStatus { * Initialize models.dev sync if enabled. */ export async function initModelsDevSync(): Promise { + if (isModelsDevSyncEnvDisabled()) { + console.log("[MODELS_DEV] Disabled (MODELS_DEV_SYNC_ENABLED=0)"); + return; + } + const { getSettings } = await import("./localDb"); const settings = await getSettings(); - // Until now the docblock above advertised MODELS_DEV_SYNC_ENABLED and nothing - // read it: the only control was the stored setting, so an operator following - // that line got silence whichever value they set. This makes the variable real. - // - // An explicit env value decides, in either direction, and only an unset or - // empty one defers to the setting. That means a deployment can pin the sync - // off from its compose file or unit even when a previous operator left the - // dashboard toggle on, which is the case a force-on-only variable cannot - // express and the reason for choosing this shape. - // - // It is worth being plain that this is a third resolution pattern rather than - // a reuse of an existing one, because the two in the tree solve different - // problems: shared/utils/featureFlags.ts::resolveFeatureFlag puts the DB - // override ABOVE the env var, so a deployment cannot override an operator's - // stored choice at all; db/ccDiscoveryAliases.ts::getCcAliasGlobalState reads - // only "1" and "true" and can force a flag ON, letting every other value - // including "false" fall through to the DB. Neither can turn a - // dashboard-enabled switch off from the environment. Following either one - // here would leave the variable unable to do the thing it is being added for. - const envValue = process.env.MODELS_DEV_SYNC_ENABLED?.trim(); - const enabled = envValue - ? TRUE_ENV_VALUES.has(envValue.toLowerCase()) - : settings.modelsDevSyncEnabled === true; - - if (!enabled) { - console.log("[MODELS_DEV] Disabled (enable via Settings > AI or MODELS_DEV_SYNC_ENABLED=true)"); + if (!isModelsDevSyncEnvForcedOn() && settings.modelsDevSyncEnabled !== true) { + console.log("[MODELS_DEV] Disabled (enable via Settings > AI or MODELS_DEV_SYNC_ENABLED=1)"); return; } diff --git a/tests/unit/config-hot-reload.test.ts b/tests/unit/config-hot-reload.test.ts index e29d1addff..fabd87a500 100644 --- a/tests/unit/config-hot-reload.test.ts +++ b/tests/unit/config-hot-reload.test.ts @@ -30,6 +30,7 @@ const { getPayloadRulesConfig, resetPayloadRulesConfigForTests } = await import("../../open-sse/services/payloadRules.ts"); const { getCacheControlSettings, invalidateCacheControlSettingsCache } = await import("../../src/lib/cacheControlSettings.ts"); +const { getSyncStatus, stopPeriodicSync } = await import("../../src/lib/modelsDevSync.ts"); async function resetStorage() { stopRuntimeConfigHotReloadForTests(); @@ -128,6 +129,50 @@ test("updateSettings applies runtime settings incrementally without restart", as assert.equal(getGeminiThoughtSignatureMode(), "enabled"); }); +test("MODELS_DEV_SYNC_ENABLED=0 blocks a live settings update from starting the sync timer", async () => { + const previousEnvFlag = process.env.MODELS_DEV_SYNC_ENABLED; + const previousBackgroundTasks = process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS; + process.env.MODELS_DEV_SYNC_ENABLED = "0"; + process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS = "1"; + stopPeriodicSync(); + + try { + await applyRuntimeSettings( + { + ...(await settingsDb.getSettings()), + modelsDevSyncEnabled: false, + modelsDevSyncInterval: 3_600_000, + }, + { force: true, source: "test:startup" } + ); + + const persistedSettings = await settingsDb.updateSettings({ + modelsDevSyncEnabled: true, + modelsDevSyncInterval: 3_600_000, + }); + + assert.equal( + persistedSettings.modelsDevSyncEnabled, + true, + "the live settings update must persist the dashboard toggle" + ); + assert.equal( + getSyncStatus().enabled, + false, + "MODELS_DEV_SYNC_ENABLED=0 must prevent a live settings update from starting the timer" + ); + } finally { + stopPeriodicSync(); + if (previousEnvFlag === undefined) delete process.env.MODELS_DEV_SYNC_ENABLED; + else process.env.MODELS_DEV_SYNC_ENABLED = previousEnvFlag; + if (previousBackgroundTasks === undefined) { + delete process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS; + } else { + process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS = previousBackgroundTasks; + } + } +}); + test("hot-reload watcher picks up external sqlite changes via polling fallback", async () => { await applyRuntimeSettings(await settingsDb.getSettings(), { force: true, diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts index 258e469f14..acdd1306f9 100644 --- a/tests/unit/modelsDevSync-extended.test.ts +++ b/tests/unit/modelsDevSync-extended.test.ts @@ -746,3 +746,46 @@ test("an unset MODELS_DEV_SYNC_ENABLED still defers to a stored setting of true" else process.env.MODELS_DEV_SYNC_ENABLED = previous; } }); + +test("MODELS_DEV_SYNC_ENABLED=0 is a hard kill switch that wins over the setting", async () => { + const previous = process.env.MODELS_DEV_SYNC_ENABLED; + process.env.MODELS_DEV_SYNC_ENABLED = "0"; + try { + const modelsDev = await importFresh("env-kill-switch-0"); + mockFetchWith(MOCK_MODELS_DEV_DATA); + + const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA); + modelsDev.saveModelsDevPricing(pricing); + assert.deepEqual( + modelsDev.getModelsDevPricing(), + {}, + "kill switch skips the SQL/JSON pricing scan entirely" + ); + + await settingsDb.updateSettings({ + modelsDevSyncEnabled: true, + modelsDevSyncInterval: 15, + }); + await modelsDev.initModelsDevSync(); + assert.equal( + modelsDev.getSyncStatus().enabled, + false, + "kill switch must prevent the periodic sync even when the setting is on" + ); + + assert.equal(modelsDev.readModelsDevSyncEnvFlag("0"), "false"); + assert.equal(modelsDev.readModelsDevSyncEnvFlag("false"), "false"); + assert.equal(modelsDev.readModelsDevSyncEnvFlag("off"), "false"); + assert.equal(modelsDev.readModelsDevSyncEnvFlag("no"), "false"); + assert.equal(modelsDev.readModelsDevSyncEnvFlag(""), "unset"); + assert.equal(modelsDev.readModelsDevSyncEnvFlag("maybe"), "unset"); + assert.equal(modelsDev.readModelsDevSyncEnvFlag("1"), "true"); + } finally { + if (previous === undefined) delete process.env.MODELS_DEV_SYNC_ENABLED; + else process.env.MODELS_DEV_SYNC_ENABLED = previous; + await settingsDb.updateSettings({ + modelsDevSyncEnabled: false, + modelsDevSyncInterval: 15, + }); + } +});