Files
simple-ai-provider/src/providers/base.ts
Jan-Marlon Leibl b36d57711b 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.
2026-05-21 13:43:07 +02:00

715 lines
23 KiB
TypeScript

/**
* Abstract Base Provider for AI Services
*
* This module provides the foundation for all AI provider implementations,
* ensuring consistency, proper error handling, and maintainable code structure.
*
* Design Principles:
* - Template Method Pattern: Define algorithm structure, let subclasses implement specifics
* - Fail-Fast: Validate inputs early and provide clear error messages
* - Separation of Concerns: Configuration, validation, execution are clearly separated
* - Type Safety: Comprehensive TypeScript support for better DX
*
* @author Jan-Marlon Leibl
* @version 1.0.0
*/
import type {
AIProviderConfig,
CompletionParams,
CompletionResponse,
CompletionChunk,
ProviderInfo,
ResponseType
} from '../types/index.js';
import { AIProviderError, AIErrorType, generateResponseTypePrompt, parseAndValidateResponseType } from '../types/index.js';
import {
CONFIG_BOUNDS,
DEFAULT_MAX_RETRIES,
DEFAULT_TIMEOUT_MS
} from '../constants.js';
// ============================================================================
// ABSTRACT BASE PROVIDER CLASS
// ============================================================================
/**
* Abstract base class that all AI providers must extend.
*
* This class implements the Template Method pattern, providing a consistent
* interface and workflow while allowing providers to implement their specific
* logic in protected abstract methods.
*
* Lifecycle:
* 1. Construction: validateConfig() called automatically
* 2. Initialization: initialize() must be called before use
* 3. Usage: complete() and stream() methods available
* 4. Error Handling: All errors are normalized to AIProviderError
*
* @example
* ```typescript
* class MyProvider extends BaseAIProvider {
* protected async doInitialize(): Promise<void> {
* // Initialize your client/connection here
* }
*
* protected async doComplete(params: CompletionParams): Promise<CompletionResponse> {
* // Implement completion logic here
* }
*
* // ... other required methods
* }
* ```
*/
export abstract class BaseAIProvider {
// ========================================================================
// INSTANCE PROPERTIES
// ========================================================================
/** Validated configuration object for this provider instance */
protected readonly config: AIProviderConfig;
/** Internal flag tracking initialization state */
private initialized: boolean = false;
// ========================================================================
// CONSTRUCTOR & CONFIGURATION
// ========================================================================
/**
* Constructs a new provider instance with validated configuration.
*
* @param config - Provider configuration object
* @throws {AIProviderError} If configuration is invalid
*
* @example
* ```typescript
* const provider = new MyProvider({
* apiKey: 'your-api-key',
* timeout: 30000,
* maxRetries: 3
* });
* ```
*/
constructor(config: AIProviderConfig) {
this.config = this.validateAndNormalizeConfig(config);
}
// ========================================================================
// PUBLIC API METHODS
// ========================================================================
/**
* Initializes the provider for use.
*
* This method must be called before any completion requests. It handles
* provider-specific setup such as client initialization and connection testing.
*
* @returns Promise that resolves when initialization is complete
* @throws {AIProviderError} If initialization fails
*
* @example
* ```typescript
* await provider.initialize();
* // Provider is now ready for use
* ```
*/
public async initialize(): Promise<void> {
try {
await this.doInitialize();
this.initialized = true;
} catch (error) {
// Normalize any error to our standard format
throw this.normalizeError(error as Error);
}
}
/**
* Checks if the provider has been initialized.
*
* @returns true if the provider is ready for use, false otherwise
*/
public isInitialized(): boolean {
return this.initialized;
}
/**
* Generates a text completion based on the provided parameters.
*
* This method handles validation, error normalization, and delegates to
* the provider-specific implementation via the Template Method pattern.
*
* @param params - Completion parameters including messages, model options, etc.
* @returns Promise resolving to the completion response
* @throws {AIProviderError} If the request fails or parameters are invalid
*
* @example
* ```typescript
* const response = await provider.complete({
* messages: [{ role: 'user', content: 'Hello!' }],
* maxTokens: 100,
* temperature: 0.7
* });
* console.log(response.content);
* ```
*/
public async complete<T>(params: CompletionParams<T>): Promise<CompletionResponse<T>>;
public async complete(params: CompletionParams): Promise<CompletionResponse<string>>;
public async complete<T = any>(params: CompletionParams<T>): Promise<CompletionResponse<T | string>> {
// Ensure provider is ready for use
this.ensureInitialized();
// Validate input parameters early
this.validateCompletionParams(params);
try {
// Process response type instructions if specified
const processedParams = this.processResponseType(params);
// Delegate to provider-specific implementation
const response = await this.doComplete(processedParams);
// If a responseType is defined, parse and validate the response
if (params.responseType) {
const parsedData = parseAndValidateResponseType<T>(response.content, params.responseType);
return {
...response,
content: parsedData,
rawContent: response.content,
};
}
// Otherwise, return the raw string content
return {
...response,
content: response.content,
};
} catch (error) {
// Normalize error to our standard format
throw this.normalizeError(error as Error);
}
}
/**
* Generates a streaming text completion.
*
* This method returns an async iterable that yields chunks of the response
* as they become available, enabling real-time UI updates.
*
* @param params - Completion parameters
* @returns AsyncIterable yielding completion chunks
* @throws {AIProviderError} If the request fails or parameters are invalid
*
* @example
* ```typescript
* for await (const chunk of provider.stream(params)) {
* if (!chunk.isComplete) {
* process.stdout.write(chunk.content);
* } else {
* console.log('\nDone! Usage:', chunk.usage);
* }
* }
* ```
*/
public async *stream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
// Ensure provider is ready for use
this.ensureInitialized();
// Validate input parameters early
this.validateCompletionParams(params);
try {
// Process response type instructions if specified
const processedParams = this.processResponseType(params);
// Delegate to provider-specific implementation
yield* this.doStream(processedParams);
} catch (error) {
// Normalize error to our standard format
throw this.normalizeError(error as Error);
}
}
/**
* Returns information about this provider and its capabilities.
*
* This method provides metadata about the provider including supported
* models, context length, streaming support, and special capabilities.
*
* @returns Provider information object
*/
public abstract getInfo(): ProviderInfo;
// ========================================================================
// ABSTRACT METHODS (TEMPLATE METHOD PATTERN)
// ========================================================================
/**
* Provider-specific initialization logic.
*
* Subclasses should implement this method to handle their specific
* initialization requirements such as:
* - Creating API clients
* - Testing connections
* - Validating credentials
* - Setting up authentication
*
* @protected
* @returns Promise that resolves when initialization is complete
* @throws {Error} If initialization fails (will be normalized to AIProviderError)
*/
protected abstract doInitialize(): Promise<void>;
/**
* 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<CompletionResponse<string>>;
/**
* 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<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.
*
* This method ensures all required fields are present and sets sensible
* defaults for optional fields. It follows the fail-fast principle.
*
* @protected
* @param config - Raw configuration object
* @returns Validated and normalized configuration
* @throws {AIProviderError} If configuration is invalid
*/
protected validateAndNormalizeConfig(config: AIProviderConfig): AIProviderConfig {
// Validate required fields
if (!config) {
throw new AIProviderError(
'Configuration object is required',
AIErrorType.INVALID_REQUEST
);
}
if (!config.apiKey || typeof config.apiKey !== 'string' || config.apiKey.trim() === '') {
throw new AIProviderError(
'API key is required and must be a non-empty string',
AIErrorType.INVALID_REQUEST
);
}
this.validateNumberInRange('timeout', config.timeout, {
...CONFIG_BOUNDS.timeoutMs,
label: `${CONFIG_BOUNDS.timeoutMs.min}ms and ${CONFIG_BOUNDS.timeoutMs.max}ms`
});
this.validateNumberInRange('maxRetries', config.maxRetries, {
...CONFIG_BOUNDS.maxRetries,
integer: true
});
return {
...config,
timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES
};
}
/**
* Validates an optional numeric parameter against a range.
* Skips validation when value is undefined so callers can apply defaults afterward.
*/
private validateNumberInRange(
name: string,
value: number | undefined,
bounds: {
min?: number;
max?: number;
exclusiveMin?: boolean;
integer?: boolean;
label?: string;
}
): void {
if (value === undefined) return;
const { min, max, exclusiveMin, integer, label } = bounds;
const range = label ?? this.describeRange(min, max, exclusiveMin);
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new AIProviderError(
`${name} must be a number between ${range}`,
AIErrorType.INVALID_REQUEST
);
}
if (integer && !Number.isInteger(value)) {
throw new AIProviderError(
`${name} must be an integer`,
AIErrorType.INVALID_REQUEST
);
}
if (min !== undefined && (exclusiveMin ? value <= min : value < min)) {
throw new AIProviderError(
`${name} must be ${exclusiveMin ? 'greater than' : 'at least'} ${min}`,
AIErrorType.INVALID_REQUEST
);
}
if (max !== undefined && value > max) {
throw new AIProviderError(
`${name} must be at most ${max}`,
AIErrorType.INVALID_REQUEST
);
}
}
private describeRange(min?: number, max?: number, exclusiveMin?: boolean): string {
if (min !== undefined && max !== undefined) {
return `${exclusiveMin ? '>' : '>='}${min} and <=${max}`;
}
if (min !== undefined) return `${exclusiveMin ? '>' : '>='}${min}`;
if (max !== undefined) return `<=${max}`;
return 'a valid number';
}
/**
* Ensures the provider has been initialized before use.
*
* @protected
* @throws {AIProviderError} If the provider is not initialized
*/
protected ensureInitialized(): void {
if (!this.initialized) {
throw new AIProviderError(
'Provider must be initialized before use. Call initialize() first.',
AIErrorType.INVALID_REQUEST
);
}
}
/**
* Validates completion parameters comprehensively.
*
* This method performs thorough validation of all completion parameters
* to ensure they meet the requirements and constraints.
*
* @protected
* @param params - Parameters to validate
* @throws {AIProviderError} If any parameter is invalid
*/
protected validateCompletionParams<T = any>(params: CompletionParams<T>): void {
if (!params || typeof params !== 'object') {
throw new AIProviderError(
'Completion parameters object is required',
AIErrorType.INVALID_REQUEST
);
}
this.validateMessages(params.messages);
this.validateNumberInRange('temperature', params.temperature, CONFIG_BOUNDS.temperature);
this.validateNumberInRange('topP', params.topP, CONFIG_BOUNDS.topP);
this.validateNumberInRange('maxTokens', params.maxTokens, { ...CONFIG_BOUNDS.maxTokens, integer: true });
this.validateStopSequences(params.stopSequences);
}
/**
* Validates the messages array parameter.
*
* @private
* @param messages - Messages array to validate
* @throws {AIProviderError} If messages are invalid
*/
private validateMessages(messages: any): void {
if (!Array.isArray(messages) || messages.length === 0) {
throw new AIProviderError(
'Messages must be a non-empty array',
AIErrorType.INVALID_REQUEST
);
}
const validRoles = ['system', 'user', 'assistant'] as const;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || typeof message !== 'object') {
throw new AIProviderError(
`Message at index ${i} must be an object`,
AIErrorType.INVALID_REQUEST
);
}
if (!validRoles.includes(message.role)) {
throw new AIProviderError(
`Message at index ${i} has invalid role '${message.role}'. Must be: ${validRoles.join(', ')}`,
AIErrorType.INVALID_REQUEST
);
}
if (typeof message.content !== 'string' || message.content.trim() === '') {
throw new AIProviderError(
`Message at index ${i} must have non-empty string content`,
AIErrorType.INVALID_REQUEST
);
}
}
}
private validateStopSequences(stopSequences?: string[]): void {
if (stopSequences !== undefined) {
if (!Array.isArray(stopSequences)) {
throw new AIProviderError(
'Stop sequences must be an array of strings',
AIErrorType.INVALID_REQUEST
);
}
for (let i = 0; i < stopSequences.length; i++) {
if (typeof stopSequences[i] !== 'string') {
throw new AIProviderError(
`Stop sequence at index ${i} must be a string`,
AIErrorType.INVALID_REQUEST
);
}
}
}
}
/**
* Processes completion parameters to include response type instructions.
*
* This method automatically adds system prompt instructions when a response
* type is specified, ensuring the AI understands the expected output format.
*
* @protected
* @param params - Original completion parameters
* @returns Processed parameters with response type instructions
*/
protected processResponseType<T>(params: CompletionParams<T>): CompletionParams<T> {
if (!params.responseType) {
return params;
}
// Create a copy of the parameters to avoid mutation
const processedParams = { ...params };
// Generate the response type instruction
const responseTypePrompt = generateResponseTypePrompt(params.responseType);
// Find existing system messages or create new ones
const systemMessages = params.messages.filter(msg => msg.role === 'system');
const nonSystemMessages = params.messages.filter(msg => msg.role !== 'system');
// Combine existing system messages with response type instruction
const combinedSystemContent = systemMessages.length > 0
? systemMessages.map(msg => msg.content).join('\n\n') + '\n\n' + responseTypePrompt
: responseTypePrompt;
// Create new messages array with combined system message
processedParams.messages = [
{ role: 'system', content: combinedSystemContent },
...nonSystemMessages
];
return processedParams;
}
/**
* Normalizes any error into a standardized AIProviderError.
*
* This method provides consistent error handling across all providers,
* mapping various error types and status codes to appropriate categories.
*
* @protected
* @param error - The original error to normalize
* @returns Normalized AIProviderError with appropriate type and context
*/
protected normalizeError(error: Error): AIProviderError {
if (error instanceof AIProviderError) {
return error;
}
const providerError = this.mapProviderError(error);
if (providerError) {
return providerError;
}
const status = (error as any).status || (error as any).statusCode;
const message = error.message || 'Unknown error occurred';
const overrides = this.providerErrorMessages();
const statusTypeMap: Record<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
);
}
if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
return new AIProviderError(
'Request timed out. The operation took longer than expected.',
AIErrorType.TIMEOUT,
undefined,
error
);
}
if (message.includes('network') || message.includes('ENOTFOUND') || message.includes('ECONNREFUSED')) {
return new AIProviderError(
'Network error occurred. Please check your internet connection and try again.',
AIErrorType.NETWORK,
undefined,
error
);
}
return new AIProviderError(
`${this.providerName} error: ${message}`,
AIErrorType.UNKNOWN,
status,
error
);
}
}