/** * LLM 模型切换 - 缓存清除 & 端到端测试 * * 验证完整的 429 错误 → 切换供应商 → 使用新供应商的流程。 * 重点测试:modelCache 在切换时被正确清除,不会返回旧供应商的客户端。 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // ============================================================ // 模拟 modelCache 行为 // ============================================================ describe('modelCache 切换时清除验证', () => { // 模拟:cache key=modelId,value=provider id let cache: Map; beforeEach(() => { cache = new Map(); }); /** * 模拟 switchToNextVendorModel 的 invalidateAndReturn: * 切换时必须清除新旧 modelId 的缓存,防止返回旧供应商的客户端 */ function simulateSwitch(currentModelId: string, nextModelId: string): string { cache.delete(nextModelId); cache.delete(currentModelId); return nextModelId; } it('【关键】同名模型切换 → 缓存被清除,不会返回旧供应商', () => { // 场景:MiniMax-M3 在 Key1 上,缓存了 Key1 的客户端 cache.set('MiniMax-M3', 'MiniMax-Key1'); // 429 后切换到 Key2(也是 MiniMax-M3) simulateSwitch('MiniMax-M3', 'MiniMax-M3'); // 缓存已被清除 expect(cache.has('MiniMax-M3')).toBe(false); // 下次 getLLM('MiniMax-M3') 会重新 createClient → 走 Key2 // 而不会返回 Key1 的旧客户端 }); it('【关键】不同名模型切换 → 新旧缓存都被清除', () => { // MiniMax-M3 → qwen3.6-plus cache.set('MiniMax-M3', 'MiniMax-Key1'); simulateSwitch('MiniMax-M3', 'qwen3.6-plus'); // 两个 modelId 的缓存都被清除 expect(cache.has('MiniMax-M3')).toBe(false); expect(cache.has('qwen3.6-plus')).toBe(false); }); it('别名模型切换 → 缓存被清除', () => { cache.set('minimax-m2.7', 'MiniMax-Key1'); simulateSwitch('minimax-m2.7', 'MiniMax-M2.7'); // 火山引擎的别名 expect(cache.has('minimax-m2.7')).toBe(false); expect(cache.has('MiniMax-M2.7')).toBe(false); }); }); // ============================================================ // 完整调用链模拟:从 LLM 调用失败到切换成功 // ============================================================ interface MockProvider { name: string; displayName: string; models: string[]; exhausted: boolean; } describe('完整切换链路模拟', () => { let providers: MockProvider[]; let currentModelId: string; let switchLog: string[]; beforeEach(() => { switchLog = []; providers = [ { name: 'minimax-key1', displayName: 'MiniMax Key1', models: ['MiniMax-M3', 'MiniMax-M2.7'], exhausted: false }, { name: 'minimax-key2', displayName: 'MiniMax Key2', models: ['MiniMax-M3', 'MiniMax-M2.7'], exhausted: false }, { name: 'ali-bailian', displayName: '阿里云百炼', models: ['qwen3.6-plus', 'qwen3.5-flash'], exhausted: false }, { name: 'volcengine', displayName: '火山引擎', models: ['doubao-seed-2.0', 'MiniMax-M2.7'], exhausted: false }, ]; currentModelId = 'MiniMax-M3'; }); function findProvider(modelId: string): MockProvider | undefined { return providers.find(p => !p.exhausted && p.models.includes(modelId)); } function markExhausted(providerName: string): void { const p = providers.find(p => p.name === providerName); if (p) { p.exhausted = true; switchLog.push(`[EXHAUSTED] ${p.displayName}`); } } function switchToNext(currentModelId: string, skipProviderName?: string): string | null { const available = providers.filter(p => !p.exhausted); if (available.length === 0) { switchLog.push('[SWITCH] 无可用供应商'); return null; } // 确定起始位(匹配修复后的 switchToNextVendorModel 逻辑) let startIndex = 0; if (skipProviderName) { const allEnabled = providers; // 全部 provider(含耗尽的) const exhaustedPos = allEnabled.findIndex(p => p.name === skipProviderName); if (exhaustedPos >= 0) { for (let offset = 1; offset <= allEnabled.length; offset++) { const checkName = allEnabled[(exhaustedPos + offset) % allEnabled.length].name; const availIdx = available.findIndex(p => p.name === checkName); if (availIdx >= 0) { startIndex = availIdx; break; } } } } else { const currentNode = findProvider(currentModelId); if (currentNode) { startIndex = (available.findIndex(p => p.name === currentNode.name) + 1) % available.length; } } // 第一轮:优先找同名模型 for (let i = 0; i < available.length; i++) { const idx = (startIndex + i) % available.length; const p = available[idx]; if (p.models.includes(currentModelId)) { switchLog.push(`[SWITCH] → ${p.displayName} (同模型: ${currentModelId})`); return currentModelId; } } // 第二轮:无同名模型,fallback 用第一个可用模型 for (let i = 0; i < available.length; i++) { const idx = (startIndex + i) % available.length; const p = available[idx]; if (p.models.length > 0) { switchLog.push(`[SWITCH] → ${p.displayName} (模型: ${p.models[0]})`); return p.models[0]; } } return null; } it('429 错误 → Key1 耗尽 → 同模型切到 Key2 → 成功', () => { // Step 1: 初始调用 MiniMax-M3 → Key1 expect(findProvider('MiniMax-M3')!.name).toBe('minimax-key1'); // Step 2: 429 错误!标记 Key1 耗尽 markExhausted('minimax-key1'); expect(providers[0].exhausted).toBe(true); // Step 3: 切换(skipProviderName=被耗尽的 Key1) const nextModel = switchToNext('MiniMax-M3', 'minimax-key1'); expect(nextModel).toBe('MiniMax-M3'); // 同名模型 → Key2 expect(findProvider('MiniMax-M3')!.name).toBe('minimax-key2'); // 现在用 Key2 了 expect(switchLog).toContain('[EXHAUSTED] MiniMax Key1'); expect(switchLog).toContain('[SWITCH] → MiniMax Key2 (同模型: MiniMax-M3)'); }); it('Key1+Key2 都耗尽 → 切到阿里云百炼(不同名模型)', () => { markExhausted('minimax-key1'); markExhausted('minimax-key2'); const nextModel = switchToNext('MiniMax-M3'); expect(nextModel).toBe('qwen3.6-plus'); // 回退到百炼的第一个模型 expect(findProvider('qwen3.6-plus')!.name).toBe('ali-bailian'); expect(switchLog).toContain('[SWITCH] → 阿里云百炼 (模型: qwen3.6-plus)'); }); it('全部 4 个供应商耗尽 → 返回 null', () => { providers.forEach(p => { p.exhausted = true; }); const nextModel = switchToNext('MiniMax-M3'); expect(nextModel).toBeNull(); expect(switchLog).toContain('[SWITCH] 无可用供应商'); }); it('耗尽后若有新供应商恢复 → 可以切过去', () => { markExhausted('minimax-key1'); // Key2 可用(跳过已耗尽的 Key1) let next = switchToNext('MiniMax-M3', 'minimax-key1'); expect(next).toBe('MiniMax-M3'); expect(findProvider('MiniMax-M3')!.name).toBe('minimax-key2'); // Key2 也耗尽 markExhausted('minimax-key2'); // Key1 恢复(TTL 到期) providers[0].exhausted = false; // 从 Key2 之后找 → 回到 Key1 next = switchToNext('MiniMax-M3', 'minimax-key2'); expect(next).toBe('MiniMax-M3'); expect(findProvider('MiniMax-M3')!.name).toBe('minimax-key1'); // 轮转回到 Key1 }); });