UNPKG

llmpool

Version:

Production-ready LLM API pool manager with load balancing, failover, and dynamic configuration

825 lines (665 loc) 26.5 kB
const { LLMPool, ConfigManager, Provider, ProviderError, ConfigurationError, RateLimitError, PROVIDERS, createTextMessage, createImageMessage } = require('../src/llm-pool'); const fs = require('fs').promises; const path = require('path'); // Mock axios for testing jest.mock('axios'); const axios = require('axios'); describe('LLM Pool Manager', () => { let testConfigPath; let validConfig; beforeAll(async () => { testConfigPath = path.join(__dirname, 'test-config.json'); validConfig = { providers: [ { name: 'test-openai', type: 'openai', api_key: 'test-key', base_url: 'https://api.openai.com/v1', model: 'gpt-3.5-turbo', priority: 1, requests_per_minute: 100, requests_per_day: 1000 }, { name: 'test-anthropic', type: 'anthropic', api_key: 'test-key-2', base_url: 'https://api.anthropic.com/v1', model: 'claude-3-sonnet-20240229', priority: 2, requests_per_minute: 50, requests_per_day: 500 } ] }; await fs.writeFile(testConfigPath, JSON.stringify(validConfig)); }); afterAll(async () => { try { await fs.unlink(testConfigPath); } catch (error) { // Ignore if file doesn't exist } }); describe('Provider Class', () => { test('should create valid provider', () => { const config = validConfig.providers[0]; const provider = new Provider(config); expect(provider.name).toBe(config.name); expect(provider.type).toBe(config.type); expect(provider.model).toBe(config.model); expect(provider.canUse()).toBe(true); }); test('should validate required fields', () => { const invalidConfig = { name: 'test' }; // Missing required fields expect(() => new Provider(invalidConfig)).toThrow(ConfigurationError); }); test('should handle rate limiting', () => { const config = { ...validConfig.providers[0], requests_per_minute: 1 }; const provider = new Provider(config); expect(provider.canUse()).toBe(true); // Simulate request provider.recordRequest(true, 100, 10, 0.001); expect(provider.canUse()).toBe(false); // Reset rate limit (simulate time passing) provider.lastReset = new Date(Date.now() - 61000); // 61 seconds ago expect(provider.canUse()).toBe(true); }); test('should handle circuit breaker', () => { const config = { ...validConfig.providers[0], circuit_breaker_threshold: 2 }; const provider = new Provider(config); // Record failures provider.recordRequest(false, 100); provider.recordRequest(false, 100); expect(provider.isCircuitBreakerOpen).toBe(true); expect(provider.canUse()).toBe(false); }); test('should calculate success rate', () => { const provider = new Provider(validConfig.providers[0]); provider.recordRequest(true, 100); provider.recordRequest(true, 100); provider.recordRequest(false, 100); expect(provider.getSuccessRate()).toBe(66.66666666666666); }); test('should track response times', () => { const provider = new Provider(validConfig.providers[0]); provider.recordRequest(true, 100); provider.recordRequest(true, 200); provider.recordRequest(true, 300); expect(provider.getAverageResponseTime()).toBe(200); }); }); describe('ConfigManager Class', () => { test('should load local configuration', async () => { const configManager = new ConfigManager({ configPath: testConfigPath }); const config = await configManager.loadConfig(); expect(config).toEqual(validConfig); }); test('should validate configuration', async () => { const invalidConfigPath = path.join(__dirname, 'invalid-config.json'); const invalidConfig = { providers: [{ name: 'invalid' }] }; await fs.writeFile(invalidConfigPath, JSON.stringify(invalidConfig)); const configManager = new ConfigManager({ configPath: invalidConfigPath }); await expect(configManager.loadConfig()).rejects.toThrow(ConfigurationError); await fs.unlink(invalidConfigPath); }); test('should detect configuration changes', async () => { const configManager = new ConfigManager({ configPath: testConfigPath }); // Load initial config await configManager.loadConfig(); const initialChecksum = configManager.currentChecksum; // Modify config const modifiedConfig = { ...validConfig, providers: [...validConfig.providers, { name: 'new-provider', type: 'groq', api_key: 'new-key', base_url: 'https://api.groq.com/openai/v1', model: 'mixtral-8x7b-32768', priority: 3, requests_per_minute: 30 }] }; await fs.writeFile(testConfigPath, JSON.stringify(modifiedConfig)); // Load modified config await configManager.loadConfig(); expect(configManager.currentChecksum).not.toBe(initialChecksum); // Restore original config await fs.writeFile(testConfigPath, JSON.stringify(validConfig)); }); test('should handle remote configuration', async () => { const mockResponse = { data: validConfig }; axios.get.mockResolvedValue(mockResponse); const configManager = new ConfigManager({ configUrl: 'https://example.com/config.json' }); const config = await configManager.loadConfig(); expect(config).toEqual(validConfig); expect(axios.get).toHaveBeenCalledWith( 'https://example.com/config.json', expect.any(Object) ); }); test('should handle remote configuration errors', async () => { axios.get.mockRejectedValue(new Error('Network error')); const configManager = new ConfigManager({ configUrl: 'https://example.com/invalid-config.json' }); await expect(configManager.loadConfig()).rejects.toThrow(ConfigurationError); }); }); describe('LLMPool Class', () => { let pool; beforeEach(async () => { pool = new LLMPool({ configPath: testConfigPath }); await pool.initialize(); }); afterEach(async () => { await pool.shutdown(); }); test('should initialize with providers', async () => { expect(pool.providers.size).toBe(2); expect(pool.providers.has('test-openai')).toBe(true); expect(pool.providers.has('test-anthropic')).toBe(true); }); test('should select provider by priority', () => { const provider = pool.selectProvider(); expect(provider.name).toBe('test-openai'); // Priority 1 }); test('should exclude rate-limited providers', () => { const provider1 = pool.providers.get('test-openai'); provider1.requestCount = provider1.requestsPerMinute; // Max out requests const provider = pool.selectProvider(); expect(provider.name).toBe('test-anthropic'); }); test('should format requests for different providers', () => { const request = { messages: [createTextMessage('user', 'Hello')], temperature: 0.7, max_tokens: 100 }; const openaiProvider = pool.providers.get('test-openai'); const openaiRequest = pool.formatRequestForProvider(openaiProvider, request); expect(openaiRequest).toHaveProperty('messages'); expect(openaiRequest).toHaveProperty('model', 'gpt-3.5-turbo'); const anthropicProvider = pool.providers.get('test-anthropic'); const anthropicRequest = pool.formatRequestForProvider(anthropicProvider, request); expect(anthropicRequest).toHaveProperty('messages'); expect(anthropicRequest).toHaveProperty('model', 'claude-3-sonnet-20240229'); }); test('should handle successful requests', async () => { const mockResponse = { data: { id: 'test-id', choices: [{ message: { content: 'Hello world' } }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, model: 'gpt-3.5-turbo' } }; axios.post.mockResolvedValue({ status: 200, ...mockResponse }); const response = await pool.chat({ messages: [createTextMessage('user', 'Hello')] }); expect(response.content).toBe('Hello world'); expect(response.provider).toBe('test-openai'); expect(response.usage.total_tokens).toBe(12); }); test('should handle provider failures and retry', async () => { let callCount = 0; axios.post.mockImplementation(() => { callCount++; if (callCount === 1) { return Promise.reject(new Error('Network error')); } return Promise.resolve({ status: 200, data: { id: 'test-id', choices: [{ message: { content: 'Success on retry' } }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } } }); }); const response = await pool.chat({ messages: [createTextMessage('user', 'Hello')] }); expect(response.content).toBe('Success on retry'); expect(callCount).toBe(2); // First call failed, second succeeded }); test('should handle rate limit errors', async () => { axios.post.mockResolvedValue({ status: 429, headers: { 'retry-after': '60' }, data: { error: { message: 'Rate limit exceeded' } } }); await expect(pool.chat({ messages: [createTextMessage('user', 'Hello')] })).rejects.toThrow(RateLimitError); }); test('should get provider statistics', () => { const stats = pool.getProviderStats(); expect(stats).toHaveProperty('test-openai'); expect(stats).toHaveProperty('test-anthropic'); expect(stats['test-openai']).toHaveProperty('health'); expect(stats['test-openai']).toHaveProperty('usage'); expect(stats['test-openai']).toHaveProperty('performance'); }); test('should get pool health', () => { const health = pool.getPoolHealth(); expect(health).toHaveProperty('totalProviders', 2); expect(health).toHaveProperty('availableProviders'); expect(health).toHaveProperty('healthy'); expect(health).toHaveProperty('providers'); }); }); describe('Message Helpers', () => { test('should create text message', () => { const message = createTextMessage('user', 'Hello world'); expect(message).toEqual({ role: 'user', content: 'Hello world' }); }); test('should create image message', () => { const message = createImageMessage('user', 'What is this?', 'data:image/jpeg;base64,abc123'); expect(message).toEqual({ role: 'user', content: [ { type: 'text', text: 'What is this?' }, { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,abc123' } } ] }); }); }); describe('Error Handling', () => { test('should create ProviderError with correct properties', () => { const error = new ProviderError('Test error', 'test-provider', 500, true); expect(error.name).toBe('ProviderError'); expect(error.message).toBe('Test error'); expect(error.provider).toBe('test-provider'); expect(error.statusCode).toBe(500); expect(error.retryable).toBe(true); }); test('should create RateLimitError with reset time', () => { const error = new RateLimitError('Rate limited', 'test-provider', 60); expect(error.name).toBe('RateLimitError'); expect(error.provider).toBe('test-provider'); expect(error.resetTime).toBe(60); }); test('should create ConfigurationError', () => { const error = new ConfigurationError('Invalid config'); expect(error.name).toBe('ConfigurationError'); expect(error.message).toBe('Invalid config'); }); }); describe('Token Estimation', () => { let pool; beforeEach(async () => { pool = new LLMPool({ configPath: testConfigPath, useTokenCounting: true }); await pool.initialize(); }); afterEach(async () => { await pool.shutdown(); }); test('should estimate tokens for text messages', () => { const messages = [ createTextMessage('user', 'Hello world'), createTextMessage('assistant', 'Hi there!') ]; const tokens = pool.estimateTokens(messages); expect(tokens).toBeGreaterThan(0); }); test('should handle mixed content messages', () => { const messages = [ createImageMessage('user', 'What is this?', 'data:image/jpeg;base64,abc123') ]; const tokens = pool.estimateTokens(messages); expect(tokens).toBeGreaterThan(0); }); }); describe('Cost Calculation', () => { test('should calculate request cost', () => { const provider = new Provider({ ...validConfig.providers[0], input_token_price: 0.5, // $0.5 per 1M tokens output_token_price: 1.5 // $1.5 per 1M tokens }); const pool = new LLMPool({ configPath: testConfigPath }); const usage = { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }; const cost = pool.calculateCost(provider, usage); expect(cost).toBe((100 * 0.5 + 50 * 1.5) / 1000000); }); }); describe('Integration Tests', () => { test('should handle concurrent requests', async () => { const pool = new LLMPool({ configPath: testConfigPath }); await pool.initialize(); // Mock successful responses axios.post.mockResolvedValue({ status: 200, data: { id: 'test-id', choices: [{ message: { content: 'Concurrent response' } }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } } }); const promises = Array(10).fill(0).map((_, i) => pool.chat({ messages: [createTextMessage('user', `Request ${i}`)] }) ); const responses = await Promise.allSettled(promises); const successful = responses.filter(r => r.status === 'fulfilled').length; expect(successful).toBeGreaterThan(0); await pool.shutdown(); }); }); }); // Test utilities class TestUtils { static createMockProvider(name, type = 'openai', overrides = {}) { return { name, type, api_key: 'test-key', base_url: 'https://api.test.com/v1', model: 'test-model', priority: 1, requests_per_minute: 100, requests_per_day: 1000, ...overrides }; } static createMockResponse(content = 'Test response', provider = 'openai') { const responses = { openai: { status: 200, data: { id: 'test-id', choices: [{ message: { content } }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, model: 'gpt-3.5-turbo' } }, anthropic: { status: 200, data: { id: 'test-id', content: [{ text: content }], usage: { input_tokens: 10, output_tokens: 2 }, model: 'claude-3-sonnet-20240229' } } }; return responses[provider] || responses.openai; } static async createTestConfig(providers = null) { const config = { providers: providers || [ TestUtils.createMockProvider('test-provider-1'), TestUtils.createMockProvider('test-provider-2', 'anthropic', { priority: 2 }) ] }; const configPath = path.join(__dirname, `test-config-${Date.now()}.json`); await fs.writeFile(configPath, JSON.stringify(config)); return { config, configPath }; } static async cleanupTestConfig(configPath) { try { await fs.unlink(configPath); } catch (error) { // Ignore if file doesn't exist } } static mockAxiosResponse(responses) { let callCount = 0; axios.post.mockImplementation(() => { const response = Array.isArray(responses) ? responses[callCount++] : responses; if (response instanceof Error) { return Promise.reject(response); } return Promise.resolve(response); }); } } // Performance Tests describe('Performance Tests', () => { let pool; let configPath; beforeEach(async () => { const { config, configPath: path } = await TestUtils.createTestConfig([ TestUtils.createMockProvider('fast-provider', 'openai', { priority: 1 }), TestUtils.createMockProvider('slow-provider', 'anthropic', { priority: 2 }), TestUtils.createMockProvider('backup-provider', 'groq', { priority: 3 }) ]); configPath = path; pool = new LLMPool({ configPath }); await pool.initialize(); }); afterEach(async () => { await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); test('should handle high throughput requests', async () => { TestUtils.mockAxiosResponse(TestUtils.createMockResponse('Fast response')); const startTime = Date.now(); const promises = Array(100).fill(0).map((_, i) => pool.chat({ messages: [createTextMessage('user', `High throughput test ${i}`)] }).catch(() => null) // Don't fail the test on individual request failures ); const results = await Promise.allSettled(promises); const duration = Date.now() - startTime; const successCount = results.filter(r => r.status === 'fulfilled' && r.value !== null).length; console.log(`Processed ${successCount}/100 requests in ${duration}ms`); expect(successCount).toBeGreaterThan(50); // At least 50% success rate expect(duration).toBeLessThan(30000); // Complete within 30 seconds }); test('should maintain performance under provider failures', async () => { // First provider fails, others succeed let callCount = 0; axios.post.mockImplementation(() => { callCount++; if (callCount <= 10) { return Promise.reject(new Error('Provider temporarily down')); } return Promise.resolve(TestUtils.createMockResponse('Backup success')); }); const promises = Array(20).fill(0).map((_, i) => pool.chat({ messages: [createTextMessage('user', `Failover test ${i}`)] }).catch(() => null) ); const results = await Promise.allSettled(promises); const successCount = results.filter(r => r.status === 'fulfilled' && r.value !== null).length; expect(successCount).toBeGreaterThan(5); // Some requests should succeed via fallback }); }); // Security Tests describe('Security Tests', () => { test('should sanitize API keys in provider stats', async () => { const pool = new LLMPool(); const provider = new Provider({ name: 'security-test', type: 'openai', api_key: 'very-secret-key-12345', base_url: 'https://api.openai.com/v1', model: 'gpt-3.5-turbo' }); pool.providers.set('security-test', provider); const publicProviders = pool.GetProviders ? pool.GetProviders() : Array.from(pool.providers.values()); // API keys should be hidden in any public interface expect(JSON.stringify(publicProviders)).not.toContain('very-secret-key-12345'); }); test('should validate request inputs', async () => { const { configPath } = await TestUtils.createTestConfig(); const pool = new LLMPool({ configPath }); await pool.initialize(); // Test with invalid messages await expect(pool.chat({ messages: null })).rejects.toThrow(); await expect(pool.chat({ messages: 'invalid' })).rejects.toThrow(); await expect(pool.chat({})).rejects.toThrow(); await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); test('should handle malformed configuration gracefully', async () => { const malformedConfig = { providers: [ { name: 'malformed', type: 'unknown-provider', // Invalid provider type api_key: 'test', base_url: 'invalid-url', model: 'test' } ] }; const configPath = path.join(__dirname, 'malformed-config.json'); await fs.writeFile(configPath, JSON.stringify(malformedConfig)); const configManager = new ConfigManager({ configPath }); await expect(configManager.loadConfig()).rejects.toThrow(ConfigurationError); await TestUtils.cleanupTestConfig(configPath); }); }); // Edge Cases describe('Edge Cases', () => { test('should handle empty provider list', async () => { const emptyConfig = { providers: [] }; const configPath = path.join(__dirname, 'empty-config.json'); await fs.writeFile(configPath, JSON.stringify(emptyConfig)); const pool = new LLMPool({ configPath }); await pool.initialize(); await expect(pool.chat({ messages: [createTextMessage('user', 'Hello')] })).rejects.toThrow(); await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); test('should handle all providers rate-limited', async () => { const { configPath } = await TestUtils.createTestConfig([ TestUtils.createMockProvider('limited-1', 'openai', { requests_per_minute: 0 }), TestUtils.createMockProvider('limited-2', 'anthropic', { requests_per_minute: 0 }) ]); const pool = new LLMPool({ configPath }); await pool.initialize(); // All providers should be rate-limited const health = pool.getPoolHealth(); expect(health.availableProviders).toBe(0); await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); test('should handle network timeouts', async () => { const { configPath } = await TestUtils.createTestConfig(); const pool = new LLMPool({ configPath, timeout: 100 // Very short timeout }); await pool.initialize(); // Mock slow response axios.post.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)) ); await expect(pool.chat({ messages: [createTextMessage('user', 'Hello')] })).rejects.toThrow(); await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); test('should handle very large messages', async () => { const { configPath } = await TestUtils.createTestConfig(); const pool = new LLMPool({ configPath }); await pool.initialize(); TestUtils.mockAxiosResponse(TestUtils.createMockResponse('Response to large message')); const largeMessage = 'x'.repeat(100000); // 100K characters const response = await pool.chat({ messages: [createTextMessage('user', largeMessage)] }); expect(response.content).toBe('Response to large message'); await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); test('should handle special characters in messages', async () => { const { configPath } = await TestUtils.createTestConfig(); const pool = new LLMPool({ configPath }); await pool.initialize(); TestUtils.mockAxiosResponse(TestUtils.createMockResponse('Handled special chars')); const specialMessage = '🚀 Hello! @#$%^&*()_+ 中文 العربية 🎉'; const response = await pool.chat({ messages: [createTextMessage('user', specialMessage)] }); expect(response.content).toBe('Handled special chars'); await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); }); // Real-world Scenarios describe('Real-world Scenarios', () => { test('should handle provider maintenance windows', async () => { const { configPath } = await TestUtils.createTestConfig([ TestUtils.createMockProvider('primary', 'openai', { priority: 1 }), TestUtils.createMockProvider('backup', 'anthropic', { priority: 2 }) ]); const pool = new LLMPool({ configPath, maxRetries: 3 }); await pool.initialize(); // Simulate primary provider down for maintenance let callCount = 0; axios.post.mockImplementation((url) => { callCount++; if (url.includes('openai')) { return Promise.reject(new Error('Service temporarily unavailable')); } return Promise.resolve(TestUtils.createMockResponse('Backup response', 'anthropic')); }); const response = await pool.chat({ messages: [createTextMessage('user', 'Hello during maintenance')] }); expect(response.content).toBe('Backup response'); expect(response.provider).toBe('backup'); await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); test('should recover from temporary network issues', async () => { const { configPath } = await TestUtils.createTestConfig(); const pool = new LLMPool({ configPath, maxRetries: 3, retryDelay: 100 }); await pool.initialize(); // First two attempts fail, third succeeds let attemptCount = 0; axios.post.mockImplementation(() => { attemptCount++; if (attemptCount <= 2) { return Promise.reject(new Error('ECONNRESET')); } return Promise.resolve(TestUtils.createMockResponse('Recovered successfully')); }); const response = await pool.chat({ messages: [createTextMessage('user', 'Network recovery test')] }); expect(response.content).toBe('Recovered successfully'); expect(attemptCount).toBe(3); await pool.shutdown(); await TestUtils.cleanupTestConfig(configPath); }); }); // Export test utilities for external use module.exports = { TestUtils };