mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
c46d35bcb4
commit
b914eb1b0f
1
changelog.d/features/6976-openrouter-embeddings.md
Normal file
1
changelog.d/features/6976-openrouter-embeddings.md
Normal 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`.
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
127
tests/unit/openrouter-embeddings-catalog-6976.test.ts
Normal file
127
tests/unit/openrouter-embeddings-catalog-6976.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user