| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- /**
- * 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'
- });
- });
|