mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
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
This commit is contained in:
@@ -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
|
||||
@@ -408,6 +408,7 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,22 +89,46 @@ export default function EmbeddingSourceSelector({ settings, providers, onSave, s
|
||||
{t("embedding.noRemoteProviders")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={currentProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="embedding-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("embedding.selectProviderModel")}</option>
|
||||
{remoteProviders.map((p) =>
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
value={
|
||||
remoteProviders.some((p) => p.models.some((m) => m.id === currentProviderModel))
|
||||
? currentProviderModel
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="embedding-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("embedding.selectProviderModel")}</option>
|
||||
{remoteProviders.map((p) =>
|
||||
p.models.length > 0 ? (
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<optgroup key={p.provider} label={p.provider}>
|
||||
<option value="">{`— ${p.provider} (no curated models)`}</option>
|
||||
</optgroup>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
{/* 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/...). */}
|
||||
<input
|
||||
type="text"
|
||||
value={currentProviderModel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<CustomEmbeddingEndpointFields settings={settings} onSave={onSave} saving={saving} />
|
||||
</div>
|
||||
|
||||
@@ -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")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={rerankProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="rerank-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("rerank.selectProviderModel")}</option>
|
||||
{rerankProviders.map((p) =>
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
)),
|
||||
)}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
value={
|
||||
rerankProviders.some((p) => p.models.some((m) => m.id === rerankProviderModel))
|
||||
? rerankProviderModel
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="rerank-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("rerank.selectProviderModel")}</option>
|
||||
{rerankProviders.map((p) =>
|
||||
p.models.length > 0 ? (
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<optgroup key={p.provider} label={p.provider}>
|
||||
<option value="">{`— ${p.provider} (no curated models)`}</option>
|
||||
</optgroup>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
{/* Free-text override: any configured provider's Cohere-compatible
|
||||
model id is accepted by the runtime even without a curated entry. */}
|
||||
<input
|
||||
type="text"
|
||||
value={rerankProviderModel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function EngineTab() {
|
||||
const { status, isLoading: statusLoading } = useEngineStatus();
|
||||
const { settings, save: saveSettings, isLoading: settingsLoading } = useMemorySettings();
|
||||
const [providers, setProviders] = useState<EmbeddingProviderListing[]>([]);
|
||||
const [rerankProviders, setRerankProviders] = useState<EmbeddingProviderListing[]>([]);
|
||||
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() {
|
||||
</h3>
|
||||
<RerankConfigCard
|
||||
settings={settings}
|
||||
providers={providers}
|
||||
providers={rerankProviders}
|
||||
onSave={handleSaveSettings}
|
||||
saving={saving}
|
||||
/>
|
||||
|
||||
62
src/app/api/memory/rerank-providers/route.ts
Normal file
62
src/app/api/memory/rerank-providers/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
49
src/app/api/settings/qdrant/embedding-models/catalog.ts
Normal file
49
src/app/api/settings/qdrant/embedding-models/catalog.ts
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
// <base>/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<typeof deriveRerankProviderForChatProvider> = 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<string, { baseUrl?: string } | undefined>)[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,
|
||||
|
||||
@@ -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 <base>/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<string, { baseUrl?: string } | undefined>)[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[];
|
||||
|
||||
@@ -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<EmbeddingProviderListing
|
||||
});
|
||||
}
|
||||
|
||||
// Generic fallback: configured OpenAI-compatible chat providers without a
|
||||
// curated embedding entry (groq, mistral, vercel-ai-gateway, ...) expose a
|
||||
// derivable /embeddings endpoint. They appear with an empty model catalog —
|
||||
// the UI offers free-text input for the model id. Curated + local nodes win.
|
||||
try {
|
||||
const { REGISTRY } = await import("@omniroute/open-sse/config/providerRegistry.ts");
|
||||
const chatRegistry = REGISTRY as Record<string, { baseUrl?: string } | undefined>;
|
||||
// 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;
|
||||
}
|
||||
|
||||
62
src/lib/memory/embedding/providerListings.ts
Normal file
62
src/lib/memory/embedding/providerListings.ts
Normal file
@@ -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<string>,
|
||||
derive: (
|
||||
providerId: string,
|
||||
chatEntry: ChatRegistryEntry
|
||||
) => ReturnType<typeof deriveEmbeddingProviderForChatProvider>,
|
||||
hasKey: (providerId: string) => boolean
|
||||
): EmbeddingProviderListing[] {
|
||||
const registry = REGISTRY as Record<string, ChatRegistryEntry>;
|
||||
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;
|
||||
}
|
||||
42
src/lib/memory/embedding/rerankListings.ts
Normal file
42
src/lib/memory/embedding/rerankListings.ts
Normal file
@@ -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;
|
||||
}
|
||||
77
tests/unit/embedding-generic-provider-fallback.test.ts
Normal file
77
tests/unit/embedding-generic-provider-fallback.test.ts
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
98
tests/unit/memory-provider-listings.test.ts
Normal file
98
tests/unit/memory-provider-listings.test.ts
Normal file
@@ -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/<model>` 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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
83
tests/unit/qdrant-quick-select-catalog.test.ts
Normal file
83
tests/unit/qdrant-quick-select-catalog.test.ts
Normal file
@@ -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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
69
tests/unit/rerank-generic-provider-fallback.test.ts
Normal file
69
tests/unit/rerank-generic-provider-fallback.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
35
tests/unit/rerank-provider-listings.test.ts
Normal file
35
tests/unit/rerank-provider-listings.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user