diff --git a/changelog.d/fixes/memory-embedding-quick-select-providers.md b/changelog.d/fixes/memory-embedding-quick-select-providers.md
new file mode 100644
index 0000000000..07c2325d90
--- /dev/null
+++ b/changelog.d/fixes/memory-embedding-quick-select-providers.md
@@ -0,0 +1 @@
+- **fix(memory):** Embedding Model Quick select, Embedding Source remote dropdown, and Rerank selector now list every configured provider with embedding/rerank support instead of only chat-catalog text matches plus OpenRouter live discovery; a generic OpenAI-compatible `/embeddings` + Cohere-compatible `/rerank` runtime fallback resolves any configured chat provider's embedding/rerank endpoint, so unlisted providers no longer fail with "Unknown embedding provider"; both memory selectors gained a free-text model override
diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts
index 85758e4535..004bb1727c 100644
--- a/open-sse/config/embeddingRegistry.ts
+++ b/open-sse/config/embeddingRegistry.ts
@@ -408,6 +408,7 @@ export const EMBEDDING_PROVIDERS: Record = {
},
],
},
+
};
const EMBEDDING_PROVIDER_ALIASES: Record = {
@@ -470,6 +471,38 @@ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | nu
return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null;
}
+/**
+ * Derive an OpenAI-compatible embeddings config for a chat provider that has NO
+ * curated EMBEDDING_PROVIDERS entry. Works for any registry provider whose base
+ * URL ends in /chat/completions by swapping that suffix for /embeddings (groq,
+ * mistral, together, upstage, fireworks, nvidia, vercel-ai-gateway, ...).
+ * Dynamic-URL providers (no usable static base) derive to
+ * null — they need bespoke URL handling, not a bogus endpoint.
+ *
+ * This is a FALLBACK only: callers must check getEmbeddingProvider() first so
+ * curated entries keep their specialized configuration.
+ */
+export function deriveEmbeddingProviderForChatProvider(
+ providerId: string,
+ chatEntry: { id?: string; baseUrl?: string | string[] } | null | undefined
+): EmbeddingProvider | null {
+ if (!chatEntry) return null;
+ const rawBase = Array.isArray(chatEntry.baseUrl)
+ ? chatEntry.baseUrl[0]
+ : chatEntry.baseUrl;
+ if (!rawBase || typeof rawBase !== "string") return null;
+ // stripTrailingSlashes-equivalent without importing open-sse utils here:
+ const base = rawBase.replace(/\/+$/, "");
+ if (!base.endsWith("/chat/completions")) return null;
+ return {
+ id: providerId,
+ baseUrl: `${base.slice(0, -"/chat/completions".length)}/embeddings`,
+ authType: "apikey",
+ authHeader: "bearer",
+ models: [],
+ };
+}
+
/**
* Parse embedding model string (format: "provider/model" or just "model")
* Returns { provider, model }
@@ -485,6 +518,18 @@ export function parseEmbeddingModel(
const slashIdx = modelStr.indexOf("/");
if (slashIdx > 0) {
const rawProvider = modelStr.slice(0, slashIdx);
+
+ // A configured provider_node whose prefix exactly equals the requested
+ // provider segment always wins — even when that segment is also an alias
+ // of a curated provider (a local node must not be hijacked by a registry
+ // alias). Same exact-match precedence documented for
+ // EMBEDDING_MODEL_ALIASES above.
+ const dynamicExact =
+ dynamicProviders && dynamicProviders.find((dp) => dp.id === rawProvider);
+ if (dynamicExact) {
+ return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) };
+ }
+
const resolvedProvider = resolveEmbeddingProviderId(rawProvider);
if (EMBEDDING_PROVIDERS[resolvedProvider]) {
diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts
index f1647f9756..4f7769fca9 100644
--- a/open-sse/config/rerankRegistry.ts
+++ b/open-sse/config/rerankRegistry.ts
@@ -218,3 +218,29 @@ export function getAllRerankModels() {
}
return models;
}
+
+/**
+ * Derive a Cohere-compatible rerank config for a chat provider that has NO
+ * curated RERANK_PROVIDERS entry. Works for any registry provider whose base
+ * URL ends in /chat/completions by swapping that suffix for /rerank (groq,
+ * mistral, vercel-ai-gateway, ...). Dynamic-URL providers (no usable static
+ * base, e.g. dynamic account-scoped hosts) derive to null — they need bespoke
+ * URL handling.
+ *
+ * This is a FALLBACK only: callers must check getRerankProvider() first so
+ * curated entries keep their specialized configuration and format adapters.
+ */
+export function deriveRerankProviderForChatProvider(providerId, chatEntry) {
+ if (!chatEntry) return null;
+ const rawBase = Array.isArray(chatEntry.baseUrl) ? chatEntry.baseUrl[0] : chatEntry.baseUrl;
+ if (!rawBase || typeof rawBase !== "string") return null;
+ const base = rawBase.replace(/\/+$/, "");
+ if (!base.endsWith("/chat/completions")) return null;
+ return {
+ id: providerId,
+ baseUrl: `${base.slice(0, -"/chat/completions".length)}/rerank`,
+ authType: "apikey",
+ authHeader: "bearer",
+ models: [],
+ };
+}
diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts
index 452e6f3500..45ab3c2bee 100644
--- a/open-sse/handlers/rerank.ts
+++ b/open-sse/handlers/rerank.ts
@@ -201,6 +201,7 @@ export async function handleRerank({
connectionId = null,
apiKeyId = null,
apiKeyName = null,
+ resolvedProvider = null,
}) {
const startTime = Date.now();
if (!model) return errorResponse(400, "model is required");
@@ -210,7 +211,8 @@ export async function handleRerank({
}
const { provider: providerId, model: modelId } = parseRerankModel(model);
- const providerConfig = providerId ? getRerankProvider(providerId) : null;
+ const providerConfig =
+ resolvedProvider || (providerId ? getRerankProvider(providerId) : null);
if (!providerConfig) {
const availableProviders = Object.keys(RERANK_PROVIDERS).join(", ");
@@ -219,10 +221,13 @@ export async function handleRerank({
`No rerank provider found for model "${model}". Available: ${availableProviders}`
);
}
+ // When a derived/generic provider is injected, its id is authoritative for
+ // logging and cost attribution even though parseRerankModel returned null.
+ const effectiveProviderId = providerConfig.id || providerId;
const token = credentials?.apiKey || credentials?.accessToken;
if (!token) {
- return errorResponse(401, `No credentials for rerank provider: ${providerId}`);
+ return errorResponse(401, `No credentials for rerank provider: ${effectiveProviderId}`);
}
const requestBody = transformRequestForProvider(providerConfig, {
@@ -275,8 +280,8 @@ export async function handleRerank({
method: "POST",
path: "/v1/rerank",
status: res.status,
- model: `${providerId}/${modelId}`,
- provider: providerId,
+ model: `${effectiveProviderId}/${modelId}`,
+ provider: effectiveProviderId,
connectionId: connectionId || undefined,
duration: Date.now() - startTime,
requestBody,
@@ -296,14 +301,14 @@ export async function handleRerank({
});
const searchUnits = Number(result?.meta?.billed_units?.search_units) || 0;
- const costUsd = await calculateModalCost("rerank", providerId, modelId, { searchUnits });
+ const costUsd = await calculateModalCost("rerank", effectiveProviderId, modelId, { searchUnits });
saveCallLog({
method: "POST",
path: "/v1/rerank",
status: 200,
- model: `${providerId}/${modelId}`,
- provider: providerId,
+ model: `${effectiveProviderId}/${modelId}`,
+ provider: effectiveProviderId,
connectionId: connectionId || undefined,
duration: Date.now() - startTime,
tokens: { prompt_tokens: 0, completion_tokens: 0 },
@@ -315,7 +320,7 @@ export async function handleRerank({
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
attachOmniRouteMetaHeaders(headers, {
- provider: providerId,
+ provider: effectiveProviderId,
model: modelId,
costUsd,
latencyMs: Date.now() - startTime,
diff --git a/src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector.tsx b/src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector.tsx
index 15fd4fec04..1c8eece963 100644
--- a/src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector.tsx
+++ b/src/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector.tsx
@@ -89,22 +89,46 @@ export default function EmbeddingSourceSelector({ settings, providers, onSave, s
{t("embedding.noRemoteProviders")}
) : (
-
+ <>
+
+ {/* Free-text override: the runtime accepts any configured provider's
+ OpenAI-compatible model id, including ones without a curated
+ registry entry (e.g. groq/, mistral/, cf/...). */}
+ handleProviderModelChange(e.target.value)}
+ disabled={saving}
+ placeholder="provider/model — e.g. mistral/mistral-embed"
+ data-testid="embedding-provider-model-input"
+ className="w-full mt-2 px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-violet-500"
+ />
+ >
)}
diff --git a/src/app/(dashboard)/dashboard/memory/components/RerankConfigCard.tsx b/src/app/(dashboard)/dashboard/memory/components/RerankConfigCard.tsx
index 27ca10469f..1bf8dbb74f 100644
--- a/src/app/(dashboard)/dashboard/memory/components/RerankConfigCard.tsx
+++ b/src/app/(dashboard)/dashboard/memory/components/RerankConfigCard.tsx
@@ -43,11 +43,7 @@ export default function RerankConfigCard({ settings, providers, onSave, saving }
}}
disabled={saving || (!rerankEnabled && !hasProvider)}
aria-disabled={saving || (!rerankEnabled && !hasProvider)}
- title={
- !rerankEnabled && !hasProvider
- ? t("rerank.noProviderWithKey")
- : undefined
- }
+ title={!rerankEnabled && !hasProvider ? t("rerank.noProviderWithKey") : undefined}
role="switch"
aria-checked={rerankEnabled}
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${
@@ -83,22 +79,45 @@ export default function RerankConfigCard({ settings, providers, onSave, saving }
{t("rerank.noProviderWithKey")}
) : (
-
+ <>
+
+ {/* Free-text override: any configured provider's Cohere-compatible
+ model id is accepted by the runtime even without a curated entry. */}
+ handleProviderModelChange(e.target.value)}
+ disabled={saving}
+ placeholder="provider/model — e.g. groq/my-reranker"
+ data-testid="rerank-provider-model-input"
+ className="w-full mt-2 px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-violet-500"
+ />
+ >
)}
>
diff --git a/src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab.tsx b/src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab.tsx
index 555b46a9e2..0a2c319ec7 100644
--- a/src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab.tsx
+++ b/src/app/(dashboard)/dashboard/memory/components/tabs/EngineTab.tsx
@@ -16,6 +16,7 @@ export default function EngineTab() {
const { status, isLoading: statusLoading } = useEngineStatus();
const { settings, save: saveSettings, isLoading: settingsLoading } = useMemorySettings();
const [providers, setProviders] = useState([]);
+ const [rerankProviders, setRerankProviders] = useState([]);
const [saving, setSaving] = useState(false);
const [reindexing, setReindexing] = useState(false);
const [reindexMsg, setReindexMsg] = useState("");
@@ -32,6 +33,14 @@ export default function EngineTab() {
if (!cancelled && data?.providers) setProviders(data.providers);
})
.catch(() => {});
+ // Rerank has its own curated registry — the embedding listing does not
+ // include rerank-only providers (cohere rerank SKUs, siliconflow, ...).
+ fetch("/api/memory/rerank-providers")
+ .then((r) => (r.ok ? r.json() : null))
+ .then((data) => {
+ if (!cancelled && data?.providers) setRerankProviders(data.providers);
+ })
+ .catch(() => {});
return () => {
cancelled = true;
};
@@ -139,7 +148,7 @@ export default function EngineTab() {
diff --git a/src/app/api/memory/rerank-providers/route.ts b/src/app/api/memory/rerank-providers/route.ts
new file mode 100644
index 0000000000..7ab9102340
--- /dev/null
+++ b/src/app/api/memory/rerank-providers/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from "next/server";
+import { isAuthenticated } from "@/shared/utils/apiAuth";
+import { RERANK_PROVIDERS } from "@omniroute/open-sse/config/rerankRegistry.ts";
+import { getProviderCredentials } from "@/sse/services/auth";
+import {
+ buildRerankProviderListing,
+ mergeRerankProviderListings,
+} from "@/lib/memory/embedding/rerankListings";
+import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
+
+/**
+ * GET /api/memory/rerank-providers
+ *
+ * Lists rerank providers with hasKey state for the memory Rerank selector:
+ * curated RERANK_PROVIDERS entries first, then local provider_nodes.
+ */
+export async function GET(request: NextRequest) {
+ if (!(await isAuthenticated(request))) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ try {
+ const curated = [];
+ for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) {
+ let hasKey = false;
+ try {
+ const creds = await getProviderCredentials(providerId);
+ hasKey = !!(
+ creds &&
+ !("allRateLimited" in creds && (creds as { allRateLimited?: boolean }).allRateLimited) &&
+ ((creds as { apiKey?: string | null }).apiKey ||
+ (creds as { accessToken?: string | null }).accessToken)
+ );
+ } catch {
+ hasKey = false;
+ }
+ curated.push(buildRerankProviderListing(providerId, config, hasKey));
+ }
+
+ // Local rerank-capable provider_nodes appended after curated entries.
+ const extra = [];
+ try {
+ const { getCachedProviderNodes } = await import("@/lib/localDb");
+ const nodes = await getCachedProviderNodes();
+ for (const n of Array.isArray(nodes) ? nodes : []) {
+ const apiType = (n as { apiType?: string }).apiType || "";
+ if (!["chat", "responses", "rerank"].includes(apiType)) continue;
+ const prefix = (n as { prefix?: string }).prefix;
+ const baseUrl = (n as { baseUrl?: string }).baseUrl;
+ if (!prefix || !baseUrl) continue;
+ extra.push({ provider: prefix, hasKey: true, models: [] });
+ }
+ } catch {
+ // best-effort
+ }
+
+ return NextResponse.json({ providers: mergeRerankProviderListings(curated, extra) });
+ } catch (err: unknown) {
+ const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
+ return NextResponse.json({ error: { message } }, { status: 500 });
+ }
+}
diff --git a/src/app/api/settings/qdrant/embedding-models/catalog.ts b/src/app/api/settings/qdrant/embedding-models/catalog.ts
new file mode 100644
index 0000000000..dad5e4ddac
--- /dev/null
+++ b/src/app/api/settings/qdrant/embedding-models/catalog.ts
@@ -0,0 +1,49 @@
+import {
+ EMBEDDING_PROVIDERS,
+ type EmbeddingProvider,
+} from "@omniroute/open-sse/config/embeddingRegistry.ts";
+
+export type EmbeddingModelOption = {
+ value: string;
+ label: string;
+};
+
+/**
+ * Build quick-select options from the curated embedding registry — one option
+ * per registered model, provider-prefixed so the value matches what users type
+ * elsewhere (e.g. "mistral/mistral-embed"). Providers without curated
+ * models (dynamic-only, like lmstudio) contribute nothing.
+ */
+export function buildRegistryEmbeddingOptions(): EmbeddingModelOption[] {
+ const options: EmbeddingModelOption[] = [];
+ for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS) as Array<
+ [string, EmbeddingProvider]
+ >) {
+ for (const model of config.models) {
+ const value = `${providerId}/${model.id}`;
+ const dims = typeof model.dimensions === "number" ? `${model.dimensions}d` : "?";
+ options.push({ value, label: `${value} - ${model.name} (${dims})` });
+ }
+ }
+ return options;
+}
+
+/**
+ * Merge heuristic/live-discovered options with registry options: dedupe by
+ * `value` (first occurrence wins, so chat-catalog and OpenRouter-live labels
+ * keep priority), then sort by value for stable dropdown ordering.
+ */
+export function mergeEmbeddingOptions(
+ existing: EmbeddingModelOption[],
+ registry: EmbeddingModelOption[]
+): EmbeddingModelOption[] {
+ const seen = new Set(existing.map((o) => o.value));
+ const merged = [...existing];
+ for (const opt of registry) {
+ if (!seen.has(opt.value)) {
+ seen.add(opt.value);
+ merged.push(opt);
+ }
+ }
+ return merged.sort((a, b) => a.value.localeCompare(b.value));
+}
diff --git a/src/app/api/settings/qdrant/embedding-models/route.ts b/src/app/api/settings/qdrant/embedding-models/route.ts
index a0cd0b3441..82f1284c9d 100644
--- a/src/app/api/settings/qdrant/embedding-models/route.ts
+++ b/src/app/api/settings/qdrant/embedding-models/route.ts
@@ -3,6 +3,10 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getProviderConnections } from "@/lib/db/providers";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
+import {
+ buildRegistryEmbeddingOptions,
+ mergeEmbeddingOptions,
+} from "./catalog";
type EmbeddingModelOption = {
value: string;
@@ -82,8 +86,22 @@ export async function GET(request: NextRequest) {
// Best effort only: keep endpoint fast and resilient.
}
- options.sort((a, b) => a.value.localeCompare(b.value));
- return NextResponse.json({ models: options });
+ // Ensure the default always exists as a safe fallback.
+ if (!options.some((o) => o.value === "openai/text-embedding-3-small")) {
+ options.unshift({
+ value: "openai/text-embedding-3-small",
+ label: "openai/text-embedding-3-small - OpenAI Text Embedding 3 Small",
+ });
+ }
+
+ // Merge curated registry models (EMBEDDING_PROVIDERS — cohere, voyage,
+ // jina, ...) so the Quick select lists real
+ // embedding providers instead of only chat-catalog text matches and
+ // OpenRouter live discovery. Registry options dedupe against the above;
+ // mergeEmbeddingOptions returns value-sorted options for stable UI order.
+ const withRegistry = mergeEmbeddingOptions(options, buildRegistryEmbeddingOptions());
+
+ return NextResponse.json({ models: withRegistry });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
return NextResponse.json({ error: { message }, models: [] }, { status: 500 });
diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts
index 04fb1632dc..1104914def 100644
--- a/src/app/api/v1/rerank/route.ts
+++ b/src/app/api/v1/rerank/route.ts
@@ -19,6 +19,7 @@ import { saveCallLog } from "@/lib/usageDb";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import { CORS_HEADERS } from "@omniroute/open-sse/utils/cors.ts";
+import { deriveRerankProviderForChatProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
/**
* Handle CORS preflight
@@ -107,14 +108,36 @@ async function postHandler(request, context) {
// Try cloud registry first
const { provider, model: modelId } = parseRerankModel(body.model);
- if (provider) {
- // Cloud provider matched
- const credentials = await getProviderCredentialsWithQuotaPreflight(provider);
+ // Generic fallback: a configured OpenAI-compatible chat provider with no
+ // curated rerank entry (groq, mistral, ...) still exposes a Cohere-compatible
+ // /rerank endpoint. Only used when the prefix matches a chat provider
+ // that can actually derive an endpoint — otherwise fall through to local nodes.
+ let derivedProvider: ReturnType = null;
+ if (!provider) {
+ const prefix = body.model.split("/")[0];
+ if (prefix && prefix !== body.model) {
+ try {
+ const { REGISTRY } = await import("@omniroute/open-sse/config/providerRegistry.ts");
+ const chatEntry = (REGISTRY as Record)[prefix];
+ derivedProvider = deriveRerankProviderForChatProvider(prefix, chatEntry);
+ } catch {
+ derivedProvider = null;
+ }
+ }
+ }
+
+ if (provider || derivedProvider) {
+ // Cloud provider matched (or a generic Cohere-compatible endpoint was derived)
+ const effectiveProviderId = provider || derivedProvider!.id;
+ const credentials = await getProviderCredentialsWithQuotaPreflight(effectiveProviderId);
if (!credentials) {
- return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
+ return errorResponse(
+ HTTP_STATUS.BAD_REQUEST,
+ `No credentials for provider: ${effectiveProviderId}`
+ );
}
if (isAllRateLimitedCredentials(credentials)) {
- return rateLimitedProviderResponse(provider, credentials);
+ return rateLimitedProviderResponse(effectiveProviderId, credentials);
}
const response = await handleRerank({
@@ -124,6 +147,7 @@ async function postHandler(request, context) {
top_n: body.top_n,
return_documents: body.return_documents,
credentials,
+ resolvedProvider: derivedProvider || null,
connectionId: (credentials as { connectionId?: string } | null)?.connectionId || null,
apiKeyId: policy.apiKeyInfo?.id || null,
apiKeyName: policy.apiKeyInfo?.name || null,
diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts
index afa728a1a9..7d6ee77727 100644
--- a/src/lib/embeddings/service.ts
+++ b/src/lib/embeddings/service.ts
@@ -3,6 +3,7 @@ import {
parseEmbeddingModel,
getEmbeddingProvider,
buildDynamicEmbeddingProvider,
+ deriveEmbeddingProviderForChatProvider,
type EmbeddingProviderNodeRow,
type EmbeddingProvider,
} from "@omniroute/open-sse/config/embeddingRegistry.ts";
@@ -191,6 +192,8 @@ export async function createEmbeddingResponse(
null;
let credentialsProviderId = provider;
+ // #11088: synced-endpoint route — the connection advertising this endpoint
+ // supplies credentials and its configured base URL directly.
if (syncedEndpointRoute) {
credentials = await getProviderCredentials(
provider,
@@ -233,6 +236,26 @@ export async function createEmbeddingResponse(
};
}
+ // Generic fallback: a configured OpenAI-compatible chat provider with no
+ // curated embedding entry (groq, mistral, upstage, ...) still serves
+ // embeddings via the standard /embeddings endpoint. Curated registry
+ // entries are checked first and keep their specialized configuration.
+ if (!providerConfig && !options.resolvedProvider) {
+ try {
+ const { REGISTRY } = await import("@omniroute/open-sse/config/providerRegistry.ts");
+ const chatEntry = (REGISTRY as Record)[provider];
+ providerConfig = deriveEmbeddingProviderForChatProvider(provider, chatEntry);
+ if (providerConfig) {
+ log.info(
+ "EMBED",
+ `Derived generic embedding endpoint for configured provider ${provider}: ${providerConfig.baseUrl}`
+ );
+ }
+ } catch (err) {
+ log.error("EMBED", `Failed to derive generic embedding provider ${provider}: ${err}`);
+ }
+ }
+
if (!providerConfig) {
try {
const allNodes = (await getCachedProviderNodes()) as unknown as EmbeddingProviderNodeRow[];
diff --git a/src/lib/memory/embedding/index.ts b/src/lib/memory/embedding/index.ts
index 9adf55f69e..10fb895f7b 100644
--- a/src/lib/memory/embedding/index.ts
+++ b/src/lib/memory/embedding/index.ts
@@ -5,8 +5,12 @@ import {
type EmbeddingProviderNodeRow,
} from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getProviderCredentials } from "@/sse/services/auth";
-import { getCachedProviderNodes } from "@/lib/localDb";
+import { getCachedProviderNodes } from "@/lib/db/readCache";
import type { MemorySettingsExtended } from "@/shared/schemas/memory";
+import {
+ getEmbeddingProvider,
+ deriveEmbeddingProviderForChatProvider,
+} from "@omniroute/open-sse/config/embeddingRegistry.ts";
import type {
EmbeddingResolution,
EmbeddingResult,
@@ -334,5 +338,42 @@ export async function listEmbeddingProviders(): Promise;
+ // Cheap sync pass first: which providers CAN derive an endpoint at all.
+ const derivable: string[] = [];
+ for (const id of Object.keys(chatRegistry)) {
+ if (!getEmbeddingProvider(id) && deriveEmbeddingProviderForChatProvider(id, chatRegistry[id])) {
+ derivable.push(id);
+ }
+ }
+ // Credential lookups only for derivable candidates (a handful), not the
+ // whole registry.
+ for (const id of derivable) {
+ let hasKey = false;
+ try {
+ const creds = await getProviderCredentials(id);
+ hasKey = !!(
+ creds &&
+ !("allRateLimited" in creds && creds.allRateLimited) &&
+ (("apiKey" in creds ? creds.apiKey : undefined) ||
+ ("accessToken" in creds ? creds.accessToken : undefined))
+ );
+ } catch {
+ hasKey = false;
+ }
+ if (hasKey) {
+ result.push({ provider: id, hasKey: true, models: [] });
+ }
+ }
+ } catch {
+ // Listing enhancement is best-effort; never fail the endpoint.
+ }
+
return result;
}
diff --git a/src/lib/memory/embedding/providerListings.ts b/src/lib/memory/embedding/providerListings.ts
new file mode 100644
index 0000000000..3536806037
--- /dev/null
+++ b/src/lib/memory/embedding/providerListings.ts
@@ -0,0 +1,62 @@
+import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
+import {
+ deriveEmbeddingProviderForChatProvider,
+ getEmbeddingProvider,
+} from "@omniroute/open-sse/config/embeddingRegistry.ts";
+import type {
+ EmbeddingProviderListing,
+} from "./types";
+
+type ChatRegistryEntry = { id?: string; baseUrl?: string | string[] } | undefined;
+
+/**
+ * Derive EmbeddingProviderListings for configured providers that have NO
+ * curated EMBEDDING_PROVIDERS entry but expose a derivable OpenAI-compatible
+ * /embeddings endpoint (groq, mistral, upstage, vercel-ai-gateway, ...).
+ *
+ * @param configuredProviderIds provider ids/aliases with working credentials
+ * @param derive pure derivation fn (injectable for tests)
+ * @param hasKey predicate marking which ids are actually configured
+ */
+export function buildDerivedProviderListings(
+ configuredProviderIds: Iterable,
+ derive: (
+ providerId: string,
+ chatEntry: ChatRegistryEntry
+ ) => ReturnType,
+ hasKey: (providerId: string) => boolean
+): EmbeddingProviderListing[] {
+ const registry = REGISTRY as Record;
+ const result: EmbeddingProviderListing[] = [];
+ for (const id of configuredProviderIds) {
+ // Curated registry entries are authoritative — never duplicate them here.
+ if (getEmbeddingProvider(id)) continue;
+ const derived = derive(id, registry[id]);
+ if (!derived) continue;
+ result.push({
+ provider: id,
+ hasKey: hasKey(id),
+ models: [],
+ });
+ }
+ return result;
+}
+
+/**
+ * Merge curated listings (first) with derived/local listings (appended in
+ * order). Curated entries win on id collisions.
+ */
+export function mergeProviderListings(
+ curated: EmbeddingProviderListing[],
+ extra: EmbeddingProviderListing[]
+): EmbeddingProviderListing[] {
+ const seen = new Set(curated.map((p) => p.provider));
+ const merged = [...curated];
+ for (const p of extra) {
+ if (!seen.has(p.provider)) {
+ seen.add(p.provider);
+ merged.push(p);
+ }
+ }
+ return merged;
+}
diff --git a/src/lib/memory/embedding/rerankListings.ts b/src/lib/memory/embedding/rerankListings.ts
new file mode 100644
index 0000000000..e806c1128d
--- /dev/null
+++ b/src/lib/memory/embedding/rerankListings.ts
@@ -0,0 +1,42 @@
+import { RERANK_PROVIDERS } from "@omniroute/open-sse/config/rerankRegistry.ts";
+import type { EmbeddingProviderListing } from "./types";
+
+type RerankProviderConfig = (typeof RERANK_PROVIDERS)[keyof typeof RERANK_PROVIDERS];
+
+/**
+ * Build a rerank provider listing entry for one curated registry config.
+ */
+export function buildRerankProviderListing(
+ providerId: string,
+ config: RerankProviderConfig,
+ hasKey: boolean
+): EmbeddingProviderListing {
+ return {
+ provider: providerId,
+ hasKey,
+ models: config.models.map((m) => ({
+ id: `${providerId}/${m.id}`,
+ name: m.name,
+ dimensions: null,
+ })),
+ };
+}
+
+/**
+ * Merge curated rerank listings (first, authoritative on id collisions) with
+ * derived/local entries (appended in order).
+ */
+export function mergeRerankProviderListings(
+ curated: EmbeddingProviderListing[],
+ extra: EmbeddingProviderListing[]
+): EmbeddingProviderListing[] {
+ const seen = new Set(curated.map((p) => p.provider));
+ const merged = [...curated];
+ for (const p of extra) {
+ if (!seen.has(p.provider)) {
+ seen.add(p.provider);
+ merged.push(p);
+ }
+ }
+ return merged;
+}
diff --git a/tests/unit/embedding-generic-provider-fallback.test.ts b/tests/unit/embedding-generic-provider-fallback.test.ts
new file mode 100644
index 0000000000..860738913b
--- /dev/null
+++ b/tests/unit/embedding-generic-provider-fallback.test.ts
@@ -0,0 +1,77 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ getEmbeddingProvider,
+ parseEmbeddingModel,
+ deriveEmbeddingProviderForChatProvider,
+ type EmbeddingProvider,
+} from "@omniroute/open-sse/config/embeddingRegistry.ts";
+
+describe("deriveEmbeddingProviderForChatProvider (global OpenAI-compatible fallback)", () => {
+ it("derives an embeddings endpoint from a chat-completions base URL", () => {
+ const derived = deriveEmbeddingProviderForChatProvider("groq", {
+ id: "groq",
+ baseUrl: "https://api.groq.com/openai/v1/chat/completions",
+ });
+ assert.ok(derived, "groq should derive an embedding provider");
+ assert.equal(derived.baseUrl, "https://api.groq.com/openai/v1/embeddings");
+ assert.equal(derived.authType, "apikey");
+ assert.equal(derived.authHeader, "bearer");
+ assert.deepEqual(derived.models, []);
+ });
+
+ it("returns null for providers without a usable static base URL", () => {
+ assert.equal(deriveEmbeddingProviderForChatProvider("x", null), null);
+ assert.equal(
+ deriveEmbeddingProviderForChatProvider("dynamic-provider", {
+ id: "dynamic-provider",
+ baseUrl: "https://host.example/accounts",
+ }),
+ null,
+ "non chat/completions bases must not derive a bogus /embeddings endpoint"
+ );
+ });
+
+ it("is a fallback only: curated registry entries stay authoritative", () => {
+ const derived = deriveEmbeddingProviderForChatProvider("deepinfra", {
+ id: "deepinfra",
+ baseUrl: "https://api.deepinfra.com/v1/openai/chat/completions",
+ });
+ // deepinfra IS in EMBEDDING_PROVIDERS — the helper still derives mechanically;
+ // callers must check getEmbeddingProvider() first.
+ assert.ok(derived);
+ assert.ok(getEmbeddingProvider("deepinfra"), "curated entry remains authoritative");
+ });
+
+ it("covers known embedding-capable chat providers with derivable endpoints", () => {
+ for (const id of ["mistral", "together", "upstage", "fireworks", "nvidia"]) {
+ const derived = deriveEmbeddingProviderForChatProvider(id, {
+ id,
+ baseUrl: `https://${id}.example.com/v1/chat/completions`,
+ });
+ assert.ok(derived, `${id} should derive`);
+ assert.equal(derived?.baseUrl, `https://${id}.example.com/v1/embeddings`);
+ }
+ });
+});
+
+describe("parseEmbeddingModel precedence (provider_node vs registry)", () => {
+ it("a configured provider_node prefix wins over alias resolution", () => {
+ const dynamic: EmbeddingProvider[] = [
+ {
+ id: "jina-ai",
+ baseUrl: "http://127.0.0.1:9/embeddings",
+ authType: "none",
+ authHeader: "none",
+ models: [],
+ },
+ ];
+ const parsed = parseEmbeddingModel("jina-ai/my-local-model", dynamic);
+ assert.deepEqual(parsed, { provider: "jina-ai", model: "my-local-model" });
+ });
+
+ it("unknown prefixes fall through to the generic provider segment", () => {
+ const parsed = parseEmbeddingModel("totally-unknown/model-id");
+ assert.deepEqual(parsed, { provider: "totally-unknown", model: "model-id" });
+ });
+});
diff --git a/tests/unit/memory-provider-listings.test.ts b/tests/unit/memory-provider-listings.test.ts
new file mode 100644
index 0000000000..113519881f
--- /dev/null
+++ b/tests/unit/memory-provider-listings.test.ts
@@ -0,0 +1,98 @@
+/**
+ * Issue: the Embedding Source "remote provider" dropdown and the Rerank
+ * (optional) selector both render from listEmbeddingProviders(), which
+ * aggregates ONLY the hand-curated EMBEDDING_PROVIDERS + local provider_nodes.
+ * Providers configured in OmniRoute but absent from that curated registry (groq,
+ * vercel-ai-gateway, ...) never appear — and before the runtime
+ * fallback existed, selecting them manually would fail with
+ * "Unknown embedding provider".
+ *
+ * These tests pin the pure derivation helper used by listEmbeddingProviders():
+ * every chat provider with a derivable /embeddings endpoint contributes a listing,
+ * curated entries win, and rerank listings come from the rerank registry.
+ */
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
+import {
+ deriveEmbeddingProviderForChatProvider,
+ getEmbeddingProvider,
+} from "@omniroute/open-sse/config/embeddingRegistry.ts";
+import {
+ buildDerivedProviderListings,
+ mergeProviderListings,
+} from "../../src/lib/memory/embedding/providerListings";
+
+describe("memory embedding listings: derived providers", () => {
+ it("derives a listing for a configured OpenAI-compatible chat provider", () => {
+ const listings = buildDerivedProviderListings(
+ new Set(["groq"]),
+ (id) => {
+ const entry = REGISTRY[id] as { baseUrl?: string } | undefined;
+ return entry ? deriveEmbeddingProviderForChatProvider(id, entry) : null;
+ },
+ (id) => id === "groq"
+ );
+ const groq = listings.find((p) => p.provider === "groq");
+ assert.ok(groq, "groq should be listed once configured");
+ assert.equal(groq?.hasKey, true);
+ // Derived providers expose no curated model catalog; they exist so the
+ // runtime accepts `groq/` and the UI can offer free-text input.
+ assert.deepEqual(groq?.models, []);
+ });
+
+ it("never shadows curated registry entries", () => {
+ const listings = buildDerivedProviderListings(
+ new Set(["deepinfra", "mistral"]),
+ (id) => {
+ const entry = REGISTRY[id] as { baseUrl?: string } | undefined;
+ return entry ? deriveEmbeddingProviderForChatProvider(id, entry) : null;
+ },
+ () => true
+ );
+ for (const listing of listings) {
+ assert.ok(
+ !getEmbeddingProvider(listing.provider),
+ "derived listings must not duplicate curated providers"
+ );
+ }
+ });
+
+ it("skips dynamic-URL providers without a static base", () => {
+ const listings = buildDerivedProviderListings(
+ new Set(["account-scoped"]),
+ () => null,
+ () => true
+ );
+ // Providers with no derivable static /embeddings endpoint contribute
+ // nothing — no bogus derived entry may be produced here.
+ assert.equal(listings.filter((p) => p.provider === "account-scoped").length, 0);
+ });
+});
+
+describe("memory embedding listings: merge", () => {
+ it("curated entries win over derived ones with the same id", () => {
+ const merged = mergeProviderListings(
+ [{ provider: "groq", hasKey: false, models: [{ id: "groq/curated", name: "C" }] }],
+ [{ provider: "groq", hasKey: true, models: [] }]
+ );
+ assert.equal(merged.length, 1);
+ assert.equal(merged[0].hasKey, false, "curated (first) entry is authoritative");
+ assert.equal(merged[0].models.length, 1);
+ });
+
+ it("keeps curated order first, appends unseen derived/local providers", () => {
+ const merged = mergeProviderListings(
+ [{ provider: "openai", hasKey: true, models: [] }],
+ [
+ { provider: "zzz-local", hasKey: true, models: [] },
+ { provider: "openai", hasKey: false, models: [] },
+ { provider: "aaa-local", hasKey: true, models: [] },
+ ]
+ );
+ assert.deepEqual(
+ merged.map((p) => p.provider),
+ ["openai", "zzz-local", "aaa-local"]
+ );
+ });
+});
diff --git a/tests/unit/qdrant-quick-select-catalog.test.ts b/tests/unit/qdrant-quick-select-catalog.test.ts
new file mode 100644
index 0000000000..862391c8db
--- /dev/null
+++ b/tests/unit/qdrant-quick-select-catalog.test.ts
@@ -0,0 +1,83 @@
+/**
+ * Issue: dashboard/memory?tab=engine "Quick select" only listed models found by a
+ * text heuristic over the CHAT catalog (AI_MODELS) plus live OpenRouter discovery.
+ * Curated embedding-registry providers (EMBEDDING_PROVIDERS) never appeared — e.g.
+ * configured providers with embedding models (mistral, gemini, nvidia nim,
+ * groq, ...) was missing even though the provider
+ * serves embeddings via a standard OpenAI-compatible /embeddings endpoint.
+ *
+ * These tests pin the pure catalog helper the route now uses: registry models must
+ * be merged into the option list with provider-prefixed values, deduped against
+ * heuristic/live options.
+ */
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { EMBEDDING_PROVIDERS } from "@omniroute/open-sse/config/embeddingRegistry.ts";
+import {
+ buildRegistryEmbeddingOptions,
+ mergeEmbeddingOptions,
+} from "../../src/app/api/settings/qdrant/embedding-models/catalog";
+
+describe("qdrant quick-select: registry catalog helpers", () => {
+ it("emits one option per registered embedding model, provider-prefixed", () => {
+ const options = buildRegistryEmbeddingOptions();
+ assert.ok(options.length > 0, "registry should contribute options");
+
+ const byValue = new Map(options.map((o) => [o.value, o.label]));
+ for (const [providerId, cfg] of Object.entries(EMBEDDING_PROVIDERS)) {
+ for (const m of cfg.models) {
+ const value = `${providerId}/${m.id}`;
+ assert.ok(byValue.has(value), `missing quick-select option for ${value}`);
+ }
+ }
+ });
+
+ it("skips providers without static curated models (dynamic-only)", () => {
+ const options = buildRegistryEmbeddingOptions();
+ assert.equal(
+ options.filter((o) => o.value.startsWith("lmstudio/")).length,
+ 0,
+ "lmstudio has no curated models; nothing to list"
+ );
+ });
+
+ it("labels include dimensions when known", () => {
+ const options = buildRegistryEmbeddingOptions();
+ const hit = options.find((o) => o.value === "deepinfra/BAAI/bge-m3");
+ assert.ok(hit, "deepinfra BAAI/bge-m3 expected in registry");
+ assert.match(hit.label, /\b1024d\b/);
+ });
+});
+
+describe("qdrant quick-select: merge with existing options", () => {
+ it("dedupes by value and keeps first-seen label (heuristic wins ties)", () => {
+ const merged = mergeEmbeddingOptions(
+ [{ value: "openai/text-embedding-3-small", label: "heuristic-label" }],
+ [{ value: "openai/text-embedding-3-small", label: "registry-label" }]
+ );
+ assert.equal(merged.length, 1);
+ assert.equal(merged[0].label, "heuristic-label");
+ });
+
+ it("appends unseen registry options", () => {
+ const merged = mergeEmbeddingOptions(
+ [{ value: "a/x", label: "A" }],
+ [{ value: "b/y", label: "B" }, { value: "b/z", label: "C" }]
+ );
+ assert.deepEqual(
+ merged.map((o) => o.value),
+ ["a/x", "b/y", "b/z"]
+ );
+ });
+
+ it("output is sorted by value for stable UI ordering", () => {
+ const merged = mergeEmbeddingOptions(
+ [{ value: "z/1", label: "Z" }],
+ [{ value: "a/1", label: "A" }, { value: "m/1", label: "M" }]
+ );
+ assert.deepEqual(
+ merged.map((o) => o.value),
+ ["a/1", "m/1", "z/1"]
+ );
+ });
+});
diff --git a/tests/unit/rerank-generic-provider-fallback.test.ts b/tests/unit/rerank-generic-provider-fallback.test.ts
new file mode 100644
index 0000000000..4ff1310398
--- /dev/null
+++ b/tests/unit/rerank-generic-provider-fallback.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Issue: rerank model strings outside the curated RERANK_PROVIDERS registry were
+ * rejected with "No rerank provider found" even when the provider was configured
+ * in OmniRoute with a working Cohere-compatible /rerank endpoint (e.g. groq,
+ * siliconflow-style hosts). The memory Rerank selector fed by the curated list
+ * had the same blind spot.
+ *
+ * These tests pin: parseRerankModel keeps returning null provider for unknown
+ * prefixes (registry semantics unchanged), and the new generic fallback builder
+ * derives a Cohere-compatible config for any configured OpenAI-compatible chat
+ * provider without shadowing curated entries.
+ */
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
+import {
+ getRerankProvider,
+ parseRerankModel,
+} from "../../open-sse/config/rerankRegistry.ts";
+import { deriveRerankProviderForChatProvider } from "../../open-sse/config/rerankRegistry.ts";
+
+describe("rerank registry: unknown providers stay rejected at parse level", () => {
+ it("parseRerankModel returns provider null for an unregistered prefix", () => {
+ const parsed = parseRerankModel("groq/some-reranker");
+ assert.equal(parsed.provider, null);
+ // Registry semantics: when no curated provider matches, model keeps its
+ // full original string (provider prefix included).
+ assert.equal(parsed.model, "groq/some-reranker");
+ assert.equal(getRerankProvider("groq"), null);
+ });
+});
+
+describe("deriveRerankProviderForChatProvider (generic Cohere-compatible fallback)", () => {
+ it("derives a /rerank endpoint from a chat-completions base URL", () => {
+ const derived = deriveRerankProviderForChatProvider("groq", {
+ id: "groq",
+ baseUrl: "https://api.groq.com/openai/v1/chat/completions",
+ });
+ assert.ok(derived, "groq should derive a rerank provider");
+ assert.equal(derived.baseUrl, "https://api.groq.com/openai/v1/rerank");
+ assert.deepEqual(derived.models, []);
+ });
+
+ it("returns null for dynamic-URL providers and missing entries", () => {
+ assert.equal(
+ deriveRerankProviderForChatProvider("account-scoped", {
+ id: "account-scoped",
+ baseUrl: "https://api.example.com/client/v4/accounts",
+ }),
+ null
+ );
+ assert.equal(deriveRerankProviderForChatProvider("ghost", undefined), null);
+ });
+
+ it("does not shadow curated rerank registries", () => {
+ for (const id of ["cohere", "together", "siliconflow", "voyage-ai", "jina-ai"]) {
+ const entry = REGISTRY[id] as { baseUrl?: string } | undefined;
+ if (!entry) continue;
+ const derived = deriveRerankProviderForChatProvider(id, entry);
+ if (derived) {
+ assert.ok(getRerankProvider(id), `${id} remains curated; derivation is fallback-only`);
+ }
+ }
+ // cohere IS curated — helper still derives mechanically, but callers must
+ // check the curated registry first. Pin that ordering here:
+ const curated = getRerankProvider("cohere");
+ assert.ok(curated?.models.length, "curated cohere entry has models");
+ });
+});
diff --git a/tests/unit/rerank-provider-listings.test.ts b/tests/unit/rerank-provider-listings.test.ts
new file mode 100644
index 0000000000..cbb33a4aa5
--- /dev/null
+++ b/tests/unit/rerank-provider-listings.test.ts
@@ -0,0 +1,35 @@
+/**
+ * Issue: the rerank listing endpoint (added alongside /api/memory/embedding-providers)
+ * must expose curated rerank providers with hasKey state so the memory Rerank
+ * selector can grey out unconfigured providers, mirroring the embedding listing.
+ */
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ RERANK_PROVIDERS,
+} from "../../open-sse/config/rerankRegistry.ts";
+import {
+ buildRerankProviderListing,
+ mergeRerankProviderListings,
+} from "../../src/lib/memory/embedding/rerankListings";
+
+describe("rerank provider listings", () => {
+ it("builds a curated listing per registry provider", () => {
+ const cohere = buildRerankProviderListing("cohere", RERANK_PROVIDERS.cohere, true);
+ assert.equal(cohere.provider, "cohere");
+ assert.equal(cohere.hasKey, true);
+ assert.ok(cohere.models.some((m) => m.id === "cohere/rerank-v3.5"));
+ });
+
+ it("merge keeps curated first and dedupes by provider id", () => {
+ const merged = mergeRerankProviderListings(
+ [buildRerankProviderListing("cohere", RERANK_PROVIDERS.cohere, false)],
+ [{ provider: "cohere", hasKey: true, models: [] }, { provider: "local-x", hasKey: true, models: [] }]
+ );
+ assert.deepEqual(
+ merged.map((p) => p.provider),
+ ["cohere", "local-x"]
+ );
+ assert.equal(merged[0].hasKey, false, "curated entry wins");
+ });
+});