Spaces:
Runtime error
Runtime error
| import { getSettings } from "@/lib/db/settings"; | |
| import { createEmbeddingResponse } from "@/lib/embeddings/service"; | |
| type JsonRecord = Record<string, unknown>; | |
| /** | |
| * Vector quantization mode for the memory collection (F4.4 / Q1). | |
| * - `none`: float32 vectors (default, unchanged behavior). | |
| * - `int8`: scalar int8 quantization (~4× less vector RAM, rescore preserves quality). | |
| * - `binary`: binary quantization (up to ~32× less, lossier — opt-in for scale). | |
| */ | |
| export type QdrantQuantization = "none" | "int8" | "binary"; | |
| const QDRANT_QUANTIZATION_MODES: readonly QdrantQuantization[] = ["none", "int8", "binary"]; | |
| export type QdrantConfig = { | |
| enabled: boolean; | |
| host: string; | |
| port: number; | |
| apiKey: string | null; | |
| collection: string; | |
| embeddingModel: string; | |
| quantization: QdrantQuantization; | |
| vectorSize: number; | |
| hnswEfConstruct: number; | |
| }; | |
| /** | |
| * Build the Qdrant `quantization_config` block for collection creation. | |
| * Returns `undefined` for `none` so the create body stays byte-identical to the | |
| * pre-quantization behavior (no silent change for existing deployments). | |
| * Reference: Qdrant scalar/binary quantization + rescore docs. | |
| */ | |
| export function buildQuantizationConfig( | |
| quantization: QdrantQuantization | |
| ): Record<string, unknown> | undefined { | |
| switch (quantization) { | |
| case "int8": | |
| return { scalar: { type: "int8", always_ram: true, quantile: 0.99 } }; | |
| case "binary": | |
| return { binary: { always_ram: true } }; | |
| case "none": | |
| default: | |
| return undefined; | |
| } | |
| } | |
| /** | |
| * Build the Qdrant search `params` block. When the collection is quantized we | |
| * request `rescore: true` so the original vectors refine the quantized shortlist | |
| * (preserves recall). Returns `undefined` for `none` (no `params` sent). | |
| */ | |
| export function searchQuantizationParams( | |
| quantization: QdrantQuantization | |
| ): Record<string, unknown> | undefined { | |
| if (quantization === "none") return undefined; | |
| return { quantization: { rescore: true } }; | |
| } | |
| export function normalizeQdrantConfig(settings: Record<string, unknown>): QdrantConfig { | |
| // Env-var fallbacks (cluster profile: docker compose --profile memory). | |
| // Settings-table values take precedence when present, so users who configure | |
| // Qdrant via the Settings UI are not overridden by the container hostname. | |
| const envHost = typeof process.env.QDRANT_HOST === "string" ? process.env.QDRANT_HOST.trim() : ""; | |
| const envPortRaw = process.env.QDRANT_PORT; | |
| const envPort = | |
| typeof envPortRaw === "string" && envPortRaw.trim().length > 0 | |
| ? Math.round(Number(envPortRaw) || 6333) | |
| : undefined; | |
| const envApiKey = | |
| typeof process.env.QDRANT_API_KEY === "string" && process.env.QDRANT_API_KEY.trim().length > 0 | |
| ? process.env.QDRANT_API_KEY.trim() | |
| : undefined; | |
| const envCollection = | |
| typeof process.env.QDRANT_COLLECTION === "string" && process.env.QDRANT_COLLECTION.trim().length > 0 | |
| ? process.env.QDRANT_COLLECTION.trim() | |
| : undefined; | |
| const host = | |
| (typeof settings.qdrantHost === "string" ? settings.qdrantHost.trim() : "") || envHost || ""; | |
| const portRaw = settings.qdrantPort; | |
| const port = | |
| typeof portRaw === "number" && Number.isFinite(portRaw) | |
| ? Math.round(portRaw) | |
| : typeof portRaw === "string" | |
| ? Math.round(Number(portRaw) || 6333) | |
| : envPort ?? 6333; | |
| const apiKey = | |
| (typeof settings.qdrantApiKey === "string" && settings.qdrantApiKey.trim().length > 0 | |
| ? settings.qdrantApiKey.trim() | |
| : null) ?? envApiKey ?? null; | |
| const collection = | |
| (typeof settings.qdrantCollection === "string" && settings.qdrantCollection.trim().length > 0 | |
| ? settings.qdrantCollection.trim() | |
| : null) ?? envCollection ?? "omniroute_memory"; | |
| const embeddingModel = | |
| (typeof settings.qdrantEmbeddingModel === "string" && | |
| settings.qdrantEmbeddingModel.trim().length > 0 | |
| ? settings.qdrantEmbeddingModel.trim() | |
| : null) ?? | |
| (typeof process.env.QDRANT_EMBEDDING_MODEL === "string" && | |
| process.env.QDRANT_EMBEDDING_MODEL.trim().length > 0 | |
| ? process.env.QDRANT_EMBEDDING_MODEL.trim() | |
| : null) ?? | |
| "openai/text-embedding-3-small"; | |
| const enabled = settings.qdrantEnabled === true; | |
| const quantizationRaw = settings.qdrantQuantization; | |
| const quantization: QdrantQuantization = | |
| typeof quantizationRaw === "string" && | |
| (QDRANT_QUANTIZATION_MODES as readonly string[]).includes(quantizationRaw) | |
| ? (quantizationRaw as QdrantQuantization) | |
| : "none"; | |
| // Vector size + HNSW ef_construct are deployment-time constants. They are | |
| // read here so the env vars documented in .env.example (QDRANT_VECTOR_SIZE, | |
| // QDRANT_HNSW_EF_CONSTRUCT) have a single source of truth and don't get | |
| // flagged as DOC_ONLY by check-env-doc-sync. | |
| const vectorSizeRaw = | |
| typeof settings.qdrantVectorSize === "number" | |
| ? settings.qdrantVectorSize | |
| : typeof settings.qdrantVectorSize === "string" | |
| ? Number(settings.qdrantVectorSize) | |
| : Number(process.env.QDRANT_VECTOR_SIZE) || 1536; | |
| const vectorSize = Number.isFinite(vectorSizeRaw) && vectorSizeRaw > 0 ? vectorSizeRaw : 1536; | |
| const hnswEfRaw = | |
| typeof settings.qdrantHnswEfConstruct === "number" | |
| ? settings.qdrantHnswEfConstruct | |
| : typeof settings.qdrantHnswEfConstruct === "string" | |
| ? Number(settings.qdrantHnswEfConstruct) | |
| : Number(process.env.QDRANT_HNSW_EF_CONSTRUCT) || 128; | |
| const hnswEfConstruct = Number.isFinite(hnswEfRaw) && hnswEfRaw > 0 ? hnswEfRaw : 128; | |
| return { | |
| enabled, | |
| host, | |
| port, | |
| apiKey, | |
| collection, | |
| embeddingModel, | |
| quantization, | |
| vectorSize, | |
| hnswEfConstruct, | |
| }; | |
| } | |
| export async function getQdrantConfig(): Promise<QdrantConfig> { | |
| const settings = (await getSettings()) as Record<string, unknown>; | |
| return normalizeQdrantConfig(settings); | |
| } | |
| function baseUrl(cfg: QdrantConfig): string { | |
| const host = cfg.host.replace(/\/+$/, ""); | |
| const withProto = | |
| host.startsWith("http://") || host.startsWith("https://") ? host : `http://${host}`; | |
| try { | |
| const url = new URL(withProto); | |
| if (!url.port) url.port = String(cfg.port); | |
| return url.toString().replace(/\/+$/, ""); | |
| } catch { | |
| return `${withProto}:${cfg.port}`; | |
| } | |
| } | |
| async function qdrantFetch(cfg: QdrantConfig, path: string, init?: RequestInit): Promise<Response> { | |
| const headers: Record<string, string> = { | |
| "content-type": "application/json", | |
| ...(init?.headers as Record<string, string> | undefined), | |
| }; | |
| if (cfg.apiKey) headers["api-key"] = cfg.apiKey; | |
| return fetch(`${baseUrl(cfg)}${path}`, { | |
| ...init, | |
| headers, | |
| }); | |
| } | |
| export async function checkQdrantHealth(): Promise<{ | |
| ok: boolean; | |
| latencyMs: number; | |
| error?: string; | |
| }> { | |
| const cfg = await getQdrantConfig(); | |
| const start = Date.now(); | |
| if (!cfg.enabled || !cfg.host) { | |
| return { ok: false, latencyMs: 0, error: "not_configured" }; | |
| } | |
| try { | |
| const res = await qdrantFetch(cfg, "/readyz", { method: "GET" }); | |
| const latencyMs = Date.now() - start; | |
| if (!res.ok) { | |
| const text = await res.text().catch(() => ""); | |
| return { ok: false, latencyMs, error: text.slice(0, 200) || `HTTP ${res.status}` }; | |
| } | |
| return { ok: true, latencyMs }; | |
| } catch (err) { | |
| return { | |
| ok: false, | |
| latencyMs: Date.now() - start, | |
| error: err instanceof Error ? err.message : String(err), | |
| }; | |
| } | |
| } | |
| async function ensureCollection(cfg: QdrantConfig, vectorSize: number): Promise<void> { | |
| const getRes = await qdrantFetch(cfg, `/collections/${encodeURIComponent(cfg.collection)}`, { | |
| method: "GET", | |
| }); | |
| if (getRes.ok) return; | |
| // Quantization only applies to NEW collections — an existing collection is left | |
| // untouched (the GET above returns early). Switching modes on a populated | |
| // collection is a destructive recreate+reindex that must be an explicit UI action. | |
| const quantizationConfig = buildQuantizationConfig(cfg.quantization); | |
| const createRes = await qdrantFetch(cfg, `/collections/${encodeURIComponent(cfg.collection)}`, { | |
| method: "PUT", | |
| body: JSON.stringify({ | |
| vectors: { size: vectorSize, distance: "Cosine" }, | |
| ...(quantizationConfig ? { quantization_config: quantizationConfig } : {}), | |
| }), | |
| }); | |
| if (!createRes.ok) { | |
| const text = await createRes.text().catch(() => ""); | |
| throw new Error(text.slice(0, 300) || `Failed to create collection (${createRes.status})`); | |
| } | |
| } | |
| async function getCollectionVectorName(cfg: QdrantConfig): Promise<string | null> { | |
| const res = await qdrantFetch(cfg, `/collections/${encodeURIComponent(cfg.collection)}`, { | |
| method: "GET", | |
| }); | |
| if (!res.ok) return null; | |
| const data = (await res.json().catch(() => null)) as any; | |
| const vectors = data?.result?.config?.params?.vectors; | |
| if (!vectors || typeof vectors !== "object" || Array.isArray(vectors)) { | |
| return null; | |
| } | |
| // Unnamed/single-vector config: { size, distance, ... } (not a named map) | |
| if ( | |
| Object.prototype.hasOwnProperty.call(vectors, "size") && | |
| (typeof vectors.size === "number" || typeof vectors.size === "string") | |
| ) { | |
| return null; | |
| } | |
| const names = Object.keys(vectors); | |
| if (names.length === 0) return null; | |
| return names[0] || null; | |
| } | |
| async function embedText(cfg: QdrantConfig, text: string): Promise<number[]> { | |
| const modelStr = cfg.embeddingModel.trim(); | |
| if (!modelStr.includes("/")) { | |
| throw new Error(`Invalid embedding model '${modelStr}'. Use provider/model format.`); | |
| } | |
| const res = await createEmbeddingResponse({ | |
| model: modelStr, | |
| input: text, | |
| }); | |
| if (!res.ok) { | |
| const txt = await res.text().catch(() => ""); | |
| throw new Error(txt.slice(0, 300) || `Embeddings request failed (${res.status})`); | |
| } | |
| const data = (await res.json().catch(() => null)) as any; | |
| const vec = data?.data?.[0]?.embedding; | |
| if (!Array.isArray(vec) || vec.length === 0) { | |
| throw new Error("Embedding response missing vector"); | |
| } | |
| return vec as number[]; | |
| } | |
| export async function upsertSemanticMemoryPoint(input: { | |
| id: string; | |
| apiKeyId: string; | |
| sessionId: string; | |
| key: string; | |
| content: string; | |
| metadata: JsonRecord; | |
| createdAt: string; | |
| expiresAt: string | null; | |
| }): Promise<{ ok: boolean; latencyMs: number; error?: string }> { | |
| const cfg = await getQdrantConfig(); | |
| if (!cfg.enabled || !cfg.host) return { ok: false, latencyMs: 0, error: "not_configured" }; | |
| const start = Date.now(); | |
| try { | |
| const vector = await embedText(cfg, `${input.key}\n\n${input.content}`); | |
| await ensureCollection(cfg, vector.length); | |
| const vectorName = await getCollectionVectorName(cfg); | |
| const createdAtUnix = Math.floor(new Date(input.createdAt).getTime() / 1000); | |
| const expiresAtUnix = input.expiresAt | |
| ? Math.floor(new Date(input.expiresAt).getTime() / 1000) | |
| : null; | |
| const payload = { | |
| kind: "omniroute_memory", | |
| memoryId: input.id, | |
| apiKeyId: input.apiKeyId || "", | |
| sessionId: input.sessionId || "", | |
| type: "semantic", | |
| key: input.key || "", | |
| content: input.content || "", | |
| metadata: input.metadata || {}, | |
| createdAtUnix, | |
| expiresAtUnix, | |
| }; | |
| const res = await qdrantFetch( | |
| cfg, | |
| `/collections/${encodeURIComponent(cfg.collection)}/points?wait=true`, | |
| { | |
| method: "PUT", | |
| body: JSON.stringify({ | |
| points: [ | |
| { | |
| id: input.id, | |
| vector: vectorName ? { [vectorName]: vector } : vector, | |
| payload, | |
| }, | |
| ], | |
| }), | |
| } | |
| ); | |
| const latencyMs = Date.now() - start; | |
| if (!res.ok) { | |
| const text = await res.text().catch(() => ""); | |
| return { ok: false, latencyMs, error: text.slice(0, 300) || `HTTP ${res.status}` }; | |
| } | |
| return { ok: true, latencyMs }; | |
| } catch (err) { | |
| return { | |
| ok: false, | |
| latencyMs: Date.now() - start, | |
| error: err instanceof Error ? err.message : String(err), | |
| }; | |
| } | |
| } | |
| export async function searchSemanticMemory( | |
| query: string, | |
| topK = 5, | |
| scope?: { apiKeyId?: string; sessionId?: string | null } | |
| ): Promise<{ | |
| ok: boolean; | |
| latencyMs: number; | |
| results?: Array<{ id: string; score: number; payload?: JsonRecord }>; | |
| error?: string; | |
| }> { | |
| const cfg = await getQdrantConfig(); | |
| if (!cfg.enabled || !cfg.host) return { ok: false, latencyMs: 0, error: "not_configured" }; | |
| const start = Date.now(); | |
| try { | |
| const vector = await embedText(cfg, query); | |
| await ensureCollection(cfg, vector.length); | |
| const vectorName = await getCollectionVectorName(cfg); | |
| const searchParams = searchQuantizationParams(cfg.quantization); | |
| const res = await qdrantFetch( | |
| cfg, | |
| `/collections/${encodeURIComponent(cfg.collection)}/points/search`, | |
| { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| vector: vectorName ? { name: vectorName, vector } : vector, | |
| limit: Math.max(1, Math.min(20, topK)), | |
| ...(searchParams ? { params: searchParams } : {}), | |
| filter: { | |
| must: [ | |
| { key: "kind", match: { value: "omniroute_memory" } }, | |
| ...(scope?.apiKeyId ? [{ key: "apiKeyId", match: { value: scope.apiKeyId } }] : []), | |
| ...(scope?.sessionId | |
| ? [{ key: "sessionId", match: { value: String(scope.sessionId) } }] | |
| : []), | |
| ], | |
| }, | |
| with_payload: true, | |
| }), | |
| } | |
| ); | |
| const latencyMs = Date.now() - start; | |
| if (!res.ok) { | |
| const text = await res.text().catch(() => ""); | |
| return { ok: false, latencyMs, error: text.slice(0, 300) || `HTTP ${res.status}` }; | |
| } | |
| const data = (await res.json().catch(() => null)) as any; | |
| const result = Array.isArray(data?.result) ? data.result : []; | |
| return { | |
| ok: true, | |
| latencyMs, | |
| results: result.map((r: any) => ({ | |
| id: String(r.id), | |
| score: typeof r.score === "number" ? r.score : 0, | |
| payload: r.payload && typeof r.payload === "object" ? (r.payload as JsonRecord) : undefined, | |
| })), | |
| }; | |
| } catch (err) { | |
| return { | |
| ok: false, | |
| latencyMs: Date.now() - start, | |
| error: err instanceof Error ? err.message : String(err), | |
| }; | |
| } | |
| } | |
| export async function deleteSemanticMemoryPoint( | |
| id: string | |
| ): Promise<{ ok: boolean; latencyMs: number; error?: string }> { | |
| const cfg = await getQdrantConfig(); | |
| if (!cfg.enabled || !cfg.host) return { ok: false, latencyMs: 0, error: "not_configured" }; | |
| const start = Date.now(); | |
| try { | |
| const res = await qdrantFetch( | |
| cfg, | |
| `/collections/${encodeURIComponent(cfg.collection)}/points/delete?wait=true`, | |
| { | |
| method: "POST", | |
| body: JSON.stringify({ points: [id] }), | |
| } | |
| ); | |
| const latencyMs = Date.now() - start; | |
| if (!res.ok) { | |
| const text = await res.text().catch(() => ""); | |
| return { ok: false, latencyMs, error: text.slice(0, 300) || `HTTP ${res.status}` }; | |
| } | |
| return { ok: true, latencyMs }; | |
| } catch (err) { | |
| return { | |
| ok: false, | |
| latencyMs: Date.now() - start, | |
| error: err instanceof Error ? err.message : String(err), | |
| }; | |
| } | |
| } | |
| export async function cleanupSemanticMemoryPoints(input: { | |
| retentionDays: number; | |
| }): Promise<{ ok: boolean; deletedCount: number; latencyMs: number; error?: string }> { | |
| const cfg = await getQdrantConfig(); | |
| if (!cfg.enabled || !cfg.host) | |
| return { ok: false, deletedCount: 0, latencyMs: 0, error: "not_configured" }; | |
| const retentionDays = | |
| typeof input.retentionDays === "number" && Number.isFinite(input.retentionDays) | |
| ? Math.max(1, Math.min(3650, Math.round(input.retentionDays))) | |
| : 30; | |
| const start = Date.now(); | |
| try { | |
| const nowUnix = Math.floor(Date.now() / 1000); | |
| const cutoffUnix = nowUnix - retentionDays * 24 * 60 * 60; | |
| const filter: Record<string, unknown> = { | |
| must: [{ key: "kind", match: { value: "omniroute_memory" } }], | |
| should: [ | |
| { key: "expiresAtUnix", range: { lt: nowUnix } }, | |
| { key: "createdAtUnix", range: { lt: cutoffUnix } }, | |
| ], | |
| }; | |
| // Count first (so we can show an actual number in the dashboard) | |
| const countRes = await qdrantFetch( | |
| cfg, | |
| `/collections/${encodeURIComponent(cfg.collection)}/points/count`, | |
| { | |
| method: "POST", | |
| body: JSON.stringify({ filter, exact: true }), | |
| } | |
| ); | |
| if (!countRes.ok) { | |
| const text = await countRes.text().catch(() => ""); | |
| return { | |
| ok: false, | |
| deletedCount: 0, | |
| latencyMs: Date.now() - start, | |
| error: text.slice(0, 300) || `HTTP ${countRes.status}`, | |
| }; | |
| } | |
| const countData = (await countRes.json().catch(() => null)) as any; | |
| const toDelete = | |
| typeof countData?.result?.count === "number" && Number.isFinite(countData.result.count) | |
| ? Math.max(0, Math.round(countData.result.count)) | |
| : 0; | |
| if (toDelete === 0) { | |
| return { ok: true, deletedCount: 0, latencyMs: Date.now() - start }; | |
| } | |
| const delRes = await qdrantFetch( | |
| cfg, | |
| `/collections/${encodeURIComponent(cfg.collection)}/points/delete?wait=true`, | |
| { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| filter, | |
| }), | |
| } | |
| ); | |
| if (!delRes.ok) { | |
| const text = await delRes.text().catch(() => ""); | |
| return { | |
| ok: false, | |
| deletedCount: 0, | |
| latencyMs: Date.now() - start, | |
| error: text.slice(0, 300) || `HTTP ${delRes.status}`, | |
| }; | |
| } | |
| return { ok: true, deletedCount: toDelete, latencyMs: Date.now() - start }; | |
| } catch (err) { | |
| return { | |
| ok: false, | |
| deletedCount: 0, | |
| latencyMs: Date.now() - start, | |
| error: err instanceof Error ? err.message : String(err), | |
| }; | |
| } | |
| } | |