const fs = require('fs'); const path = require('path'); const { OpenAI } = require('openai'); const { GoogleGenAI } = require('@google/genai'); const { Progress } = require('../utils/progress'); const { color } = require('../utils/colors'); /** * Enhanced AI provider management with caching and fallbacks */ class AIProviderManager { constructor(config = {}) { this.config = config; this.cacheFile = path.join( process.env.HOME || process.env.USERPROFILE || process.cwd(), '.gims', 'cache.json' ); } getDefaultModel(provider) { const defaults = { 'gemini': 'gemini-2.0-flash', 'openai': 'gpt-4o-mini', 'groq': 'llama-3.3-70b-versatile' }; return defaults[provider] || ''; } getCacheKey(prompt, options) { const crypto = require('crypto'); const key = JSON.stringify({ prompt: prompt.substring(0, 1000), options }); return crypto.createHash('md5').update(key).digest('hex'); } getFromCache(cacheKey) { if (!this.config.cacheEnabled) return null; try { if (!fs.existsSync(this.cacheFile)) return null; const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf8')); return data[cacheKey] || null; } catch { return null; } } setCache(cacheKey, result, usedLocal = false) { if (!this.config.cacheEnabled) return; try { const dir = path.dirname(this.cacheFile); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); let data = {}; if (fs.existsSync(this.cacheFile)) { try { data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf8')); } catch {} } const keys = Object.keys(data); if (keys.length >= 100) { const oldest = keys.sort((a, b) => (data[a].timestamp || 0) - (data[b].timestamp || 0)); oldest.slice(0, keys.length - 99).forEach(k => delete data[k]); } data[cacheKey] = { result, usedLocal, timestamp: Date.now() }; fs.writeFileSync(this.cacheFile, JSON.stringify(data)); } catch {} } async generateWithProvider(provider, prompt, options = {}) { const { model = '' } = options; try { switch (provider) { case 'gemini': return await this.generateWithGemini(prompt, model || this.getDefaultModel('gemini'), options); case 'openai': return await this.generateWithOpenAI(prompt, model || this.getDefaultModel('openai'), options); case 'groq': return await this.generateWithGroq(prompt, model || this.getDefaultModel('groq'), options); default: throw new Error(`Unknown provider: ${provider}`); } } catch (error) { const msg = error.message || ''; if (msg.includes('429') || /rate.?limit/i.test(msg)) { throw new Error(`${provider} rate limit hit — try again in a moment`); } if (msg.includes('401') || /invalid.api.key|api_key/i.test(msg)) { throw new Error(`${provider} API key invalid — run: g setup --api-key ${provider}`); } throw new Error(`${provider} generation failed: ${msg}`); } } async generateWithGemini(prompt, model, options) { const genai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); const actualModel = model || this.getDefaultModel('gemini'); const response = await genai.models.generateContent({ model: actualModel, contents: prompt, }); // Handle @google/genai v1.5+ raw response structure if (response && response.candidates && response.candidates.length > 0) { const parts = response.candidates[0].content.parts; return parts.map(p => p.text || '').join('').trim(); } return ''; } async generateWithOpenAI(prompt, model, options) { const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const actualModel = model || this.getDefaultModel('openai'); const response = await openai.chat.completions.create({ model: actualModel, messages: [{ role: 'user', content: prompt }], temperature: options.temperature || 0.3, max_tokens: options.maxTokens || 200, }); return (response.choices[0]?.message?.content || '').trim(); } async generateWithGroq(prompt, model, options) { const groq = new OpenAI({ apiKey: process.env.GROQ_API_KEY, baseURL: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1' }); const actualModel = model || this.getDefaultModel('groq'); const response = await groq.chat.completions.create({ model: actualModel, messages: [{ role: 'user', content: prompt }], temperature: options.temperature || 0.3, max_tokens: options.maxTokens || 200, }); return (response.choices[0]?.message?.content || '').trim(); } async generateCommitMessage(diff, options = {}) { const { provider: preferredProvider = this.config.provider || 'auto', conventional = this.config.conventional || false, body = false, verbose = false } = options; // Truncate oversized diffs before sending to AI const maxDiffSize = this.config.maxDiffSize || 100000; const truncatedDiff = diff.length > maxDiffSize ? diff.substring(0, maxDiffSize) + '\n... (diff truncated for AI)' : diff; // Check cache first const cacheKey = this.getCacheKey(truncatedDiff, { conventional, body }); const cached = this.getFromCache(cacheKey); if (cached && Date.now() - cached.timestamp < 3600000) { // 1 hour cache if (verbose) Progress.info('Using cached result'); return { message: cached.result, usedLocal: cached.usedLocal || false }; } const providerChain = this.buildProviderChain(preferredProvider); for (const provider of providerChain) { try { if (provider === 'local') { const result = await this.generateLocalHeuristic(truncatedDiff, options); this.setCache(cacheKey, result, true); return { message: result, usedLocal: true }; } const prompt = this.buildPrompt(truncatedDiff, { conventional, body }); const result = await this.generateWithProvider(provider, prompt, options); const cleaned = this.cleanCommitMessage(result, { body }); this.setCache(cacheKey, cleaned, false); return { message: cleaned, usedLocal: false }; } catch (error) { if (verbose) Progress.warning(`${provider} failed: ${error.message}`); continue; } } // Final fallback const fallback = 'Update project files'; this.setCache(cacheKey, fallback, true); return { message: fallback, usedLocal: true }; } buildProviderChain(preferred) { const available = []; // First, try the preferred provider if its key is available if (preferred !== 'auto' && preferred !== 'none') { if (preferred === 'gemini' && process.env.GEMINI_API_KEY) available.push('gemini'); else if (preferred === 'openai' && process.env.OPENAI_API_KEY) available.push('openai'); else if (preferred === 'groq' && process.env.GROQ_API_KEY) available.push('groq'); } // Then add all other available providers as fallbacks if (process.env.GEMINI_API_KEY && !available.includes('gemini')) available.push('gemini'); if (process.env.OPENAI_API_KEY && !available.includes('openai')) available.push('openai'); if (process.env.GROQ_API_KEY && !available.includes('groq')) available.push('groq'); // Local heuristics as final fallback available.push('local'); return available; } buildPrompt(diff, options) { const { conventional, body } = options; const style = conventional ? 'Use Conventional Commits format (e.g., feat:, fix:, chore:) for the subject.' : 'Subject must be a single short line.'; const bodyInstr = body ? 'Provide a short subject line followed by an optional body separated by a blank line.' : 'Return only a short subject line without extra quotes.'; return `Write a concise git commit message for these changes:\n${diff}\n\n${style} ${bodyInstr}`; } cleanCommitMessage(message, options = {}) { if (!message) return 'Update project code'; // Remove markdown formatting let cleaned = message .replace(/```[\s\S]*?```/g, '') .replace(/`([^`]+)`/g, '$1') .replace(/^\s*[-*+]\s*/gm, '') .replace(/^\s*\d+\.\s*/gm, '') .replace(/^\s*#+\s*/gm, '') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/[\u{1F300}-\u{1FAFF}]/gu, '') .replace(/[\t\r]+/g, ' ') .trim(); const lines = cleaned.split('\n').map(l => l.trim()).filter(Boolean); let subject = (lines[0] || '').replace(/\s{2,}/g, ' ').replace(/[\s:,.!;]+$/g, '').trim(); if (subject.length === 0) subject = 'Update project code'; // No length restriction - allow AI to generate full commit messages if (!options.body) return subject; const bodyLines = lines.slice(1).filter(l => l.length > 0); const bodyText = bodyLines.join('\n').trim(); return bodyText ? `${subject}\n\n${bodyText}` : subject; } async generateLocalHeuristic(diff, options) { // This would need access to git status - simplified version const { conventional = false } = options; // Analyze diff for patterns const lines = diff.split('\n'); const additions = lines.filter(l => l.startsWith('+')).length; const deletions = lines.filter(l => l.startsWith('-')).length; const files = (diff.match(/diff --git/g) || []).length; let type = 'chore'; let subject = 'update files'; if (additions > deletions * 2) { type = 'feat'; subject = files === 1 ? 'add new functionality' : `add features to ${files} files`; } else if (deletions > additions * 2) { type = 'chore'; subject = files === 1 ? 'remove unused code' : `clean up ${files} files`; } else if (diff.includes('test') || diff.includes('spec')) { type = 'test'; subject = 'update tests'; } else if (diff.includes('README') || diff.includes('doc')) { type = 'docs'; subject = 'update documentation'; } return conventional ? `${type}: ${subject}` : subject.charAt(0).toUpperCase() + subject.slice(1); } async generateMultipleSuggestions(diff, options = {}, count = 3) { const suggestions = []; const baseOptions = { ...options }; // Generate different styles const variants = [ { ...baseOptions, conventional: false }, { ...baseOptions, conventional: true }, { ...baseOptions, conventional: true, body: true } ]; for (let i = 0; i < Math.min(count, variants.length); i++) { try { const result = await this.generateCommitMessage(diff, variants[i]); // Extract message string from result object const message = result.message || result; if (message && !suggestions.includes(message)) { suggestions.push(message); } } catch (error) { // Skip failed generations } } // Ensure we have at least one suggestion if (suggestions.length === 0) { suggestions.push('Update project files'); } return suggestions; } } module.exports = { AIProviderManager };