diff --git a/changelog.d/features/6976-openrouter-embeddings.md b/changelog.d/features/6976-openrouter-embeddings.md new file mode 100644 index 0000000000..1997f3cd5a --- /dev/null +++ b/changelog.d/features/6976-openrouter-embeddings.md @@ -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`. diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 16ffcd68ab..e36b649201 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -187,6 +187,12 @@ export const EMBEDDING_PROVIDERS: Record = { ], }, + // #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 = { 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, }, ], }, diff --git a/src/app/api/providers/[id]/models/discovery/helpers.ts b/src/app/api/providers/[id]/models/discovery/helpers.ts index 7d22589ca4..c0bb513b6f 100644 --- a/src/app/api/providers/[id]/models/discovery/helpers.ts +++ b/src/app/api/providers/[id]/models/discovery/helpers.ts @@ -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; @@ -51,6 +51,36 @@ export function mergeLocalCatalogModels(["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( + liveModels: T[], + provider: string +): Array { + 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 { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index ebca992a15..62fe747189 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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, diff --git a/tests/unit/openrouter-embeddings-catalog-6976.test.ts b/tests/unit/openrouter-embeddings-catalog-6976.test.ts new file mode 100644 index 0000000000..f32d1d0612 --- /dev/null +++ b/tests/unit/openrouter-embeddings-catalog-6976.test.ts @@ -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 = {}) { + 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"); +}); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 6a604f2305..be36ef661a 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -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 () => {