From 3d985a95a88be471804b75925bbc8029a5cf3b5e Mon Sep 17 00:00:00 2001 From: Jan-Marlon Leibl Date: Thu, 21 May 2026 13:51:08 +0200 Subject: [PATCH] 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. --- src/providers/openwebui-http.ts | 59 ++ src/providers/openwebui-strategies.ts | 306 +++++++ src/providers/openwebui-types.ts | 55 ++ src/providers/openwebui.ts | 1065 ++++--------------------- 4 files changed, 562 insertions(+), 923 deletions(-) create mode 100644 src/providers/openwebui-http.ts create mode 100644 src/providers/openwebui-strategies.ts create mode 100644 src/providers/openwebui-types.ts diff --git a/src/providers/openwebui-http.ts b/src/providers/openwebui-http.ts new file mode 100644 index 0000000..90d3400 --- /dev/null +++ b/src/providers/openwebui-http.ts @@ -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 { + const headers: Record = { + '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; + } + } +} diff --git a/src/providers/openwebui-strategies.ts b/src/providers/openwebui-strategies.ts new file mode 100644 index 0000000..f593eda --- /dev/null +++ b/src/providers/openwebui-strategies.ts @@ -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; + complete(params: CompletionParams, defaultModel: string): Promise>; + stream(params: CompletionParams, defaultModel: string): AsyncIterable; +} + +// ============================================================================ +// Chat strategy — OpenAI-compatible /api/chat/completions endpoint +// ============================================================================ + +export class OpenWebUIChatStrategy implements OpenWebUIStrategy { + constructor(private readonly http: OpenWebUIHttpClient) {} + + async validateConnection(): Promise { + 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> { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 + } + }; +} diff --git a/src/providers/openwebui-types.ts b/src/providers/openwebui-types.ts new file mode 100644 index 0000000..5c745bf --- /dev/null +++ b/src/providers/openwebui-types.ts @@ -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; + }>; +} diff --git a/src/providers/openwebui.ts b/src/providers/openwebui.ts index c078a6b..37bf98e 100644 --- a/src/providers/openwebui.ts +++ b/src/providers/openwebui.ts @@ -1,26 +1,12 @@ /** * OpenWebUI Provider Implementation - * - * This module provides integration with OpenWebUI instances, supporting both the native - * chat completions API and Ollama proxy endpoints. OpenWebUI is a self-hosted interface - * for large language models that provides a unified API for various model backends. - * - * Key Features: - * - Dual API support: Chat completions (OpenAI-compatible) and Ollama proxy - * - Local model support through Ollama integration - * - Self-hosted deployment flexibility with custom base URLs - * - Streaming support for real-time response delivery - * - Comprehensive model management and discovery - * - * OpenWebUI-Specific Considerations: - * - Typically runs on localhost with custom ports (default: 3000) - * - May use self-signed certificates requiring insecure connections - * - Supports both conversation and single-prompt formats - * - Model availability depends on configured backends (Ollama, etc.) - * - Rate limiting and authentication vary by deployment configuration - * + * + * Integrates with OpenWebUI instances supporting both its native + * chat-completions API (OpenAI-compatible) and the Ollama proxy. The + * actual HTTP plumbing lives in two strategy classes; this file picks + * one at construction based on `useOllamaProxy`. + * * @author Jan-Marlon Leibl - * @version 1.0.0 * @see https://docs.openwebui.com/ */ @@ -29,924 +15,102 @@ import type { CompletionParams, CompletionResponse, CompletionChunk, - ProviderInfo, - AIMessage + ProviderInfo } from '../types/index.js'; import { BaseAIProvider } from './base.js'; import { AIProviderError, AIErrorType } from '../types/index.js'; import { - DEFAULT_MAX_TOKENS, DEFAULT_MODELS, - DEFAULT_OPENWEBUI_BASE_URL, - DEFAULT_TEMPERATURE + DEFAULT_OPENWEBUI_BASE_URL } from '../constants.js'; +import { OpenWebUIHttpClient } from './openwebui-http.js'; +import { + OpenWebUIChatStrategy, + OpenWebUIOllamaStrategy, + type OpenWebUIStrategy +} from './openwebui-strategies.js'; -// ============================================================================ -// TYPES AND INTERFACES -// ============================================================================ - -/** - * Configuration interface for OpenWebUI provider with OpenWebUI-specific options. - * - * @example - * ```typescript - * const config: OpenWebUIConfig = { - * apiKey: 'your-openwebui-api-key', // Optional for local instances - * baseUrl: 'http://localhost:3000', - * defaultModel: 'llama3.1:latest', - * useOllamaProxy: false, - * dangerouslyAllowInsecureConnections: true - * }; - * ``` - */ export interface OpenWebUIConfig extends AIProviderConfig { - /** - * Default model to use for requests. - * - * Common OpenWebUI models: - * - 'llama3.1:latest': Latest Llama 3.1 model - * - 'mistral:latest': Mistral model variant - * - 'codellama:latest': Code-specialized Llama - * - 'phi3:latest': Microsoft Phi-3 model - * - * @default 'llama3.1:latest' - */ + /** Default model to use for requests. @default 'llama3.1:latest' */ defaultModel?: string; - /** - * Base URL for the OpenWebUI instance. - * - * This should point to your OpenWebUI deployment. For local development, - * this is typically localhost with the configured port. - * - * @default 'http://localhost:3000' - */ + /** Base URL for the OpenWebUI instance. @default 'http://localhost:3000' */ baseUrl?: string; - /** - * Whether to use Ollama API proxy endpoints instead of chat completions. - * - * - `false`: Use OpenAI-compatible chat completions API (recommended) - * - `true`: Use direct Ollama generate API (for legacy compatibility) - * - * @default false + /** + * Use the Ollama proxy endpoint (`/ollama/api/generate`) instead of the + * OpenAI-compatible chat completions endpoint. @default false */ useOllamaProxy?: boolean; - /** - * Whether to allow insecure SSL connections. - * - * This is often needed for local OpenWebUI instances that use - * self-signed certificates or HTTP instead of HTTPS. - * + /** + * Allow connections to instances with self-signed certs or plain HTTP. * @default true */ dangerouslyAllowInsecureConnections?: boolean; } -/** - * OpenWebUI chat completion response structure. - * Compatible with OpenAI's chat completions format. - */ -interface OpenWebUIChatResponse { - /** Unique identifier for the completion */ - id: string; - /** Object type, typically 'chat.completion' */ - object: string; - /** Unix timestamp of when the completion was created */ - created: number; - /** Model that generated the completion */ - model: string; - /** Array of completion choices */ - choices: Array<{ - /** Choice index in the array */ - index: number; - /** The generated message */ - message: { - /** Role of the message sender */ - role: string; - /** Content of the message */ - content: string; - }; - /** Reason why the completion finished */ - finish_reason: string | null; - }>; - /** Token usage statistics (if available) */ - usage?: { - /** Number of tokens in the prompt */ - prompt_tokens: number; - /** Number of tokens in the completion */ - completion_tokens: number; - /** Total tokens used */ - total_tokens: number; - }; -} - -/** - * OpenWebUI streaming response chunk structure. - * Each chunk represents a piece of the streaming completion. - */ -interface OpenWebUIStreamChunk { - /** Unique identifier for the completion */ - id: string; - /** Object type, typically 'chat.completion.chunk' */ - object: string; - /** Unix timestamp of when the chunk was created */ - created: number; - /** Model generating the completion */ - model: string; - /** Array of choice deltas */ - choices: Array<{ - /** Choice index in the array */ - index: number; - /** Delta containing new content */ - delta: { - /** Role of the message (only in first chunk) */ - role?: string; - /** New content fragment */ - content?: string; - }; - /** Reason why the completion finished (only in last chunk) */ - finish_reason: string | null; - }>; -} - -/** - * Ollama generate response structure. - * Used when communicating directly with Ollama through OpenWebUI proxy. - */ -interface OllamaGenerateResponse { - /** Model that generated the response */ - model: string; - /** ISO timestamp of when the response was created */ - created_at: string; - /** Generated text response */ - response: string; - /** Whether the generation is complete */ - done: boolean; - /** Context array for continuing conversations */ - context?: number[]; - /** Total duration of the request in nanoseconds */ - total_duration?: number; - /** Time spent loading the model in nanoseconds */ - load_duration?: number; - /** Number of tokens in the prompt */ - prompt_eval_count?: number; - /** Time spent evaluating the prompt in nanoseconds */ - prompt_eval_duration?: number; - /** Number of tokens in the response */ - eval_count?: number; - /** Time spent generating the response in nanoseconds */ - eval_duration?: number; -} - -/** - * OpenWebUI models list response structure. - * Provides information about available models. - */ -interface OpenWebUIModelsResponse { - /** Array of available models */ - data: Array<{ - /** Model identifier */ - id: string; - /** Object type, typically 'model' */ - object: string; - /** Unix timestamp of when the model was created */ - created: number; - /** Organization or source that owns the model */ - owned_by: string; - }>; -} - -// ============================================================================ -// OPENWEBUI PROVIDER IMPLEMENTATION -// ============================================================================ - -/** - * OpenWebUI provider implementation. - * - * This class handles interactions with OpenWebUI instances, providing support - * for both the native chat completions API and direct Ollama proxy access. - * It's designed to work with self-hosted OpenWebUI deployments. - * - * Usage Pattern: - * 1. Deploy OpenWebUI instance (local or remote) - * 2. Create provider with instance URL and configuration - * 3. Call initialize() to test connection and discover models - * 4. Use complete() or stream() for text generation - * 5. Handle AIProviderError exceptions for deployment-specific issues - * - * @example - * ```typescript - * const openwebui = new OpenWebUIProvider({ - * apiKey: 'optional-api-key', - * baseUrl: 'http://localhost:3000', - * defaultModel: 'llama3.1:latest', - * useOllamaProxy: false - * }); - * - * await openwebui.initialize(); - * - * const response = await openwebui.complete({ - * messages: [ - * { role: 'user', content: 'Explain container orchestration.' } - * ], - * maxTokens: 500, - * temperature: 0.7 - * }); - * ``` - */ export class OpenWebUIProvider extends BaseAIProvider { - // ======================================================================== - // INSTANCE PROPERTIES - // ======================================================================== - - /** Default model identifier for requests */ private readonly defaultModel: string; - - /** Base URL for the OpenWebUI instance */ private readonly baseUrl: string; - - /** Whether to use Ollama proxy API instead of chat completions */ - private readonly useOllamaProxy: boolean; - - /** Whether to allow insecure SSL connections */ - private readonly dangerouslyAllowInsecureConnections: boolean; + private readonly strategy: OpenWebUIStrategy; - // ======================================================================== - // CONSTRUCTOR - // ======================================================================== - - /** - * Creates a new OpenWebUI provider instance. - * - * @param config - OpenWebUI-specific configuration options - * @throws {AIProviderError} If configuration validation fails - */ constructor(config: OpenWebUIConfig) { super(config); - - // Set OpenWebUI-specific defaults and normalize configuration + this.defaultModel = config.defaultModel || DEFAULT_MODELS.openwebui; this.baseUrl = this.normalizeBaseUrl(config.baseUrl || DEFAULT_OPENWEBUI_BASE_URL); - this.useOllamaProxy = config.useOllamaProxy ?? false; - this.dangerouslyAllowInsecureConnections = config.dangerouslyAllowInsecureConnections ?? true; - - // Validate configuration - this.validateConfiguration(); + + this.validateBaseUrl(); + this.warnOnInsecureMix(config); + + const http = new OpenWebUIHttpClient({ + baseUrl: this.baseUrl, + apiKey: this.config.apiKey, + timeout: this.config.timeout, + dangerouslyAllowInsecureConnections: config.dangerouslyAllowInsecureConnections ?? true + }); + + this.strategy = config.useOllamaProxy + ? new OpenWebUIOllamaStrategy(http) + : new OpenWebUIChatStrategy(http); } - // ======================================================================== - // PUBLIC INTERFACE METHODS - // ======================================================================== - - /** - * Returns comprehensive information about the OpenWebUI provider. - * - * @returns Provider information including capabilities and supported models - */ public getInfo(): ProviderInfo { return { name: 'OpenWebUI', - version: '1.0.0', + version: '2.0.0', models: [ - 'llama3.1:latest', // Latest Llama 3.1 - 'llama3.1:8b', // Llama 3.1 8B parameters - 'llama3.1:70b', // Llama 3.1 70B parameters - 'mistral:latest', // Mistral model - 'mistral:7b', // Mistral 7B parameters - 'codellama:latest', // Code Llama latest - 'codellama:7b', // Code Llama 7B - 'phi3:latest', // Microsoft Phi-3 - 'phi3:mini', // Phi-3 mini variant - 'qwen2:latest', // Qwen2 model - 'gemma:latest' // Google Gemma + 'llama3.1:latest', + 'llama3.1:8b', + 'llama3.1:70b', + 'mistral:latest', + 'mistral:7b', + 'codellama:latest', + 'codellama:7b', + 'phi3:latest', + 'phi3:mini', + 'qwen2:latest', + 'gemma:latest' ], - maxContextLength: 32768, // Varies by model, 32K common + maxContextLength: 32768, supportsStreaming: true, capabilities: { - vision: false, // Depends on specific models - functionCalling: false, // Limited by underlying models - jsonMode: false, // Model-dependent feature - systemMessages: true, // Supported through prompt engineering - reasoning: true, // Strong reasoning with Llama models - codeGeneration: true, // Excellent with Code Llama - localDeployment: true, // Key advantage of OpenWebUI - multiModel: true // Can switch between models + vision: false, + functionCalling: false, + jsonMode: false, + systemMessages: true, + reasoning: true, + codeGeneration: true, + localDeployment: true, + multiModel: true } }; } - // ======================================================================== - // PRIVATE UTILITY METHODS - // ======================================================================== - - /** - * Normalizes the base URL by removing trailing slashes and validating format. - * - * @private - * @param baseUrl - Raw base URL - * @returns Normalized base URL - */ - private normalizeBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/$/, ''); - } - - /** - * Validates the provider configuration for common issues. - * - * @private - * @throws {AIProviderError} If configuration is invalid - */ - private validateConfiguration(): void { - // Validate base URL format - try { - new URL(this.baseUrl); - } catch { - throw new AIProviderError( - `Invalid base URL: ${this.baseUrl}. Must be a valid URL.`, - AIErrorType.INVALID_REQUEST - ); - } - - // Warn about common configuration issues - if (this.baseUrl.includes('https://') && this.dangerouslyAllowInsecureConnections) { - console.warn('Using HTTPS with insecure connections allowed. Consider setting dangerouslyAllowInsecureConnections to false.'); - } - } - - /** - * Validates the connection to the OpenWebUI instance. - * - * @private - * @throws {AIProviderError} If connection validation fails - */ - protected override async validateConnection(): Promise { - try { - // Try to fetch available models to test connectivity - const url = this.useOllamaProxy - ? `${this.baseUrl}/ollama/api/tags` - : `${this.baseUrl}/api/models`; - - const response = await this.makeRequest(url, 'GET'); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - // For chat completions API, verify the response structure - if (!this.useOllamaProxy) { - const data = await response.json() as OpenWebUIModelsResponse; - if (!data.data || !Array.isArray(data.data)) { - throw new Error('Invalid models response format'); - } - } - - } catch (error: any) { - if (error.message?.includes('ECONNREFUSED')) { - throw new AIProviderError( - `Cannot connect to OpenWebUI at ${this.baseUrl}. Please verify the instance is running and accessible.`, - AIErrorType.NETWORK, - undefined, - error - ); - } - - if (error.message?.includes('certificate')) { - throw new AIProviderError( - `SSL certificate error connecting to ${this.baseUrl}. Consider setting dangerouslyAllowInsecureConnections to true for local instances.`, - AIErrorType.NETWORK, - undefined, - error - ); - } - - throw new AIProviderError( - `Failed to validate OpenWebUI connection: ${error.message}`, - AIErrorType.NETWORK, - undefined, - error - ); - } - } - - /** - * Makes an HTTP request with proper error handling and configuration. - * - * @private - * @param url - Request URL - * @param method - HTTP method - * @param body - Request body (optional) - * @returns Promise resolving to Response object - */ - private async makeRequest(url: string, method: string, body?: any): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - 'User-Agent': 'simple-ai-provider/1.0.0' - }; - - // Add authorization header if API key is provided - if (this.config.apiKey) { - headers['Authorization'] = `Bearer ${this.config.apiKey}`; - } - - const requestOptions: RequestInit = { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(this.config.timeout || 30000) - }; - - // Handle insecure connections for local instances - if (this.dangerouslyAllowInsecureConnections) { - // Note: This would need additional configuration in Node.js environments - // For browser environments, this setting doesn't apply - } - - try { - const response = await fetch(url, requestOptions); - return response; - } catch (error: any) { - if (error.name === 'AbortError') { - throw new AIProviderError( - 'Request timed out', - AIErrorType.TIMEOUT, - undefined, - error - ); - } - throw error; - } - } - - /** - * Converts generic messages to OpenWebUI chat format. - * - * @private - * @param messages - Input messages - * @returns OpenWebUI-formatted messages - */ - private convertMessages(messages: AIMessage[]): Array<{role: string; content: string}> { - return messages.map(message => ({ - role: message.role, - content: message.content - })); - } - - /** - * Converts messages to a single prompt string for Ollama API. - * - * @private - * @param messages - Input messages - * @returns Formatted prompt string - */ - private 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; - } - } - - // Add final prompt for the assistant to respond - prompt += 'Assistant: '; - - return prompt; - } - - /** - * Formats OpenWebUI chat completion response to standard interface. - * - * @private - * @param response - Raw OpenWebUI response - * @returns Formatted completion response - */ - private formatChatResponse(response: OpenWebUIChatResponse): CompletionResponse { - 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 - } - }; - } - - /** - * Formats Ollama generate response to standard interface. - * - * @private - * @param response - Raw Ollama response - * @returns Formatted completion response - */ - private formatOllamaResponse(response: OllamaGenerateResponse): CompletionResponse { - 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 - } - }; - } - - /** - * Handles and transforms OpenWebUI-specific errors. - * - * @private - * @param error - Original error - * @returns Normalized AIProviderError - */ - private handleOpenWebUIError(error: any): AIProviderError { - if (error instanceof AIProviderError) { - return error; - } - - const message = error.message || 'Unknown OpenWebUI error'; - const status = error.status || error.statusCode; - - // Map HTTP status codes - switch (status) { - case 400: - return new AIProviderError( - `Invalid request to OpenWebUI: ${message}`, - AIErrorType.INVALID_REQUEST, - status, - error - ); - - case 401: - case 403: - return new AIProviderError( - 'Authentication failed with OpenWebUI. Please check your API key or instance configuration.', - AIErrorType.AUTHENTICATION, - status, - error - ); - - case 404: - if (message.includes('model')) { - return new AIProviderError( - 'Model not found in OpenWebUI. Please check the model name and ensure it\'s available.', - AIErrorType.MODEL_NOT_FOUND, - status, - error - ); - } - return new AIProviderError( - 'OpenWebUI endpoint not found. Please verify the base URL and API path.', - AIErrorType.INVALID_REQUEST, - status, - error - ); - - case 429: - return new AIProviderError( - 'Rate limit exceeded on OpenWebUI instance.', - AIErrorType.RATE_LIMIT, - status, - error - ); - - case 500: - case 502: - case 503: - return new AIProviderError( - 'OpenWebUI instance error. The server may be overloaded or misconfigured.', - AIErrorType.NETWORK, - status, - error - ); - - default: - if (message.includes('timeout')) { - return new AIProviderError( - 'Request to OpenWebUI timed out.', - AIErrorType.TIMEOUT, - status, - error - ); - } - - if (message.includes('network') || message.includes('ECONNREFUSED')) { - return new AIProviderError( - 'Network error connecting to OpenWebUI instance.', - AIErrorType.NETWORK, - status, - error - ); - } - - return new AIProviderError( - `OpenWebUI error: ${message}`, - AIErrorType.UNKNOWN, - status, - error - ); - } - } - - // ======================================================================== - // PRIVATE API IMPLEMENTATION METHODS - // ======================================================================== - - /** - * Completes using OpenWebUI's chat completions API (OpenAI-compatible). - * - * @private - * @param params - Completion parameters - * @returns Promise resolving to formatted completion response - */ - private async completeWithChat(params: CompletionParams): Promise { - const url = `${this.baseUrl}/api/chat/completions`; - - const requestBody = { - model: params.model || this.defaultModel, - messages: this.convertMessages(params.messages), - max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS, - temperature: params.temperature ?? DEFAULT_TEMPERATURE, - top_p: params.topP, - stop: params.stopSequences, - stream: false - }; - - try { - const response = await this.makeRequest(url, 'POST', requestBody); - const data = await response.json() as OpenWebUIChatResponse; - - return this.formatChatResponse(data); - } catch (error) { - throw this.handleOpenWebUIError(error as Error); - } - } - - /** - * Completes using Ollama proxy API (direct Ollama format). - * - * @private - * @param params - Completion parameters - * @returns Promise resolving to formatted completion response - */ - private async completeWithOllama(params: CompletionParams): Promise { - const url = `${this.baseUrl}/ollama/api/generate`; - - // Convert messages to a single prompt for Ollama - const prompt = this.convertMessagesToPrompt(params.messages); - - const requestBody = { - model: params.model || this.defaultModel, - prompt: prompt, - stream: false, - options: { - temperature: params.temperature ?? DEFAULT_TEMPERATURE, - top_p: params.topP, - num_predict: params.maxTokens || DEFAULT_MAX_TOKENS, - stop: params.stopSequences - } - }; - - try { - const response = await this.makeRequest(url, 'POST', requestBody); - const data = await response.json() as OllamaGenerateResponse; - - return this.formatOllamaResponse(data); - } catch (error) { - throw this.handleOpenWebUIError(error as Error); - } - } - - /** - * Streams using OpenWebUI's chat completions API with enhanced chunk processing. - * - * @private - * @param params - Completion parameters - * @returns AsyncIterable of completion chunks - */ - private async *streamWithChat(params: CompletionParams): AsyncIterable { - const url = `${this.baseUrl}/api/chat/completions`; - - const requestBody = { - model: params.model || this.defaultModel, - messages: this.convertMessages(params.messages), - max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS, - temperature: params.temperature ?? DEFAULT_TEMPERATURE, - top_p: params.topP, - stop: params.stopSequences, - stream: true - }; - - try { - const response = await this.makeRequest(url, 'POST', requestBody); - - 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: ')) { - const data = trimmed.slice(6); - if (data === '[DONE]') { - return; - } - - try { - const chunk = JSON.parse(data) as OpenWebUIStreamChunk; - 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, // OpenWebUI doesn't always provide usage - completionTokens: 0, - totalTokens: 0 - } - }; - return; - } - } catch (parseError) { - // Skip invalid JSON chunks - console.warn('Failed to parse streaming chunk:', parseError); - } - } - } - } - } finally { - reader.releaseLock(); - } - } catch (error) { - throw this.handleOpenWebUIError(error as Error); - } - } - - /** - * Streams using Ollama proxy API with enhanced error handling. - * - * @private - * @param params - Completion parameters - * @returns AsyncIterable of completion chunks - */ - private async *streamWithOllama(params: CompletionParams): AsyncIterable { - const url = `${this.baseUrl}/ollama/api/generate`; - - const prompt = this.convertMessagesToPrompt(params.messages); - - const requestBody = { - model: params.model || this.defaultModel, - prompt: prompt, - stream: true, - options: { - temperature: params.temperature ?? DEFAULT_TEMPERATURE, - top_p: params.topP, - num_predict: params.maxTokens || DEFAULT_MAX_TOKENS, - stop: params.stopSequences - } - }; - - try { - const response = await this.makeRequest(url, 'POST', requestBody); - - 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) { - try { - const chunk = JSON.parse(trimmed) as OllamaGenerateResponse; - - 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; - } - } catch (parseError) { - // Skip invalid JSON chunks - console.warn('Failed to parse Ollama streaming chunk:', parseError); - } - } - } - } - } finally { - reader.releaseLock(); - } - } catch (error) { - throw this.handleOpenWebUIError(error as Error); - } - } - - // ======================================================================== - // PROTECTED TEMPLATE METHOD IMPLEMENTATIONS - // ======================================================================== - - /** - * Initializes the OpenWebUI provider by testing the connection. - * - * This method: - * 1. Tests connectivity to the OpenWebUI instance - * 2. Validates API access and permissions - * 3. Optionally discovers available models - * 4. Verifies the default model is accessible - * - * @protected - * @throws {Error} If connection validation fails - */ protected async doInitialize(): Promise { try { - // Test basic connectivity and API access await this.validateConnection(); - } catch (error) { throw new AIProviderError( `Failed to initialize OpenWebUI provider: ${(error as Error).message}`, @@ -957,38 +121,93 @@ export class OpenWebUIProvider extends BaseAIProvider { } } - /** - * Generates a text completion using the configured API method. - * - * This method automatically chooses between chat completions API - * and Ollama proxy based on the useOllamaProxy configuration. - * - * @protected - * @param params - Validated completion parameters - * @returns Promise resolving to formatted completion response - * @throws {Error} If API request fails - */ protected async doComplete(params: CompletionParams): Promise> { - if (this.useOllamaProxy) { - return this.completeWithOllama(params); - } else { - return this.completeWithChat(params); + return this.strategy.complete(params, this.defaultModel); + } + + protected async *doStream(params: CompletionParams): AsyncIterable { + yield* this.strategy.stream(params, this.defaultModel); + } + + protected override async validateConnection(): Promise { + try { + await this.strategy.validateConnection(); + } catch (error: any) { + if (error.message?.includes('ECONNREFUSED')) { + throw new AIProviderError( + `Cannot connect to OpenWebUI at ${this.baseUrl}. Please verify the instance is running and accessible.`, + AIErrorType.NETWORK, + undefined, + error + ); + } + + if (error.message?.includes('certificate')) { + throw new AIProviderError( + `SSL certificate error connecting to ${this.baseUrl}. Consider setting dangerouslyAllowInsecureConnections to true for local instances.`, + AIErrorType.NETWORK, + undefined, + error + ); + } + + throw new AIProviderError( + `Failed to validate OpenWebUI connection: ${error.message}`, + AIErrorType.NETWORK, + undefined, + error + ); } } - /** - * Generates a streaming text completion using the configured API method. - * - * @protected - * @param params - Validated completion parameters - * @returns AsyncIterable yielding completion chunks - * @throws {Error} If streaming request fails - */ - protected async *doStream(params: CompletionParams): AsyncIterable { - if (this.useOllamaProxy) { - yield* this.streamWithOllama(params); - } else { - yield* this.streamWithChat(params); + protected override providerErrorMessages(): Partial> { + return { + 401: 'Authentication failed with OpenWebUI. Please check your API key or instance configuration.', + 403: 'Authentication failed with OpenWebUI. Please check your API key or instance configuration.', + 404: 'OpenWebUI endpoint not found. Please verify the base URL and API path.', + 429: 'Rate limit exceeded on OpenWebUI instance.', + 500: 'OpenWebUI instance error. The server may be overloaded or misconfigured.', + 502: 'OpenWebUI instance error. The server may be overloaded or misconfigured.', + 503: 'OpenWebUI instance error. The server may be overloaded or misconfigured.' + }; + } + + protected override mapProviderError(error: any): AIProviderError | null { + if (!error) return null; + const message: string = error.message || ''; + const status = error.status || error.statusCode; + + if (status === 404 && message.includes('model')) { + return new AIProviderError( + "Model not found in OpenWebUI. Please check the model name and ensure it's available.", + AIErrorType.MODEL_NOT_FOUND, + status, + error + ); + } + + return null; + } + + private normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/$/, ''); + } + + private validateBaseUrl(): void { + try { + new URL(this.baseUrl); + } catch { + throw new AIProviderError( + `Invalid base URL: ${this.baseUrl}. Must be a valid URL.`, + AIErrorType.INVALID_REQUEST + ); } } -} \ No newline at end of file + + private warnOnInsecureMix(config: OpenWebUIConfig): void { + const allowInsecure = config.dangerouslyAllowInsecureConnections ?? true; + if (this.baseUrl.includes('https://') && allowInsecure) { + console.warn('Using HTTPS with insecure connections allowed. Consider setting dangerouslyAllowInsecureConnections to false.'); + } + } +}