import { describe, it, expect, jest, beforeEach } from '@jest/globals'; // Mock OpenAI BEFORE importing the provider const mockCreate = jest.fn(); jest.mock('openai', () => { const MockOpenAI = jest.fn().mockImplementation(() => ({ chat: { completions: { create: mockCreate, }, }, })); return { __esModule: true, default: MockOpenAI, }; }); // Mock config manager and logger jest.mock('../src/config/config'); jest.mock('../src/utils/logger'); // NOW import the modules after setting up mocks import { DuckProvider } from '../src/providers/provider'; import { ProviderManager } from '../src/providers/manager'; import { ConfigManager } from '../src/config/config'; import { CLIDuckProvider } from '../src/providers/cli/cli-provider'; describe('DuckProvider', () => { let provider: DuckProvider; beforeEach(() => { jest.clearAllMocks(); // Setup mock response mockCreate.mockResolvedValue({ choices: [{ message: { content: 'Mocked response' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, }, model: 'mock-model', }); provider = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model', temperature: 0.7, }); // Override the method on the actual instance since Jest ESM mocking isn't working provider['client'].chat.completions.create = mockCreate; }); it('should create a provider instance', () => { expect(provider).toBeDefined(); expect(provider.name).toBe('test'); expect(provider.nickname).toBe('Test Duck'); }); it('should get provider info', () => { const info = provider.getInfo(); expect(info).toEqual({ name: 'test', nickname: 'Test Duck', model: 'test-model', type: 'http', availableModels: undefined, baseURL: 'https://api.test.com/v1', hasApiKey: true, }); }); it('should send chat request', async () => { const response = await provider.chat({ messages: [ { role: 'user', content: 'Hello', timestamp: new Date() }, ], }); expect(response).toBeDefined(); expect(response.content).toBe('Mocked response'); expect(response.usage).toBeDefined(); expect(response.model).toBe('test-model'); // Verify the mock was called with correct parameters expect(mockCreate).toHaveBeenCalledTimes(1); const callArgs = (mockCreate as any).mock.calls[0][0]; expect(callArgs.model).toBe('test-model'); expect(callArgs.messages).toEqual( expect.arrayContaining([ expect.objectContaining({ role: 'user', content: 'Hello' }), ]) ); }); it('should use correct parameters for o1 models', async () => { mockCreate.mockClear(); const testProvider = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'o1', }); // Override the method on the actual instance since Jest ESM mocking isn't working testProvider['client'].chat.completions.create = mockCreate; await testProvider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], model: 'o1', }); expect(mockCreate).toHaveBeenCalledTimes(1); const callParams = (mockCreate as any).mock.calls[0][0]; // o1 models should NOT have temperature or token limits expect(callParams).not.toHaveProperty('temperature'); // But SHOULD have the correct model and messages expect(callParams.model).toBe('o1'); expect(callParams.messages).toEqual( expect.arrayContaining([ expect.objectContaining({ role: 'user', content: 'Hello' }), ]) ); }); it('should use correct parameters for GPT-5 models', async () => { mockCreate.mockClear(); const testProvider = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'gpt-5', }); // Override the method on the actual instance since Jest ESM mocking isn't working testProvider['client'].chat.completions.create = mockCreate; await testProvider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], model: 'gpt-5', }); expect(mockCreate).toHaveBeenCalledTimes(1); const callParams = (mockCreate as any).mock.calls[0][0]; // GPT-5 models should NOT have temperature or token limits expect(callParams).not.toHaveProperty('temperature'); // But SHOULD have the correct model and messages expect(callParams.model).toBe('gpt-5'); expect(callParams.messages).toEqual( expect.arrayContaining([ expect.objectContaining({ role: 'user', content: 'Hello' }), ]) ); }); it('should use correct parameters for non-o1 models', async () => { mockCreate.mockClear(); const testProvider = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'gpt-4', }); // Override the method on the actual instance since Jest ESM mocking isn't working testProvider['client'].chat.completions.create = mockCreate; await testProvider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], model: 'gpt-4', }); expect(mockCreate).toHaveBeenCalledTimes(1); const callParams = (mockCreate as any).mock.calls[0][0]; // non-o1 models SHOULD have temperature expect(callParams).toHaveProperty('temperature'); // And SHOULD have the correct model and messages expect(callParams.model).toBe('gpt-4'); expect(callParams.messages).toEqual( expect.arrayContaining([ expect.objectContaining({ role: 'user', content: 'Hello' }), ]) ); }); }); describe('ProviderManager', () => { let manager: ProviderManager; let mockConfigManager: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); // Setup mock response mockCreate.mockResolvedValue({ choices: [{ message: { content: 'Mocked response' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, }, model: 'mock-model', }); mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { test1: { api_key: 'key1', base_url: 'https://api1.test.com/v1', default_model: 'model1', nickname: 'Duck 1', models: ['model1'], }, test2: { api_key: 'key2', base_url: 'https://api2.test.com/v1', default_model: 'model2', nickname: 'Duck 2', models: ['model2'], }, }, default_provider: 'test1', enable_failover: true, default_temperature: 0.7, }), } as any; manager = new ProviderManager(mockConfigManager); // Override the client method on all providers in the manager const provider1 = manager.getProvider('test1'); const provider2 = manager.getProvider('test2'); provider1['client'].chat.completions.create = mockCreate; provider2['client'].chat.completions.create = mockCreate; }); it('should initialize providers from config', () => { const providers = manager.getProviderNames(); expect(providers).toContain('test1'); expect(providers).toContain('test2'); }); it('should get a specific provider', () => { const provider = manager.getProvider('test1'); expect(provider).toBeDefined(); expect(provider.name).toBe('test1'); }); it('should get default provider when no name specified', () => { const provider = manager.getProvider(); expect(provider).toBeDefined(); expect(provider.name).toBe('test1'); }); it('should throw error for non-existent provider', () => { expect(() => manager.getProvider('nonexistent')).toThrow( 'Duck "nonexistent" not found in the pond' ); }); it('should ask a duck', async () => { const response = await manager.askDuck('test1', 'Hello'); expect(response).toBeDefined(); expect(response.provider).toBe('test1'); expect(response.content).toBe('Mocked response'); }); it('should compare multiple ducks', async () => { const responses = await manager.compareDucks('Hello', ['test1', 'test2']); expect(responses).toHaveLength(2); expect(responses[0].provider).toBe('test1'); expect(responses[1].provider).toBe('test2'); }); it('should run duck council', async () => { const responses = await manager.duckCouncil('Hello'); expect(responses).toHaveLength(2); }); it('should get all providers info', () => { const providers = manager.getAllProviders(); expect(providers).toHaveLength(2); expect(providers[0].name).toBe('test1'); expect(providers[0].info.nickname).toBe('Duck 1'); expect(providers[1].name).toBe('test2'); expect(providers[1].info.nickname).toBe('Duck 2'); }); it('should validate model for provider', () => { // Valid model should return true const isValid = manager.validateModel('test1', 'model1'); expect(isValid).toBe(true); }); it('should return false for invalid model', () => { const isValid = manager.validateModel('test1', 'nonexistent-model'); expect(isValid).toBe(false); }); it('should handle error in compareDucks gracefully', async () => { // Make one provider fail const provider1 = manager.getProvider('test1'); provider1['client'].chat.completions.create = jest.fn().mockRejectedValue(new Error('API Error')); const responses = await manager.compareDucks('Hello', ['test1', 'test2']); // Should still return responses, with error message for failed provider expect(responses).toHaveLength(2); expect(responses[0].content).toContain('Error'); expect(responses[1].content).toBe('Mocked response'); }); it('should throw error when no valid providers in compareDucks', async () => { await expect(manager.compareDucks('Hello', ['nonexistent'])).rejects.toThrow( 'No valid providers specified' ); }); it('should check health of all providers', async () => { const results = await manager.checkHealth(); expect(results).toHaveLength(2); expect(results[0].provider).toBe('test1'); expect(results[1].provider).toBe('test2'); }); it('should check health of specific provider', async () => { const results = await manager.checkHealth('test1'); expect(results).toHaveLength(1); expect(results[0].provider).toBe('test1'); }); it('should handle health check that returns false', async () => { // Mock the health check to return false (not throw) const provider1 = manager.getProvider('test1'); provider1['client'].chat.completions.create = jest.fn().mockResolvedValue({ choices: [{ message: { content: '' } }], // Empty content = unhealthy }); const results = await manager.checkHealth('test1'); expect(results).toHaveLength(1); expect(results[0].healthy).toBe(false); }); it('should throw error when getting models for nonexistent provider', async () => { await expect(manager.getAvailableModels('nonexistent')).rejects.toThrow( 'Provider nonexistent not found' ); }); it('should return false when validating model for nonexistent provider', () => { const isValid = manager.validateModel('nonexistent', 'model1'); expect(isValid).toBe(false); }); it('should return true for any model when provider has no models list', () => { // Create manager with a provider that has no models configured const noModelsConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { testNoModels: { api_key: 'key1', base_url: 'https://api.test.com/v1', default_model: 'model1', nickname: 'Test No Models', // No models array - undefined }, }, default_provider: 'testNoModels', enable_failover: false, default_temperature: 0.7, }), } as any; const noModelsManager = new ProviderManager(noModelsConfigManager); // When provider has no availableModels, should return true (let API validate) const isValid = noModelsManager.validateModel('testNoModels', 'any-model-name'); expect(isValid).toBe(true); }); }); describe('ProviderManager compareDucksWithProgress', () => { let manager: ProviderManager; let mockConfigManager: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); mockCreate.mockResolvedValue({ choices: [{ message: { content: 'Mocked response' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, }, model: 'mock-model', }); mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { test1: { api_key: 'key1', base_url: 'https://api1.test.com/v1', default_model: 'model1', nickname: 'Duck 1', models: ['model1'], }, test2: { api_key: 'key2', base_url: 'https://api2.test.com/v1', default_model: 'model2', nickname: 'Duck 2', models: ['model2'], }, }, default_provider: 'test1', enable_failover: true, default_temperature: 0.7, }), } as any; manager = new ProviderManager(mockConfigManager); const provider1 = manager.getProvider('test1'); const provider2 = manager.getProvider('test2'); provider1['client'].chat.completions.create = mockCreate; provider2['client'].chat.completions.create = mockCreate; }); it('should call onProviderComplete for each provider', async () => { const onComplete = jest.fn(); await manager.compareDucksWithProgress('Hello', ['test1', 'test2'], undefined, onComplete); expect(onComplete).toHaveBeenCalledTimes(2); // First call: completed=1, total=2 expect(onComplete).toHaveBeenNthCalledWith(1, expect.any(String), 1, 2); // Second call: completed=2, total=2 expect(onComplete).toHaveBeenNthCalledWith(2, expect.any(String), 2, 2); }); it('should return responses from all providers', async () => { const onComplete = jest.fn(); const responses = await manager.compareDucksWithProgress('Hello', ['test1', 'test2'], undefined, onComplete); expect(responses).toHaveLength(2); expect(responses[0].provider).toBe('test1'); expect(responses[1].provider).toBe('test2'); expect(responses[0].content).toBe('Mocked response'); }); it('should use all providers when providerNames is undefined', async () => { const onComplete = jest.fn(); const responses = await manager.compareDucksWithProgress('Hello', undefined, undefined, onComplete); expect(responses).toHaveLength(2); expect(onComplete).toHaveBeenCalledTimes(2); }); it('should call onProviderComplete even when a provider errors', async () => { const provider1 = manager.getProvider('test1'); provider1['client'].chat.completions.create = jest.fn().mockRejectedValue(new Error('API Error')); const onComplete = jest.fn(); const responses = await manager.compareDucksWithProgress('Hello', ['test1', 'test2'], undefined, onComplete); // Both callbacks should fire (error path included via .catch().then()) expect(onComplete).toHaveBeenCalledTimes(2); expect(responses).toHaveLength(2); expect(responses[0].content).toContain('Error'); expect(responses[1].content).toBe('Mocked response'); }); it('should throw when no valid providers specified', async () => { const onComplete = jest.fn(); await expect( manager.compareDucksWithProgress('Hello', ['nonexistent'], undefined, onComplete) ).rejects.toThrow('No valid providers specified'); expect(onComplete).not.toHaveBeenCalled(); }); it('should pass options through to askDuck', async () => { const onComplete = jest.fn(); const askDuckSpy = jest.spyOn(manager, 'askDuck'); await manager.compareDucksWithProgress('Hello', ['test1'], { model: 'custom-model' }, onComplete); expect(askDuckSpy).toHaveBeenCalledWith('test1', 'Hello', { model: 'custom-model' }); askDuckSpy.mockRestore(); }); }); describe('ProviderManager Error Cases', () => { it('should throw error when no default provider and none specified', () => { const mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { test1: { api_key: 'key1', base_url: 'https://api1.test.com/v1', default_model: 'model1', nickname: 'Duck 1', }, }, // No default_provider set enable_failover: false, default_temperature: 0.7, }), } as any; const manager = new ProviderManager(mockConfigManager); // Override the client const provider = manager.getProvider('test1'); provider['client'].chat.completions.create = mockCreate; // This should work since we're specifying the provider expect(() => manager.getProvider('test1')).not.toThrow(); }); it('should throw error when getProvider called without name and no default', () => { const mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { test1: { api_key: 'key1', base_url: 'https://api1.test.com/v1', default_model: 'model1', nickname: 'Duck 1', }, }, // No default_provider set enable_failover: false, default_temperature: 0.7, }), } as any; const manager = new ProviderManager(mockConfigManager); expect(() => manager.getProvider()).toThrow( 'No provider specified and no default provider configured' ); }); }); describe('ProviderManager Health Check Exception', () => { let manager: ProviderManager; let mockConfigManager: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { test1: { api_key: 'key1', base_url: 'https://api1.test.com/v1', default_model: 'model1', nickname: 'Duck 1', }, }, default_provider: 'test1', enable_failover: false, default_temperature: 0.7, }), } as any; manager = new ProviderManager(mockConfigManager); }); it('should handle health check that fails with rejected promise', async () => { const provider = manager.getProvider('test1'); provider['client'].chat.completions.create = jest.fn().mockRejectedValue( new Error('Network timeout') ); const results = await manager.checkHealth('test1'); expect(results).toHaveLength(1); // DuckProvider.healthCheck() catches errors internally and returns false // so error is not propagated to manager expect(results[0].healthy).toBe(false); }); it('should handle health check when provider.healthCheck itself throws', async () => { const provider = manager.getProvider('test1'); // Override healthCheck to actually throw (unlike normal behavior) provider.healthCheck = jest.fn().mockRejectedValue(new Error('Unexpected error')); const results = await manager.checkHealth('test1'); expect(results).toHaveLength(1); expect(results[0].healthy).toBe(false); expect(results[0].error).toBe('Unexpected error'); }); it('should handle non-Error exception from healthCheck', async () => { const provider = manager.getProvider('test1'); // Override healthCheck to throw a non-Error provider.healthCheck = jest.fn().mockRejectedValue('String error'); const results = await manager.checkHealth('test1'); expect(results).toHaveLength(1); expect(results[0].healthy).toBe(false); expect(results[0].error).toBe('String error'); }); }); describe('ProviderManager Failover', () => { let manager: ProviderManager; let mockConfigManager: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); mockCreate.mockResolvedValue({ choices: [{ message: { content: 'Mocked response' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'model2', }); mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { test1: { api_key: 'key1', base_url: 'https://api1.test.com/v1', default_model: 'model1', nickname: 'Duck 1', }, test2: { api_key: 'key2', base_url: 'https://api2.test.com/v1', default_model: 'model2', nickname: 'Duck 2', }, }, default_provider: 'test1', enable_failover: true, // Enable failover default_temperature: 0.7, }), } as any; manager = new ProviderManager(mockConfigManager); // Set up mock clients const provider1 = manager.getProvider('test1'); const provider2 = manager.getProvider('test2'); provider2['client'].chat.completions.create = mockCreate; provider1['client'].chat.completions.create = jest.fn().mockRejectedValue( new Error('Primary provider failed') ); }); it('should failover to another provider when primary fails', async () => { // Call askDuck without specifying provider (use default) const response = await manager.askDuck(undefined, 'Hello'); expect(response.provider).toBe('test2'); expect(response.content).toBe('Mocked response'); }); it('should throw when all providers fail during failover', async () => { // Make both providers fail const provider2 = manager.getProvider('test2'); provider2['client'].chat.completions.create = jest.fn().mockRejectedValue( new Error('Secondary also failed') ); await expect(manager.askDuck(undefined, 'Hello')).rejects.toThrow( 'All ducks have flown away!' ); }); it('should not failover when provider is explicitly specified', async () => { await expect(manager.askDuck('test1', 'Hello')).rejects.toThrow( 'Primary provider failed' ); }); }); describe('ProviderManager getAllModels', () => { let manager: ProviderManager; let mockConfigManager: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { test1: { api_key: 'key1', base_url: 'https://api1.test.com/v1', default_model: 'model1', nickname: 'Duck 1', models: ['model1'], }, test2: { api_key: 'key2', base_url: 'https://api2.test.com/v1', default_model: 'model2', nickname: 'Duck 2', models: ['model2', 'model2b'], }, }, default_provider: 'test1', enable_failover: false, default_temperature: 0.7, }), } as any; manager = new ProviderManager(mockConfigManager); }); it('should return models from all providers', async () => { // Mock listModels to use fallback (already configured models) const provider1 = manager.getProvider('test1'); const provider2 = manager.getProvider('test2'); provider1['client'].models = { list: jest.fn().mockRejectedValue(new Error('API error')) } as any; provider2['client'].models = { list: jest.fn().mockRejectedValue(new Error('API error')) } as any; const allModels = await manager.getAllModels(); expect(allModels.size).toBe(2); expect(allModels.get('test1')).toHaveLength(1); expect(allModels.get('test2')).toHaveLength(2); }); it('should return empty array for providers that fail', async () => { const provider1 = manager.getProvider('test1'); // Make listModels throw by accessing undefined (simulating real error) provider1.listModels = jest.fn().mockRejectedValue(new Error('Fatal error')); const allModels = await manager.getAllModels(); expect(allModels.get('test1')).toEqual([]); }); }); describe('DuckProvider Health Check', () => { let provider: DuckProvider; beforeEach(() => { jest.clearAllMocks(); mockCreate.mockResolvedValue({ choices: [{ message: { content: 'healthy' }, finish_reason: 'stop', }], }); provider = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model', }); provider['client'].chat.completions.create = mockCreate; }); it('should return true when health check succeeds', async () => { const isHealthy = await provider.healthCheck(); expect(isHealthy).toBe(true); expect(mockCreate).toHaveBeenCalled(); }); it('should return false when health check fails', async () => { mockCreate.mockRejectedValue(new Error('Connection failed')); const isHealthy = await provider.healthCheck(); expect(isHealthy).toBe(false); }); it('should return false when response has no content', async () => { mockCreate.mockResolvedValue({ choices: [{ message: { content: '' }, finish_reason: 'stop', }], }); const isHealthy = await provider.healthCheck(); expect(isHealthy).toBe(false); }); it('should return false when response has null content', async () => { mockCreate.mockResolvedValue({ choices: [{ message: { content: null }, finish_reason: 'stop', }], }); const isHealthy = await provider.healthCheck(); expect(isHealthy).toBe(false); }); }); describe('DuckProvider listModels', () => { let provider: DuckProvider; beforeEach(() => { jest.clearAllMocks(); provider = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model', availableModels: ['model-a', 'model-b'], }); }); it('should return models from API on success', async () => { // Create async iterator for models const mockModels = [ { id: 'gpt-4', created: 1234567890, owned_by: 'openai', object: 'model' }, { id: 'gpt-3.5', created: 1234567880, owned_by: 'openai', object: 'model' }, ]; const asyncIterator = (async function* () { for (const model of mockModels) { yield model; } })(); provider['client'].models = { list: jest.fn().mockResolvedValue(asyncIterator), } as any; const models = await provider.listModels(); expect(models).toHaveLength(2); expect(models[0].id).toBe('gpt-4'); expect(models[1].id).toBe('gpt-3.5'); }); it('should fallback to configured models when API fails', async () => { provider['client'].models = { list: jest.fn().mockRejectedValue(new Error('API error')), } as any; const models = await provider.listModels(); expect(models).toHaveLength(2); expect(models[0].id).toBe('model-a'); expect(models[0].description).toBe('Configured model (not fetched from API)'); expect(models[1].id).toBe('model-b'); }); it('should fallback to default model when API fails and no configured models', async () => { // Create provider without availableModels const providerNoModels = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'default-model', }); providerNoModels['client'].models = { list: jest.fn().mockRejectedValue(new Error('API error')), } as any; const models = await providerNoModels.listModels(); expect(models).toHaveLength(1); expect(models[0].id).toBe('default-model'); expect(models[0].description).toBe('Default configured model'); }); it('should fallback to default model when API fails and configured models is empty', async () => { // Create provider with empty availableModels const providerEmptyModels = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'fallback-model', availableModels: [], }); providerEmptyModels['client'].models = { list: jest.fn().mockRejectedValue(new Error('API error')), } as any; const models = await providerEmptyModels.listModels(); expect(models).toHaveLength(1); expect(models[0].id).toBe('fallback-model'); expect(models[0].description).toBe('Default configured model'); }); it('should handle non-Error thrown from API', async () => { provider['client'].models = { list: jest.fn().mockRejectedValue('String error'), } as any; const models = await provider.listModels(); // Should still fallback to configured models expect(models).toHaveLength(2); expect(models[0].id).toBe('model-a'); }); }); describe('DuckProvider Error Handling', () => { let provider: DuckProvider; beforeEach(() => { jest.clearAllMocks(); provider = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model', }); provider['client'].chat.completions.create = mockCreate; }); it('should throw error when API call fails', async () => { mockCreate.mockRejectedValue(new Error('API rate limited')); await expect( provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }) ).rejects.toThrow('API rate limited'); }); it('should throw error when response has empty choices array', async () => { mockCreate.mockResolvedValue({ choices: [], usage: { prompt_tokens: 10, completion_tokens: 0, total_tokens: 10 }, model: 'test-model', }); // When choices is empty, accessing choices[0].message throws await expect( provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }) ).rejects.toThrow("couldn't respond"); }); it('should handle system prompt in options', async () => { mockCreate.mockResolvedValue({ choices: [{ message: { content: 'Response with system prompt' }, finish_reason: 'stop', }], usage: { prompt_tokens: 15, completion_tokens: 5, total_tokens: 20 }, model: 'test-model', }); const providerWithSystem = new DuckProvider('test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model', systemPrompt: 'You are a helpful assistant', }); providerWithSystem['client'].chat.completions.create = mockCreate; const response = await providerWithSystem.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(response.content).toBe('Response with system prompt'); // Verify system prompt was included in the call const callArgs = mockCreate.mock.calls[0][0]; expect(callArgs.messages[0].role).toBe('system'); expect(callArgs.messages[0].content).toBe('You are a helpful assistant'); }); }); describe('DuckProvider with Guardrails', () => { let mockGuardrailsService: { isEnabled: jest.Mock; createContext: jest.Mock; execute: jest.Mock; }; beforeEach(() => { jest.clearAllMocks(); // Setup mock OpenAI response mockCreate.mockResolvedValue({ choices: [{ message: { content: 'Mocked response' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, }, model: 'test-model', }); // Create mock guardrails service mockGuardrailsService = { isEnabled: jest.fn().mockReturnValue(true), createContext: jest.fn().mockImplementation((params) => ({ requestId: 'test-request-id', provider: params.provider, model: params.model, messages: params.messages || [], prompt: params.prompt, violations: [], modifications: [], metadata: new Map(), })), execute: jest.fn().mockResolvedValue({ action: 'allow', context: {} }), }; }); it('should execute pre_request guardrails before chat', async () => { const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(mockGuardrailsService.isEnabled).toHaveBeenCalled(); expect(mockGuardrailsService.createContext).toHaveBeenCalledWith( expect.objectContaining({ provider: 'test', model: 'test-model', prompt: 'Hello', }) ); expect(mockGuardrailsService.execute).toHaveBeenCalledWith('pre_request', expect.any(Object)); }); it('should execute post_response guardrails after chat', async () => { const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); // Should be called twice: pre_request and post_response expect(mockGuardrailsService.execute).toHaveBeenCalledTimes(2); expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(1, 'pre_request', expect.any(Object)); expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(2, 'post_response', expect.any(Object)); }); it('should block request when pre_request guardrails return block', async () => { mockGuardrailsService.execute.mockResolvedValueOnce({ action: 'block', blockedBy: 'rate_limiter', blockReason: 'Too many requests', context: {}, }); const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await expect( provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }) ).rejects.toThrow("Request blocked by guardrail 'rate_limiter': Too many requests"); // Should NOT call the LLM when blocked expect(mockCreate).not.toHaveBeenCalled(); }); it('should block response when post_response guardrails return block', async () => { // Pre-request allows, post-response blocks mockGuardrailsService.execute .mockResolvedValueOnce({ action: 'allow', context: {} }) .mockResolvedValueOnce({ action: 'block', blockedBy: 'content_filter', blockReason: 'Inappropriate content detected', context: {}, }); const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await expect( provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }) ).rejects.toThrow("Request blocked by guardrail 'content_filter': Inappropriate content detected"); // LLM was called but response was blocked expect(mockCreate).toHaveBeenCalled(); }); it('should modify messages when pre_request guardrails return modify', async () => { const modifiedMessages = [ { role: 'user' as const, content: 'Hello [EMAIL_1]', timestamp: new Date() }, ]; mockGuardrailsService.execute.mockImplementation((phase) => { if (phase === 'pre_request') { return Promise.resolve({ action: 'modify', context: { messages: modifiedMessages }, }); } return Promise.resolve({ action: 'allow', context: {} }); }); mockGuardrailsService.createContext.mockReturnValue({ requestId: 'test-id', provider: 'test', model: 'test-model', messages: modifiedMessages, prompt: 'Hello [EMAIL_1]', violations: [], modifications: [], metadata: new Map(), }); const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await provider.chat({ messages: [{ role: 'user', content: 'Hello test@example.com', timestamp: new Date() }], }); // The LLM should receive the modified (redacted) message const callArgs = mockCreate.mock.calls[0][0]; expect(callArgs.messages[0].content).toBe('Hello [EMAIL_1]'); }); it('should modify response when post_response guardrails return modify', async () => { // Create a shared context object that gets modified by execute const sharedContext = { requestId: 'test-id', provider: 'test', model: 'test-model', messages: [] as Array<{ role: string; content: string; timestamp: Date }>, prompt: 'Hello', response: '', violations: [], modifications: [], metadata: new Map(), }; mockGuardrailsService.createContext.mockReturnValue(sharedContext); mockGuardrailsService.execute.mockImplementation((phase) => { if (phase === 'post_response') { // Modify the response in the shared context sharedContext.response = 'Modified response with restored PII'; return Promise.resolve({ action: 'modify', context: sharedContext, }); } return Promise.resolve({ action: 'allow', context: sharedContext }); }); const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; const response = await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(response.content).toBe('Modified response with restored PII'); }); it('should skip guardrails when service is disabled', async () => { mockGuardrailsService.isEnabled.mockReturnValue(false); const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; const response = await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(response.content).toBe('Mocked response'); expect(mockGuardrailsService.createContext).not.toHaveBeenCalled(); expect(mockGuardrailsService.execute).not.toHaveBeenCalled(); }); it('should work without guardrails service (undefined)', async () => { const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' } // No guardrails service passed ); provider['client'].chat.completions.create = mockCreate; const response = await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(response.content).toBe('Mocked response'); }); it('should re-throw GuardrailBlockError without wrapping', async () => { mockGuardrailsService.execute.mockRejectedValueOnce( new (await import('../src/guardrails/errors')).GuardrailBlockError('test_plugin', 'Test block reason') ); const provider = new DuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await expect( provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }) ).rejects.toThrow("Request blocked by guardrail 'test_plugin': Test block reason"); }); }); describe('EnhancedDuckProvider with Guardrails', () => { let mockGuardrailsService: { isEnabled: jest.Mock; createContext: jest.Mock; execute: jest.Mock; }; let mockFunctionBridge: { getFunctionDefinitions: jest.Mock; handleFunctionCall: jest.Mock; getStats: jest.Mock; }; beforeEach(() => { jest.clearAllMocks(); mockGuardrailsService = { isEnabled: jest.fn().mockReturnValue(true), createContext: jest.fn().mockImplementation((params) => ({ requestId: 'test-request-id', provider: params.provider, model: params.model, messages: params.messages || [], prompt: params.prompt, response: '', violations: [], modifications: [], metadata: new Map(), })), execute: jest.fn().mockResolvedValue({ action: 'allow', context: {} }), }; mockFunctionBridge = { getFunctionDefinitions: jest.fn().mockResolvedValue([]), handleFunctionCall: jest.fn(), getStats: jest.fn().mockReturnValue({ totalFunctions: 0, serverCount: 0, trustedToolCount: 0, connectedServers: [] }), }; // Setup mock response for regular chat mockCreate.mockResolvedValue({ choices: [{ message: { content: 'Mocked response' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, }, model: 'test-model', }); }); it('should execute pre_request guardrails before making request', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false, // mcpEnabled mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(mockGuardrailsService.isEnabled).toHaveBeenCalled(); expect(mockGuardrailsService.createContext).toHaveBeenCalledWith( expect.objectContaining({ provider: 'test', model: 'test-model', }) ); expect(mockGuardrailsService.execute).toHaveBeenCalledWith('pre_request', expect.any(Object)); }); it('should execute post_response guardrails after receiving response', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); // Should be called twice: pre_request and post_response expect(mockGuardrailsService.execute).toHaveBeenCalledTimes(2); expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(1, 'pre_request', expect.any(Object)); expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(2, 'post_response', expect.any(Object)); }); it('should block request when pre_request guardrails return block', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); mockGuardrailsService.execute.mockResolvedValueOnce({ action: 'block', blockedBy: 'pattern_blocker', blockReason: 'Blocked pattern detected', context: {}, }); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await expect( provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }) ).rejects.toThrow("Request blocked by guardrail 'pattern_blocker': Blocked pattern detected"); // API should NOT have been called expect(mockCreate).not.toHaveBeenCalled(); }); it('should block response when post_response guardrails return block', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); // Pre-request allows, post-response blocks mockGuardrailsService.execute .mockResolvedValueOnce({ action: 'allow', context: {} }) .mockResolvedValueOnce({ action: 'block', blockedBy: 'pii_redactor', blockReason: 'PII detected in response', context: {}, }); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await expect( provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }) ).rejects.toThrow("Request blocked by guardrail 'pii_redactor': PII detected in response"); }); it('should modify messages when pre_request guardrails return modify', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); const modifiedMessages = [ { role: 'user' as const, content: '[REDACTED]', timestamp: new Date() } ]; const sharedContext = { requestId: 'test-id', provider: 'test', model: 'test-model', messages: modifiedMessages, prompt: 'Hello', response: '', violations: [], modifications: [], metadata: new Map(), }; mockGuardrailsService.createContext.mockReturnValue(sharedContext); mockGuardrailsService.execute.mockImplementation((phase) => { if (phase === 'pre_request') { return Promise.resolve({ action: 'modify', context: sharedContext }); } return Promise.resolve({ action: 'allow', context: sharedContext }); }); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await provider.chat({ messages: [{ role: 'user', content: 'sensitive data', timestamp: new Date() }], }); // The API should receive the modified messages const callArgs = mockCreate.mock.calls[0][0] as { messages: Array<{ content: string }> }; expect(callArgs.messages[0].content).toBe('[REDACTED]'); }); it('should modify response when post_response guardrails return modify', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); const sharedContext = { requestId: 'test-id', provider: 'test', model: 'test-model', messages: [], prompt: 'Hello', response: '', violations: [], modifications: [], metadata: new Map(), }; mockGuardrailsService.createContext.mockReturnValue(sharedContext); mockGuardrailsService.execute.mockImplementation((phase) => { if (phase === 'post_response') { sharedContext.response = '[REDACTED RESPONSE]'; return Promise.resolve({ action: 'modify', context: sharedContext }); } return Promise.resolve({ action: 'allow', context: sharedContext }); }); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; const result = await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(result.content).toBe('[REDACTED RESPONSE]'); }); it('should skip guardrails when service is disabled', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); mockGuardrailsService.isEnabled.mockReturnValue(false); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(mockGuardrailsService.createContext).not.toHaveBeenCalled(); expect(mockGuardrailsService.execute).not.toHaveBeenCalled(); }); it('should work without guardrails service (undefined)', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false // No guardrails service ); provider['client'].chat.completions.create = mockCreate; const result = await provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }); expect(result.content).toBe('Mocked response'); }); it('should re-throw GuardrailBlockError without wrapping', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); const { GuardrailBlockError } = await import('../src/guardrails/errors'); mockGuardrailsService.execute.mockRejectedValueOnce( new GuardrailBlockError('custom_plugin', 'Custom block reason') ); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, false, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await expect( provider.chat({ messages: [{ role: 'user', content: 'Hello', timestamp: new Date() }], }) ).rejects.toThrow("Request blocked by guardrail 'custom_plugin': Custom block reason"); }); it('should apply guardrails with tool calls - blocking post_response after tool execution', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); // First call returns tool_calls, second returns final response mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: null, tool_calls: [{ id: 'call_123', type: 'function', function: { name: 'mcp__test__tool', arguments: JSON.stringify({ arg: 'value' }), }, }], }, finish_reason: 'tool_calls', }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, model: 'test-model', }) .mockResolvedValueOnce({ choices: [{ message: { content: 'Final response with sensitive data' }, finish_reason: 'stop', }], usage: { prompt_tokens: 20, completion_tokens: 30, total_tokens: 50 }, model: 'test-model', }); mockFunctionBridge.handleFunctionCall.mockResolvedValue({ success: true, data: { result: 'tool result' }, }); // Pre-request allows, post-response blocks mockGuardrailsService.execute .mockResolvedValueOnce({ action: 'allow', context: {} }) // pre_request .mockResolvedValueOnce({ action: 'block', blockedBy: 'pii_redactor', blockReason: 'Sensitive data in final response', context: {}, }); // post_response const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, true, // mcpEnabled mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; await expect( provider.chat({ messages: [{ role: 'user', content: 'Run the tool', timestamp: new Date() }], }) ).rejects.toThrow("Request blocked by guardrail 'pii_redactor': Sensitive data in final response"); }); it('should modify tool result when post_response guardrails return modify after tool calls', async () => { const { EnhancedDuckProvider } = await import('../src/providers/duck-provider-enhanced'); mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: null, tool_calls: [{ id: 'call_123', type: 'function', function: { name: 'mcp__test__tool', arguments: JSON.stringify({ arg: 'value' }), }, }], }, finish_reason: 'tool_calls', }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, model: 'test-model', }) .mockResolvedValueOnce({ choices: [{ message: { content: 'Final response with SSN: 123-45-6789' }, finish_reason: 'stop', }], usage: { prompt_tokens: 20, completion_tokens: 30, total_tokens: 50 }, model: 'test-model', }); mockFunctionBridge.handleFunctionCall.mockResolvedValue({ success: true, data: { result: 'tool result' }, }); const sharedContext = { requestId: 'test-id', provider: 'test', model: 'test-model', messages: [], prompt: 'Run the tool', response: '', violations: [], modifications: [], metadata: new Map(), }; mockGuardrailsService.createContext.mockReturnValue(sharedContext); mockGuardrailsService.execute.mockImplementation((phase) => { if (phase === 'post_response') { sharedContext.response = 'Final response with SSN: [REDACTED]'; return Promise.resolve({ action: 'modify', context: sharedContext }); } return Promise.resolve({ action: 'allow', context: sharedContext }); }); const provider = new EnhancedDuckProvider( 'test', 'Test Duck', { apiKey: 'test-key', baseURL: 'https://api.test.com/v1', model: 'test-model' }, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge, true, mockGuardrailsService as unknown as import('../src/guardrails/service').GuardrailsService ); provider['client'].chat.completions.create = mockCreate; const result = await provider.chat({ messages: [{ role: 'user', content: 'Run the tool', timestamp: new Date() }], }); expect(result.content).toBe('Final response with SSN: [REDACTED]'); }); }); describe('ProviderManager with CLI Providers', () => { it('should initialize CLI provider when config has type cli', () => { const mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { 'cli-claude': { type: 'cli', cli_type: 'claude', nickname: 'Claude Agent', default_model: 'claude-sonnet-4-20250514', }, }, default_provider: 'cli-claude', enable_failover: false, default_temperature: 0.7, }), } as any; const manager = new ProviderManager(mockConfigManager); const provider = manager.getProvider('cli-claude'); expect(provider).toBeInstanceOf(CLIDuckProvider); expect(provider.name).toBe('cli-claude'); expect(provider.nickname).toBe('Claude Agent'); }); it('should initialize mixed HTTP and CLI providers', () => { const mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { openai: { type: 'http', api_key: 'sk-test', base_url: 'https://api.openai.com/v1', default_model: 'gpt-4', nickname: 'OpenAI Duck', models: ['gpt-4'], }, 'cli-claude': { type: 'cli', cli_type: 'claude', nickname: 'Claude Agent', }, }, default_provider: 'openai', enable_failover: false, default_temperature: 0.7, }), } as any; const manager = new ProviderManager(mockConfigManager); expect(manager.getProvider('openai')).toBeInstanceOf(DuckProvider); expect(manager.getProvider('cli-claude')).toBeInstanceOf(CLIDuckProvider); expect(manager.getProviderNames()).toContain('openai'); expect(manager.getProviderNames()).toContain('cli-claude'); }); it('should return correct info for CLI provider via getAllProviders', () => { const mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { 'cli-claude': { type: 'cli', cli_type: 'claude', nickname: 'Claude Agent', default_model: 'claude-sonnet-4-20250514', }, }, default_provider: 'cli-claude', enable_failover: false, default_temperature: 0.7, }), } as any; const manager = new ProviderManager(mockConfigManager); const providers = manager.getAllProviders(); expect(providers).toHaveLength(1); expect(providers[0].info.type).toBe('cli'); expect(providers[0].info.cliCommand).toBe('claude'); expect(providers[0].info.cliType).toBe('claude'); expect(providers[0].info.model).toBe('claude-sonnet-4-20250514'); }); it('should initialize custom CLI provider with cli_command', () => { const mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { 'my-tool': { type: 'cli', cli_type: 'custom', cli_command: '/usr/local/bin/my-llm', prompt_delivery: 'stdin', output_format: 'text', nickname: 'My Custom Tool', }, }, default_provider: 'my-tool', enable_failover: false, default_temperature: 0.7, }), } as any; const manager = new ProviderManager(mockConfigManager); const provider = manager.getProvider('my-tool'); expect(provider).toBeInstanceOf(CLIDuckProvider); expect(provider.nickname).toBe('My Custom Tool'); const info = provider.getInfo(); expect(info.cliCommand).toBe('/usr/local/bin/my-llm'); expect(info.cliType).toBe('custom'); }); }); describe('EnhancedProviderManager with CLI Providers', () => { let mockConfigManager: jest.Mocked; let mockFunctionBridge: { getFunctionDefinitions: jest.Mock; handleFunctionCall: jest.Mock; getStats: jest.Mock; }; beforeEach(() => { jest.clearAllMocks(); mockCreate.mockResolvedValue({ choices: [{ message: { content: 'Mocked response' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, }, model: 'mock-model', }); mockFunctionBridge = { getFunctionDefinitions: jest.fn().mockResolvedValue([]), handleFunctionCall: jest.fn(), getStats: jest.fn().mockReturnValue({ totalFunctions: 0, serverCount: 0, trustedToolCount: 0, connectedServers: [] }), }; mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { openai: { type: 'http', api_key: 'sk-test', base_url: 'https://api.openai.com/v1', default_model: 'gpt-4', nickname: 'OpenAI Duck', models: ['gpt-4'], }, 'cli-codex': { type: 'cli', cli_type: 'codex', nickname: 'Codex Agent', }, }, default_provider: 'openai', enable_failover: false, default_temperature: 0.7, mcp_bridge: { enabled: true, }, }), } as any; }); it('compareDucksWithMCP should include CLI providers', async () => { const { EnhancedProviderManager } = await import('../src/providers/enhanced-manager'); const manager = new EnhancedProviderManager( mockConfigManager, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge ); // Mock the enhanced HTTP provider's client (this is what compareDucksWithMCP uses) const enhancedProvider = manager.getEnhancedProvider('openai'); enhancedProvider['client'].chat.completions.create = mockCreate; // Mock the CLI provider's chat method to return a response const cliProvider = manager.getProvider('cli-codex'); cliProvider.chat = jest.fn().mockResolvedValue({ content: 'CLI response', model: 'cli', finishReason: 'stop', }); const responses = await manager.compareDucksWithMCP('Hello'); // Should have responses from both HTTP and CLI providers expect(responses).toHaveLength(2); const providerNames = responses.map(r => r.provider); expect(providerNames).toContain('openai'); expect(providerNames).toContain('cli-codex'); }); it('compareDucksWithProgressMCP should include CLI providers', async () => { const { EnhancedProviderManager } = await import('../src/providers/enhanced-manager'); const manager = new EnhancedProviderManager( mockConfigManager, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge ); // Mock the enhanced HTTP provider's client const enhancedProvider = manager.getEnhancedProvider('openai'); enhancedProvider['client'].chat.completions.create = mockCreate; // Mock the CLI provider's chat method const cliProvider = manager.getProvider('cli-codex'); cliProvider.chat = jest.fn().mockResolvedValue({ content: 'CLI response', model: 'cli', finishReason: 'stop', }); const onComplete = jest.fn(); const responses = await manager.compareDucksWithProgressMCP('Hello', undefined, undefined, onComplete); // Should have responses from both providers expect(responses).toHaveLength(2); const providerNames = responses.map(r => r.provider); expect(providerNames).toContain('openai'); expect(providerNames).toContain('cli-codex'); // Progress callback should be called for each provider expect(onComplete).toHaveBeenCalledTimes(2); }); it('compareDucksWithMCP should handle CLI provider errors gracefully', async () => { const { EnhancedProviderManager } = await import('../src/providers/enhanced-manager'); const manager = new EnhancedProviderManager( mockConfigManager, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge ); // Mock the enhanced HTTP provider's client const enhancedProvider = manager.getEnhancedProvider('openai'); enhancedProvider['client'].chat.completions.create = mockCreate; // Mock the CLI provider to throw an error const cliProvider = manager.getProvider('cli-codex'); cliProvider.chat = jest.fn().mockRejectedValue(new Error('CLI process failed')); const responses = await manager.compareDucksWithMCP('Hello'); // Should still return 2 responses expect(responses).toHaveLength(2); // HTTP provider should succeed const httpResponse = responses.find(r => r.provider === 'openai'); expect(httpResponse?.content).toBe('Mocked response'); // CLI provider should have error message const cliResponse = responses.find(r => r.provider === 'cli-codex'); expect(cliResponse?.content).toContain('Error'); expect(cliResponse?.content).toContain('CLI process failed'); }); it('askDuckWithMCP should fall back to regular askDuck for CLI providers', async () => { const { EnhancedProviderManager } = await import('../src/providers/enhanced-manager'); const manager = new EnhancedProviderManager( mockConfigManager, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge ); // Mock the CLI provider's chat method const cliProvider = manager.getProvider('cli-codex'); cliProvider.chat = jest.fn().mockResolvedValue({ content: 'CLI response', model: 'cli', finishReason: 'stop', }); const response = await manager.askDuckWithMCP('cli-codex', 'Hello'); expect(response.provider).toBe('cli-codex'); expect(response.content).toBe('CLI response'); expect(cliProvider.chat).toHaveBeenCalled(); }); it('duckCouncilWithMCP should include CLI providers', async () => { const { EnhancedProviderManager } = await import('../src/providers/enhanced-manager'); const manager = new EnhancedProviderManager( mockConfigManager, mockFunctionBridge as unknown as import('../src/services/function-bridge').FunctionBridge ); // Mock the enhanced HTTP provider's client const enhancedProvider = manager.getEnhancedProvider('openai'); enhancedProvider['client'].chat.completions.create = mockCreate; // Mock the CLI provider's chat method const cliProvider = manager.getProvider('cli-codex'); cliProvider.chat = jest.fn().mockResolvedValue({ content: 'CLI council response', model: 'cli', finishReason: 'stop', }); const responses = await manager.duckCouncilWithMCP('Hello'); // Should have responses from both providers expect(responses).toHaveLength(2); const providerNames = responses.map(r => r.provider); expect(providerNames).toContain('openai'); expect(providerNames).toContain('cli-codex'); }); });