mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
348 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|