mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(api): stop leaking the internal provider UUID in /v1/models and honor the configured prefix (#8327) (#8361)
This commit is contained in:
committed by
GitHub
parent
cbc7786533
commit
9b7bd6e5d3
1
changelog.d/fixes/8327-models-owned-by-prefix.md
Normal file
1
changelog.d/fixes/8327-models-owned-by-prefix.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): stop leaking the internal provider UUID in /v1/models and honor the configured prefix (#8327)
|
||||
@@ -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,
|
||||
|
||||
190
tests/unit/8327-models-owned-by-prefix.test.ts
Normal file
190
tests/unit/8327-models-owned-by-prefix.test.ts
Normal file
@@ -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-<uuid>` 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<Record<string, unknown>> };
|
||||
|
||||
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<Record<string, unknown>> };
|
||||
|
||||
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<Record<string, unknown>> };
|
||||
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user