Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {};
try {
settings = await getSettings();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
177 changes: 177 additions & 0 deletions tests/unit/hidden-models-leak-v1-models-11300.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* #11300 — Models toggled to "Hidden" on Provider pages are still listed in
* `GET /v1/models`.
*
* `PATCH /api/provider-models?provider=<key>&modelId=<id>` 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<string[]> {
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/<model>", 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`
);
});
Loading