Files
simple-ai-provider/tests/openwebui.test.ts
Jan-Marlon Leibl a0d181787c test: repair test suite after R1-R3 refactors and add Claude Code tests
The R1-R3 refactors removed the per-provider handle*Error methods,
consolidated validators into validateNumberInRange, renamed Gemini's
buildGenerationConfig to buildConfig, and split OpenWebUI's message
conversion and response formatting into openwebui-strategies.ts.
44 of 91 existing tests broke as a result.

Changes:

claude.test.ts (8 -> 13 passing)
- Switch error mapping tests from handleAnthropicError to the base
  normalizeError method.
- Update temperature error-message expectation to match the new
  validateNumberInRange wording.

openai.test.ts (16 -> 22 passing)
- Same handleOpenAIError -> normalizeError switch.
- Add a regression test for the 429+"quota" path that goes through
  mapProviderError instead of the base mapping.

gemini.test.ts (17 -> 27 passing)
- Update getInfo expectations to the new default model (gemini-2.5)
  and provider version 2.0.0.
- Switch to normalizeError.
- Rename buildGenerationConfig -> buildConfig (renamed during R2).
- Update formatCompletionResponse mocks: the @google/genai SDK
  exposes response.text as a getter rather than walking
  candidates[0].content.parts.

openwebui.test.ts (4 -> 19 passing)
- Drop tests for convertMessages / convertMessagesToPrompt /
  formatChatResponse / formatOllamaResponse / makeRequest: these
  moved into openwebui-strategies.ts and openwebui-http.ts during R3
  and warrant their own test files.
- Add error-mapping tests covering the new mapProviderError +
  providerErrorMessages plumbing.

claude-code.test.ts (new, 18 tests)
- Constructor accepts subscription mode (empty config).
- getInfo advertises subscriptionAuth + requiresLocalCli.
- mapProviderError catches ENOENT and "not logged in".
- errorForSdkAssistantError covers each documented SDK error code.
- buildPrompt handles single-turn pass-through, multi-system
  combination, multi-turn flattening with role labels, and rejects
  empty turn lists.

Full suite: 100 pass / 0 fail across 5 files.
2026-05-21 14:37:33 +02:00

230 lines
7.1 KiB
TypeScript

/**
* 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();
});
});
});