@vfarcic/dot-ai
Version:
AI-powered development productivity platform that enhances software development workflows through intelligent automation and AI-driven assistance
396 lines (395 loc) • 15.8 kB
JavaScript
"use strict";
/**
* Embedding Service
*
* Optional semantic search enhancement for pattern matching.
* Gracefully falls back to keyword-only search when embedding providers are not available.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.EmbeddingService = exports.VercelEmbeddingProvider = exports.EMBEDDING_PROVIDERS = void 0;
const amazon_bedrock_1 = require("@ai-sdk/amazon-bedrock");
const google_1 = require("@ai-sdk/google");
const openai_1 = require("@ai-sdk/openai");
const ai_1 = require("ai");
const ai_retry_config_1 = require("./ai-retry-config");
const circuit_breaker_1 = require("./circuit-breaker");
const tracing_1 = require("./tracing");
/**
* Module-level circuit breaker for embedding API calls.
* Shared across all EmbeddingService instances since they hit the same API.
* Opens after 3 consecutive failures, blocks for 30s before testing recovery.
*/
const embeddingCircuitBreaker = new circuit_breaker_1.CircuitBreaker('embedding-api', {
failureThreshold: 3, // Open after 3 consecutive failures
cooldownPeriodMs: 30000, // 30s cooldown before half-open
halfOpenMaxAttempts: 1, // Allow 1 test request in half-open
});
/**
* Supported embedding providers - single source of truth
*/
exports.EMBEDDING_PROVIDERS = [
'openai',
'google',
'amazon_bedrock',
];
/**
* Unified Vercel AI SDK Embedding Provider
* Supports OpenAI, Google, and Amazon Bedrock through Vercel AI SDK
*/
class VercelEmbeddingProvider {
providerType;
apiKey;
model;
dimensions;
available;
modelInstance;
constructor(config) {
this.providerType = config.provider;
this.available = false;
// Get API key based on provider
switch (this.providerType) {
case 'openai': {
this.apiKey =
config.apiKey ||
process.env.CUSTOM_EMBEDDINGS_API_KEY ||
process.env.OPENAI_API_KEY ||
'';
this.model =
config.model ||
process.env.EMBEDDINGS_MODEL ||
'text-embedding-3-small';
const envDimensions = process.env.EMBEDDINGS_DIMENSIONS
? parseInt(process.env.EMBEDDINGS_DIMENSIONS, 10)
: undefined;
this.dimensions =
config.dimensions ||
(Number.isFinite(envDimensions) ? envDimensions : 1536);
break;
}
case 'google':
this.apiKey = config.apiKey || process.env.GOOGLE_API_KEY || '';
this.model =
config.model ||
process.env.EMBEDDINGS_MODEL ||
'gemini-embedding-001';
this.dimensions = config.dimensions || 768;
break;
case 'amazon_bedrock':
// AWS SDK handles credentials automatically - no API key needed
this.apiKey = 'bedrock-uses-aws-credentials';
this.model =
config.model ||
process.env.EMBEDDINGS_MODEL ||
'amazon.titan-embed-text-v2:0';
this.dimensions = config.dimensions || 1024; // Titan v2 default
break;
}
if (!this.apiKey) {
this.available = false;
return;
}
try {
// Initialize model instance based on provider
switch (this.providerType) {
case 'openai': {
const baseURL = process.env.CUSTOM_EMBEDDINGS_BASE_URL;
const openai = (0, openai_1.createOpenAI)({
apiKey: this.apiKey,
...(baseURL && { baseURL }),
});
this.modelInstance = openai.textEmbedding(this.model);
break;
}
case 'google':
// Set environment variable that Google SDK expects
process.env.GOOGLE_GENERATIVE_AI_API_KEY = this.apiKey;
this.modelInstance = google_1.google.textEmbedding(this.model);
break;
case 'amazon_bedrock': {
// AWS SDK automatically uses credential chain:
// 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)
// 2. ~/.aws/credentials file
// 3. IAM roles (EC2 instance profiles, ECS roles, EKS service accounts)
const bedrock = (0, amazon_bedrock_1.createAmazonBedrock)({
region: process.env.AWS_REGION || 'us-east-1',
});
this.modelInstance = bedrock.textEmbeddingModel(this.model);
break;
}
}
this.available = true;
}
catch {
this.available = false;
}
}
async generateEmbedding(text) {
if (!this.isAvailable()) {
throw new Error(`${this.providerType} embedding provider not available`);
}
if (!text || text.trim().length === 0) {
throw new Error('Text cannot be empty for embedding generation');
}
// Wrap embed() call with OpenTelemetry tracing
return await (0, tracing_1.withAITracing)({
provider: this.providerType,
model: this.model,
operation: 'embeddings',
}, async () => {
try {
// Execute through circuit breaker to prevent cascading failures
return await embeddingCircuitBreaker.execute(async () => {
const embedOptions = {
model: this.modelInstance,
value: text.trim(),
// Configurable retry budget; embeddings default to higher resilience.
maxRetries: (0, ai_retry_config_1.getMaxRetries)('embeddings'),
};
// Add Google-specific options
if (this.providerType === 'google') {
embedOptions.providerOptions = {
google: {
outputDimensionality: this.dimensions,
taskType: 'SEMANTIC_SIMILARITY',
},
};
}
const result = await (0, ai_1.embed)(embedOptions);
return result.embedding;
});
}
catch (error) {
// Convert CircuitOpenError to descriptive message
if (error instanceof circuit_breaker_1.CircuitOpenError) {
throw new Error(`Embedding API circuit open: ${error.message}`, {
cause: error,
});
}
if (error instanceof Error) {
throw new Error(`${this.providerType} embedding failed: ${error.message}`, { cause: error });
}
throw new Error(`${this.providerType} embedding failed: ${String(error)}`, { cause: error });
}
}, embedding => ({
embeddingCount: 1,
embeddingDimensions: embedding.length,
}));
}
async generateEmbeddings(texts) {
if (!this.isAvailable()) {
throw new Error(`${this.providerType} embedding provider not available`);
}
if (!texts || texts.length === 0) {
return [];
}
const validTexts = texts.map(t => t?.trim()).filter(t => t && t.length > 0);
if (validTexts.length === 0) {
return [];
}
// Wrap batch embed calls with OpenTelemetry tracing
return await (0, tracing_1.withAITracing)({
provider: this.providerType,
model: this.model,
operation: 'embeddings',
}, async () => {
try {
// Execute through circuit breaker to prevent cascading failures
return await embeddingCircuitBreaker.execute(async () => {
// Single batch request via SDK; handles chunking internally.
const embedManyOptions = {
model: this.modelInstance,
values: validTexts,
// Configurable retry budget; embeddings default to higher resilience.
maxRetries: (0, ai_retry_config_1.getMaxRetries)('embeddings'),
};
// Apply Google-specific options once for the whole batch.
if (this.providerType === 'google') {
embedManyOptions.providerOptions = {
google: {
outputDimensionality: this.dimensions,
taskType: 'SEMANTIC_SIMILARITY',
},
};
}
const result = await (0, ai_1.embedMany)(embedManyOptions);
return result.embeddings;
});
}
catch (error) {
// Convert CircuitOpenError to descriptive message
if (error instanceof circuit_breaker_1.CircuitOpenError) {
throw new Error(`Embedding API circuit open: ${error.message}`, {
cause: error,
});
}
if (error instanceof Error) {
throw new Error(`${this.providerType} batch embedding failed: ${error.message}`, { cause: error });
}
throw new Error(`${this.providerType} batch embedding failed: ${String(error)}`, { cause: error });
}
}, embeddings => ({
embeddingCount: embeddings.length,
embeddingDimensions: embeddings[0]?.length || this.dimensions,
}));
}
isAvailable() {
return this.available;
}
getDimensions() {
return this.dimensions;
}
getModel() {
return this.model;
}
getProviderType() {
return this.providerType;
}
}
exports.VercelEmbeddingProvider = VercelEmbeddingProvider;
/**
* Factory function to create embedding provider based on configuration
*/
function createEmbeddingProvider(config = {}) {
const providerType = (config.provider ||
process.env.EMBEDDINGS_PROVIDER ||
'openai').toLowerCase();
// Validate provider type using centralized list
if (!exports.EMBEDDING_PROVIDERS.includes(providerType)) {
console.warn(`Unknown embedding provider: ${providerType}, falling back to openai`);
return createEmbeddingProvider({ ...config, provider: 'openai' });
}
try {
const provider = new VercelEmbeddingProvider({
...config,
provider: providerType,
});
return provider.isAvailable() ? provider : null;
}
catch (error) {
console.error(`Failed to create ${providerType} embedding provider:`, error);
return null;
}
}
/**
* Main Embedding Service
* Provides optional semantic search capabilities with graceful degradation
*/
class EmbeddingService {
provider;
lastBatchFailureLogTime;
suppressedBatchFailureCount = 0;
static BATCH_FAILURE_LOG_INTERVAL_MS = 30000;
constructor(config = {}) {
// Use factory to initialize appropriate provider
this.provider = createEmbeddingProvider(config);
}
/**
* Generate embedding for text
* Throws error if embeddings not available or generation fails
*/
async generateEmbedding(text) {
if (!this.isAvailable()) {
throw new Error('Embedding service not available');
}
try {
return await this.provider.generateEmbedding(text);
}
catch (error) {
// Throw error immediately - no silent fallback
throw new Error(`Embedding generation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
}
}
/**
* Generate embeddings for multiple texts (optional enhancement)
* Returns empty array if embeddings not available
*/
async generateEmbeddings(texts) {
if (!this.isAvailable()) {
return [];
}
try {
return await this.provider.generateEmbeddings(texts);
}
catch (error) {
// Rate-limit fallback warnings to avoid log spam during sustained outages
const now = Date.now();
if (!this.lastBatchFailureLogTime ||
now - this.lastBatchFailureLogTime >=
EmbeddingService.BATCH_FAILURE_LOG_INTERVAL_MS) {
const suppressed = this.suppressedBatchFailureCount;
this.suppressedBatchFailureCount = 0;
this.lastBatchFailureLogTime = now;
console.warn('Batch embedding generation failed, falling back to keyword search:', error instanceof Error ? error.message : String(error), suppressed > 0 ? `(${suppressed} similar warnings suppressed)` : '');
}
else {
this.suppressedBatchFailureCount++;
}
return [];
}
}
/**
* Check if semantic search is available
*/
isAvailable() {
return this.provider !== null && this.provider.isAvailable();
}
/**
* Get embedding dimensions (if available)
*/
getDimensions() {
return this.provider?.getDimensions() || 1536;
}
/**
* Get status information for debugging/logging
*/
getStatus() {
if (this.isAvailable()) {
// Get provider type from VercelEmbeddingProvider
const providerName = this.provider.getProviderType?.() ||
'unknown';
const isCustomEndpoint = providerName === 'openai' && !!process.env.CUSTOM_EMBEDDINGS_BASE_URL;
return {
available: true,
provider: isCustomEndpoint ? 'custom' : providerName,
model: isCustomEndpoint && !process.env.EMBEDDINGS_MODEL
? 'custom'
: this.provider.getModel(),
dimensions: this.provider.getDimensions(),
};
}
const requestedProvider = process.env.EMBEDDINGS_PROVIDER || 'openai';
const keyMap = {
openai: 'OPENAI_API_KEY',
google: 'GOOGLE_API_KEY',
amazon_bedrock: 'AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)',
};
const requiredKey = keyMap[requestedProvider] || 'OPENAI_API_KEY';
return {
available: false,
provider: null,
reason: `${requiredKey} not set - vector operations will fail`,
};
}
/**
* Create searchable text from pattern data
*/
createPatternSearchText(pattern) {
return [
pattern.description,
...pattern.triggers,
pattern.rationale,
// Include resource types for better semantic matching
...pattern.suggestedResources.map(r => `kubernetes ${r.toLowerCase()}`),
]
.join(' ')
.trim();
}
/**
* Get circuit breaker statistics for monitoring
* Returns null if embedding service is not available
*/
getCircuitBreakerStats() {
return embeddingCircuitBreaker.getStats();
}
}
exports.EmbeddingService = EmbeddingService;