diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 9a2252d9ee..78fd327009 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -265,10 +265,6 @@ async function buildUnifiedModelsResponseCore( // try would let a crash here propagate as an unhandled rejection instead // (catalogCache.ts's in-flight coalescing does not fully consume rejections). const hiddenModelsByProvider = getHiddenModelsByProvider(); - const isModelHiddenBulk = (providerId: string, modelId: string): boolean => { - const hiddenSet = hiddenModelsByProvider.get(providerId); - return hiddenSet ? hiddenSet.has(modelId) : false; - }; let settings: Record = {}; try { settings = await getSettings(); @@ -377,6 +373,35 @@ async function buildUnifiedModelsResponseCore( const resolvePublicOwnerId = (providerId: string, canonicalProviderId: string): string => providerIdToPrefix[providerId] || canonicalProviderId; + // #11300: the visibility toggle on a provider's dashboard page persists the + // hidden-model row under whatever key the route's `[id]` param happened to be + // (a node UUID, an alias like `cc`/`gh`/`cx`, or a canonical provider id) — + // see `PATCH /api/provider-models`. The catalog loops below each key their own + // lookup differently (raw connection provider, canonical id, or alias), so a + // single-key lookup missed the override whenever the write key and the read key + // diverged. Check every key a model could plausibly have been hidden under: + // the raw key passed in, its resolved canonical provider id, that canonical id's + // alias, and the compatible-provider-node prefix for either. + const isModelHiddenBulk = ( + providerKey: string | null | undefined, + modelId: string, + canonicalProviderId?: string | null + ): boolean => { + if (!providerKey || !modelId) return false; + const canonical = canonicalProviderId || resolveCanonicalProviderId(providerKey); + const alias = + providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined; + const nodePrefix = providerIdToPrefix[providerKey] || providerIdToPrefix[canonical]; + const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter( + (k): k is string => Boolean(k) + ); + for (const key of keysToCheck) { + const hiddenSet = hiddenModelsByProvider.get(key); + if (hiddenSet?.has(modelId)) return true; + } + return false; + }; + // Get combos let combos = []; await yieldCatalogBuildTurn(); @@ -955,7 +980,7 @@ async function buildUnifiedModelsResponseCore( if (!isModelSelectable(canonicalProviderId, model.id)) continue; if (!providerSupportsModel(canonicalProviderId, model.id)) continue; const aliasId = `${alias}/${model.id}`; - if (isModelHiddenBulk(canonicalProviderId, model.id)) continue; + if (isModelHiddenBulk(alias, model.id, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, model.id)) continue; if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing)) continue; @@ -1018,7 +1043,15 @@ async function buildUnifiedModelsResponseCore( for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) { if (!providerSupportsModel("codex", modelId)) continue; - if (isModelHiddenBulk("codex", modelId)) continue; + // #11300: a codex-native unprefixed model can also be hidden via the + // `openai` provider page (codex runs on the openai-compatible connection) + // or via the `cx` alias — check all three so a hide from any of them + // suppresses the bare model id here. + if ( + isModelHiddenBulk("codex", modelId) || + isModelHiddenBulk("openai", modelId) + ) + continue; const alias = providerIdToAlias.codex || "cx"; const aliasId = `${alias}/${modelId}`; @@ -1079,7 +1112,7 @@ async function buildUnifiedModelsResponseCore( if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) { continue; } - if (isModelHiddenBulk(providerId, sm.id)) continue; + if (isModelHiddenBulk(providerId, sm.id, canonicalProviderId)) 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, @@ -1498,7 +1531,7 @@ async function buildUnifiedModelsResponseCore( if (!isUnifiedChatSourceModelSelectable(canonicalProviderId, { ...model, id: modelId })) continue; if (model.isHidden === true) continue; - if (isModelHiddenBulk(canonicalProviderId, modelId)) continue; + if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to user-defined custom rows too. // Custom entries do not carry pricing, so shouldHidePaid() decides @@ -1682,7 +1715,7 @@ async function buildUnifiedModelsResponseCore( continue; } - if (isModelHiddenBulk(canonicalProviderId, modelId)) continue; + if (isModelHiddenBulk(providerKey, modelId, canonicalProviderId)) 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() @@ -1756,7 +1789,7 @@ async function buildUnifiedModelsResponseCore( for (const model of fallbackModels) { const modelId = typeof model.id === "string" ? model.id : null; if (!modelId) continue; - if (isModelHiddenBulk(canonicalProviderId, modelId)) continue; + if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to managed-fallback rows too. Compatible // provider fallbacks lack pricing; shouldHidePaid() decides via the diff --git a/tests/unit/hidden-models-leak-v1-models-11300.test.ts b/tests/unit/hidden-models-leak-v1-models-11300.test.ts new file mode 100644 index 0000000000..c937d2464e --- /dev/null +++ b/tests/unit/hidden-models-leak-v1-models-11300.test.ts @@ -0,0 +1,177 @@ +/** + * #11300 — Models toggled to "Hidden" on Provider pages are still listed in + * `GET /v1/models`. + * + * `PATCH /api/provider-models?provider=&modelId=` persists the hidden + * override under whatever key the dashboard's `[id]` route param happened to be + * (an alias like `cc`/`gh`/`cx`, a canonical provider id, a compatible-provider + * node UUID, or its configured prefix). `catalog.ts`'s `isModelHiddenBulk()` did + * a single-key lookup, so a model stayed listed in `/v1/models` whenever the key + * used to READ diverged from the key used to WRITE: + * + * - Static `PROVIDER_MODELS` loop checked only `canonicalProviderId` — a model + * hidden under the alias (e.g. `cc` for Claude Code) never matched. + * - The Codex-native-unprefixed loop checked only `"codex"` — a model hidden + * via the `openai` provider page (codex often shares the openai-compatible + * connection) never matched. + * - The synced-discovery loop checked only the raw connection `providerId` — + * a model hidden via the compatible-provider node's configured *prefix* + * (the identifier the operator actually sees/uses on that node's page) + * never matched. + */ +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-11300-hidden-leak-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const { mergeModelCompatOverride } = await import("../../src/lib/localDb.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function fetchCatalogIds(): Promise { + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { data: Array<{ id: string }> }; + assert.ok(Array.isArray(body.data), "response has data array"); + return body.data.map((m) => m.id); +} + +test("#11300 A: hiding a static model under its ALIAS (cc) excludes it under both cc/ and claude/ ids", async () => { + await providersDb.createProviderConnection({ + provider: "claude", + authType: "apikey", + name: "claude-main", + apiKey: "sk-test-11300a", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + // Sanity: before hiding, the model is advertised. + let ids = await fetchCatalogIds(); + assert.ok( + ids.includes("cc/claude-opus-5"), + `expected cc/claude-opus-5 to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes("claude-opus-5")))}` + ); + + // Operator hides the model on the provider page, whose route param is the + // alias "cc" (not the canonical "claude"). + mergeModelCompatOverride("cc", "claude-opus-5", { isHidden: true }); + + ids = await fetchCatalogIds(); + assert.ok( + !ids.includes("cc/claude-opus-5"), + `#11300 RED: cc/claude-opus-5 hidden under alias "cc" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes("claude-opus-5")))}` + ); + assert.ok( + !ids.includes("claude/claude-opus-5"), + `#11300 RED: claude/claude-opus-5 hidden under alias "cc" must not appear either` + ); +}); + +test("#11300 B: hiding a codex-native unprefixed model under \"openai\" excludes the bare model id", async () => { + await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-main", + apiKey: "sk-test-11300b", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + const nativeModelId = "gpt-5.6-sol"; + + let ids = await fetchCatalogIds(); + assert.ok( + ids.includes(nativeModelId), + `expected bare "${nativeModelId}" to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes("gpt-5.6-sol")))}` + ); + + // Hidden via the "openai" provider page (codex native models are commonly + // reached through the shared openai-compatible connection). + mergeModelCompatOverride("openai", nativeModelId, { isHidden: true }); + + ids = await fetchCatalogIds(); + assert.ok( + !ids.includes(nativeModelId), + `#11300 RED: bare "${nativeModelId}" hidden under "openai" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes("gpt-5.6-sol")))}` + ); +}); + +test("#11300 C: hiding a compatible-node synced model under its configured PREFIX excludes prefix/", async () => { + const NODE_ID = "openai-compatible-chat-11300-c0ffee00-0000-4000-8000-000000000000"; + const PREFIX = "deepseek-node-11300"; + + await providersDb.createProviderNode({ + id: NODE_ID, + type: "openai-compatible", + name: "Deepseek Node (11300 probe)", + prefix: PREFIX, + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); + const connection = await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + name: "deepseek-node-conn", + apiKey: "sk-test-11300c", + isActive: true, + testStatus: "active", + providerSpecificData: { + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }, + }); + + const modelId = "deepseek-v4-flash-0731"; + await modelsDb.replaceSyncedAvailableModelsForConnection(NODE_ID, (connection as { id: string }).id, [ + { id: modelId, name: "DeepSeek V4 Flash", source: "imported", supportedEndpoints: ["chat"] }, + ]); + + let ids = await fetchCatalogIds(); + assert.ok( + ids.includes(`${PREFIX}/${modelId}`), + `expected ${PREFIX}/${modelId} to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes(modelId)))}` + ); + + // Operator hides the model via the node's page, which is keyed by the + // configured prefix rather than the internal node UUID. + mergeModelCompatOverride(PREFIX, modelId, { isHidden: true }); + + ids = await fetchCatalogIds(); + assert.ok( + !ids.includes(`${PREFIX}/${modelId}`), + `#11300 RED: ${PREFIX}/${modelId} hidden under prefix "${PREFIX}" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes(modelId)))}` + ); + assert.ok( + !ids.includes(`${NODE_ID}/${modelId}`), + `#11300 RED: ${NODE_ID}/${modelId} hidden under prefix "${PREFIX}" must not appear either` + ); +});