Files
ccs/tests/unit/cliproxy/composite-fallback.test.ts
T

348 lines
12 KiB
TypeScript

/**
* Composite Variant Fallback Tests
*
* Tests fallback detection and application for composite variants
*/
import { describe, it, expect } from 'bun:test';
import {
isProviderError,
detectFailedTier,
PROVIDER_ERROR_PATTERNS,
} from '../../../src/cliproxy/executor/retry-handler';
import { applyFallback } from '../../../src/cliproxy/executor/env-resolver';
import { CompositeTierConfig } from '../../../src/config/unified-config-types';
// ========================================
// isProviderError
// ========================================
describe('isProviderError', () => {
it('should return false for exit code 0 (success)', () => {
const result = isProviderError(0, 'Error: 500 Internal Server Error');
expect(result).toBe(false);
});
it('should detect 4xx error codes', () => {
const stderr = 'Error: 401 Unauthorized';
const result = isProviderError(1, stderr);
expect(result).toBe(true);
});
it('should detect 5xx error codes', () => {
const stderr = 'Error: 503 Service Unavailable';
const result = isProviderError(1, stderr);
expect(result).toBe(true);
});
it('should detect overloaded errors', () => {
const stderr = 'Provider is currently overloaded. Please try again later.';
const result = isProviderError(1, stderr);
expect(result).toBe(true);
});
it('should detect quota exceeded errors', () => {
const stderr = 'quota has been exceeded for this account';
const result = isProviderError(1, stderr);
expect(result).toBe(true);
});
it('should detect connection refused errors', () => {
const stderr = 'ECONNREFUSED 127.0.0.1:8317';
const result = isProviderError(1, stderr);
expect(result).toBe(true);
});
it('should detect rate limit errors', () => {
const stderr = 'Rate limit exceeded. Please slow down.';
const result = isProviderError(1, stderr);
expect(result).toBe(true);
});
it('should return false for normal user exit', () => {
const stderr = 'User cancelled the operation';
const result = isProviderError(1, stderr);
expect(result).toBe(false);
});
it('should return false for empty stderr with non-zero exit', () => {
const result = isProviderError(1, '');
expect(result).toBe(false);
});
it('should be case insensitive for error patterns', () => {
const stderr = 'ERROR: OVERLOADED';
const result = isProviderError(1, stderr);
expect(result).toBe(true);
});
it('should handle multiple error patterns in same stderr', () => {
const stderr = 'Error: 429 rate limit exceeded';
const result = isProviderError(1, stderr);
expect(result).toBe(true);
});
});
// ========================================
// detectFailedTier
// ========================================
describe('detectFailedTier', () => {
const tiers: {
opus: CompositeTierConfig;
sonnet: CompositeTierConfig;
haiku: CompositeTierConfig;
} = {
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
sonnet: { provider: 'gemini', model: 'gemini-3-pro-preview' },
haiku: { provider: 'codex', model: 'gpt-4o-mini' },
};
it('should detect opus tier from model name in stderr', () => {
const stderr = 'Error calling model claude-opus-4-6-thinking: 503 overloaded';
const result = detectFailedTier(stderr, tiers);
expect(result).toBe('opus');
});
it('should detect sonnet tier from model name in stderr', () => {
const stderr = 'Failed to connect: gemini-3-pro-preview returned 500';
const result = detectFailedTier(stderr, tiers);
expect(result).toBe('sonnet');
});
it('should detect haiku tier from model name in stderr', () => {
const stderr = 'gpt-4o-mini: rate limit exceeded';
const result = detectFailedTier(stderr, tiers);
expect(result).toBe('haiku');
});
it('should return null when no tier model found in stderr', () => {
const stderr = 'Generic error with no model mention';
const result = detectFailedTier(stderr, tiers);
expect(result).toBe(null);
});
it('should return null for empty stderr', () => {
const result = detectFailedTier('', tiers);
expect(result).toBe(null);
});
it('should match first tier when multiple models mentioned', () => {
const stderr = 'Tried claude-opus-4-6-thinking, then gemini-3-pro-preview, both failed';
const result = detectFailedTier(stderr, tiers);
expect(result).toBe('opus'); // First match
});
it('should handle model names with suffixes (thinking budgets)', () => {
const tiersWithBudget: typeof tiers = {
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking(high)' },
sonnet: { provider: 'gemini', model: 'gemini-3-pro-preview(medium)' },
haiku: { provider: 'codex', model: 'gpt-4o-mini' },
};
// Stderr might not include suffix
const stderr = 'Error with claude-opus-4-6-thinking: timeout';
const result = detectFailedTier(stderr, tiersWithBudget);
// Should still match because model name is substring
expect(result).toBe('opus');
});
it('should strip complex thinking suffix with comma-separated params', () => {
const tiersWithComplexBudget: typeof tiers = {
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking(32768,extended)' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' },
haiku: { provider: 'agy', model: 'claude-3-5-haiku' },
};
// Stderr contains base model name without suffix
const stderr = 'Error: claude-opus-4-6-thinking overloaded';
const result = detectFailedTier(stderr, tiersWithComplexBudget);
// Should match because regex strips (32768,extended) to get base name
expect(result).toBe('opus');
});
});
// ========================================
// applyFallback
// ========================================
describe('applyFallback', () => {
it('should update opus tier env var', () => {
const env = {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyFallback(env, 'opus', {
provider: 'gemini',
model: 'gemini-3-pro-preview',
});
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
// Should not modify other tiers
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
});
it('should update sonnet tier env var', () => {
const env = {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyFallback(env, 'sonnet', {
provider: 'codex',
model: 'gpt-4o',
});
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4o');
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
});
it('should update haiku tier env var', () => {
const env = {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyFallback(env, 'haiku', {
provider: 'gemini',
model: 'gemini-3-flash-preview',
});
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gemini-3-flash-preview');
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
});
it('should update ANTHROPIC_MODEL when failed tier is default tier', () => {
const env = {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyFallback(env, 'opus', {
provider: 'gemini',
model: 'gemini-3-pro-preview',
});
// Both tier model and default model should be updated
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
expect(result.ANTHROPIC_MODEL).toBe('gemini-3-pro-preview');
});
it('should NOT update ANTHROPIC_MODEL when failed tier is not default tier', () => {
const env = {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', // Sonnet is default
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyFallback(env, 'opus', {
provider: 'gemini',
model: 'gemini-3-pro-preview',
});
// Opus tier should be updated but ANTHROPIC_MODEL should remain sonnet
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking');
});
it('should preserve other env vars', () => {
const env = {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
CUSTOM_VAR: 'custom_value',
ANOTHER_VAR: '12345',
};
const result = applyFallback(env, 'opus', {
provider: 'gemini',
model: 'gemini-3-pro-preview',
});
expect(result.CUSTOM_VAR).toBe('custom_value');
expect(result.ANOTHER_VAR).toBe('12345');
expect(result.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8318');
});
it('should not mutate original env object', () => {
const env = {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const original = { ...env };
applyFallback(env, 'opus', {
provider: 'gemini',
model: 'gemini-3-pro-preview',
});
// Original env should be unchanged
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe(original.ANTHROPIC_DEFAULT_OPUS_MODEL);
});
});
// ========================================
// PROVIDER_ERROR_PATTERNS
// ========================================
describe('PROVIDER_ERROR_PATTERNS', () => {
it('should export error pattern regexes', () => {
expect(PROVIDER_ERROR_PATTERNS).toBeDefined();
expect(Array.isArray(PROVIDER_ERROR_PATTERNS)).toBe(true);
expect(PROVIDER_ERROR_PATTERNS.length).toBeGreaterThan(0);
});
it('should match 4xx errors', () => {
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('Error: 401'));
expect(pattern).toBeDefined();
});
it('should match 5xx errors', () => {
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('Error: 503'));
expect(pattern).toBeDefined();
});
it('should match overloaded keyword', () => {
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('overloaded'));
expect(pattern).toBeDefined();
});
it('should match quota exceeded', () => {
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('quota exceeded'));
expect(pattern).toBeDefined();
});
it('should match ECONNREFUSED', () => {
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('ECONNREFUSED'));
expect(pattern).toBeDefined();
});
it('should match rate limit', () => {
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('rate limit'));
expect(pattern).toBeDefined();
});
});