Merge pull request 'refactor: pull validateConnection / validateModelName / error mapping into base' (#8) from refactor/template-method-pullup into main

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-05-21 11:46:55 +00:00
committed by Gitea
5 changed files with 352 additions and 717 deletions

View File

@@ -288,10 +288,104 @@ export abstract class BaseAIProvider {
*/ */
protected abstract doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk>; protected abstract doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk>;
// ========================================================================
// 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<void> {
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<Record<number, string>> {
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 // 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<void> {
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. * Validates and normalizes provider configuration.
* *
@@ -547,65 +641,52 @@ export abstract class BaseAIProvider {
* @returns Normalized AIProviderError with appropriate type and context * @returns Normalized AIProviderError with appropriate type and context
*/ */
protected normalizeError(error: Error): AIProviderError { protected normalizeError(error: Error): AIProviderError {
// If already normalized, return as-is
if (error instanceof AIProviderError) { if (error instanceof AIProviderError) {
return error; return error;
} }
// Extract status code if available const providerError = this.mapProviderError(error);
const status = (error as any).status || (error as any).statusCode; if (providerError) {
const message = error.message || 'Unknown error occurred'; return providerError;
}
// Map HTTP status codes to error types
if (status) { const status = (error as any).status || (error as any).statusCode;
switch (status) { const message = error.message || 'Unknown error occurred';
case 400: const overrides = this.providerErrorMessages();
return new AIProviderError(
`Bad request: ${message}`, const statusTypeMap: Record<number, AIErrorType> = {
AIErrorType.INVALID_REQUEST, 400: AIErrorType.INVALID_REQUEST,
status, 401: AIErrorType.AUTHENTICATION,
error 403: AIErrorType.AUTHENTICATION,
); 404: AIErrorType.MODEL_NOT_FOUND,
429: AIErrorType.RATE_LIMIT,
case 401: 500: AIErrorType.NETWORK,
case 403: 502: AIErrorType.NETWORK,
return new AIProviderError( 503: AIErrorType.NETWORK,
'Authentication failed. Please verify your API key is correct and has the necessary permissions.', 504: AIErrorType.NETWORK
AIErrorType.AUTHENTICATION, };
status,
error const defaultMessages: Record<number, string> = {
); 400: `Bad request: ${message}`,
401: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
case 404: 403: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
return new AIProviderError( 404: 'The specified model or endpoint was not found. Please check the model name and availability.',
'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.',
AIErrorType.MODEL_NOT_FOUND, 500: 'Service temporarily unavailable. Please try again in a few moments.',
status, 502: 'Service temporarily unavailable. Please try again in a few moments.',
error 503: 'Service temporarily unavailable. Please try again in a few moments.',
); 504: 'Service temporarily unavailable. Please try again in a few moments.'
};
case 429:
return new AIProviderError( if (status && statusTypeMap[status]) {
'Rate limit exceeded. Please reduce your request frequency and try again later.', return new AIProviderError(
AIErrorType.RATE_LIMIT, overrides[status] ?? defaultMessages[status]!,
status, statusTypeMap[status]!,
error 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
);
}
} }
// Map common error patterns
if (message.includes('timeout') || message.includes('ETIMEDOUT')) { if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
return new AIProviderError( return new AIProviderError(
'Request timed out. The operation took longer than expected.', '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( return new AIProviderError(
`Provider error: ${message}`, `${this.providerName} error: ${message}`,
AIErrorType.UNKNOWN, AIErrorType.UNKNOWN,
status, status,
error error

View File

@@ -184,7 +184,6 @@ export class ClaudeProvider extends BaseAIProvider {
*/ */
protected async doInitialize(): Promise<void> { protected async doInitialize(): Promise<void> {
try { try {
// Create Anthropic client with optimized configuration
this.client = new Anthropic({ this.client = new Anthropic({
apiKey: this.config.apiKey, apiKey: this.config.apiKey,
baseURL: this.config.baseUrl, baseURL: this.config.baseUrl,
@@ -192,15 +191,12 @@ export class ClaudeProvider extends BaseAIProvider {
maxRetries: this.config.maxRetries, maxRetries: this.config.maxRetries,
defaultHeaders: { defaultHeaders: {
'anthropic-version': this.version, '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(); await this.validateConnection();
} catch (error) { } catch (error) {
// Clean up on failure
this.client = null; this.client = null;
throw new AIProviderError( throw new AIProviderError(
@@ -231,22 +227,10 @@ export class ClaudeProvider extends BaseAIProvider {
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const { system, messages } = this.processMessages(params.messages);
// Process messages for Claude's format requirements const requestParams = this.buildRequestParams(params, system, messages, false);
const { system, messages } = this.processMessages(params.messages); const response = await this.client.messages.create(requestParams);
return this.formatCompletionResponse(response);
// 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);
}
} }
/** /**
@@ -268,22 +252,10 @@ export class ClaudeProvider extends BaseAIProvider {
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const { system, messages } = this.processMessages(params.messages);
// Process messages for Claude's format requirements const requestParams = this.buildRequestParams(params, system, messages, true);
const { system, messages } = this.processMessages(params.messages); const stream = await this.client.messages.create(requestParams);
yield* this.processStreamChunks(stream);
// 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);
}
} }
// ======================================================================== // ========================================================================
@@ -323,75 +295,62 @@ export class ClaudeProvider extends BaseAIProvider {
// PRIVATE UTILITY METHODS // PRIVATE UTILITY METHODS
// ======================================================================== // ========================================================================
/** protected override getModelNamePatterns(): RegExp[] {
* Validates the connection by making a minimal test request. return [
* /^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/,
* @private /^claude-instant-[0-9.]+$/,
* @throws {AIProviderError} If connection validation fails /^claude-[0-9.]+$/
*/ ];
private async validateConnection(): Promise<void> { }
protected override async sendValidationProbe(): Promise<void> {
if (!this.client) { if (!this.client) {
throw new Error('Client not initialized'); throw new Error('Client not initialized');
} }
await this.client.messages.create({
try { model: this.defaultModel,
await this.client.messages.create({ max_tokens: 1,
model: this.defaultModel, messages: [{ role: 'user', content: VALIDATION_PROMPT }]
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);
}
} }
/** protected override providerErrorMessages(): Partial<Record<number, string>> {
* Validates model name format for Claude models. return {
* 401: 'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/',
* @private 403: 'Access forbidden. Your API key may not have permission for this model or feature.',
* @param modelName - Model name to validate 404: 'Model not found. The specified Claude model may not be available to your account.',
* @throws {AIProviderError} If model name format is invalid 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.',
private validateModelName(modelName: string): void { 502: 'Anthropic service temporarily unavailable. Please try again in a few moments.',
if (!modelName || typeof modelName !== 'string') { 503: 'Anthropic service temporarily unavailable. Please try again in a few moments.'
throw new AIProviderError( };
'Model name must be a non-empty string', }
AIErrorType.INVALID_REQUEST
); 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 return null;
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.`);
}
} }
/** /**
@@ -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
);
}
}
} }

View File

@@ -134,20 +134,16 @@ export class GeminiProvider extends BaseAIProvider {
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const { systemInstruction, contents } = this.convertMessages(params.messages);
const { systemInstruction, contents } = this.convertMessages(params.messages); const model = params.model || this.defaultModel;
const model = params.model || this.defaultModel;
const response = await this.client.models.generateContent({ const response = await this.client.models.generateContent({
model, model,
contents, contents,
config: this.buildConfig(params, systemInstruction) config: this.buildConfig(params, systemInstruction)
}); });
return this.formatCompletionResponse(response, model); return this.formatCompletionResponse(response, model);
} catch (error) {
throw this.handleGeminiError(error as Error);
}
} }
protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> { protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
@@ -155,20 +151,16 @@ export class GeminiProvider extends BaseAIProvider {
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const { systemInstruction, contents } = this.convertMessages(params.messages);
const { systemInstruction, contents } = this.convertMessages(params.messages); const model = params.model || this.defaultModel;
const model = params.model || this.defaultModel;
const stream = await this.client.models.generateContentStream({ const stream = await this.client.models.generateContentStream({
model, model,
contents, contents,
config: this.buildConfig(params, systemInstruction) config: this.buildConfig(params, systemInstruction)
}); });
yield* this.processStreamChunks(stream); yield* this.processStreamChunks(stream);
} catch (error) {
throw this.handleGeminiError(error as Error);
}
} }
// ======================================================================== // ========================================================================
@@ -205,55 +197,8 @@ export class GeminiProvider extends BaseAIProvider {
// PRIVATE UTILITY METHODS // PRIVATE UTILITY METHODS
// ======================================================================== // ========================================================================
private async validateConnection(): Promise<void> { protected override getModelNamePatterns(): RegExp[] {
if (!this.client) { return [
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 = [
/^gemini-2\.5-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/, /^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-2\.0-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
/^gemini-1\.5-pro(?:-latest|-vision)?$/, /^gemini-1\.5-pro(?:-latest|-vision)?$/,
@@ -262,12 +207,72 @@ export class GeminiProvider extends BaseAIProvider {
/^gemini-pro(?:-vision)?$/, /^gemini-pro(?:-vision)?$/,
/^models\/gemini-.+$/ /^models\/gemini-.+$/
]; ];
}
const isValid = validPatterns.some(pattern => pattern.test(modelName)); protected override async sendValidationProbe(): Promise<void> {
if (!this.client) {
if (!isValid) { throw new Error('Client not initialized');
console.warn(`Model name '${modelName}' doesn't match expected Gemini naming patterns. This may cause API errors.`);
} }
await this.client.models.generateContent({
model: this.defaultModel,
contents: [{ role: 'user', parts: [{ text: VALIDATION_PROMPT }] }],
config: { maxOutputTokens: 1 }
});
}
protected override providerErrorMessages(): Partial<Record<number, string>> {
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 { 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
);
}
}
} }

View File

@@ -255,19 +255,9 @@ export class OpenAIProvider extends BaseAIProvider {
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const requestParams = this.buildRequestParams(params, false);
// Build optimized request parameters const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion;
const requestParams = this.buildRequestParams(params, false); return this.formatCompletionResponse(response);
// 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);
}
} }
/** /**
@@ -289,19 +279,9 @@ export class OpenAIProvider extends BaseAIProvider {
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const requestParams = this.buildRequestParams(params, true);
// Build streaming request parameters const stream = this.client.chat.completions.create(requestParams);
const requestParams = this.buildRequestParams(params, true); yield* this.processStreamChunks(stream);
// 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);
}
} }
// ======================================================================== // ========================================================================
@@ -346,86 +326,82 @@ export class OpenAIProvider extends BaseAIProvider {
// PRIVATE UTILITY METHODS // PRIVATE UTILITY METHODS
// ======================================================================== // ========================================================================
/** protected override getModelNamePatterns(): RegExp[] {
* Validates the connection by making a minimal test request. return [
* /^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/,
* @private /^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/,
* @throws {AIProviderError} If connection validation fails /^gpt-3\.5-turbo(?:-\d{4})?$/,
*/ /^gpt-4-\d{4}-preview$/,
private async validateConnection(): Promise<void> { /^text-davinci-\d{3}$/,
/^ft:.+$/
];
}
protected override async sendValidationProbe(): Promise<void> {
if (!this.client) { if (!this.client) {
throw new Error('Client not initialized'); throw new Error('Client not initialized');
} }
await this.client.chat.completions.create({
try { model: this.defaultModel,
await this.client.chat.completions.create({ messages: [{ role: 'user', content: VALIDATION_PROMPT }],
model: this.defaultModel, max_tokens: 1
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);
}
} }
/** protected override providerErrorMessages(): Partial<Record<number, string>> {
* Validates model name format for OpenAI models. return {
* 401: 'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys',
* @private 403: 'Access forbidden. Your API key may not have permission for this model or feature.',
* @param modelName - Model name to validate 404: 'Model not found. The specified OpenAI model may not exist or be available to your account.',
* @throws {AIProviderError} If model name format is invalid 429: 'Too many requests. Please slow down and try again.',
*/ 500: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
private validateModelName(modelName: string): void { 502: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
if (!modelName || typeof modelName !== 'string') { 503: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
throw new AIProviderError( 504: 'OpenAI service temporarily unavailable. Please try again in a few moments.'
'Model name must be a non-empty string', };
AIErrorType.INVALID_REQUEST }
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 if (status === 429 && message.includes('quota')) {
const validPatterns = [ return new AIProviderError(
/^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4o, gpt-4o-mini, gpt-4o-2024-11-20 'Usage quota exceeded. Please check your OpenAI usage limits and billing.',
/^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4, gpt-4-turbo, gpt-4-turbo-2024-04-09 AIErrorType.RATE_LIMIT, status, error
/^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.`);
} }
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
);
}
}
} }

View File

@@ -381,7 +381,7 @@ export class OpenWebUIProvider extends BaseAIProvider {
* @private * @private
* @throws {AIProviderError} If connection validation fails * @throws {AIProviderError} If connection validation fails
*/ */
private async validateConnection(): Promise<void> { protected override async validateConnection(): Promise<void> {
try { try {
// Try to fetch available models to test connectivity // Try to fetch available models to test connectivity
const url = this.useOllamaProxy const url = this.useOllamaProxy