feat(providers): curated OpenRouter embeddings catalog + specialty merge in live discovery (#6976) (#6994)

* feat(providers): curated OpenRouter embeddings catalog + specialty merge in live discovery (#6976)

OpenRouter serves embeddings via a dedicated OpenAI-compatible
/api/v1/embeddings endpoint that is omitted from /v1/models, and the
embeddingRegistry entry for it was stale (3 legacy ids). Meanwhile
providerModelsConfig gives openrouter a live discovery config, so
buildApiDiscoveryResponse's success path returned only the live chat
catalog verbatim — the specialty (embeddings/rerank) static catalog was
only ever merged in on the no-config local_catalog fallback, so OpenRouter
embeddings never surfaced through model discovery.

Refreshed the curated openrouter embeddingRegistry lineup (ids verified
against https://openrouter.ai/docs/api/reference/embeddings and the
collections page) and added a scoped, additive merge
(mergeSpecialtyCatalogIntoLiveModels, allowlisted to openrouter) that folds
embeddings/rerank entries from getStaticModelsForProvider() into the live
discovery response, deduped by id. Scoped as an allowlist rather than a
blanket merge because some providers (e.g. Gemini) already return
embedding models directly from their live /v1/models endpoint, where a
blind merge would risk stale/duplicate entries.

* test(providers): type the models discovery payload instead of any (#6976)

no-explicit-any is an error under tests/ (#6218), so the 4 `any`
usages in the new discovery assertions failed the max-warnings-0
lint gate. Replace them with an explicit ModelsResponseBody shape —
type-only change, all 13 assertions unchanged and still passing.

* test(providers): type the openrouter merge assertion callback (#6976)

The new #6976 assertion added a 56th explicit `any` to this file,
one over the 55 frozen in config/quality/eslint-suppressions.json,
tripping the max-warnings-0 lint gate. Type the callback param
instead of raising the frozen count — the debt ratchet only decreases.
All 59 tests still pass.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 10:39:21 -03:00
committed by GitHub
parent c46d35bcb4
commit b914eb1b0f
6 changed files with 207 additions and 6 deletions

View File

@@ -0,0 +1 @@
- **feat(providers):** refresh the curated OpenRouter embeddings catalog (`open-sse/config/embeddingRegistry.ts`) with the current lineup — `openai/text-embedding-3-small`/`-large`, `qwen/qwen3-embedding-8b`/`-4b`, `baai/bge-m3`, `mistralai/mistral-embed-2312`, `google/gemini-embedding-001` — and fold curated embedding/rerank entries into OpenRouter's live model-discovery response (`src/app/api/providers/[id]/models/route.ts`), additively and deduped by id, so they no longer only appear on the no-config `local_catalog` fallback. OpenRouter serves embeddings via a dedicated `/api/v1/embeddings` endpoint (omitted from `/v1/models`), so the live-discovery success path previously returned chat models only ([#6976](https://github.com/diegosouzapw/OmniRoute/issues/6976)). Regression guard: `tests/unit/openrouter-embeddings-catalog-6976.test.ts`.

View File

@@ -187,6 +187,12 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
],
},
// #6976 — OpenRouter serves embeddings via a dedicated OpenAI-compatible
// /api/v1/embeddings endpoint (omitted from /v1/models, so this catalog is
// curated rather than live-discovered). Ids verified against the API
// reference (not the display-name collections page) at refresh time:
// https://openrouter.ai/docs/api/reference/embeddings and
// https://openrouter.ai/collections/embedding-models
openrouter: {
id: "openrouter",
baseUrl: "https://openrouter.ai/api/v1/embeddings",
@@ -204,9 +210,29 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
dimensions: 3072,
},
{
id: "openai/text-embedding-ada-002",
name: "Text Embedding Ada 002 (OpenRouter)",
dimensions: 1536,
id: "qwen/qwen3-embedding-8b",
name: "Qwen3 Embedding 8B (OpenRouter)",
dimensions: 4096,
},
{
id: "qwen/qwen3-embedding-4b",
name: "Qwen3 Embedding 4B (OpenRouter)",
dimensions: 2560,
},
{
id: "baai/bge-m3",
name: "BGE-M3 (OpenRouter)",
dimensions: 1024,
},
{
id: "mistralai/mistral-embed-2312",
name: "Mistral Embed (OpenRouter)",
dimensions: 1024,
},
{
id: "google/gemini-embedding-001",
name: "Gemini Embedding 001 (OpenRouter)",
dimensions: 768,
},
],
},

View File

@@ -1,5 +1,5 @@
import { isSelfHostedChatProvider } from "@/shared/constants/providers";
import type { LocalCatalogModel } from "@/lib/providers/staticModels";
import { getStaticModelsForProvider, type LocalCatalogModel } from "@/lib/providers/staticModels";
export type JsonRecord = Record<string, unknown>;
@@ -51,6 +51,36 @@ export function mergeLocalCatalogModels<T extends LocalCatalogModel, U extends L
];
}
// #6976 — providers whose live /v1/models endpoint is known to serve ONLY
// chat models (verified: OpenRouter's catalog is chat-only — embeddings live
// on a separate /api/v1/embeddings endpoint per
// https://openrouter.ai/docs/api/reference/embeddings) never surface their
// embedding/rerank specialty catalog once they have a live discovery config,
// because the specialty catalog is otherwise only folded in on the no-config
// local_catalog fallback. Deliberately an allowlist, not every provider with
// an embeddingRegistry/rerankRegistry entry: some providers' live /v1/models
// response legitimately DOES include embedding ids already (e.g. Gemini's
// models.list mixes generateContent and embedContent models in one response —
// see provider-models-route.test.ts pagination coverage), so blind-merging
// the curated catalog there would risk stale-duplicate/conflicting entries.
const LIVE_DISCOVERY_SPECIALTY_MERGE_PROVIDERS = new Set<string>(["openrouter"]);
// Fold the embeddings/rerank subset of the static catalog into a successful
// live-discovery response, additively and deduped by id, without touching
// chat/image/video/audio entries — scoped to
// LIVE_DISCOVERY_SPECIALTY_MERGE_PROVIDERS above.
export function mergeSpecialtyCatalogIntoLiveModels<T extends { id: string }>(
liveModels: T[],
provider: string
): Array<T | LocalCatalogModel> {
if (!LIVE_DISCOVERY_SPECIALTY_MERGE_PROVIDERS.has(provider)) return liveModels;
const specialty = (getStaticModelsForProvider(provider) || []).filter(
(model) => model.apiFormat === "embeddings" || model.apiFormat === "rerank"
);
if (specialty.length === 0) return liveModels;
return mergeLocalCatalogModels(liveModels, specialty);
}
export function buildOptionalBearerHeaders(
token: string | null | undefined
): Record<string, string> {

View File

@@ -85,6 +85,7 @@ import {
getAzureOpenAIApiVersion,
isLocalOpenAIStyleProvider,
mergeLocalCatalogModels,
mergeSpecialtyCatalogIntoLiveModels,
buildOptionalBearerHeaders,
buildNamedOpenAiStyleHeaders,
} from "./discovery/helpers";
@@ -408,10 +409,15 @@ export async function GET(
) => {
const discoveredModels = await persistDiscoveredModels(provider, connectionId, models);
if (discoveredModels.length > 0) {
// #6976 — merge curated embedding/rerank specialty entries (e.g.
// OpenRouter's embeddingRegistry catalog) into the live-discovery
// response; the live /v1/models endpoint only lists chat models, and
// the specialty catalog otherwise only reached local_catalog fallback.
const mergedModels = mergeSpecialtyCatalogIntoLiveModels(models, provider);
return buildResponse({
provider,
connectionId,
models,
models: mergedModels,
source: "api",
...(warning ? { warning } : {}),
...extraPayload,

View File

@@ -0,0 +1,127 @@
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-openrouter-embeddings-"));
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 providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
const embeddingRegistry = await import("../../open-sse/config/embeddingRegistry.ts");
const staticModels = await import("../../src/lib/providers/staticModels.ts");
const originalFetch = globalThis.fetch;
/** Shape of the /api/providers/[id]/models discovery payload asserted below. */
type DiscoveredModel = { id: string; name?: string };
type ModelsResponseBody = { source: string; models: DiscoveredModel[] };
async function resetStorage() {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function seedConnection(provider: string, overrides: Record<string, unknown> = {}) {
return providersDb.createProviderConnection({
provider,
authType: "apikey",
name: `${provider}-${Math.random().toString(16).slice(2, 8)}`,
apiKey: "or-test-key",
isActive: true,
testStatus: "active",
providerSpecificData: {},
...overrides,
});
}
async function callRoute(connectionId: string) {
return providerModelsRoute.GET(
new Request(`http://localhost/api/providers/${connectionId}/models`),
{ params: { id: connectionId } }
);
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("embeddingRegistry curated openrouter catalog carries the refreshed lineup with dimensions (#6976)", () => {
const config = embeddingRegistry.getEmbeddingProvider("openrouter");
assert.ok(config, "openrouter embedding provider config must exist");
const ids = config!.models.map((m) => m.id);
for (const expected of [
"openai/text-embedding-3-small",
"openai/text-embedding-3-large",
"qwen/qwen3-embedding-8b",
"qwen/qwen3-embedding-4b",
"baai/bge-m3",
"mistralai/mistral-embed-2312",
"google/gemini-embedding-001",
]) {
assert.ok(ids.includes(expected), `expected curated id ${expected}; got ${ids.join(", ")}`);
const dim = config!.models.find((m) => m.id === expected)?.dimensions;
assert.equal(typeof dim, "number", `${expected} must carry a dimensions value`);
}
});
test("getStaticModelsForProvider(openrouter) folds the curated embeddings into the specialty catalog (#6976)", () => {
const specialty = staticModels.getStaticModelsForProvider("openrouter");
assert.ok(specialty && specialty.length > 0);
const embeddingEntry = specialty!.find((m) => m.id === "baai/bge-m3");
assert.ok(embeddingEntry, "curated bge-m3 entry must be present in the static catalog");
assert.equal(embeddingEntry!.apiFormat, "embeddings");
});
test("live discovery merges curated embeddings into the response even when /v1/models returns none (#6976)", async () => {
const connection = await seedConnection("openrouter");
globalThis.fetch = async () =>
Response.json({
data: [{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5" }],
});
const response = await callRoute(connection.id);
const body = (await response.json()) as ModelsResponseBody;
assert.equal(response.status, 200);
assert.equal(body.source, "api");
const ids = body.models.map((m) => m.id);
// Chat model from the live /v1/models fetch is preserved.
assert.ok(ids.includes("anthropic/claude-sonnet-5"));
// RED before the Step 2 merge: the live discovery success path (buildApiDiscoveryResponse)
// returned `models` verbatim, so curated embeddings never appeared here — only on the
// no-config local_catalog fallback. GREEN after: curated embeddings/rerank entries from
// getStaticModelsForProvider() are folded in additively.
assert.ok(
ids.includes("baai/bge-m3"),
`curated embedding baai/bge-m3 should be merged into live discovery; got: ${ids.join(", ")}`
);
assert.ok(ids.includes("openai/text-embedding-3-small"));
});
test("live discovery dedups: a model already present in the live catalog is not duplicated (#6976)", async () => {
const connection = await seedConnection("openrouter");
globalThis.fetch = async () =>
Response.json({
// OpenRouter's live /v1/models never actually lists embedding ids today, but
// this proves the merge is a dedup-by-id union, not a blind concat.
data: [{ id: "baai/bge-m3", name: "BGE-M3 (live)" }],
});
const response = await callRoute(connection.id);
const body = (await response.json()) as ModelsResponseBody;
const bgeEntries = body.models.filter((m) => m.id === "baai/bge-m3");
assert.equal(bgeEntries.length, 1, "baai/bge-m3 must appear exactly once");
assert.equal(bgeEntries[0].name, "BGE-M3 (live)", "live entry wins over the curated duplicate");
});

View File

@@ -572,7 +572,18 @@ test("provider models route prefers the remote OpenRouter /models API over stati
assert.equal(response.status, 200);
assert.equal(body.source, "api");
assert.deepEqual(seenUrls, ["https://openrouter.ai/api/v1/models"]);
assert.deepEqual(body.models, [{ id: "openai/gpt-4.1", name: "GPT-4.1 via OpenRouter" }]);
// #6976 — OpenRouter's live /v1/models never lists embeddings/rerank (they live
// on dedicated endpoints), so the curated specialty catalog is folded into the
// live-discovery response additively; static IMAGE models stay excluded
// (hasChatRegistry is true for openrouter — see staticModels.ts).
const ids = body.models.map((m: { id: string }) => m.id);
assert.ok(ids.includes("openai/gpt-4.1"), "live-fetched chat model is preserved");
assert.ok(ids.includes("baai/bge-m3"), "curated embedding is merged in");
assert.ok(ids.includes("cohere/rerank-v3.5"), "curated rerank is merged in");
assert.ok(
!ids.some((id: string) => id.includes("gpt-5.4-image")),
"static image models stay excluded from the chat+specialty catalog"
);
});
test("provider models route returns the local catalog for embedding and rerank providers", async () => {