Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6298a0027d
|
|||
|
8e430b2659
|
|||
|
3d985a95a8
|
|||
|
eacd76d259
|
|||
|
b36d57711b
|
|||
|
8e73296ac2
|
|||
|
bb37e61eaf
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "simple-ai-provider",
|
"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",
|
"description": "A simple and extensible AI provider package for easy integration of multiple AI services",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.mjs",
|
"module": "dist/index.mjs",
|
||||||
|
|||||||
35
src/constants.ts
Normal file
35
src/constants.ts
Normal 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;
|
||||||
@@ -58,4 +58,4 @@ export const SUPPORTED_PROVIDERS = ['claude', 'openai', 'gemini', 'openwebui'] a
|
|||||||
/**
|
/**
|
||||||
* Package version
|
* Package version
|
||||||
*/
|
*/
|
||||||
export const VERSION = '1.3.1';
|
export const VERSION = '2.0.1';
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import type {
|
|||||||
ResponseType
|
ResponseType
|
||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
import { AIProviderError, AIErrorType, generateResponseTypePrompt, parseAndValidateResponseType } 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 PROVIDER CLASS
|
||||||
@@ -283,10 +288,104 @@ export abstract class BaseAIProvider {
|
|||||||
*/
|
*/
|
||||||
protected abstract doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk>;
|
protected abstract doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk>;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PROTECTED HOOKS (subclass overrides; safe defaults provided)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex patterns that valid model names should match.
|
||||||
|
* Empty array (the default) skips validation entirely.
|
||||||
|
*/
|
||||||
|
protected getModelNamePatterns(): RegExp[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a minimal request to verify the API key and connectivity.
|
||||||
|
* The default does nothing; SDK-based providers override this and the
|
||||||
|
* base `validateConnection` handles error mapping.
|
||||||
|
*/
|
||||||
|
protected async sendValidationProbe(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a provider-specific error to a normalized AIProviderError, or
|
||||||
|
* return null to let the base error mapping handle it generically.
|
||||||
|
* Override to recognize provider-specific message patterns (e.g. "API
|
||||||
|
* key", "quota", "model not found").
|
||||||
|
*/
|
||||||
|
protected mapProviderError(_error: any): AIProviderError | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-HTTP-status message overrides used by `normalizeError` when no
|
||||||
|
* provider-specific mapping applies. Lets providers add brand-flavored
|
||||||
|
* hints (console URLs, etc.) without re-implementing the status switch.
|
||||||
|
*/
|
||||||
|
protected providerErrorMessages(): Partial<Record<number, string>> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable provider name used in warning messages.
|
||||||
|
* Default derives from getInfo(); override if construction order makes
|
||||||
|
* getInfo() unsafe to call here.
|
||||||
|
*/
|
||||||
|
protected get providerName(): string {
|
||||||
|
try {
|
||||||
|
return this.getInfo().name;
|
||||||
|
} catch {
|
||||||
|
return 'Provider';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// PROTECTED UTILITY METHODS
|
// PROTECTED UTILITY METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a model name against the patterns returned by
|
||||||
|
* `getModelNamePatterns()`. Throws for invalid input, warns for
|
||||||
|
* non-matching names (since model lists change frequently).
|
||||||
|
*/
|
||||||
|
protected validateModelName(modelName: string): void {
|
||||||
|
if (!modelName || typeof modelName !== 'string') {
|
||||||
|
throw new AIProviderError(
|
||||||
|
'Model name must be a non-empty string',
|
||||||
|
AIErrorType.INVALID_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = this.getModelNamePatterns();
|
||||||
|
if (patterns.length === 0) return;
|
||||||
|
|
||||||
|
const matches = patterns.some(pattern => pattern.test(modelName));
|
||||||
|
if (!matches) {
|
||||||
|
console.warn(
|
||||||
|
`Model name '${modelName}' doesn't match expected ${this.providerName} naming patterns. This may cause API errors.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps `sendValidationProbe()` with consistent error handling: throws
|
||||||
|
* AIProviderError for provider-recognized failures, warns and continues
|
||||||
|
* for transient ones.
|
||||||
|
*/
|
||||||
|
protected async validateConnection(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.sendValidationProbe();
|
||||||
|
} catch (error: any) {
|
||||||
|
const mapped = this.mapProviderError(error);
|
||||||
|
if (mapped) {
|
||||||
|
throw mapped;
|
||||||
|
}
|
||||||
|
console.warn(`${this.providerName} connection validation warning:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and normalizes provider configuration.
|
* Validates and normalizes provider configuration.
|
||||||
*
|
*
|
||||||
@@ -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 {
|
return {
|
||||||
...config,
|
...config,
|
||||||
timeout: this.validateTimeout(config.timeout),
|
timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||||
maxRetries: this.validateMaxRetries(config.maxRetries)
|
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates timeout configuration value.
|
* Validates an optional numeric parameter against a range.
|
||||||
*
|
* Skips validation when value is undefined so callers can apply defaults afterward.
|
||||||
* @private
|
|
||||||
* @param timeout - Timeout value to validate
|
|
||||||
* @returns Validated timeout value with default if needed
|
|
||||||
*/
|
*/
|
||||||
private validateTimeout(timeout?: number): number {
|
private validateNumberInRange(
|
||||||
const defaultTimeout = 30000; // 30 seconds
|
name: string,
|
||||||
|
value: number | undefined,
|
||||||
if (timeout === undefined) {
|
bounds: {
|
||||||
return defaultTimeout;
|
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(
|
throw new AIProviderError(
|
||||||
'Timeout must be a number between 1000ms (1s) and 300000ms (5min)',
|
`${name} must be a number between ${range}`,
|
||||||
AIErrorType.INVALID_REQUEST
|
AIErrorType.INVALID_REQUEST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return timeout;
|
if (integer && !Number.isInteger(value)) {
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof maxRetries !== 'number' || maxRetries < 0 || maxRetries > 10) {
|
|
||||||
throw new AIProviderError(
|
throw new AIProviderError(
|
||||||
'Max retries must be a number between 0 and 10',
|
`${name} must be an integer`,
|
||||||
AIErrorType.INVALID_REQUEST
|
AIErrorType.INVALID_REQUEST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return maxRetries;
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,13 +520,11 @@ export abstract class BaseAIProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate messages array
|
|
||||||
this.validateMessages(params.messages);
|
this.validateMessages(params.messages);
|
||||||
|
|
||||||
// Validate optional parameters
|
this.validateNumberInRange('temperature', params.temperature, CONFIG_BOUNDS.temperature);
|
||||||
this.validateTemperature(params.temperature);
|
this.validateNumberInRange('topP', params.topP, CONFIG_BOUNDS.topP);
|
||||||
this.validateTopP(params.topP);
|
this.validateNumberInRange('maxTokens', params.maxTokens, { ...CONFIG_BOUNDS.maxTokens, integer: true });
|
||||||
this.validateMaxTokens(params.maxTokens);
|
|
||||||
this.validateStopSequences(params.stopSequences);
|
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 {
|
private validateStopSequences(stopSequences?: string[]): void {
|
||||||
if (stopSequences !== undefined) {
|
if (stopSequences !== undefined) {
|
||||||
if (!Array.isArray(stopSequences)) {
|
if (!Array.isArray(stopSequences)) {
|
||||||
@@ -587,65 +641,52 @@ export abstract class BaseAIProvider {
|
|||||||
* @returns Normalized AIProviderError with appropriate type and context
|
* @returns Normalized AIProviderError with appropriate type and context
|
||||||
*/
|
*/
|
||||||
protected normalizeError(error: Error): AIProviderError {
|
protected normalizeError(error: Error): AIProviderError {
|
||||||
// If already normalized, return as-is
|
|
||||||
if (error instanceof AIProviderError) {
|
if (error instanceof AIProviderError) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract status code if available
|
const providerError = this.mapProviderError(error);
|
||||||
|
if (providerError) {
|
||||||
|
return providerError;
|
||||||
|
}
|
||||||
|
|
||||||
const status = (error as any).status || (error as any).statusCode;
|
const status = (error as any).status || (error as any).statusCode;
|
||||||
const message = error.message || 'Unknown error occurred';
|
const message = error.message || 'Unknown error occurred';
|
||||||
|
const overrides = this.providerErrorMessages();
|
||||||
|
|
||||||
// Map HTTP status codes to error types
|
const statusTypeMap: Record<number, AIErrorType> = {
|
||||||
if (status) {
|
400: AIErrorType.INVALID_REQUEST,
|
||||||
switch (status) {
|
401: AIErrorType.AUTHENTICATION,
|
||||||
case 400:
|
403: AIErrorType.AUTHENTICATION,
|
||||||
return new AIProviderError(
|
404: AIErrorType.MODEL_NOT_FOUND,
|
||||||
`Bad request: ${message}`,
|
429: AIErrorType.RATE_LIMIT,
|
||||||
AIErrorType.INVALID_REQUEST,
|
500: AIErrorType.NETWORK,
|
||||||
status,
|
502: AIErrorType.NETWORK,
|
||||||
error
|
503: AIErrorType.NETWORK,
|
||||||
);
|
504: AIErrorType.NETWORK
|
||||||
|
};
|
||||||
|
|
||||||
case 401:
|
const defaultMessages: Record<number, string> = {
|
||||||
case 403:
|
400: `Bad request: ${message}`,
|
||||||
return new AIProviderError(
|
401: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
|
||||||
'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.',
|
||||||
AIErrorType.AUTHENTICATION,
|
404: 'The specified model or endpoint was not found. Please check the model name and availability.',
|
||||||
status,
|
429: 'Rate limit exceeded. Please reduce your request frequency and try again later.',
|
||||||
error
|
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.'
|
||||||
|
};
|
||||||
|
|
||||||
case 404:
|
if (status && statusTypeMap[status]) {
|
||||||
return new AIProviderError(
|
return new AIProviderError(
|
||||||
'The specified model or endpoint was not found. Please check the model name and availability.',
|
overrides[status] ?? defaultMessages[status]!,
|
||||||
AIErrorType.MODEL_NOT_FOUND,
|
statusTypeMap[status]!,
|
||||||
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,
|
status,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Map common error patterns
|
|
||||||
if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
|
if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
|
||||||
return new AIProviderError(
|
return new AIProviderError(
|
||||||
'Request timed out. The operation took longer than expected.',
|
'Request timed out. The operation took longer than expected.',
|
||||||
@@ -664,9 +705,8 @@ export abstract class BaseAIProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to unknown error type
|
|
||||||
return new AIProviderError(
|
return new AIProviderError(
|
||||||
`Provider error: ${message}`,
|
`${this.providerName} error: ${message}`,
|
||||||
AIErrorType.UNKNOWN,
|
AIErrorType.UNKNOWN,
|
||||||
status,
|
status,
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ import type {
|
|||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
import { BaseAIProvider } from './base.js';
|
import { BaseAIProvider } from './base.js';
|
||||||
import { AIProviderError, AIErrorType } from '../types/index.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
|
// TYPES AND INTERFACES
|
||||||
@@ -153,9 +160,8 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
constructor(config: ClaudeConfig) {
|
constructor(config: ClaudeConfig) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
// Set Claude-specific defaults
|
this.defaultModel = config.defaultModel || DEFAULT_MODELS.claude;
|
||||||
this.defaultModel = config.defaultModel || 'claude-3-5-sonnet-20241022';
|
this.version = config.version || DEFAULT_ANTHROPIC_VERSION;
|
||||||
this.version = config.version || '2023-06-01';
|
|
||||||
|
|
||||||
// Validate model name format
|
// Validate model name format
|
||||||
this.validateModelName(this.defaultModel);
|
this.validateModelName(this.defaultModel);
|
||||||
@@ -178,7 +184,6 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
*/
|
*/
|
||||||
protected async doInitialize(): Promise<void> {
|
protected async doInitialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Create Anthropic client with optimized configuration
|
|
||||||
this.client = new Anthropic({
|
this.client = new Anthropic({
|
||||||
apiKey: this.config.apiKey,
|
apiKey: this.config.apiKey,
|
||||||
baseURL: this.config.baseUrl,
|
baseURL: this.config.baseUrl,
|
||||||
@@ -186,15 +191,12 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
maxRetries: this.config.maxRetries,
|
maxRetries: this.config.maxRetries,
|
||||||
defaultHeaders: {
|
defaultHeaders: {
|
||||||
'anthropic-version': this.version,
|
'anthropic-version': this.version,
|
||||||
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', // Enable extended context
|
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate connection and permissions
|
|
||||||
await this.validateConnection();
|
await this.validateConnection();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up on failure
|
|
||||||
this.client = null;
|
this.client = null;
|
||||||
|
|
||||||
throw new AIProviderError(
|
throw new AIProviderError(
|
||||||
@@ -225,22 +227,10 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Process messages for Claude's format requirements
|
|
||||||
const { system, messages } = this.processMessages(params.messages);
|
const { system, messages } = this.processMessages(params.messages);
|
||||||
|
|
||||||
// Build optimized request parameters
|
|
||||||
const requestParams = this.buildRequestParams(params, system, messages, false);
|
const requestParams = this.buildRequestParams(params, system, messages, false);
|
||||||
|
|
||||||
// Make API request
|
|
||||||
const response = await this.client.messages.create(requestParams);
|
const response = await this.client.messages.create(requestParams);
|
||||||
|
|
||||||
// Format and return response
|
|
||||||
return this.formatCompletionResponse(response);
|
return this.formatCompletionResponse(response);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
throw this.handleAnthropicError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,22 +252,10 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Process messages for Claude's format requirements
|
|
||||||
const { system, messages } = this.processMessages(params.messages);
|
const { system, messages } = this.processMessages(params.messages);
|
||||||
|
|
||||||
// Build streaming request parameters
|
|
||||||
const requestParams = this.buildRequestParams(params, system, messages, true);
|
const requestParams = this.buildRequestParams(params, system, messages, true);
|
||||||
|
|
||||||
// Create streaming request
|
|
||||||
const stream = await this.client.messages.create(requestParams);
|
const stream = await this.client.messages.create(requestParams);
|
||||||
|
|
||||||
// Process stream chunks
|
|
||||||
yield* this.processStreamChunks(stream);
|
yield* this.processStreamChunks(stream);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
throw this.handleAnthropicError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -317,76 +295,62 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
// PRIVATE UTILITY METHODS
|
// PRIVATE UTILITY METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
protected override getModelNamePatterns(): RegExp[] {
|
||||||
* Validates the connection by making a minimal test request.
|
return [
|
||||||
*
|
/^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/,
|
||||||
* @private
|
/^claude-instant-[0-9.]+$/,
|
||||||
* @throws {AIProviderError} If connection validation fails
|
/^claude-[0-9.]+$/
|
||||||
*/
|
];
|
||||||
private async validateConnection(): Promise<void> {
|
}
|
||||||
|
|
||||||
|
protected override async sendValidationProbe(): Promise<void> {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error('Client not initialized');
|
throw new Error('Client not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Make minimal request to test connection and permissions
|
|
||||||
await this.client.messages.create({
|
await this.client.messages.create({
|
||||||
model: this.defaultModel,
|
model: this.defaultModel,
|
||||||
max_tokens: 1,
|
max_tokens: 1,
|
||||||
messages: [{ role: 'user', content: 'Hi' }]
|
messages: [{ role: 'user', content: VALIDATION_PROMPT }]
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
// Handle specific validation errors
|
|
||||||
if (error.status === 401 || error.status === 403) {
|
|
||||||
throw new AIProviderError(
|
|
||||||
'Invalid Anthropic API key. Please verify your API key from https://console.anthropic.com/',
|
|
||||||
AIErrorType.AUTHENTICATION,
|
|
||||||
error.status
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.status === 404 && error.message?.includes('model')) {
|
protected override providerErrorMessages(): Partial<Record<number, string>> {
|
||||||
throw new AIProviderError(
|
return {
|
||||||
`Model '${this.defaultModel}' is not available. Please check the model name or your API access.`,
|
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,
|
AIErrorType.MODEL_NOT_FOUND,
|
||||||
error.status
|
status,
|
||||||
|
error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return null;
|
||||||
* 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -449,8 +413,8 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
model: params.model || this.defaultModel,
|
model: params.model || this.defaultModel,
|
||||||
max_tokens: params.maxTokens || 1000,
|
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||||
temperature: params.temperature ?? 0.7,
|
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||||
top_p: params.topP,
|
top_p: params.topP,
|
||||||
stop_sequences: params.stopSequences,
|
stop_sequences: params.stopSequences,
|
||||||
system: system || undefined,
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,12 @@ import type {
|
|||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
import { BaseAIProvider } from './base.js';
|
import { BaseAIProvider } from './base.js';
|
||||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_TOKENS,
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
DEFAULT_TEMPERATURE,
|
||||||
|
VALIDATION_PROMPT
|
||||||
|
} from '../constants.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES AND INTERFACES
|
// TYPES AND INTERFACES
|
||||||
@@ -95,7 +101,7 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
constructor(config: GeminiConfig) {
|
constructor(config: GeminiConfig) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
this.defaultModel = config.defaultModel || 'gemini-2.5-flash';
|
this.defaultModel = config.defaultModel || DEFAULT_MODELS.gemini;
|
||||||
this.safetySettings = config.safetySettings;
|
this.safetySettings = config.safetySettings;
|
||||||
this.defaultGenerationConfig = config.generationConfig;
|
this.defaultGenerationConfig = config.generationConfig;
|
||||||
|
|
||||||
@@ -128,7 +134,6 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
||||||
const model = params.model || this.defaultModel;
|
const model = params.model || this.defaultModel;
|
||||||
|
|
||||||
@@ -139,9 +144,6 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return this.formatCompletionResponse(response, model);
|
return this.formatCompletionResponse(response, model);
|
||||||
} catch (error) {
|
|
||||||
throw this.handleGeminiError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
|
protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
|
||||||
@@ -149,7 +151,6 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
||||||
const model = params.model || this.defaultModel;
|
const model = params.model || this.defaultModel;
|
||||||
|
|
||||||
@@ -160,9 +161,6 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
yield* this.processStreamChunks(stream);
|
yield* this.processStreamChunks(stream);
|
||||||
} catch (error) {
|
|
||||||
throw this.handleGeminiError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -199,55 +197,8 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
// PRIVATE UTILITY METHODS
|
// PRIVATE UTILITY METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
private async validateConnection(): Promise<void> {
|
protected override getModelNamePatterns(): RegExp[] {
|
||||||
if (!this.client) {
|
return [
|
||||||
throw new Error('Client not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.client.models.generateContent({
|
|
||||||
model: this.defaultModel,
|
|
||||||
contents: [{ role: 'user', parts: [{ text: '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 = [
|
|
||||||
/^gemini-2\.5-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
/^gemini-2\.5-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||||
/^gemini-2\.0-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
/^gemini-2\.0-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||||
/^gemini-1\.5-pro(?:-latest|-vision)?$/,
|
/^gemini-1\.5-pro(?:-latest|-vision)?$/,
|
||||||
@@ -256,12 +207,72 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
/^gemini-pro(?:-vision)?$/,
|
/^gemini-pro(?:-vision)?$/,
|
||||||
/^models\/gemini-.+$/
|
/^models\/gemini-.+$/
|
||||||
];
|
];
|
||||||
|
|
||||||
const isValid = validPatterns.some(pattern => pattern.test(modelName));
|
|
||||||
|
|
||||||
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 {
|
private convertMessages(messages: AIMessage[]): ProcessedMessages {
|
||||||
@@ -291,8 +302,8 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
const defaults = this.defaultGenerationConfig;
|
const defaults = this.defaultGenerationConfig;
|
||||||
|
|
||||||
const config: GenerateContentConfig = {
|
const config: GenerateContentConfig = {
|
||||||
temperature: params.temperature ?? defaults?.temperature ?? 0.7,
|
temperature: params.temperature ?? defaults?.temperature ?? DEFAULT_TEMPERATURE,
|
||||||
maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? 1000
|
maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? DEFAULT_MAX_TOKENS
|
||||||
};
|
};
|
||||||
|
|
||||||
const topP = params.topP ?? defaults?.topP;
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ import type {
|
|||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
import { BaseAIProvider } from './base.js';
|
import { BaseAIProvider } from './base.js';
|
||||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_TOKENS,
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
DEFAULT_TEMPERATURE,
|
||||||
|
VALIDATION_PROMPT
|
||||||
|
} from '../constants.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES AND INTERFACES
|
// TYPES AND INTERFACES
|
||||||
@@ -173,7 +179,7 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
// Set OpenAI-specific defaults
|
// Set OpenAI-specific defaults
|
||||||
this.defaultModel = config.defaultModel || 'gpt-4o';
|
this.defaultModel = config.defaultModel || DEFAULT_MODELS.openai;
|
||||||
this.organization = config.organization;
|
this.organization = config.organization;
|
||||||
this.project = config.project;
|
this.project = config.project;
|
||||||
|
|
||||||
@@ -249,19 +255,9 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Build optimized request parameters
|
|
||||||
const requestParams = this.buildRequestParams(params, false);
|
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;
|
const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion;
|
||||||
|
|
||||||
// Format and return response
|
|
||||||
return this.formatCompletionResponse(response);
|
return this.formatCompletionResponse(response);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
throw this.handleOpenAIError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,19 +279,9 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Build streaming request parameters
|
|
||||||
const requestParams = this.buildRequestParams(params, true);
|
const requestParams = this.buildRequestParams(params, true);
|
||||||
|
|
||||||
// Create streaming request
|
|
||||||
const stream = this.client.chat.completions.create(requestParams);
|
const stream = this.client.chat.completions.create(requestParams);
|
||||||
|
|
||||||
// Process stream chunks
|
|
||||||
yield* this.processStreamChunks(stream);
|
yield* this.processStreamChunks(stream);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
throw this.handleOpenAIError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -340,87 +326,82 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
// PRIVATE UTILITY METHODS
|
// PRIVATE UTILITY METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
protected override getModelNamePatterns(): RegExp[] {
|
||||||
* Validates the connection by making a minimal test request.
|
return [
|
||||||
*
|
/^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||||
* @private
|
/^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||||
* @throws {AIProviderError} If connection validation fails
|
/^gpt-3\.5-turbo(?:-\d{4})?$/,
|
||||||
*/
|
/^gpt-4-\d{4}-preview$/,
|
||||||
private async validateConnection(): Promise<void> {
|
/^text-davinci-\d{3}$/,
|
||||||
|
/^ft:.+$/
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async sendValidationProbe(): Promise<void> {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error('Client not initialized');
|
throw new Error('Client not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Make minimal request to test connection and permissions
|
|
||||||
await this.client.chat.completions.create({
|
await this.client.chat.completions.create({
|
||||||
model: this.defaultModel,
|
model: this.defaultModel,
|
||||||
messages: [{ role: 'user', content: 'Hi' }],
|
messages: [{ role: 'user', content: VALIDATION_PROMPT }],
|
||||||
max_tokens: 1
|
max_tokens: 1
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
protected override providerErrorMessages(): Partial<Record<number, string>> {
|
||||||
// Handle specific validation errors
|
return {
|
||||||
if (error.status === 401) {
|
401: 'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys',
|
||||||
throw new AIProviderError(
|
403: 'Access forbidden. Your API key may not have permission for this model or feature.',
|
||||||
'Invalid OpenAI API key. Please verify your API key from https://platform.openai.com/api-keys',
|
404: 'Model not found. The specified OpenAI model may not exist or be available to your account.',
|
||||||
AIErrorType.AUTHENTICATION,
|
429: 'Too many requests. Please slow down and try again.',
|
||||||
error.status
|
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.status === 403) {
|
if (status === 429 && message.includes('quota')) {
|
||||||
throw new AIProviderError(
|
return new AIProviderError(
|
||||||
'Access forbidden. Your API key may not have permission for this model or your account may have insufficient credits.',
|
'Usage quota exceeded. Please check your OpenAI usage limits and billing.',
|
||||||
AIErrorType.AUTHENTICATION,
|
AIErrorType.RATE_LIMIT, status, error
|
||||||
error.status
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.status === 404 && error.message?.includes('model')) {
|
return null;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -435,8 +416,8 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
return {
|
return {
|
||||||
model: params.model || this.defaultModel,
|
model: params.model || this.defaultModel,
|
||||||
messages: this.convertMessages(params.messages),
|
messages: this.convertMessages(params.messages),
|
||||||
max_tokens: params.maxTokens || 1000,
|
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||||
temperature: params.temperature ?? 0.7,
|
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||||
top_p: params.topP,
|
top_p: params.topP,
|
||||||
stop: params.stopSequences,
|
stop: params.stopSequences,
|
||||||
stream,
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
59
src/providers/openwebui-http.ts
Normal file
59
src/providers/openwebui-http.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
306
src/providers/openwebui-strategies.ts
Normal file
306
src/providers/openwebui-strategies.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
55
src/providers/openwebui-types.ts
Normal file
55
src/providers/openwebui-types.ts
Normal 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
@@ -1,23 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Factory utilities for creating AI providers
|
* Factory utilities for creating AI providers.
|
||||||
* Provides convenient methods for instantiating and configuring providers
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AIProviderConfig } from '../types/index.js';
|
|
||||||
import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js';
|
import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js';
|
||||||
import { OpenAIProvider, type OpenAIConfig } from '../providers/openai.js';
|
import { OpenAIProvider, type OpenAIConfig } from '../providers/openai.js';
|
||||||
import { GeminiProvider, type GeminiConfig } from '../providers/gemini.js';
|
import { GeminiProvider, type GeminiConfig } from '../providers/gemini.js';
|
||||||
import { OpenWebUIProvider, type OpenWebUIConfig } from '../providers/openwebui.js';
|
import { OpenWebUIProvider, type OpenWebUIConfig } from '../providers/openwebui.js';
|
||||||
import { BaseAIProvider } from '../providers/base.js';
|
import { BaseAIProvider } from '../providers/base.js';
|
||||||
|
|
||||||
/**
|
export const PROVIDER_REGISTRY = {
|
||||||
* Supported AI provider types
|
claude: ClaudeProvider,
|
||||||
*/
|
openai: OpenAIProvider,
|
||||||
export type ProviderType = 'claude' | 'openai' | 'gemini' | 'openwebui';
|
gemini: GeminiProvider,
|
||||||
|
openwebui: OpenWebUIProvider
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ProviderType = keyof typeof PROVIDER_REGISTRY;
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration map for different provider types
|
|
||||||
*/
|
|
||||||
export interface ProviderConfigMap {
|
export interface ProviderConfigMap {
|
||||||
claude: ClaudeConfig;
|
claude: ClaudeConfig;
|
||||||
openai: OpenAIConfig;
|
openai: OpenAIConfig;
|
||||||
@@ -25,144 +24,38 @@ export interface ProviderConfigMap {
|
|||||||
openwebui: OpenWebUIConfig;
|
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>(
|
export function createProvider<T extends ProviderType>(
|
||||||
type: T,
|
type: T,
|
||||||
config: ProviderConfigMap[T]
|
config: ProviderConfigMap[T]
|
||||||
): BaseAIProvider {
|
): BaseAIProvider {
|
||||||
switch (type) {
|
const ProviderClass = PROVIDER_REGISTRY[type];
|
||||||
case 'claude':
|
if (!ProviderClass) {
|
||||||
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}`);
|
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(
|
export function createClaudeProvider(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options: Partial<Omit<ClaudeConfig, 'apiKey'>> = {}
|
options: Partial<Omit<ClaudeConfig, 'apiKey'>> = {}
|
||||||
): ClaudeProvider {
|
): ClaudeProvider {
|
||||||
return new ClaudeProvider({
|
return new ClaudeProvider({ apiKey, ...options });
|
||||||
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(
|
export function createOpenAIProvider(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options: Partial<Omit<OpenAIConfig, 'apiKey'>> = {}
|
options: Partial<Omit<OpenAIConfig, 'apiKey'>> = {}
|
||||||
): OpenAIProvider {
|
): OpenAIProvider {
|
||||||
return new OpenAIProvider({
|
return new OpenAIProvider({ apiKey, ...options });
|
||||||
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(
|
export function createGeminiProvider(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options: Partial<Omit<GeminiConfig, 'apiKey'>> = {}
|
options: Partial<Omit<GeminiConfig, 'apiKey'>> = {}
|
||||||
): GeminiProvider {
|
): GeminiProvider {
|
||||||
return new GeminiProvider({
|
return new GeminiProvider({ apiKey, ...options });
|
||||||
apiKey,
|
|
||||||
...options
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an OpenWebUI provider instance
|
|
||||||
*/
|
|
||||||
export function createOpenWebUIProvider(config: OpenWebUIConfig): OpenWebUIProvider {
|
export function createOpenWebUIProvider(config: OpenWebUIConfig): OpenWebUIProvider {
|
||||||
return new OpenWebUIProvider(config);
|
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;
|
|
||||||
Reference in New Issue
Block a user