refactor: extract constants, parameterize validators, simplify factory #7

Merged
jleibl merged 1 commits from refactor/quick-wins-factory-validators-constants into main 2026-05-21 11:36:34 +00:00
8 changed files with 163 additions and 253 deletions

35
src/constants.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Shared defaults used across all providers.
*/
export const DEFAULT_TIMEOUT_MS = 30_000;
export const DEFAULT_MAX_RETRIES = 3;
export const DEFAULT_MAX_TOKENS = 1000;
export const DEFAULT_TEMPERATURE = 0.7;
/**
* Minimal prompt used to validate API credentials on initialization.
*/
export const VALIDATION_PROMPT = 'Hi';
export const DEFAULT_MODELS = {
claude: 'claude-3-5-sonnet-20241022',
openai: 'gpt-4o',
gemini: 'gemini-2.5-flash',
openwebui: 'llama3.1:latest'
} as const;
export const DEFAULT_OPENWEBUI_BASE_URL = 'http://localhost:3000';
export const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
/**
* Bounds for configuration value validation.
*/
export const CONFIG_BOUNDS = {
timeoutMs: { min: 1000, max: 300_000 },
maxRetries: { min: 0, max: 10 },
temperature: { min: 0, max: 1 },
topP: { min: 0, max: 1, exclusiveMin: true },
maxTokens: { min: 1 }
} as const;

View File

@@ -58,4 +58,4 @@ export const SUPPORTED_PROVIDERS = ['claude', 'openai', 'gemini', 'openwebui'] a
/**
* Package version
*/
export const VERSION = '1.3.1';
export const VERSION = '2.0.0';

View File

@@ -23,6 +23,11 @@ import type {
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
@@ -314,60 +319,78 @@ export abstract class BaseAIProvider {
);
}
// Apply defaults and return normalized config
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: this.validateTimeout(config.timeout),
maxRetries: this.validateMaxRetries(config.maxRetries)
timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES
};
}
/**
* Validates timeout configuration value.
*
* @private
* @param timeout - Timeout value to validate
* @returns Validated timeout value with default if needed
* Validates an optional numeric parameter against a range.
* Skips validation when value is undefined so callers can apply defaults afterward.
*/
private validateTimeout(timeout?: number): number {
const defaultTimeout = 30000; // 30 seconds
if (timeout === undefined) {
return defaultTimeout;
private validateNumberInRange(
name: string,
value: number | undefined,
bounds: {
min?: number;
max?: number;
exclusiveMin?: boolean;
integer?: boolean;
label?: string;
}
): void {
if (value === undefined) return;
if (typeof timeout !== 'number' || timeout < 1000 || timeout > 300000) {
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(
'Timeout must be a number between 1000ms (1s) and 300000ms (5min)',
`${name} must be a number between ${range}`,
AIErrorType.INVALID_REQUEST
);
}
return timeout;
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
);
}
}
/**
* Validates max retries configuration value.
*
* @private
* @param maxRetries - Max retries value to validate
* @returns Validated max retries value with default if needed
*/
private validateMaxRetries(maxRetries?: number): number {
const defaultMaxRetries = 3;
if (maxRetries === undefined) {
return defaultMaxRetries;
private describeRange(min?: number, max?: number, exclusiveMin?: boolean): string {
if (min !== undefined && max !== undefined) {
return `${exclusiveMin ? '>' : '>='}${min} and <=${max}`;
}
if (typeof maxRetries !== 'number' || maxRetries < 0 || maxRetries > 10) {
throw new AIProviderError(
'Max retries must be a number between 0 and 10',
AIErrorType.INVALID_REQUEST
);
}
return maxRetries;
if (min !== undefined) return `${exclusiveMin ? '>' : '>='}${min}`;
if (max !== undefined) return `<=${max}`;
return 'a valid number';
}
/**
@@ -403,13 +426,11 @@ export abstract class BaseAIProvider {
);
}
// Validate messages array
this.validateMessages(params.messages);
// Validate optional parameters
this.validateTemperature(params.temperature);
this.validateTopP(params.topP);
this.validateMaxTokens(params.maxTokens);
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);
}
@@ -456,67 +477,6 @@ export abstract class BaseAIProvider {
}
}
/**
* Validates temperature parameter.
*
* @private
* @param temperature - Temperature value to validate
* @throws {AIProviderError} If temperature is invalid
*/
private validateTemperature(temperature?: number): void {
if (temperature !== undefined) {
if (typeof temperature !== 'number' || temperature < 0 || temperature > 1) {
throw new AIProviderError(
'Temperature must be a number between 0.0 and 1.0',
AIErrorType.INVALID_REQUEST
);
}
}
}
/**
* Validates top-p parameter.
*
* @private
* @param topP - Top-p value to validate
* @throws {AIProviderError} If top-p is invalid
*/
private validateTopP(topP?: number): void {
if (topP !== undefined) {
if (typeof topP !== 'number' || topP <= 0 || topP > 1) {
throw new AIProviderError(
'Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)',
AIErrorType.INVALID_REQUEST
);
}
}
}
/**
* Validates max tokens parameter.
*
* @private
* @param maxTokens - Max tokens value to validate
* @throws {AIProviderError} If max tokens is invalid
*/
private validateMaxTokens(maxTokens?: number): void {
if (maxTokens !== undefined) {
if (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens < 1) {
throw new AIProviderError(
'Max tokens must be a positive integer',
AIErrorType.INVALID_REQUEST
);
}
}
}
/**
* Validates stop sequences parameter.
*
* @private
* @param stopSequences - Stop sequences to validate
* @throws {AIProviderError} If stop sequences are invalid
*/
private validateStopSequences(stopSequences?: string[]): void {
if (stopSequences !== undefined) {
if (!Array.isArray(stopSequences)) {

View File

@@ -34,6 +34,13 @@ import type {
} from '../types/index.js';
import { BaseAIProvider } from './base.js';
import { AIProviderError, AIErrorType } from '../types/index.js';
import {
DEFAULT_ANTHROPIC_VERSION,
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_TEMPERATURE,
VALIDATION_PROMPT
} from '../constants.js';
// ============================================================================
// TYPES AND INTERFACES
@@ -153,9 +160,8 @@ export class ClaudeProvider extends BaseAIProvider {
constructor(config: ClaudeConfig) {
super(config);
// Set Claude-specific defaults
this.defaultModel = config.defaultModel || 'claude-3-5-sonnet-20241022';
this.version = config.version || '2023-06-01';
this.defaultModel = config.defaultModel || DEFAULT_MODELS.claude;
this.version = config.version || DEFAULT_ANTHROPIC_VERSION;
// Validate model name format
this.validateModelName(this.defaultModel);
@@ -329,11 +335,10 @@ export class ClaudeProvider extends BaseAIProvider {
}
try {
// Make minimal request to test connection and permissions
await this.client.messages.create({
model: this.defaultModel,
max_tokens: 1,
messages: [{ role: 'user', content: 'Hi' }]
messages: [{ role: 'user', content: VALIDATION_PROMPT }]
});
} catch (error: any) {
@@ -449,8 +454,8 @@ export class ClaudeProvider extends BaseAIProvider {
) {
return {
model: params.model || this.defaultModel,
max_tokens: params.maxTokens || 1000,
temperature: params.temperature ?? 0.7,
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop_sequences: params.stopSequences,
system: system || undefined,

View File

@@ -31,6 +31,12 @@ import type {
} from '../types/index.js';
import { BaseAIProvider } from './base.js';
import { AIProviderError, AIErrorType } from '../types/index.js';
import {
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_TEMPERATURE,
VALIDATION_PROMPT
} from '../constants.js';
// ============================================================================
// TYPES AND INTERFACES
@@ -95,7 +101,7 @@ export class GeminiProvider extends BaseAIProvider {
constructor(config: GeminiConfig) {
super(config);
this.defaultModel = config.defaultModel || 'gemini-2.5-flash';
this.defaultModel = config.defaultModel || DEFAULT_MODELS.gemini;
this.safetySettings = config.safetySettings;
this.defaultGenerationConfig = config.generationConfig;
@@ -207,7 +213,7 @@ export class GeminiProvider extends BaseAIProvider {
try {
await this.client.models.generateContent({
model: this.defaultModel,
contents: [{ role: 'user', parts: [{ text: 'Hi' }] }],
contents: [{ role: 'user', parts: [{ text: VALIDATION_PROMPT }] }],
config: { maxOutputTokens: 1 }
});
} catch (error: any) {
@@ -291,8 +297,8 @@ export class GeminiProvider extends BaseAIProvider {
const defaults = this.defaultGenerationConfig;
const config: GenerateContentConfig = {
temperature: params.temperature ?? defaults?.temperature ?? 0.7,
maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? 1000
temperature: params.temperature ?? defaults?.temperature ?? DEFAULT_TEMPERATURE,
maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? DEFAULT_MAX_TOKENS
};
const topP = params.topP ?? defaults?.topP;

View File

@@ -35,6 +35,12 @@ import type {
} from '../types/index.js';
import { BaseAIProvider } from './base.js';
import { AIProviderError, AIErrorType } from '../types/index.js';
import {
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_TEMPERATURE,
VALIDATION_PROMPT
} from '../constants.js';
// ============================================================================
// TYPES AND INTERFACES
@@ -173,7 +179,7 @@ export class OpenAIProvider extends BaseAIProvider {
super(config);
// Set OpenAI-specific defaults
this.defaultModel = config.defaultModel || 'gpt-4o';
this.defaultModel = config.defaultModel || DEFAULT_MODELS.openai;
this.organization = config.organization;
this.project = config.project;
@@ -352,10 +358,9 @@ export class OpenAIProvider extends BaseAIProvider {
}
try {
// Make minimal request to test connection and permissions
await this.client.chat.completions.create({
model: this.defaultModel,
messages: [{ role: 'user', content: 'Hi' }],
messages: [{ role: 'user', content: VALIDATION_PROMPT }],
max_tokens: 1
});
@@ -435,8 +440,8 @@ export class OpenAIProvider extends BaseAIProvider {
return {
model: params.model || this.defaultModel,
messages: this.convertMessages(params.messages),
max_tokens: params.maxTokens || 1000,
temperature: params.temperature ?? 0.7,
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop: params.stopSequences,
stream,

View File

@@ -34,6 +34,12 @@ import type {
} from '../types/index.js';
import { BaseAIProvider } from './base.js';
import { AIProviderError, AIErrorType } from '../types/index.js';
import {
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_OPENWEBUI_BASE_URL,
DEFAULT_TEMPERATURE
} from '../constants.js';
// ============================================================================
// TYPES AND INTERFACES
@@ -281,8 +287,8 @@ export class OpenWebUIProvider extends BaseAIProvider {
super(config);
// Set OpenWebUI-specific defaults and normalize configuration
this.defaultModel = config.defaultModel || 'llama3.1:latest';
this.baseUrl = this.normalizeBaseUrl(config.baseUrl || 'http://localhost:3000');
this.defaultModel = config.defaultModel || DEFAULT_MODELS.openwebui;
this.baseUrl = this.normalizeBaseUrl(config.baseUrl || DEFAULT_OPENWEBUI_BASE_URL);
this.useOllamaProxy = config.useOllamaProxy ?? false;
this.dangerouslyAllowInsecureConnections = config.dangerouslyAllowInsecureConnections ?? true;
@@ -689,8 +695,8 @@ export class OpenWebUIProvider extends BaseAIProvider {
const requestBody = {
model: params.model || this.defaultModel,
messages: this.convertMessages(params.messages),
max_tokens: params.maxTokens || 1000,
temperature: params.temperature ?? 0.7,
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop: params.stopSequences,
stream: false
@@ -724,9 +730,9 @@ export class OpenWebUIProvider extends BaseAIProvider {
prompt: prompt,
stream: false,
options: {
temperature: params.temperature ?? 0.7,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
num_predict: params.maxTokens || 1000,
num_predict: params.maxTokens || DEFAULT_MAX_TOKENS,
stop: params.stopSequences
}
};
@@ -754,8 +760,8 @@ export class OpenWebUIProvider extends BaseAIProvider {
const requestBody = {
model: params.model || this.defaultModel,
messages: this.convertMessages(params.messages),
max_tokens: params.maxTokens || 1000,
temperature: params.temperature ?? 0.7,
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop: params.stopSequences,
stream: true
@@ -850,9 +856,9 @@ export class OpenWebUIProvider extends BaseAIProvider {
prompt: prompt,
stream: true,
options: {
temperature: params.temperature ?? 0.7,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
num_predict: params.maxTokens || 1000,
num_predict: params.maxTokens || DEFAULT_MAX_TOKENS,
stop: params.stopSequences
}
};

View File

@@ -1,23 +1,22 @@
/**
* Factory utilities for creating AI providers
* Provides convenient methods for instantiating and configuring providers
* Factory utilities for creating AI providers.
*/
import type { AIProviderConfig } from '../types/index.js';
import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js';
import { OpenAIProvider, type OpenAIConfig } from '../providers/openai.js';
import { GeminiProvider, type GeminiConfig } from '../providers/gemini.js';
import { OpenWebUIProvider, type OpenWebUIConfig } from '../providers/openwebui.js';
import { BaseAIProvider } from '../providers/base.js';
/**
* Supported AI provider types
*/
export type ProviderType = 'claude' | 'openai' | 'gemini' | 'openwebui';
export const PROVIDER_REGISTRY = {
claude: ClaudeProvider,
openai: OpenAIProvider,
gemini: GeminiProvider,
openwebui: OpenWebUIProvider
} as const;
export type ProviderType = keyof typeof PROVIDER_REGISTRY;
/**
* Configuration map for different provider types
*/
export interface ProviderConfigMap {
claude: ClaudeConfig;
openai: OpenAIConfig;
@@ -25,144 +24,38 @@ export interface ProviderConfigMap {
openwebui: OpenWebUIConfig;
}
/**
* Factory function to create AI providers
* @param type - The type of provider to create
* @param config - Configuration for the provider
* @returns Configured AI provider instance
*/
export function createProvider<T extends ProviderType>(
type: T,
config: ProviderConfigMap[T]
): BaseAIProvider {
switch (type) {
case 'claude':
return new ClaudeProvider(config as ClaudeConfig);
case 'openai':
return new OpenAIProvider(config as OpenAIConfig);
case 'gemini':
return new GeminiProvider(config as GeminiConfig);
case 'openwebui':
return new OpenWebUIProvider(config as OpenWebUIConfig);
default:
throw new Error(`Unsupported provider type: ${type}`);
const ProviderClass = PROVIDER_REGISTRY[type];
if (!ProviderClass) {
throw new Error(`Unsupported provider type: ${type}`);
}
return new ProviderClass(config as any);
}
/**
* Create a Claude provider with simplified configuration
* @param apiKey - Anthropic API key
* @param options - Optional additional configuration
* @returns Configured Claude provider instance
*/
export function createClaudeProvider(
apiKey: string,
options: Partial<Omit<ClaudeConfig, 'apiKey'>> = {}
): ClaudeProvider {
return new ClaudeProvider({
apiKey,
...options
});
return new ClaudeProvider({ apiKey, ...options });
}
/**
* Create an OpenAI provider with simplified configuration
* @param apiKey - OpenAI API key
* @param options - Optional additional configuration
* @returns Configured OpenAI provider instance
*/
export function createOpenAIProvider(
apiKey: string,
options: Partial<Omit<OpenAIConfig, 'apiKey'>> = {}
): OpenAIProvider {
return new OpenAIProvider({
apiKey,
...options
});
return new OpenAIProvider({ apiKey, ...options });
}
/**
* Create a Gemini provider with simplified configuration
* @param apiKey - Google AI API key
* @param options - Optional additional configuration
* @returns Configured Gemini provider instance
*/
export function createGeminiProvider(
apiKey: string,
options: Partial<Omit<GeminiConfig, 'apiKey'>> = {}
): GeminiProvider {
return new GeminiProvider({
apiKey,
...options
});
return new GeminiProvider({ apiKey, ...options });
}
/**
* Create an OpenWebUI provider instance
*/
export function createOpenWebUIProvider(config: OpenWebUIConfig): OpenWebUIProvider {
return new OpenWebUIProvider(config);
}
/**
* Provider registry for dynamic provider creation
*/
export class ProviderRegistry {
private static providers = new Map<string, new (config: AIProviderConfig) => BaseAIProvider>();
/**
* Register a new provider type
* @param name - Name of the provider
* @param providerClass - Provider class constructor
*/
static register(name: string, providerClass: new (config: AIProviderConfig) => BaseAIProvider): void {
this.providers.set(name.toLowerCase(), providerClass);
}
/**
* Create a provider by name
* @param name - Name of the provider
* @param config - Configuration for the provider
* @returns Provider instance
*/
static create(name: string, config: AIProviderConfig): BaseAIProvider {
const ProviderClass = this.providers.get(name.toLowerCase());
if (!ProviderClass) {
throw new Error(`Provider '${name}' is not registered`);
}
return new ProviderClass(config);
}
/**
* Get list of registered provider names
* @returns Array of registered provider names
*/
static getRegisteredProviders(): string[] {
return Array.from(this.providers.keys());
}
/**
* Check if a provider is registered
* @param name - Name of the provider
* @returns True if provider is registered
*/
static isRegistered(name: string): boolean {
return this.providers.has(name.toLowerCase());
}
}
// Pre-register built-in providers
ProviderRegistry.register('claude', ClaudeProvider);
ProviderRegistry.register('openai', OpenAIProvider);
ProviderRegistry.register('gemini', GeminiProvider);
ProviderRegistry.register('openwebui', OpenWebUIProvider);
/**
* Registry of all available providers
*/
export const PROVIDER_REGISTRY = {
claude: ClaudeProvider,
openai: OpenAIProvider,
gemini: GeminiProvider,
openwebui: OpenWebUIProvider
} as const;