/** * Provider-agnostic embedding client for autonomous memory. * * Factory pattern mirrors host's `getSharedSummaryBedrockClient()` in * `api/server/controllers/agents/client.js` — lazy init, shared instance, * testable reset. Same AWS credential chain as host's existing Bedrock * calls, so no new IAM policy or secret. */ import { BedrockRuntimeClient, InvokeModelCommand, } from '@aws-sdk/client-bedrock-runtime'; import { DEFAULT_MEMORY_DIMENSIONS, DEFAULT_MEMORY_MODEL, DEFAULT_MEMORY_PROVIDER, } from './constants'; export type EmbeddingProviderKind = 'bedrock' | 'openai'; export interface EmbeddingProvider { readonly kind: EmbeddingProviderKind; readonly model: string; readonly dimensions: number; embed(text: string): Promise; embedBatch(texts: string[]): Promise; } interface ResolvedEmbedderConfig { provider: EmbeddingProviderKind; model: string; dimensions: number; } /** * Read embedder config from environment. Called once per factory invocation; * fresh reads make testing override-friendly via {@link resetMemoryEmbedder}. */ function resolveConfig(): ResolvedEmbedderConfig { const rawProvider = ( process.env.MEMORY_EMBEDDINGS_PROVIDER ?? DEFAULT_MEMORY_PROVIDER ).toLowerCase(); if (rawProvider !== 'bedrock' && rawProvider !== 'openai') { throw new Error( `Unsupported MEMORY_EMBEDDINGS_PROVIDER: "${rawProvider}". Supported: bedrock, openai.` ); } const model = process.env.MEMORY_EMBEDDINGS_MODEL ?? DEFAULT_MEMORY_MODEL; const dimensions = Number(process.env.MEMORY_EMBEDDINGS_DIMENSIONS) || DEFAULT_MEMORY_DIMENSIONS; return { provider: rawProvider, model, dimensions }; } class BedrockEmbedder implements EmbeddingProvider { readonly kind = 'bedrock' as const; readonly model: string; readonly dimensions: number; private client: BedrockRuntimeClient; constructor(params: { model: string; dimensions: number }) { this.model = params.model; this.dimensions = params.dimensions; const region = process.env.BEDROCK_AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; // Default credential provider chain — identical to host's existing // Bedrock client. In ECS this resolves to the task role. this.client = new BedrockRuntimeClient({ region }); } async embed(text: string): Promise { const input = text.trim(); if (!input) { throw new Error('Cannot embed empty text'); } const body = { inputText: input, dimensions: this.dimensions, normalize: true, }; const command = new InvokeModelCommand({ modelId: this.model, contentType: 'application/json', accept: 'application/json', body: new TextEncoder().encode(JSON.stringify(body)), }); const response = await this.client.send(command); const decoded = JSON.parse(new TextDecoder().decode(response.body)) as { embedding: number[]; }; if (!Array.isArray(decoded.embedding)) { throw new Error('Bedrock embedding response missing `embedding` field'); } return decoded.embedding; } async embedBatch(texts: string[]): Promise { // Titan embed v2 has no native batch API — sequential calls are the // documented pattern. Parallelism here can trip per-account TPS ceilings, // so we keep it serial. For Phase 4 we can introduce a bounded queue. const out: number[][] = []; for (const text of texts) { out.push(await this.embed(text)); } return out; } } class OpenAIEmbedder implements EmbeddingProvider { readonly kind = 'openai' as const; readonly model: string; readonly dimensions: number; private apiKey: string; private baseUrl: string; constructor(params: { model: string; dimensions: number }) { this.model = params.model; this.dimensions = params.dimensions; const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error( 'OPENAI_API_KEY is not set — required when MEMORY_EMBEDDINGS_PROVIDER=openai' ); } this.apiKey = apiKey; this.baseUrl = process.env.OPENAI_BASEURL ?? 'https://api.openai.com/v1'; } private async call(texts: string[]): Promise { const res = await fetch(`${this.baseUrl}/embeddings`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, }, body: JSON.stringify({ model: this.model, input: texts }), }); if (!res.ok) { const errText = await res.text(); throw new Error(`OpenAI embeddings ${res.status}: ${errText}`); } const json = (await res.json()) as { data: Array<{ embedding: number[] }> }; return json.data.map((d) => d.embedding); } async embed(text: string): Promise { const [vec] = await this.call([text]); return vec; } async embedBatch(texts: string[]): Promise { if (texts.length === 0) return []; return this.call(texts); } } let sharedEmbedder: EmbeddingProvider | null = null; /** * Lazy singleton accessor. Same idiom as host's * `getSharedSummaryBedrockClient()`. */ export function getMemoryEmbedder(): EmbeddingProvider { if (sharedEmbedder) return sharedEmbedder; const cfg = resolveConfig(); switch (cfg.provider) { case 'bedrock': sharedEmbedder = new BedrockEmbedder({ model: cfg.model, dimensions: cfg.dimensions, }); break; case 'openai': sharedEmbedder = new OpenAIEmbedder({ model: cfg.model, dimensions: cfg.dimensions, }); break; } return sharedEmbedder; } /** Test hook — drops the cached embedder so the next call re-reads env. */ export function resetMemoryEmbedder(): void { sharedEmbedder = null; }