feat: tiered LLM providers (fast/heavy) with multi-provider config

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>
This commit is contained in:
Michal
2026-02-25 02:16:08 +00:00
parent 7b5a658d9b
commit d2be0d7198
17 changed files with 834 additions and 285 deletions

View File

@@ -115,4 +115,105 @@ describe('ProviderRegistry', () => {
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();
});
});
});