Compare commits

..

4 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
8fc4023f94 fix(migrations): allow fresh install past mass-migration guard (#9934) (#10022)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-10 11:18:19 -03:00
Diego Rodrigues de Sa e Souza
aafdc4d4c6 fix(images): normalize image endpoint error format (#9981) (#10020)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-10 11:18:14 -03:00
Diego Rodrigues de Sa e Souza
fed0858f89 fix(encryption): identify failing credential in decrypt errors (#9927) (#10019)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-10 11:18:09 -03:00
Diego Rodrigues de Sa e Souza
2b6977229b fix(search): nest Exa contents options for /search (#9914) (#10018)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-10 11:18:04 -03:00
30 changed files with 473 additions and 591 deletions

View File

@@ -1 +0,0 @@
- feat(memory): support custom OpenAI-compatible endpoints for Memory embeddings (#9622)

View File

@@ -0,0 +1 @@
- fix(search): nest Exa contents options (text/highlights) for /search API (#9914)

View File

@@ -0,0 +1 @@
- fix(encryption): name failing credential + recovery path in decrypt errors, dedupe per connection (#9927)

View File

@@ -0,0 +1 @@
- fix(migrations): don't abort on fresh install with only the 001 seed (#9934)

View File

@@ -0,0 +1 @@
- fix(images): normalize terminal upstream errors via OpenAI-standard type/code (#9981)

View File

@@ -161,15 +161,13 @@ The `memory_vec_meta` table (migration `073_memory_vec.sql`) stores:
## Settings extension
Nine embedding and vector fields are available in `MemorySettingsExtended` in
Seven new fields were added to `MemorySettingsExtended` (plan 21, D9) 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) |
@@ -178,14 +176,6 @@ Nine embedding and vector fields are available in `MemorySettingsExtended` 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.

View File

@@ -336,8 +336,10 @@ function buildExaRequest(
query: params.query,
numResults: params.maxResults,
type: "auto",
text: true,
highlights: true,
contents: {
text: true,
highlights: true,
},
};
if (includes.length) body.includeDomains = includes;
if (excludes.length) body.excludeDomains = excludes;

View File

@@ -1,104 +0,0 @@
"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>
);
}

View File

@@ -3,7 +3,6 @@
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;
@@ -102,11 +101,10 @@ 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>
)}

View File

@@ -14,17 +14,35 @@ import { buildErrorBody } from "@omniroute/open-sse/utils/error";
* Returns a 424 (Failed Dependency) response with a clear, sanitized message
* when the connection carries that flag; otherwise null (proceed normally).
*/
const STALE_ENCRYPTION_MESSAGE =
"Stored API key cannot be decrypted (STORAGE_ENCRYPTION_KEY changed or unset). Re-enter the API key.";
export function buildStaleEncryptionKeyResponse(
connection: { credentialDecryptFailed?: unknown } | null | undefined
connection:
| {
credentialDecryptFailed?: unknown;
id?: unknown;
provider?: unknown;
}
| null
| undefined
): NextResponse | null {
if (!connection || connection.credentialDecryptFailed !== true) return null;
// #9927 — surface WHICH credential failed plus the recovery path so the
// dashboard points the operator at the account to re-authenticate instead of
// a generic "API key cannot be decrypted".
const provider = typeof connection.provider === "string" ? connection.provider : "";
const id = typeof connection.id === "string" ? connection.id : "";
const identity = [provider && `provider "${provider}"`, id && `connection ${id}`]
.filter(Boolean)
.join(", ");
const message =
`Stored credential${identity ? ` for ${identity}` : ""} cannot be decrypted ` +
`(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` +
`STORAGE_ENCRYPTION_KEY matches the key used to store it.`;
// buildErrorBody sanitizes the message (Rule #12); override the type so the
// client can key off the specific stale-encryption cause.
const body = buildErrorBody(424, STALE_ENCRYPTION_MESSAGE);
const body = buildErrorBody(424, message);
body.error.type = "storage_encryption_stale";
return NextResponse.json(body, { status: 424 });
}

View File

@@ -308,10 +308,11 @@ async function postHandler(request, context) {
}
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
return new Response(JSON.stringify(errorPayload), {
status: (result as any).status,
headers: { "Content-Type": "application/json" },
});
const message =
typeof errorPayload?.error?.message === "string"
? errorPayload.error.message
: "Image generation provider error";
return errorResponse((result as any).status, message);
}
export const POST = withInjectionGuard(postHandler);

View File

@@ -119,8 +119,9 @@ export async function POST(request, { params }) {
}
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
return new Response(JSON.stringify(errorPayload), {
status: (result as any).status,
headers: { "Content-Type": "application/json" },
});
const message =
typeof errorPayload?.error?.message === "string"
? errorPayload.error.message
: "Image generation provider error";
return errorResponse((result as any).status, message);
}

View File

@@ -4142,12 +4142,6 @@
"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",

View File

@@ -4142,12 +4142,6 @@
"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",

View File

@@ -1045,13 +1045,35 @@ export function getDbInstance(): SqliteDatabase {
// This is needed so the migration runner skips the mass-migration safety abort
// that would otherwise trigger because heuristic seeding marks some migrations
// as applied, making the fresh DB look like a wiped existing DB (#1328).
const isNewDb = !fs.existsSync(sqliteFile);
// #9934: also classify as fresh a file that `omniroute setup` created with
// only the clipped skeleton schema (see the probe below) — even though the
// file exists, it has never had migrations run.
let isNewDb = !fs.existsSync(sqliteFile);
// Detect and handle old schema format — preserve data when possible (#146)
// Uses a single probe connection that becomes the real connection when possible.
if (fs.existsSync(sqliteFile)) {
try {
const probe = openSqliteDatabase(sqliteFile, { readonly: true });
// #9934: init asymmetry — bin/cli/sqlite.mjs::openOmniRouteDb (used by
// `omniroute setup`) creates storage.sqlite with only the partial inline
// schema (key_value + provider_connections) and never runs migrations.
// Purely file-existence-based freshness made that file look like an
// existing DB, so the first `serve` auto-seeded only the 001 marker and
// tripped the mass-migration safety abort on a brand-new install. A
// skeleton file has provider_connections but none of the tables the 001
// migration creates (combos) — treat it as fresh, not as a wiped DB.
const probeHasProviderConnections = !!probe
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='provider_connections'"
)
.get();
const probeHasCombos = !!probe
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='combos'")
.get();
if (probeHasProviderConnections && !probeHasCombos) {
isNewDb = true;
}
const hasOldSchema = probe
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
.get();

View File

@@ -51,6 +51,31 @@ export interface ConnectionFields {
[key: string]: unknown;
}
/**
* #9927 — dedupe tracker for credential-decrypt-failure messages. The health
* sweep / refresh / request routing re-decrypt the same corrupt row every
* cycle; we log the enriched, actionable message ONCE per
* (provider + connection + failing-ciphertext) state so it does not spam
* every sweep, while still re-logging if the row state actually changes
* (e.g. a different field starts failing) instead of permanently suppressing.
*/
const loggedDecryptFailures = new Set<string>();
function decryptFailureSignature(
connectionId: string,
provider: string,
failed: Array<{ field: string; value: unknown }>
): string {
const parts = failed
.map((f) => `${f.field}:${typeof f.value === "string" ? f.value : ""}`)
.sort()
.join("|");
return `${provider}::${connectionId}::${parts}`;
}
const RECOVERY_HINT =
"Re-authenticate this account, or verify STORAGE_ENCRYPTION_KEY matches the key used to store it.";
/**
* Derive the PRIMARY encryption key using the static salt.
* This is the canonical key derivation that all new encryptions use.
@@ -157,7 +182,10 @@ export function encrypt(plaintext: string | null | undefined): string | null | u
* auto-migration: the next encrypt() call will re-encrypt it with the
* static-salt key, gradually migrating the database.
*/
export function decrypt(ciphertext: string | null | undefined): string | null | undefined {
export function decrypt(
ciphertext: string | null | undefined,
opts?: { quiet?: boolean }
): string | null | undefined {
if (!ciphertext || typeof ciphertext !== "string") return ciphertext;
// Not encrypted — return as-is (legacy plaintext or passthrough mode)
@@ -204,14 +232,21 @@ export function decrypt(ciphertext: string | null | undefined): string | null |
return decrypted;
}
console.error(
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
`Auth tag validation likely failed.`
);
// #9927 — the low-level generic log is suppressed when called through the
// connection-decryption path (quiet:true); decryptConnectionFields emits a
// single enriched message naming the credential + recovery path instead.
if (!opts?.quiet) {
console.error(
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
`Auth tag validation likely failed.`
);
}
return null;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("[Encryption] Decryption failed:", message);
if (!opts?.quiet) {
console.error("[Encryption] Decryption failed:", message);
}
return null;
}
}
@@ -242,10 +277,13 @@ export function decryptConnectionFields<T extends ConnectionFields | null | unde
if (!row) return row;
if (!isEncryptionEnabled()) return row;
const apiKey = decrypt(row.apiKey);
const accessToken = decrypt(row.accessToken);
const refreshToken = decrypt(row.refreshToken);
const idToken = decrypt(row.idToken);
// quiet:true — the low-level generic decrypt() log is suppressed here so a
// single failure emits ONE enriched message (below) naming the credential
// and recovery path (#9927) instead of one generic line per field per cycle.
const apiKey = decrypt(row.apiKey, { quiet: true });
const accessToken = decrypt(row.accessToken, { quiet: true });
const refreshToken = decrypt(row.refreshToken, { quiet: true });
const idToken = decrypt(row.idToken, { quiet: true });
// #6148 — a stored credential that is still encrypted (`enc:v1:…`) but
// decrypts to null means the STORAGE_ENCRYPTION_KEY changed or was unset.
@@ -257,6 +295,31 @@ export function decryptConnectionFields<T extends ConnectionFields | null | unde
(looksEncrypted(row.refreshToken) && refreshToken === null) ||
(looksEncrypted(row.idToken) && idToken === null);
if (credentialDecryptFailed) {
const failed: Array<{ field: string; value: unknown }> = [];
if (looksEncrypted(row.apiKey) && apiKey === null) failed.push({ field: "apiKey", value: row.apiKey });
if (looksEncrypted(row.accessToken) && accessToken === null)
failed.push({ field: "accessToken", value: row.accessToken });
if (looksEncrypted(row.refreshToken) && refreshToken === null)
failed.push({ field: "refreshToken", value: row.refreshToken });
if (looksEncrypted(row.idToken) && idToken === null) failed.push({ field: "idToken", value: row.idToken });
const connectionId = typeof row.id === "string" ? row.id : "";
const provider = typeof row.provider === "string" ? row.provider : "unknown";
const fields = failed.map((f) => f.field).join(", ");
// Dedupe per credential/row state: the sweep re-decrypts the same corrupt
// row every cycle — log ONCE unless the failing state actually changes.
const signature = decryptFailureSignature(connectionId, provider, failed);
if (!loggedDecryptFailures.has(signature)) {
loggedDecryptFailures.add(signature);
console.error(
`[Encryption] Failed to decrypt credential(s) [${fields}] for provider ` +
`"${provider}" (connection ${connectionId || "unknown"}). ${RECOVERY_HINT}`
);
}
}
return {
...row,
apiKey,

View File

@@ -922,9 +922,26 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
// interpolates this resolved value, so it auto-reflects any override.
const maxPendingMigrations = resolveMaxPendingMigrations();
// #9934: `omniroute setup`'s openOmniRouteDb writes a partial skeleton file
// (provider_connections + key_value) that has never had migrations run. When
// the first `serve` opens it and auto-seeds only the 001 marker, the applied
// set is exactly {001} — which would otherwise look like a wiped existing DB
// and trip this abort on a brand-new install. This is distinct from a real
// wiped/backup-restored database: that case has a non-trivial physical schema
// (baseline inference is non-null) and full data tables, so it still aborts.
// The 001-marker-only state on a provider_connections skeleton is the fresh
// auto-seed — let it through. A genuinely empty table is already exempt via
// `applied.size > 0`, and an upgraded DB has a non-trivial applied set.
const isFreshSeedOnly =
applied.size === 1 &&
applied.has("001") &&
inferPhysicalSchemaBaseline(db) === null &&
hasTable(db, "provider_connections");
if (
!isTestEnvironment &&
!isNewDb &&
!isFreshSeedOnly &&
process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" &&
maxPendingMigrations > 0 &&
applied.size > 0 &&

View File

@@ -50,8 +50,6 @@ export interface EmbeddingHandlerOptions {
apiKeyId?: string | null;
apiKeyName?: string | null;
connectionId?: string | null;
resolvedProvider?: EmbeddingProvider | null;
resolvedModel?: string | null;
}
export async function createEmbeddingResponse(
@@ -153,13 +151,7 @@ export async function createEmbeddingResponse(
log.error("EMBED", `Failed to load provider_nodes for embeddings: ${err}`);
}
const parsedModel = options.resolvedProvider
? {
provider: options.resolvedProvider.id,
model: options.resolvedModel ?? body.model,
}
: parseEmbeddingModel(body.model, dynamicProviders);
const { provider, model: resolvedModel } = parsedModel;
const { provider, model: resolvedModel } = parseEmbeddingModel(body.model, dynamicProviders);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
@@ -168,10 +160,7 @@ export async function createEmbeddingResponse(
}
let providerConfig: EmbeddingProvider | null =
options.resolvedProvider ||
dynamicProviders.find((dp) => dp.id === provider) ||
getEmbeddingProvider(provider) ||
null;
dynamicProviders.find((dp) => dp.id === provider) || getEmbeddingProvider(provider) || null;
let credentialsProviderId = provider;
if (!providerConfig) {

View File

@@ -1,62 +0,0 @@
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();
}
}

View File

@@ -17,7 +17,6 @@ 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";
@@ -67,22 +66,6 @@ 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.
@@ -90,11 +73,6 @@ function customRemoteResolution(settings: MemorySettingsExtended): EmbeddingReso
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;
@@ -216,12 +194,7 @@ export async function embed(
};
}
const cacheKey = buildCacheKey(
resolution.source,
resolution.identity ?? resolution.model,
resolution.dimensions,
text
);
const cacheKey = buildCacheKey(resolution.source, resolution.model, resolution.dimensions, text);
const cached = cacheGet(cacheKey);
if (cached) {
@@ -238,21 +211,7 @@ export async function embed(
let result: EmbeddingResult | EmbeddingError;
if (resolution.source === "remote") {
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);
result = await embedRemote(text, resolution.model ?? "");
} else if (resolution.source === "static") {
result = await embedStatic(text);
} else {

View File

@@ -1,26 +1,16 @@
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { createEmbeddingResponse } from "@/lib/embeddings/service";
import type { EmbeddingResult, EmbeddingError } from "./types";
import type { ResolvedMemoryCustomEmbeddingProvider } from "./customProvider";
export async function embedRemote(
text: string,
model: string,
customProvider: ResolvedMemoryCustomEmbeddingProvider | null = null
model: string
): Promise<EmbeddingResult | EmbeddingError> {
const t0 = Date.now();
let resp: Response;
try {
resp = await createEmbeddingResponse(
{ model, input: text },
customProvider
? {
resolvedProvider: customProvider.provider,
resolvedModel: customProvider.model,
}
: undefined
);
resp = await createEmbeddingResponse({ model, input: text });
} catch (err: unknown) {
// Network-level errors (ECONNREFUSED, AbortError, etc.)
const isTimeout =
@@ -81,9 +71,7 @@ export async function embedRemote(
source: "remote",
model,
reason: "request_failed",
message: sanitizeErrorMessage(
"Unexpected embedding response shape: missing data[0].embedding"
),
message: sanitizeErrorMessage("Unexpected embedding response shape: missing data[0].embedding"),
};
}
const rawVec = data[0].embedding as number[];

View File

@@ -19,8 +19,6 @@ export interface EmbeddingResolution {
dimensions: number | null;
/** Assinatura única usada como chave do vectorStore para detectar troca de modelo. */
signature: string; // ${source}:${model}:${dim}
/** Cache/signature identity when the same model ID can exist at multiple custom endpoints. */
identity?: string;
/** Motivo da escolha (UI exibe no Engine status). */
reason: string; // e.g. "provider openai com key configurada"
}
@@ -37,7 +35,6 @@ export interface EmbeddingResult {
export interface EmbeddingError {
source: "remote" | "static" | "transformers";
model: string | null;
reason:
"no_key" | "model_load_failed" | "request_failed" | "rate_limited" | "timeout" | "unknown";
reason: "no_key" | "model_load_failed" | "request_failed" | "rate_limited" | "timeout" | "unknown";
message: string; // ALWAYS via sanitizeErrorMessage()
}

View File

@@ -10,8 +10,6 @@ export interface MemorySettings {
// Plan 21 — D9: new embedding / vector store fields
embeddingSource: "remote" | "static" | "transformers" | "auto";
embeddingProviderModel: string | null;
customBaseUrl: string | null;
customModelId: string | null;
transformersEnabled: boolean;
staticEnabled: boolean;
rerankEnabled: boolean;
@@ -38,8 +36,6 @@ export const DEFAULT_MEMORY_SETTINGS: MemorySettings = {
// Plan 21 — D9 defaults
embeddingSource: "auto",
embeddingProviderModel: null,
customBaseUrl: null,
customModelId: null,
transformersEnabled: false,
staticEnabled: false,
rerankEnabled: false,
@@ -85,17 +81,6 @@ function normalizeNullableString(value: unknown, fallback: string | null): strin
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function normalizeCustomString(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function normalizeCustomBaseUrl(value: unknown): string | null {
const normalized = normalizeCustomString(value);
return normalized ? normalized.replace(/\/+$/, "") : null;
}
export function normalizeMemorySettings(rawSettings: Record<string, unknown> = {}): MemorySettings {
return {
enabled: toBoolean(rawSettings.memoryEnabled, DEFAULT_MEMORY_SETTINGS.enabled),
@@ -119,8 +104,6 @@ export function normalizeMemorySettings(rawSettings: Record<string, unknown> = {
rawSettings.memoryEmbeddingProviderModel,
DEFAULT_MEMORY_SETTINGS.embeddingProviderModel
),
customBaseUrl: normalizeCustomBaseUrl(rawSettings.memoryEmbeddingCustomBaseUrl),
customModelId: normalizeCustomString(rawSettings.memoryEmbeddingCustomModelId),
transformersEnabled: toBoolean(
rawSettings.memoryTransformersEnabled,
DEFAULT_MEMORY_SETTINGS.transformersEnabled
@@ -169,10 +152,6 @@ export function toMemorySettingsUpdates(
updates.memoryEmbeddingSource = settings.embeddingSource;
if (settings.embeddingProviderModel !== undefined)
updates.memoryEmbeddingProviderModel = settings.embeddingProviderModel;
if (settings.customBaseUrl !== undefined)
updates.memoryEmbeddingCustomBaseUrl = settings.customBaseUrl;
if (settings.customModelId !== undefined)
updates.memoryEmbeddingCustomModelId = settings.customModelId;
if (settings.transformersEnabled !== undefined)
updates.memoryTransformersEnabled = settings.transformersEnabled;
if (settings.staticEnabled !== undefined) updates.memoryStaticEnabled = settings.staticEnabled;

View File

@@ -1,34 +1,5 @@
import { z } from "zod";
import { MemoryType } from "@/lib/memory/types";
const optionalCustomEmbeddingValue = z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? null : value),
z.string().trim().max(2048).nullable().optional()
);
const optionalCustomEmbeddingUrl = z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? null : value),
z
.string()
.trim()
.max(2048)
.refine((value) => {
try {
const url = new URL(value);
return (
(url.protocol === "http:" || url.protocol === "https:") &&
!url.username &&
!url.password &&
!url.search &&
!url.hash
);
} catch {
return false;
}
}, "Custom embedding endpoint must be an HTTP(S) URL without credentials or query data")
.nullable()
.optional()
);
/** Schema estendido para PUT /api/settings/memory (D9). */
export const MemorySettingsExtendedSchema = z
.object({
@@ -41,8 +12,6 @@ export const MemorySettingsExtendedSchema = z
// Campos novos (D9)
embeddingSource: z.enum(["remote", "static", "transformers", "auto"]).optional(),
embeddingProviderModel: z.string().nullable().optional(), // formato `provider/model`
customBaseUrl: optionalCustomEmbeddingUrl,
customModelId: optionalCustomEmbeddingValue,
transformersEnabled: z.boolean().optional(),
staticEnabled: z.boolean().optional(),
rerankEnabled: z.boolean().optional(),

View File

@@ -0,0 +1,138 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import Database from "better-sqlite3";
import { resetDbInstance } from "../../src/lib/db/core.ts";
// Regression guard for #9934 — init asymmetry breaks a fresh install.
//
// `omniroute setup` (bin/cli/sqlite.mjs::openOmniRouteDb) creates
// storage.sqlite with the *partial* inline schema (key_value +
// provider_connections) but NEVER creates _omniroute_migrations and never runs
// migrations. That file flips the server's new-DB heuristic
// (src/lib/db/core.ts uses `!fs.existsSync(sqliteFile)`), so the first
// `omniroute serve` believes it is an existing DB, auto-seeds only the 001
// marker, and then trips the mass-migration safety abort because 139 pending
// migrations exceed the default threshold of 50 (#6260 gate).
//
// A DB whose ONLY applied migration is the 001 initial-schema auto-seed is a
// fresh install, not a wiped/backup-restored database — it must NOT abort.
const serial = { concurrency: false };
// Re-import a module so module-level env-derived constants (DATA_DIR,
// SQLITE_FILE) re-resolve after we set DATA_DIR. Static import cannot work
// here: the whole point is exercising the module-loading boundary.
async function importFresh(modulePath: string) {
const url = pathToFileURL(path.resolve(modulePath)).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
// Simulate a production (non-test) process so the #6260 mass-migration safety
// gate is actually LIVE: under `node --test` the runner would be detected and
// the gate skipped, making the bug invisible.
function withNonTestEnvironment<R>(fn: () => R): R {
const originalNodeEnv = process.env.NODE_ENV;
const originalVitest = process.env.VITEST;
const originalDisableAutoBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
const originalArgv = [...process.argv];
const originalExecArgv = [...process.execArgv];
delete process.env.NODE_ENV;
delete process.env.VITEST;
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
process.argv = process.argv.filter((arg) => !arg.includes("test"));
process.execArgv = process.execArgv.filter((arg) => !arg.includes("test"));
try {
return fn();
} finally {
process.argv = originalArgv;
process.execArgv = originalExecArgv;
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalNodeEnv;
if (originalVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = originalVitest;
if (originalDisableAutoBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = originalDisableAutoBackup;
}
}
function cleanupGlobalDb() {
try {
const g = globalThis as Record<string, { open?: boolean; close?: () => void }>;
if (g.__omnirouteDb?.open) g.__omnirouteDb.close?.();
} catch {
/* ignore */
}
delete (globalThis as Record<string, unknown>).__omnirouteDb;
}
test.after(() => {
cleanupGlobalDb();
resetDbInstance();
});
test(
"fresh `omniroute setup` DB (only the 001 seed) survives first serve without mass-migration abort (#9934)",
serial,
async () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9934-"));
const originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
try {
// Step 1 — mimic `omniroute setup`: the CLI opens the DB, writes the
// partial inline schema (key_value + provider_connections) and closes it,
// WITHOUT running migrations or creating _omniroute_migrations.
const cli = await importFresh("bin/cli/sqlite.mjs");
const setup = await cli.openOmniRouteDb();
assert.ok(fs.existsSync(setup.dbPath), "setup created storage.sqlite");
setup.db.close();
const onDisk = new Database(setup.dbPath, { readonly: true });
try {
const hasMigrationTable = !!onDisk
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
.get("_omniroute_migrations");
assert.equal(
hasMigrationTable,
false,
"setup must NOT pre-create the migrations tracking table (bug premise)"
);
} finally {
onDisk.close();
}
// Step 2 — mimic the first `omniroute serve`: the real server opens the
// same DB, auto-seeds only the 001 marker and runs migrations. Under a
// live (non-test) safety gate this must NOT throw.
const core = await importFresh("src/lib/db/core.ts");
cleanupGlobalDb();
resetDbInstance();
let db: { prepare?: (sql: string) => { get: () => { maxV: number } | undefined } };
assert.doesNotThrow(() => {
withNonTestEnvironment(() => {
db = core.getDbInstance();
});
}, "first serve must not abort on a fresh setup DB that only has the 001 seed (#9934)");
// Prove the fresh DB actually got migrated past 001 to the latest version.
const maxRow = db.prepare(
"SELECT MAX(CAST(version AS INTEGER)) AS maxV FROM _omniroute_migrations"
).get();
assert.ok(
(maxRow?.maxV ?? 0) > 1,
`expected migrations beyond 001 to run, got max=${maxRow?.maxV}`
);
} finally {
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
fs.rmSync(dataDir, { recursive: true, force: true });
}
}
);

View File

@@ -0,0 +1,108 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { pathToFileURL } from "node:url";
// #9927 — A credential that no longer decrypts (e.g. STORAGE_ENCRYPTION_KEY
// changed between restarts) must emit a single, enriched error naming the
// provider + connection id + failing field(s) and a recovery path, instead of
// the generic low-level `[Encryption] Decryption failed … Auth tag validation
// likely failed` line that carries no identity and is re-printed every sweep.
const ORIGINAL_STORAGE_KEY = process.env.STORAGE_ENCRYPTION_KEY;
// Cache-busted fresh import so the encryption module re-derives its key from
// the current STORAGE_ENCRYPTION_KEY and resets module-level dedupe state.
async function importFresh(modulePath: string) {
const url = pathToFileURL(path.resolve(modulePath)).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
test.after(() => {
if (ORIGINAL_STORAGE_KEY === undefined) {
delete process.env.STORAGE_ENCRYPTION_KEY;
} else {
process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_KEY;
}
});
function captureConsoleError(fn: () => void): string[] {
const original = console.error;
const logs: string[] = [];
console.error = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
fn();
} finally {
console.error = original;
}
return logs;
}
test("decryptConnectionFields logs failed credential identity + recovery path (#9927)", async () => {
// 1. Encrypt an apiKey under key A.
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-A";
const encA = await importFresh("src/lib/db/encryption.ts");
const ciphertext = encA.encrypt("sk-real-secret-key");
assert.match(ciphertext, /^enc:v1:/, "expected a real enc:v1 ciphertext");
// 2. Read it back under a DIFFERENT key B (simulating a changed key).
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-B";
const encB = await importFresh("src/lib/db/encryption.ts");
const logs = captureConsoleError(() => {
encB.decryptConnectionFields({
id: "conn-9927",
provider: "openai",
apiKey: ciphertext,
});
});
// Must flag the failure so callers can surface the cause.
const decrypted = encB.decryptConnectionFields({
id: "conn-9927",
provider: "openai",
apiKey: ciphertext,
});
assert.equal(decrypted.credentialDecryptFailed, true);
// The generic low-level log must NOT fire (quiet:true); instead ONE enriched
// message names provider + connection id + recovery path.
assert.equal(
logs.some((l) => /Auth tag validation likely failed/.test(l)),
false,
"generic low-level decrypt log must be suppressed on the connection path"
);
const enriched = logs.find((l) => l.includes("Failed to decrypt credential(s)"));
assert.ok(enriched, "expected an enriched credential-decrypt-failure log");
assert.match(enriched, /provider "openai"/, "log must name the provider");
assert.match(enriched, /conn-9927/, "log must name the connection id");
assert.match(enriched, /apiKey/, "log must name the failing field");
assert.match(
enriched,
/STORAGE_ENCRYPTION_KEY matches the key used to store it/,
"log must include the recovery path"
);
});
test("credential-decrypt failure is logged once per connection (dedupe #9927)", async () => {
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-dedupe-A";
const encA = await importFresh("src/lib/db/encryption.ts");
const ciphertext = encA.encrypt("sk-dedupe-key");
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-dedupe-B";
const encB = await importFresh("src/lib/db/encryption.ts");
const row = { id: "conn-dedupe", provider: "openai", apiKey: ciphertext };
const logs = captureConsoleError(() => {
// Simulate the health sweep re-decrypting the same corrupt row repeatedly.
for (let i = 0; i < 5; i++) {
encB.decryptConnectionFields(row);
}
});
const enriched = logs.filter((l) => l.includes("Failed to decrypt credential(s)"));
assert.equal(enriched.length, 1, "identical failure must be logged once per connection");
});

View File

@@ -701,6 +701,67 @@ test("provider-scoped image generation POST uses the shared 401 account fallback
]);
});
test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
await seedConnection("openai", { apiKey: "single-expired-image-key" });
globalThis.fetch = async (url, options: RequestInit = {}) => {
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
const authorization = new Headers(options.headers).get("authorization") ?? "";
assert.equal(authorization, "Bearer single-expired-image-key");
return new Response(JSON.stringify({ error: { message: "expired access token" } }), {
status: 401,
headers: { "content-type": "application/json" },
});
};
const response = await imageRoute.POST(
new Request("http://localhost/api/v1/images/generations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "openai/gpt-image-2", prompt: "normalize terminal 401" }),
})
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 401);
assert.deepEqual(body.error, {
message: "expired access token",
type: "authentication_error",
code: "invalid_api_key",
});
});
test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
await seedConnection("openai", { apiKey: "provider-single-expired-key" });
globalThis.fetch = async (url, options: RequestInit = {}) => {
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
const authorization = new Headers(options.headers).get("authorization") ?? "";
assert.equal(authorization, "Bearer provider-single-expired-key");
return new Response(JSON.stringify({ error: { message: "expired provider token" } }), {
status: 401,
headers: { "content-type": "application/json" },
});
};
const response = await providerImageRoute.POST(
new Request("http://localhost/api/v1/providers/openai/images/generations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }),
}),
{ params: Promise.resolve({ provider: "openai" }) }
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 401);
assert.deepEqual(body.error, {
message: "expired provider token",
type: "authentication_error",
code: "invalid_api_key",
});
});
test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => {
await seedConnection("antigravity", {
authType: "oauth",

View File

@@ -1,156 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { describe, it } from "node:test";
import { MemorySettingsExtendedSchema } from "../../src/shared/schemas/memory.ts";
import {
DEFAULT_MEMORY_SETTINGS,
normalizeMemorySettings,
toMemorySettingsUpdates,
} from "../../src/lib/memory/settings.ts";
import {
MemoryCustomEmbeddingConfigError,
resolveMemoryCustomEmbeddingProvider,
} from "../../src/lib/memory/embedding/customProvider.ts";
import { resolveEmbeddingSource } from "../../src/lib/memory/embedding/index.ts";
import { EMBEDDING_PROVIDERS } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { createEmbeddingResponse } from "../../src/lib/embeddings/service.ts";
describe("Memory custom embedding endpoint", () => {
it("keeps the registry-backed behavior when custom fields are empty", () => {
const parsed = MemorySettingsExtendedSchema.parse({
customBaseUrl: "",
customModelId: "",
});
assert.equal(parsed.customBaseUrl, null);
assert.equal(parsed.customModelId, null);
assert.equal(resolveMemoryCustomEmbeddingProvider(parsed), null);
});
it("normalizes, persists, and resolves a Memory-only OpenAI-compatible provider", () => {
const settings = normalizeMemorySettings({
memoryEmbeddingCustomBaseUrl: " http://localhost:8000/v1/ ",
memoryEmbeddingCustomModelId: " SuperPauly/harrier-oss-v1-0.6b-gguf ",
});
assert.equal(settings.customBaseUrl, "http://localhost:8000/v1");
assert.equal(settings.customModelId, "SuperPauly/harrier-oss-v1-0.6b-gguf");
const resolved = resolveMemoryCustomEmbeddingProvider(settings);
assert.ok(resolved);
assert.equal(resolved.provider.id, "memory-custom");
assert.equal(resolved.provider.baseUrl, "http://localhost:8000/v1/embeddings");
assert.equal(resolved.provider.authType, "none");
assert.equal(resolved.model, "SuperPauly/harrier-oss-v1-0.6b-gguf");
assert.equal(EMBEDDING_PROVIDERS["memory-custom"], undefined);
const resolution = resolveEmbeddingSource({
embeddingSource: "remote",
embeddingProviderModel: null,
customBaseUrl: settings.customBaseUrl,
customModelId: settings.customModelId,
});
assert.equal(resolution.source, "remote");
assert.equal(resolution.model, "memory-custom/SuperPauly/harrier-oss-v1-0.6b-gguf");
assert.match(resolution.signature, /localhost:8000/);
assert.deepEqual(
toMemorySettingsUpdates({
customBaseUrl: settings.customBaseUrl,
customModelId: settings.customModelId,
}),
{
memoryEmbeddingCustomBaseUrl: "http://localhost:8000/v1",
memoryEmbeddingCustomModelId: "SuperPauly/harrier-oss-v1-0.6b-gguf",
}
);
});
it("preserves an endpoint that already ends in /embeddings", () => {
const resolved = resolveMemoryCustomEmbeddingProvider({
customBaseUrl: "https://embeddings.example.test/v1/embeddings/",
customModelId: "custom-model",
});
assert.equal(resolved?.provider.baseUrl, "https://embeddings.example.test/v1/embeddings");
});
it("rejects malformed, non-http, credential-bearing, query-bearing, and metadata URLs", () => {
const blocked = [
"not-a-url",
"file:///tmp/embeddings",
"https://user:secret@example.test/v1",
"https://example.test/v1?api_key=secret",
"http://169.254.169.254/latest/meta-data",
];
for (const customBaseUrl of blocked) {
if (customBaseUrl !== "http://169.254.169.254/latest/meta-data") {
assert.equal(MemorySettingsExtendedSchema.safeParse({ customBaseUrl }).success, false);
}
assert.throws(
() =>
resolveMemoryCustomEmbeddingProvider({
customBaseUrl,
customModelId: "custom-model",
}),
(error: unknown) => {
assert.ok(error instanceof MemoryCustomEmbeddingConfigError);
assert.equal(error.message, "Custom embedding endpoint is invalid or blocked");
assert.equal(error.message.includes(customBaseUrl), false);
return true;
}
);
}
});
it("requires both custom fields and leaves defaults disabled", () => {
assert.equal(DEFAULT_MEMORY_SETTINGS.customBaseUrl, null);
assert.equal(DEFAULT_MEMORY_SETTINGS.customModelId, null);
assert.throws(
() =>
resolveMemoryCustomEmbeddingProvider({
customBaseUrl: "http://localhost:8000/v1",
customModelId: null,
}),
MemoryCustomEmbeddingConfigError
);
});
it("dispatches the custom model to a disposable OpenAI-compatible server", async () => {
let receivedPath = "";
let receivedBody: Record<string, unknown> | null = null;
const server = createServer((request, response) => {
receivedPath = request.url ?? "";
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
receivedBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<
string,
unknown
>;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
assert.ok(address && typeof address === "object");
const custom = resolveMemoryCustomEmbeddingProvider({
customBaseUrl: `http://127.0.0.1:${address.port}/v1`,
customModelId: "custom-model",
});
assert.ok(custom);
const response = await createEmbeddingResponse(
{ model: "memory-custom/custom-model", input: "hello" },
{ resolvedProvider: custom.provider, resolvedModel: custom.model }
);
assert.equal(response.status, 200);
assert.equal(receivedPath, "/v1/embeddings");
assert.equal(receivedBody?.model, "custom-model");
assert.equal(EMBEDDING_PROVIDERS["memory-custom"], undefined);
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
}
});
});

View File

@@ -123,7 +123,7 @@ test("handleSearch builds Brave news requests and normalizes favicon metadata",
}
});
test("handleSearch builds Exa requests with include/exclude domains and preserves rich result fields", async () => {
test("handleSearch builds Exa requests with contents-nested options, include/exclude domains, and preserves rich result fields", async () => {
const originalFetch = globalThis.fetch;
let captured;
@@ -165,8 +165,7 @@ test("handleSearch builds Exa requests with include/exclude domains and preserve
query: "agentic workflows",
numResults: 5,
type: "auto",
text: true,
highlights: true,
contents: { text: true, highlights: true },
includeDomains: ["allowed.com"],
excludeDomains: ["blocked.com"],
category: "news",

View File

@@ -1,86 +0,0 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
function changeInput(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
describe("Memory custom embedding endpoint controls", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("validates locally and persists a complete custom endpoint override", async () => {
const { default: EmbeddingSourceSelector } =
await import("@/app/(dashboard)/dashboard/memory/components/EmbeddingSourceSelector");
const onSave = vi.fn().mockResolvedValue(true);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
<EmbeddingSourceSelector
settings={{
embeddingSource: "remote",
embeddingProviderModel: null,
customBaseUrl: null,
customModelId: null,
}}
providers={[]}
onSave={onSave}
/>
);
});
const baseUrl = container.querySelector(
"[data-testid='embedding-custom-base-url']"
) as HTMLInputElement;
const modelId = container.querySelector(
"[data-testid='embedding-custom-model-id']"
) as HTMLInputElement;
const save = container.querySelector(
"[data-testid='embedding-custom-save']"
) as HTMLButtonElement;
expect(baseUrl).toBeTruthy();
expect(modelId).toBeTruthy();
await act(async () => {
changeInput(baseUrl, "file:///tmp/embeddings");
});
await act(async () => {
changeInput(modelId, "custom-model");
});
await act(async () => {
save.click();
});
expect(container.textContent).toContain("embedding.customEndpointInvalid");
expect(onSave).not.toHaveBeenCalled();
await act(async () => {
changeInput(baseUrl, "http://localhost:8000/v1/");
});
await act(async () => {
save.click();
});
expect(onSave).toHaveBeenCalledWith({
customBaseUrl: "http://localhost:8000/v1",
customModelId: "custom-model",
});
});
});