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