Files
OmniRoute/tests/unit/memory-node-modality-listings.test.ts
Sean Ford 010250cf08 fix(memory): list provider-node models in the Embedding and Rerank selectors (#13740)
* 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
2026-09-18 11:32:26 -03:00

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