UNPKG

@entro314labs/ai-changelog-generator

Version:

AI-powered changelog generator with MCP server support - works with most providers, online and local models

397 lines (396 loc) 16.5 kB
import process from 'node:process'; import { GoogleGenAI } from '@google/genai'; import { BaseProvider } from '../core/base-provider.js'; import { applyMixins } from '../utils/base-provider-helpers.js'; import { buildClientOptions } from '../utils/provider-utils.js'; // Cache for model instances to avoid recreating them (with size limit to prevent memory leaks) const modelCache = new Map(); const MAX_MODEL_CACHE_SIZE = 50; function addToModelCache(key, value) { if (modelCache.size >= MAX_MODEL_CACHE_SIZE) { // Remove oldest entries (first added) const firstKey = modelCache.keys().next().value; modelCache.delete(firstKey); } modelCache.set(key, value); } class VertexAIProvider extends BaseProvider { constructor(config) { super(config); this.client = null; this.generativeModel = null; if (this.isAvailable()) { this.initializeClient(); } } async initializeClient() { try { const clientOptions = buildClientOptions(this.getProviderConfig(), { location: 'us-central1', apiVersion: 'v1', }); const vertexOptions = { vertexai: true, project: clientOptions.projectId, location: clientOptions.location, apiVersion: clientOptions.apiVersion, httpOptions: { timeout: 60000, }, }; if (clientOptions.keyFile || clientOptions.credentials) { vertexOptions.googleAuthOptions = { scopes: ['https://www.googleapis.com/auth/cloud-platform'], ...(typeof clientOptions.keyFile === 'string' ? { keyFilename: clientOptions.keyFile } : {}), ...(typeof clientOptions.credentials === 'string' ? { credentials: JSON.parse(clientOptions.credentials) } : {}), }; } if (typeof clientOptions.apiEndpoint === 'string') { vertexOptions.httpOptions = { ...vertexOptions.httpOptions, baseUrl: clientOptions.apiEndpoint, }; } this.client = new GoogleGenAI(vertexOptions); this.generativeModel = null; return true; } catch (error) { console.error('Failed to initialize Vertex AI client:', error); this.client = null; this.generativeModel = null; return false; } } getModelInstance(modelName, options = {}) { // Create a cache key based on model name and options const cacheKey = `${modelName}-${JSON.stringify(options)}`; // Check if we already have this model instance cached if (modelCache.has(cacheKey)) { return modelCache.get(cacheKey); } // Default generation config const generationConfig = { temperature: options.temperature || this.config.VERTEX_TEMPERATURE || 0.7, topP: options.top_p || this.config.VERTEX_TOP_P || 0.95, topK: options.top_k || this.config.VERTEX_TOP_K || 40, maxOutputTokens: options.max_tokens || this.config.VERTEX_MAX_TOKENS || 8192, }; // Add stop sequences if provided if (options.stop && options.stop.length > 0) { generationConfig.stopSequences = options.stop; } const modelInstance = { model: modelName, config: { ...generationConfig, safetySettings: this.getSafetySettings(), }, }; // Cache the model instance with size limit addToModelCache(cacheKey, modelInstance); return modelInstance; } getSafetySettings() { // Configure safety settings based on environment variables or defaults const safetySettings = [ { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: this.config.VERTEX_SAFETY_HATE_SPEECH || 'BLOCK_MEDIUM_AND_ABOVE', }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: this.config.VERTEX_SAFETY_DANGEROUS || 'BLOCK_MEDIUM_AND_ABOVE', }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: this.config.VERTEX_SAFETY_SEXUALLY_EXPLICIT || 'BLOCK_MEDIUM_AND_ABOVE', }, { category: 'HARM_CATEGORY_HARASSMENT', threshold: this.config.VERTEX_SAFETY_HARASSMENT || 'BLOCK_MEDIUM_AND_ABOVE', }, ]; return safetySettings; } getName() { return 'vertex'; } isAvailable() { // Check if we have the required configuration for Vertex AI return !!(this.config.VERTEX_PROJECT_ID && (this.config.VERTEX_KEY_FILE || this.config.VERTEX_CREDENTIALS || process.env.GOOGLE_APPLICATION_CREDENTIALS)); } async generateCompletion(messages, options = {}) { if (!this.isAvailable()) { return this.handleProviderError(new Error('Vertex AI provider is not configured'), 'generate_completion'); } // Initialize client if not already done if (!this.client) { await this.initializeClient(); if (!this.client) { return this.handleProviderError(new Error('Failed to initialize Vertex AI client'), 'generate_completion'); } } const modelConfig = this.getProviderModelConfig(); const modelName = options.model || modelConfig.standardModel; try { // Get or create model request with the specified options const modelRequest = this.getModelInstance(modelName, options); // Convert messages to Vertex AI format const formattedMessages = await this.formatMessages(messages); // Add function calling if provided and the model supports it let tools = null; if (options.tools && Array.isArray(options.tools) && options.tools.length > 0) { const capabilities = this.getCapabilities(modelName); if (capabilities.function_calling) { tools = this.formatTools(options.tools); } } // Handle streaming if requested if (options.stream && typeof options.onUpdate === 'function') { const streamResult = await this.client.models.generateContentStream({ model: modelRequest.model, contents: formattedMessages, config: { ...modelRequest.config, ...(tools ? { tools } : {}), }, }); let fullContent = ''; for await (const chunk of streamResult) { const chunkContent = chunk.text || ''; fullContent += chunkContent; options.onUpdate({ content: chunkContent, done: false, }); } // Signal completion options.onUpdate({ content: '', done: true, }); // Return the full result return { content: fullContent, model: modelName, }; } // Non-streaming request const response = await this.client.models.generateContent({ model: modelRequest.model, contents: formattedMessages, config: { ...modelRequest.config, ...(tools ? { tools } : {}), }, }); const responseText = response.text || ''; // Handle function calls if present let functionCalls = null; if (response.functionCalls && response.functionCalls.length > 0) { functionCalls = response.functionCalls.map((call) => ({ name: call.name, arguments: call.args ?? {}, })); } return { content: responseText, model: modelName, function_call: functionCalls ? functionCalls[0] : undefined, function_calls: functionCalls, }; } catch (error) { // Handle rate limiting with exponential backoff if (error.message && (error.message.includes('quota') || error.message.includes('rate') || error.message.includes('limit'))) { const retryCount = options.retryCount || 0; if (retryCount < 3) { const delay = 2 ** retryCount * 1000; // Exponential backoff: 1s, 2s, 4s console.warn(`Rate limit hit, retrying in ${delay}ms...`); return new Promise((resolve) => { setTimeout(async () => { const retryOptions = { ...options, retryCount: retryCount + 1 }; const result = await this.generateCompletion(messages, retryOptions); resolve(result); }, delay); }); } } // If model not found, try with a fallback model if (error.message && (error.message.includes('not found') || error.message.includes('invalid model'))) { const fallbackModels = this.getSuggestedModels(modelName); if (fallbackModels.length > 0 && !options.triedFallback) { console.warn(`Model ${modelName} not found, trying fallback model: ${fallbackModels[0]}`); return this.generateCompletion(messages, { ...options, model: fallbackModels[0], triedFallback: true, }); } } return this.handleProviderError(error, 'generate_completion', { model: options.model }); } } async formatMessages(messages) { // Convert messages to Vertex AI format for the new SDK const formattedMessages = []; for (const message of messages) { const role = message.role === 'assistant' ? 'model' : message.role; // Handle different content formats const parts = []; // If content is a string, convert to text part if (typeof message.content === 'string') { parts.push({ text: message.content }); } // If content is an array (multimodal), convert each part else if (Array.isArray(message.content)) { for (const part of message.content) { if (part.type === 'text') { parts.push({ text: part.text }); } else if (part.type === 'image_url') { // Handle inline image data if (part.image_url.url.startsWith('data:image/')) { const imageData = await this.getImageData(part.image_url.url); parts.push({ inlineData: { data: imageData, mimeType: 'image/jpeg' } }); } // Handle remote image URL - for remote URLs, we now fetch and convert else { try { const imageData = await this.getImageData(part.image_url.url); parts.push({ inlineData: { data: imageData, mimeType: 'image/jpeg' } }); } catch { // Fallback to fileData if remote fetch fails parts.push({ fileData: { mimeType: 'image/jpeg', fileUri: part.image_url.url } }); } } } } } formattedMessages.push({ role, parts, }); } return formattedMessages; } async getImageData(imageUrl) { // For base64 data URLs if (imageUrl.startsWith('data:')) { return imageUrl.split(',')[1]; } // For regular URLs, fetch the image and convert to base64 try { const response = await fetch(imageUrl); if (!response.ok) { throw new Error(`Failed to fetch image: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); return buffer.toString('base64'); } catch (error) { throw new Error(`Remote image fetching failed: ${error.message}`, { cause: error }); } } formatTools(tools) { const functionDeclarations = []; for (const tool of tools) { if (tool.type === 'function' && tool.function) { functionDeclarations.push({ name: tool.function.name, description: tool.function.description || '', parameters: tool.function.parameters || {}, }); } } return { functionDeclarations }; } estimateTokenCount(text) { // Simple estimation: ~4 characters per token return Math.ceil(text.length / 4); } // Vertex AI-specific helper methods getRetryDelay(attempt) { return 2 ** attempt * 1000; // Exponential backoff } // Vertex AI-specific method for testing model availability async testModel(modelName) { try { const modelRequest = this.getModelInstance(modelName, { max_tokens: 10 }); await this.client.models.generateContent({ model: modelRequest.model, contents: [{ role: 'user', parts: [{ text: 'Test' }] }], config: { ...modelRequest.config, maxOutputTokens: 10, temperature: 0, }, }); return { success: true, model: modelName, project: this.config.VERTEX_PROJECT_ID, location: this.config.VERTEX_LOCATION || 'us-central1', }; } catch (error) { return { success: false, error: error.message, model: modelName, }; } } getAvailableModels() { return [ { id: 'gemini-2.5-exp', name: 'Gemini 2.5 Experimental', contextWindow: 1048576, maxOutput: 8192, inputCost: 0.00000125, outputCost: 0.000005, features: ['text', 'vision', 'tools', 'reasoning'], description: 'Latest Gemini model via Vertex AI', }, { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', contextWindow: 2097152, maxOutput: 8192, inputCost: 0.00000125, outputCost: 0.000005, features: ['text', 'vision', 'tools'], description: 'Previous generation - consider upgrading to Gemini 3 or 2.5', deprecated: true, }, { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', contextWindow: 1048576, maxOutput: 8192, inputCost: 0.00000075, outputCost: 0.000003, features: ['text', 'vision', 'tools'], description: 'Previous generation - consider upgrading to Gemini 3 or 2.5', deprecated: true, }, ]; } } // Apply mixins to add standard provider functionality export default applyMixins(VertexAIProvider, 'vertex');