import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ProviderRegistry } from '../src/providers/registry.js'; import { AnthropicProvider } from '../src/providers/anthropic.js'; import type { LlmProvider, CompletionOptions, CompletionResult } from '../src/providers/types.js'; function mockProvider(name: string): LlmProvider { return { name, complete: vi.fn(async (): Promise => ({ content: `Response from ${name}`, toolCalls: [], usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 }, finishReason: 'stop', })), listModels: vi.fn(async () => [`${name}-model-1`, `${name}-model-2`]), isAvailable: vi.fn(async () => true), }; } describe('ProviderRegistry', () => { let registry: ProviderRegistry; beforeEach(() => { registry = new ProviderRegistry(); }); it('starts with no providers', () => { expect(registry.list()).toEqual([]); expect(registry.getActive()).toBeNull(); expect(registry.getActiveName()).toBeNull(); }); it('registers a provider and sets it as active', () => { const openai = mockProvider('openai'); registry.register(openai); expect(registry.list()).toEqual(['openai']); expect(registry.getActive()).toBe(openai); expect(registry.getActiveName()).toBe('openai'); }); it('first registered provider becomes active', () => { registry.register(mockProvider('openai')); registry.register(mockProvider('anthropic')); expect(registry.getActiveName()).toBe('openai'); expect(registry.list()).toEqual(['openai', 'anthropic']); }); it('switches active provider', () => { registry.register(mockProvider('openai')); registry.register(mockProvider('anthropic')); registry.setActive('anthropic'); expect(registry.getActiveName()).toBe('anthropic'); }); it('throws when setting unknown provider as active', () => { expect(() => registry.setActive('unknown')).toThrow("Provider 'unknown' is not registered"); }); it('gets provider by name', () => { const openai = mockProvider('openai'); registry.register(openai); expect(registry.get('openai')).toBe(openai); expect(registry.get('unknown')).toBeUndefined(); }); it('unregisters a provider', () => { registry.register(mockProvider('openai')); registry.register(mockProvider('anthropic')); registry.unregister('openai'); expect(registry.list()).toEqual(['anthropic']); // Active should switch to remaining provider expect(registry.getActiveName()).toBe('anthropic'); }); it('unregistering active provider switches to next available', () => { registry.register(mockProvider('openai')); registry.register(mockProvider('anthropic')); registry.setActive('openai'); registry.unregister('openai'); expect(registry.getActiveName()).toBe('anthropic'); }); it('unregistering last provider clears active', () => { registry.register(mockProvider('openai')); registry.unregister('openai'); expect(registry.getActive()).toBeNull(); expect(registry.getActiveName()).toBeNull(); }); it('active provider can complete', async () => { const provider = mockProvider('openai'); registry.register(provider); const active = registry.getActive()!; const result = await active.complete({ messages: [{ role: 'user', content: 'Hello' }], }); expect(result.content).toBe('Response from openai'); expect(result.finishReason).toBe('stop'); expect(provider.complete).toHaveBeenCalled(); }); it('active provider can list models', async () => { registry.register(mockProvider('anthropic')); const active = registry.getActive()!; const models = await active.listModels(); expect(models).toEqual(['anthropic-model-1', 'anthropic-model-2']); }); describe('tier management', () => { it('assigns providers to tiers', () => { registry.register(mockProvider('vllm')); registry.register(mockProvider('gemini')); registry.assignTier('vllm', 'fast'); registry.assignTier('gemini', 'heavy'); expect(registry.getTierProviders('fast')).toEqual(['vllm']); expect(registry.getTierProviders('heavy')).toEqual(['gemini']); expect(registry.hasTierConfig()).toBe(true); }); it('getProvider returns tier-specific provider', () => { const vllm = mockProvider('vllm'); const gemini = mockProvider('gemini'); registry.register(vllm); registry.register(gemini); registry.assignTier('vllm', 'fast'); registry.assignTier('gemini', 'heavy'); expect(registry.getProvider('fast')).toBe(vllm); expect(registry.getProvider('heavy')).toBe(gemini); }); it('getProvider falls back to other tier', () => { const vllm = mockProvider('vllm'); registry.register(vllm); registry.assignTier('vllm', 'fast'); // Requesting heavy but only fast exists → falls back to fast expect(registry.getProvider('heavy')).toBe(vllm); }); it('getProvider falls back to getActive when no tiers', () => { const openai = mockProvider('openai'); registry.register(openai); // No tier assignments → falls back to legacy getActive() expect(registry.getProvider('fast')).toBe(openai); expect(registry.getProvider('heavy')).toBe(openai); expect(registry.hasTierConfig()).toBe(false); }); it('unregister removes from tier assignments', () => { registry.register(mockProvider('vllm')); registry.register(mockProvider('gemini')); registry.assignTier('vllm', 'fast'); registry.assignTier('gemini', 'heavy'); registry.unregister('vllm'); expect(registry.getTierProviders('fast')).toEqual([]); expect(registry.getTierProviders('heavy')).toEqual(['gemini']); }); it('assignTier throws for unregistered provider', () => { expect(() => registry.assignTier('unknown', 'fast')).toThrow("Provider 'unknown' is not registered"); }); it('multiple providers in same tier uses first', () => { const vllm = mockProvider('vllm'); const ollama = mockProvider('ollama'); registry.register(vllm); registry.register(ollama); registry.assignTier('vllm', 'fast'); registry.assignTier('ollama', 'fast'); expect(registry.getProvider('fast')).toBe(vllm); expect(registry.getTierProviders('fast')).toEqual(['vllm', 'ollama']); }); it('listProviders includes tier info', () => { registry.register(mockProvider('vllm')); registry.register(mockProvider('gemini')); registry.assignTier('vllm', 'fast'); registry.assignTier('gemini', 'heavy'); const providers = registry.listProviders(); expect(providers).toEqual([ { name: 'vllm', tiers: ['fast'] }, { name: 'gemini', tiers: ['heavy'] }, ]); }); it('disposeAll calls dispose on all providers', () => { const disposeFn = vi.fn(); const provider = { ...mockProvider('test'), dispose: disposeFn }; registry.register(provider); registry.disposeAll(); expect(disposeFn).toHaveBeenCalledOnce(); }); }); }); vi.mock('node:https', () => { let capturedOpts: Record = {}; const mockRequest = vi.fn((opts: unknown, cb?: unknown) => { capturedOpts = opts as Record; const mockRes = { on: (event: string, handler: (data?: unknown) => void) => { if (event === 'data') { handler(Buffer.from(JSON.stringify({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 1, output_tokens: 1 }, stop_reason: 'end_turn', }))); } if (event === 'end') handler(); return mockRes; }, }; if (typeof cb === 'function') cb(mockRes); return { on: vi.fn().mockReturnThis(), write: vi.fn(), end: vi.fn(), }; }); return { default: { request: mockRequest }, __capturedOpts: () => capturedOpts, __mockRequest: mockRequest, }; }); describe('AnthropicProvider auth headers', () => { it('uses Authorization: Bearer for OAuth tokens', async () => { const { __capturedOpts } = await import('node:https') as unknown as { __capturedOpts: () => Record }; const provider = new AnthropicProvider({ apiKey: 'sk-ant-oat01-test-token' }); await provider.complete({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 1 }); const headers = __capturedOpts().headers as Record; expect(headers['Authorization']).toBe('Bearer sk-ant-oat01-test-token'); expect(headers['x-api-key']).toBeUndefined(); }); it('uses x-api-key for standard API keys', async () => { const { __capturedOpts } = await import('node:https') as unknown as { __capturedOpts: () => Record }; const provider = new AnthropicProvider({ apiKey: 'sk-ant-api03-standard-key' }); await provider.complete({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 1 }); const headers = __capturedOpts().headers as Record; expect(headers['x-api-key']).toBe('sk-ant-api03-standard-key'); expect(headers['Authorization']).toBeUndefined(); }); it('includes claude-sonnet-4-5 in model list', async () => { const provider = new AnthropicProvider({ apiKey: 'test' }); const models = await provider.listModels(); expect(models).toContain('claude-sonnet-4-5-20250514'); expect(models).toContain('claude-opus-4-20250514'); expect(models).toContain('claude-haiku-3-5-20241022'); }); });