From 7256d34a236b57794d43c503100f965263f5a4ef Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Fri, 26 Jun 2026 16:13:24 +0200 Subject: [PATCH] fix: preserve model hidden flags (isHidden) across model sync (#5086) Integrated into release/v3.8.38 (leva 5) --- CHANGELOG.md | 1 + src/lib/db/models.ts | 8 --- ...custom-models-preserve-hidden-5086.test.ts | 66 +++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tests/unit/replace-custom-models-preserve-hidden-5086.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e666de56fd..f056feb6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(tts):** resolve Gemini TTS models from catalog and add `gemini-3.1-flash-tts-preview` as the new default Vertex TTS model. (thanks @nguyenha935) - **fix(sse): don't cool down a healthy connection on a self-inflicted upstream timeout (504)** — when OmniRoute's own deadline elapses (surfaced as `TimeoutError`/`BodyTimeoutError` → 504), the connection is no longer disabled/failed-over, so a slow-but-healthy provider isn't penalised for our timeout. Genuine upstream 5xx/429 still trigger cooldown; antigravity keeps its own policy. (thanks @costaeder) - **fix(sse): robust Anthropic `/v1/messages` streaming — real ping keepalive + client-disconnect guard** — slow first tokens on reasoning models could trip strict clients' idle-read watchdog; the route now keeps the stream warm with a real `event: ping` (Anthropic clients ignore SSE comments) from the very first frame, and a client disconnect (AbortError / controller-closed) no longer counts as a provider failure (no failover/cooldown). (thanks @costaeder) +- **fix: preserve model hidden flags (`isHidden`) across model sync** — `replaceCustomModels` pruned the compat-override list to the new custom-model ids, silently wiping the `isHidden` flag of eye-hidden SYNCED models on every periodic sync / import (all hidden models turned back on). The redundant cleanup is removed (per-model removal already handles its own compat cleanup), so eye-hidden models stay hidden across re-sync. (#4389, thanks @herjarsa) --- diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index d4cfc6b2dc..e56512fb2a 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -544,14 +544,6 @@ export async function replaceCustomModels( ).run(providerId, JSON.stringify(merged)); } - // Remove compat overrides for models that no longer exist - const newIds = new Set(models.map((m) => m.id)); - const compatList = readCompatList(providerId); - const filteredCompat = compatList.filter((e) => newIds.has(e.id)); - if (filteredCompat.length !== compatList.length) { - writeCompatList(providerId, filteredCompat); - } - backupDbFile("pre-write"); return merged; } diff --git a/tests/unit/replace-custom-models-preserve-hidden-5086.test.ts b/tests/unit/replace-custom-models-preserve-hidden-5086.test.ts new file mode 100644 index 0000000000..0b950e3f8a --- /dev/null +++ b/tests/unit/replace-custom-models-preserve-hidden-5086.test.ts @@ -0,0 +1,66 @@ +/** + * #5086 / #4389 — model visibility reset after periodic sync. + * + * The EYE/visibility toggle hides a model by writing `isHidden:true` into the + * `modelCompatOverrides` namespace via `mergeModelCompatOverride`. Hidden flags + * for SYNCED models live there, not in the `customModels` store. + * + * Before the fix, `replaceCustomModels` pruned the compat-override list down to + * only the ids present in the new `customModels` array. The periodic model sync + * (and manual import) calls `replaceCustomModels` with a list that does NOT + * contain eye-hidden synced models, so their `isHidden` override was silently + * wiped — every hidden model turned back on after a sync. + * + * This guards that an eye-hidden override survives a `replaceCustomModels` call + * whose new model list omits that id. + */ +import test, { before, after } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// Hermetic DB (see #3782 test): isolate DATA_DIR so override state never leaks +// into the shared dev/CI database between runs. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-replace-custom-hide-")); +process.env.DATA_DIR = tmpDir; + +const { replaceCustomModels, mergeModelCompatOverride, getModelIsHidden, getModelCompatOverrides } = + await import("../../src/lib/localDb.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +before(() => { + resetDbInstance(); +}); + +after(() => { + resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +const PROVIDER = "llama-cpp-5086"; + +test("an eye-hidden override survives replaceCustomModels when the new list omits it", async () => { + // Operator hides a SYNCED model "ghost" with the EYE toggle (visibility only). + mergeModelCompatOverride(PROVIDER, "ghost", { isHidden: true }); + assert.equal(getModelIsHidden(PROVIDER, "ghost"), true, "ghost is eye-hidden before sync"); + + // A periodic sync / import replaces the CUSTOM models with a list that does + // NOT include "ghost" (it lives in the synced store, not customModels). + await replaceCustomModels(PROVIDER, [ + { id: "keep-1", name: "Keep One" }, + { id: "keep-2", name: "Keep Two" }, + ]); + + // The compat override (and thus the hidden flag) must NOT be wiped. + assert.equal( + getModelIsHidden(PROVIDER, "ghost"), + true, + "ghost must stay hidden after replaceCustomModels omits it" + ); + const overrides = getModelCompatOverrides(PROVIDER).map((o) => o.id); + assert.ok( + overrides.includes("ghost"), + "the compat override for ghost must still exist after the sync" + ); +});