diff --git a/changelog.d/fixes/8327-models-owned-by-prefix.md b/changelog.d/fixes/8327-models-owned-by-prefix.md new file mode 100644 index 0000000000..4e23834052 --- /dev/null +++ b/changelog.d/fixes/8327-models-owned-by-prefix.md @@ -0,0 +1 @@ +- fix(api): stop leaking the internal provider UUID in /v1/models and honor the configured prefix (#8327) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 27582391fc..af0192a998 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -357,6 +357,18 @@ async function buildUnifiedModelsResponseCore( } } + // #8327: `resolveCanonicalProviderId`/`canonicalProviderId` only know the static + // AI_PROVIDERS/PROVIDER_MODELS alias maps, so a compatible-provider node (whose raw + // `id` is an internal UUID, never present in those static maps) falls through every + // lookup and returns the raw UUID verbatim. That UUID is still required for the + // internal registry/connection/hidden-model lookups that key off `canonicalProviderId` + // (getConnectionsForProvider, getModelIsHidden, etc. are keyed by the raw node id, not + // the prefix) — so `canonicalProviderId` itself must stay untouched. What must NOT leak + // is the raw UUID in the *public* `owned_by` field: resolve it to the operator's + // configured prefix there, and only there. + const resolvePublicOwnerId = (providerId: string, canonicalProviderId: string): string => + providerIdToPrefix[providerId] || canonicalProviderId; + // Get combos let combos = []; try { @@ -890,7 +902,7 @@ async function buildUnifiedModelsResponseCore( id: aliasId, object: "model", created: timestamp, - owned_by: canonicalProviderId, + owned_by: resolvePublicOwnerId(providerId, canonicalProviderId), permission: [], root: sm.id, parent: null, @@ -902,7 +914,7 @@ async function buildUnifiedModelsResponseCore( id: aliasId, object: "model", created: timestamp, - owned_by: canonicalProviderId, + owned_by: resolvePublicOwnerId(providerId, canonicalProviderId), permission: [], root: sm.id, parent: null, @@ -925,7 +937,7 @@ async function buildUnifiedModelsResponseCore( id: providerPrefixedId, object: "model", created: timestamp, - owned_by: canonicalProviderId, + owned_by: resolvePublicOwnerId(providerId, canonicalProviderId), permission: [], root: sm.id, parent: includeAlias ? aliasId : null, @@ -1247,7 +1259,7 @@ async function buildUnifiedModelsResponseCore( id: aliasId, object: "model", created: timestamp, - owned_by: canonicalProviderId, + owned_by: resolvePublicOwnerId(providerId, canonicalProviderId), permission: [], root: modelId, parent: null, @@ -1278,7 +1290,7 @@ async function buildUnifiedModelsResponseCore( id: providerPrefixedId, object: "model", created: timestamp, - owned_by: canonicalProviderId, + owned_by: resolvePublicOwnerId(providerId, canonicalProviderId), permission: [], root: modelId, parent: includeAlias ? aliasId : null, @@ -1352,7 +1364,7 @@ async function buildUnifiedModelsResponseCore( id: aliasId, object: "model", created: timestamp, - owned_by: canonicalProviderId, + owned_by: resolvePublicOwnerId(providerKey, canonicalProviderId), permission: [], root: modelId, parent: null, @@ -1373,7 +1385,7 @@ async function buildUnifiedModelsResponseCore( id: providerPrefixedId, object: "model", created: timestamp, - owned_by: canonicalProviderId, + owned_by: resolvePublicOwnerId(providerKey, canonicalProviderId), permission: [], root: modelId, parent: includeAlias ? aliasId : null, @@ -1421,7 +1433,7 @@ async function buildUnifiedModelsResponseCore( id: aliasId, object: "model", created: timestamp, - owned_by: providerId, + owned_by: resolvePublicOwnerId(providerId, canonicalProviderId), permission: [], root: modelId, parent: null, diff --git a/tests/unit/8327-models-owned-by-prefix.test.ts b/tests/unit/8327-models-owned-by-prefix.test.ts new file mode 100644 index 0000000000..01b130e0fa --- /dev/null +++ b/tests/unit/8327-models-owned-by-prefix.test.ts @@ -0,0 +1,190 @@ +/** + * Regression test for #8327 — /v1/models leaked the internal provider-node UUID as + * `owned_by` for synced/custom models on openai-compatible / anthropic-compatible + * provider nodes, instead of honoring the operator-configured `prefix`. + * + * Root cause: `catalog.ts` builds two different identifiers per model entry — + * `alias` (which DOES receive `providerIdToPrefix` and correctly becomes the + * configured prefix for the published `id` field) and `canonicalProviderId` (via + * `resolveCanonicalProviderId()`, catalogProviderMaps.ts), which is used for + * `owned_by` and never consulted `providerIdToPrefix` at all — for a compatible + * provider node (UUID id, not present in the static AI_PROVIDERS/PROVIDER_MODELS + * alias maps) it fell through every lookup and returned the raw UUID verbatim. + * + * Fix: resolve `owned_by` through a dedicated `resolvePublicOwnerId()` helper that + * checks `providerIdToPrefix` first, applied at every emit site (synced-models, + * custom-models, model-alias, managed-fallback blocks) — WITHOUT changing what + * `canonicalProviderId` resolves to internally, since that value is still required, + * unmodified, by connection/hidden-model/registry lookups keyed on the raw node id. + */ +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-8327-")); +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 v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +// A realistic provider-node id shape, matching `openai-compatible-chat-` per +// src/app/api/provider-nodes/route.ts / createProviderNode()'s `id: data.id || uuidv4()`. +const NODE_ID = "openai-compatible-chat-550e8400-e29b-41d4-a716-446655440000"; +const UUID_SHAPE_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; +const CONFIGURED_PREFIX = "pix4k-talk"; + +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 }); +}); + +test("#8327: synced models on a compatible provider node expose the configured prefix as owned_by, not the raw UUID", async () => { + await providersDb.createProviderNode({ + id: NODE_ID, + type: "openai-compatible", + name: "pix4k talk (probe)", + prefix: CONFIGURED_PREFIX, + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); + const connection = await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + name: "pix4k-talk-conn", + apiKey: "sk-test", + isActive: true, + testStatus: "active", + providerSpecificData: { + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }, + }); + + await modelsDb.replaceSyncedAvailableModelsForConnection( + NODE_ID, + (connection as { id: string }).id, + [ + { + id: "glm-5.2", + name: "GLM 5.2", + source: "imported", + supportedEndpoints: ["chat"], + }, + ] + ); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array> }; + + assert.equal(response.status, 200); + + const entry = body.data.find((m) => m.id === `${CONFIGURED_PREFIX}/glm-5.2`); + assert.ok( + entry, + `expected an entry with id "${CONFIGURED_PREFIX}/glm-5.2" in ${JSON.stringify(body.data.map((m) => m.id))}` + ); + assert.equal( + entry!.owned_by, + CONFIGURED_PREFIX, + `owned_by must be the configured prefix "${CONFIGURED_PREFIX}", not the raw provider-node id — got "${entry!.owned_by}"` + ); + + // The raw UUID-shaped provider-node id must never appear as owned_by anywhere. + for (const model of body.data) { + assert.equal( + typeof model.owned_by === "string" && UUID_SHAPE_RE.test(model.owned_by), + false, + `owned_by "${model.owned_by}" (id "${model.id}") must not be a raw provider-node UUID` + ); + assert.notEqual( + model.owned_by, + NODE_ID, + `owned_by must never equal the raw provider-node id "${NODE_ID}"` + ); + } +}); + +test("#8327: custom models on a compatible provider node expose the configured prefix as owned_by", async () => { + await providersDb.createProviderNode({ + id: NODE_ID, + type: "anthropic-compatible", + name: "pix4k talk custom (probe)", + prefix: CONFIGURED_PREFIX, + baseUrl: "https://proxy.example.com", + chatPath: "/v1/messages", + modelsPath: "/v1/models", + }); + await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + name: "pix4k-talk-custom-conn", + apiKey: "sk-test", + isActive: true, + testStatus: "active", + providerSpecificData: { + baseUrl: "https://proxy.example.com", + chatPath: "/v1/messages", + modelsPath: "/v1/models", + }, + }); + await modelsDb.addCustomModel(NODE_ID, "custom-glm", "Custom GLM"); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array> }; + + const entry = body.data.find((m) => m.id === `${CONFIGURED_PREFIX}/custom-glm`); + assert.ok( + entry, + `expected an entry with id "${CONFIGURED_PREFIX}/custom-glm" in ${JSON.stringify(body.data.map((m) => m.id))}` + ); + assert.equal( + entry!.owned_by, + CONFIGURED_PREFIX, + `owned_by must be the configured prefix "${CONFIGURED_PREFIX}", not the raw provider-node id — got "${entry!.owned_by}"` + ); + assert.notEqual(entry!.owned_by, NODE_ID); +}); + +test("#8327: built-in providers keep their existing owned_by contract (unaffected by the fix)", async () => { + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "openai-main", + apiKey: "sk-test", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array> }; + + const openaiModel = body.data.find( + (m) => typeof m.id === "string" && (m.id as string).startsWith("openai/") + ); + assert.ok(openaiModel, "expected at least one openai/* built-in model in the catalog"); + assert.equal(openaiModel!.owned_by, "openai"); +});