diff --git a/changelog.d/fixes/7620-noauth-hidden-filter.md b/changelog.d/fixes/7620-noauth-hidden-filter.md new file mode 100644 index 0000000000..0c106c6931 --- /dev/null +++ b/changelog.d/fixes/7620-noauth-hidden-filter.md @@ -0,0 +1 @@ +- fix(routing): honor eye-icon hidden models for no-auth providers in auto-combo candidate pools (#7620) diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index e5abe5bed9..ac87698a39 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -140,7 +140,8 @@ function getNoAuthCandidates( excludedProviders: Set, blockedProviders: Set, disabledNoAuthProviders: Set, - noAuthProviderSpecificData: Map | null | undefined> + noAuthProviderSpecificData: Map | null | undefined>, + hiddenModelsMap: Map> ): VirtualAutoComboCandidate[] { const registry = getProviderRegistry(); const candidates: VirtualAutoComboCandidate[] = []; @@ -190,10 +191,19 @@ function getNoAuthCandidates( ? noAuthProviderSpecificData.get(providerDef.alias) : undefined); + // #7620: honor the eye-icon "hidden" flag (isHidden, from the + // modelCompatOverrides/customModels key_value namespaces) the same way the + // credentialed-connection loop below does, so a hidden no-auth model never + // enters the auto-combo/fusion candidate pool either. + const hiddenModels = + hiddenModelsMap.get(providerId) ?? + (typeof providerDef.alias === "string" ? hiddenModelsMap.get(providerDef.alias) : undefined); + for (const model of registryModels) { const modelId = typeof model?.id === "string" && model.id.trim().length > 0 ? model.id : null; if (!modelId) continue; if (isModelExcludedByConnection(modelId, providerSpecificData)) continue; + if (hiddenModels?.has(modelId)) continue; candidates.push({ provider: providerId, connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, @@ -323,7 +333,8 @@ export async function createVirtualAutoCombo( new Set(validConnections.map((conn) => conn.provider)), blockedProviders, disabledNoAuthProviders, - noAuthProviderSpecificData + noAuthProviderSpecificData, + hiddenModelsMap ) ); diff --git a/tests/unit/noauth-autocombo-hidden-7620.test.ts b/tests/unit/noauth-autocombo-hidden-7620.test.ts new file mode 100644 index 0000000000..79bda0a20f --- /dev/null +++ b/tests/unit/noauth-autocombo-hidden-7620.test.ts @@ -0,0 +1,73 @@ +/** + * #7620 — hiding a no-auth-provider model with the EYE icon (Dashboard → Models, + * `isHidden: true` written via setModelIsHidden()/mergeModelCompatOverride()) does + * remove it from `/v1/models`, but `getNoAuthCandidates()` in + * `open-sse/services/autoCombo/virtualFactory.ts` never consults + * `getHiddenModelsByProvider()` at all (unlike the credentialed-connection loop a + * few lines above it, which does). A hidden no-auth model therefore stays in the + * `auto/*` candidate pool and can still be selected, causing a 401 when the + * upstream account for that hidden model is no longer valid/allowed. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7620-noauth-hidden-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; + +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const virtualFactory = await import("../../open-sse/services/autoCombo/virtualFactory.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +test("#7620: a no-auth model hidden via the eye icon (isHidden:true) must be ABSENT from the auto-combo candidate pool", async () => { + modelsDb.setModelIsHidden("opencode", "mimo-v2.5-free", true); + + const hiddenMap = modelsDb.getHiddenModelsByProvider(); + assert.equal( + hiddenMap.get("opencode")?.has("mimo-v2.5-free"), + true, + "sanity: getHiddenModelsByProvider() must report opencode/mimo-v2.5-free as hidden" + ); + + const combo = await virtualFactory.createVirtualAutoCombo(undefined); + + const modelStrings = combo.models.map((m: { model: string }) => m.model); + assert.ok( + !modelStrings.some((model: string) => model.endsWith("/mimo-v2.5-free")), + "BUG #7620: the eye-hidden model 'mimo-v2.5-free' must not appear in the auto-combo " + + `candidate pool, but it did. Pool: ${JSON.stringify(modelStrings)}` + ); +}); + +test("#7620 baseline: with nothing hidden, opencode/mimo-v2.5-free is present in the pool", async () => { + const combo = await virtualFactory.createVirtualAutoCombo(undefined); + const modelStrings = combo.models.map((m: { model: string }) => m.model); + assert.ok( + modelStrings.some((model: string) => model.endsWith("/mimo-v2.5-free")), + `baseline: with nothing hidden, mimo-v2.5-free must be present. Pool: ${JSON.stringify(modelStrings)}` + ); +});