/** * Tests for AI Provider abstraction * * @module @agentkits/memory/hooks/__tests__/ai-provider.test */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { resolveAIProvider, createClaudeCliProvider, createOpenAIProvider, createGeminiProvider, type AIProviderConfig, } from '../ai-provider.js'; describe('AI Provider', () => { const originalEnv = { ...process.env }; beforeEach(() => { // Clean env vars before each test delete process.env.AGENTKITS_AI_PROVIDER; delete process.env.AGENTKITS_AI_API_KEY; delete process.env.AGENTKITS_AI_BASE_URL; delete process.env.AGENTKITS_AI_MODEL; }); afterEach(() => { // Restore env process.env = { ...originalEnv }; vi.restoreAllMocks(); }); // ===== resolveAIProvider ===== describe('resolveAIProvider', () => { it('should default to claude-cli when no config and no env', () => { const provider = resolveAIProvider(); expect(provider.name).toBe('claude-cli'); }); it('should use settings config provider', () => { const config: AIProviderConfig = { provider: 'openai', apiKey: 'test-key' }; const provider = resolveAIProvider(config); expect(provider.name).toBe('openai'); }); it('should use gemini provider from settings', () => { const config: AIProviderConfig = { provider: 'gemini', apiKey: 'gemini-key' }; const provider = resolveAIProvider(config); expect(provider.name).toBe('gemini'); }); it('should override settings with env var AGENTKITS_AI_PROVIDER', () => { process.env.AGENTKITS_AI_PROVIDER = 'gemini'; process.env.AGENTKITS_AI_API_KEY = 'env-key'; const config: AIProviderConfig = { provider: 'openai', apiKey: 'settings-key' }; const provider = resolveAIProvider(config); expect(provider.name).toBe('gemini'); }); it('should merge env API key with settings provider', () => { process.env.AGENTKITS_AI_API_KEY = 'env-key'; const config: AIProviderConfig = { provider: 'openai' }; const provider = resolveAIProvider(config); expect(provider.name).toBe('openai'); // Provider should be available because env key is set expect(provider.isAvailable()).toBe(true); }); it('should fall back to claude-cli for unknown provider', () => { process.env.AGENTKITS_AI_PROVIDER = 'unknown-provider'; const provider = resolveAIProvider(); expect(provider.name).toBe('claude-cli'); }); it('should use env model override', () => { process.env.AGENTKITS_AI_MODEL = 'custom-model'; const provider = resolveAIProvider(); expect(provider.name).toBe('claude-cli'); // Can't directly test model, but it shouldn't throw }); }); // ===== Claude CLI Provider ===== describe('createClaudeCliProvider', () => { it('should create a provider with name claude-cli', () => { const provider = createClaudeCliProvider('haiku'); expect(provider.name).toBe('claude-cli'); }); it('should cache isAvailable result', () => { const provider = createClaudeCliProvider('haiku'); // First call checks CLI, second should use cache const first = provider.isAvailable(); const second = provider.isAvailable(); expect(first).toBe(second); }); it('should return null from run when CLI is not available', async () => { const provider = createClaudeCliProvider('nonexistent-model'); // execFileSync will throw for invalid claude args, provider catches and returns null const result = await provider.run('test prompt', 'system', 5000); // On CI or machines without claude CLI, this returns null expect(result === null || typeof result === 'string').toBe(true); }); }); // ===== OpenAI Provider ===== describe('createOpenAIProvider', () => { it('should return unavailable when no API key', () => { const provider = createOpenAIProvider('', 'https://api.openai.com/v1', 'gpt-4o-mini'); expect(provider.isAvailable()).toBe(false); }); it('should return available when API key is set', () => { const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); expect(provider.isAvailable()).toBe(true); expect(provider.name).toBe('openai'); }); it('should return null when fetch fails', async () => { // Mock fetch to simulate network error vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); const result = await provider.run('test', 'system', 5000); expect(result).toBeNull(); }); it('should return null when response is not ok', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, })); const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); const result = await provider.run('test', 'system', 5000); expect(result).toBeNull(); }); it('should parse successful response correctly', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ choices: [{ message: { content: '{"subtitle": "test"}' } }], }), })); const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); const result = await provider.run('test', 'system', 5000); expect(result).toBe('{"subtitle": "test"}'); }); it('should send correct request format', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ choices: [{ message: { content: 'result' } }] }), }); vi.stubGlobal('fetch', mockFetch); const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); await provider.run('my prompt', 'my system', 10000); expect(mockFetch).toHaveBeenCalledTimes(1); const [url, options] = mockFetch.mock.calls[0]; expect(url).toBe('https://api.openai.com/v1/chat/completions'); expect(options.method).toBe('POST'); expect(options.headers['Authorization']).toBe('Bearer sk-test'); const body = JSON.parse(options.body); expect(body.model).toBe('gpt-4o-mini'); expect(body.messages).toEqual([ { role: 'system', content: 'my system' }, { role: 'user', content: 'my prompt' }, ]); expect(body.temperature).toBe(0.3); expect(body.max_tokens).toBe(1024); }); it('should strip trailing slash from base URL', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ choices: [{ message: { content: 'ok' } }] }), }); vi.stubGlobal('fetch', mockFetch); const provider = createOpenAIProvider('sk-test', 'https://openrouter.ai/api/v1/', 'model'); await provider.run('prompt', 'system', 5000); const [url] = mockFetch.mock.calls[0]; expect(url).toBe('https://openrouter.ai/api/v1/chat/completions'); }); it('should return null when response has no choices', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ choices: [] }), })); const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); const result = await provider.run('test', 'system', 5000); expect(result).toBeNull(); }); }); // ===== Gemini Provider ===== describe('createGeminiProvider', () => { it('should return unavailable when no API key', () => { const provider = createGeminiProvider('', 'gemini-2.0-flash'); expect(provider.isAvailable()).toBe(false); }); it('should return available when API key is set', () => { const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); expect(provider.isAvailable()).toBe(true); expect(provider.name).toBe('gemini'); }); it('should return null when fetch fails', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); const result = await provider.run('test', 'system', 5000); expect(result).toBeNull(); }); it('should parse successful response correctly', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ candidates: [{ content: { parts: [{ text: '{"result": "ok"}' }] } }], }), })); const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); const result = await provider.run('test', 'system', 5000); expect(result).toBe('{"result": "ok"}'); }); it('should send correct Gemini API format', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ candidates: [{ content: { parts: [{ text: 'result' }] } }], }), }); vi.stubGlobal('fetch', mockFetch); const provider = createGeminiProvider('gemini-key', 'gemini-2.0-flash'); await provider.run('my prompt', 'my system', 10000); expect(mockFetch).toHaveBeenCalledTimes(1); const [url, options] = mockFetch.mock.calls[0]; expect(url).toContain('generativelanguage.googleapis.com'); expect(url).toContain('gemini-2.0-flash'); expect(url).toContain('key=gemini-key'); const body = JSON.parse(options.body); expect(body.system_instruction).toEqual({ parts: [{ text: 'my system' }] }); expect(body.contents).toEqual([{ parts: [{ text: 'my prompt' }] }]); expect(body.generationConfig.temperature).toBe(0.3); expect(body.generationConfig.maxOutputTokens).toBe(1024); }); it('should return null when response has no candidates', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ candidates: [] }), })); const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); const result = await provider.run('test', 'system', 5000); expect(result).toBeNull(); }); it('should return null on non-200 response', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, })); const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); const result = await provider.run('test', 'system', 5000); expect(result).toBeNull(); }); }); });