mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
d55fcfbbda
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>
533 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|