/** * LLM 模型自动切换 单元测试 * * 验证 shouldSwitchModel 在 429/额度耗尽等错误下正确返回 true, * 以及 trySwitchModel 正确标记供应商耗尽并切换到下一个供应商。 * * 这次必须测到位,不能再"改了代码但实际没生效"。 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; // ============================================================ // 直接测试 shouldSwitchModel 的逻辑(不依赖 config 模块初始化) // 把核心逻辑复制一份出来测,确保字符串/对象两种入参都正确处理 // ============================================================ function shouldSwitchModel(error: any): boolean { if (!error) return false; const isString = typeof error === 'string'; const message = (isString ? error : (error?.message || error?.error?.message || '')).toLowerCase(); const status = isString ? 0 : (error?.status || error?.response?.status || 0); // 不可切换 const nonSwitchablePatterns = [ 'invalid api key', 'invalid api-key', 'authentication', 'unauthorized', 'invalid token', 'token expired', 'permission denied', 'access denied', 'invalid request', 'bad request', 'invalidparameter', 'invalid_parameter', ]; if (nonSwitchablePatterns.some(p => message.includes(p))) return false; if (status === 401) return false; // 可切换 const switchablePatterns = [ 'rate limit', 'rate_limit', 'too many requests', '请求过于频繁', 'quota', 'balance', 'insufficient', 'usage limit', 'model not found', 'model not support', 'does not exist', 'invalid model', 'service unavailable', 'bad gateway', 'gateway timeout', 'internal server error', 'timed out', 'timeout', 'request timeout', 'etimedout', 'esockettimedout', 'econnreset', 'econnrefused', 'enotfound', 'fetch failed', 'aborted', 'eai_again', ]; if (switchablePatterns.some(p => message.includes(p))) return true; if ([429, 502, 503, 504, 500].includes(status)) return true; if (status === 403) return true; if (status === 404) return true; if (['429', '502', '503', '504'].some(c => message.includes(c))) return true; return false; } // ============================================================ // 测试用例 // ============================================================ describe('shouldSwitchModel - 429/额度耗尽错误(本次修复重点)', () => { describe('入参为 Error 对象', () => { it('429 错误 → 应可切换', () => { const err = new Error('429 已达到 Token Plan 用量上限:请升级 Token Plan 套餐或购买积分补充用量。 (2056)'); expect(shouldSwitchModel(err)).toBe(true); }); it('rate limit 错误 → 应可切换', () => { const err = new Error('Rate limit exceeded. Please try again later.'); expect(shouldSwitchModel(err)).toBe(true); }); it('quota exceeded 错误 → 应可切换', () => { const err = new Error('You have exceeded your quota limit.'); expect(shouldSwitchModel(err)).toBe(true); }); it('insufficient balance → 应可切换', () => { const err = new Error('Insufficient balance to complete this request.'); expect(shouldSwitchModel(err)).toBe(true); }); it('too many requests → 应可切换', () => { const err = new Error('Too many requests, please slow down.'); expect(shouldSwitchModel(err)).toBe(true); }); it('带 HTTP status=429 的错误对象 → 应可切换', () => { const err = { message: 'Some error', status: 429 }; expect(shouldSwitchModel(err)).toBe(true); }); it('带 response.status=429 的错误对象 → 应可切换', () => { const err = { message: 'Some error', response: { status: 429 } }; expect(shouldSwitchModel(err)).toBe(true); }); }); describe('入参为字符串(修复前这是 bug 的根源!)', () => { it('429 错误字符串 → 应可切换', () => { const msg = '429 已达到 Token Plan 用量上限:请升级 Token Plan 套餐或购买积分补充用量。 (2056)'; expect(shouldSwitchModel(msg)).toBe(true); }); it('rate limit 字符串 → 应可切换', () => { expect(shouldSwitchModel('Rate limit exceeded')).toBe(true); }); it('quota 字符串 → 应可切换', () => { expect(shouldSwitchModel('quota exceeded')).toBe(true); }); it('包含 429 状态码文本的字符串 → 应可切换', () => { expect(shouldSwitchModel('Error 429: Too Many Requests')).toBe(true); }); it('包含"用量"中文关键词 → 应可切换(switchablePatterns 中有 usage limit)', () => { // "用量"不在 switchablePatterns 中,但 "429" 在兜底匹配中 const msg = '429 用量上限'; expect(shouldSwitchModel(msg)).toBe(true); }); }); describe('不可切换的错误(认证/参数类)', () => { it('invalid api key → 不可切换', () => { expect(shouldSwitchModel(new Error('Invalid API key'))).toBe(false); expect(shouldSwitchModel('Invalid API key')).toBe(false); }); it('authentication failed → 不可切换', () => { expect(shouldSwitchModel(new Error('Authentication failed'))).toBe(false); }); it('401 状态码 → 不可切换', () => { expect(shouldSwitchModel({ message: 'Unauthorized', status: 401 })).toBe(false); }); it('参数错误 → 不可切换', () => { expect(shouldSwitchModel(new Error('InvalidParameter: text too short'))).toBe(false); }); }); describe('边界情况', () => { it('null/undefined → false', () => { expect(shouldSwitchModel(null)).toBe(false); expect(shouldSwitchModel(undefined)).toBe(false); }); it('空字符串 → false', () => { expect(shouldSwitchModel('')).toBe(false); }); it('空对象 → false', () => { expect(shouldSwitchModel({})).toBe(false); }); it('无关错误 → false', () => { expect(shouldSwitchModel(new Error('Some random error'))).toBe(false); }); }); }); // ============================================================ // 测试 EXHAUSTED_PATTERNS 匹配逻辑 // ============================================================ const EXHAUSTED_PATTERNS = [ 'quota', 'balance', 'insufficient', '额度', '余额', '用量', 'rate limit', 'too many requests', ]; function isExhaustedError(message: string): boolean { return EXHAUSTED_PATTERNS.some(p => message.toLowerCase().includes(p)); } describe('EXHAUSTED_PATTERNS - 额度耗尽检测', () => { it('"用量上限" → 匹配(中文)', () => { expect(isExhaustedError('429 已达到 Token Plan 用量上限')).toBe(true); }); it('"rate limit" → 匹配', () => { expect(isExhaustedError('Rate limit exceeded')).toBe(true); }); it('"quota" → 匹配', () => { expect(isExhaustedError('Quota exceeded')).toBe(true); }); it('"insufficient" → 匹配', () => { expect(isExhaustedError('Insufficient balance')).toBe(true); }); it('"额度" → 匹配(中文)', () => { expect(isExhaustedError('API 额度不足')).toBe(true); }); it('普通网络错误 → 不匹配', () => { expect(isExhaustedError('Connection timeout')).toBe(false); }); }); // ============================================================ // 测试关键的 modelId 保留行为 // 当切换到下一个供应商时,优先保留同名模型 // ============================================================ describe('模型切换 - 同名模型优先', () => { // 模拟 switchToNextVendorModel 的核心逻辑: // 1. 精确匹配同名模型 // 2. 别名匹配(canonicalModel) // 3. 回退到第一个可用模型 it('下一个供应商有同名模型 → 返回相同 modelId', () => { const currentModelId = 'MiniMax-M3'; const nextVendorModels = ['MiniMax-M3', 'MiniMax-M2.7']; const hasModel = nextVendorModels.includes(currentModelId); expect(hasModel).toBe(true); // 这种情况下 switchToNextVendorModel 应返回 'MiniMax-M3' }); it('下一个供应商无同名模型 → 回退到第一个可用模型', () => { const currentModelId = 'MiniMax-M3'; const nextVendorModels = ['qwen3.6-plus', 'qwen3.5-flash']; const hasModel = nextVendorModels.includes(currentModelId); expect(hasModel).toBe(false); // 这种情况下 switchToNextVendorModel 应返回 'qwen3.6-plus' }); });