mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 13:23:50 +03:00
* fix(api): type compatible-provider-node models in /v1/models by the node's apiType Model rows discovered from an OpenAI-compatible provider node rarely carry endpoint metadata — a TEI / Infinity / vLLM `/v1/models` listing is just ids — and the catalog defaulted such rows to `["chat"]`. An `embeddings`-typed node exposing `bge-m3` and a `rerank`-typed node exposing `bge-reranker-v2-m3` therefore both surfaced in GET /v1/models as untyped chat models: clients that build their picker from `type: "embedding"` / `type: "rerank"` never saw them, and chat pickers listed models that 400 on chat. - src/shared/constants/modelSupportedEndpoints.ts: add defaultEndpointsForProviderNodeApiType(apiType) — embeddings → ["embeddings"], rerank → ["rerank"], audio-* → themselves, images-generations → ["images"], chat/responses/unknown → ["chat"] (unchanged default). - src/app/api/v1/models/catalog.ts: build a node-id → apiType map next to the existing node-id → type map; the synced-model and custom-model loops fall back to the node's modality instead of ["chat"] when a row has no supportedEndpoints; the custom-overlay merge path also classifies `type`/`subtype` from the overlay's explicit supportedEndpoints, so a manual `["rerank"]` row layered on a discovered chat-default row is re-typed. Explicit supportedEndpoints on any row still take precedence, and chat / responses nodes keep the historical behavior. tests/unit/catalog-provider-node-apitype-endpoints.test.ts covers the helper and the catalog end-to-end for embeddings, rerank, mixed, chat, and overlay cases via getUnifiedModelsResponse(). * chore(changelog): name the #13734 fragment * refactor(api): keep the provider-node modality helpers out of catalog.ts catalog.ts is frozen by the file-size gate (must not grow past 2075 lines) and the apiType fallback pushed it to 2093. Move the node apiType index, the endpoint fallback and the overlay type/subtype fields into catalogNodeModality.ts so catalog.ts ends one line shorter than the base; behaviour and tests are unchanged. * fix(api): give nodeModelEndpoints a string[] return so the catalog classifier typechecks The API-route typecheck gate flagged TS2345 at both classifyModelSupportedEndpoints() call sites: the helper returned `ModelSupportedEndpoint[] | unknown[]`, and unknown[] is not a readonly string[]. The base code only passed because the synced row's supportedEndpoints was untyped. Same pass-through cast overlayEndpoints() already uses; no behaviour change. * fix(memory): list provider-node models in the embedding and rerank selectors GET /api/memory/embedding-providers and GET /api/memory/rerank-providers appended local provider nodes by apiType alone and always with models: []. A node typed "embeddings" that also serves a rerank model — one TEI / Infinity / vLLM box hosting both bge-m3 and bge-reranker-v2-m3 is the common self-hosted layout — was filtered out of the Rerank selector entirely (apiType not in chat/responses/rerank) and showed up in the Embedding selector as a provider with nothing to pick. Typing prefix/model by hand worked because the request path resolves it directly; only the convenience layer was blind. Add src/lib/memory/embedding/nodeModalityListings.ts, which builds the listing from the node's synced + custom model rows, typing each row the way /v1/models does (explicit supportedEndpoints wins, otherwise the node's apiType via defaultEndpointsForProviderNodeApiType; a custom overlay re-types a discovered row). A node is listed for a modality when its apiType matches, when it is a generic chat/responses node (historical behaviour, kept so catalog-less nodes still appear), or when any of its rows is typed for the modality. Both endpoints use it; the curated registries stay first and win on prefix collisions. * chore(changelog): name the #13740 fragment
184 lines
6.5 KiB
TypeScript
184 lines
6.5 KiB
TypeScript
// Regression tests: the memory Embedding / Rerank selectors list local provider
|
|
// nodes with the models they actually expose for that modality.
|
|
//
|
|
// Before: a node was listed by apiType alone and always with `models: []`, so a
|
|
// node typed `embeddings` that also served a rerank model never appeared in the
|
|
// Rerank dropdown, and appeared in the Embedding dropdown with nothing to pick.
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { NextRequest } from "next/server";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-node-modality-listings-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
|
|
const { buildProviderNodeModalityListings } =
|
|
await import("../../src/lib/memory/embedding/nodeModalityListings.ts");
|
|
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 { invalidateDbCache } = await import("../../src/lib/db/readCache.ts");
|
|
const rerankProvidersRoute = await import("../../src/app/api/memory/rerank-providers/route.ts");
|
|
const { listEmbeddingProviders } = await import("../../src/lib/memory/embedding/index.ts");
|
|
|
|
test.after(() => {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
});
|
|
|
|
test.describe("buildProviderNodeModalityListings (pure)", () => {
|
|
const nodes = [
|
|
{
|
|
id: "n-emb",
|
|
prefix: "skilled-mini",
|
|
baseUrl: "http://10.10.50.19:8888/v1",
|
|
apiType: "embeddings",
|
|
},
|
|
{ id: "n-chat", prefix: "homegw", baseUrl: "http://127.0.0.1:11434/v1", apiType: "chat" },
|
|
{ id: "n-rr", prefix: "rr-only", baseUrl: "http://127.0.0.1:8099/v1", apiType: "rerank" },
|
|
{ id: "n-bad", prefix: "bad/prefix", baseUrl: "http://127.0.0.1:1/v1", apiType: "rerank" },
|
|
];
|
|
const synced = {
|
|
"n-emb": [
|
|
{ id: "bge-m3", name: "bge-m3" },
|
|
{ id: "bge-reranker-v2-m3", name: "bge-reranker-v2-m3" },
|
|
],
|
|
"n-chat": [{ id: "qwen3-8b", name: "qwen3-8b" }],
|
|
};
|
|
const custom = {
|
|
// A manual override re-types the reranker row on the embeddings node.
|
|
"n-emb": [
|
|
{ id: "bge-reranker-v2-m3", name: "BGE Reranker v2 M3", supportedEndpoints: ["rerank"] },
|
|
],
|
|
};
|
|
|
|
test("embeddings: embeddings-typed rows are listed under the node prefix", () => {
|
|
const out = buildProviderNodeModalityListings("embeddings", nodes, synced, custom);
|
|
const mini = out.find((p) => p.provider === "skilled-mini");
|
|
assert.deepEqual(mini, {
|
|
provider: "skilled-mini",
|
|
hasKey: true,
|
|
models: [{ id: "skilled-mini/bge-m3", name: "bge-m3", dimensions: null }],
|
|
});
|
|
});
|
|
|
|
test("rerank: the embeddings node is listed because one of its rows is typed rerank", () => {
|
|
const out = buildProviderNodeModalityListings("rerank", nodes, synced, custom);
|
|
const mini = out.find((p) => p.provider === "skilled-mini");
|
|
assert.deepEqual(mini, {
|
|
provider: "skilled-mini",
|
|
hasKey: true,
|
|
models: [
|
|
{ id: "skilled-mini/bge-reranker-v2-m3", name: "BGE Reranker v2 M3", dimensions: null },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("rerank: a rerank-typed node with no catalog is still listed (historical behaviour)", () => {
|
|
const out = buildProviderNodeModalityListings("rerank", nodes, synced, custom);
|
|
assert.deepEqual(
|
|
out.find((p) => p.provider === "rr-only"),
|
|
{
|
|
provider: "rr-only",
|
|
hasKey: true,
|
|
models: [],
|
|
}
|
|
);
|
|
});
|
|
|
|
test("chat nodes stay listed for both modalities, but their chat rows are not offered", () => {
|
|
for (const modality of ["embeddings", "rerank"] as const) {
|
|
const out = buildProviderNodeModalityListings(modality, nodes, synced, custom);
|
|
assert.deepEqual(
|
|
out.find((p) => p.provider === "homegw"),
|
|
{
|
|
provider: "homegw",
|
|
hasKey: true,
|
|
models: [],
|
|
}
|
|
);
|
|
}
|
|
});
|
|
|
|
test("invalid prefixes are skipped", () => {
|
|
const out = buildProviderNodeModalityListings("rerank", nodes, synced, custom);
|
|
assert.equal(
|
|
out.find((p) => p.provider === "bad/prefix"),
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
test.describe("selector endpoints end-to-end", () => {
|
|
test.before(async () => {
|
|
await providersDb.createProviderNode({
|
|
id: "openai-compatible-skilled-mini",
|
|
type: "openai-compatible",
|
|
name: "skilled-mini",
|
|
prefix: "skilled-mini",
|
|
apiType: "embeddings",
|
|
baseUrl: "http://10.10.50.19:8888/v1",
|
|
modelsPath: "/v1/models",
|
|
});
|
|
const connection = await providersDb.createProviderConnection({
|
|
provider: "openai-compatible-skilled-mini",
|
|
authType: "apikey",
|
|
name: "skilled-mini-main",
|
|
apiKey: "sk-test",
|
|
isActive: true,
|
|
providerSpecificData: {
|
|
baseUrl: "http://10.10.50.19:8888/v1",
|
|
prefix: "skilled-mini",
|
|
apiType: "embeddings",
|
|
},
|
|
});
|
|
await modelsDb.replaceSyncedAvailableModelsForConnection(
|
|
"openai-compatible-skilled-mini",
|
|
(connection as { id: string }).id,
|
|
[
|
|
{ id: "bge-m3", name: "bge-m3" },
|
|
{ id: "bge-reranker-v2-m3", name: "bge-reranker-v2-m3" },
|
|
]
|
|
);
|
|
await modelsDb.addCustomModel(
|
|
"openai-compatible-skilled-mini",
|
|
"bge-reranker-v2-m3",
|
|
"bge-reranker-v2-m3",
|
|
"manual",
|
|
"rerank",
|
|
["rerank"]
|
|
);
|
|
invalidateDbCache("nodes");
|
|
invalidateDbCache("connections");
|
|
});
|
|
|
|
test("GET /api/memory/rerank-providers lists the reranker under skilled-mini", async () => {
|
|
const response = await rerankProvidersRoute.GET(
|
|
new NextRequest("http://localhost/api/memory/rerank-providers")
|
|
);
|
|
assert.equal(response.status, 200);
|
|
const body = (await response.json()) as {
|
|
providers: Array<{ provider: string; hasKey: boolean; models: Array<{ id: string }> }>;
|
|
};
|
|
const mini = body.providers.find((p) => p.provider === "skilled-mini");
|
|
assert.ok(mini, "skilled-mini should be listed for rerank");
|
|
assert.equal(mini.hasKey, true);
|
|
assert.deepEqual(
|
|
mini.models.map((m) => m.id),
|
|
["skilled-mini/bge-reranker-v2-m3"]
|
|
);
|
|
});
|
|
|
|
test("listEmbeddingProviders lists the embedding model under skilled-mini", async () => {
|
|
const providers = await listEmbeddingProviders();
|
|
const mini = providers.find((p) => p.provider === "skilled-mini");
|
|
assert.ok(mini, "skilled-mini should be listed for embeddings");
|
|
assert.deepEqual(
|
|
mini.models.map((m) => m.id),
|
|
["skilled-mini/bge-m3"]
|
|
);
|
|
});
|
|
});
|