From ff493da805b91dee89224bd159dea5adb1ed915e Mon Sep 17 00:00:00 2001 From: Sahil Daswani <37410864+sahildaswani@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:37:11 +0800 Subject: [PATCH] fix: match compatible-provider models owned by public prefix (#13831) * fix: match compatible-provider models owned by public prefix Resolves #13829 The provider-scoped /v1/providers/{provider}/models route filters unified-catalog rows by internal provider ID. For compatible provider nodes, the catalog emits the configured public prefix in owned_by, so all valid models were dropped and the endpoint returned an empty list. Resolve the compatible node's prefix and accept it alongside the internal ID when filtering and when stripping the prefix from returned model ids. * chore(quality): satisfy the format and lint-suppression gates for #13829 Two gate-only touch-ups on top of the fix, no behaviour change: - prettier --check rejected tests/unit/provider-models-v1-route.test.ts over a double blank line before a test block. - typing the map callback removed the file's only `any`, which left the frozen entry in config/quality/eslint-suppressions.json unused; the lint gate fails on a stale suppression, so it is pruned. Both were found by running the gates locally, because this fork PR's workflow runs are still awaiting maintainer approval and only the semgrep check had run. Co-authored-by: sahildaswani --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: sahildaswani --- config/quality/eslint-suppressions.json | 5 -- .../v1/providers/[provider]/models/route.ts | 19 ++++++- tests/unit/provider-models-v1-route.test.ts | 53 ++++++++++++++++++- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 2e43b2f01b..162409c5a6 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -4435,11 +4435,6 @@ "count": 2 } }, - "tests/unit/provider-models-v1-route.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "tests/unit/provider-node-icon-url.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 6 diff --git a/src/app/api/v1/providers/[provider]/models/route.ts b/src/app/api/v1/providers/[provider]/models/route.ts index 9e5450be90..c4c73bf3f3 100644 --- a/src/app/api/v1/providers/[provider]/models/route.ts +++ b/src/app/api/v1/providers/[provider]/models/route.ts @@ -1,4 +1,5 @@ import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; +import { getProviderNodeById } from "@/lib/db/providers/nodes"; import { getServiceModels } from "@/lib/db/serviceModels"; import { isServiceBackendPluginId } from "@/lib/services/serviceBackends"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; @@ -40,6 +41,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov const providerEntry = getRegistryEntry(rawProvider); let providerId = rawProvider; let providerAlias = rawProvider; + let compatiblePrefix: string | null = null; if (providerEntry) { providerId = providerEntry.id; @@ -65,6 +67,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov { status: 400 } ); } + + const compatibleNode = (await getProviderNodeById(rawProvider)) as { + prefix?: unknown; + } | null; + compatiblePrefix = + typeof compatibleNode?.prefix === "string" && compatibleNode.prefix.trim().length > 0 + ? compatibleNode.prefix.trim() + : null; } } @@ -86,10 +96,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov if (!id) return id; if (id.startsWith(`${providerAlias}/`)) return id.slice(providerAlias.length + 1); if (id.startsWith(`${providerId}/`)) return id.slice(providerId.length + 1); + if (compatiblePrefix && id.startsWith(`${compatiblePrefix}/`)) { + return id.slice(compatiblePrefix.length + 1); + } return id; }; - const filtered = payload.data.filter((model) => model?.owned_by === providerId); + const acceptedOwners = new Set([providerId, providerAlias]); + if (compatiblePrefix) acceptedOwners.add(compatiblePrefix); + const filtered = payload.data.filter( + (model) => typeof model?.owned_by === "string" && acceptedOwners.has(model.owned_by) + ); const deduped = new Map>(); for (const model of filtered) { diff --git a/tests/unit/provider-models-v1-route.test.ts b/tests/unit/provider-models-v1-route.test.ts index af92b95b4f..91ece226e8 100644 --- a/tests/unit/provider-models-v1-route.test.ts +++ b/tests/unit/provider-models-v1-route.test.ts @@ -15,7 +15,11 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-v1-provid process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const providerNodesDb = await import("../../src/lib/db/providers/nodes.ts"); const serviceModelsDb = await import("../../src/lib/db/serviceModels.ts"); +const catalog = await import("../../src/app/api/v1/models/catalog.ts"); const routeModule = await import("../../src/app/api/v1/providers/[provider]/models/route.ts"); function makeRequest(provider: string) { @@ -30,6 +34,9 @@ async function callGET(provider: string) { test.beforeEach(() => { core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + catalog.__resetCatalogBuilderRunsForTest(); }); test.after(() => { @@ -105,13 +112,57 @@ test("GET /v1/providers/:provider/models returns synced embedded service models" assert.equal(res.status, 200); assert.equal(body.object, "list"); assert.deepEqual( - body.data.map((model: any) => model.id), + body.data.map((model: { id: string }) => model.id), ["cli/gpt-5"] ); assert.equal(body.data[0].owned_by, "cliproxyapi"); assert.equal(body.data[0].parent, null); }); +test("#13829: compatible provider IDs return synced models owned by their public prefix", async () => { + const providerId = "openai-compatible-chat-a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const prefix = "hz"; + const modelId = "Qwen/Qwen3.6-35B-A3B-FP8"; + + await providerNodesDb.createProviderNode({ + id: providerId, + type: "openai-compatible-chat", + name: "Hetzner", + prefix, + apiType: "chat", + baseUrl: "https://inference.example.com/v1", + }); + const connection = await providersDb.createProviderConnection({ + provider: providerId, + authType: "apikey", + name: "Hetzner", + apiKey: "test-key", + isActive: true, + testStatus: "active", + }); + await modelsDb.replaceSyncedAvailableModelsForConnection(providerId, String(connection.id), [ + { id: modelId, name: modelId, source: "imported" }, + ]); + + const catalogResponse = await catalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const catalogBody = (await catalogResponse.json()) as { data: Array> }; + const catalogModel = catalogBody.data.find((model) => model.id === `${prefix}/${modelId}`); + assert.equal(catalogModel?.owned_by, prefix); + + const res = await callGET(providerId); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.deepEqual( + body.data.map((model: { id: string }) => model.id), + [modelId] + ); + assert.equal(body.data[0].owned_by, prefix); + assert.equal(body.data[0].parent, null); +}); + test("GET /v1/providers/:provider/models rejects non-matching connection-like strings", async () => { // Looks like a connection ID but with wrong prefix const res = await callGET("custom-compatible-chat-a1b2c3d4-e5f6-7890-abcd-ef1234567890");