LLMProviderAdapter now tries all registered providers before giving up: 1. Named provider (if specified) 2. All 'fast' tier providers in order 3. All 'heavy' tier providers in order 4. Legacy active provider Previously, if the first provider (e.g., vllm-local) failed, the adapter threw immediately even though Anthropic and Gemini were available. Now it logs the failure and tries the next candidate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
170 lines
6.0 KiB
TypeScript
170 lines
6.0 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { LLMProviderAdapter } from '../src/proxymodel/llm-adapter.js';
|
|
import { ProviderRegistry } from '../src/providers/registry.js';
|
|
import type { LlmProvider, CompletionResult } from '../src/providers/types.js';
|
|
|
|
function mockProvider(name: string, response = 'mock response'): LlmProvider {
|
|
return {
|
|
name,
|
|
complete: vi.fn().mockResolvedValue({
|
|
content: response,
|
|
toolCalls: [],
|
|
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
|
|
finishReason: 'stop',
|
|
} satisfies CompletionResult),
|
|
listModels: vi.fn().mockResolvedValue([]),
|
|
isAvailable: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
|
|
function failingProvider(name: string, error = 'connection refused'): LlmProvider {
|
|
return {
|
|
name,
|
|
complete: vi.fn().mockRejectedValue(new Error(error)),
|
|
listModels: vi.fn().mockResolvedValue([]),
|
|
isAvailable: vi.fn().mockResolvedValue(true),
|
|
};
|
|
}
|
|
|
|
describe('LLMProviderAdapter', () => {
|
|
it('available() returns true when a provider is registered', () => {
|
|
const registry = new ProviderRegistry();
|
|
registry.register(mockProvider('test'));
|
|
registry.assignTier('test', 'fast');
|
|
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
expect(adapter.available()).toBe(true);
|
|
});
|
|
|
|
it('available() returns false when no provider is registered', () => {
|
|
const registry = new ProviderRegistry();
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
expect(adapter.available()).toBe(false);
|
|
});
|
|
|
|
it('complete() sends prompt as user message', async () => {
|
|
const provider = mockProvider('test');
|
|
const registry = new ProviderRegistry();
|
|
registry.register(provider);
|
|
registry.assignTier('test', 'fast');
|
|
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
const result = await adapter.complete('summarize this');
|
|
|
|
expect(result).toBe('mock response');
|
|
expect(provider.complete).toHaveBeenCalledWith({
|
|
messages: [{ role: 'user', content: 'summarize this' }],
|
|
temperature: 0,
|
|
});
|
|
});
|
|
|
|
it('complete() includes system message when provided', async () => {
|
|
const provider = mockProvider('test');
|
|
const registry = new ProviderRegistry();
|
|
registry.register(provider);
|
|
registry.assignTier('test', 'fast');
|
|
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
await adapter.complete('summarize', { system: 'You are a summarizer', maxTokens: 200 });
|
|
|
|
expect(provider.complete).toHaveBeenCalledWith({
|
|
messages: [
|
|
{ role: 'system', content: 'You are a summarizer' },
|
|
{ role: 'user', content: 'summarize' },
|
|
],
|
|
maxTokens: 200,
|
|
temperature: 0,
|
|
});
|
|
});
|
|
|
|
it('complete() throws when no provider available', async () => {
|
|
const registry = new ProviderRegistry();
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
|
|
await expect(adapter.complete('test')).rejects.toThrow('No LLM provider available');
|
|
});
|
|
|
|
// --- Failover tests ---
|
|
|
|
it('falls back to next provider in same tier on failure', async () => {
|
|
const failing = failingProvider('vllm-local', 'vLLM startup timed out');
|
|
const working = mockProvider('anthropic', 'anthropic response');
|
|
const registry = new ProviderRegistry();
|
|
registry.register(failing);
|
|
registry.register(working);
|
|
registry.assignTier('vllm-local', 'fast');
|
|
registry.assignTier('anthropic', 'fast');
|
|
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
const result = await adapter.complete('test');
|
|
|
|
expect(result).toBe('anthropic response');
|
|
expect(failing.complete).toHaveBeenCalledOnce();
|
|
expect(working.complete).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('falls back cross-tier when all fast providers fail', async () => {
|
|
const fastFail = failingProvider('vllm-local');
|
|
const heavy = mockProvider('gemini', 'gemini response');
|
|
const registry = new ProviderRegistry();
|
|
registry.register(fastFail);
|
|
registry.register(heavy);
|
|
registry.assignTier('vllm-local', 'fast');
|
|
registry.assignTier('gemini', 'heavy');
|
|
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
const result = await adapter.complete('test');
|
|
|
|
expect(result).toBe('gemini response');
|
|
expect(fastFail.complete).toHaveBeenCalledOnce();
|
|
expect(heavy.complete).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('throws last error when all providers fail', async () => {
|
|
const fail1 = failingProvider('vllm', 'vLLM down');
|
|
const fail2 = failingProvider('anthropic', 'rate limited');
|
|
const registry = new ProviderRegistry();
|
|
registry.register(fail1);
|
|
registry.register(fail2);
|
|
registry.assignTier('vllm', 'fast');
|
|
registry.assignTier('anthropic', 'heavy');
|
|
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
await expect(adapter.complete('test')).rejects.toThrow('rate limited');
|
|
expect(fail1.complete).toHaveBeenCalledOnce();
|
|
expect(fail2.complete).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('does not retry provider that already succeeded', async () => {
|
|
const fast = mockProvider('fast-provider', 'fast result');
|
|
const heavy = mockProvider('heavy-provider', 'heavy result');
|
|
const registry = new ProviderRegistry();
|
|
registry.register(fast);
|
|
registry.register(heavy);
|
|
registry.assignTier('fast-provider', 'fast');
|
|
registry.assignTier('heavy-provider', 'heavy');
|
|
|
|
const adapter = new LLMProviderAdapter(registry);
|
|
const result = await adapter.complete('test');
|
|
|
|
expect(result).toBe('fast result');
|
|
expect(fast.complete).toHaveBeenCalledOnce();
|
|
expect(heavy.complete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prefers named provider but falls back on failure', async () => {
|
|
const named = failingProvider('preferred', 'preferred down');
|
|
const fallback = mockProvider('fallback', 'fallback response');
|
|
const registry = new ProviderRegistry();
|
|
registry.register(named);
|
|
registry.register(fallback);
|
|
registry.assignTier('preferred', 'fast');
|
|
registry.assignTier('fallback', 'fast');
|
|
|
|
const adapter = new LLMProviderAdapter(registry, 'preferred');
|
|
const result = await adapter.complete('test');
|
|
|
|
expect(result).toBe('fallback response');
|
|
});
|
|
});
|