From b36d57711bd465577a5d43087180cf968375279b Mon Sep 17 00:00:00 2001 From: Jan-Marlon Leibl Date: Thu, 21 May 2026 13:43:07 +0200 Subject: [PATCH] refactor: pull validateConnection / validateModelName / error mapping into base Form Template Method / Pull Up Method from the Refactoring Guru catalog. The three SDK-based providers (Claude, OpenAI, Gemini) each had their own near-identical implementations of: - validateConnection: try a tiny request, catch errors, map a handful of patterns, warn on the rest - validateModelName: iterate regex patterns, warn on no match - handle{X}Error: switch on HTTP status, map to AIProviderError with brand-flavored messages These collapse into three protected hooks on BaseAIProvider: - getModelNamePatterns(): RegExp[] - sendValidationProbe(): Promise - mapProviderError(error): AIProviderError | null - providerErrorMessages(): Partial> The base owns the wrapping logic (warning, status -> error-type map, common message patterns). Providers only contribute their unique parts. Side effects: - normalizeError now consults mapProviderError before generic mapping, so provider-specific patterns work via the existing try/catch in BaseAIProvider.complete/stream. The per-provider try/catch wrappers around doComplete/doStream are removed. - The unknown-error message becomes `${providerName} error: ${msg}` instead of the hardcoded `Provider error:` / `Claude API error:` etc. - OpenWebUI's HTTP-based validateConnection is preserved (override), since its non-SDK shape doesn't fit the SDK probe pattern. --- src/providers/base.ts | 190 +++++++++++++++------- src/providers/claude.ts | 275 +++++++------------------------- src/providers/gemini.ts | 283 ++++++++++---------------------- src/providers/openai.ts | 319 +++++++++---------------------------- src/providers/openwebui.ts | 2 +- 5 files changed, 352 insertions(+), 717 deletions(-) diff --git a/src/providers/base.ts b/src/providers/base.ts index bad8ba3..85eaf78 100644 --- a/src/providers/base.ts +++ b/src/providers/base.ts @@ -288,10 +288,104 @@ export abstract class BaseAIProvider { */ protected abstract doStream(params: CompletionParams): AsyncIterable; + // ======================================================================== + // PROTECTED HOOKS (subclass overrides; safe defaults provided) + // ======================================================================== + + /** + * Regex patterns that valid model names should match. + * Empty array (the default) skips validation entirely. + */ + protected getModelNamePatterns(): RegExp[] { + return []; + } + + /** + * Send a minimal request to verify the API key and connectivity. + * The default does nothing; SDK-based providers override this and the + * base `validateConnection` handles error mapping. + */ + protected async sendValidationProbe(): Promise { + return; + } + + /** + * Map a provider-specific error to a normalized AIProviderError, or + * return null to let the base error mapping handle it generically. + * Override to recognize provider-specific message patterns (e.g. "API + * key", "quota", "model not found"). + */ + protected mapProviderError(_error: any): AIProviderError | null { + return null; + } + + /** + * Per-HTTP-status message overrides used by `normalizeError` when no + * provider-specific mapping applies. Lets providers add brand-flavored + * hints (console URLs, etc.) without re-implementing the status switch. + */ + protected providerErrorMessages(): Partial> { + return {}; + } + + /** + * Human-readable provider name used in warning messages. + * Default derives from getInfo(); override if construction order makes + * getInfo() unsafe to call here. + */ + protected get providerName(): string { + try { + return this.getInfo().name; + } catch { + return 'Provider'; + } + } + // ======================================================================== // PROTECTED UTILITY METHODS // ======================================================================== + /** + * Validates a model name against the patterns returned by + * `getModelNamePatterns()`. Throws for invalid input, warns for + * non-matching names (since model lists change frequently). + */ + protected validateModelName(modelName: string): void { + if (!modelName || typeof modelName !== 'string') { + throw new AIProviderError( + 'Model name must be a non-empty string', + AIErrorType.INVALID_REQUEST + ); + } + + const patterns = this.getModelNamePatterns(); + if (patterns.length === 0) return; + + const matches = patterns.some(pattern => pattern.test(modelName)); + if (!matches) { + console.warn( + `Model name '${modelName}' doesn't match expected ${this.providerName} naming patterns. This may cause API errors.` + ); + } + } + + /** + * Wraps `sendValidationProbe()` with consistent error handling: throws + * AIProviderError for provider-recognized failures, warns and continues + * for transient ones. + */ + protected async validateConnection(): Promise { + try { + await this.sendValidationProbe(); + } catch (error: any) { + const mapped = this.mapProviderError(error); + if (mapped) { + throw mapped; + } + console.warn(`${this.providerName} connection validation warning:`, error.message); + } + } + /** * Validates and normalizes provider configuration. * @@ -547,65 +641,52 @@ export abstract class BaseAIProvider { * @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; } - // 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 verify your API key is correct and has the necessary permissions.', - AIErrorType.AUTHENTICATION, - status, - error - ); - - case 404: - return new AIProviderError( - 'The specified model or endpoint was not found. Please check the model name and availability.', - AIErrorType.MODEL_NOT_FOUND, - status, - error - ); - - case 429: - return new AIProviderError( - '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 - ); - } + const providerError = this.mapProviderError(error); + if (providerError) { + return providerError; + } + + const status = (error as any).status || (error as any).statusCode; + const message = error.message || 'Unknown error occurred'; + const overrides = this.providerErrorMessages(); + + const statusTypeMap: Record = { + 400: AIErrorType.INVALID_REQUEST, + 401: AIErrorType.AUTHENTICATION, + 403: AIErrorType.AUTHENTICATION, + 404: AIErrorType.MODEL_NOT_FOUND, + 429: AIErrorType.RATE_LIMIT, + 500: AIErrorType.NETWORK, + 502: AIErrorType.NETWORK, + 503: AIErrorType.NETWORK, + 504: AIErrorType.NETWORK + }; + + const defaultMessages: Record = { + 400: `Bad request: ${message}`, + 401: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.', + 403: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.', + 404: 'The specified model or endpoint was not found. Please check the model name and availability.', + 429: 'Rate limit exceeded. Please reduce your request frequency and try again later.', + 500: 'Service temporarily unavailable. Please try again in a few moments.', + 502: 'Service temporarily unavailable. Please try again in a few moments.', + 503: 'Service temporarily unavailable. Please try again in a few moments.', + 504: 'Service temporarily unavailable. Please try again in a few moments.' + }; + + if (status && statusTypeMap[status]) { + return new AIProviderError( + overrides[status] ?? defaultMessages[status]!, + statusTypeMap[status]!, + status, + error + ); } - // Map common error patterns if (message.includes('timeout') || message.includes('ETIMEDOUT')) { return new AIProviderError( 'Request timed out. The operation took longer than expected.', @@ -624,9 +705,8 @@ export abstract class BaseAIProvider { ); } - // Default to unknown error type return new AIProviderError( - `Provider error: ${message}`, + `${this.providerName} error: ${message}`, AIErrorType.UNKNOWN, status, error diff --git a/src/providers/claude.ts b/src/providers/claude.ts index f1bd7a9..d1cab52 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -184,7 +184,6 @@ export class ClaudeProvider extends BaseAIProvider { */ protected async doInitialize(): Promise { try { - // Create Anthropic client with optimized configuration this.client = new Anthropic({ apiKey: this.config.apiKey, baseURL: this.config.baseUrl, @@ -192,17 +191,14 @@ export class ClaudeProvider extends BaseAIProvider { maxRetries: this.config.maxRetries, defaultHeaders: { 'anthropic-version': this.version, - 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', // Enable extended context + 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', } }); - // 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, @@ -231,22 +227,10 @@ export class ClaudeProvider extends BaseAIProvider { throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST); } - try { - // Process messages for Claude's format requirements - const { system, messages } = this.processMessages(params.messages); - - // 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); - } + const { system, messages } = this.processMessages(params.messages); + const requestParams = this.buildRequestParams(params, system, messages, false); + const response = await this.client.messages.create(requestParams); + return this.formatCompletionResponse(response); } /** @@ -268,22 +252,10 @@ export class ClaudeProvider extends BaseAIProvider { throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST); } - try { - // Process messages for Claude's format requirements - const { system, messages } = this.processMessages(params.messages); - - // Build streaming request parameters - const requestParams = this.buildRequestParams(params, system, messages, true); - - // Create streaming request - const stream = await this.client.messages.create(requestParams); - - // Process stream chunks - yield* this.processStreamChunks(stream); - - } catch (error) { - throw this.handleAnthropicError(error as Error); - } + const { system, messages } = this.processMessages(params.messages); + const requestParams = this.buildRequestParams(params, system, messages, true); + const stream = await this.client.messages.create(requestParams); + yield* this.processStreamChunks(stream); } // ======================================================================== @@ -323,75 +295,62 @@ export class ClaudeProvider extends BaseAIProvider { // PRIVATE UTILITY METHODS // ======================================================================== - /** - * Validates the connection by making a minimal test request. - * - * @private - * @throws {AIProviderError} If connection validation fails - */ - private async validateConnection(): Promise { + protected override getModelNamePatterns(): RegExp[] { + return [ + /^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/, + /^claude-instant-[0-9.]+$/, + /^claude-[0-9.]+$/ + ]; + } + + protected override async sendValidationProbe(): Promise { if (!this.client) { throw new Error('Client not initialized'); } - - try { - await this.client.messages.create({ - model: this.defaultModel, - max_tokens: 1, - messages: [{ role: 'user', content: VALIDATION_PROMPT }] - }); - - } catch (error: any) { - // Handle specific validation errors - if (error.status === 401 || error.status === 403) { - throw new AIProviderError( - 'Invalid Anthropic API key. Please verify your API key from https://console.anthropic.com/', - 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.`, - 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); - } + await this.client.messages.create({ + model: this.defaultModel, + max_tokens: 1, + messages: [{ role: 'user', content: VALIDATION_PROMPT }] + }); } - /** - * Validates model name format for Claude 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 - ); + protected override providerErrorMessages(): Partial> { + return { + 401: 'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/', + 403: 'Access forbidden. Your API key may not have permission for this model or feature.', + 404: 'Model not found. The specified Claude model may not be available to your account.', + 429: 'Rate limit exceeded. Claude APIs have usage limits. Please wait before retrying.', + 500: 'Anthropic service temporarily unavailable. Please try again in a few moments.', + 502: 'Anthropic service temporarily unavailable. Please try again in a few moments.', + 503: 'Anthropic service temporarily unavailable. Please try again in a few moments.' + }; + } + + protected override mapProviderError(error: any): AIProviderError | null { + if (!error) return null; + const message: string = error.message || ''; + const status = error.status || error.statusCode; + + if (status === 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 + ); + } } - // 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.`); - } + return null; } /** @@ -569,118 +528,4 @@ export class ClaudeProvider extends BaseAIProvider { }; } - /** - * 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 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 from https://console.anthropic.com/', - AIErrorType.AUTHENTICATION, - status, - error - ); - - case 403: - return new AIProviderError( - '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. 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. Claude APIs have usage limits. Please wait before retrying.', - AIErrorType.RATE_LIMIT, - status, - error - ); - - case 500: - case 502: - case 503: - return new AIProviderError( - '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( - `Claude API error: ${message}`, - AIErrorType.UNKNOWN, - status, - error - ); - } - } -} \ No newline at end of file +} diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts index 78b342f..e124e5b 100644 --- a/src/providers/gemini.ts +++ b/src/providers/gemini.ts @@ -134,20 +134,16 @@ export class GeminiProvider extends BaseAIProvider { throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST); } - try { - const { systemInstruction, contents } = this.convertMessages(params.messages); - const model = params.model || this.defaultModel; + const { systemInstruction, contents } = this.convertMessages(params.messages); + const model = params.model || this.defaultModel; - const response = await this.client.models.generateContent({ - model, - contents, - config: this.buildConfig(params, systemInstruction) - }); + const response = await this.client.models.generateContent({ + model, + contents, + config: this.buildConfig(params, systemInstruction) + }); - return this.formatCompletionResponse(response, model); - } catch (error) { - throw this.handleGeminiError(error as Error); - } + return this.formatCompletionResponse(response, model); } protected async *doStream(params: CompletionParams): AsyncIterable { @@ -155,20 +151,16 @@ export class GeminiProvider extends BaseAIProvider { throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST); } - try { - const { systemInstruction, contents } = this.convertMessages(params.messages); - const model = params.model || this.defaultModel; + const { systemInstruction, contents } = this.convertMessages(params.messages); + const model = params.model || this.defaultModel; - const stream = await this.client.models.generateContentStream({ - model, - contents, - config: this.buildConfig(params, systemInstruction) - }); + const stream = await this.client.models.generateContentStream({ + model, + contents, + config: this.buildConfig(params, systemInstruction) + }); - yield* this.processStreamChunks(stream); - } catch (error) { - throw this.handleGeminiError(error as Error); - } + yield* this.processStreamChunks(stream); } // ======================================================================== @@ -205,55 +197,8 @@ export class GeminiProvider extends BaseAIProvider { // PRIVATE UTILITY METHODS // ======================================================================== - private async validateConnection(): Promise { - if (!this.client) { - throw new Error('Client not initialized'); - } - - try { - await this.client.models.generateContent({ - model: this.defaultModel, - contents: [{ role: 'user', parts: [{ text: VALIDATION_PROMPT }] }], - config: { maxOutputTokens: 1 } - }); - } catch (error: any) { - 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 - ); - } - - console.warn('Gemini connection validation warning:', error.message); - } - } - - private validateModelName(modelName: string): void { - if (!modelName || typeof modelName !== 'string') { - throw new AIProviderError( - 'Model name must be a non-empty string', - AIErrorType.INVALID_REQUEST - ); - } - - const validPatterns = [ + protected override getModelNamePatterns(): RegExp[] { + return [ /^gemini-2\.5-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/, /^gemini-2\.0-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/, /^gemini-1\.5-pro(?:-latest|-vision)?$/, @@ -262,12 +207,72 @@ export class GeminiProvider extends BaseAIProvider { /^gemini-pro(?:-vision)?$/, /^models\/gemini-.+$/ ]; + } - 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.`); + protected override async sendValidationProbe(): Promise { + if (!this.client) { + throw new Error('Client not initialized'); } + await this.client.models.generateContent({ + model: this.defaultModel, + contents: [{ role: 'user', parts: [{ text: VALIDATION_PROMPT }] }], + config: { maxOutputTokens: 1 } + }); + } + + protected override providerErrorMessages(): Partial> { + return { + 401: 'Authentication failed with Gemini API. Please check your API key and permissions.', + 403: 'Authentication failed with Gemini API. Please check your API key and permissions.', + 404: 'Gemini API endpoint not found. Please check the model name and API version.', + 429: 'Rate limit exceeded. Please reduce request frequency.', + 500: 'Gemini service temporarily unavailable. Please try again in a few moments.', + 502: 'Gemini service temporarily unavailable. Please try again in a few moments.', + 503: 'Gemini service temporarily unavailable. Please try again in a few moments.' + }; + } + + protected override mapProviderError(error: any): AIProviderError | null { + if (!error) return null; + const message: string = error.message || ''; + const status = error.status || error.statusCode; + + if (message.includes('API key')) { + return new AIProviderError( + 'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey', + AIErrorType.AUTHENTICATION, status, error + ); + } + + if (message.includes('quota') || message.includes('limit exceeded')) { + return new AIProviderError( + 'API quota exceeded. Please check your Google Cloud quotas and billing.', + AIErrorType.RATE_LIMIT, status, error + ); + } + + if (message.includes('model not found') || message.includes('invalid model')) { + return new AIProviderError( + 'Model not found. The specified Gemini model may not exist or be available to your API key.', + AIErrorType.MODEL_NOT_FOUND, status, error + ); + } + + if (message.includes('safety') || message.includes('blocked')) { + return new AIProviderError( + 'Content blocked by safety filters. Please modify your input or adjust safety settings.', + AIErrorType.INVALID_REQUEST, status, error + ); + } + + if (message.includes('length') || message.includes('token')) { + return new AIProviderError( + 'Input too long or exceeds token limits. Please reduce input size or max tokens.', + AIErrorType.INVALID_REQUEST, status, error + ); + } + + return null; } private convertMessages(messages: AIMessage[]): ProcessedMessages { @@ -389,128 +394,4 @@ export class GeminiProvider extends BaseAIProvider { }; } - private handleGeminiError(error: any): AIProviderError { - if (error instanceof AIProviderError) { - return error; - } - - const message = error.message || 'Unknown Gemini API error'; - const status = error.status || error.statusCode; - - if (message.includes('API key')) { - return new AIProviderError( - 'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey', - AIErrorType.AUTHENTICATION, - status, - error - ); - } - - if (message.includes('quota') || message.includes('limit exceeded')) { - return new AIProviderError( - 'API quota exceeded. Please check your Google Cloud quotas and billing.', - AIErrorType.RATE_LIMIT, - status, - error - ); - } - - if (message.includes('model not found') || message.includes('invalid model')) { - return new AIProviderError( - 'Model not found. The specified Gemini model may not exist or be available to your API key.', - AIErrorType.MODEL_NOT_FOUND, - status, - error - ); - } - - if (message.includes('safety') || message.includes('blocked')) { - return new AIProviderError( - 'Content blocked by safety filters. Please modify your input or adjust safety settings.', - AIErrorType.INVALID_REQUEST, - status, - error - ); - } - - if (message.includes('length') || message.includes('token')) { - return new AIProviderError( - '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. Gemini may be experiencing high load.', - AIErrorType.TIMEOUT, - status, - error - ); - } - - if (message.includes('network') || message.includes('connection')) { - return new AIProviderError( - 'Network error connecting to Gemini servers.', - AIErrorType.NETWORK, - status, - error - ); - } - - 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 - ); - } - } } diff --git a/src/providers/openai.ts b/src/providers/openai.ts index 00e89f0..3e20b77 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -255,19 +255,9 @@ export class OpenAIProvider extends BaseAIProvider { throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); } - try { - // 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); - } + const requestParams = this.buildRequestParams(params, false); + const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion; + return this.formatCompletionResponse(response); } /** @@ -289,19 +279,9 @@ export class OpenAIProvider extends BaseAIProvider { throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); } - try { - // Build streaming request parameters - const requestParams = this.buildRequestParams(params, true); - - // Create streaming request - const stream = this.client.chat.completions.create(requestParams); - - // Process stream chunks - yield* this.processStreamChunks(stream); - - } catch (error) { - throw this.handleOpenAIError(error as Error); - } + const requestParams = this.buildRequestParams(params, true); + const stream = this.client.chat.completions.create(requestParams); + yield* this.processStreamChunks(stream); } // ======================================================================== @@ -346,86 +326,82 @@ export class OpenAIProvider extends BaseAIProvider { // PRIVATE UTILITY METHODS // ======================================================================== - /** - * Validates the connection by making a minimal test request. - * - * @private - * @throws {AIProviderError} If connection validation fails - */ - private async validateConnection(): Promise { + protected override getModelNamePatterns(): RegExp[] { + return [ + /^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/, + /^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/, + /^gpt-3\.5-turbo(?:-\d{4})?$/, + /^gpt-4-\d{4}-preview$/, + /^text-davinci-\d{3}$/, + /^ft:.+$/ + ]; + } + + protected override async sendValidationProbe(): Promise { if (!this.client) { throw new Error('Client not initialized'); } - - try { - await this.client.chat.completions.create({ - model: this.defaultModel, - messages: [{ role: 'user', content: VALIDATION_PROMPT }], - max_tokens: 1 - }); - - } catch (error: any) { - // Handle specific validation errors - if (error.status === 401) { - throw new AIProviderError( - 'Invalid OpenAI API key. Please verify your API key from https://platform.openai.com/api-keys', - AIErrorType.AUTHENTICATION, - error.status - ); - } - - 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); - } + await this.client.chat.completions.create({ + model: this.defaultModel, + messages: [{ role: 'user', content: VALIDATION_PROMPT }], + max_tokens: 1 + }); } - /** - * 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 + protected override providerErrorMessages(): Partial> { + return { + 401: 'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys', + 403: 'Access forbidden. Your API key may not have permission for this model or feature.', + 404: 'Model not found. The specified OpenAI model may not exist or be available to your account.', + 429: 'Too many requests. Please slow down and try again.', + 500: 'OpenAI service temporarily unavailable. Please try again in a few moments.', + 502: 'OpenAI service temporarily unavailable. Please try again in a few moments.', + 503: 'OpenAI service temporarily unavailable. Please try again in a few moments.', + 504: 'OpenAI service temporarily unavailable. Please try again in a few moments.' + }; + } + + protected override mapProviderError(error: any): AIProviderError | null { + if (!error) return null; + const message: string = error.message || ''; + const status = error.status || error.statusCode; + + if (status === 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 + ); + } + } + + if (status === 403 && (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 ); } - // 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.`); + if (status === 429 && message.includes('quota')) { + return new AIProviderError( + 'Usage quota exceeded. Please check your OpenAI usage limits and billing.', + AIErrorType.RATE_LIMIT, status, error + ); } + + return null; } /** @@ -568,151 +544,4 @@ export class OpenAIProvider extends BaseAIProvider { }; } - /** - * 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 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 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. Your API key may not have permission for this model or feature.', - AIErrorType.AUTHENTICATION, - status, - error - ); - - case 404: - return new AIProviderError( - '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( - '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 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, - status, - error - ); - } - } -} \ No newline at end of file +} diff --git a/src/providers/openwebui.ts b/src/providers/openwebui.ts index d8220b2..c078a6b 100644 --- a/src/providers/openwebui.ts +++ b/src/providers/openwebui.ts @@ -381,7 +381,7 @@ export class OpenWebUIProvider extends BaseAIProvider { * @private * @throws {AIProviderError} If connection validation fails */ - private async validateConnection(): Promise { + protected override async validateConnection(): Promise { try { // Try to fetch available models to test connectivity const url = this.useOllamaProxy -- 2.49.1