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.
This commit is contained in:
diegosouzapw
2026-08-04 06:38:59 -03:00
parent 7163081f5e
commit 5ba47fa382
3 changed files with 149 additions and 9 deletions

View File

@@ -0,0 +1 @@
- fix(api): specialty model catalog ignores hidden OpenRouter model flags (#9293)

View File

@@ -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({

View File

@@ -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"
);
});