Files
Synapsis/src/lib/bots/llmClient.test.ts
T
AskIt 8ad3b97b7e feat(bots): Implement comprehensive bot system with autonomous posting, content management, and API endpoints
- Add bot management system with creation, suspension, and reinstatement functionality
- Implement autonomous bot posting with scheduling, rate limiting, and content generation
- Add content fetching system supporting RSS feeds and multiple content sources
- Implement LLM-based content generation with customizable bot personalities
- Add mention handling and automated response system for bot interactions
- Implement API key management with encryption using AUTH_SECRET for simplified deployment
- Add comprehensive bot logging system for activity tracking and error monitoring
- Create bot administration pages and settings UI for managing bot configurations
- Add database migrations for bot system schema including users, sources, and content items
- Implement cron job system for automated bot operations and scheduled tasks
- Add extensive test coverage with unit and property-based tests for core bot modules
- Simplify encryption by deriving keys from AUTH_SECRET instead of separate environment variable
- Implement automatic content fetching on post trigger with retry logic
- Add Reddit-specific link preview handling using oEmbed API for reliable metadata extraction
- Create utility scripts for bot inspection and cleanup operations
- Add comprehensive bot system documentation and improvement tracking
2026-01-25 16:22:41 +01:00

1306 lines
36 KiB
TypeScript

/**
* Unit Tests for LLM Client Module
*
* Tests the LLM client implementation for OpenRouter, OpenAI, and Anthropic providers.
* Includes tests for retry logic, error handling, and response parsing.
*
* Requirements: 2.6, 11.4
*/
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach, afterEach } from 'vitest';
import {
LLMClient,
LLMClientError,
LLMConfig,
LLMCompletionRequest,
DEFAULT_RETRY_CONFIG,
PROVIDER_ENDPOINTS,
calculateRetryDelay,
isRetryableError,
mapStatusToErrorCode,
buildOpenRouterRequest,
buildOpenAIRequest,
buildAnthropicRequest,
parseOpenRouterResponse,
parseOpenAIResponse,
parseAnthropicResponse,
buildHeaders,
createLLMClient,
validateLLMConfig,
sleep,
} from './llmClient';
import type { LLMProvider } from './encryption';
// ============================================
// TEST SETUP
// ============================================
// Store original env value to restore after tests
const originalEncryptionKey = process.env.BOT_ENCRYPTION_KEY;
// Generate a valid 32-byte encryption key for testing (base64 encoded)
const TEST_ENCRYPTION_KEY = Buffer.from(
'test-encryption-key-32-bytes!!!!'.slice(0, 32)
).toString('base64');
beforeAll(() => {
// Set up test encryption key
process.env.BOT_ENCRYPTION_KEY = TEST_ENCRYPTION_KEY;
});
afterAll(() => {
// Restore original encryption key
if (originalEncryptionKey !== undefined) {
process.env.BOT_ENCRYPTION_KEY = originalEncryptionKey;
} else {
delete process.env.BOT_ENCRYPTION_KEY;
}
});
// ============================================
// UTILITY FUNCTION TESTS
// ============================================
describe('Utility Functions', () => {
describe('calculateRetryDelay', () => {
it('calculates exponential backoff correctly', () => {
const config = {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
backoffMultiplier: 2,
};
expect(calculateRetryDelay(0, config)).toBe(1000);
expect(calculateRetryDelay(1, config)).toBe(2000);
expect(calculateRetryDelay(2, config)).toBe(4000);
expect(calculateRetryDelay(3, config)).toBe(8000);
});
it('caps delay at maxDelayMs', () => {
const config = {
maxRetries: 5,
initialDelayMs: 1000,
maxDelayMs: 5000,
backoffMultiplier: 2,
};
expect(calculateRetryDelay(0, config)).toBe(1000);
expect(calculateRetryDelay(1, config)).toBe(2000);
expect(calculateRetryDelay(2, config)).toBe(4000);
expect(calculateRetryDelay(3, config)).toBe(5000); // Capped
expect(calculateRetryDelay(4, config)).toBe(5000); // Capped
});
});
describe('isRetryableError', () => {
it('returns true for retryable LLMClientError', () => {
const error = new LLMClientError('Rate limit', 'RATE_LIMIT_ERROR', 'openai', 429, true);
expect(isRetryableError(error)).toBe(true);
});
it('returns false for non-retryable LLMClientError', () => {
const error = new LLMClientError('Auth error', 'AUTHENTICATION_ERROR', 'openai', 401, false);
expect(isRetryableError(error)).toBe(false);
});
it('returns true for fetch TypeError', () => {
const error = new TypeError('fetch failed');
expect(isRetryableError(error)).toBe(true);
});
it('returns false for other errors', () => {
const error = new Error('Some error');
expect(isRetryableError(error)).toBe(false);
});
});
describe('mapStatusToErrorCode', () => {
it('maps 401 to AUTHENTICATION_ERROR (non-retryable)', () => {
const result = mapStatusToErrorCode(401);
expect(result.code).toBe('AUTHENTICATION_ERROR');
expect(result.retryable).toBe(false);
});
it('maps 403 to AUTHENTICATION_ERROR (non-retryable)', () => {
const result = mapStatusToErrorCode(403);
expect(result.code).toBe('AUTHENTICATION_ERROR');
expect(result.retryable).toBe(false);
});
it('maps 429 to RATE_LIMIT_ERROR (retryable)', () => {
const result = mapStatusToErrorCode(429);
expect(result.code).toBe('RATE_LIMIT_ERROR');
expect(result.retryable).toBe(true);
});
it('maps 400 to INVALID_REQUEST (non-retryable)', () => {
const result = mapStatusToErrorCode(400);
expect(result.code).toBe('INVALID_REQUEST');
expect(result.retryable).toBe(false);
});
it('maps 500 to SERVER_ERROR (retryable)', () => {
const result = mapStatusToErrorCode(500);
expect(result.code).toBe('SERVER_ERROR');
expect(result.retryable).toBe(true);
});
it('maps 502 to SERVER_ERROR (retryable)', () => {
const result = mapStatusToErrorCode(502);
expect(result.code).toBe('SERVER_ERROR');
expect(result.retryable).toBe(true);
});
it('maps 503 to SERVER_ERROR (retryable)', () => {
const result = mapStatusToErrorCode(503);
expect(result.code).toBe('SERVER_ERROR');
expect(result.retryable).toBe(true);
});
it('maps unknown status to UNKNOWN_ERROR (non-retryable)', () => {
const result = mapStatusToErrorCode(418);
expect(result.code).toBe('UNKNOWN_ERROR');
expect(result.retryable).toBe(false);
});
});
describe('sleep', () => {
it('delays for the specified duration', async () => {
const start = Date.now();
await sleep(50);
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(45); // Allow some tolerance
});
});
});
// ============================================
// REQUEST BUILDING TESTS
// ============================================
describe('Request Building', () => {
const sampleRequest: LLMCompletionRequest = {
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello!' },
],
temperature: 0.8,
maxTokens: 1000,
};
describe('buildOpenRouterRequest', () => {
it('builds correct request body', () => {
const result = buildOpenRouterRequest(sampleRequest, 'openai/gpt-4');
expect(result.model).toBe('openai/gpt-4');
expect(result.messages).toEqual([
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello!' },
]);
expect(result.temperature).toBe(0.8);
expect(result.max_tokens).toBe(1000);
});
it('uses default values when not provided', () => {
const minimalRequest: LLMCompletionRequest = {
messages: [{ role: 'user', content: 'Hi' }],
};
const result = buildOpenRouterRequest(minimalRequest, 'openai/gpt-3.5-turbo');
expect(result.temperature).toBe(0.7);
expect(result.max_tokens).toBe(500);
});
});
describe('buildOpenAIRequest', () => {
it('builds correct request body', () => {
const result = buildOpenAIRequest(sampleRequest, 'gpt-4');
expect(result.model).toBe('gpt-4');
expect(result.messages).toEqual([
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello!' },
]);
expect(result.temperature).toBe(0.8);
expect(result.max_tokens).toBe(1000);
});
});
describe('buildAnthropicRequest', () => {
it('builds correct request body with system message separated', () => {
const result = buildAnthropicRequest(sampleRequest, 'claude-3-opus-20240229');
expect(result.model).toBe('claude-3-opus-20240229');
expect(result.system).toBe('You are a helpful assistant.');
expect(result.messages).toEqual([
{ role: 'user', content: 'Hello!' },
]);
expect(result.temperature).toBe(0.8);
expect(result.max_tokens).toBe(1000);
});
it('handles request without system message', () => {
const requestWithoutSystem: LLMCompletionRequest = {
messages: [
{ role: 'user', content: 'Hello!' },
{ role: 'assistant', content: 'Hi there!' },
{ role: 'user', content: 'How are you?' },
],
};
const result = buildAnthropicRequest(requestWithoutSystem, 'claude-3-haiku-20240307');
expect(result.system).toBeUndefined();
expect(result.messages).toHaveLength(3);
});
});
});
// ============================================
// RESPONSE PARSING TESTS
// ============================================
describe('Response Parsing', () => {
describe('parseOpenRouterResponse', () => {
it('parses successful response correctly', () => {
const apiResponse = {
id: 'chatcmpl-123',
model: 'openai/gpt-4',
choices: [
{
message: {
role: 'assistant',
content: 'Hello! How can I help you today?',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 8,
total_tokens: 18,
},
};
const result = parseOpenRouterResponse(apiResponse, 'openai/gpt-4');
expect(result.content).toBe('Hello! How can I help you today?');
expect(result.tokensUsed.prompt).toBe(10);
expect(result.tokensUsed.completion).toBe(8);
expect(result.tokensUsed.total).toBe(18);
expect(result.model).toBe('openai/gpt-4');
expect(result.provider).toBe('openrouter');
});
it('handles missing usage data', () => {
const apiResponse = {
choices: [
{
message: {
content: 'Response without usage',
},
},
],
};
const result = parseOpenRouterResponse(apiResponse, 'test-model');
expect(result.content).toBe('Response without usage');
expect(result.tokensUsed.prompt).toBe(0);
expect(result.tokensUsed.completion).toBe(0);
expect(result.tokensUsed.total).toBe(0);
});
it('handles empty choices', () => {
const apiResponse = {
choices: [],
usage: { prompt_tokens: 5, completion_tokens: 0, total_tokens: 5 },
};
const result = parseOpenRouterResponse(apiResponse, 'test-model');
expect(result.content).toBe('');
});
});
describe('parseOpenAIResponse', () => {
it('parses successful response correctly', () => {
const apiResponse = {
id: 'chatcmpl-456',
model: 'gpt-4-turbo',
choices: [
{
message: {
role: 'assistant',
content: 'I am GPT-4!',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 15,
completion_tokens: 5,
total_tokens: 20,
},
};
const result = parseOpenAIResponse(apiResponse, 'gpt-4');
expect(result.content).toBe('I am GPT-4!');
expect(result.tokensUsed.prompt).toBe(15);
expect(result.tokensUsed.completion).toBe(5);
expect(result.tokensUsed.total).toBe(20);
expect(result.model).toBe('gpt-4-turbo');
expect(result.provider).toBe('openai');
});
});
describe('parseAnthropicResponse', () => {
it('parses successful response correctly', () => {
const apiResponse = {
id: 'msg_123',
type: 'message',
model: 'claude-3-opus-20240229',
content: [
{
type: 'text',
text: 'Hello from Claude!',
},
],
usage: {
input_tokens: 12,
output_tokens: 4,
},
};
const result = parseAnthropicResponse(apiResponse, 'claude-3-opus-20240229');
expect(result.content).toBe('Hello from Claude!');
expect(result.tokensUsed.prompt).toBe(12);
expect(result.tokensUsed.completion).toBe(4);
expect(result.tokensUsed.total).toBe(16);
expect(result.model).toBe('claude-3-opus-20240229');
expect(result.provider).toBe('anthropic');
});
it('handles multiple content blocks', () => {
const apiResponse = {
model: 'claude-3-haiku-20240307',
content: [
{ type: 'text', text: 'First part. ' },
{ type: 'text', text: 'Second part.' },
],
usage: {
input_tokens: 10,
output_tokens: 6,
},
};
const result = parseAnthropicResponse(apiResponse, 'claude-3-haiku-20240307');
expect(result.content).toBe('First part. Second part.');
});
it('filters non-text content blocks', () => {
const apiResponse = {
model: 'claude-3-sonnet-20240229',
content: [
{ type: 'text', text: 'Text content' },
{ type: 'tool_use', id: 'tool_1', name: 'calculator' },
],
usage: {
input_tokens: 8,
output_tokens: 3,
},
};
const result = parseAnthropicResponse(apiResponse, 'claude-3-sonnet-20240229');
expect(result.content).toBe('Text content');
});
});
});
// ============================================
// HEADER BUILDING TESTS
// ============================================
describe('Header Building', () => {
describe('buildHeaders', () => {
it('builds correct headers for OpenRouter', () => {
const headers = buildHeaders('openrouter', 'sk-or-test-key');
expect(headers['Content-Type']).toBe('application/json');
expect(headers['Authorization']).toBe('Bearer sk-or-test-key');
expect(headers['HTTP-Referer']).toBe('https://synapsis.social');
expect(headers['X-Title']).toBe('Synapsis Bot');
});
it('builds correct headers for OpenAI', () => {
const headers = buildHeaders('openai', 'sk-test-key');
expect(headers['Content-Type']).toBe('application/json');
expect(headers['Authorization']).toBe('Bearer sk-test-key');
expect(headers['HTTP-Referer']).toBeUndefined();
});
it('builds correct headers for Anthropic', () => {
const headers = buildHeaders('anthropic', 'sk-ant-test-key');
expect(headers['Content-Type']).toBe('application/json');
expect(headers['x-api-key']).toBe('sk-ant-test-key');
expect(headers['anthropic-version']).toBe('2023-06-01');
expect(headers['Authorization']).toBeUndefined();
});
});
});
// ============================================
// VALIDATION TESTS
// ============================================
describe('Configuration Validation', () => {
describe('validateLLMConfig', () => {
it('validates correct configuration', () => {
const config: LLMConfig = {
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
};
const result = validateLLMConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('validates configuration without model', () => {
const config = {
provider: 'anthropic',
apiKey: 'sk-ant-test-key-1234567890',
};
const result = validateLLMConfig(config);
expect(result.valid).toBe(true);
});
it('rejects invalid provider', () => {
const config = {
provider: 'invalid-provider',
apiKey: 'test-key',
};
const result = validateLLMConfig(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Provider must be one of: openrouter, openai, anthropic');
});
it('rejects missing API key', () => {
const config = {
provider: 'openai',
};
const result = validateLLMConfig(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain('API key is required and must be a string');
});
it('rejects non-string model', () => {
const config = {
provider: 'openai',
apiKey: 'test-key',
model: 123,
};
const result = validateLLMConfig(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Model must be a string');
});
it('rejects non-object configuration', () => {
const result = validateLLMConfig('not an object');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Configuration must be an object');
});
it('rejects null configuration', () => {
const result = validateLLMConfig(null);
expect(result.valid).toBe(false);
});
});
});
// ============================================
// LLM CLIENT TESTS
// ============================================
describe('LLMClient', () => {
describe('constructor', () => {
it('creates client with correct provider', () => {
const client = new LLMClient({
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
});
expect(client.getProvider()).toBe('openai');
expect(client.getModel()).toBe('gpt-4');
});
it('uses default model when not provided', () => {
const client = new LLMClient({
provider: 'anthropic',
apiKey: 'sk-ant-test-key-1234567890',
model: '',
});
expect(client.getModel()).toBe('claude-3-haiku-20240307');
});
it('creates client for each supported provider', () => {
const providers: LLMProvider[] = ['openrouter', 'openai', 'anthropic'];
for (const provider of providers) {
const client = new LLMClient({
provider,
apiKey: 'test-key-12345678901234567890',
model: 'test-model',
});
expect(client.getProvider()).toBe(provider);
}
});
});
describe('createLLMClient factory', () => {
it('creates client with default retry config', () => {
const client = createLLMClient({
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
});
expect(client).toBeInstanceOf(LLMClient);
expect(client.getProvider()).toBe('openai');
});
it('creates client with custom retry config', () => {
const customRetryConfig = {
maxRetries: 5,
initialDelayMs: 500,
maxDelayMs: 5000,
backoffMultiplier: 1.5,
};
const client = createLLMClient(
{
provider: 'anthropic',
apiKey: 'sk-ant-test-key-1234567890',
model: 'claude-3-opus-20240229',
},
customRetryConfig
);
expect(client).toBeInstanceOf(LLMClient);
});
});
});
// ============================================
// LLM CLIENT ERROR TESTS
// ============================================
describe('LLMClientError', () => {
it('creates error with all properties', () => {
const error = new LLMClientError(
'Rate limit exceeded',
'RATE_LIMIT_ERROR',
'openai',
429,
true
);
expect(error.message).toBe('Rate limit exceeded');
expect(error.code).toBe('RATE_LIMIT_ERROR');
expect(error.provider).toBe('openai');
expect(error.statusCode).toBe(429);
expect(error.retryable).toBe(true);
expect(error.name).toBe('LLMClientError');
});
it('creates error with default retryable false', () => {
const error = new LLMClientError(
'Auth failed',
'AUTHENTICATION_ERROR',
'anthropic',
401
);
expect(error.retryable).toBe(false);
});
it('is instanceof Error', () => {
const error = new LLMClientError(
'Test error',
'UNKNOWN_ERROR',
'openrouter'
);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(LLMClientError);
});
});
// ============================================
// INTEGRATION TESTS WITH MOCKED FETCH
// ============================================
describe('LLMClient Integration (Mocked)', () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
global.fetch = originalFetch;
vi.useRealTimers();
});
describe('generateCompletion', () => {
it('makes successful request to OpenAI', async () => {
const mockResponse = {
id: 'chatcmpl-test',
model: 'gpt-4',
choices: [
{
message: {
role: 'assistant',
content: 'Hello from OpenAI!',
},
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const client = new LLMClient({
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
});
const result = await client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
});
expect(result.content).toBe('Hello from OpenAI!');
expect(result.provider).toBe('openai');
expect(result.tokensUsed.total).toBe(15);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.openai.com/v1/chat/completions',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer sk-test-key-12345678901234567890',
}),
})
);
});
it('makes successful request to Anthropic', async () => {
const mockResponse = {
id: 'msg_test',
model: 'claude-3-haiku-20240307',
content: [
{
type: 'text',
text: 'Hello from Claude!',
},
],
usage: {
input_tokens: 8,
output_tokens: 4,
},
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const client = new LLMClient({
provider: 'anthropic',
apiKey: 'sk-ant-test-key-1234567890',
model: 'claude-3-haiku-20240307',
});
const result = await client.generateCompletion({
messages: [
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Hi!' },
],
});
expect(result.content).toBe('Hello from Claude!');
expect(result.provider).toBe('anthropic');
// Verify Anthropic-specific headers
expect(global.fetch).toHaveBeenCalledWith(
'https://api.anthropic.com/v1/messages',
expect.objectContaining({
headers: expect.objectContaining({
'x-api-key': 'sk-ant-test-key-1234567890',
'anthropic-version': '2023-06-01',
}),
})
);
});
it('makes successful request to OpenRouter', async () => {
const mockResponse = {
id: 'gen-test',
model: 'openai/gpt-4',
choices: [
{
message: {
content: 'Hello from OpenRouter!',
},
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 6,
total_tokens: 18,
},
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const client = new LLMClient({
provider: 'openrouter',
apiKey: 'sk-or-test-key-1234567890',
model: 'openai/gpt-4',
});
const result = await client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
});
expect(result.content).toBe('Hello from OpenRouter!');
expect(result.provider).toBe('openrouter');
// Verify OpenRouter-specific headers
expect(global.fetch).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/chat/completions',
expect.objectContaining({
headers: expect.objectContaining({
'HTTP-Referer': 'https://synapsis.social',
'X-Title': 'Synapsis Bot',
}),
})
);
});
});
});
// ============================================
// RETRY LOGIC TESTS
// ============================================
describe('Retry Logic', () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
global.fetch = originalFetch;
vi.useRealTimers();
});
it('retries on server error (500) up to 3 times', async () => {
let callCount = 0;
global.fetch = vi.fn().mockImplementation(() => {
callCount++;
return Promise.resolve({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Internal server error' }),
});
});
const client = new LLMClient(
{
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
},
{
maxRetries: 3,
initialDelayMs: 10, // Short delay for testing
maxDelayMs: 100,
backoffMultiplier: 2,
}
);
await expect(
client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
})
).rejects.toThrow(LLMClientError);
// Initial attempt + 3 retries = 4 total calls
expect(callCount).toBe(4);
});
it('retries on rate limit error (429)', async () => {
let callCount = 0;
global.fetch = vi.fn().mockImplementation(() => {
callCount++;
if (callCount < 3) {
return Promise.resolve({
ok: false,
status: 429,
json: () => Promise.resolve({ error: 'Rate limit exceeded' }),
});
}
// Succeed on third attempt
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'Success!' } }],
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
}),
});
});
const client = new LLMClient(
{
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
},
{
maxRetries: 3,
initialDelayMs: 10,
maxDelayMs: 100,
backoffMultiplier: 2,
}
);
const result = await client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
});
expect(result.content).toBe('Success!');
expect(callCount).toBe(3);
});
it('does not retry on authentication error (401)', async () => {
let callCount = 0;
global.fetch = vi.fn().mockImplementation(() => {
callCount++;
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ error: 'Invalid API key' }),
});
});
const client = new LLMClient(
{
provider: 'openai',
apiKey: 'sk-invalid-key-12345678901234567890',
model: 'gpt-4',
},
{
maxRetries: 3,
initialDelayMs: 10,
maxDelayMs: 100,
backoffMultiplier: 2,
}
);
await expect(
client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
})
).rejects.toThrow(LLMClientError);
// Should not retry - only 1 call
expect(callCount).toBe(1);
});
it('does not retry on invalid request (400)', async () => {
let callCount = 0;
global.fetch = vi.fn().mockImplementation(() => {
callCount++;
return Promise.resolve({
ok: false,
status: 400,
json: () => Promise.resolve({ error: 'Invalid request' }),
});
});
const client = new LLMClient(
{
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
},
{
maxRetries: 3,
initialDelayMs: 10,
maxDelayMs: 100,
backoffMultiplier: 2,
}
);
await expect(
client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
})
).rejects.toThrow(LLMClientError);
expect(callCount).toBe(1);
});
it('retries on network error', async () => {
let callCount = 0;
global.fetch = vi.fn().mockImplementation(() => {
callCount++;
if (callCount < 3) {
return Promise.reject(new TypeError('fetch failed'));
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'Success after network error!' } }],
usage: { prompt_tokens: 5, completion_tokens: 5, total_tokens: 10 },
}),
});
});
const client = new LLMClient(
{
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
},
{
maxRetries: 3,
initialDelayMs: 10,
maxDelayMs: 100,
backoffMultiplier: 2,
}
);
const result = await client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
});
expect(result.content).toBe('Success after network error!');
expect(callCount).toBe(3);
});
});
// ============================================
// ERROR HANDLING TESTS
// ============================================
describe('Error Handling', () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
global.fetch = originalFetch;
vi.useRealTimers();
});
it('throws LLMClientError with correct code for auth error', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ error: 'Unauthorized' }),
});
const client = new LLMClient({
provider: 'openai',
apiKey: 'sk-invalid-key-12345678901234567890',
model: 'gpt-4',
});
try {
await client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(LLMClientError);
const llmError = error as LLMClientError;
expect(llmError.code).toBe('AUTHENTICATION_ERROR');
expect(llmError.statusCode).toBe(401);
expect(llmError.retryable).toBe(false);
}
});
it('throws LLMClientError with correct code for rate limit', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
json: () => Promise.resolve({ error: 'Rate limit exceeded' }),
});
const client = new LLMClient(
{
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
},
{
maxRetries: 0, // No retries for this test
initialDelayMs: 10,
maxDelayMs: 100,
backoffMultiplier: 2,
}
);
try {
await client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(LLMClientError);
const llmError = error as LLMClientError;
expect(llmError.code).toBe('RATE_LIMIT_ERROR');
expect(llmError.statusCode).toBe(429);
expect(llmError.retryable).toBe(true);
}
});
it('throws LLMClientError for network errors', async () => {
global.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'));
const client = new LLMClient(
{
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
},
{
maxRetries: 0,
initialDelayMs: 10,
maxDelayMs: 100,
backoffMultiplier: 2,
}
);
try {
await client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(LLMClientError);
const llmError = error as LLMClientError;
expect(llmError.code).toBe('NETWORK_ERROR');
expect(llmError.retryable).toBe(true);
}
});
it('handles timeout correctly', async () => {
// Use real timers for this test since AbortController needs real timing
vi.useRealTimers();
// Create a fetch that takes longer than the timeout
global.fetch = vi.fn().mockImplementation((_url, options) => {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve({
ok: true,
json: () => Promise.resolve({}),
});
}, 5000); // 5 seconds - longer than timeout
// Listen for abort signal
if (options?.signal) {
options.signal.addEventListener('abort', () => {
clearTimeout(timeoutId);
const error = new Error('The operation was aborted');
error.name = 'AbortError';
reject(error);
});
}
});
});
const client = new LLMClient(
{
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
},
{
maxRetries: 0,
initialDelayMs: 10,
maxDelayMs: 100,
backoffMultiplier: 2,
},
50 // 50ms timeout
);
try {
await client.generateCompletion({
messages: [{ role: 'user', content: 'Hello!' }],
});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(LLMClientError);
const llmError = error as LLMClientError;
expect(llmError.code).toBe('TIMEOUT_ERROR');
expect(llmError.retryable).toBe(true);
}
// Restore fake timers for other tests
vi.useFakeTimers({ shouldAdvanceTime: true });
});
it('detects content policy violation', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({
error: 'Content flagged by safety system'
}),
});
const client = new LLMClient({
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4',
});
try {
await client.generateCompletion({
messages: [{ role: 'user', content: 'Inappropriate content' }],
});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(LLMClientError);
const llmError = error as LLMClientError;
expect(llmError.code).toBe('CONTENT_POLICY_VIOLATION');
}
});
});
// ============================================
// PROVIDER SUPPORT TESTS
// ============================================
describe('Provider Support (Requirement 2.6)', () => {
/**
* Validates: Requirements 2.6
* THE Bot_Manager SHALL support multiple LLM provider types (OpenRouter, OpenAI, Anthropic)
*/
it('supports OpenRouter provider', () => {
const client = createLLMClient({
provider: 'openrouter',
apiKey: 'sk-or-test-key-1234567890',
model: 'openai/gpt-4',
});
expect(client.getProvider()).toBe('openrouter');
expect(client.getModel()).toBe('openai/gpt-4');
});
it('supports OpenAI provider', () => {
const client = createLLMClient({
provider: 'openai',
apiKey: 'sk-test-key-12345678901234567890',
model: 'gpt-4-turbo',
});
expect(client.getProvider()).toBe('openai');
expect(client.getModel()).toBe('gpt-4-turbo');
});
it('supports Anthropic provider', () => {
const client = createLLMClient({
provider: 'anthropic',
apiKey: 'sk-ant-test-key-1234567890',
model: 'claude-3-opus-20240229',
});
expect(client.getProvider()).toBe('anthropic');
expect(client.getModel()).toBe('claude-3-opus-20240229');
});
it('all three providers have correct endpoints', () => {
expect(PROVIDER_ENDPOINTS.openrouter).toBe('https://openrouter.ai/api/v1/chat/completions');
expect(PROVIDER_ENDPOINTS.openai).toBe('https://api.openai.com/v1/chat/completions');
expect(PROVIDER_ENDPOINTS.anthropic).toBe('https://api.anthropic.com/v1/messages');
});
});
// ============================================
// RETRY LOGIC TESTS (Requirement 11.4)
// ============================================
describe('LLM Retry Logic (Requirement 11.4)', () => {
/**
* Validates: Requirements 11.4
* WHEN LLM generation fails, THE Bot_Manager SHALL log the error and retry up to 3 times
*/
it('default retry config has 3 retries', () => {
expect(DEFAULT_RETRY_CONFIG.maxRetries).toBe(3);
});
it('retry config uses exponential backoff', () => {
expect(DEFAULT_RETRY_CONFIG.backoffMultiplier).toBeGreaterThan(1);
expect(DEFAULT_RETRY_CONFIG.initialDelayMs).toBeGreaterThan(0);
expect(DEFAULT_RETRY_CONFIG.maxDelayMs).toBeGreaterThan(DEFAULT_RETRY_CONFIG.initialDelayMs);
});
});