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<void>
- mapProviderError(error): AIProviderError | null
- providerErrorMessages(): Partial<Record<number, string>>
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.
This commit is contained in:
@@ -288,10 +288,104 @@ export abstract class BaseAIProvider {
|
||||
*/
|
||||
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
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -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<number, AIErrorType> = {
|
||||
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<number, string> = {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user