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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- feat(memory): support custom OpenAI-compatible endpoints for Memory embeddings (#9622)
12 changes: 11 additions & 1 deletion docs/frameworks/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,15 @@ The `memory_vec_meta` table (migration `073_memory_vec.sql`) stores:

## Settings extension

Seven new fields were added to `MemorySettingsExtended` (plan 21, D9) in
Nine embedding and vector fields are available in `MemorySettingsExtended` in
`src/shared/schemas/memory.ts`, persisted via `src/lib/db/settings.ts`:

| Field | Type | Default | Description |
| ------------------------ | -------------------------------------------------- | -------- | ------------------------------------------------ |
| `embeddingSource` | `"remote" \| "static" \| "transformers" \| "auto"` | `"auto"` | Which embedding source to use |
| `embeddingProviderModel` | `string \| null` | `null` | Provider/model in `provider/model` format |
| `customBaseUrl` | `string \| null` | `null` | Memory-only OpenAI-compatible endpoint base URL |
| `customModelId` | `string \| null` | `null` | Model ID sent to the custom endpoint |
| `transformersEnabled` | `boolean` | `false` | Opt-in for Transformers.js (MiniLM, ~400MB) |
| `staticEnabled` | `boolean` | `false` | Opt-in for static potion-base-8M local model |
| `rerankEnabled` | `boolean` | `false` | Enable reranking step (adds +200-500ms/req) |
Expand All @@ -176,6 +178,14 @@ Seven new fields were added to `MemorySettingsExtended` (plan 21, D9) in

These are exposed via `GET /PUT /api/settings/memory` (schema `MemorySettingsExtendedSchema`).

For the `remote` source, Memory also accepts the optional `customBaseUrl` and
`customModelId` settings. Together they select an OpenAI-compatible `/embeddings`
endpoint and model without changing the global embedding registry. The endpoint is
normalized before use and checked by the provider outbound URL policy: HTTP(S) is
required, embedded credentials and query strings are rejected, and cloud-metadata
addresses remain blocked. Empty values preserve the selected registry provider. Errors
returned to the dashboard are sanitized and endpoint credentials are never logged.

> **TODO (D20):** Scope `global` (sharing memories across all API keys) is not
> implemented in this release. It requires schema changes and a global retrieval
> path. Track separately.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"use client";

import { useState } from "react";
import { useTranslations } from "next-intl";
import type { MemorySettingsExtended } from "@/shared/schemas/memory";

type Props = {
settings: MemorySettingsExtended;
onSave: (updates: Partial<MemorySettingsExtended>) => Promise<boolean>;
saving?: boolean;
};

function normalizeBaseUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.username ||
url.password ||
url.search ||
url.hash
) {
return null;
}
return trimmed.replace(/\/+$/, "");
} catch {
return null;
}
}

export default function CustomEmbeddingEndpointFields({ settings, onSave, saving }: Props) {
const t = useTranslations("memory");
const [baseUrl, setBaseUrl] = useState(settings.customBaseUrl ?? "");
const [modelId, setModelId] = useState(settings.customModelId ?? "");
const [error, setError] = useState<string | null>(null);

const save = async () => {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const normalizedModelId = modelId.trim() || null;
if ((baseUrl.trim() || normalizedModelId) && (!normalizedBaseUrl || !normalizedModelId)) {
setError(t("embedding.customEndpointInvalid"));
return;
}
setError(null);
const saved = await onSave({
customBaseUrl: normalizedBaseUrl,
customModelId: normalizedModelId,
});
if (!saved) {
setError(t("embedding.customEndpointSaveFailed"));
return;
}
setBaseUrl(normalizedBaseUrl ?? "");
setModelId(normalizedModelId ?? "");
};

return (
<div className="mt-4 pt-4 border-t border-border/60 space-y-3">
<div>
<label className="block text-sm font-medium text-text-main mb-1">
{t("embedding.customBaseUrlLabel")}
</label>
<input
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="http://localhost:8000/v1"
disabled={saving}
data-testid="embedding-custom-base-url"
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1">
{t("embedding.customModelIdLabel")}
</label>
<input
value={modelId}
onChange={(event) => setModelId(event.target.value)}
placeholder="my-embedding-model"
disabled={saving}
data-testid="embedding-custom-model-id"
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm"
/>
</div>
<p className="text-xs text-text-muted">{t("embedding.customEndpointHelp")}</p>
{error && (
<p role="alert" className="text-xs text-red-400">
{error}
</p>
)}
<button
type="button"
onClick={() => void save()}
disabled={saving}
data-testid="embedding-custom-save"
className="px-3 py-2 rounded-lg bg-violet-500 text-white text-sm disabled:opacity-50"
>
{t("embedding.customEndpointSave")}
</button>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useTranslations } from "next-intl";
import type { MemorySettingsExtended } from "@/shared/schemas/memory";
import type { EmbeddingProviderListing } from "@/lib/memory/embedding/types";
import CustomEmbeddingEndpointFields from "./CustomEmbeddingEndpointFields";

interface Props {
settings: MemorySettingsExtended;
Expand Down Expand Up @@ -101,10 +102,11 @@ export default function EmbeddingSourceSelector({ settings, providers, onSave, s
<option key={m.id} value={m.id}>
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
</option>
)),
))
)}
</select>
)}
<CustomEmbeddingEndpointFields settings={settings} onSave={onSave} saving={saving} />
</div>
)}

Expand Down
6 changes: 6 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4142,6 +4142,12 @@
"providerModelLabel": "Provider / Model",
"noRemoteProviders": "No providers with configured API key",
"selectProviderModel": "Select a model",
"customBaseUrlLabel": "Custom base URL (optional)",
"customModelIdLabel": "Custom model ID (optional)",
"customEndpointHelp": "Use an OpenAI-compatible /embeddings endpoint. Local endpoints are allowed; cloud metadata targets are blocked.",
"customEndpointSave": "Apply custom endpoint",
"customEndpointInvalid": "Enter both a valid HTTP(S) base URL and a model ID, or leave both fields empty.",
"customEndpointSaveFailed": "The custom embedding endpoint could not be saved.",
"staticEnabledLabel": "Enable Static Potion",
"staticEnabledDesc": "Download and use potion-base-8M model locally",
"transformersEnabledLabel": "Enable Transformers.js",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -4142,6 +4142,12 @@
"providerModelLabel": "Provider / Modelo",
"noRemoteProviders": "Nenhum provider com chave configurada",
"selectProviderModel": "Selecione um modelo",
"customBaseUrlLabel": "URL base personalizada (opcional)",
"customModelIdLabel": "ID do modelo personalizado (opcional)",
"customEndpointHelp": "Use um endpoint /embeddings compatível com OpenAI. Endpoints locais são permitidos; alvos de metadados de nuvem são bloqueados.",
"customEndpointSave": "Aplicar endpoint personalizado",
"customEndpointInvalid": "Informe uma URL base HTTP(S) válida e um ID de modelo, ou deixe os dois campos vazios.",
"customEndpointSaveFailed": "Não foi possível salvar o endpoint de embedding personalizado.",
"staticEnabledLabel": "Habilitar Static Potion",
"staticEnabledDesc": "Baixa e usa o modelo potion-base-8M localmente",
"transformersEnabledLabel": "Habilitar Transformers.js",
Expand Down
15 changes: 13 additions & 2 deletions src/lib/embeddings/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface EmbeddingHandlerOptions {
apiKeyId?: string | null;
apiKeyName?: string | null;
connectionId?: string | null;
resolvedProvider?: EmbeddingProvider | null;
resolvedModel?: string | null;
}

export async function createEmbeddingResponse(
Expand Down Expand Up @@ -151,7 +153,13 @@ export async function createEmbeddingResponse(
log.error("EMBED", `Failed to load provider_nodes for embeddings: ${err}`);
}

const { provider, model: resolvedModel } = parseEmbeddingModel(body.model, dynamicProviders);
const parsedModel = options.resolvedProvider
? {
provider: options.resolvedProvider.id,
model: options.resolvedModel ?? body.model,
}
: parseEmbeddingModel(body.model, dynamicProviders);
const { provider, model: resolvedModel } = parsedModel;
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
Expand All @@ -160,7 +168,10 @@ export async function createEmbeddingResponse(
}

let providerConfig: EmbeddingProvider | null =
dynamicProviders.find((dp) => dp.id === provider) || getEmbeddingProvider(provider) || null;
options.resolvedProvider ||
dynamicProviders.find((dp) => dp.id === provider) ||
getEmbeddingProvider(provider) ||
null;
let credentialsProviderId = provider;

if (!providerConfig) {
Expand Down
62 changes: 62 additions & 0 deletions src/lib/memory/embedding/customProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { EmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import {
parseAndValidateNonMetadataUrl,
parseAndValidatePublicUrl,
} from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";

type CustomEmbeddingSettings = {
customBaseUrl?: string | null;
customModelId?: string | null;
};

export type ResolvedMemoryCustomEmbeddingProvider = {
provider: EmbeddingProvider;
model: string;
identity: string;
};

export class MemoryCustomEmbeddingConfigError extends Error {
constructor() {
super("Custom embedding endpoint is invalid or blocked");
this.name = "MemoryCustomEmbeddingConfigError";
}
}

function validateEndpoint(rawBaseUrl: string): URL {
const guard = getProviderValidationGuard();
if (guard === "public-only") return parseAndValidatePublicUrl(rawBaseUrl);
return parseAndValidateNonMetadataUrl(rawBaseUrl);
}

function toEmbeddingsUrl(url: URL): string {
if (url.search || url.hash) throw new MemoryCustomEmbeddingConfigError();
const normalized = url.toString().replace(/\/+$/, "");
return normalized.endsWith("/embeddings") ? normalized : `${normalized}/embeddings`;
}

export function resolveMemoryCustomEmbeddingProvider(
settings: CustomEmbeddingSettings
): ResolvedMemoryCustomEmbeddingProvider | null {
const rawBaseUrl = settings.customBaseUrl?.trim() ?? "";
const model = settings.customModelId?.trim() ?? "";
if (!rawBaseUrl && !model) return null;
if (!rawBaseUrl || !model) throw new MemoryCustomEmbeddingConfigError();

try {
const baseUrl = toEmbeddingsUrl(validateEndpoint(rawBaseUrl));
return {
provider: {
id: "memory-custom",
baseUrl,
authType: "none",
authHeader: "none",
models: [],
},
model,
identity: `${baseUrl}|${model}`,
};
} catch {
throw new MemoryCustomEmbeddingConfigError();
}
}
45 changes: 43 additions & 2 deletions src/lib/memory/embedding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { embedRemote } from "./remote";
import { embedStatic } from "./staticPotion";
import { embedTransformers } from "./transformersLocal";
import { buildCacheKey, get as cacheGet, set as cacheSet } from "./cache";
import { resolveMemoryCustomEmbeddingProvider } from "./customProvider";

const STATIC_MODEL = process.env.MEMORY_STATIC_MODEL || "minishlab/potion-base-8M";
const TRANSFORMERS_MODEL = process.env.MEMORY_TRANSFORMERS_MODEL || "Xenova/all-MiniLM-L6-v2";
Expand Down Expand Up @@ -66,13 +67,34 @@ function remoteResolution(model: string, reasonPrefix: string): EmbeddingResolut
};
}

function customRemoteResolution(settings: MemorySettingsExtended): EmbeddingResolution | null {
const customBaseUrl = settings.customBaseUrl?.trim() ?? "";
const customModelId = settings.customModelId?.trim() ?? "";
if (!customBaseUrl && !customModelId) return null;
if (!customBaseUrl || !customModelId) return noSource("custom embedding endpoint is incomplete");
const identity = `${customBaseUrl.replace(/\/+$/, "")}|${customModelId}`;
return {
source: "remote",
model: `memory-custom/${customModelId}`,
dimensions: null,
identity,
signature: makeSignature("remote", identity, null),
reason: "custom remote provider configured (dim=unknown, will probe at embed time)",
};
}

/**
* Resolve which embedding source is active for the given settings (D4).
* Pure: no heavy I/O. Provider key check done via synchronous registry lookup.
*/
export function resolveEmbeddingSource(settings: MemorySettingsExtended): EmbeddingResolution {
const source = settings.embeddingSource ?? "auto";

const customResolution = customRemoteResolution(settings);
if (customResolution && (source === "remote" || source === "auto")) {
return customResolution;
}

if (source === "remote") {
// Explicit remote — check if the configured model has a key
const model = settings.embeddingProviderModel ?? null;
Expand Down Expand Up @@ -194,7 +216,12 @@ export async function embed(
};
}

const cacheKey = buildCacheKey(resolution.source, resolution.model, resolution.dimensions, text);
const cacheKey = buildCacheKey(
resolution.source,
resolution.identity ?? resolution.model,
resolution.dimensions,
text
);

const cached = cacheGet(cacheKey);
if (cached) {
Expand All @@ -211,7 +238,21 @@ export async function embed(
let result: EmbeddingResult | EmbeddingError;

if (resolution.source === "remote") {
result = await embedRemote(text, resolution.model ?? "");
let customProvider;
try {
customProvider = resolveMemoryCustomEmbeddingProvider(settings);
} catch (error: unknown) {
return {
source: "remote",
model: resolution.model,
reason: "request_failed",
message:
error instanceof Error
? error.message
: "Custom embedding endpoint is invalid or blocked",
};
}
result = await embedRemote(text, resolution.model ?? "", customProvider);
} else if (resolution.source === "static") {
result = await embedStatic(text);
} else {
Expand Down
Loading
Loading