/** * Abstract Base Provider for AI Services * * This module provides the foundation for all AI provider implementations, * ensuring consistency, proper error handling, and maintainable code structure. * * Design Principles: * - Template Method Pattern: Define algorithm structure, let subclasses implement specifics * - Fail-Fast: Validate inputs early and provide clear error messages * - Separation of Concerns: Configuration, validation, execution are clearly separated * - Type Safety: Comprehensive TypeScript support for better DX * * @author Jan-Marlon Leibl * @version 1.0.0 */ import type { AIProviderConfig, CompletionParams, CompletionResponse, CompletionChunk, ProviderInfo, ResponseType } from '../types/index.js'; import { AIProviderError, AIErrorType, generateResponseTypePrompt, parseAndValidateResponseType } from '../types/index.js'; import { CONFIG_BOUNDS, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_MS } from '../constants.js'; // ============================================================================ // ABSTRACT BASE PROVIDER CLASS // ============================================================================ /** * Abstract base class that all AI providers must extend. * * This class implements the Template Method pattern, providing a consistent * interface and workflow while allowing providers to implement their specific * logic in protected abstract methods. * * Lifecycle: * 1. Construction: validateConfig() called automatically * 2. Initialization: initialize() must be called before use * 3. Usage: complete() and stream() methods available * 4. Error Handling: All errors are normalized to AIProviderError * * @example * ```typescript * class MyProvider extends BaseAIProvider { * protected async doInitialize(): Promise { * // Initialize your client/connection here * } * * protected async doComplete(params: CompletionParams): Promise { * // Implement completion logic here * } * * // ... other required methods * } * ``` */ export abstract class BaseAIProvider { // ======================================================================== // INSTANCE PROPERTIES // ======================================================================== /** Validated configuration object for this provider instance */ protected readonly config: AIProviderConfig; /** Internal flag tracking initialization state */ private initialized: boolean = false; // ======================================================================== // CONSTRUCTOR & CONFIGURATION // ======================================================================== /** * Constructs a new provider instance with validated configuration. * * @param config - Provider configuration object * @throws {AIProviderError} If configuration is invalid * * @example * ```typescript * const provider = new MyProvider({ * apiKey: 'your-api-key', * timeout: 30000, * maxRetries: 3 * }); * ``` */ constructor(config: AIProviderConfig) { this.config = this.validateAndNormalizeConfig(config); } // ======================================================================== // PUBLIC API METHODS // ======================================================================== /** * Initializes the provider for use. * * This method must be called before any completion requests. It handles * provider-specific setup such as client initialization and connection testing. * * @returns Promise that resolves when initialization is complete * @throws {AIProviderError} If initialization fails * * @example * ```typescript * await provider.initialize(); * // Provider is now ready for use * ``` */ public async initialize(): Promise { try { await this.doInitialize(); this.initialized = true; } catch (error) { // Normalize any error to our standard format throw this.normalizeError(error as Error); } } /** * Checks if the provider has been initialized. * * @returns true if the provider is ready for use, false otherwise */ public isInitialized(): boolean { return this.initialized; } /** * Generates a text completion based on the provided parameters. * * This method handles validation, error normalization, and delegates to * the provider-specific implementation via the Template Method pattern. * * @param params - Completion parameters including messages, model options, etc. * @returns Promise resolving to the completion response * @throws {AIProviderError} If the request fails or parameters are invalid * * @example * ```typescript * const response = await provider.complete({ * messages: [{ role: 'user', content: 'Hello!' }], * maxTokens: 100, * temperature: 0.7 * }); * console.log(response.content); * ``` */ public async complete(params: CompletionParams): Promise>; public async complete(params: CompletionParams): Promise>; public async complete(params: CompletionParams): Promise> { // Ensure provider is ready for use this.ensureInitialized(); // Validate input parameters early this.validateCompletionParams(params); try { // Process response type instructions if specified const processedParams = this.processResponseType(params); // Delegate to provider-specific implementation const response = await this.doComplete(processedParams); // If a responseType is defined, parse and validate the response if (params.responseType) { const parsedData = parseAndValidateResponseType(response.content, params.responseType); return { ...response, content: parsedData, rawContent: response.content, }; } // Otherwise, return the raw string content return { ...response, content: response.content, }; } catch (error) { // Normalize error to our standard format throw this.normalizeError(error as Error); } } /** * Generates a streaming text completion. * * This method returns an async iterable that yields chunks of the response * as they become available, enabling real-time UI updates. * * @param params - Completion parameters * @returns AsyncIterable yielding completion chunks * @throws {AIProviderError} If the request fails or parameters are invalid * * @example * ```typescript * for await (const chunk of provider.stream(params)) { * if (!chunk.isComplete) { * process.stdout.write(chunk.content); * } else { * console.log('\nDone! Usage:', chunk.usage); * } * } * ``` */ public async *stream(params: CompletionParams): AsyncIterable { // Ensure provider is ready for use this.ensureInitialized(); // Validate input parameters early this.validateCompletionParams(params); try { // Process response type instructions if specified const processedParams = this.processResponseType(params); // Delegate to provider-specific implementation yield* this.doStream(processedParams); } catch (error) { // Normalize error to our standard format throw this.normalizeError(error as Error); } } /** * Returns information about this provider and its capabilities. * * This method provides metadata about the provider including supported * models, context length, streaming support, and special capabilities. * * @returns Provider information object */ public abstract getInfo(): ProviderInfo; // ======================================================================== // ABSTRACT METHODS (TEMPLATE METHOD PATTERN) // ======================================================================== /** * Provider-specific initialization logic. * * Subclasses should implement this method to handle their specific * initialization requirements such as: * - Creating API clients * - Testing connections * - Validating credentials * - Setting up authentication * * @protected * @returns Promise that resolves when initialization is complete * @throws {Error} If initialization fails (will be normalized to AIProviderError) */ protected abstract doInitialize(): Promise; /** * Provider-specific completion implementation. * * Subclasses should implement this method to handle text completion * using their specific API. The base class handles validation and * error normalization. * * @protected * @param params - Validated completion parameters * @returns Promise resolving to completion response * @throws {Error} If completion fails (will be normalized to AIProviderError) */ protected abstract doComplete(params: CompletionParams): Promise>; /** * Provider-specific streaming implementation. * * Subclasses should implement this method to handle streaming completions * using their specific API. The base class handles validation and * error normalization. * * @protected * @param params - Validated completion parameters * @returns AsyncIterable yielding completion chunks * @throws {Error} If streaming fails (will be normalized to AIProviderError) */ protected abstract doStream(params: CompletionParams): AsyncIterable; // ======================================================================== // PROTECTED HOOKS (subclass overrides; safe defaults provided) // ======================================================================== /** * Regex patterns that valid model names should match. * Empty array (the default) skips validation entirely. */ protected getModelNamePatterns(): RegExp[] { return []; } /** * Send a minimal request to verify the API key and connectivity. * The default does nothing; SDK-based providers override this and the * base `validateConnection` handles error mapping. */ protected async sendValidationProbe(): Promise { return; } /** * Map a provider-specific error to a normalized AIProviderError, or * return null to let the base error mapping handle it generically. * Override to recognize provider-specific message patterns (e.g. "API * key", "quota", "model not found"). */ protected mapProviderError(_error: any): AIProviderError | null { return null; } /** * Per-HTTP-status message overrides used by `normalizeError` when no * provider-specific mapping applies. Lets providers add brand-flavored * hints (console URLs, etc.) without re-implementing the status switch. */ protected providerErrorMessages(): Partial> { return {}; } /** * Human-readable provider name used in warning messages. * Default derives from getInfo(); override if construction order makes * getInfo() unsafe to call here. */ protected get providerName(): string { try { return this.getInfo().name; } catch { return 'Provider'; } } // ======================================================================== // PROTECTED UTILITY METHODS // ======================================================================== /** * Validates a model name against the patterns returned by * `getModelNamePatterns()`. Throws for invalid input, warns for * non-matching names (since model lists change frequently). */ protected validateModelName(modelName: string): void { if (!modelName || typeof modelName !== 'string') { throw new AIProviderError( 'Model name must be a non-empty string', AIErrorType.INVALID_REQUEST ); } const patterns = this.getModelNamePatterns(); if (patterns.length === 0) return; const matches = patterns.some(pattern => pattern.test(modelName)); if (!matches) { console.warn( `Model name '${modelName}' doesn't match expected ${this.providerName} naming patterns. This may cause API errors.` ); } } /** * Wraps `sendValidationProbe()` with consistent error handling: throws * AIProviderError for provider-recognized failures, warns and continues * for transient ones. */ protected async validateConnection(): Promise { try { await this.sendValidationProbe(); } catch (error: any) { const mapped = this.mapProviderError(error); if (mapped) { throw mapped; } console.warn(`${this.providerName} connection validation warning:`, error.message); } } /** * Validates and normalizes provider configuration. * * This method ensures all required fields are present and sets sensible * defaults for optional fields. It follows the fail-fast principle. * * @protected * @param config - Raw configuration object * @returns Validated and normalized configuration * @throws {AIProviderError} If configuration is invalid */ protected validateAndNormalizeConfig(config: AIProviderConfig): AIProviderConfig { // Validate required fields if (!config) { throw new AIProviderError( 'Configuration object is required', AIErrorType.INVALID_REQUEST ); } if (!config.apiKey || typeof config.apiKey !== 'string' || config.apiKey.trim() === '') { throw new AIProviderError( 'API key is required and must be a non-empty string', AIErrorType.INVALID_REQUEST ); } this.validateNumberInRange('timeout', config.timeout, { ...CONFIG_BOUNDS.timeoutMs, label: `${CONFIG_BOUNDS.timeoutMs.min}ms and ${CONFIG_BOUNDS.timeoutMs.max}ms` }); this.validateNumberInRange('maxRetries', config.maxRetries, { ...CONFIG_BOUNDS.maxRetries, integer: true }); return { ...config, timeout: config.timeout ?? DEFAULT_TIMEOUT_MS, maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES }; } /** * Validates an optional numeric parameter against a range. * Skips validation when value is undefined so callers can apply defaults afterward. */ private validateNumberInRange( name: string, value: number | undefined, bounds: { min?: number; max?: number; exclusiveMin?: boolean; integer?: boolean; label?: string; } ): void { if (value === undefined) return; const { min, max, exclusiveMin, integer, label } = bounds; const range = label ?? this.describeRange(min, max, exclusiveMin); if (typeof value !== 'number' || Number.isNaN(value)) { throw new AIProviderError( `${name} must be a number between ${range}`, AIErrorType.INVALID_REQUEST ); } if (integer && !Number.isInteger(value)) { throw new AIProviderError( `${name} must be an integer`, AIErrorType.INVALID_REQUEST ); } if (min !== undefined && (exclusiveMin ? value <= min : value < min)) { throw new AIProviderError( `${name} must be ${exclusiveMin ? 'greater than' : 'at least'} ${min}`, AIErrorType.INVALID_REQUEST ); } if (max !== undefined && value > max) { throw new AIProviderError( `${name} must be at most ${max}`, AIErrorType.INVALID_REQUEST ); } } private describeRange(min?: number, max?: number, exclusiveMin?: boolean): string { if (min !== undefined && max !== undefined) { return `${exclusiveMin ? '>' : '>='}${min} and <=${max}`; } if (min !== undefined) return `${exclusiveMin ? '>' : '>='}${min}`; if (max !== undefined) return `<=${max}`; return 'a valid number'; } /** * Ensures the provider has been initialized before use. * * @protected * @throws {AIProviderError} If the provider is not initialized */ protected ensureInitialized(): void { if (!this.initialized) { throw new AIProviderError( 'Provider must be initialized before use. Call initialize() first.', AIErrorType.INVALID_REQUEST ); } } /** * Validates completion parameters comprehensively. * * This method performs thorough validation of all completion parameters * to ensure they meet the requirements and constraints. * * @protected * @param params - Parameters to validate * @throws {AIProviderError} If any parameter is invalid */ protected validateCompletionParams(params: CompletionParams): void { if (!params || typeof params !== 'object') { throw new AIProviderError( 'Completion parameters object is required', AIErrorType.INVALID_REQUEST ); } this.validateMessages(params.messages); this.validateNumberInRange('temperature', params.temperature, CONFIG_BOUNDS.temperature); this.validateNumberInRange('topP', params.topP, CONFIG_BOUNDS.topP); this.validateNumberInRange('maxTokens', params.maxTokens, { ...CONFIG_BOUNDS.maxTokens, integer: true }); this.validateStopSequences(params.stopSequences); } /** * Validates the messages array parameter. * * @private * @param messages - Messages array to validate * @throws {AIProviderError} If messages are invalid */ private validateMessages(messages: any): void { if (!Array.isArray(messages) || messages.length === 0) { throw new AIProviderError( 'Messages must be a non-empty array', AIErrorType.INVALID_REQUEST ); } const validRoles = ['system', 'user', 'assistant'] as const; for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (!message || typeof message !== 'object') { throw new AIProviderError( `Message at index ${i} must be an object`, AIErrorType.INVALID_REQUEST ); } if (!validRoles.includes(message.role)) { throw new AIProviderError( `Message at index ${i} has invalid role '${message.role}'. Must be: ${validRoles.join(', ')}`, AIErrorType.INVALID_REQUEST ); } if (typeof message.content !== 'string' || message.content.trim() === '') { throw new AIProviderError( `Message at index ${i} must have non-empty string content`, AIErrorType.INVALID_REQUEST ); } } } private validateStopSequences(stopSequences?: string[]): void { if (stopSequences !== undefined) { if (!Array.isArray(stopSequences)) { throw new AIProviderError( 'Stop sequences must be an array of strings', AIErrorType.INVALID_REQUEST ); } for (let i = 0; i < stopSequences.length; i++) { if (typeof stopSequences[i] !== 'string') { throw new AIProviderError( `Stop sequence at index ${i} must be a string`, AIErrorType.INVALID_REQUEST ); } } } } /** * Processes completion parameters to include response type instructions. * * This method automatically adds system prompt instructions when a response * type is specified, ensuring the AI understands the expected output format. * * @protected * @param params - Original completion parameters * @returns Processed parameters with response type instructions */ protected processResponseType(params: CompletionParams): CompletionParams { if (!params.responseType) { return params; } // Create a copy of the parameters to avoid mutation const processedParams = { ...params }; // Generate the response type instruction const responseTypePrompt = generateResponseTypePrompt(params.responseType); // Find existing system messages or create new ones const systemMessages = params.messages.filter(msg => msg.role === 'system'); const nonSystemMessages = params.messages.filter(msg => msg.role !== 'system'); // Combine existing system messages with response type instruction const combinedSystemContent = systemMessages.length > 0 ? systemMessages.map(msg => msg.content).join('\n\n') + '\n\n' + responseTypePrompt : responseTypePrompt; // Create new messages array with combined system message processedParams.messages = [ { role: 'system', content: combinedSystemContent }, ...nonSystemMessages ]; return processedParams; } /** * Normalizes any error into a standardized AIProviderError. * * This method provides consistent error handling across all providers, * mapping various error types and status codes to appropriate categories. * * @protected * @param error - The original error to normalize * @returns Normalized AIProviderError with appropriate type and context */ protected normalizeError(error: Error): AIProviderError { if (error instanceof AIProviderError) { return error; } const providerError = this.mapProviderError(error); if (providerError) { return providerError; } const status = (error as any).status || (error as any).statusCode; const message = error.message || 'Unknown error occurred'; const overrides = this.providerErrorMessages(); const statusTypeMap: Record = { 400: AIErrorType.INVALID_REQUEST, 401: AIErrorType.AUTHENTICATION, 403: AIErrorType.AUTHENTICATION, 404: AIErrorType.MODEL_NOT_FOUND, 429: AIErrorType.RATE_LIMIT, 500: AIErrorType.NETWORK, 502: AIErrorType.NETWORK, 503: AIErrorType.NETWORK, 504: AIErrorType.NETWORK }; const defaultMessages: Record = { 400: `Bad request: ${message}`, 401: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.', 403: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.', 404: 'The specified model or endpoint was not found. Please check the model name and availability.', 429: 'Rate limit exceeded. Please reduce your request frequency and try again later.', 500: 'Service temporarily unavailable. Please try again in a few moments.', 502: 'Service temporarily unavailable. Please try again in a few moments.', 503: 'Service temporarily unavailable. Please try again in a few moments.', 504: 'Service temporarily unavailable. Please try again in a few moments.' }; if (status && statusTypeMap[status]) { return new AIProviderError( overrides[status] ?? defaultMessages[status]!, statusTypeMap[status]!, status, error ); } if (message.includes('timeout') || message.includes('ETIMEDOUT')) { return new AIProviderError( 'Request timed out. The operation took longer than expected.', AIErrorType.TIMEOUT, undefined, error ); } if (message.includes('network') || message.includes('ENOTFOUND') || message.includes('ECONNREFUSED')) { return new AIProviderError( 'Network error occurred. Please check your internet connection and try again.', AIErrorType.NETWORK, undefined, error ); } return new AIProviderError( `${this.providerName} error: ${message}`, AIErrorType.UNKNOWN, status, error ); } }