// OpenAI-compatible embedding client (backend-only). Targets the explicitly // configured internal endpoint (llama.cpp / Ollama / LM Studio / LiteLLM). // Vectors are L2-normalized so a plain dot product equals cosine similarity. // // Never logs input text, vectors, or endpoint payloads. export interface EmbeddingClientConfig { baseUrl: string; model: string; apiKey?: string; dimensions: number; queryPrefix?: string; passagePrefix?: string; requestTimeoutMs: number; } export type EmbeddingKind = 'query' | 'passage'; /** Safe, stable error codes — carry no sensitive content. */ export class EmbeddingError extends Error { constructor(readonly code: string, message: string) { super(message); this.name = 'EmbeddingError'; } } export interface EmbeddingClient { embed(texts: string[], kind: EmbeddingKind): Promise; } function normalize(vector: number[]): number[] { let sumSquares = 0; for (const value of vector) sumSquares += value * value; const magnitude = Math.sqrt(sumSquares); if (!Number.isFinite(magnitude) || magnitude === 0) return vector; return vector.map(value => value / magnitude); } export function createEmbeddingClient(config: EmbeddingClientConfig): EmbeddingClient { const prefixFor = (kind: EmbeddingKind): string => (kind === 'query' ? config.queryPrefix : config.passagePrefix) ?? ''; return { async embed(texts: string[], kind: EmbeddingKind): Promise { if (texts.length === 0) return []; const prefix = prefixFor(kind); const input = texts.map(text => `${prefix}${text}`); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); let response: Response; try { response = await fetch(`${config.baseUrl}/embeddings`, { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json', ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}) }, body: JSON.stringify({ model: config.model, input }) }); } catch (error) { const aborted = error instanceof Error && error.name === 'AbortError'; throw new EmbeddingError(aborted ? 'embedding_timeout' : 'embedding_unreachable', aborted ? 'Der Embedding-Dienst hat nicht rechtzeitig geantwortet.' : 'Der Embedding-Dienst ist nicht erreichbar.'); } finally { clearTimeout(timeout); } if (!response.ok) { throw new EmbeddingError('embedding_http_error', `Embedding-Dienst antwortete mit HTTP ${response.status}.`); } let payload: { data?: Array<{ embedding?: number[] }> }; try { payload = await response.json() as typeof payload; } catch { throw new EmbeddingError('embedding_invalid_response', 'Die Antwort des Embedding-Dienstes war kein gültiges JSON.'); } const rows = Array.isArray(payload.data) ? payload.data : []; if (rows.length !== texts.length) { throw new EmbeddingError('embedding_count_mismatch', 'Der Embedding-Dienst lieferte eine andere Anzahl an Vektoren als angefragt.'); } return rows.map(row => { const vector = Array.isArray(row.embedding) ? row.embedding : []; if (vector.length !== config.dimensions) { throw new EmbeddingError('dimension_mismatch', `Der Embedding-Dienst lieferte ${vector.length} Dimensionen, erwartet wurden ${config.dimensions}.`); } return normalize(vector); }); } }; }