Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/features/6976-openrouter-embeddings.md
Original file line number Diff line number Diff line change
@@ -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`.
32 changes: 29 additions & 3 deletions open-sse/config/embeddingRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
},
],
},
Expand Down
32 changes: 31 additions & 1 deletion src/app/api/providers/[id]/models/discovery/helpers.ts
Original file line number Diff line number Diff line change
@@ -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>;

Expand Down Expand Up @@ -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> {
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/providers/[id]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
getAzureOpenAIApiVersion,
isLocalOpenAIStyleProvider,
mergeLocalCatalogModels,
mergeSpecialtyCatalogIntoLiveModels,
buildOptionalBearerHeaders,
buildNamedOpenAiStyleHeaders,
} from "./discovery/helpers";
Expand Down Expand Up @@ -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,
Expand Down
127 changes: 127 additions & 0 deletions tests/unit/openrouter-embeddings-catalog-6976.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
13 changes: 12 additions & 1 deletion tests/unit/provider-models-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading