UNPKG

@entro314labs/ai-changelog-generator

Version:

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

577 lines (576 loc) 26.1 kB
/** * Helper functions that can be mixed into provider classes * Reduces code duplication by providing common implementations */ import { analyzeCommitComplexity, getBestModelForCapabilities, getModelCapabilities, getProviderModelConfig, getSuggestedModels, MODEL_CONFIGS, normalizeModelName, } from './model-config.js'; import { buildClientOptions, createProviderErrorResponse, createProviderSuccessResponse, extractProviderConfig, selectModelByComplexity, standardConnectionTest, validateModelWithFallbacks, } from './provider-utils.js'; function getSyncAvailableModels(host) { if (!host.getAvailableModels) { return []; } const availableModels = host.getAvailableModels(); return Array.isArray(availableModels) ? availableModels : []; } async function getAvailableModels(host) { if (!host.getAvailableModels) { return []; } const availableModels = await host.getAvailableModels(); return Array.isArray(availableModels) ? availableModels : []; } function getModelIds(models) { return models .map((model) => { if (typeof model === 'string') { return model; } if (typeof model.id === 'string') { return model.id; } if (typeof model.name === 'string') { return model.name; } return null; }) .filter((modelId) => typeof modelId === 'string'); } // Provider utility functions function isHubProvider(providerName) { const config = MODEL_CONFIGS[providerName]; return config && config.isHub === true; } function selectHubModel(providerName, complexity = 'standard', availableModels = []) { const modelConfig = getProviderModelConfig(providerName); if (!modelConfig) { return null; } const modelKey = { simple: 'smallModel', standard: 'standardModel', medium: 'mediumModel', complex: 'complexModel', }[complexity] || 'standardModel'; const preferredModel = modelConfig[modelKey]; // If we have available models list, check if preferred model exists if (Array.isArray(availableModels) && availableModels.length > 0) { if (availableModels.includes(preferredModel)) { return preferredModel; } // Try fallbacks for (const fallback of modelConfig.fallbacks || []) { if (availableModels.includes(fallback)) { return fallback; } } // Return first available model if nothing else matches return availableModels[0]; } return preferredModel; } /** * Mixin that provides enhanced model recommendation logic with hub support * @param {string} providerName - Name of the provider * @returns {Object} Mixin methods */ export function ModelRecommendationMixin(providerName) { return { getModelRecommendation(commitDetails) { // Get available models for hub providers const availableModels = getModelIds(getSyncAvailableModels(this)); const modelConfig = getProviderModelConfig(providerName, this.config, availableModels); // Use enhanced complexity analysis const complexityAnalysis = analyzeCommitComplexity(commitDetails, providerName); // For hub providers, use specialized selection logic if (isHubProvider(providerName)) { const selectedModel = selectHubModel(providerName, complexityAnalysis.complexity, availableModels); return { model: selectedModel, complexity: complexityAnalysis.complexity, reasoning: complexityAnalysis.reasoning, isHubProvider: true, availableModels: availableModels.length, }; } // Standard provider logic return selectModelByComplexity(commitDetails, modelConfig); }, async selectOptimalModel(commitDetails) { // Enhanced async version with hub awareness if (isHubProvider(providerName)) { // Try to refresh available models for hub providers if (this.refreshAvailableModels) { try { await this.refreshAvailableModels(); } catch (error) { // Continue with cached models if refresh fails console.warn(`Failed to refresh models for ${providerName}:`, error.message); } } } return this.getModelRecommendation?.(commitDetails) ?? null; }, async selectModelForCapabilities(requiredCapabilities = []) { return getBestModelForCapabilities(providerName, requiredCapabilities, this.config); }, }; } /** * Mixin that provides standard connection testing * @param {string} providerName - Name of the provider * @returns {Object} Mixin methods */ export function ConnectionTestMixin(providerName) { return { async testConnection() { if (!this.isAvailable()) { return createProviderErrorResponse(providerName, 'connection_test', `${providerName} provider is not configured`, [`Configure ${providerName.toUpperCase()}_API_KEY`]); } const modelConfig = getProviderModelConfig(providerName, this.config); const defaultModel = normalizeModelName(providerName, modelConfig.standardModel); const result = await standardConnectionTest(this.generateCompletion.bind(this), defaultModel); if (result.success) { return createProviderSuccessResponse(providerName, { response: result.response, model: result.model, provider_info: this.getProviderInfo ? this.getProviderInfo() : {}, }); } return createProviderErrorResponse(providerName, 'connection_test', result.error, modelConfig.fallbacks); }, }; } /** * Mixin that provides standard model validation * @param {string} providerName - Name of the provider * @returns {Object} Mixin methods */ export function ModelValidationMixin(providerName) { return { async validateModelAvailability(modelName) { if (!this.isAvailable()) { return createProviderErrorResponse(providerName, 'model_validation', `${providerName} provider is not configured`, [`Configure ${providerName.toUpperCase()}_API_KEY`]); } const normalizedModel = normalizeModelName(providerName, modelName); const availableModels = getModelIds(await getAvailableModels(this)); const fallbacks = getSuggestedModels(providerName, normalizedModel); // For hub providers, check if model is in available list first if (isHubProvider(providerName) && Array.isArray(availableModels) && availableModels.length > 0 && !availableModels.includes(normalizedModel)) { return createProviderErrorResponse(providerName, 'model_validation', `Model '${normalizedModel}' not found in available deployments`, fallbacks); } // Use provider-specific model testing if available if (this.testModel) { return validateModelWithFallbacks(this.testModel.bind(this), normalizedModel, fallbacks); } // Fallback to basic connection test with the model try { const result = await standardConnectionTest(this.generateCompletion.bind(this), normalizedModel); if (result.success) { return createProviderSuccessResponse(providerName, { model: normalizedModel, capabilities: this.getCapabilities(normalizedModel), isHubProvider: isHubProvider(providerName), availableModels: availableModels.length, }); } return createProviderErrorResponse(providerName, 'model_validation', result.error, fallbacks); } catch (error) { return createProviderErrorResponse(providerName, 'model_validation', error.message, fallbacks); } }, }; } /** * Mixin that provides standard capabilities lookup * @param {string} providerName - Name of the provider * @returns {Object} Mixin methods */ export function CapabilitiesMixin(providerName) { return { getCapabilities(modelName) { const availableModels = getModelIds(getSyncAvailableModels(this)); const modelConfig = getProviderModelConfig(providerName, this.config, availableModels); const model = modelName || this.config.AI_MODEL || this.config[`${providerName.toUpperCase()}_MODEL`] || modelConfig.standardModel; const normalizedModel = normalizeModelName(providerName, model); const capabilities = { ...getModelCapabilities(normalizedModel), }; // Add provider-specific metadata if (isHubProvider(providerName)) { capabilities.isHubProvider = true; capabilities.availableInHub = !Array.isArray(availableModels) || availableModels.length === 0 || availableModels.includes(normalizedModel); } return capabilities; }, /** * Enhanced capability testing - tests actual provider functionality * @param {Object} options - Test options * @returns {Promise<Object>} Detailed capability test results */ async testCapabilities(options = {}) { const results = { available: false, connection: false, modelAccess: false, capabilities: {}, errors: [], performance: {}, tested_at: new Date().toISOString(), }; try { // Test 1: Basic availability results.available = this.isAvailable(); if (!results.available) { results.errors.push('Provider not available - check configuration'); return results; } // Test 2: Connection test if (this.testConnection) { const startTime = Date.now(); try { await this.testConnection(); results.connection = true; results.performance.connectionTime = Date.now() - startTime; } catch (error) { results.errors.push(`Connection test failed: ${error.message}`); } } // Test 3: Model access test if (options.testModel !== false) { try { const testStartTime = Date.now(); const testResponse = await ProviderResponseHandler.executeWithErrorHandling(this, 'test_model_access', async () => { return await this.generateCompletion([{ role: 'user', content: 'Test message - respond with "OK"' }], { max_tokens: 10 }); }); if (testResponse && !testResponse.error) { results.modelAccess = true; results.performance.modelResponseTime = Date.now() - testStartTime; results.performance.tokensGenerated = 'tokens' in testResponse && typeof testResponse.tokens === 'number' ? testResponse.tokens : 0; } else { results.errors.push(`Model test failed: ${testResponse.error || 'Unknown error'}`); } } catch (error) { results.errors.push(`Model access test failed: ${error.message}`); } } // Test 4: Get detailed capabilities try { results.capabilities = this.getCapabilities(options.model); } catch (error) { results.errors.push(`Capabilities detection failed: ${error.message}`); } } catch (error) { results.errors.push(`Capability testing failed: ${error.message}`); } return results; }, /** * Quick health check - lightweight version of testCapabilities * @returns {Promise<Object>} Basic health status */ async quickHealthCheck() { const health = { status: 'unknown', available: false, configured: false, timestamp: new Date().toISOString(), }; try { health.available = this.isAvailable(); health.configured = Boolean(this.getName && this.config); if (health.available && health.configured) { health.status = 'healthy'; } else if (health.configured) { health.status = 'configured_but_unavailable'; } else { health.status = 'not_configured'; } } catch (error) { health.status = 'error'; health.error = error.message; } return health; }, getSimilarModels(modelName, providedAvailableModels = []) { // Use provider's available models if not provided const availableModels = providedAvailableModels.length > 0 ? providedAvailableModels : getModelIds(getSyncAvailableModels(this)); // Enhanced similarity matching for hub providers if (isHubProvider(providerName) && Array.isArray(availableModels) && availableModels.length > 0) { const modelFamily = modelName.split('-')[0] || modelName.split('.')[0]; // e.g., 'gpt', 'claude', 'gemini', 'anthropic' const familyModels = availableModels .filter((m) => m.includes(modelFamily) && m !== modelName) .slice(0, 3); // If no family matches, get models from the same capability tier if (familyModels.length === 0) { const modelCapabilities = getModelCapabilities(modelName); const similarCapabilityModels = availableModels .filter((m) => { const caps = getModelCapabilities(m); return (caps.reasoning === modelCapabilities.reasoning && caps.large_context === modelCapabilities.large_context); }) .slice(0, 3); return similarCapabilityModels; } return familyModels; } // Standard provider logic with enhanced suggestions return getSuggestedModels(providerName, modelName); }, }; } /** * Mixin that provides standard configuration handling * @param {string} providerName - Name of the provider * @param {Object} defaults - Default configuration values * @returns {Object} Mixin methods */ export function ConfigurationMixin(providerName, defaults = {}) { return { getProviderConfig() { return extractProviderConfig(this.config, providerName.toUpperCase(), defaults); }, getProviderModelConfig() { const availableModels = getModelIds(getSyncAvailableModels(this)); return getProviderModelConfig(providerName, this.config, availableModels); }, buildClientOptions(extraDefaults = {}) { const providerConfig = this.getProviderConfig?.() || {}; return buildClientOptions(providerConfig, { ...defaults, ...extraDefaults }); }, getRequiredEnvVars() { // Fallback for providers that authenticate with a plain <PROVIDER>_API_KEY. // Providers with a different credential shape (Ollama's OLLAMA_HOST, Copilot's // GITHUB_COPILOT_TOKEN, Bedrock's AWS chain, the gateway's AI_GATEWAY_API_KEY) // declare their own; applyMixins keeps those. const baseKey = `${providerName.toUpperCase()}_API_KEY`; return [baseKey]; }, getDefaultModel() { const modelConfig = this.getProviderModelConfig?.() || getProviderModelConfig(providerName, this.config); return modelConfig.standardModel || 'unknown'; }, getProviderInfo() { const providerConfig = this.getProviderConfig?.() || {}; const availableModels = getModelIds(getSyncAvailableModels(this)); const modelConfig = getProviderModelConfig(providerName, this.config, availableModels); const info = { name: providerName, configured: this.isAvailable(), config_keys: Object.keys(providerConfig).filter((k) => providerConfig[k]), default_model: modelConfig.standardModel || 'unknown', isHub: isHubProvider(providerName), }; // Add hub-specific information if (isHubProvider(providerName)) { info.hubInfo = { availableModels: availableModels.length, supportedProviders: modelConfig.hubInfo?.supportedProviders || [], defaultProvider: modelConfig.hubInfo?.defaultProvider, canDetectDeployments: !!(this.getAvailableModels || this.refreshAvailableModels), }; if (availableModels.length > 0) { info.hubInfo.sampleModels = availableModels.slice(0, 3); } } return info; }, }; } /** * Unified Provider Response Handler * Centralizes common patterns across all providers for consistency and maintainability */ export class ProviderResponseHandler { /** * Execute provider operation with standardized error handling and availability checking * @param {Object} provider - Provider instance * @param {string} operation - Operation name (e.g., 'generate_completion') * @param {Function} operationFn - Function to execute the operation * @param {Object} context - Additional context for error handling * @returns {Promise<Object>} Standardized response */ static async executeWithErrorHandling(provider, operation, operationFn, context = {}) { // Check availability first if (!provider.isAvailable()) { return ProviderResponseHandler.createUnavailableResponse(provider.getName?.() || 'provider', operation); } try { return await operationFn(); } catch (error) { return provider.handleProviderError(error, operation, context); } } /** * Create standardized unavailable response * @param {string} providerName - Name of the provider * @param {string} operation - Operation that was attempted * @returns {Object} Error response */ static createUnavailableResponse(providerName, operation) { return createProviderErrorResponse(providerName, operation, `${providerName} provider is not configured`, [`Configure ${providerName.toUpperCase()}_API_KEY and other required settings`]); } /** * Execute multiple provider operations in sequence with unified error handling * @param {Object} provider - Provider instance * @param {Array} operations - Array of {name, fn, context} operations * @returns {Promise<Array>} Array of results */ static async executeMultiple(provider, operations) { const results = []; for (const op of operations) { const result = await ProviderResponseHandler.executeWithErrorHandling(provider, op.name, op.fn, op.context); results.push(result); // Stop on first error if any operation fails if (result.error) { break; } } return results; } } /** * Mixin that provides standard error handling for providers * @param {string} providerName - Name of the provider * @returns {Object} Mixin methods */ export function ErrorHandlingMixin(providerName) { return { handleProviderError(error, operation, context = {}) { // Common error patterns and their standardized responses if (error.message.includes('API key') || error.message.includes('401')) { return createProviderErrorResponse(providerName, operation, 'Invalid or missing API key', [ `Check ${providerName.toUpperCase()}_API_KEY configuration`, ]); } if (error.message.includes('model') && error.message.includes('not found')) { const availableModels = getModelIds(getSyncAvailableModels(this)); const _modelConfig = getProviderModelConfig(providerName, this.config, availableModels); const fallbacks = getSuggestedModels(providerName, context.model); let errorMessage = `Model not available: ${context.model}`; if (isHubProvider(providerName) && Array.isArray(availableModels) && availableModels.length > 0) { errorMessage += ` (Available models: ${availableModels.length})`; } return createProviderErrorResponse(providerName, operation, errorMessage, fallbacks); } if (error.message.includes('rate limit') || error.message.includes('429')) { return createProviderErrorResponse(providerName, operation, 'Rate limit exceeded', [ 'Wait before retrying', 'Consider upgrading API plan', ]); } if (error.message.includes('timeout')) { return createProviderErrorResponse(providerName, operation, 'Request timeout', [ 'Increase timeout setting', 'Try again later', ]); } // Generic error response return createProviderErrorResponse(providerName, operation, error.message, [ 'Check provider configuration', 'Verify network connectivity', ]); }, }; } /** * Apply multiple mixins to a provider class. * * Mixins fill in behaviour a provider has NOT defined; they never replace it. * A plain `Object.assign` onto the prototype would overwrite the class body, * which silently disabled every provider-specific `getRequiredEnvVars`, * `getDefaultModel`, `getCapabilities`, `getProviderModelConfig`, * `getProviderInfo`, `testConnection` and `validateModelAvailability` in the * codebase — the opposite of what "providers should override if needed" implies. * * Own properties of the class prototype therefore win. Later mixins still fill * gaps left by earlier ones, so mixin ordering is unchanged for anything a * provider does not implement itself. * * @param {Function} ProviderClass - The provider class to enhance * @param {string} providerName - Name of the provider * @param {Array<Function>} mixins - Array of mixin functions to apply * @returns {Function} Enhanced provider class */ export function applyMixins(ProviderClass, providerName, mixins = []) { const defaultMixins = [ ConfigurationMixin, ModelRecommendationMixin, ConnectionTestMixin, ModelValidationMixin, CapabilitiesMixin, ErrorHandlingMixin, ]; const allMixins = [...defaultMixins, ...mixins]; const prototype = ProviderClass.prototype; for (const mixinFn of allMixins) { const methods = mixinFn(providerName); for (const [name, implementation] of Object.entries(Object.getOwnPropertyDescriptors(methods))) { // Skip anything the provider (or an earlier mixin) already supplies. if (Object.hasOwn(prototype, name)) { continue; } Object.defineProperty(prototype, name, implementation); } } return ProviderClass; } /** * Create a standardized provider class with all common functionality * @param {string} providerName - Name of the provider * @param {Object} options - Provider-specific options * @returns {Function} Base provider class with mixins applied */ export function createEnhancedProvider(providerName, options = {}) { const { mixins = [] } = options; class EnhancedProvider { constructor(config) { this.config = config; this.providerName = providerName; // Initialize provider-specific client if available method exists if (this.initializeClient && this.isAvailable()) { this.initializeClient(); } } getName() { return providerName; } // These methods should be implemented by the specific provider isAvailable() { throw new Error('isAvailable() must be implemented by the provider'); } async generateCompletion() { throw new Error('generateCompletion() must be implemented by the provider'); } } // Apply all mixins return applyMixins(EnhancedProvider, providerName, mixins); }