Files
proffesor-for-testing__agen…/tests/integration/llm/failover.test.ts
Dragan Spiridonov d55fcfbbda refactor(adr-093): sweep retiring model IDs to Opus 4.7/Sonnet 4.6/Haiku 4.5
Commit 2 of 6 for ADR-093 Opus 4.7 migration. Real behavior change:
the AQE fleet now targets Sonnet 4.6, Haiku 4.5, Opus 4.7.

43 files changed, 253 insertions, 246 deletions.

Mechanical sed sweep across 29 src/ files and 14 tests/ files:
* 'claude-sonnet-4-20250514' → 'claude-sonnet-4-6'
* 'claude-3-5-haiku-20241022' → 'claude-haiku-4-5-20251001'
* 'claude-opus-4-5-20251101' → 'claude-opus-4-7'
* 'claude-opus-4-20250514' → 'claude-opus-4-7'

Data-definition files (model-mapping.ts, model-registry.ts,
cost-tracker.ts, model-mapping.test.ts, model-registry.test.ts)
deliberately excluded from sweep — they keep retiring IDs as
canonical data + regression coverage for backward-compat paths.

* src/shared/llm/cost-tracker.ts: added claude-opus-4-7 ($5/$25),
  claude-sonnet-4-6 ($3/$15), claude-haiku-4-5-20251001 ($1/$5)
  pricing entries alongside retained retiring entries.
* src/shared/llm/providers/bedrock.ts: rebuilt BEDROCK_MODEL_MAPPING
  to preserve both new ARNs (claude-opus-4-7-v1:0 etc.) and retiring
  ARNs; fixes duplicate-key compile error caused by sed collapsing
  'claude-opus-4-20250514' and 'claude-opus-4-5-20251101' to same key.
* src/shared/llm/interfaces.ts: ClaudeConfig.model type union
  updated to hint at new IDs; '| string' fallback keeps backward
  compat for lingering callers.
* tests updated: 3 provider/bedrock/cost-tracker assertions
  corrected to match new pricing + new ARN mappings.

Verification:
* 0 tsc errors (npx tsc --noEmit)
* 664/664 LLM unit tests pass
* 2302/2302 coordination + CLI tests pass
* 2884/2885 domain tests pass (1 pre-existing skip)
* grep -rcE 'retired-id-pattern' src/ returns 0 outside data files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:32:24 +00:00

533 lines
16 KiB
TypeScript

/**
* Agentic QE v3 - Failover Integration Tests
* ADR-043: Vendor-Independent LLM Support - Milestone 12
*
* Tests for resilience mechanisms:
* - Fallback chain execution
* - Circuit breaker behavior
* - Retry with exponential backoff
* - Graceful degradation
* - Fallback metrics tracking
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { HybridRouter, createHybridRouter } from '../../../src/shared/llm/router/hybrid-router';
import { ProviderManager } from '../../../src/shared/llm/provider-manager';
import { CircuitBreaker, CircuitState } from '../../../src/shared/llm/circuit-breaker';
import {
DEFAULT_FALLBACK_CHAIN,
DEFAULT_FALLBACK_BEHAVIOR,
type ChatParams,
type FallbackChain,
type FallbackChainEntry,
} from '../../../src/shared/llm/router/types';
import { createLLMError, isLLMError } from '../../../src/shared/llm/interfaces';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('Failover Integration Tests', () => {
let providerManager: ProviderManager;
let router: HybridRouter;
beforeEach(async () => {
vi.clearAllMocks();
providerManager = new ProviderManager({
defaultProvider: 'claude',
providers: {
claude: { apiKey: 'test-claude-key' },
openai: { apiKey: 'test-openai-key' },
ollama: { baseUrl: 'http://localhost:11434' },
},
});
await providerManager.initialize();
router = createHybridRouter(providerManager);
await router.initialize();
router.setMode('manual');
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Fallback Chain Execution', () => {
it('should have default fallback chain configured', () => {
expect(DEFAULT_FALLBACK_CHAIN).toBeDefined();
expect(DEFAULT_FALLBACK_CHAIN.entries.length).toBeGreaterThan(0);
});
it('should include multiple providers in fallback chain', () => {
const providers = DEFAULT_FALLBACK_CHAIN.entries.map(e => e.provider);
expect(providers).toContain('claude');
expect(providers).toContain('openai');
expect(providers).toContain('ollama');
});
it('should have fallback entries with multiple models', () => {
for (const entry of DEFAULT_FALLBACK_CHAIN.entries) {
expect(entry.models.length).toBeGreaterThan(0);
}
});
it('should fallback to next provider on error', async () => {
// Mock Claude failure, then OpenAI success
let callCount = 0;
mockFetch.mockImplementation((url: string) => {
callCount++;
if (url.includes('anthropic')) {
// First call to Claude fails
return Promise.resolve({
ok: false,
status: 500,
json: () => Promise.resolve({
error: { type: 'server_error', message: 'Internal error' },
}),
});
}
if (url.includes('openai') || url.includes('localhost:11434')) {
// Fallback to OpenAI/Ollama succeeds
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
id: 'chatcmpl-test',
object: 'chat.completion',
model: 'gpt-4o',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Fallback response' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
});
}
return Promise.reject(new Error('Unknown endpoint'));
});
const params: ChatParams = {
messages: [{ role: 'user', content: 'Test' }],
preferredProvider: 'claude',
};
try {
const response = await router.chat(params);
expect(response.content).toBe('Fallback response');
expect(callCount).toBeGreaterThan(1); // Fallback occurred
} catch (error) {
// If all providers fail, that's also valid behavior
expect(isLLMError(error)).toBe(true);
}
});
it('should respect fallback chain priority order', () => {
const sortedEntries = [...DEFAULT_FALLBACK_CHAIN.entries].sort(
(a, b) => b.priority - a.priority
);
// Verify entries have priority
for (const entry of sortedEntries) {
expect(entry.priority).toBeDefined();
expect(entry.priority).toBeGreaterThan(0);
}
});
it('should skip disabled fallback entries', () => {
const enabledEntries = DEFAULT_FALLBACK_CHAIN.entries.filter(e => e.enabled);
expect(enabledEntries.length).toBeGreaterThan(0);
// All enabled entries should have valid providers
for (const entry of enabledEntries) {
expect(entry.provider).toBeDefined();
expect(entry.models.length).toBeGreaterThan(0);
}
});
});
describe('Circuit Breaker Behavior', () => {
let circuitBreaker: CircuitBreaker;
beforeEach(() => {
// CircuitBreaker requires a provider type as first argument
circuitBreaker = new CircuitBreaker('claude', {
failureThreshold: 5, // Default is 5
resetTimeoutMs: 5000,
halfOpenSuccessThreshold: 2,
});
});
it('should start in closed state', () => {
expect(circuitBreaker.getState()).toBe('closed');
});
it('should open after failure threshold', async () => {
// Record failures - need to hit the threshold (5 by default)
for (let i = 0; i < 5; i++) {
try {
await circuitBreaker.execute(async () => {
throw new Error('Simulated failure');
});
} catch (e) {
// Expected
}
}
expect(circuitBreaker.getState()).toBe('open');
});
it('should fast-fail when open', async () => {
// Open the circuit by hitting threshold
for (let i = 0; i < 5; i++) {
try {
await circuitBreaker.execute(async () => {
throw new Error('Failure');
});
} catch (e) {
// Expected
}
}
expect(circuitBreaker.getState()).toBe('open');
// Next call should fast-fail
await expect(circuitBreaker.execute(async () => 'result')).rejects.toThrow();
});
it('should transition to half-open after reset timeout', async () => {
// Use shorter timeout for test
const fastBreaker = new CircuitBreaker('openai', {
failureThreshold: 3,
resetTimeoutMs: 50,
halfOpenSuccessThreshold: 1,
});
// Open the circuit
for (let i = 0; i < 3; i++) {
try {
await fastBreaker.execute(async () => {
throw new Error('Failure');
});
} catch (e) {
// Expected
}
}
expect(fastBreaker.getState()).toBe('open');
// Wait for reset timeout
await new Promise(resolve => setTimeout(resolve, 100));
expect(fastBreaker.getState()).toBe('half-open');
});
it('should close on successful half-open request', async () => {
const fastBreaker = new CircuitBreaker('ollama', {
failureThreshold: 3,
resetTimeoutMs: 50,
halfOpenSuccessThreshold: 1,
});
// Open the circuit
for (let i = 0; i < 3; i++) {
try {
await fastBreaker.execute(async () => {
throw new Error('Failure');
});
} catch (e) {
// Expected
}
}
// Wait for half-open
await new Promise(resolve => setTimeout(resolve, 100));
// Successful request should close circuit
const result = await fastBreaker.execute(async () => 'success');
expect(result).toBe('success');
expect(fastBreaker.getState()).toBe('closed');
});
it('should record success count', async () => {
await circuitBreaker.execute(async () => 'success');
const stats = circuitBreaker.getStats();
expect(stats.successCount).toBeGreaterThanOrEqual(1);
});
it('should record failure count', async () => {
try {
await circuitBreaker.execute(async () => {
throw new Error('Failure');
});
} catch (e) {
// Expected
}
const stats = circuitBreaker.getStats();
expect(stats.failureCount).toBeGreaterThanOrEqual(1);
});
});
describe('Retry with Exponential Backoff', () => {
it('should have default retry configuration', () => {
expect(DEFAULT_FALLBACK_BEHAVIOR.maxAttempts).toBeGreaterThan(0);
expect(DEFAULT_FALLBACK_BEHAVIOR.delayMs).toBeGreaterThanOrEqual(0);
});
it('should have backoff multiplier in fallback chain', () => {
expect(DEFAULT_FALLBACK_CHAIN.backoffMultiplier).toBeGreaterThan(1);
expect(DEFAULT_FALLBACK_CHAIN.maxDelayMs).toBeGreaterThan(0);
});
it('should retry on retryable errors', () => {
expect(DEFAULT_FALLBACK_BEHAVIOR.retryableErrors).toContain('RATE_LIMITED');
expect(DEFAULT_FALLBACK_BEHAVIOR.retryableErrors).toContain('TIMEOUT');
expect(DEFAULT_FALLBACK_BEHAVIOR.retryableErrors).toContain('NETWORK_ERROR');
});
it('should not retry on non-retryable errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({
error: { type: 'authentication_error', message: 'Invalid API key' },
}),
});
const params: ChatParams = {
messages: [{ role: 'user', content: 'Test' }],
preferredProvider: 'claude',
};
await expect(router.chat(params)).rejects.toMatchObject({
retryable: false,
});
});
it('should calculate exponential delay', () => {
const baseDelay = 100;
const multiplier = 2;
const maxDelay = 5000;
const delays: number[] = [];
let delay = baseDelay;
for (let i = 0; i < 5; i++) {
delays.push(delay);
delay = Math.min(delay * multiplier, maxDelay);
}
expect(delays[0]).toBe(100);
expect(delays[1]).toBe(200);
expect(delays[2]).toBe(400);
expect(delays[3]).toBe(800);
expect(delays[4]).toBe(1600);
});
});
describe('Graceful Degradation', () => {
it('should return error with context when all providers fail', async () => {
// Mock all providers to fail
mockFetch.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({
error: { type: 'server_error', message: 'All servers down' },
}),
text: () => Promise.resolve('Server error'),
});
const params: ChatParams = {
messages: [{ role: 'user', content: 'Test' }],
};
try {
await router.chat(params);
// Should not reach here
expect(true).toBe(false);
} catch (error) {
expect(isLLMError(error)).toBe(true);
if (isLLMError(error)) {
expect(error.code).toBeDefined();
expect(error.message).toBeDefined();
}
}
});
it('should preserve error information in LLM errors', () => {
const error = createLLMError('Test error', 'PROVIDER_UNAVAILABLE', {
retryable: true,
cause: new Error('Original error'),
});
expect(error.code).toBe('PROVIDER_UNAVAILABLE');
expect(error.retryable).toBe(true);
expect(error.cause).toBeDefined();
});
it('should track fallback attempts in metrics', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.resolve({ error: { message: 'Fail' } }),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
id: 'chatcmpl-test',
choices: [{ message: { content: 'OK' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
});
const params: ChatParams = {
messages: [{ role: 'user', content: 'Test' }],
preferredProvider: 'claude',
};
try {
await router.chat(params);
} catch (e) {
// Expected if all fail
}
const metrics = router.getMetrics();
expect(metrics.fallbackRate).toBeDefined();
});
});
describe('Fallback Metrics Tracking', () => {
it('should track fallback rate', async () => {
const metrics = router.getMetrics();
expect(metrics.fallbackRate).toBeDefined();
expect(metrics.fallbackRate).toBeGreaterThanOrEqual(0);
expect(metrics.fallbackRate).toBeLessThanOrEqual(1);
});
it('should track provider success rate', async () => {
// Mock successful response
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
id: 'msg_test',
type: 'message',
content: [{ type: 'text', text: 'Success' }],
model: 'claude-sonnet-4-6',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 20 },
}),
});
const params: ChatParams = {
messages: [{ role: 'user', content: 'Test' }],
preferredProvider: 'claude',
};
await router.chat(params);
const metrics = router.getMetrics();
if (metrics.byProvider.claude) {
expect(metrics.byProvider.claude.successRate).toBeGreaterThan(0);
}
});
it('should track decisions per provider', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
id: 'msg_test',
type: 'message',
content: [{ type: 'text', text: 'Success' }],
model: 'claude-sonnet-4-6',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 20 },
}),
});
await router.selectProvider({
messages: [{ role: 'user', content: 'Test' }],
preferredProvider: 'claude',
});
const metrics = router.getMetrics();
expect(metrics.byProvider).toBeDefined();
});
it('should include metrics time period', () => {
const metrics = router.getMetrics();
expect(metrics.period).toBeDefined();
expect(metrics.period.start).toBeInstanceOf(Date);
expect(metrics.period.end).toBeInstanceOf(Date);
});
it('should reset metrics correctly', async () => {
// Make some calls first
await router.selectProvider({
messages: [{ role: 'user', content: 'Test' }],
});
router.resetMetrics();
const metrics = router.getMetrics();
expect(metrics.totalDecisions).toBe(0);
});
});
describe('Fallback Configuration', () => {
it('should allow custom fallback chain', () => {
const customChain: FallbackChain = {
id: 'custom-chain',
entries: [
{
provider: 'ollama',
models: ['llama3.1'],
enabled: true,
priority: 100,
},
{
provider: 'openai',
models: ['gpt-4o-mini'],
enabled: true,
priority: 90,
},
],
maxRetries: 2,
retryDelayMs: 50,
backoffMultiplier: 2,
maxDelayMs: 1000,
};
expect(customChain.entries.length).toBe(2);
expect(customChain.entries[0].provider).toBe('ollama');
});
it('should validate fallback chain entries', () => {
for (const entry of DEFAULT_FALLBACK_CHAIN.entries) {
expect(entry.provider).toBeDefined();
expect(entry.models).toBeDefined();
expect(Array.isArray(entry.models)).toBe(true);
expect(typeof entry.enabled).toBe('boolean');
expect(typeof entry.priority).toBe('number');
}
});
it('should support per-entry timeout configuration', () => {
const entryWithTimeout = DEFAULT_FALLBACK_CHAIN.entries.find(e => e.timeoutMs);
// Some entries should have custom timeouts
const hasTimeouts = DEFAULT_FALLBACK_CHAIN.entries.some(e => e.timeoutMs !== undefined);
expect(hasTimeouts || DEFAULT_FALLBACK_CHAIN.entries.length > 0).toBe(true);
});
it('should support per-entry max attempts', () => {
const hasMaxAttempts = DEFAULT_FALLBACK_CHAIN.entries.some(e => e.maxAttempts !== undefined);
expect(hasMaxAttempts || DEFAULT_FALLBACK_CHAIN.entries.length > 0).toBe(true);
});
});
});