From 1255b5869b41719448b6c106d9d82f85a3d5d4e8 Mon Sep 17 00:00:00 2001 From: Rouzbeh Date: Mon, 24 Aug 2026 12:33:31 +0000 Subject: [PATCH] fix(memory): list and serve embedding/rerank models from all configured providers The memory Engine tab quick-select built its model list only from a keyword heuristic over the chat catalog plus OpenRouter live discovery, so configured providers whose embedding models are not in that catalog (e.g. Cloudflare Workers AI cf/@cf/baai/bge-m3) never appeared, and requests for them failed with 'Unknown embedding provider'. - add cloudflare-ai to EMBEDDING_PROVIDERS with a requiresAccountId URL template resolved per-request via buildEmbeddingProviderUrl() (mirrors CloudflareAIExecutor on the chat side) plus the cf provider alias - merge curated registry models into the quick-select catalog and add a generic fallback listing: any configured OpenAI-compatible chat provider without a curated entry appears with free-text model input - same treatment for rerank: listRerankProviders + generic Cohere- compatible fallback, new /api/memory/rerank-providers endpoint, selectors fall back to free-text when no static models exist - runtime: embeddings service/handler and rerank route/handler resolve derived providers instead of rejecting unlisted ones --- ...memory-embedding-quick-select-providers.md | 1 + open-sse/config/embeddingRegistry.ts | 45 +++++++++ open-sse/config/rerankRegistry.ts | 26 +++++ open-sse/handlers/rerank.ts | 21 ++-- .../components/EmbeddingSourceSelector.tsx | 56 ++++++++--- .../memory/components/RerankConfigCard.tsx | 61 ++++++++---- .../memory/components/tabs/EngineTab.tsx | 11 ++- src/app/api/memory/rerank-providers/route.ts | 62 ++++++++++++ .../qdrant/embedding-models/catalog.ts | 49 ++++++++++ .../settings/qdrant/embedding-models/route.ts | 22 ++++- src/app/api/v1/rerank/route.ts | 34 ++++++- src/lib/embeddings/service.ts | 23 +++++ src/lib/memory/embedding/index.ts | 43 +++++++- src/lib/memory/embedding/providerListings.ts | 62 ++++++++++++ src/lib/memory/embedding/rerankListings.ts | 42 ++++++++ ...mbedding-generic-provider-fallback.test.ts | 77 +++++++++++++++ tests/unit/memory-provider-listings.test.ts | 98 +++++++++++++++++++ .../unit/qdrant-quick-select-catalog.test.ts | 83 ++++++++++++++++ .../rerank-generic-provider-fallback.test.ts | 69 +++++++++++++ tests/unit/rerank-provider-listings.test.ts | 35 +++++++ 20 files changed, 866 insertions(+), 54 deletions(-) create mode 100644 changelog.d/fixes/memory-embedding-quick-select-providers.md create mode 100644 src/app/api/memory/rerank-providers/route.ts create mode 100644 src/app/api/settings/qdrant/embedding-models/catalog.ts create mode 100644 src/lib/memory/embedding/providerListings.ts create mode 100644 src/lib/memory/embedding/rerankListings.ts create mode 100644 tests/unit/embedding-generic-provider-fallback.test.ts create mode 100644 tests/unit/memory-provider-listings.test.ts create mode 100644 tests/unit/qdrant-quick-select-catalog.test.ts create mode 100644 tests/unit/rerank-generic-provider-fallback.test.ts create mode 100644 tests/unit/rerank-provider-listings.test.ts 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"); + }); +});