mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
1 Commits
fix/better
...
fix/11300-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
268d97cd43 |
@@ -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();
|
||||
@@ -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
|
||||
|
||||
177
tests/unit/hidden-models-leak-v1-models-11300.test.ts
Normal file
177
tests/unit/hidden-models-leak-v1-models-11300.test.ts
Normal file
@@ -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`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user