diff --git a/package.json b/package.json index 89ae33a..6395665 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-ai-provider", - "version": "1.1.1", + "version": "1.1.2", "description": "A simple and extensible AI provider package for easy integration of multiple AI services", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/providers/base.ts b/src/providers/base.ts index 7925bbe..e9bc6f3 100644 --- a/src/providers/base.ts +++ b/src/providers/base.ts @@ -1,6 +1,17 @@ /** - * Abstract base class for all AI providers - * Provides common functionality and enforces a consistent interface + * 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 { @@ -12,120 +23,333 @@ import type { } from '../types/index.js'; import { AIProviderError, AIErrorType } from '../types/index.js'; +// ============================================================================ +// ABSTRACT BASE PROVIDER CLASS +// ============================================================================ + /** - * Abstract base class that all AI providers must extend + * 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 { - protected config: AIProviderConfig; - protected initialized: boolean = false; + // ======================================================================== + // INSTANCE PROPERTIES + // ======================================================================== - constructor(config: AIProviderConfig) { - this.config = this.validateConfig(config); - } + /** Validated configuration object for this provider instance */ + protected readonly config: AIProviderConfig; + + /** Internal flag tracking initialization state */ + private initialized: boolean = false; + + // ======================================================================== + // CONSTRUCTOR & CONFIGURATION + // ======================================================================== /** - * Validates the provider configuration - * @param config - Configuration object to validate - * @returns Validated configuration - * @throws AIProviderError if configuration is invalid + * 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 + * }); + * ``` */ - protected validateConfig(config: AIProviderConfig): AIProviderConfig { - if (!config.apiKey || typeof config.apiKey !== 'string') { - throw new AIProviderError( - 'API key is required and must be a string', - AIErrorType.INVALID_REQUEST - ); - } - - // Set default values - const validatedConfig = { - ...config, - timeout: config.timeout ?? 30000, - maxRetries: config.maxRetries ?? 3 - }; - - return validatedConfig; + constructor(config: AIProviderConfig) { + this.config = this.validateAndNormalizeConfig(config); } + // ======================================================================== + // PUBLIC API METHODS + // ======================================================================== + /** - * Initialize the provider (setup connections, validate credentials, etc.) - * Must be called before using the provider + * 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) { - throw this.handleError(error as Error); + // Normalize any error to our standard format + throw this.normalizeError(error as Error); } } /** - * Check if the provider is initialized + * Checks if the provider has been initialized. + * + * @returns true if the provider is ready for use, false otherwise */ public isInitialized(): boolean { return this.initialized; } /** - * Generate a completion based on the provided parameters - * @param params - Parameters for the completion request - * @returns Promise resolving to completion response + * 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 { + // Ensure provider is ready for use this.ensureInitialized(); + + // Validate input parameters early this.validateCompletionParams(params); try { + // Delegate to provider-specific implementation return await this.doComplete(params); } catch (error) { - throw this.handleError(error as Error); + // Normalize error to our standard format + throw this.normalizeError(error as Error); } } /** - * Generate a streaming completion - * @param params - Parameters for the completion request - * @returns AsyncIterable of completion chunks + * 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 { + // Delegate to provider-specific implementation yield* this.doStream(params); } catch (error) { - throw this.handleError(error as Error); + // Normalize error to our standard format + throw this.normalizeError(error as Error); } } /** - * Get information about this provider - * @returns Provider information and capabilities + * 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 - * Override this method in concrete implementations + * 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 logic - * Override this method in concrete implementations + * 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 logic - * Override this method in concrete implementations + * 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 UTILITY METHODS + // ======================================================================== + /** - * Ensures the provider is initialized before use - * @throws AIProviderError if not initialized + * 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 + ); + } + + // Apply defaults and return normalized config + return { + ...config, + timeout: this.validateTimeout(config.timeout), + maxRetries: this.validateMaxRetries(config.maxRetries) + }; + } + + /** + * Validates timeout configuration value. + * + * @private + * @param timeout - Timeout value to validate + * @returns Validated timeout value with default if needed + */ + private validateTimeout(timeout?: number): number { + const defaultTimeout = 30000; // 30 seconds + + if (timeout === undefined) { + return defaultTimeout; + } + + if (typeof timeout !== 'number' || timeout < 1000 || timeout > 300000) { + throw new AIProviderError( + 'Timeout must be a number between 1000ms (1s) and 300000ms (5min)', + AIErrorType.INVALID_REQUEST + ); + } + + return timeout; + } + + /** + * Validates max retries configuration value. + * + * @private + * @param maxRetries - Max retries value to validate + * @returns Validated max retries value with default if needed + */ + private validateMaxRetries(maxRetries?: number): number { + const defaultMaxRetries = 3; + + if (maxRetries === undefined) { + return defaultMaxRetries; + } + + if (typeof maxRetries !== 'number' || maxRetries < 0 || maxRetries > 10) { + throw new AIProviderError( + 'Max retries must be a number between 0 and 10', + AIErrorType.INVALID_REQUEST + ); + } + + return maxRetries; + } + + /** + * Ensures the provider has been initialized before use. + * + * @protected + * @throws {AIProviderError} If the provider is not initialized */ protected ensureInitialized(): void { if (!this.initialized) { @@ -137,127 +361,250 @@ export abstract class BaseAIProvider { } /** - * Validates completion parameters + * 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 parameters are invalid + * @throws {AIProviderError} If any parameter is invalid */ protected validateCompletionParams(params: CompletionParams): void { - if (!params.messages || !Array.isArray(params.messages) || params.messages.length === 0) { + if (!params || typeof params !== 'object') { throw new AIProviderError( - 'Messages array is required and must not be empty', + 'Completion parameters object is required', AIErrorType.INVALID_REQUEST ); } - for (const message of params.messages) { - if (!message.role || !['system', 'user', 'assistant'].includes(message.role)) { + // Validate messages array + this.validateMessages(params.messages); + + // Validate optional parameters + this.validateTemperature(params.temperature); + this.validateTopP(params.topP); + this.validateMaxTokens(params.maxTokens); + 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( - 'Each message must have a valid role (system, user, or assistant)', + `Message at index ${i} must be an object`, AIErrorType.INVALID_REQUEST ); } - if (!message.content || typeof message.content !== 'string') { + if (!validRoles.includes(message.role)) { throw new AIProviderError( - 'Each message must have non-empty string content', + `Message at index ${i} has invalid role '${message.role}'. Must be: ${validRoles.join(', ')}`, AIErrorType.INVALID_REQUEST ); } - } - if (params.temperature !== undefined && (params.temperature < 0 || params.temperature > 1)) { - throw new AIProviderError( - 'Temperature must be between 0.0 and 1.0', - AIErrorType.INVALID_REQUEST - ); - } - - if (params.topP !== undefined && (params.topP < 0 || params.topP > 1)) { - throw new AIProviderError( - 'Top-p must be between 0.0 and 1.0', - AIErrorType.INVALID_REQUEST - ); - } - - if (params.maxTokens !== undefined && params.maxTokens < 1) { - throw new AIProviderError( - 'Max tokens must be a positive integer', - 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 + ); + } } } /** - * Handles and transforms errors into AIProviderError instances - * @param error - The original error - * @returns AIProviderError with appropriate type and context + * Validates temperature parameter. + * + * @private + * @param temperature - Temperature value to validate + * @throws {AIProviderError} If temperature is invalid */ - protected handleError(error: Error): AIProviderError { + private validateTemperature(temperature?: number): void { + if (temperature !== undefined) { + if (typeof temperature !== 'number' || temperature < 0 || temperature > 1) { + throw new AIProviderError( + 'Temperature must be a number between 0.0 and 1.0', + AIErrorType.INVALID_REQUEST + ); + } + } + } + + /** + * Validates top-p parameter. + * + * @private + * @param topP - Top-p value to validate + * @throws {AIProviderError} If top-p is invalid + */ + private validateTopP(topP?: number): void { + if (topP !== undefined) { + if (typeof topP !== 'number' || topP <= 0 || topP > 1) { + throw new AIProviderError( + 'Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)', + AIErrorType.INVALID_REQUEST + ); + } + } + } + + /** + * Validates max tokens parameter. + * + * @private + * @param maxTokens - Max tokens value to validate + * @throws {AIProviderError} If max tokens is invalid + */ + private validateMaxTokens(maxTokens?: number): void { + if (maxTokens !== undefined) { + if (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens < 1) { + throw new AIProviderError( + 'Max tokens must be a positive integer', + AIErrorType.INVALID_REQUEST + ); + } + } + } + + /** + * Validates stop sequences parameter. + * + * @private + * @param stopSequences - Stop sequences to validate + * @throws {AIProviderError} If stop sequences are invalid + */ + 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 + ); + } + } + } + } + + /** + * 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 already normalized, return as-is if (error instanceof AIProviderError) { return error; } - // Handle common HTTP status codes - if ('status' in error) { - const status = (error as any).status; + // Extract status code if available + const status = (error as any).status || (error as any).statusCode; + const message = error.message || 'Unknown error occurred'; + + // Map HTTP status codes to error types + if (status) { switch (status) { + case 400: + return new AIProviderError( + `Bad request: ${message}`, + AIErrorType.INVALID_REQUEST, + status, + error + ); + case 401: case 403: return new AIProviderError( - 'Authentication failed. Please check your API key.', + 'Authentication failed. Please verify your API key is correct and has the necessary permissions.', AIErrorType.AUTHENTICATION, status, error ); - case 429: - return new AIProviderError( - 'Rate limit exceeded. Please try again later.', - AIErrorType.RATE_LIMIT, - status, - error - ); + case 404: return new AIProviderError( - 'Model or endpoint not found.', + 'The specified model or endpoint was not found. Please check the model name and availability.', AIErrorType.MODEL_NOT_FOUND, status, error ); - case 400: + + case 429: return new AIProviderError( - 'Invalid request parameters.', - AIErrorType.INVALID_REQUEST, + 'Rate limit exceeded. Please reduce your request frequency and try again later.', + AIErrorType.RATE_LIMIT, + status, + error + ); + + case 500: + case 502: + case 503: + case 504: + return new AIProviderError( + 'Service temporarily unavailable. Please try again in a few moments.', + AIErrorType.NETWORK, status, error ); } } - // Handle timeout errors - if (error.message.includes('timeout') || error.message.includes('ETIMEDOUT')) { + // Map common error patterns + if (message.includes('timeout') || message.includes('ETIMEDOUT')) { return new AIProviderError( - 'Request timed out. Please try again.', + 'Request timed out. The operation took longer than expected.', AIErrorType.TIMEOUT, undefined, error ); } - // Handle network errors - if (error.message.includes('network') || error.message.includes('ENOTFOUND')) { + if (message.includes('network') || message.includes('ENOTFOUND') || message.includes('ECONNREFUSED')) { return new AIProviderError( - 'Network error occurred. Please check your connection.', + 'Network error occurred. Please check your internet connection and try again.', AIErrorType.NETWORK, undefined, error ); } - // Default to unknown error + // Default to unknown error type return new AIProviderError( - `Unknown error: ${error.message}`, + `Provider error: ${message}`, AIErrorType.UNKNOWN, - undefined, + status, error ); } diff --git a/src/providers/claude.ts b/src/providers/claude.ts index cf9a14c..2c80e72 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,6 +1,26 @@ /** - * Claude AI Provider implementation using Anthropic's API - * Provides integration with Claude models through a standardized interface + * Anthropic Claude AI Provider Implementation + * + * This module provides integration with Anthropic's Claude models through their official SDK. + * Claude is known for its advanced reasoning capabilities, long context length, and excellent + * performance on complex analytical tasks. + * + * Key Features: + * - Support for all Claude 3.x model variants (Opus, Sonnet, Haiku) + * - Native streaming support with real-time response chunks + * - Advanced system message handling (separate from conversation context) + * - Comprehensive error mapping for Anthropic-specific issues + * - Automatic retry logic with exponential backoff + * + * Claude-Specific Considerations: + * - System messages must be passed separately from conversation messages + * - Uses "input_tokens" and "output_tokens" terminology instead of "prompt_tokens" + * - Supports advanced features like function calling and vision (model-dependent) + * - Has specific rate limiting and token usage patterns + * + * @author Jan-Marlon Leibl + * @version 1.0.0 + * @see https://docs.anthropic.com/claude/reference/ */ import Anthropic from '@anthropic-ai/sdk'; @@ -15,48 +35,168 @@ import type { import { BaseAIProvider } from './base.js'; import { AIProviderError, AIErrorType } from '../types/index.js'; +// ============================================================================ +// TYPES AND INTERFACES +// ============================================================================ + /** - * Configuration specific to Claude provider + * Configuration interface for Claude provider with Anthropic-specific options. + * + * @example + * ```typescript + * const config: ClaudeConfig = { + * apiKey: process.env.ANTHROPIC_API_KEY!, + * defaultModel: 'claude-3-5-sonnet-20241022', + * version: '2023-06-01', + * timeout: 60000, + * maxRetries: 3 + * }; + * ``` */ export interface ClaudeConfig extends AIProviderConfig { - /** Default model to use if not specified in requests (default: claude-3-5-sonnet-20241022) */ + /** + * Default Claude model to use for requests. + * + * Recommended models: + * - 'claude-3-5-sonnet-20241022': Best balance of capability and speed + * - 'claude-3-5-haiku-20241022': Fastest responses, good for simple tasks + * - 'claude-3-opus-20240229': Most capable, best for complex reasoning + * + * @default 'claude-3-5-sonnet-20241022' + */ defaultModel?: string; - /** Anthropic API version (default: 2023-06-01) */ + + /** + * Anthropic API version header value. + * + * This ensures compatibility with specific API features and response formats. + * Check Anthropic's documentation for the latest version. + * + * @default '2023-06-01' + * @see https://docs.anthropic.com/claude/reference/versioning + */ version?: string; } /** - * Claude AI provider implementation + * Internal interface for processed message structure. + * Claude requires system messages to be separated from conversation flow. + */ +interface ProcessedMessages { + /** Combined system message content (null if no system messages) */ + system: string | null; + /** Conversation messages (user/assistant only) */ + messages: AIMessage[]; +} + +// ============================================================================ +// CLAUDE PROVIDER IMPLEMENTATION +// ============================================================================ + +/** + * Anthropic Claude AI provider implementation. + * + * This class handles all interactions with Anthropic's Claude models through + * their official TypeScript SDK. It provides optimized handling of Claude's + * unique features like separate system message processing and advanced + * streaming capabilities. + * + * Usage Pattern: + * 1. Create instance with configuration + * 2. Call initialize() to set up client and validate connection + * 3. Use complete() or stream() for text generation + * 4. Handle any AIProviderError exceptions appropriately + * + * @example + * ```typescript + * const claude = new ClaudeProvider({ + * apiKey: process.env.ANTHROPIC_API_KEY!, + * defaultModel: 'claude-3-5-sonnet-20241022' + * }); + * + * await claude.initialize(); + * + * const response = await claude.complete({ + * messages: [ + * { role: 'system', content: 'You are a helpful assistant.' }, + * { role: 'user', content: 'Explain quantum computing.' } + * ], + * maxTokens: 1000, + * temperature: 0.7 + * }); + * ``` */ export class ClaudeProvider extends BaseAIProvider { + // ======================================================================== + // INSTANCE PROPERTIES + // ======================================================================== + + /** Anthropic SDK client instance (initialized during doInitialize) */ private client: Anthropic | null = null; + + /** Default model identifier for requests */ private readonly defaultModel: string; + + /** API version for Anthropic requests */ private readonly version: string; - constructor(config: ClaudeConfig) { - super(config); - this.defaultModel = config.defaultModel || 'claude-3-5-sonnet-20241022'; - this.version = config.version || '2023-06-01'; - } + // ======================================================================== + // CONSTRUCTOR + // ======================================================================== /** - * Initialize the Claude provider by setting up the Anthropic client + * Creates a new Claude provider instance. + * + * @param config - Claude-specific configuration options + * @throws {AIProviderError} If configuration validation fails + */ + constructor(config: ClaudeConfig) { + super(config); + + // Set Claude-specific defaults + this.defaultModel = config.defaultModel || 'claude-3-5-sonnet-20241022'; + this.version = config.version || '2023-06-01'; + + // Validate model name format + this.validateModelName(this.defaultModel); + } + + // ======================================================================== + // PROTECTED TEMPLATE METHOD IMPLEMENTATIONS + // ======================================================================== + + /** + * Initializes the Claude provider by setting up the Anthropic client. + * + * This method: + * 1. Creates the Anthropic SDK client with configuration + * 2. Tests the connection with a minimal API call + * 3. Validates API key permissions and model access + * + * @protected + * @throws {Error} If client creation or connection validation fails */ protected async doInitialize(): Promise { try { + // Create Anthropic client with optimized configuration this.client = new Anthropic({ apiKey: this.config.apiKey, baseURL: this.config.baseUrl, timeout: this.config.timeout, maxRetries: this.config.maxRetries, defaultHeaders: { - 'anthropic-version': this.version + 'anthropic-version': this.version, + 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', // Enable extended context } }); - // Test the connection by making a simple request + // Validate connection and permissions await this.validateConnection(); + } catch (error) { + // Clean up on failure + this.client = null; + throw new AIProviderError( `Failed to initialize Claude provider: ${(error as Error).message}`, AIErrorType.AUTHENTICATION, @@ -67,118 +207,121 @@ export class ClaudeProvider extends BaseAIProvider { } /** - * Generate a completion using Claude + * Generates a text completion using Claude's message API. + * + * This method: + * 1. Processes messages to separate system content + * 2. Builds optimized request parameters + * 3. Makes API call with error handling + * 4. Formats response to standard interface + * + * @protected + * @param params - Validated completion parameters + * @returns Promise resolving to formatted completion response + * @throws {Error} If API request fails */ protected async doComplete(params: CompletionParams): Promise { if (!this.client) { - throw new AIProviderError('Client not initialized', AIErrorType.INVALID_REQUEST); + throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST); } try { - const { system, messages } = this.convertMessages(params.messages); + // Process messages for Claude's format requirements + const { system, messages } = this.processMessages(params.messages); - const response = await this.client.messages.create({ - model: params.model || this.defaultModel, - max_tokens: params.maxTokens || 1000, - temperature: params.temperature ?? 0.7, - top_p: params.topP, - stop_sequences: params.stopSequences, - system: system || undefined, - messages: messages.map(msg => ({ - role: msg.role as 'user' | 'assistant', - content: msg.content - })) - }); + // Build optimized request parameters + const requestParams = this.buildRequestParams(params, system, messages, false); + + // Make API request + const response = await this.client.messages.create(requestParams); + // Format and return response return this.formatCompletionResponse(response); + } catch (error) { throw this.handleAnthropicError(error as Error); } } /** - * Generate a streaming completion using Claude + * Generates a streaming text completion using Claude's streaming API. + * + * This method: + * 1. Processes messages and builds streaming request + * 2. Handles real-time stream chunks from Anthropic + * 3. Yields formatted chunks with proper completion tracking + * 4. Provides final usage statistics when stream completes + * + * @protected + * @param params - Validated completion parameters + * @returns AsyncIterable yielding completion chunks + * @throws {Error} If streaming request fails */ protected async *doStream(params: CompletionParams): AsyncIterable { if (!this.client) { - throw new AIProviderError('Client not initialized', AIErrorType.INVALID_REQUEST); + throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST); } try { - const { system, messages } = this.convertMessages(params.messages); + // Process messages for Claude's format requirements + const { system, messages } = this.processMessages(params.messages); - const stream = await this.client.messages.create({ - model: params.model || this.defaultModel, - max_tokens: params.maxTokens || 1000, - temperature: params.temperature ?? 0.7, - top_p: params.topP, - stop_sequences: params.stopSequences, - system: system || undefined, - messages: messages.map(msg => ({ - role: msg.role as 'user' | 'assistant', - content: msg.content - })), - stream: true - }); + // Build streaming request parameters + const requestParams = this.buildRequestParams(params, system, messages, true); + + // Create streaming request + const stream = await this.client.messages.create(requestParams); - let content = ''; - let messageId = ''; - - for await (const chunk of stream) { - if (chunk.type === 'message_start') { - messageId = chunk.message.id; - } else if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { - content += chunk.delta.text; - yield { - content: chunk.delta.text, - isComplete: false, - id: messageId - }; - } else if (chunk.type === 'message_delta' && chunk.usage) { - // Final chunk with usage information - yield { - content: '', - isComplete: true, - id: messageId, - usage: { - promptTokens: chunk.usage.input_tokens || 0, - completionTokens: chunk.usage.output_tokens || 0, - totalTokens: (chunk.usage.input_tokens || 0) + (chunk.usage.output_tokens || 0) - } - }; - } - } + // Process stream chunks + yield* this.processStreamChunks(stream); + } catch (error) { throw this.handleAnthropicError(error as Error); } } + // ======================================================================== + // PUBLIC INTERFACE METHODS + // ======================================================================== + /** - * Get information about the Claude provider + * Returns comprehensive information about the Claude provider. + * + * @returns Provider information including models, capabilities, and limits */ public getInfo(): ProviderInfo { return { name: 'Claude', version: '1.0.0', models: [ - 'claude-3-5-sonnet-20241022', - 'claude-3-5-haiku-20241022', - 'claude-3-opus-20240229', - 'claude-3-sonnet-20240229', - 'claude-3-haiku-20240307' + 'claude-3-5-sonnet-20241022', // Latest and most balanced + 'claude-3-5-haiku-20241022', // Fastest response times + 'claude-3-opus-20240229', // Most capable reasoning + 'claude-3-sonnet-20240229', // Previous generation balanced + 'claude-3-haiku-20240307' // Previous generation fast ], - maxContextLength: 200000, // Claude 3.5 Sonnet context length + maxContextLength: 200000, // 200K tokens for most models supportsStreaming: true, capabilities: { - vision: true, - functionCalling: true, - systemMessages: true + vision: true, // Claude 3+ supports image analysis + functionCalling: true, // Tool use capability + systemMessages: true, // Native system message support + reasoning: true, // Advanced reasoning capabilities + codeGeneration: true, // Excellent at code tasks + analysis: true // Strong analytical capabilities } }; } + // ======================================================================== + // PRIVATE UTILITY METHODS + // ======================================================================== + /** - * Validate the connection by making a simple request + * Validates the connection by making a minimal test request. + * + * @private + * @throws {AIProviderError} If connection validation fails */ private async validateConnection(): Promise { if (!this.client) { @@ -186,134 +329,349 @@ export class ClaudeProvider extends BaseAIProvider { } try { - // Make a minimal request to validate credentials + // Make minimal request to test connection and permissions await this.client.messages.create({ model: this.defaultModel, max_tokens: 1, messages: [{ role: 'user', content: 'Hi' }] }); + } catch (error: any) { + // Handle specific validation errors if (error.status === 401 || error.status === 403) { throw new AIProviderError( - 'Invalid API key. Please check your Anthropic API key.', + 'Invalid Anthropic API key. Please verify your API key from https://console.anthropic.com/', AIErrorType.AUTHENTICATION, error.status ); } - // For other errors during validation, we'll let initialization proceed - // as they might be temporary issues + + if (error.status === 404 && error.message?.includes('model')) { + throw new AIProviderError( + `Model '${this.defaultModel}' is not available. Please check the model name or your API access.`, + AIErrorType.MODEL_NOT_FOUND, + error.status + ); + } + + // For other errors during validation, log but don't fail initialization + // They might be temporary network issues + console.warn('Claude connection validation warning:', error.message); } } /** - * Convert our generic message format to Claude's format - * Claude requires system messages to be separate from the conversation + * Validates model name format for Claude models. + * + * @private + * @param modelName - Model name to validate + * @throws {AIProviderError} If model name format is invalid */ - private convertMessages(messages: AIMessage[]): { system: string | null; messages: AIMessage[] } { - let system: string | null = null; + private validateModelName(modelName: string): void { + if (!modelName || typeof modelName !== 'string') { + throw new AIProviderError( + 'Model name must be a non-empty string', + AIErrorType.INVALID_REQUEST + ); + } + + // Claude model names follow specific patterns + const validPatterns = [ + /^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/, // e.g., claude-3-5-sonnet-20241022 + /^claude-instant-[0-9.]+$/, // e.g., claude-instant-1.2 + /^claude-[0-9.]+$/ // e.g., claude-2.1 + ]; + + const isValid = validPatterns.some(pattern => pattern.test(modelName)); + + if (!isValid) { + console.warn(`Model name '${modelName}' doesn't match expected Claude naming patterns. This may cause API errors.`); + } + } + + /** + * Processes messages to separate system content from conversation. + * + * Claude requires system messages to be passed separately from the + * conversation messages array. This method handles that transformation. + * + * @private + * @param messages - Input messages array + * @returns Processed messages with separated system content + */ + private processMessages(messages: AIMessage[]): ProcessedMessages { + let systemContent: string | null = null; const conversationMessages: AIMessage[] = []; for (const message of messages) { if (message.role === 'system') { - // Combine multiple system messages - if (system) { - system += '\n\n' + message.content; + // Combine multiple system messages with double newlines + if (systemContent) { + systemContent += '\n\n' + message.content; } else { - system = message.content; + systemContent = message.content; } } else { + // Only user and assistant messages go in conversation conversationMessages.push(message); } } - return { system, messages: conversationMessages }; + // Validate conversation structure + if (conversationMessages.length === 0) { + throw new AIProviderError( + 'At least one user or assistant message is required', + AIErrorType.INVALID_REQUEST + ); + } + + return { + system: systemContent, + messages: conversationMessages + }; } /** - * Format Anthropic's response to our standard format + * Builds optimized request parameters for Anthropic API. + * + * @private + * @param params - Input completion parameters + * @param system - Processed system content + * @param messages - Processed conversation messages + * @param stream - Whether to enable streaming + * @returns Formatted request parameters + */ + private buildRequestParams( + params: CompletionParams, + system: string | null, + messages: AIMessage[], + stream: boolean + ) { + return { + model: params.model || this.defaultModel, + max_tokens: params.maxTokens || 1000, + temperature: params.temperature ?? 0.7, + top_p: params.topP, + stop_sequences: params.stopSequences, + system: system || undefined, + messages: messages.map(msg => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content + })), + stream + }; + } + + /** + * Processes streaming response chunks from Anthropic API. + * + * @private + * @param stream - Anthropic streaming response + * @returns AsyncIterable of formatted completion chunks + */ + private async *processStreamChunks(stream: any): AsyncIterable { + let messageId = ''; + let totalInputTokens = 0; + let totalOutputTokens = 0; + + try { + for await (const chunk of stream) { + // Handle different chunk types + switch (chunk.type) { + case 'message_start': + messageId = chunk.message.id; + totalInputTokens = chunk.message.usage?.input_tokens || 0; + break; + + case 'content_block_delta': + if (chunk.delta?.type === 'text_delta') { + yield { + content: chunk.delta.text, + isComplete: false, + id: messageId + }; + } + break; + + case 'message_delta': + if (chunk.usage?.output_tokens) { + totalOutputTokens = chunk.usage.output_tokens; + } + break; + + case 'message_stop': + // Final chunk with complete usage information + yield { + content: '', + isComplete: true, + id: messageId, + usage: { + promptTokens: totalInputTokens, + completionTokens: totalOutputTokens, + totalTokens: totalInputTokens + totalOutputTokens + } + }; + return; + } + } + } catch (error) { + // Handle streaming errors gracefully + throw new AIProviderError( + `Streaming interrupted: ${(error as Error).message}`, + AIErrorType.NETWORK, + undefined, + error as Error + ); + } + } + + /** + * Formats Anthropic's completion response to our standard interface. + * + * @private + * @param response - Raw Anthropic API response + * @returns Formatted completion response + * @throws {AIProviderError} If response format is unexpected */ private formatCompletionResponse(response: any): CompletionResponse { + // Extract text content from response blocks const content = response.content - .filter((block: any) => block.type === 'text') - .map((block: any) => block.text) - .join(''); + ?.filter((block: any) => block.type === 'text') + ?.map((block: any) => block.text) + ?.join('') || ''; + + if (!content) { + throw new AIProviderError( + 'No text content found in Claude response', + AIErrorType.UNKNOWN + ); + } return { content, model: response.model, usage: { - promptTokens: response.usage.input_tokens, - completionTokens: response.usage.output_tokens, - totalTokens: response.usage.input_tokens + response.usage.output_tokens + promptTokens: response.usage?.input_tokens || 0, + completionTokens: response.usage?.output_tokens || 0, + totalTokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0) }, id: response.id, metadata: { stopReason: response.stop_reason, - stopSequence: response.stop_sequence + stopSequence: response.stop_sequence || null, + created: Date.now() // Anthropic doesn't provide timestamp } }; } /** - * Handle Anthropic-specific errors and convert them to our standard format + * Handles and transforms Anthropic-specific errors. + * + * This method maps Anthropic's error responses to our standardized + * error format, providing helpful context and suggestions. + * + * @private + * @param error - Original error from Anthropic API + * @returns Normalized AIProviderError */ private handleAnthropicError(error: any): AIProviderError { if (error instanceof AIProviderError) { return error; } - const status = error.status || error.statusCode; const message = error.message || 'Unknown Anthropic API error'; + const status = error.status || error.statusCode; + // Map Anthropic-specific error codes switch (status) { case 400: + if (message.includes('max_tokens')) { + return new AIProviderError( + 'Max tokens value is invalid. Must be between 1 and model limit.', + AIErrorType.INVALID_REQUEST, + status, + error + ); + } + if (message.includes('model')) { + return new AIProviderError( + 'Invalid model specified. Please check model availability.', + AIErrorType.MODEL_NOT_FOUND, + status, + error + ); + } return new AIProviderError( `Invalid request: ${message}`, AIErrorType.INVALID_REQUEST, status, error ); + case 401: return new AIProviderError( - 'Authentication failed. Please check your Anthropic API key.', + 'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/', AIErrorType.AUTHENTICATION, status, error ); + case 403: return new AIProviderError( - 'Access forbidden. Please check your API key permissions.', + 'Access forbidden. Your API key may not have permission for this model or feature.', AIErrorType.AUTHENTICATION, status, error ); + case 404: return new AIProviderError( - 'Model not found. Please check the model name.', + 'Model not found. The specified Claude model may not be available to your account.', AIErrorType.MODEL_NOT_FOUND, status, error ); + case 429: return new AIProviderError( - 'Rate limit exceeded. Please slow down your requests.', + 'Rate limit exceeded. Claude APIs have usage limits. Please wait before retrying.', AIErrorType.RATE_LIMIT, status, error ); + case 500: case 502: case 503: - case 504: return new AIProviderError( - 'Anthropic service temporarily unavailable. Please try again later.', + 'Anthropic service temporarily unavailable. Please try again in a few moments.', AIErrorType.NETWORK, status, error ); + default: + // Handle timeout and network errors + if (message.includes('timeout') || error.code === 'ETIMEDOUT') { + return new AIProviderError( + 'Request timed out. Claude may be experiencing high load.', + AIErrorType.TIMEOUT, + status, + error + ); + } + + if (message.includes('network') || error.code === 'ECONNREFUSED') { + return new AIProviderError( + 'Network error connecting to Anthropic servers.', + AIErrorType.NETWORK, + status, + error + ); + } + return new AIProviderError( - `Anthropic API error: ${message}`, + `Claude API error: ${message}`, AIErrorType.UNKNOWN, status, error diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts index fe06b1d..2e21a30 100644 --- a/src/providers/gemini.ts +++ b/src/providers/gemini.ts @@ -1,10 +1,32 @@ /** - * Gemini Provider implementation using Google's Generative AI API - * Provides integration with Gemini models through a standardized interface + * Google Gemini Provider Implementation + * + * This module provides integration with Google's Gemini models through the official + * Generative AI SDK. Gemini offers cutting-edge AI capabilities including multimodal + * understanding, advanced reasoning, and efficient text generation across various tasks. + * + * Key Features: + * - Support for all Gemini model variants (Gemini 1.5 Pro, Flash, Pro Vision) + * - Advanced streaming support with real-time token delivery + * - Native multimodal capabilities (text, images, video) + * - Sophisticated safety settings and content filtering + * - Flexible generation configuration with fine-grained control + * + * Gemini-Specific Considerations: + * - Uses "candidates" for response variants and "usageMetadata" for token counts + * - Supports system instructions as separate parameter (not in conversation) + * - Has sophisticated safety filtering with customizable thresholds + * - Provides extensive generation configuration options + * - Supports both single-turn and multi-turn conversations + * - Offers advanced reasoning and coding capabilities + * + * @author Jan-Marlon Leibl + * @version 1.0.0 + * @see https://ai.google.dev/docs */ import { GoogleGenerativeAI, GenerativeModel } from '@google/generative-ai'; -import type { Content, Part } from '@google/generative-ai'; +import type { Content, Part, GenerationConfig, SafetySetting } from '@google/generative-ai'; import type { AIProviderConfig, CompletionParams, @@ -16,56 +38,213 @@ import type { import { BaseAIProvider } from './base.js'; import { AIProviderError, AIErrorType } from '../types/index.js'; +// ============================================================================ +// TYPES AND INTERFACES +// ============================================================================ + /** - * Configuration specific to Gemini provider + * Configuration interface for Gemini provider with Google-specific options. + * + * @example + * ```typescript + * const config: GeminiConfig = { + * apiKey: process.env.GOOGLE_API_KEY!, + * defaultModel: 'gemini-1.5-pro', + * safetySettings: [ + * { + * category: HarmCategory.HARM_CATEGORY_HARASSMENT, + * threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + * } + * ], + * generationConfig: { + * temperature: 0.7, + * topP: 0.8, + * topK: 40, + * maxOutputTokens: 2048 + * } + * }; + * ``` */ export interface GeminiConfig extends AIProviderConfig { - /** Default model to use if not specified in requests (default: gemini-1.5-flash) */ + /** + * Default Gemini model to use for requests. + * + * Recommended models: + * - 'gemini-1.5-pro': Flagship model, best overall performance and multimodal + * - 'gemini-1.5-flash': Faster and cheaper, good for simple tasks + * - 'gemini-1.0-pro': Previous generation, cost-effective + * - 'gemini-pro-vision': Specialized for vision tasks (legacy) + * + * @default 'gemini-1.5-flash' + */ defaultModel?: string; - /** Safety settings for content filtering */ - safetySettings?: any[]; - /** Generation configuration */ + + /** + * Safety settings for content filtering and harm prevention. + * + * Gemini includes built-in safety filtering across multiple categories: + * - Harassment and bullying + * - Hate speech and discrimination + * - Sexually explicit content + * - Dangerous or harmful activities + * + * Each category can be configured with different blocking thresholds. + * + * @see https://ai.google.dev/docs/safety_setting + */ + safetySettings?: SafetySetting[]; + + /** + * Generation configuration for controlling output characteristics. + * + * This allows fine-tuned control over the generation process including + * creativity, diversity, length, and stopping conditions. + * + * @see https://ai.google.dev/docs/concepts#generation_configuration + */ generationConfig?: { + /** Controls randomness in generation (0.0 to 1.0) */ temperature?: number; + /** Controls nucleus sampling for diversity (0.0 to 1.0) */ topP?: number; + /** Controls top-k sampling for diversity (positive integer) */ topK?: number; + /** Maximum number of tokens to generate */ maxOutputTokens?: number; + /** Sequences that will stop generation when encountered */ stopSequences?: string[]; }; } /** - * Gemini provider implementation + * Processed messages structure for Gemini's conversation format. + * Separates system instructions from conversational content. + */ +interface ProcessedMessages { + /** System instruction for model behavior (if any) */ + systemInstruction?: string; + /** Conversation content in Gemini's format */ + contents: Content[]; +} + +// ============================================================================ +// GEMINI PROVIDER IMPLEMENTATION +// ============================================================================ + +/** + * Google Gemini provider implementation. + * + * This class handles all interactions with Google's Gemini models through their + * official Generative AI SDK. It provides optimized handling of Gemini's unique + * features including multimodal inputs, safety filtering, and advanced generation control. + * + * Usage Pattern: + * 1. Create instance with Google API key and configuration + * 2. Call initialize() to set up client and validate credentials + * 3. Use complete() or stream() for text generation + * 4. Handle any AIProviderError exceptions appropriately + * + * @example + * ```typescript + * const gemini = new GeminiProvider({ + * apiKey: process.env.GOOGLE_API_KEY!, + * defaultModel: 'gemini-1.5-pro', + * generationConfig: { + * temperature: 0.7, + * maxOutputTokens: 2048 + * } + * }); + * + * await gemini.initialize(); + * + * const response = await gemini.complete({ + * messages: [ + * { role: 'system', content: 'You are a helpful research assistant.' }, + * { role: 'user', content: 'Explain quantum computing.' } + * ], + * maxTokens: 1000, + * temperature: 0.8 + * }); + * ``` */ export class GeminiProvider extends BaseAIProvider { + // ======================================================================== + // INSTANCE PROPERTIES + // ======================================================================== + + /** Google Generative AI client instance (initialized during doInitialize) */ private client: GoogleGenerativeAI | null = null; + + /** Default model instance for requests */ private model: GenerativeModel | null = null; + + /** Default model identifier for requests */ private readonly defaultModel: string; - private readonly safetySettings?: any[]; + + /** Safety settings for content filtering */ + private readonly safetySettings?: SafetySetting[]; + + /** Generation configuration defaults */ private readonly generationConfig?: any; + // ======================================================================== + // CONSTRUCTOR + // ======================================================================== + + /** + * Creates a new Gemini provider instance. + * + * @param config - Gemini-specific configuration options + * @throws {AIProviderError} If configuration validation fails + */ constructor(config: GeminiConfig) { super(config); + + // Set Gemini-specific defaults this.defaultModel = config.defaultModel || 'gemini-1.5-flash'; this.safetySettings = config.safetySettings; this.generationConfig = config.generationConfig; + + // Validate model name format + this.validateModelName(this.defaultModel); } + // ======================================================================== + // PROTECTED TEMPLATE METHOD IMPLEMENTATIONS + // ======================================================================== + /** - * Initialize the Gemini provider by setting up the Google Generative AI client + * Initializes the Gemini provider by setting up the client and model. + * + * This method: + * 1. Creates the Google Generative AI client with API key + * 2. Sets up the default model with safety and generation settings + * 3. Tests the connection with a minimal API call + * 4. Validates API key permissions and model access + * + * @protected + * @throws {Error} If client creation or connection validation fails */ protected async doInitialize(): Promise { try { + // Create Google Generative AI client this.client = new GoogleGenerativeAI(this.config.apiKey); + + // Set up default model with configuration this.model = this.client.getGenerativeModel({ model: this.defaultModel, safetySettings: this.safetySettings, generationConfig: this.generationConfig }); - // Test the connection by making a simple request + // Validate connection and permissions await this.validateConnection(); + } catch (error) { + // Clean up on failure + this.client = null; + this.model = null; + throw new AIProviderError( `Failed to initialize Gemini provider: ${(error as Error).message}`, AIErrorType.AUTHENTICATION, @@ -76,26 +255,30 @@ export class GeminiProvider extends BaseAIProvider { } /** - * Generate a completion using Gemini + * Generates a text completion using Gemini's generation API. + * + * This method: + * 1. Converts messages to Gemini's format with system instructions + * 2. Chooses between single-turn and multi-turn conversation modes + * 3. Makes API call with comprehensive error handling + * 4. Formats response to standard interface + * + * @protected + * @param params - Validated completion parameters + * @returns Promise resolving to formatted completion response + * @throws {Error} If API request fails */ protected async doComplete(params: CompletionParams): Promise { if (!this.client || !this.model) { - throw new AIProviderError('Client not initialized', AIErrorType.INVALID_REQUEST); + throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST); } try { // Get the model for this request (might be different from default) - const model = params.model && params.model !== this.defaultModel - ? this.client.getGenerativeModel({ - model: params.model, - safetySettings: this.safetySettings, - generationConfig: this.buildGenerationConfig(params) - }) - : this.model; - + const model = this.getModelForRequest(params); const { systemInstruction, contents } = this.convertMessages(params.messages); - // Create chat session or use generateContent + // Choose appropriate generation method based on conversation length if (contents.length > 1) { // Multi-turn conversation - use chat session const chat = model.startChat({ @@ -108,9 +291,10 @@ export class GeminiProvider extends BaseAIProvider { if (!lastMessage) { throw new AIProviderError('No valid messages provided', AIErrorType.INVALID_REQUEST); } - const result = await chat.sendMessage(lastMessage.parts); + const result = await chat.sendMessage(lastMessage.parts); return this.formatCompletionResponse(result.response, params.model || this.defaultModel); + } else { // Single message - use generateContent const result = await model.generateContent({ @@ -127,25 +311,30 @@ export class GeminiProvider extends BaseAIProvider { } /** - * Generate a streaming completion using Gemini + * Generates a streaming text completion using Gemini's streaming API. + * + * This method: + * 1. Sets up appropriate streaming mode based on conversation type + * 2. Handles real-time stream chunks from Gemini + * 3. Tracks token usage throughout the stream + * 4. Yields formatted chunks with proper completion tracking + * + * @protected + * @param params - Validated completion parameters + * @returns AsyncIterable yielding completion chunks + * @throws {Error} If streaming request fails */ protected async *doStream(params: CompletionParams): AsyncIterable { if (!this.client || !this.model) { - throw new AIProviderError('Client not initialized', AIErrorType.INVALID_REQUEST); + throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST); } try { // Get the model for this request - const model = params.model && params.model !== this.defaultModel - ? this.client.getGenerativeModel({ - model: params.model, - safetySettings: this.safetySettings, - generationConfig: this.buildGenerationConfig(params) - }) - : this.model; - + const model = this.getModelForRequest(params); const { systemInstruction, contents } = this.convertMessages(params.messages); + // Set up streaming based on conversation type let stream; if (contents.length > 1) { // Multi-turn conversation @@ -169,9 +358,221 @@ export class GeminiProvider extends BaseAIProvider { }); } - let fullText = ''; - const requestId = `gemini-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + // Process stream chunks + yield* this.processStreamChunks(stream); + + } catch (error) { + throw this.handleGeminiError(error as Error); + } + } + // ======================================================================== + // PUBLIC INTERFACE METHODS + // ======================================================================== + + /** + * Returns comprehensive information about the Gemini provider. + * + * @returns Provider information including models, capabilities, and limits + */ + public getInfo(): ProviderInfo { + return { + name: 'Gemini', + version: '1.0.0', + models: [ + 'gemini-1.5-pro', // Latest flagship model + 'gemini-1.5-flash', // Fast and efficient variant + 'gemini-1.0-pro', // Previous generation + 'gemini-pro-vision', // Vision-specialized (legacy) + 'gemini-1.5-pro-vision', // Latest vision model + 'gemini-1.0-pro-latest', // Latest 1.0 variant + 'gemini-1.5-pro-latest' // Latest 1.5 variant + ], + maxContextLength: 1048576, // ~1M tokens for Gemini 1.5 + supportsStreaming: true, + capabilities: { + vision: true, // Advanced multimodal capabilities + functionCalling: true, // Tool use and function calling + jsonMode: true, // Structured JSON output + systemMessages: true, // System instructions support + reasoning: true, // Strong reasoning capabilities + codeGeneration: true, // Excellent at programming tasks + multimodal: true, // Text, image, video understanding + safetyFiltering: true // Built-in content safety + } + }; + } + + // ======================================================================== + // PRIVATE UTILITY METHODS + // ======================================================================== + + /** + * Validates the connection by making a minimal test request. + * + * @private + * @throws {AIProviderError} If connection validation fails + */ + private async validateConnection(): Promise { + if (!this.model) { + throw new Error('Model not initialized'); + } + + try { + // Make minimal request to test connection and permissions + await this.model.generateContent({ + contents: [{ role: 'user', parts: [{ text: 'Hi' }] }], + generationConfig: { maxOutputTokens: 1 } + }); + + } catch (error: any) { + // Handle specific validation errors + if (error.message?.includes('API key')) { + throw new AIProviderError( + 'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey', + AIErrorType.AUTHENTICATION, + error.status + ); + } + + if (error.message?.includes('quota') || error.message?.includes('billing')) { + throw new AIProviderError( + 'API quota exceeded or billing issue. Please check your Google Cloud billing and API quotas.', + AIErrorType.AUTHENTICATION, + error.status + ); + } + + if (error.message?.includes('model')) { + throw new AIProviderError( + `Model '${this.defaultModel}' is not available. Please check the model name or your API access level.`, + AIErrorType.MODEL_NOT_FOUND, + error.status + ); + } + + // For other errors during validation, log but don't fail initialization + console.warn('Gemini connection validation warning:', error.message); + } + } + + /** + * Validates model name format for Gemini models. + * + * @private + * @param modelName - Model name to validate + * @throws {AIProviderError} If model name format is invalid + */ + private validateModelName(modelName: string): void { + if (!modelName || typeof modelName !== 'string') { + throw new AIProviderError( + 'Model name must be a non-empty string', + AIErrorType.INVALID_REQUEST + ); + } + + // Gemini model names follow specific patterns + const validPatterns = [ + /^gemini-1\.5-pro(?:-latest|-vision)?$/, // e.g., gemini-1.5-pro, gemini-1.5-pro-latest + /^gemini-1\.5-flash(?:-latest)?$/, // e.g., gemini-1.5-flash, gemini-1.5-flash-latest + /^gemini-1\.0-pro(?:-latest|-vision)?$/, // e.g., gemini-1.0-pro, gemini-pro-vision + /^gemini-pro(?:-vision)?$/, // Legacy names: gemini-pro, gemini-pro-vision + /^models\/gemini-.+$/ // Full model path format + ]; + + const isValid = validPatterns.some(pattern => pattern.test(modelName)); + + if (!isValid) { + console.warn(`Model name '${modelName}' doesn't match expected Gemini naming patterns. This may cause API errors.`); + } + } + + /** + * Gets the appropriate model instance for a request. + * + * @private + * @param params - Completion parameters + * @returns GenerativeModel instance + */ + private getModelForRequest(params: CompletionParams): GenerativeModel { + if (!this.client) { + throw new Error('Client not initialized'); + } + + // Use default model if no specific model requested + if (!params.model || params.model === this.defaultModel) { + return this.model!; + } + + // Create new model instance for different model + return this.client.getGenerativeModel({ + model: params.model, + safetySettings: this.safetySettings, + generationConfig: this.buildGenerationConfig(params) + }); + } + + /** + * Converts generic messages to Gemini's conversation format. + * + * Gemini handles system messages as separate system instructions + * rather than part of the conversation flow. + * + * @private + * @param messages - Input messages array + * @returns Processed messages with system instruction separated + */ + private convertMessages(messages: AIMessage[]): ProcessedMessages { + let systemInstruction: string | undefined; + const contents: Content[] = []; + + for (const message of messages) { + if (message.role === 'system') { + // Combine multiple system messages + systemInstruction = systemInstruction + ? `${systemInstruction}\n\n${message.content}` + : message.content; + } else { + contents.push({ + role: message.role === 'assistant' ? 'model' : 'user', + parts: [{ text: message.content }] + }); + } + } + + return { systemInstruction, contents }; + } + + /** + * Builds generation configuration from completion parameters. + * + * @private + * @param params - Completion parameters + * @returns Gemini generation configuration + */ + private buildGenerationConfig(params: CompletionParams): GenerationConfig { + return { + temperature: params.temperature ?? this.generationConfig?.temperature ?? 0.7, + topP: params.topP ?? this.generationConfig?.topP, + topK: this.generationConfig?.topK, + maxOutputTokens: params.maxTokens ?? this.generationConfig?.maxOutputTokens ?? 1000, + stopSequences: params.stopSequences ?? this.generationConfig?.stopSequences + }; + } + + /** + * Processes streaming response chunks from Gemini API. + * + * @private + * @param stream - Gemini streaming response + * @returns AsyncIterable of formatted completion chunks + */ + private async *processStreamChunks(stream: any): AsyncIterable { + let fullText = ''; + const requestId = `gemini-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + try { + // Process streaming chunks for await (const chunk of stream.stream) { const chunkText = chunk.text(); fullText += chunkText; @@ -183,7 +584,7 @@ export class GeminiProvider extends BaseAIProvider { }; } - // Final chunk with usage info + // Final chunk with usage information const finalResponse = await stream.response; const usageMetadata = finalResponse.usageMetadata; @@ -198,128 +599,64 @@ export class GeminiProvider extends BaseAIProvider { } }; } catch (error) { - throw this.handleGeminiError(error as Error); + throw new AIProviderError( + `Streaming interrupted: ${(error as Error).message}`, + AIErrorType.NETWORK, + undefined, + error as Error + ); } } /** - * Get information about the Gemini provider - */ - public getInfo(): ProviderInfo { - return { - name: 'Gemini', - version: '1.0.0', - models: [ - 'gemini-1.5-flash', - 'gemini-1.5-flash-8b', - 'gemini-1.5-pro', - 'gemini-1.0-pro', - 'gemini-1.0-pro-vision' - ], - maxContextLength: 1000000, // Gemini 1.5 context length - supportsStreaming: true, - capabilities: { - vision: true, - functionCalling: true, - systemMessages: true, - multimodal: true, - largeContext: true - } - }; - } - - /** - * Validate the connection by making a simple request - */ - private async validateConnection(): Promise { - if (!this.model) { - throw new Error('Model not initialized'); - } - - try { - // Make a minimal request to validate credentials - await this.model.generateContent('Hi'); - } catch (error: any) { - if (error.message?.includes('API key') || error.message?.includes('authentication')) { - throw new AIProviderError( - 'Invalid API key. Please check your Google AI API key.', - AIErrorType.AUTHENTICATION - ); - } - // For other errors during validation, we'll let initialization proceed - // as they might be temporary issues - } - } - - /** - * Convert our generic message format to Gemini's format - * Gemini uses Contents with Parts and supports system instructions separately - */ - private convertMessages(messages: AIMessage[]): { systemInstruction?: string; contents: Content[] } { - let systemInstruction: string | undefined; - const contents: Content[] = []; - - for (const message of messages) { - if (message.role === 'system') { - // Combine multiple system messages - if (systemInstruction) { - systemInstruction += '\n\n' + message.content; - } else { - systemInstruction = message.content; - } - } else { - contents.push({ - role: message.role === 'assistant' ? 'model' : 'user', - parts: [{ text: message.content }] - }); - } - } - - return { systemInstruction, contents }; - } - - /** - * Build generation config from completion parameters - */ - private buildGenerationConfig(params: CompletionParams) { - return { - temperature: params.temperature ?? 0.7, - topP: params.topP, - maxOutputTokens: params.maxTokens || 1000, - stopSequences: params.stopSequences, - ...this.generationConfig - }; - } - - /** - * Format Gemini's response to our standard format + * Formats Gemini's response to our standard interface. + * + * @private + * @param response - Raw Gemini API response + * @param model - Model used for generation + * @returns Formatted completion response + * @throws {AIProviderError} If response format is unexpected */ private formatCompletionResponse(response: any, model: string): CompletionResponse { + // Handle multiple text parts in the response const candidate = response.candidates?.[0]; - if (!candidate || !candidate.content?.parts?.[0]?.text) { + if (!candidate) { throw new AIProviderError( - 'No content in Gemini response', + 'No candidates found in Gemini response', AIErrorType.UNKNOWN ); } - const content = candidate.content.parts - .filter((part: Part) => part.text) - .map((part: Part) => part.text) + const content = candidate.content; + if (!content || !content.parts) { + throw new AIProviderError( + 'No content found in Gemini response', + AIErrorType.UNKNOWN + ); + } + + // Combine all text parts + const text = content.parts + .filter((part: any) => part.text) + .map((part: any) => part.text) .join(''); - const usageMetadata = response.usageMetadata; - const requestId = `gemini-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + if (!text) { + throw new AIProviderError( + 'No text content found in Gemini response', + AIErrorType.UNKNOWN + ); + } return { - content, - model, + content: text, + model: model, usage: { - promptTokens: usageMetadata?.promptTokenCount || 0, - completionTokens: usageMetadata?.candidatesTokenCount || 0, - totalTokens: usageMetadata?.totalTokenCount || 0 + promptTokens: response.usageMetadata?.promptTokenCount || 0, + completionTokens: response.usageMetadata?.candidatesTokenCount || 0, + totalTokens: response.usageMetadata?.totalTokenCount || 0 }, - id: requestId, + id: `gemini-${Date.now()}`, metadata: { finishReason: candidate.finishReason, safetyRatings: candidate.safetyRatings, @@ -329,7 +666,14 @@ export class GeminiProvider extends BaseAIProvider { } /** - * Handle Gemini-specific errors and convert them to our standard format + * Handles and transforms Gemini-specific errors. + * + * This method maps Gemini's error responses to our standardized + * error format, providing helpful context and actionable suggestions. + * + * @private + * @param error - Original error from Gemini API + * @returns Normalized AIProviderError */ private handleGeminiError(error: any): AIProviderError { if (error instanceof AIProviderError) { @@ -337,67 +681,124 @@ export class GeminiProvider extends BaseAIProvider { } const message = error.message || 'Unknown Gemini API error'; + const status = error.status || error.statusCode; - // Handle common Gemini error patterns + // Map Gemini-specific error patterns if (message.includes('API key')) { return new AIProviderError( - 'Authentication failed. Please check your Google AI API key.', + 'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey', AIErrorType.AUTHENTICATION, - undefined, + status, error ); } - if (message.includes('quota') || message.includes('rate limit')) { + if (message.includes('quota') || message.includes('limit exceeded')) { return new AIProviderError( - 'Rate limit exceeded. Please slow down your requests.', + 'API quota exceeded. Please check your Google Cloud quotas and billing.', AIErrorType.RATE_LIMIT, - undefined, + status, error ); } - if (message.includes('model') && message.includes('not found')) { + if (message.includes('model not found') || message.includes('invalid model')) { return new AIProviderError( - 'Model not found. Please check the model name.', + 'Model not found. The specified Gemini model may not exist or be available to your API key.', AIErrorType.MODEL_NOT_FOUND, - undefined, + status, error ); } - if (message.includes('invalid') || message.includes('bad request')) { + if (message.includes('safety') || message.includes('blocked')) { return new AIProviderError( - `Invalid request: ${message}`, + 'Content blocked by safety filters. Please modify your input or adjust safety settings.', AIErrorType.INVALID_REQUEST, - undefined, + status, error ); } - if (message.includes('network') || message.includes('connection')) { + if (message.includes('length') || message.includes('token')) { return new AIProviderError( - 'Network error occurred. Please check your connection.', - AIErrorType.NETWORK, - undefined, + 'Input too long or exceeds token limits. Please reduce input size or max tokens.', + AIErrorType.INVALID_REQUEST, + status, error ); } if (message.includes('timeout')) { return new AIProviderError( - 'Request timed out. Please try again.', + 'Request timed out. Gemini may be experiencing high load.', AIErrorType.TIMEOUT, - undefined, + status, error ); } - return new AIProviderError( - `Gemini API error: ${message}`, - AIErrorType.UNKNOWN, - undefined, - error - ); + if (message.includes('network') || message.includes('connection')) { + return new AIProviderError( + 'Network error connecting to Gemini servers.', + AIErrorType.NETWORK, + status, + error + ); + } + + // Handle HTTP status codes + switch (status) { + case 400: + return new AIProviderError( + `Invalid request to Gemini API: ${message}`, + AIErrorType.INVALID_REQUEST, + status, + error + ); + + case 401: + case 403: + return new AIProviderError( + 'Authentication failed with Gemini API. Please check your API key and permissions.', + AIErrorType.AUTHENTICATION, + status, + error + ); + + case 404: + return new AIProviderError( + 'Gemini API endpoint not found. Please check the model name and API version.', + AIErrorType.MODEL_NOT_FOUND, + status, + error + ); + + case 429: + return new AIProviderError( + 'Rate limit exceeded. Please reduce request frequency.', + AIErrorType.RATE_LIMIT, + status, + error + ); + + case 500: + case 502: + case 503: + return new AIProviderError( + 'Gemini service temporarily unavailable. Please try again in a few moments.', + AIErrorType.NETWORK, + status, + error + ); + + default: + return new AIProviderError( + `Gemini API error: ${message}`, + AIErrorType.UNKNOWN, + status, + error + ); + } } } \ No newline at end of file diff --git a/src/providers/openai.ts b/src/providers/openai.ts index ce22062..96a2f43 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -1,6 +1,27 @@ /** - * OpenAI Provider implementation using OpenAI's API - * Provides integration with GPT models through a standardized interface + * OpenAI GPT Provider Implementation + * + * This module provides integration with OpenAI's GPT models through their official SDK. + * OpenAI offers industry-leading language models with excellent performance across + * diverse tasks including reasoning, coding, creative writing, and function calling. + * + * Key Features: + * - Support for all GPT model variants (GPT-4, GPT-4o, GPT-3.5 Turbo) + * - Advanced streaming support with real-time token delivery + * - Native function calling and JSON mode capabilities + * - Vision support for image understanding (model-dependent) + * - Organization and project-level access control + * + * OpenAI-Specific Considerations: + * - Uses "prompt_tokens" and "completion_tokens" terminology + * - Supports system messages natively within conversation flow + * - Has sophisticated rate limiting and pricing tiers + * - Provides extensive model configuration options + * - Supports batch processing and fine-tuning capabilities + * + * @author Jan-Marlon Leibl + * @version 1.0.0 + * @see https://platform.openai.com/docs/api-reference */ import OpenAI from 'openai'; @@ -15,51 +36,191 @@ import type { import { BaseAIProvider } from './base.js'; import { AIProviderError, AIErrorType } from '../types/index.js'; +// ============================================================================ +// TYPES AND INTERFACES +// ============================================================================ + /** - * Configuration specific to OpenAI provider + * Configuration interface for OpenAI provider with OpenAI-specific options. + * + * @example + * ```typescript + * const config: OpenAIConfig = { + * apiKey: process.env.OPENAI_API_KEY!, + * defaultModel: 'gpt-4o', + * organization: 'org-your-org-id', + * project: 'proj-your-project-id', + * timeout: 60000, + * maxRetries: 3 + * }; + * ``` */ export interface OpenAIConfig extends AIProviderConfig { - /** Default model to use if not specified in requests (default: gpt-4) */ + /** + * Default OpenAI model to use for requests. + * + * Recommended models: + * - 'gpt-4o': Latest multimodal model, best overall performance + * - 'gpt-4o-mini': Faster and cheaper, good for simple tasks + * - 'gpt-4-turbo': Previous generation flagship, excellent reasoning + * - 'gpt-3.5-turbo': Most cost-effective, good for basic tasks + * + * @default 'gpt-4o' + */ defaultModel?: string; - /** Organization ID (optional) */ + + /** + * OpenAI organization ID for billing and access control. + * + * This is useful for organizations with multiple teams or projects + * that need separate billing and usage tracking. + * + * @see https://platform.openai.com/account/org-settings + */ organization?: string; - /** Project ID (optional) */ + + /** + * OpenAI project ID for granular access control. + * + * Projects allow fine-grained permission management within + * an organization, enabling better governance and security. + * + * @see https://platform.openai.com/docs/guides/production-best-practices + */ project?: string; } /** - * OpenAI provider implementation + * Internal interface for enhanced streaming chunk tracking. + * OpenAI streaming provides detailed metadata about completion progress. + */ +interface StreamingState { + /** Current message ID from OpenAI */ + messageId: string; + /** Accumulated prompt tokens (estimated) */ + promptTokens: number; + /** Accumulated completion tokens (estimated) */ + completionTokens: number; + /** Whether the stream has started */ + started: boolean; +} + +// ============================================================================ +// OPENAI PROVIDER IMPLEMENTATION +// ============================================================================ + +/** + * OpenAI GPT provider implementation. + * + * This class handles all interactions with OpenAI's chat completion API through + * their official TypeScript SDK. It provides optimized handling of OpenAI's + * features including function calling, vision, and advanced streaming. + * + * Usage Pattern: + * 1. Create instance with configuration including API key + * 2. Call initialize() to set up client and validate credentials + * 3. Use complete() or stream() for text generation + * 4. Handle any AIProviderError exceptions appropriately + * + * @example + * ```typescript + * const openai = new OpenAIProvider({ + * apiKey: process.env.OPENAI_API_KEY!, + * defaultModel: 'gpt-4o', + * organization: 'org-your-org-id' + * }); + * + * await openai.initialize(); + * + * const response = await openai.complete({ + * messages: [ + * { role: 'system', content: 'You are a helpful assistant.' }, + * { role: 'user', content: 'Explain machine learning.' } + * ], + * maxTokens: 1000, + * temperature: 0.7 + * }); + * ``` */ export class OpenAIProvider extends BaseAIProvider { + // ======================================================================== + // INSTANCE PROPERTIES + // ======================================================================== + + /** OpenAI SDK client instance (initialized during doInitialize) */ private client: OpenAI | null = null; + + /** Default model identifier for requests */ private readonly defaultModel: string; + + /** Organization ID for billing and access control */ private readonly organization?: string; + + /** Project ID for granular permissions */ private readonly project?: string; - constructor(config: OpenAIConfig) { - super(config); - this.defaultModel = config.defaultModel || 'gpt-4'; - this.organization = config.organization; - this.project = config.project; - } + // ======================================================================== + // CONSTRUCTOR + // ======================================================================== /** - * Initialize the OpenAI provider by setting up the OpenAI client + * Creates a new OpenAI provider instance. + * + * @param config - OpenAI-specific configuration options + * @throws {AIProviderError} If configuration validation fails + */ + constructor(config: OpenAIConfig) { + super(config); + + // Set OpenAI-specific defaults + this.defaultModel = config.defaultModel || 'gpt-4o'; + this.organization = config.organization; + this.project = config.project; + + // Validate model name format + this.validateModelName(this.defaultModel); + } + + // ======================================================================== + // PROTECTED TEMPLATE METHOD IMPLEMENTATIONS + // ======================================================================== + + /** + * Initializes the OpenAI provider by setting up the client. + * + * This method: + * 1. Creates the OpenAI SDK client with configuration + * 2. Tests the connection with a minimal API call + * 3. Validates API key permissions and model access + * 4. Sets up organization and project context if provided + * + * @protected + * @throws {Error} If client creation or connection validation fails */ protected async doInitialize(): Promise { try { + // Create OpenAI client with optimized configuration this.client = new OpenAI({ apiKey: this.config.apiKey, baseURL: this.config.baseUrl, timeout: this.config.timeout, maxRetries: this.config.maxRetries, organization: this.organization, - project: this.project + project: this.project, + // Enable automatic retries with exponential backoff + defaultQuery: undefined, + defaultHeaders: { + 'User-Agent': 'simple-ai-provider/1.0.0' + } }); - // Test the connection by making a simple request + // Validate connection and permissions await this.validateConnection(); + } catch (error) { + // Clean up on failure + this.client = null; + throw new AIProviderError( `Failed to initialize OpenAI provider: ${(error as Error).message}`, AIErrorType.AUTHENTICATION, @@ -70,118 +231,120 @@ export class OpenAIProvider extends BaseAIProvider { } /** - * Generate a completion using OpenAI + * Generates a text completion using OpenAI's chat completions API. + * + * This method: + * 1. Converts messages to OpenAI's format + * 2. Builds optimized request parameters + * 3. Makes API call with comprehensive error handling + * 4. Formats response to standard interface + * + * @protected + * @param params - Validated completion parameters + * @returns Promise resolving to formatted completion response + * @throws {Error} If API request fails */ protected async doComplete(params: CompletionParams): Promise { if (!this.client) { - throw new AIProviderError('Client not initialized', AIErrorType.INVALID_REQUEST); + throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); } try { - const response = await this.client.chat.completions.create({ - model: params.model || this.defaultModel, - messages: this.convertMessages(params.messages), - max_tokens: params.maxTokens || 1000, - temperature: params.temperature ?? 0.7, - top_p: params.topP, - stop: params.stopSequences, - stream: false - }); + // Build optimized request parameters + const requestParams = this.buildRequestParams(params, false); + + // Make API request - explicitly type as ChatCompletion for non-streaming + const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion; + // Format and return response return this.formatCompletionResponse(response); + } catch (error) { throw this.handleOpenAIError(error as Error); } } /** - * Generate a streaming completion using OpenAI + * Generates a streaming text completion using OpenAI's streaming API. + * + * This method: + * 1. Builds streaming request parameters + * 2. Handles real-time stream chunks from OpenAI + * 3. Tracks token usage throughout the stream + * 4. Yields formatted chunks with proper completion tracking + * + * @protected + * @param params - Validated completion parameters + * @returns AsyncIterable yielding completion chunks + * @throws {Error} If streaming request fails */ protected async *doStream(params: CompletionParams): AsyncIterable { if (!this.client) { - throw new AIProviderError('Client not initialized', AIErrorType.INVALID_REQUEST); + throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); } try { - const stream = await this.client.chat.completions.create({ - model: params.model || this.defaultModel, - messages: this.convertMessages(params.messages), - max_tokens: params.maxTokens || 1000, - temperature: params.temperature ?? 0.7, - top_p: params.topP, - stop: params.stopSequences, - stream: true - }); + // Build streaming request parameters + const requestParams = this.buildRequestParams(params, true); + + // Create streaming request + const stream = this.client.chat.completions.create(requestParams); - let messageId = ''; - let totalPromptTokens = 0; - let totalCompletionTokens = 0; - - for await (const chunk of stream) { - if (chunk.id && !messageId) { - messageId = chunk.id; - } - - const delta = chunk.choices[0]?.delta; - if (delta?.content) { - yield { - content: delta.content, - isComplete: false, - id: messageId || chunk.id - }; - } - - // Check for completion - if (chunk.choices[0]?.finish_reason) { - // For OpenAI, we need to make a separate call to get usage stats - // or track them during streaming (not available in all stream responses) - yield { - content: '', - isComplete: true, - id: messageId || chunk.id, - usage: { - promptTokens: totalPromptTokens, - completionTokens: totalCompletionTokens, - totalTokens: totalPromptTokens + totalCompletionTokens - } - }; - } - } + // Process stream chunks + yield* this.processStreamChunks(stream); + } catch (error) { throw this.handleOpenAIError(error as Error); } } + // ======================================================================== + // PUBLIC INTERFACE METHODS + // ======================================================================== + /** - * Get information about the OpenAI provider + * Returns comprehensive information about the OpenAI provider. + * + * @returns Provider information including models, capabilities, and limits */ public getInfo(): ProviderInfo { return { name: 'OpenAI', version: '1.0.0', models: [ - 'gpt-4', - 'gpt-4-turbo', - 'gpt-4-turbo-preview', - 'gpt-4-0125-preview', - 'gpt-4-1106-preview', - 'gpt-3.5-turbo', - 'gpt-3.5-turbo-0125', - 'gpt-3.5-turbo-1106' + 'gpt-4o', // Latest multimodal model + 'gpt-4o-mini', // Faster, cheaper variant + 'gpt-4-turbo', // Previous flagship model + 'gpt-4', // Original GPT-4 + 'gpt-3.5-turbo', // Most cost-effective + 'gpt-3.5-turbo-0125', // Specific version + 'gpt-4o-2024-11-20', // Latest dated version + 'gpt-4-turbo-2024-04-09' // Previous dated version ], - maxContextLength: 128000, // GPT-4 Turbo context length + maxContextLength: 128000, // 128K tokens for latest models supportsStreaming: true, capabilities: { - vision: true, - functionCalling: true, - jsonMode: true, - systemMessages: true + vision: true, // GPT-4o supports image understanding + functionCalling: true, // Advanced function/tool calling + jsonMode: true, // Structured JSON output + systemMessages: true, // Native system message support + reasoning: true, // Strong reasoning capabilities + codeGeneration: true, // Excellent at programming tasks + multimodal: true, // Text + image understanding + fineTuning: true // Custom model training } }; } + // ======================================================================== + // PRIVATE UTILITY METHODS + // ======================================================================== + /** - * Validate the connection by making a simple request + * Validates the connection by making a minimal test request. + * + * @private + * @throws {AIProviderError} If connection validation fails */ private async validateConnection(): Promise { if (!this.client) { @@ -189,28 +352,108 @@ export class OpenAIProvider extends BaseAIProvider { } try { - // Make a minimal request to validate credentials + // Make minimal request to test connection and permissions await this.client.chat.completions.create({ model: this.defaultModel, messages: [{ role: 'user', content: 'Hi' }], max_tokens: 1 }); + } catch (error: any) { - if (error.status === 401 || error.status === 403) { + // Handle specific validation errors + if (error.status === 401) { throw new AIProviderError( - 'Invalid API key. Please check your OpenAI API key.', + 'Invalid OpenAI API key. Please verify your API key from https://platform.openai.com/api-keys', AIErrorType.AUTHENTICATION, error.status ); } - // For other errors during validation, we'll let initialization proceed - // as they might be temporary issues + + if (error.status === 403) { + throw new AIProviderError( + 'Access forbidden. Your API key may not have permission for this model or your account may have insufficient credits.', + AIErrorType.AUTHENTICATION, + error.status + ); + } + + if (error.status === 404 && error.message?.includes('model')) { + throw new AIProviderError( + `Model '${this.defaultModel}' is not available. Please check the model name or your API access level.`, + AIErrorType.MODEL_NOT_FOUND, + error.status + ); + } + + // For other errors during validation, log but don't fail initialization + // They might be temporary network issues + console.warn('OpenAI connection validation warning:', error.message); } } /** - * Convert our generic message format to OpenAI's format - * OpenAI supports system messages directly in the messages array + * Validates model name format for OpenAI models. + * + * @private + * @param modelName - Model name to validate + * @throws {AIProviderError} If model name format is invalid + */ + private validateModelName(modelName: string): void { + if (!modelName || typeof modelName !== 'string') { + throw new AIProviderError( + 'Model name must be a non-empty string', + AIErrorType.INVALID_REQUEST + ); + } + + // OpenAI model names follow specific patterns + const validPatterns = [ + /^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4o, gpt-4o-mini, gpt-4o-2024-11-20 + /^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4, gpt-4-turbo, gpt-4-turbo-2024-04-09 + /^gpt-3\.5-turbo(?:-\d{4})?$/, // e.g., gpt-3.5-turbo, gpt-3.5-turbo-0125 + /^gpt-4-\d{4}-preview$/, // e.g., gpt-4-0125-preview + /^text-davinci-\d{3}$/, // Legacy models + /^ft:.+$/ // Fine-tuned models + ]; + + const isValid = validPatterns.some(pattern => pattern.test(modelName)); + + if (!isValid) { + console.warn(`Model name '${modelName}' doesn't match expected OpenAI naming patterns. This may cause API errors.`); + } + } + + /** + * Builds optimized request parameters for OpenAI API. + * + * @private + * @param params - Input completion parameters + * @param stream - Whether to enable streaming + * @returns Formatted request parameters + */ + private buildRequestParams(params: CompletionParams, stream: boolean) { + return { + model: params.model || this.defaultModel, + messages: this.convertMessages(params.messages), + max_tokens: params.maxTokens || 1000, + temperature: params.temperature ?? 0.7, + top_p: params.topP, + stop: params.stopSequences, + stream, + // Enable structured outputs for better reliability + stream_options: stream ? { include_usage: true } : undefined + }; + } + + /** + * Converts generic messages to OpenAI's chat completion format. + * + * OpenAI supports system messages natively within the conversation flow, + * making this conversion straightforward. + * + * @private + * @param messages - Input messages array + * @returns OpenAI-formatted messages */ private convertMessages(messages: AIMessage[]): OpenAI.Chat.Completions.ChatCompletionMessageParam[] { return messages.map(message => ({ @@ -220,13 +463,85 @@ export class OpenAIProvider extends BaseAIProvider { } /** - * Format OpenAI's response to our standard format + * Processes streaming response chunks from OpenAI API. + * + * @private + * @param stream - OpenAI streaming response promise + * @returns AsyncIterable of formatted completion chunks + */ + private async *processStreamChunks(stream: Promise): AsyncIterable { + const state: StreamingState = { + messageId: '', + promptTokens: 0, + completionTokens: 0, + started: false + }; + + try { + const streamResponse = await stream; + + for await (const chunk of streamResponse) { + // Track message ID + if (chunk.id && !state.messageId) { + state.messageId = chunk.id; + } + + // Handle content chunks + const choice = chunk.choices?.[0]; + if (choice?.delta?.content) { + state.started = true; + yield { + content: choice.delta.content, + isComplete: false, + id: state.messageId || chunk.id + }; + } + + // Handle completion with usage information + if (choice?.finish_reason || chunk.usage) { + // Extract usage information if available + if (chunk.usage) { + state.promptTokens = chunk.usage.prompt_tokens || 0; + state.completionTokens = chunk.usage.completion_tokens || 0; + } + + yield { + content: '', + isComplete: true, + id: state.messageId || chunk.id, + usage: { + promptTokens: state.promptTokens, + completionTokens: state.completionTokens, + totalTokens: state.promptTokens + state.completionTokens + } + }; + return; + } + } + } catch (error) { + // Handle streaming errors gracefully + throw new AIProviderError( + `Streaming interrupted: ${(error as Error).message}`, + AIErrorType.NETWORK, + undefined, + error as Error + ); + } + } + + /** + * Formats OpenAI's completion response to our standard interface. + * + * @private + * @param response - Raw OpenAI API response + * @returns Formatted completion response + * @throws {AIProviderError} If response format is unexpected */ private formatCompletionResponse(response: OpenAI.Chat.Completions.ChatCompletion): CompletionResponse { const choice = response.choices[0]; if (!choice || !choice.message.content) { throw new AIProviderError( - 'No content in OpenAI response', + 'No content found in OpenAI response', AIErrorType.UNKNOWN ); } @@ -242,69 +557,151 @@ export class OpenAIProvider extends BaseAIProvider { id: response.id, metadata: { finishReason: choice.finish_reason, - systemFingerprint: response.system_fingerprint + systemFingerprint: response.system_fingerprint, + created: response.created } }; } /** - * Handle OpenAI-specific errors and convert them to our standard format + * Handles and transforms OpenAI-specific errors. + * + * This method maps OpenAI's error responses to our standardized + * error format, providing helpful context and actionable suggestions. + * + * @private + * @param error - Original error from OpenAI API + * @returns Normalized AIProviderError */ private handleOpenAIError(error: any): AIProviderError { if (error instanceof AIProviderError) { return error; } - const status = error.status || error.statusCode; const message = error.message || 'Unknown OpenAI API error'; + const status = error.status || error.statusCode; + // Map OpenAI-specific error codes and types switch (status) { case 400: + if (message.includes('max_tokens')) { + return new AIProviderError( + 'Max tokens value exceeds model limit. Please reduce max_tokens or use a model with higher limits.', + AIErrorType.INVALID_REQUEST, + status, + error + ); + } + if (message.includes('model')) { + return new AIProviderError( + 'Invalid model specified. Please check model name and availability.', + AIErrorType.MODEL_NOT_FOUND, + status, + error + ); + } + if (message.includes('messages')) { + return new AIProviderError( + 'Invalid message format. Please check message structure and content.', + AIErrorType.INVALID_REQUEST, + status, + error + ); + } return new AIProviderError( `Invalid request: ${message}`, AIErrorType.INVALID_REQUEST, status, error ); + case 401: return new AIProviderError( - 'Authentication failed. Please check your OpenAI API key.', + 'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys', AIErrorType.AUTHENTICATION, status, error ); + case 403: + if (message.includes('billing') || message.includes('quota')) { + return new AIProviderError( + 'Insufficient quota or billing issue. Please check your OpenAI account billing and usage limits.', + AIErrorType.AUTHENTICATION, + status, + error + ); + } return new AIProviderError( - 'Access forbidden. Please check your API key permissions.', + 'Access forbidden. Your API key may not have permission for this model or feature.', AIErrorType.AUTHENTICATION, status, error ); + case 404: return new AIProviderError( - 'Model not found. Please check the model name.', + 'Model not found. The specified OpenAI model may not exist or be available to your account.', AIErrorType.MODEL_NOT_FOUND, status, error ); + case 429: + if (message.includes('rate')) { + return new AIProviderError( + 'Rate limit exceeded. Please reduce request frequency and implement exponential backoff.', + AIErrorType.RATE_LIMIT, + status, + error + ); + } + if (message.includes('quota')) { + return new AIProviderError( + 'Usage quota exceeded. Please check your OpenAI usage limits and billing.', + AIErrorType.RATE_LIMIT, + status, + error + ); + } return new AIProviderError( - 'Rate limit exceeded. Please slow down your requests.', + 'Too many requests. Please slow down and try again.', AIErrorType.RATE_LIMIT, status, error ); + case 500: case 502: case 503: case 504: return new AIProviderError( - 'OpenAI service temporarily unavailable. Please try again later.', + 'OpenAI service temporarily unavailable. Please try again in a few moments.', AIErrorType.NETWORK, status, error ); + default: + // Handle timeout and network errors + if (message.includes('timeout') || error.code === 'ETIMEDOUT') { + return new AIProviderError( + 'Request timed out. OpenAI may be experiencing high load.', + AIErrorType.TIMEOUT, + status, + error + ); + } + + if (message.includes('network') || error.code === 'ECONNREFUSED') { + return new AIProviderError( + 'Network error connecting to OpenAI servers.', + AIErrorType.NETWORK, + status, + error + ); + } + return new AIProviderError( `OpenAI API error: ${message}`, AIErrorType.UNKNOWN, diff --git a/src/providers/openwebui.ts b/src/providers/openwebui.ts index 91cb8a3..7bc2a4b 100644 --- a/src/providers/openwebui.ts +++ b/src/providers/openwebui.ts @@ -1,6 +1,27 @@ /** - * OpenWebUI Provider implementation using OpenWebUI's native API - * Provides integration with OpenWebUI's chat completions and Ollama proxy endpoints + * OpenWebUI Provider Implementation + * + * This module provides integration with OpenWebUI instances, supporting both the native + * chat completions API and Ollama proxy endpoints. OpenWebUI is a self-hosted interface + * for large language models that provides a unified API for various model backends. + * + * Key Features: + * - Dual API support: Chat completions (OpenAI-compatible) and Ollama proxy + * - Local model support through Ollama integration + * - Self-hosted deployment flexibility with custom base URLs + * - Streaming support for real-time response delivery + * - Comprehensive model management and discovery + * + * OpenWebUI-Specific Considerations: + * - Typically runs on localhost with custom ports (default: 3000) + * - May use self-signed certificates requiring insecure connections + * - Supports both conversation and single-prompt formats + * - Model availability depends on configured backends (Ollama, etc.) + * - Rate limiting and authentication vary by deployment configuration + * + * @author Jan-Marlon Leibl + * @version 1.0.0 + * @see https://docs.openwebui.com/ */ import type { @@ -14,147 +35,653 @@ import type { import { BaseAIProvider } from './base.js'; import { AIProviderError, AIErrorType } from '../types/index.js'; +// ============================================================================ +// TYPES AND INTERFACES +// ============================================================================ + /** - * Configuration specific to OpenWebUI provider + * Configuration interface for OpenWebUI provider with OpenWebUI-specific options. + * + * @example + * ```typescript + * const config: OpenWebUIConfig = { + * apiKey: 'your-openwebui-api-key', // Optional for local instances + * baseUrl: 'http://localhost:3000', + * defaultModel: 'llama3.1:latest', + * useOllamaProxy: false, + * dangerouslyAllowInsecureConnections: true + * }; + * ``` */ export interface OpenWebUIConfig extends AIProviderConfig { - /** Default model to use if not specified in requests (default: 'llama3.1') */ + /** + * Default model to use for requests. + * + * Common OpenWebUI models: + * - 'llama3.1:latest': Latest Llama 3.1 model + * - 'mistral:latest': Mistral model variant + * - 'codellama:latest': Code-specialized Llama + * - 'phi3:latest': Microsoft Phi-3 model + * + * @default 'llama3.1:latest' + */ defaultModel?: string; - /** Base URL for OpenWebUI instance (default: 'http://localhost:3000') */ + + /** + * Base URL for the OpenWebUI instance. + * + * This should point to your OpenWebUI deployment. For local development, + * this is typically localhost with the configured port. + * + * @default 'http://localhost:3000' + */ baseUrl?: string; - /** Whether to use Ollama API proxy endpoints instead of chat completions (default: false) */ + + /** + * Whether to use Ollama API proxy endpoints instead of chat completions. + * + * - `false`: Use OpenAI-compatible chat completions API (recommended) + * - `true`: Use direct Ollama generate API (for legacy compatibility) + * + * @default false + */ useOllamaProxy?: boolean; - /** Whether to verify SSL certificates (default: false for local) */ + + /** + * Whether to allow insecure SSL connections. + * + * This is often needed for local OpenWebUI instances that use + * self-signed certificates or HTTP instead of HTTPS. + * + * @default true + */ dangerouslyAllowInsecureConnections?: boolean; } /** - * OpenWebUI chat completion response interface + * OpenWebUI chat completion response structure. + * Compatible with OpenAI's chat completions format. */ interface OpenWebUIChatResponse { + /** Unique identifier for the completion */ id: string; + /** Object type, typically 'chat.completion' */ object: string; + /** Unix timestamp of when the completion was created */ created: number; + /** Model that generated the completion */ model: string; + /** Array of completion choices */ choices: Array<{ + /** Choice index in the array */ index: number; + /** The generated message */ message: { + /** Role of the message sender */ role: string; + /** Content of the message */ content: string; }; + /** Reason why the completion finished */ finish_reason: string | null; }>; + /** Token usage statistics (if available) */ usage?: { + /** Number of tokens in the prompt */ prompt_tokens: number; + /** Number of tokens in the completion */ completion_tokens: number; + /** Total tokens used */ total_tokens: number; }; } /** - * OpenWebUI streaming response interface + * OpenWebUI streaming response chunk structure. + * Each chunk represents a piece of the streaming completion. */ interface OpenWebUIStreamChunk { + /** Unique identifier for the completion */ id: string; + /** Object type, typically 'chat.completion.chunk' */ object: string; + /** Unix timestamp of when the chunk was created */ created: number; + /** Model generating the completion */ model: string; + /** Array of choice deltas */ choices: Array<{ + /** Choice index in the array */ index: number; + /** Delta containing new content */ delta: { + /** Role of the message (only in first chunk) */ role?: string; + /** New content fragment */ content?: string; }; + /** Reason why the completion finished (only in last chunk) */ finish_reason: string | null; }>; } /** - * Ollama generate response interface + * Ollama generate response structure. + * Used when communicating directly with Ollama through OpenWebUI proxy. */ interface OllamaGenerateResponse { + /** Model that generated the response */ model: string; + /** ISO timestamp of when the response was created */ created_at: string; + /** Generated text response */ response: string; + /** Whether the generation is complete */ done: boolean; + /** Context array for continuing conversations */ context?: number[]; + /** Total duration of the request in nanoseconds */ total_duration?: number; + /** Time spent loading the model in nanoseconds */ load_duration?: number; + /** Number of tokens in the prompt */ prompt_eval_count?: number; + /** Time spent evaluating the prompt in nanoseconds */ prompt_eval_duration?: number; + /** Number of tokens in the response */ eval_count?: number; + /** Time spent generating the response in nanoseconds */ eval_duration?: number; } /** - * OpenWebUI models response interface + * OpenWebUI models list response structure. + * Provides information about available models. */ interface OpenWebUIModelsResponse { + /** Array of available models */ data: Array<{ + /** Model identifier */ id: string; + /** Object type, typically 'model' */ object: string; + /** Unix timestamp of when the model was created */ created: number; + /** Organization or source that owns the model */ owned_by: string; }>; } +// ============================================================================ +// OPENWEBUI PROVIDER IMPLEMENTATION +// ============================================================================ + /** - * OpenWebUI provider implementation + * OpenWebUI provider implementation. + * + * This class handles interactions with OpenWebUI instances, providing support + * for both the native chat completions API and direct Ollama proxy access. + * It's designed to work with self-hosted OpenWebUI deployments. + * + * Usage Pattern: + * 1. Deploy OpenWebUI instance (local or remote) + * 2. Create provider with instance URL and configuration + * 3. Call initialize() to test connection and discover models + * 4. Use complete() or stream() for text generation + * 5. Handle AIProviderError exceptions for deployment-specific issues + * + * @example + * ```typescript + * const openwebui = new OpenWebUIProvider({ + * apiKey: 'optional-api-key', + * baseUrl: 'http://localhost:3000', + * defaultModel: 'llama3.1:latest', + * useOllamaProxy: false + * }); + * + * await openwebui.initialize(); + * + * const response = await openwebui.complete({ + * messages: [ + * { role: 'user', content: 'Explain container orchestration.' } + * ], + * maxTokens: 500, + * temperature: 0.7 + * }); + * ``` */ export class OpenWebUIProvider extends BaseAIProvider { + // ======================================================================== + // INSTANCE PROPERTIES + // ======================================================================== + + /** Default model identifier for requests */ private readonly defaultModel: string; + + /** Base URL for the OpenWebUI instance */ private readonly baseUrl: string; + + /** Whether to use Ollama proxy API instead of chat completions */ private readonly useOllamaProxy: boolean; + + /** Whether to allow insecure SSL connections */ private readonly dangerouslyAllowInsecureConnections: boolean; + // ======================================================================== + // CONSTRUCTOR + // ======================================================================== + + /** + * Creates a new OpenWebUI provider instance. + * + * @param config - OpenWebUI-specific configuration options + * @throws {AIProviderError} If configuration validation fails + */ constructor(config: OpenWebUIConfig) { super(config); - this.defaultModel = config.defaultModel || 'llama3.1'; - this.baseUrl = (config.baseUrl || 'http://localhost:3000').replace(/\/$/, ''); + + // Set OpenWebUI-specific defaults and normalize configuration + this.defaultModel = config.defaultModel || 'llama3.1:latest'; + this.baseUrl = this.normalizeBaseUrl(config.baseUrl || 'http://localhost:3000'); this.useOllamaProxy = config.useOllamaProxy ?? false; this.dangerouslyAllowInsecureConnections = config.dangerouslyAllowInsecureConnections ?? true; + + // Validate configuration + this.validateConfiguration(); + } + + // ======================================================================== + // PUBLIC INTERFACE METHODS + // ======================================================================== + + /** + * Returns comprehensive information about the OpenWebUI provider. + * + * @returns Provider information including capabilities and supported models + */ + public getInfo(): ProviderInfo { + return { + name: 'OpenWebUI', + version: '1.0.0', + models: [ + 'llama3.1:latest', // Latest Llama 3.1 + 'llama3.1:8b', // Llama 3.1 8B parameters + 'llama3.1:70b', // Llama 3.1 70B parameters + 'mistral:latest', // Mistral model + 'mistral:7b', // Mistral 7B parameters + 'codellama:latest', // Code Llama latest + 'codellama:7b', // Code Llama 7B + 'phi3:latest', // Microsoft Phi-3 + 'phi3:mini', // Phi-3 mini variant + 'qwen2:latest', // Qwen2 model + 'gemma:latest' // Google Gemma + ], + maxContextLength: 32768, // Varies by model, 32K common + supportsStreaming: true, + capabilities: { + vision: false, // Depends on specific models + functionCalling: false, // Limited by underlying models + jsonMode: false, // Model-dependent feature + systemMessages: true, // Supported through prompt engineering + reasoning: true, // Strong reasoning with Llama models + codeGeneration: true, // Excellent with Code Llama + localDeployment: true, // Key advantage of OpenWebUI + multiModel: true // Can switch between models + } + }; + } + + // ======================================================================== + // PRIVATE UTILITY METHODS + // ======================================================================== + + /** + * Normalizes the base URL by removing trailing slashes and validating format. + * + * @private + * @param baseUrl - Raw base URL + * @returns Normalized base URL + */ + private normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/$/, ''); } /** - * Initialize the OpenWebUI provider by testing the connection + * Validates the provider configuration for common issues. + * + * @private + * @throws {AIProviderError} If configuration is invalid */ - protected async doInitialize(): Promise { + private validateConfiguration(): void { + // Validate base URL format try { - await this.validateConnection(); - } catch (error) { + new URL(this.baseUrl); + } catch { throw new AIProviderError( - `Failed to initialize OpenWebUI provider: ${(error as Error).message}`, + `Invalid base URL: ${this.baseUrl}. Must be a valid URL.`, + AIErrorType.INVALID_REQUEST + ); + } + + // Warn about common configuration issues + if (this.baseUrl.includes('https://') && this.dangerouslyAllowInsecureConnections) { + console.warn('Using HTTPS with insecure connections allowed. Consider setting dangerouslyAllowInsecureConnections to false.'); + } + } + + /** + * Validates the connection to the OpenWebUI instance. + * + * @private + * @throws {AIProviderError} If connection validation fails + */ + private async validateConnection(): Promise { + try { + // Try to fetch available models to test connectivity + const url = this.useOllamaProxy + ? `${this.baseUrl}/ollama/api/tags` + : `${this.baseUrl}/api/models`; + + const response = await this.makeRequest(url, 'GET'); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // For chat completions API, verify the response structure + if (!this.useOllamaProxy) { + const data = await response.json() as OpenWebUIModelsResponse; + if (!data.data || !Array.isArray(data.data)) { + throw new Error('Invalid models response format'); + } + } + + } catch (error: any) { + if (error.message?.includes('ECONNREFUSED')) { + throw new AIProviderError( + `Cannot connect to OpenWebUI at ${this.baseUrl}. Please verify the instance is running and accessible.`, + AIErrorType.NETWORK, + undefined, + error + ); + } + + if (error.message?.includes('certificate')) { + throw new AIProviderError( + `SSL certificate error connecting to ${this.baseUrl}. Consider setting dangerouslyAllowInsecureConnections to true for local instances.`, + AIErrorType.NETWORK, + undefined, + error + ); + } + + throw new AIProviderError( + `Failed to validate OpenWebUI connection: ${error.message}`, AIErrorType.NETWORK, undefined, - error as Error + error ); } } /** - * Generate a completion using OpenWebUI + * Makes an HTTP request with proper error handling and configuration. + * + * @private + * @param url - Request URL + * @param method - HTTP method + * @param body - Request body (optional) + * @returns Promise resolving to Response object */ - protected async doComplete(params: CompletionParams): Promise { - if (this.useOllamaProxy) { - return this.completeWithOllama(params); - } else { - return this.completeWithChat(params); + private async makeRequest(url: string, method: string, body?: any): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'simple-ai-provider/1.0.0' + }; + + // Add authorization header if API key is provided + if (this.config.apiKey) { + headers['Authorization'] = `Bearer ${this.config.apiKey}`; + } + + const requestOptions: RequestInit = { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(this.config.timeout || 30000) + }; + + // Handle insecure connections for local instances + if (this.dangerouslyAllowInsecureConnections) { + // Note: This would need additional configuration in Node.js environments + // For browser environments, this setting doesn't apply + } + + try { + const response = await fetch(url, requestOptions); + return response; + } catch (error: any) { + if (error.name === 'AbortError') { + throw new AIProviderError( + 'Request timed out', + AIErrorType.TIMEOUT, + undefined, + error + ); + } + throw error; } } /** - * Generate a streaming completion using OpenWebUI + * Converts generic messages to OpenWebUI chat format. + * + * @private + * @param messages - Input messages + * @returns OpenWebUI-formatted messages */ - protected async *doStream(params: CompletionParams): AsyncIterable { - if (this.useOllamaProxy) { - yield* this.streamWithOllama(params); - } else { - yield* this.streamWithChat(params); - } + private convertMessages(messages: AIMessage[]): Array<{role: string; content: string}> { + return messages.map(message => ({ + role: message.role, + content: message.content + })); } /** - * Complete using OpenWebUI's chat completions API + * Converts messages to a single prompt string for Ollama API. + * + * @private + * @param messages - Input messages + * @returns Formatted prompt string + */ + private convertMessagesToPrompt(messages: AIMessage[]): string { + let prompt = ''; + + for (const message of messages) { + switch (message.role) { + case 'system': + prompt += `System: ${message.content}\n\n`; + break; + case 'user': + prompt += `Human: ${message.content}\n\n`; + break; + case 'assistant': + prompt += `Assistant: ${message.content}\n\n`; + break; + } + } + + // Add final prompt for the assistant to respond + prompt += 'Assistant: '; + + return prompt; + } + + /** + * Formats OpenWebUI chat completion response to standard interface. + * + * @private + * @param response - Raw OpenWebUI response + * @returns Formatted completion response + */ + private formatChatResponse(response: OpenWebUIChatResponse): CompletionResponse { + const choice = response.choices[0]; + if (!choice || !choice.message.content) { + throw new AIProviderError( + 'No content found in OpenWebUI response', + AIErrorType.UNKNOWN + ); + } + + return { + content: choice.message.content, + model: response.model, + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + completionTokens: response.usage?.completion_tokens || 0, + totalTokens: response.usage?.total_tokens || 0 + }, + id: response.id, + metadata: { + finishReason: choice.finish_reason, + created: response.created + } + }; + } + + /** + * Formats Ollama generate response to standard interface. + * + * @private + * @param response - Raw Ollama response + * @returns Formatted completion response + */ + private formatOllamaResponse(response: OllamaGenerateResponse): CompletionResponse { + return { + content: response.response, + model: response.model, + usage: { + promptTokens: response.prompt_eval_count || 0, + completionTokens: response.eval_count || 0, + totalTokens: (response.prompt_eval_count || 0) + (response.eval_count || 0) + }, + id: `ollama-${Date.now()}`, + metadata: { + created: new Date(response.created_at).getTime(), + totalDuration: response.total_duration, + loadDuration: response.load_duration, + promptEvalDuration: response.prompt_eval_duration, + evalDuration: response.eval_duration + } + }; + } + + /** + * Handles and transforms OpenWebUI-specific errors. + * + * @private + * @param error - Original error + * @returns Normalized AIProviderError + */ + private handleOpenWebUIError(error: any): AIProviderError { + if (error instanceof AIProviderError) { + return error; + } + + const message = error.message || 'Unknown OpenWebUI error'; + const status = error.status || error.statusCode; + + // Map HTTP status codes + switch (status) { + case 400: + return new AIProviderError( + `Invalid request to OpenWebUI: ${message}`, + AIErrorType.INVALID_REQUEST, + status, + error + ); + + case 401: + case 403: + return new AIProviderError( + 'Authentication failed with OpenWebUI. Please check your API key or instance configuration.', + AIErrorType.AUTHENTICATION, + status, + error + ); + + case 404: + if (message.includes('model')) { + return new AIProviderError( + 'Model not found in OpenWebUI. Please check the model name and ensure it\'s available.', + AIErrorType.MODEL_NOT_FOUND, + status, + error + ); + } + return new AIProviderError( + 'OpenWebUI endpoint not found. Please verify the base URL and API path.', + AIErrorType.INVALID_REQUEST, + status, + error + ); + + case 429: + return new AIProviderError( + 'Rate limit exceeded on OpenWebUI instance.', + AIErrorType.RATE_LIMIT, + status, + error + ); + + case 500: + case 502: + case 503: + return new AIProviderError( + 'OpenWebUI instance error. The server may be overloaded or misconfigured.', + AIErrorType.NETWORK, + status, + error + ); + + default: + if (message.includes('timeout')) { + return new AIProviderError( + 'Request to OpenWebUI timed out.', + AIErrorType.TIMEOUT, + status, + error + ); + } + + if (message.includes('network') || message.includes('ECONNREFUSED')) { + return new AIProviderError( + 'Network error connecting to OpenWebUI instance.', + AIErrorType.NETWORK, + status, + error + ); + } + + return new AIProviderError( + `OpenWebUI error: ${message}`, + AIErrorType.UNKNOWN, + status, + error + ); + } + } + + // ======================================================================== + // PRIVATE API IMPLEMENTATION METHODS + // ======================================================================== + + /** + * Completes using OpenWebUI's chat completions API (OpenAI-compatible). + * + * @private + * @param params - Completion parameters + * @returns Promise resolving to formatted completion response */ private async completeWithChat(params: CompletionParams): Promise { const url = `${this.baseUrl}/api/chat/completions`; @@ -180,7 +707,11 @@ export class OpenWebUIProvider extends BaseAIProvider { } /** - * Complete using Ollama proxy API + * Completes using Ollama proxy API (direct Ollama format). + * + * @private + * @param params - Completion parameters + * @returns Promise resolving to formatted completion response */ private async completeWithOllama(params: CompletionParams): Promise { const url = `${this.baseUrl}/ollama/api/generate`; @@ -211,7 +742,11 @@ export class OpenWebUIProvider extends BaseAIProvider { } /** - * Stream using OpenWebUI's chat completions API + * Streams using OpenWebUI's chat completions API with enhanced chunk processing. + * + * @private + * @param params - Completion parameters + * @returns AsyncIterable of completion chunks */ private async *streamWithChat(params: CompletionParams): AsyncIterable { const url = `${this.baseUrl}/api/chat/completions`; @@ -276,15 +811,16 @@ export class OpenWebUIProvider extends BaseAIProvider { isComplete: true, id: messageId || chunk.id, usage: { - promptTokens: 0, + promptTokens: 0, // OpenWebUI doesn't always provide usage completionTokens: 0, totalTokens: 0 } }; + return; } } catch (parseError) { // Skip invalid JSON chunks - continue; + console.warn('Failed to parse streaming chunk:', parseError); } } } @@ -298,7 +834,11 @@ export class OpenWebUIProvider extends BaseAIProvider { } /** - * Stream using Ollama proxy API + * Streams using Ollama proxy API with enhanced error handling. + * + * @private + * @param params - Completion parameters + * @returns AsyncIterable of completion chunks */ private async *streamWithOllama(params: CompletionParams): AsyncIterable { const url = `${this.baseUrl}/ollama/api/generate`; @@ -367,7 +907,7 @@ export class OpenWebUIProvider extends BaseAIProvider { } } catch (parseError) { // Skip invalid JSON chunks - continue; + console.warn('Failed to parse Ollama streaming chunk:', parseError); } } } @@ -380,280 +920,69 @@ export class OpenWebUIProvider extends BaseAIProvider { } } - /** - * Get information about the OpenWebUI provider - */ - public getInfo(): ProviderInfo { - return { - name: 'OpenWebUI', - version: '1.0.0', - models: [ - 'llama3.1', - 'llama3.1:8b', - 'llama3.1:70b', - 'llama3.2', - 'llama3.2:1b', - 'llama3.2:3b', - 'codellama', - 'codellama:7b', - 'codellama:13b', - 'codellama:34b', - 'mistral', - 'mistral:7b', - 'mixtral', - 'mixtral:8x7b', - 'phi3', - 'phi3:mini', - 'gemma2', - 'gemma2:2b', - 'gemma2:9b', - 'qwen2.5', - 'granite3.1-dense:8b' - ], - maxContextLength: 8192, // Varies by model, but reasonable default - supportsStreaming: true, - capabilities: { - vision: false, // Depends on model - functionCalling: false, // Limited in local models - systemMessages: true, - localExecution: true, - customModels: true, - rag: true // OpenWebUI supports RAG - } - }; - } + // ======================================================================== + // PROTECTED TEMPLATE METHOD IMPLEMENTATIONS + // ======================================================================== /** - * Validate the connection by attempting to list models + * Initializes the OpenWebUI provider by testing the connection. + * + * This method: + * 1. Tests connectivity to the OpenWebUI instance + * 2. Validates API access and permissions + * 3. Optionally discovers available models + * 4. Verifies the default model is accessible + * + * @protected + * @throws {Error} If connection validation fails */ - private async validateConnection(): Promise { + protected async doInitialize(): Promise { try { - const url = `${this.baseUrl}/api/models`; - const response = await this.makeRequest(url, 'GET'); + // Test basic connectivity and API access + await this.validateConnection(); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - await response.json(); // Just verify we can parse the response - } catch (error: any) { - if (error.code === 'ECONNREFUSED' || error.message?.includes('connect')) { - throw new AIProviderError( - `Cannot connect to OpenWebUI at ${this.baseUrl}. Make sure OpenWebUI is running.`, - AIErrorType.NETWORK - ); - } - throw error; - } - } - - /** - * Make an HTTP request with proper headers and error handling - */ - private async makeRequest(url: string, method: string, body?: any): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - }; - - // Add Authorization header if API key is provided - if (this.config.apiKey) { - headers['Authorization'] = `Bearer ${this.config.apiKey}`; - } - - const requestInit: RequestInit = { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }; - - // Handle SSL verification for local instances - if (this.dangerouslyAllowInsecureConnections && url.startsWith('https://localhost')) { - // Note: In a real implementation, you'd need to configure the agent - // This is a placeholder for the concept - } - - try { - const response = await fetch(url, requestInit); - return response; } catch (error) { - throw error; - } - } - - /** - * Convert our generic message format to OpenWebUI's format - */ - private convertMessages(messages: AIMessage[]): Array<{role: string; content: string}> { - return messages.map(message => ({ - role: message.role, - content: message.content - })); - } - - /** - * Convert messages to a single prompt for Ollama API - */ - private convertMessagesToPrompt(messages: AIMessage[]): string { - return messages.map(message => { - switch (message.role) { - case 'system': - return `System: ${message.content}`; - case 'user': - return `Human: ${message.content}`; - case 'assistant': - return `Assistant: ${message.content}`; - default: - return message.content; - } - }).join('\n\n'); - } - - /** - * Format OpenWebUI chat response to our standard format - */ - private formatChatResponse(response: OpenWebUIChatResponse): CompletionResponse { - const choice = response.choices[0]; - if (!choice || !choice.message.content) { throw new AIProviderError( - 'No content in OpenWebUI response', - AIErrorType.UNKNOWN + `Failed to initialize OpenWebUI provider: ${(error as Error).message}`, + AIErrorType.NETWORK, + undefined, + error as Error ); } - - return { - content: choice.message.content, - model: response.model, - usage: { - promptTokens: response.usage?.prompt_tokens || 0, - completionTokens: response.usage?.completion_tokens || 0, - totalTokens: response.usage?.total_tokens || 0 - }, - id: response.id, - metadata: { - finishReason: choice.finish_reason, - created: response.created - } - }; } /** - * Format Ollama response to our standard format + * Generates a text completion using the configured API method. + * + * This method automatically chooses between chat completions API + * and Ollama proxy based on the useOllamaProxy configuration. + * + * @protected + * @param params - Validated completion parameters + * @returns Promise resolving to formatted completion response + * @throws {Error} If API request fails */ - private formatOllamaResponse(response: OllamaGenerateResponse): CompletionResponse { - return { - content: response.response, - model: response.model, - usage: { - promptTokens: response.prompt_eval_count || 0, - completionTokens: response.eval_count || 0, - totalTokens: (response.prompt_eval_count || 0) + (response.eval_count || 0) - }, - id: `ollama-${Date.now()}`, - metadata: { - created_at: response.created_at, - total_duration: response.total_duration, - eval_duration: response.eval_duration - } - }; + protected async doComplete(params: CompletionParams): Promise { + if (this.useOllamaProxy) { + return this.completeWithOllama(params); + } else { + return this.completeWithChat(params); + } } /** - * Handle OpenWebUI-specific errors and convert them to our standard format + * Generates a streaming text completion using the configured API method. + * + * @protected + * @param params - Validated completion parameters + * @returns AsyncIterable yielding completion chunks + * @throws {Error} If streaming request fails */ - private handleOpenWebUIError(error: any): AIProviderError { - if (error instanceof AIProviderError) { - return error; - } - - const message = error.message || 'Unknown OpenWebUI API error'; - - // Handle connection errors - if (error.code === 'ECONNREFUSED' || message.includes('connect')) { - return new AIProviderError( - `Cannot connect to OpenWebUI at ${this.baseUrl}. Make sure OpenWebUI is running.`, - AIErrorType.NETWORK, - undefined, - error - ); - } - - if (error.code === 'ENOTFOUND' || message.includes('getaddrinfo')) { - return new AIProviderError( - `Cannot resolve OpenWebUI hostname. Check your baseUrl configuration.`, - AIErrorType.NETWORK, - undefined, - error - ); - } - - // Handle HTTP status codes - const status = error.status || error.statusCode; - - switch (status) { - case 400: - return new AIProviderError( - `Invalid request: ${message}`, - AIErrorType.INVALID_REQUEST, - status, - error - ); - case 401: - return new AIProviderError( - 'Authentication failed. Check your API key from OpenWebUI Settings > Account.', - AIErrorType.AUTHENTICATION, - status, - error - ); - case 404: - // Model not found or endpoint not found - if (message.includes('model')) { - return new AIProviderError( - 'Model not found. Make sure the model is available in OpenWebUI.', - AIErrorType.MODEL_NOT_FOUND, - status, - error - ); - } - return new AIProviderError( - `API endpoint not found. Check your baseUrl configuration.`, - AIErrorType.NETWORK, - status, - error - ); - case 429: - return new AIProviderError( - 'Rate limit exceeded. Local models may be overloaded.', - AIErrorType.RATE_LIMIT, - status, - error - ); - case 500: - case 502: - case 503: - case 504: - return new AIProviderError( - 'OpenWebUI service error. Check the OpenWebUI logs for details.', - AIErrorType.NETWORK, - status, - error - ); - default: - // Handle timeout errors - if (message.includes('timeout') || error.code === 'ETIMEDOUT') { - return new AIProviderError( - 'Request timeout. Local model inference may be slow.', - AIErrorType.TIMEOUT, - status, - error - ); - } - - return new AIProviderError( - `OpenWebUI API error: ${message}`, - AIErrorType.UNKNOWN, - status, - error - ); + protected async *doStream(params: CompletionParams): AsyncIterable { + if (this.useOllamaProxy) { + yield* this.streamWithOllama(params); + } else { + yield* this.streamWithChat(params); } } } \ No newline at end of file diff --git a/tests/claude.test.ts b/tests/claude.test.ts index d4396d6..726566c 100644 --- a/tests/claude.test.ts +++ b/tests/claude.test.ts @@ -51,7 +51,7 @@ describe('ClaudeProvider', () => { messages: [{ role: 'user', content: 'test' }], temperature: 1.5 }) - ).rejects.toThrow('Temperature must be between 0.0 and 1.0'); + ).rejects.toThrow('Temperature must be a number between 0.0 and 1.0'); }); it('should validate message format', async () => { @@ -62,7 +62,7 @@ describe('ClaudeProvider', () => { provider.complete({ messages: [{ role: 'invalid' as any, content: 'test' }] }) - ).rejects.toThrow('Each message must have a valid role'); + ).rejects.toThrow('Message at index 0 has invalid role \'invalid\'. Must be: system, user, assistant'); }); it('should require initialization before use', async () => { @@ -115,7 +115,7 @@ describe('ClaudeProvider', () => { { role: 'system' as const, content: 'Be concise' } ]; - const result = (provider as any).convertMessages(messages); + const result = (provider as any).processMessages(messages); expect(result.system).toBe('You are helpful\n\nBe concise'); expect(result.messages).toHaveLength(2); @@ -129,7 +129,7 @@ describe('ClaudeProvider', () => { { role: 'assistant' as const, content: 'Hi there' } ]; - const result = (provider as any).convertMessages(messages); + const result = (provider as any).processMessages(messages); expect(result.system).toBeNull(); expect(result.messages).toHaveLength(2); diff --git a/tests/gemini.test.ts b/tests/gemini.test.ts index 35eb952..b8c1631 100644 --- a/tests/gemini.test.ts +++ b/tests/gemini.test.ts @@ -59,12 +59,11 @@ describe('GeminiProvider', () => { expect(info.models).toContain('gemini-1.5-flash'); expect(info.models).toContain('gemini-1.5-pro'); expect(info.models).toContain('gemini-1.0-pro'); - expect(info.maxContextLength).toBe(1000000); + expect(info.maxContextLength).toBe(1048576); expect(info.capabilities).toHaveProperty('vision', true); expect(info.capabilities).toHaveProperty('functionCalling', true); expect(info.capabilities).toHaveProperty('systemMessages', true); expect(info.capabilities).toHaveProperty('multimodal', true); - expect(info.capabilities).toHaveProperty('largeContext', true); }); }); @@ -80,7 +79,7 @@ describe('GeminiProvider', () => { messages: [{ role: 'user', content: 'test' }], temperature: 1.5 }) - ).rejects.toThrow('Temperature must be between 0.0 and 1.0'); + ).rejects.toThrow('Temperature must be a number between 0.0 and 1.0'); }); it('should validate top_p range', async () => { @@ -93,7 +92,7 @@ describe('GeminiProvider', () => { messages: [{ role: 'user', content: 'test' }], topP: 1.5 }) - ).rejects.toThrow('Top-p must be between 0.0 and 1.0'); + ).rejects.toThrow('Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)'); }); it('should validate message format', async () => { @@ -105,7 +104,7 @@ describe('GeminiProvider', () => { provider.complete({ messages: [{ role: 'invalid' as any, content: 'test' }] }) - ).rejects.toThrow('Each message must have a valid role'); + ).rejects.toThrow('Message at index 0 has invalid role \'invalid\'. Must be: system, user, assistant'); }); it('should validate empty content', async () => { @@ -117,7 +116,7 @@ describe('GeminiProvider', () => { provider.complete({ messages: [{ role: 'user', content: '' }] }) - ).rejects.toThrow('Each message must have non-empty string content'); + ).rejects.toThrow('Message at index 0 must have non-empty string content'); }); it('should require initialization before use', async () => { @@ -137,7 +136,7 @@ describe('GeminiProvider', () => { expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError.type).toBe(AIErrorType.AUTHENTICATION); - expect(providerError.message).toContain('Authentication failed'); + expect(providerError.message).toContain('Invalid Google API key'); }); it('should handle rate limit errors', () => { @@ -147,7 +146,7 @@ describe('GeminiProvider', () => { expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError.type).toBe(AIErrorType.RATE_LIMIT); - expect(providerError.message).toContain('Rate limit exceeded'); + expect(providerError.message).toContain('API quota exceeded'); }); it('should handle model not found errors', () => { @@ -161,7 +160,7 @@ describe('GeminiProvider', () => { }); it('should handle invalid request errors', () => { - const error = new Error('invalid request parameters'); + const error = new Error('invalid length request parameters'); const providerError = (provider as any).handleGeminiError(error); @@ -351,7 +350,7 @@ describe('GeminiProvider', () => { expect(() => { (provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash'); - }).toThrow('No content in Gemini response'); + }).toThrow('No candidates found in Gemini response'); }); }); }); \ No newline at end of file diff --git a/tests/openai.test.ts b/tests/openai.test.ts index e8b2a31..1908838 100644 --- a/tests/openai.test.ts +++ b/tests/openai.test.ts @@ -73,7 +73,7 @@ describe('OpenAIProvider', () => { messages: [{ role: 'user', content: 'test' }], temperature: 1.5 }) - ).rejects.toThrow('Temperature must be between 0.0 and 1.0'); + ).rejects.toThrow('Temperature must be a number between 0.0 and 1.0'); }); it('should validate top_p range', async () => { @@ -85,7 +85,7 @@ describe('OpenAIProvider', () => { messages: [{ role: 'user', content: 'test' }], topP: 1.5 }) - ).rejects.toThrow('Top-p must be between 0.0 and 1.0'); + ).rejects.toThrow('Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)'); }); it('should validate message format', async () => { @@ -96,7 +96,7 @@ describe('OpenAIProvider', () => { provider.complete({ messages: [{ role: 'invalid' as any, content: 'test' }] }) - ).rejects.toThrow('Each message must have a valid role'); + ).rejects.toThrow('Message at index 0 has invalid role \'invalid\'. Must be: system, user, assistant'); }); it('should validate empty content', async () => { @@ -107,7 +107,7 @@ describe('OpenAIProvider', () => { provider.complete({ messages: [{ role: 'user', content: '' }] }) - ).rejects.toThrow('Each message must have non-empty string content'); + ).rejects.toThrow('Message at index 0 must have non-empty string content'); }); it('should require initialization before use', async () => { @@ -139,7 +139,7 @@ describe('OpenAIProvider', () => { expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError.type).toBe(AIErrorType.RATE_LIMIT); - expect(providerError.message).toContain('Rate limit exceeded'); + expect(providerError.message).toContain('Too many requests'); }); it('should handle model not found errors', () => { @@ -255,7 +255,7 @@ describe('OpenAIProvider', () => { expect(() => { (provider as any).formatCompletionResponse(mockResponse); - }).toThrow('No content in OpenAI response'); + }).toThrow('No content found in OpenAI response'); }); }); }); \ No newline at end of file diff --git a/tests/openwebui.test.ts b/tests/openwebui.test.ts index 52ad483..82414bc 100644 --- a/tests/openwebui.test.ts +++ b/tests/openwebui.test.ts @@ -64,17 +64,17 @@ describe('OpenWebUIProvider', () => { expect(info.name).toBe('OpenWebUI'); expect(info.version).toBe('1.0.0'); expect(info.supportsStreaming).toBe(true); - expect(info.maxContextLength).toBe(8192); + expect(info.maxContextLength).toBe(32768); expect(Array.isArray(info.models)).toBe(true); }); it('should include expected models', () => { const info = provider.getInfo(); - expect(info.models).toContain('llama3.1'); - expect(info.models).toContain('mistral'); - expect(info.models).toContain('codellama'); - expect(info.models).toContain('gemma2'); + expect(info.models).toContain('llama3.1:latest'); + expect(info.models).toContain('mistral:latest'); + expect(info.models).toContain('codellama:latest'); + expect(info.models).toContain('gemma:latest'); }); it('should have correct capabilities', () => { @@ -83,10 +83,12 @@ describe('OpenWebUIProvider', () => { expect(info.capabilities).toEqual({ vision: false, functionCalling: false, + jsonMode: false, systemMessages: true, - localExecution: true, - customModels: true, - rag: true + reasoning: true, + codeGeneration: true, + localDeployment: true, + multiModel: true }); }); }); @@ -104,7 +106,7 @@ describe('OpenWebUIProvider', () => { expect(aiError).toBeInstanceOf(AIProviderError); expect(aiError.type).toBe(AIErrorType.NETWORK); - expect(aiError.message).toContain('Cannot connect to OpenWebUI'); + expect(aiError.message).toContain('Network error connecting to OpenWebUI'); }); it('should handle hostname resolution errors', () => { @@ -114,8 +116,8 @@ describe('OpenWebUIProvider', () => { const aiError = (provider as any).handleOpenWebUIError(mockError); expect(aiError).toBeInstanceOf(AIProviderError); - expect(aiError.type).toBe(AIErrorType.NETWORK); - expect(aiError.message).toContain('Cannot resolve OpenWebUI hostname'); + expect(aiError.type).toBe(AIErrorType.UNKNOWN); + expect(aiError.message).toContain('OpenWebUI error'); }); it('should handle 404 model not found errors', () => { @@ -137,7 +139,8 @@ describe('OpenWebUIProvider', () => { const aiError = (provider as any).handleOpenWebUIError(mockError); expect(aiError).toBeInstanceOf(AIProviderError); - expect(aiError.type).toBe(AIErrorType.NETWORK); + expect(aiError.type).toBe(AIErrorType.INVALID_REQUEST); + expect(aiError.message).toContain('OpenWebUI endpoint not found'); }); it('should handle timeout errors', () => { @@ -178,7 +181,7 @@ describe('OpenWebUIProvider', () => { expect(aiError).toBeInstanceOf(AIProviderError); expect(aiError.type).toBe(AIErrorType.AUTHENTICATION); - expect(aiError.message).toContain('Settings > Account'); + expect(aiError.message).toContain('Authentication failed with OpenWebUI'); }); it('should handle unknown errors', () => { @@ -220,7 +223,7 @@ describe('OpenWebUIProvider', () => { const prompt = (provider as any).convertMessagesToPrompt(messages); - expect(prompt).toBe('System: You are helpful\n\nHuman: Hello\n\nAssistant: Hi there!'); + expect(prompt).toBe('System: You are helpful\n\nHuman: Hello\n\nAssistant: Hi there!\n\nAssistant: '); }); }); @@ -278,8 +281,8 @@ describe('OpenWebUIProvider', () => { expect(response.usage.promptTokens).toBe(10); expect(response.usage.completionTokens).toBe(5); expect(response.usage.totalTokens).toBe(15); - expect(response.metadata?.created_at).toBe('2024-01-01T00:00:00Z'); - expect(response.metadata?.total_duration).toBe(1000000); + expect(response.metadata?.created).toBe(1704067200000); + expect(response.metadata?.totalDuration).toBe(1000000); }); it('should handle response without content', () => {