/** * Tests for OpenWebUI provider implementation * * The R3 refactor split message conversion, response formatting, and the * HTTP client into openwebui-strategies.ts and openwebui-http.ts. Tests for * those helpers should live alongside those files. This file covers the * provider's public surface and the error-mapping hooks it adds on top of * BaseAIProvider. */ import { describe, it, expect, beforeEach, jest } from 'bun:test'; import { OpenWebUIProvider, type OpenWebUIConfig } from '../src/providers/openwebui.js'; import { AIProviderError, AIErrorType, type CompletionParams, createResponseType } from '../src/types/index.js'; describe('OpenWebUIProvider', () => { let provider: OpenWebUIProvider; let mockConfig: OpenWebUIConfig; beforeEach(() => { mockConfig = { apiKey: 'test-bearer-token', baseUrl: 'http://localhost:3000', defaultModel: 'llama3.1:latest' }; }); describe('Constructor', () => { it('should create provider with default configuration', () => { provider = new OpenWebUIProvider(mockConfig); expect(provider).toBeInstanceOf(OpenWebUIProvider); expect(provider.getInfo().name).toBe('OpenWebUI'); }); it('should use default values when not specified', () => { provider = new OpenWebUIProvider({ apiKey: 'test' }); expect(provider).toBeInstanceOf(OpenWebUIProvider); }); it('should accept Ollama proxy mode', () => { provider = new OpenWebUIProvider({ apiKey: 'test', baseUrl: 'http://localhost:8080', defaultModel: 'mistral:7b', useOllamaProxy: true }); expect(provider).toBeInstanceOf(OpenWebUIProvider); }); it('should normalize trailing slash in baseUrl', () => { provider = new OpenWebUIProvider({ apiKey: 'test', baseUrl: 'http://localhost:3000/' }); expect(provider).toBeInstanceOf(OpenWebUIProvider); }); it('should reject malformed baseUrl', () => { expect(() => new OpenWebUIProvider({ apiKey: 'test', baseUrl: 'not a url' })) .toThrow(AIProviderError); }); }); describe('getInfo', () => { beforeEach(() => { provider = new OpenWebUIProvider(mockConfig); }); it('should return correct provider information', () => { const info = provider.getInfo(); expect(info.name).toBe('OpenWebUI'); expect(info.version).toBe('2.0.0'); expect(info.supportsStreaming).toBe(true); expect(info.maxContextLength).toBe(32768); expect(Array.isArray(info.models)).toBe(true); }); it('should include expected models', () => { const info = provider.getInfo(); expect(info.models).toContain('llama3.1:latest'); expect(info.models).toContain('mistral:latest'); expect(info.models).toContain('codellama:latest'); expect(info.models).toContain('gemma:latest'); }); it('should expose capabilities', () => { const info = provider.getInfo(); expect(info.capabilities).toMatchObject({ vision: false, functionCalling: false, systemMessages: true, localDeployment: true }); }); }); describe('Error mapping', () => { const mapError = (err: any) => (provider as any).normalizeError(err); beforeEach(() => { provider = new OpenWebUIProvider(mockConfig); }); it('should map ECONNREFUSED to NETWORK', () => { const error = new Error('connect ECONNREFUSED') as any; const aiError = mapError(error); expect(aiError).toBeInstanceOf(AIProviderError); expect(aiError.type).toBe(AIErrorType.NETWORK); }); it('should map ENOTFOUND to NETWORK', () => { const error = new Error('getaddrinfo ENOTFOUND') as any; const aiError = mapError(error); expect(aiError.type).toBe(AIErrorType.NETWORK); }); it('should map 404 with "model" in message to MODEL_NOT_FOUND', () => { const error = new Error('model not available') as any; error.status = 404; const aiError = mapError(error); expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND); expect(aiError.message).toContain('OpenWebUI'); }); it('should map 404 without "model" in message to MODEL_NOT_FOUND with endpoint message', () => { const error = new Error('Not found') as any; error.status = 404; const aiError = mapError(error); expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND); expect(aiError.message).toContain('endpoint'); }); it('should map timeout errors to TIMEOUT', () => { const error = new Error('Request timeout') as any; const aiError = mapError(error); expect(aiError.type).toBe(AIErrorType.TIMEOUT); }); it('should map 429 to RATE_LIMIT', () => { const error = new Error('Rate limit exceeded') as any; error.status = 429; const aiError = mapError(error); expect(aiError.type).toBe(AIErrorType.RATE_LIMIT); }); it('should map 500 to NETWORK', () => { const error = new Error('Internal server error') as any; error.status = 500; const aiError = mapError(error); expect(aiError.type).toBe(AIErrorType.NETWORK); }); it('should map 401 to AUTHENTICATION with OpenWebUI hint', () => { const error = new Error('Unauthorized') as any; error.status = 401; const aiError = mapError(error); expect(aiError.type).toBe(AIErrorType.AUTHENTICATION); expect(aiError.message).toContain('OpenWebUI'); }); it('should map unrecognized errors to UNKNOWN', () => { const error = new Error('Unknown error'); const aiError = mapError(error); expect(aiError.type).toBe(AIErrorType.UNKNOWN); }); }); describe('Validation', () => { beforeEach(() => { provider = new OpenWebUIProvider(mockConfig); }); it('should require initialization before use', async () => { const params: CompletionParams = { messages: [{ role: 'user', content: 'Hello' }] }; await expect(provider.complete(params)).rejects.toThrow('Provider must be initialized before use'); }); }); describe('Structured responses', () => { it('should parse JSON content into typed responses', async () => { const p = new OpenWebUIProvider({ apiKey: 'test-key' }); const mockResponse = { content: JSON.stringify({ name: 'John Doe', age: 30 }), model: 'llama3.1:latest', usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 }, id: 'test-id' }; (p as any).doComplete = jest.fn().mockResolvedValue(mockResponse); (p as any).initialized = true; const responseType = createResponseType<{ name: string; age: number }>( `{ name: string; age: number }`, 'A user profile' ); const response = await p.complete<{ name: string; age: number }>({ messages: [{ role: 'user', content: 'test' }], responseType }); expect(response.content).toEqual({ name: 'John Doe', age: 30 }); expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 })); expect((p as any).doComplete).toHaveBeenCalled(); }); }); });