Merge pull request 'refactor: split OpenWebUI into strategy classes by backend' (#9) from refactor/openwebui-strategy-split into main

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-05-21 11:51:34 +00:00
committed by Gitea
4 changed files with 562 additions and 923 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff