Files
gims/bin/lib/ai/providers.js
T

294 lines
10 KiB
JavaScript

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.cache = new Map();
this.maxCacheSize = 100;
}
resolveProvider(preference = 'auto') {
if (preference === 'none') return 'none';
// Check if preferred provider's key is available
if (preference === 'openai' && process.env.OPENAI_API_KEY) return 'openai';
if (preference === 'gemini' && process.env.GEMINI_API_KEY) return 'gemini';
if (preference === 'groq' && process.env.GROQ_API_KEY) return 'groq';
// Fallback: try any available provider (priority: Gemini → OpenAI → Groq)
if (process.env.GEMINI_API_KEY) return 'gemini';
if (process.env.OPENAI_API_KEY) return 'openai';
if (process.env.GROQ_API_KEY) return 'groq';
return 'none';
}
getDefaultModel(provider) {
const defaults = {
'gemini': 'gemini-3-flash-preview', // Latest Gemini model
'openai': 'gpt-5.2-2025-12-11', // Latest GPT model
'groq': 'groq/compound' // Latest Groq model
};
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;
return this.cache.get(cacheKey);
}
setCache(cacheKey, result, usedLocal = false) {
if (!this.config.cacheEnabled) return;
if (this.cache.size >= this.maxCacheSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(cacheKey, {
result,
usedLocal,
timestamp: Date.now()
});
}
async generateWithProvider(provider, prompt, options = {}) {
const { model = '', temperature = 0.3, maxTokens = 200 } = 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) {
throw new Error(`${provider} generation failed: ${error.message}`);
}
}
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,
});
return (await response.response.text()).trim();
}
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;
// Check cache first
const cacheKey = this.getCacheKey(diff, { 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(diff, options);
this.setCache(cacheKey, result, true);
return { message: result, usedLocal: true };
}
const prompt = this.buildPrompt(diff, { 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 };