| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- /**
- * LLM 模型切换 - 缓存清除 & 端到端测试
- *
- * 验证完整的 429 错误 → 切换供应商 → 使用新供应商的流程。
- * 重点测试:modelCache 在切换时被正确清除,不会返回旧供应商的客户端。
- */
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
- // ============================================================
- // 模拟 modelCache 行为
- // ============================================================
- describe('modelCache 切换时清除验证', () => {
- // 模拟:cache key=modelId,value=provider id
- let cache: Map<string, string>;
- 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
- });
- });
|