UNPKG

pushscript

Version:

AI-powered Git workflow automation with conventional commits, vulnerability scanning, and multi-provider LLM support

406 lines (350 loc) 14.7 kB
/** * LLM provider configuration for PushScript * Configures multiple AI providers for commit message generation */ import fetch from 'node-fetch'; import config from './utils/config.js'; import { GeminiTokenManager } from './token-utils.js'; // Provider configurations with default models and API endpoints export const LLM_PROVIDERS = { groq: { apiEndpoint: 'https://api.groq.com/openai/v1/chat/completions', defaultModel: 'llama-3.3-70b-versatile', headerTemplate: (apiKey) => ({ 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }), responseHandler: (data) => data.choices[0].message.content.trim(), requestBuilder: (prompt, model, maxTokens) => ({ // Groq requires the model parameter, so use default if not specified model: model || 'llama-3.3-70b-versatile', messages: [ { role: 'system', content: `You are a senior software developer. Create a concise, conventional commit message that strictly follows the Conventional Commits format: <type>(<scope>): <description> Valid types: feat, fix, docs, style, refactor, perf, test, chore Example formats: - feat(ui): add new button component - fix(auth): resolve login issue with expired tokens - docs(readme): update installation instructions Use lowercase for type and scope. Keep the first line under 80 characters.` }, { role: 'user', content: prompt } ], temperature: 0.3, max_tokens: maxTokens }) }, openai: { apiEndpoint: 'https://api.openai.com/v1/chat/completions', defaultModel: 'gpt-4o', headerTemplate: (apiKey) => ({ 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }), responseHandler: (data) => data.choices[0].message.content.trim(), requestBuilder: (prompt, model, maxTokens) => ({ // OpenAI may also require model parameter, so use default if not specified model: model || 'gpt-4o', messages: [ { role: 'system', content: `You are a senior software developer. Create a concise, conventional commit message that strictly follows the Conventional Commits format: <type>(<scope>): <description> Valid types: feat, fix, docs, style, refactor, perf, test, chore Example formats: - feat(ui): add new button component - fix(auth): resolve login issue with expired tokens - docs(readme): update installation instructions Use lowercase for type and scope. Keep the first line under 80 characters.` }, { role: 'user', content: prompt } ], temperature: 0.3, max_tokens: maxTokens }) }, anthropic: { apiEndpoint: 'https://api.anthropic.com/v1/messages', defaultModel: 'claude-3.7-sonnet', headerTemplate: (apiKey) => ({ 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }), responseHandler: (data) => data.content[0].text, requestBuilder: (prompt, model, maxTokens) => ({ // Anthropic also requires the model parameter model: model || 'claude-3.7-sonnet', messages: [ { role: 'user', content: `Create a concise, conventional commit message that strictly follows the Conventional Commits format: <type>(<scope>): <description> Valid types: feat, fix, docs, style, refactor, perf, test, chore Example formats: - feat(ui): add new button component - fix(auth): resolve login issue with expired tokens - docs(readme): update installation instructions Use lowercase for type and scope. Keep the first line under 80 characters. For the following changes: ${prompt}` } ], max_tokens: maxTokens }) }, gemini: { apiEndpoint: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', defaultModel: 'gemini-2.0-flash', headerTemplate: (apiKey) => { // Validate API key format for Google APIs if (!apiKey) { throw new Error('Gemini API key is required'); } // Google API keys should start with "AIza" - warn if they don't but don't auto-fix if (!apiKey.startsWith('AIza')) { console.warn('Warning: Gemini API key should start with "AIza". Please verify your API key format.'); } return { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }; }, responseHandler: (data) => { // Validate response structure before attempting to extract content if (!data) { throw new Error('Empty response received from Gemini API'); } if (!data.candidates || !Array.isArray(data.candidates) || data.candidates.length === 0) { throw new Error('No candidates found in Gemini API response'); } const candidate = data.candidates[0]; if (!candidate.content) { throw new Error('No content found in Gemini API response candidate'); } if (!candidate.content.parts || !Array.isArray(candidate.content.parts) || candidate.content.parts.length === 0) { throw new Error('No content parts found in Gemini API response'); } if (!candidate.content.parts[0].text) { throw new Error('No text found in Gemini API response content parts'); } // If we've made it here, we have valid text content const rawText = candidate.content.parts[0].text; // Log the raw response for debugging if it contains problematic characters if (rawText.includes('```') || rawText.includes('`')) { console.log('Raw Gemini API response:', JSON.stringify(rawText, null, 2)); } // Sanitize the response to remove markdown formatting that causes shell issues // Gemini sometimes adds code blocks and backticks which break git commit commands const sanitizedText = rawText .replace(/```\w*\n?/g, '') // Remove opening code block markers (e.g., ```text) .replace(/```\n?/g, '') // Remove closing code block markers .replace(/`/g, '') // Remove any remaining backticks .replace(/\n\s*\n\s*\n/g, '\n') // Normalize multiple newlines .replace(/\n\s*\n/g, '\n') // Normalize double newlines to single .replace(/^\s+|\s+$/g, '') // Trim whitespace from start and end .trim(); // Final trim // Additional validation: if the result still contains problematic characters, log a warning if (sanitizedText.includes('`') || sanitizedText.includes('```')) { console.warn('Warning: Sanitized response still contains backticks, applying additional cleanup'); return sanitizedText .replace(/`/g, '') // Remove any remaining backticks .replace(/\n\s*\n/g, '\n') // Normalize newlines again .trim(); } return sanitizedText; }, requestBuilder: (prompt, model, maxTokens) => { // Configure Gemini-specific parameters based on our token manager knowledge const tokenManager = new GeminiTokenManager(); return { contents: [ { role: "user", parts: [ { text: `You are a senior software developer. Create a clear and informative conventional commit message that explains what was changed and why. IMPORTANT: Provide a descriptive summary on the first line, followed by 1-2 sentences explaining the key changes and their impact. Be informative but concise. Do NOT use markdown formatting, code blocks, or backticks. Return plain text only. Format: <type>(<scope>): <description> Valid types: feat, fix, docs, style, refactor, perf, test, chore Example formats: - feat(auth): add OAuth2 authentication with Google and GitHub providers Implement secure OAuth2 flow with proper error handling and user session management - fix(api): resolve race condition in user data updates Prevent data corruption when multiple requests modify the same user record simultaneously - refactor(ui): migrate button components to use new design system Update all button variants to use consistent styling and improve accessibility Use lowercase for type and scope. Keep the first line under 80 characters, and provide 1-3 sentences of explanation on subsequent lines. For the following changes: ${prompt}` } ] } ], generationConfig: { maxOutputTokens: maxTokens || 300, temperature: 0, topP: 0.95, topK: 40 }, // Safety settings - set to low thresholds since this is just code commit generation safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_ONLY_HIGH" }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_ONLY_HIGH" }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_ONLY_HIGH" } ] }; } } }; /** * Lists available models for the Gemini API * This helper function can be used to troubleshoot model availability issues * @param {string} apiKey - The Gemini API key * @returns {Promise<Array>} List of available models */ export async function listGeminiModels(apiKey) { try { // Validate API key if (!apiKey) { throw new Error('Gemini API key is required'); } if (!apiKey.startsWith('AIza')) { console.warn('Warning: Gemini API key should start with "AIza". Please verify your API key format.'); } // Call the ListModels endpoint const response = await fetch( 'https://generativelanguage.googleapis.com/v1beta/models', { method: 'GET', headers: { 'x-goog-api-key': apiKey } } ); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to list models: ${errorText}`); } const data = await response.json(); // Filter for text generation models and sort by name const validModels = data.models .filter(model => model.supportedGenerationMethods && model.supportedGenerationMethods.includes('generateContent') ) .sort((a, b) => a.name.localeCompare(b.name)); return validModels; } catch (error) { console.error('Error listing Gemini models:', error.message); return []; } } /** * Gets the configuration details for the selected LLM provider * @returns {Object} Provider configuration details */ export function getProviderConfig() { // Use the provider from config const providerName = config.provider.toLowerCase(); const providerConfig = LLM_PROVIDERS[providerName]; if (!providerConfig) { console.error(`Error: Unknown provider "${providerName}". Supported providers: ${Object.keys(LLM_PROVIDERS).join(', ')}`); // Default to Groq if provider not found return { name: 'groq', apiKey: config.apiKey, model: config.model, config: LLM_PROVIDERS.groq }; } return { name: providerName, apiKey: config.apiKey, model: config.model, config: providerConfig }; } /** * Builds an API request for a given provider * @param {Object} providerDetails - The provider configuration from getProviderConfig * @param {String} prompt - The input prompt for the LLM * @param {Number} maxTokens - Maximum tokens for the response * @returns {Object} Request configuration for fetch API */ export async function buildApiRequest(providerDetails, prompt, maxTokens = 500) { const { config, apiKey, model } = providerDetails; // Generate request body with or without model const requestBody = config.requestBuilder(prompt, model, maxTokens); // Create the base request object let request = { method: 'POST', headers: config.headerTemplate(apiKey), body: JSON.stringify(requestBody) }; // For providers like Gemini that have model in the URL let endpoint = config.apiEndpoint; // Special handling for providers that need model in endpoint if (config.getEndpoint && apiKey) { endpoint = config.getEndpoint(apiKey); } else if (endpoint.includes('{model}') && model) { // Replace {model} placeholder in endpoint endpoint = endpoint.replace('{model}', model); } else if (endpoint.includes('{model}') && !model) { // For Gemini, we must have a model in URL, so use default if not specified endpoint = endpoint.replace('{model}', config.defaultModel); } // Return the request with the endpoint return { request, endpoint }; } /** * Retry wrapper for API requests with exponential backoff * @param {Function} requestFn - Function that makes the API request * @param {number} maxRetries - Maximum number of retries * @param {number} baseDelay - Base delay in milliseconds * @returns {Promise} The API response */ export async function retryApiRequest(requestFn, maxRetries = 3, baseDelay = 1000) { let lastError; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const result = await requestFn(); return result; } catch (error) { lastError = error; // Check if this is a retryable error const isRetryable = error.message.includes('503') || error.message.includes('overloaded') || error.message.includes('UNAVAILABLE') || error.message.includes('rate limit'); if (!isRetryable || attempt === maxRetries) { throw error; } // Calculate delay with exponential backoff and jitter const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000; console.log(`API request failed (attempt ${attempt + 1}/${maxRetries + 1}): ${error.message}`); console.log(`Retrying in ${Math.round(delay)}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } throw lastError; }