export interface TextEmbeddingClient { readonly model: string; readonly dimensions: number; embed(texts: string[]): Promise; } export interface EmbeddingEnvironment { ASSESSOR_RAG_EMBEDDING_BASE_URL?: string; ASSESSOR_RAG_EMBEDDING_MODEL?: string; ASSESSOR_RAG_EMBEDDING_API_KEY?: string; ASSESSOR_RAG_EMBEDDING_DIMENSIONS?: string; ASSESSOR_RAG_EMBEDDING_REQUEST_DIMENSIONS?: string; ASSESSOR_RAG_EMBEDDING_TIMEOUT_MS?: string; } export class EmbeddingClientError extends Error { constructor(readonly code: string, message: string) { super(message); this.name = 'EmbeddingClientError'; } } interface OpenAiCompatibleEmbeddingClientOptions { endpoint: URL; model: string; apiKey: string; dimensions: number; timeoutMs: number; requestDimensions?: boolean; fetcher?: typeof fetch; } export class OpenAiCompatibleEmbeddingClient implements TextEmbeddingClient { readonly model: string; readonly dimensions: number; private readonly endpoint: URL; private readonly apiKey: string; private readonly timeoutMs: number; private readonly requestDimensions: boolean; private readonly fetcher: typeof fetch; constructor(options: OpenAiCompatibleEmbeddingClientOptions) { this.endpoint = new URL(options.endpoint.href); this.model = options.model; this.apiKey = options.apiKey; this.dimensions = options.dimensions; this.timeoutMs = options.timeoutMs; this.requestDimensions = options.requestDimensions ?? false; this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis); } async embed(texts: string[]): Promise { if (!texts.length || texts.some(text => typeof text !== 'string' || !text.trim())) { throw new EmbeddingClientError('invalid_embedding_input', 'At least one non-empty embedding input is required.'); } let response: Response; try { response = await this.fetcher(this.endpoint, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(this.timeoutMs), headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, body: JSON.stringify({ model: this.model, input: texts, encoding_format: 'float', ...(this.requestDimensions ? { dimensions: this.dimensions } : {}) }) }); } catch { throw new EmbeddingClientError('embedding_unavailable', 'The configured embedding service could not be reached.'); } if (!response.ok) throw new EmbeddingClientError('embedding_http_error', `The embedding service returned HTTP ${response.status}.`); const body = await readBoundedJson(response, 32 * 1024 * 1024); if (!isObject(body) || !Array.isArray(body.data) || body.data.length !== texts.length) { throw new EmbeddingClientError('embedding_invalid_response', 'The embedding service returned an invalid result count.'); } const ordered = body.data.map((entry, responseIndex) => ({ entry, responseIndex })).sort((left, right) => { const leftIndex = isObject(left.entry) && Number.isInteger(left.entry.index) ? Number(left.entry.index) : left.responseIndex; const rightIndex = isObject(right.entry) && Number.isInteger(right.entry.index) ? Number(right.entry.index) : right.responseIndex; return leftIndex - rightIndex; }); return ordered.map(({ entry }) => { const vector = isObject(entry) && Array.isArray(entry.embedding) ? entry.embedding : undefined; if (!vector || vector.length !== this.dimensions || vector.some(value => typeof value !== 'number' || !Number.isFinite(value))) { throw new EmbeddingClientError('embedding_invalid_dimensions', `The embedding service must return ${this.dimensions} finite dimensions per input.`); } return vector as number[]; }); } } export function createEmbeddingClientFromEnvironment( environment: EmbeddingEnvironment = { ASSESSOR_RAG_EMBEDDING_BASE_URL: process.env.ASSESSOR_RAG_EMBEDDING_BASE_URL, ASSESSOR_RAG_EMBEDDING_MODEL: process.env.ASSESSOR_RAG_EMBEDDING_MODEL, ASSESSOR_RAG_EMBEDDING_API_KEY: process.env.ASSESSOR_RAG_EMBEDDING_API_KEY, ASSESSOR_RAG_EMBEDDING_DIMENSIONS: process.env.ASSESSOR_RAG_EMBEDDING_DIMENSIONS, ASSESSOR_RAG_EMBEDDING_REQUEST_DIMENSIONS: process.env.ASSESSOR_RAG_EMBEDDING_REQUEST_DIMENSIONS, ASSESSOR_RAG_EMBEDDING_TIMEOUT_MS: process.env.ASSESSOR_RAG_EMBEDDING_TIMEOUT_MS }, fetcher?: typeof fetch ): OpenAiCompatibleEmbeddingClient { const baseUrl = environment.ASSESSOR_RAG_EMBEDDING_BASE_URL?.trim(); const model = environment.ASSESSOR_RAG_EMBEDDING_MODEL?.trim(); const apiKey = environment.ASSESSOR_RAG_EMBEDDING_API_KEY?.trim(); if (!baseUrl || !model || !apiKey) { throw new Error('Regulatory RAG requires ASSESSOR_RAG_EMBEDDING_BASE_URL, ASSESSOR_RAG_EMBEDDING_MODEL, and ASSESSOR_RAG_EMBEDDING_API_KEY.'); } return new OpenAiCompatibleEmbeddingClient({ endpoint: embeddingEndpoint(baseUrl), model, apiKey, dimensions: boundedInteger(environment.ASSESSOR_RAG_EMBEDDING_DIMENSIONS, undefined, 64, 16_384, 'ASSESSOR_RAG_EMBEDDING_DIMENSIONS'), requestDimensions: booleanFlag(environment.ASSESSOR_RAG_EMBEDDING_REQUEST_DIMENSIONS, 'ASSESSOR_RAG_EMBEDDING_REQUEST_DIMENSIONS'), timeoutMs: boundedInteger(environment.ASSESSOR_RAG_EMBEDDING_TIMEOUT_MS, 60_000, 1_000, 300_000, 'ASSESSOR_RAG_EMBEDDING_TIMEOUT_MS'), fetcher }); } export function embeddingEndpoint(baseUrl: string): URL { let url: URL; try { url = new URL(baseUrl); } catch { throw new Error('ASSESSOR_RAG_EMBEDDING_BASE_URL must be an absolute HTTP(S) URL.'); } const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname.toLowerCase()); if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { throw new Error('ASSESSOR_RAG_EMBEDDING_BASE_URL must use HTTPS unless it targets loopback.'); } if (url.username || url.password || url.search || url.hash) { throw new Error('ASSESSOR_RAG_EMBEDDING_BASE_URL must not contain credentials, query parameters, or a fragment.'); } const path = url.pathname.replace(/\/+$/, ''); url.pathname = path.endsWith('/embeddings') ? path : path ? `${path}/embeddings` : '/v1/embeddings'; return url; } function booleanFlag(value: string | undefined, name: string): boolean { if (!value?.trim()) return false; if (value === 'true') return true; if (value === 'false') return false; throw new Error(`${name} must be true or false.`); } function boundedInteger(value: string | undefined, fallback: number | undefined, minimum: number, maximum: number, name: string): number { if (!value?.trim()) { if (fallback !== undefined) return fallback; throw new Error(`${name} is required.`); } const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`); return parsed; } async function readBoundedJson(response: Response, maximumBytes: number): Promise { const declared = Number(response.headers.get('content-length') ?? 0); if (declared > maximumBytes) throw new EmbeddingClientError('embedding_response_too_large', 'The embedding response exceeded the size limit.'); if (!response.body) throw new EmbeddingClientError('embedding_invalid_response', 'The embedding service returned an empty response.'); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > maximumBytes) { await reader.cancel(); throw new EmbeddingClientError('embedding_response_too_large', 'The embedding response exceeded the size limit.'); } chunks.push(value); } const merged = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } const text = new TextDecoder().decode(merged); try { return JSON.parse(text); } catch { throw new EmbeddingClientError('embedding_invalid_response', 'The embedding service returned invalid JSON.'); } } function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }