7 Commits

Author SHA1 Message Date
6298a0027d chore(release): 2.0.1
Patch release covering three internal refactors (see PRs #7, #8, #9):

- Extract shared constants, parameterize validators, simplify factory
- Pull validateConnection / validateModelName / error mapping into base
- Split OpenWebUI into strategy classes by backend

Public API surface and method signatures are unchanged. A handful of
error message strings were reworded (error type and status code remain
identical), and BaseAIProvider gained new protected hooks with no-op
defaults.
2026-05-21 13:54:31 +02:00
8e430b2659 Merge pull request 'refactor: split OpenWebUI into strategy classes by backend' (#9) from refactor/openwebui-strategy-split into main
Reviewed-on: #9
2026-05-21 11:51:34 +00:00
3d985a95a8 refactor: split OpenWebUI into strategy classes by backend
Replace Conditional with Polymorphism from the Refactoring Guru catalog.

The OpenWebUIProvider previously contained two parallel implementations
(completeWithChat / completeWithOllama, streamWithChat / streamWithOllama,
plus branching in validateConnection) selected by a `useOllamaProxy`
boolean. The 987-line file is split into:

  - openwebui-types.ts       wire-format response types
  - openwebui-http.ts        shared HTTP client (auth, timeout)
  - openwebui-strategies.ts  OpenWebUIStrategy interface plus
                             OpenWebUIChatStrategy and
                             OpenWebUIOllamaStrategy implementations
  - openwebui.ts             thin provider that picks one strategy at
                             construction and delegates

OpenWebUIProvider and OpenWebUIConfig (including useOllamaProxy) stay
in their original locations, so consumer imports are unchanged.

Side effects:
- Error mapping now goes through the base mapProviderError / providerErrorMessages
  hooks added in R2, removing handleOpenWebUIError and its duplicated status switch.
- providerInfo.version bumped to 2.0.0 to reflect the rewrite.
2026-05-21 13:51:08 +02:00
eacd76d259 Merge pull request 'refactor: pull validateConnection / validateModelName / error mapping into base' (#8) from refactor/template-method-pullup into main
Reviewed-on: #8
2026-05-21 11:46:55 +00:00
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
8e73296ac2 Merge pull request 'refactor: extract constants, parameterize validators, simplify factory' (#7) from refactor/quick-wins-factory-validators-constants into main
Reviewed-on: #7
2026-05-21 11:36:34 +00:00
bb37e61eaf refactor: extract constants, parameterize validators, simplify factory
Quick-win refactors from the Refactoring Guru catalog:

- Replace Magic Number with Symbolic Constant: extract DEFAULT_TIMEOUT_MS,
  DEFAULT_MAX_RETRIES, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE, default
  models per provider, and validation prompt into src/constants.ts.
- Parameterize Method: collapse validateTemperature / validateTopP /
  validateMaxTokens (and the inline timeout/maxRetries checks) into a
  single validateNumberInRange helper with bounds metadata.
- Inline Class / Remove Middle Man: drop the unused ProviderRegistry
  class from utils/factory.ts. createProvider now dispatches through
  the PROVIDER_REGISTRY const directly instead of a parallel switch.

Also fixes stale VERSION constant in src/index.ts (was 1.3.1).
2026-05-21 13:35:12 +02:00
12 changed files with 1063 additions and 1879 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "simple-ai-provider",
"version": "2.0.0",
"version": "2.0.1",
"description": "A simple and extensible AI provider package for easy integration of multiple AI services",
"main": "dist/index.js",
"module": "dist/index.mjs",

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.1';

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
@@ -283,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.
*
@@ -314,60 +413,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 +520,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 +571,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)) {
@@ -587,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.',
@@ -664,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

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);
@@ -178,7 +184,6 @@ export class ClaudeProvider extends BaseAIProvider {
*/
protected async doInitialize(): Promise<void> {
try {
// Create Anthropic client with optimized configuration
this.client = new Anthropic({
apiKey: this.config.apiKey,
baseURL: this.config.baseUrl,
@@ -186,15 +191,12 @@ 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(
@@ -225,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);
}
/**
@@ -262,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);
}
// ========================================================================
@@ -317,76 +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<void> {
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<void> {
if (!this.client) {
throw new Error('Client not initialized');
}
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' }]
});
} 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<Record<number, string>> {
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;
}
/**
@@ -449,8 +413,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,
@@ -564,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

@@ -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;
@@ -128,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<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
@@ -149,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);
}
// ========================================================================
@@ -199,55 +197,8 @@ export class GeminiProvider extends BaseAIProvider {
// PRIVATE UTILITY METHODS
// ========================================================================
private async validateConnection(): Promise<void> {
if (!this.client) {
throw new Error('Client not initialized');
}
try {
await this.client.models.generateContent({
model: this.defaultModel,
contents: [{ role: 'user', parts: [{ text: 'Hi' }] }],
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)?$/,
@@ -256,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<void> {
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<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 {
@@ -291,8 +302,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;
@@ -383,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

@@ -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;
@@ -249,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);
}
/**
@@ -283,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);
}
// ========================================================================
@@ -340,87 +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<void> {
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<void> {
if (!this.client) {
throw new Error('Client not initialized');
}
try {
// Make minimal request to test connection and permissions
await this.client.chat.completions.create({
model: this.defaultModel,
messages: [{ role: 'user', content: 'Hi' }],
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<Record<number, string>> {
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;
}
/**
@@ -435,8 +416,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,
@@ -563,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

@@ -0,0 +1,59 @@
import { AIProviderError, AIErrorType } from '../types/index.js';
import { DEFAULT_TIMEOUT_MS } from '../constants.js';
export interface OpenWebUIHttpOptions {
baseUrl: string;
apiKey?: string;
timeout?: number;
dangerouslyAllowInsecureConnections?: boolean;
}
/**
* Thin HTTP client shared by OpenWebUI strategies.
* Handles auth header, request body serialization, and timeout-to-AIProviderError translation.
*/
export class OpenWebUIHttpClient {
readonly baseUrl: string;
private readonly apiKey: string | undefined;
private readonly timeout: number;
private readonly dangerouslyAllowInsecureConnections: boolean;
constructor(options: OpenWebUIHttpOptions) {
this.baseUrl = options.baseUrl;
this.apiKey = options.apiKey;
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
this.dangerouslyAllowInsecureConnections = options.dangerouslyAllowInsecureConnections ?? true;
}
async request(path: string, method: string, body?: unknown): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'simple-ai-provider/2.0.0'
};
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}
const requestOptions: RequestInit = {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(this.timeout)
};
try {
return await fetch(`${this.baseUrl}${path}`, requestOptions);
} catch (error: any) {
if (error.name === 'AbortError') {
throw new AIProviderError(
'Request timed out',
AIErrorType.TIMEOUT,
undefined,
error
);
}
throw error;
}
}
}

View File

@@ -0,0 +1,306 @@
import type {
AIMessage,
CompletionChunk,
CompletionParams,
CompletionResponse
} from '../types/index.js';
import { AIProviderError, AIErrorType } from '../types/index.js';
import { DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '../constants.js';
import type { OpenWebUIHttpClient } from './openwebui-http.js';
import type {
OllamaGenerateResponse,
OpenWebUIChatResponse,
OpenWebUIModelsResponse,
OpenWebUIStreamChunk
} from './openwebui-types.js';
/**
* Strategy interface for OpenWebUI's two backend modes.
* Selected at construction based on `useOllamaProxy`.
*/
export interface OpenWebUIStrategy {
validateConnection(): Promise<void>;
complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>>;
stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk>;
}
// ============================================================================
// Chat strategy — OpenAI-compatible /api/chat/completions endpoint
// ============================================================================
export class OpenWebUIChatStrategy implements OpenWebUIStrategy {
constructor(private readonly http: OpenWebUIHttpClient) {}
async validateConnection(): Promise<void> {
const response = await this.http.request('/api/models', 'GET');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json() as OpenWebUIModelsResponse;
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid models response format');
}
}
async complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>> {
const response = await this.http.request('/api/chat/completions', 'POST', {
model: params.model || defaultModel,
messages: convertMessages(params.messages),
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop: params.stopSequences,
stream: false
});
const data = await response.json() as OpenWebUIChatResponse;
return formatChatResponse(data);
}
async *stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk> {
const response = await this.http.request('/api/chat/completions', 'POST', {
model: params.model || defaultModel,
messages: convertMessages(params.messages),
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop: params.stopSequences,
stream: true
});
if (!response.body) {
throw new Error('No response body for streaming');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let messageId = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') return;
let chunk: OpenWebUIStreamChunk;
try {
chunk = JSON.parse(data) as OpenWebUIStreamChunk;
} catch (parseError) {
console.warn('Failed to parse streaming chunk:', parseError);
continue;
}
if (chunk.id && !messageId) {
messageId = chunk.id;
}
const delta = chunk.choices[0]?.delta;
if (delta?.content) {
yield {
content: delta.content,
isComplete: false,
id: messageId || chunk.id
};
}
if (chunk.choices[0]?.finish_reason) {
yield {
content: '',
isComplete: true,
id: messageId || chunk.id,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
};
return;
}
}
}
} finally {
reader.releaseLock();
}
}
}
// ============================================================================
// Ollama strategy — direct /ollama/api/generate endpoint
// ============================================================================
export class OpenWebUIOllamaStrategy implements OpenWebUIStrategy {
constructor(private readonly http: OpenWebUIHttpClient) {}
async validateConnection(): Promise<void> {
const response = await this.http.request('/ollama/api/tags', 'GET');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
}
async complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>> {
const response = await this.http.request('/ollama/api/generate', 'POST', {
model: params.model || defaultModel,
prompt: convertMessagesToPrompt(params.messages),
stream: false,
options: {
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
num_predict: params.maxTokens || DEFAULT_MAX_TOKENS,
stop: params.stopSequences
}
});
const data = await response.json() as OllamaGenerateResponse;
return formatOllamaResponse(data);
}
async *stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk> {
const response = await this.http.request('/ollama/api/generate', 'POST', {
model: params.model || defaultModel,
prompt: convertMessagesToPrompt(params.messages),
stream: true,
options: {
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
num_predict: params.maxTokens || DEFAULT_MAX_TOKENS,
stop: params.stopSequences
}
});
if (!response.body) {
throw new Error('No response body for streaming');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const messageId = `ollama-${Date.now()}`;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let chunk: OllamaGenerateResponse;
try {
chunk = JSON.parse(trimmed) as OllamaGenerateResponse;
} catch (parseError) {
console.warn('Failed to parse Ollama streaming chunk:', parseError);
continue;
}
if (chunk.response) {
yield {
content: chunk.response,
isComplete: false,
id: messageId
};
}
if (chunk.done) {
yield {
content: '',
isComplete: true,
id: messageId,
usage: {
promptTokens: chunk.prompt_eval_count || 0,
completionTokens: chunk.eval_count || 0,
totalTokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0)
}
};
return;
}
}
}
} finally {
reader.releaseLock();
}
}
}
// ============================================================================
// Shared message helpers
// ============================================================================
function convertMessages(messages: AIMessage[]): Array<{ role: string; content: string }> {
return messages.map(message => ({
role: message.role,
content: message.content
}));
}
function convertMessagesToPrompt(messages: AIMessage[]): string {
let prompt = '';
for (const message of messages) {
switch (message.role) {
case 'system':
prompt += `System: ${message.content}\n\n`;
break;
case 'user':
prompt += `Human: ${message.content}\n\n`;
break;
case 'assistant':
prompt += `Assistant: ${message.content}\n\n`;
break;
}
}
return prompt + 'Assistant: ';
}
function formatChatResponse(response: OpenWebUIChatResponse): CompletionResponse<string> {
const choice = response.choices[0];
if (!choice || !choice.message.content) {
throw new AIProviderError('No content found in OpenWebUI response', AIErrorType.UNKNOWN);
}
return {
content: choice.message.content,
model: response.model,
usage: {
promptTokens: response.usage?.prompt_tokens || 0,
completionTokens: response.usage?.completion_tokens || 0,
totalTokens: response.usage?.total_tokens || 0
},
id: response.id,
metadata: {
finishReason: choice.finish_reason,
created: response.created
}
};
}
function formatOllamaResponse(response: OllamaGenerateResponse): CompletionResponse<string> {
return {
content: response.response,
model: response.model,
usage: {
promptTokens: response.prompt_eval_count || 0,
completionTokens: response.eval_count || 0,
totalTokens: (response.prompt_eval_count || 0) + (response.eval_count || 0)
},
id: `ollama-${Date.now()}`,
metadata: {
created: new Date(response.created_at).getTime(),
totalDuration: response.total_duration,
loadDuration: response.load_duration,
promptEvalDuration: response.prompt_eval_duration,
evalDuration: response.eval_duration
}
};
}

View File

@@ -0,0 +1,55 @@
/**
* Wire-format response types for OpenWebUI's two backends.
*/
export interface OpenWebUIChatResponse {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
message: { role: string; content: string };
finish_reason: string | null;
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface OpenWebUIStreamChunk {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
delta: { role?: string; content?: string };
finish_reason: string | null;
}>;
}
export interface OllamaGenerateResponse {
model: string;
created_at: string;
response: string;
done: boolean;
context?: number[];
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
prompt_eval_duration?: number;
eval_count?: number;
eval_duration?: number;
}
export interface OpenWebUIModelsResponse {
data: Array<{
id: string;
object: string;
created: number;
owned_by: string;
}>;
}

File diff suppressed because it is too large Load Diff

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;