Adds tier-based LLM routing so fast local models (vLLM, Ollama) handle
structured tasks while cloud models (Gemini, Anthropic) are reserved for
heavy reasoning. Single-provider configs continue to work via fallback.
- Tier type + ProviderRegistry with assignTier/getProvider/fallback chain
- Multi-provider config format: { providers: [{ name, type, tier, ... }] }
- NamedProvider wrapper for multiple instances of same provider type
- Setup wizard: Simple (legacy) / Advanced (fast+heavy tiers) modes
- Status display: tiered view with /llm/providers endpoint
- Call sites use getProvider('fast') instead of getActive()
- Full backward compatibility with existing single-provider configs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
220 lines
7.0 KiB
TypeScript
220 lines
7.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ProviderRegistry } from '../src/providers/registry.js';
|
|
import type { LlmProvider, CompletionOptions, CompletionResult } from '../src/providers/types.js';
|
|
|
|
function mockProvider(name: string): LlmProvider {
|
|
return {
|
|
name,
|
|
complete: vi.fn(async (): Promise<CompletionResult> => ({
|
|
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();
|
|
});
|
|
});
|
|
});
|