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.
This commit is contained in:
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
Reference in New Issue
Block a user