From 5ba47fa3826282ee7603e48c20678f3c07acb3dc Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 4 Aug 2026 06:38:59 -0300 Subject: [PATCH] fix(api): specialty model catalog ignores hidden OpenRouter model flags (#9293) The specialty model catalog loops (image, rerank, audio, moderation, video, music) in catalog.ts reduced OpenRouter model IDs to only the final path segment via .split("/").pop() before calling getModelIsHidden(), so stored hidden flags with full provider-relative paths (e.g. openrouter+google/chirp-3) were never matched. Fix: introduce a shared getSpecialtyModelRelativeId helper that strips only the provider prefix (like the embedding loop already did), and apply it to all 6 affected specialty loops. Also add a hidden-model guard to the live OpenRouter catalog path that had no such check at all. --- changelog.d/fixes/9293-fix.plan.md | 1 + src/app/api/v1/models/catalog.ts | 29 ++-- ...ialty-model-hidden-openrouter-9293.test.ts | 128 ++++++++++++++++++ 3 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fixes/9293-fix.plan.md create mode 100644 tests/unit/specialty-model-hidden-openrouter-9293.test.ts diff --git a/changelog.d/fixes/9293-fix.plan.md b/changelog.d/fixes/9293-fix.plan.md new file mode 100644 index 0000000000..96e6f6727a --- /dev/null +++ b/changelog.d/fixes/9293-fix.plan.md @@ -0,0 +1 @@ +- fix(api): specialty model catalog ignores hidden OpenRouter model flags (#9293) \ No newline at end of file diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index d817cd796e..d15e7578ee 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -959,6 +959,9 @@ async function buildUnifiedModelsResponseCore( const modelType = getOpenRouterModelType(inputModalities, outputModalities); const isFree = isOpenRouterFreeModel(openRouterModel); if (hidePaid && !isFree) continue; + // #9293: respect per-model hidden flags (e.g. operator hid google/chirp-3 + // from the OpenRouter provider, so it should not appear in the live catalog). + if (getModelIsHidden("openrouter", openRouterModel.id)) continue; const supportedParameters = Array.isArray(openRouterModel.supported_parameters) ? openRouterModel.supported_parameters : []; @@ -1041,12 +1044,20 @@ async function buildUnifiedModelsResponseCore( return existingRoot === rawModelId; }); + // Helper: strip the provider prefix from a specialty model ID to get the + // provider-relative path (e.g. "openrouter/google/chirp-3" -> "google/chirp-3"). + // This is the correct key used by getModelIsHidden() — using .split("/").pop() + // here would discard all but the last segment and miss stored flags for + // providers whose model IDs carry a sub-path (e.g. OpenRouter scoped models). + const getSpecialtyModelRelativeId = (modelId: string, provider: string): string => + modelId.startsWith(`${provider}/`) + ? modelId.slice(provider.length + 1) + : modelId; + // Add embedding models (filtered by active providers) for (const embModel of getAllEmbeddingModels()) { if (!isProviderActive(embModel.provider)) continue; - const rawModelId = embModel.id.startsWith(`${embModel.provider}/`) - ? embModel.id.slice(embModel.provider.length + 1) - : embModel.id; + const rawModelId = getSpecialtyModelRelativeId(embModel.id, embModel.provider); if (!providerSupportsModel(embModel.provider, rawModelId)) continue; if (getModelIsHidden(embModel.provider, rawModelId)) continue; if (hasEquivalentSpecialtyModel(embModel.provider, rawModelId, "embedding", embModel.id)) { @@ -1066,7 +1077,7 @@ async function buildUnifiedModelsResponseCore( // Add image models (filtered by active providers) for (const imgModel of getAllImageModels()) { if (!isProviderActive(imgModel.provider)) continue; - const rawModelId = imgModel.id.split("/").pop() || imgModel.id; + const rawModelId = getSpecialtyModelRelativeId(imgModel.id, imgModel.provider); if (!providerSupportsModel(imgModel.provider, rawModelId)) continue; if (getModelIsHidden(imgModel.provider, rawModelId)) continue; models.push({ @@ -1085,7 +1096,7 @@ async function buildUnifiedModelsResponseCore( // Add rerank models (filtered by active providers) for (const rerankModel of getAllRerankModels()) { if (!isProviderActive(rerankModel.provider)) continue; - const rawModelId = rerankModel.id.split("/").pop() || rerankModel.id; + const rawModelId = getSpecialtyModelRelativeId(rerankModel.id, rerankModel.provider); if (!providerSupportsModel(rerankModel.provider, rawModelId)) continue; if (getModelIsHidden(rerankModel.provider, rawModelId)) continue; if (hasEquivalentSpecialtyModel(rerankModel.provider, rawModelId, "rerank", rerankModel.id)) { @@ -1104,7 +1115,7 @@ async function buildUnifiedModelsResponseCore( // Add audio models (filtered by active providers) for (const audioModel of getAllAudioModels()) { if (!isProviderActive(audioModel.provider)) continue; - const rawModelId = audioModel.id.split("/").pop() || audioModel.id; + const rawModelId = getSpecialtyModelRelativeId(audioModel.id, audioModel.provider); if (!providerSupportsModel(audioModel.provider, rawModelId)) continue; if (getModelIsHidden(audioModel.provider, rawModelId)) continue; models.push({ @@ -1120,7 +1131,7 @@ async function buildUnifiedModelsResponseCore( // Add moderation models (filtered by active providers) for (const modModel of getAllModerationModels()) { if (!isProviderActive(modModel.provider)) continue; - const rawModelId = modModel.id.split("/").pop() || modModel.id; + const rawModelId = getSpecialtyModelRelativeId(modModel.id, modModel.provider); if (!providerSupportsModel(modModel.provider, rawModelId)) continue; if (getModelIsHidden(modModel.provider, rawModelId)) continue; models.push({ @@ -1135,7 +1146,7 @@ async function buildUnifiedModelsResponseCore( // Add video models (filtered by active providers) for (const videoModel of getAllVideoModels()) { if (!isProviderActive(videoModel.provider)) continue; - const rawModelId = videoModel.id.split("/").pop() || videoModel.id; + const rawModelId = getSpecialtyModelRelativeId(videoModel.id, videoModel.provider); if (!providerSupportsModel(videoModel.provider, rawModelId)) continue; if (getModelIsHidden(videoModel.provider, rawModelId)) continue; models.push({ @@ -1150,7 +1161,7 @@ async function buildUnifiedModelsResponseCore( // Add music models (filtered by active providers) for (const musicModel of getAllMusicModels()) { if (!isProviderActive(musicModel.provider)) continue; - const rawModelId = musicModel.id.split("/").pop() || musicModel.id; + const rawModelId = getSpecialtyModelRelativeId(musicModel.id, musicModel.provider); if (!providerSupportsModel(musicModel.provider, rawModelId)) continue; if (getModelIsHidden(musicModel.provider, rawModelId)) continue; models.push({ diff --git a/tests/unit/specialty-model-hidden-openrouter-9293.test.ts b/tests/unit/specialty-model-hidden-openrouter-9293.test.ts new file mode 100644 index 0000000000..82f673aaf3 --- /dev/null +++ b/tests/unit/specialty-model-hidden-openrouter-9293.test.ts @@ -0,0 +1,128 @@ +/** + * #9293 — specialty model catalog ignores hidden OpenRouter model flags. + * + * The specialty model loops (image, rerank, audio, moderation, video, music) + * in catalog.ts reduce OpenRouter model IDs to only the final path segment + * via .split("/").pop() before calling getModelIsHidden(), so stored hidden + * flags with full provider-relative paths (e.g. openrouter+google/chirp-3) + * are never matched. The embedding loop correctly strips only the provider prefix + * rather than taking the last segment. + * + * This test: seeds an OpenRouter connection, hides two OpenRouter specialty + * models (audio: google/chirp-3, image: black-forest-labs/flux.2-pro), then + * verifies the hidden models are excluded from the /v1/models catalog while + * non-hidden models still appear. + */ +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-9293-specialty-hidden-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "9293-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const { mergeModelCompatOverride, getModelIsHidden } = await import("../../src/lib/localDb.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + 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(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#9293 hidden OpenRouter specialty models are excluded from /v1/models catalog", async () => { + // Create an active OpenRouter connection + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "openrouter-test", + apiKey: "sk-or-test-9293", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + assert.ok(connection?.id, "OpenRouter connection created"); + + // Confirm the hidden flag is not set yet + assert.equal( + getModelIsHidden("openrouter", "google/chirp-3"), + false, + "chirp-3 is initially visible" + ); + assert.equal( + getModelIsHidden("openrouter", "black-forest-labs/flux.2-pro"), + false, + "flux.2-pro is initially visible" + ); + + // Hide two OpenRouter specialty models: one audio, one image + mergeModelCompatOverride("openrouter", "google/chirp-3", { isHidden: true }); + mergeModelCompatOverride("openrouter", "black-forest-labs/flux.2-pro", { isHidden: true }); + + // Confirm the hidden flags are stored correctly + assert.equal(getModelIsHidden("openrouter", "google/chirp-3"), true, "chirp-3 is now hidden"); + assert.equal( + getModelIsHidden("openrouter", "black-forest-labs/flux.2-pro"), + true, + "flux.2-pro is now hidden" + ); + + // Fetch the full catalog + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/v1/models") + ); + assert.equal(response.status, 200); + const body = (await response.json()) as any; + assert.ok(Array.isArray(body.data), "response has data array"); + + // Find audio and image models + const audioModels = body.data.filter((m: any) => m.type === "audio"); + const imageModels = body.data.filter((m: any) => m.type === "image"); + + // chirp-3 model ID from the audio registry is openrouter/google/chirp-3 + const hiddenAudio = audioModels.find((m: any) => + String(m.id).endsWith("google/chirp-3") + ); + assert.equal( + hiddenAudio, + undefined, + "#9293 RED: hidden audio model openrouter/google/chirp-3 should NOT appear in catalog" + ); + + // flux.2-pro model ID from the image registry is openrouter/black-forest-labs/flux.2-pro + const hiddenImage = imageModels.find((m: any) => + String(m.id).endsWith("black-forest-labs/flux.2-pro") + ); + assert.equal( + hiddenImage, + undefined, + "#9293 RED: hidden image model openrouter/black-forest-labs/flux.2-pro should NOT appear in catalog" + ); + + // Verify non-hidden audio models from OpenRouter still appear + // deepgram/nova-3 is not hidden, so it should be present + const visibleAudio = audioModels.find((m: any) => + String(m.id).endsWith("deepgram/nova-3") + ); + assert.ok( + visibleAudio, + "non-hidden audio model deepgram/nova-3 should still appear in catalog" + ); +}); \ No newline at end of file