model-switch-e2e.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /**
  2. * LLM 模型切换 - 缓存清除 & 端到端测试
  3. *
  4. * 验证完整的 429 错误 → 切换供应商 → 使用新供应商的流程。
  5. * 重点测试:modelCache 在切换时被正确清除,不会返回旧供应商的客户端。
  6. */
  7. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  8. // ============================================================
  9. // 模拟 modelCache 行为
  10. // ============================================================
  11. describe('modelCache 切换时清除验证', () => {
  12. // 模拟:cache key=modelId,value=provider id
  13. let cache: Map<string, string>;
  14. beforeEach(() => {
  15. cache = new Map();
  16. });
  17. /**
  18. * 模拟 switchToNextVendorModel 的 invalidateAndReturn:
  19. * 切换时必须清除新旧 modelId 的缓存,防止返回旧供应商的客户端
  20. */
  21. function simulateSwitch(currentModelId: string, nextModelId: string): string {
  22. cache.delete(nextModelId);
  23. cache.delete(currentModelId);
  24. return nextModelId;
  25. }
  26. it('【关键】同名模型切换 → 缓存被清除,不会返回旧供应商', () => {
  27. // 场景:MiniMax-M3 在 Key1 上,缓存了 Key1 的客户端
  28. cache.set('MiniMax-M3', 'MiniMax-Key1');
  29. // 429 后切换到 Key2(也是 MiniMax-M3)
  30. simulateSwitch('MiniMax-M3', 'MiniMax-M3');
  31. // 缓存已被清除
  32. expect(cache.has('MiniMax-M3')).toBe(false);
  33. // 下次 getLLM('MiniMax-M3') 会重新 createClient → 走 Key2
  34. // 而不会返回 Key1 的旧客户端
  35. });
  36. it('【关键】不同名模型切换 → 新旧缓存都被清除', () => {
  37. // MiniMax-M3 → qwen3.6-plus
  38. cache.set('MiniMax-M3', 'MiniMax-Key1');
  39. simulateSwitch('MiniMax-M3', 'qwen3.6-plus');
  40. // 两个 modelId 的缓存都被清除
  41. expect(cache.has('MiniMax-M3')).toBe(false);
  42. expect(cache.has('qwen3.6-plus')).toBe(false);
  43. });
  44. it('别名模型切换 → 缓存被清除', () => {
  45. cache.set('minimax-m2.7', 'MiniMax-Key1');
  46. simulateSwitch('minimax-m2.7', 'MiniMax-M2.7'); // 火山引擎的别名
  47. expect(cache.has('minimax-m2.7')).toBe(false);
  48. expect(cache.has('MiniMax-M2.7')).toBe(false);
  49. });
  50. });
  51. // ============================================================
  52. // 完整调用链模拟:从 LLM 调用失败到切换成功
  53. // ============================================================
  54. interface MockProvider {
  55. name: string;
  56. displayName: string;
  57. models: string[];
  58. exhausted: boolean;
  59. }
  60. describe('完整切换链路模拟', () => {
  61. let providers: MockProvider[];
  62. let currentModelId: string;
  63. let switchLog: string[];
  64. beforeEach(() => {
  65. switchLog = [];
  66. providers = [
  67. { name: 'minimax-key1', displayName: 'MiniMax Key1', models: ['MiniMax-M3', 'MiniMax-M2.7'], exhausted: false },
  68. { name: 'minimax-key2', displayName: 'MiniMax Key2', models: ['MiniMax-M3', 'MiniMax-M2.7'], exhausted: false },
  69. { name: 'ali-bailian', displayName: '阿里云百炼', models: ['qwen3.6-plus', 'qwen3.5-flash'], exhausted: false },
  70. { name: 'volcengine', displayName: '火山引擎', models: ['doubao-seed-2.0', 'MiniMax-M2.7'], exhausted: false },
  71. ];
  72. currentModelId = 'MiniMax-M3';
  73. });
  74. function findProvider(modelId: string): MockProvider | undefined {
  75. return providers.find(p => !p.exhausted && p.models.includes(modelId));
  76. }
  77. function markExhausted(providerName: string): void {
  78. const p = providers.find(p => p.name === providerName);
  79. if (p) {
  80. p.exhausted = true;
  81. switchLog.push(`[EXHAUSTED] ${p.displayName}`);
  82. }
  83. }
  84. function switchToNext(currentModelId: string, skipProviderName?: string): string | null {
  85. const available = providers.filter(p => !p.exhausted);
  86. if (available.length === 0) {
  87. switchLog.push('[SWITCH] 无可用供应商');
  88. return null;
  89. }
  90. // 确定起始位(匹配修复后的 switchToNextVendorModel 逻辑)
  91. let startIndex = 0;
  92. if (skipProviderName) {
  93. const allEnabled = providers; // 全部 provider(含耗尽的)
  94. const exhaustedPos = allEnabled.findIndex(p => p.name === skipProviderName);
  95. if (exhaustedPos >= 0) {
  96. for (let offset = 1; offset <= allEnabled.length; offset++) {
  97. const checkName = allEnabled[(exhaustedPos + offset) % allEnabled.length].name;
  98. const availIdx = available.findIndex(p => p.name === checkName);
  99. if (availIdx >= 0) {
  100. startIndex = availIdx;
  101. break;
  102. }
  103. }
  104. }
  105. } else {
  106. const currentNode = findProvider(currentModelId);
  107. if (currentNode) {
  108. startIndex = (available.findIndex(p => p.name === currentNode.name) + 1) % available.length;
  109. }
  110. }
  111. // 第一轮:优先找同名模型
  112. for (let i = 0; i < available.length; i++) {
  113. const idx = (startIndex + i) % available.length;
  114. const p = available[idx];
  115. if (p.models.includes(currentModelId)) {
  116. switchLog.push(`[SWITCH] → ${p.displayName} (同模型: ${currentModelId})`);
  117. return currentModelId;
  118. }
  119. }
  120. // 第二轮:无同名模型,fallback 用第一个可用模型
  121. for (let i = 0; i < available.length; i++) {
  122. const idx = (startIndex + i) % available.length;
  123. const p = available[idx];
  124. if (p.models.length > 0) {
  125. switchLog.push(`[SWITCH] → ${p.displayName} (模型: ${p.models[0]})`);
  126. return p.models[0];
  127. }
  128. }
  129. return null;
  130. }
  131. it('429 错误 → Key1 耗尽 → 同模型切到 Key2 → 成功', () => {
  132. // Step 1: 初始调用 MiniMax-M3 → Key1
  133. expect(findProvider('MiniMax-M3')!.name).toBe('minimax-key1');
  134. // Step 2: 429 错误!标记 Key1 耗尽
  135. markExhausted('minimax-key1');
  136. expect(providers[0].exhausted).toBe(true);
  137. // Step 3: 切换(skipProviderName=被耗尽的 Key1)
  138. const nextModel = switchToNext('MiniMax-M3', 'minimax-key1');
  139. expect(nextModel).toBe('MiniMax-M3'); // 同名模型 → Key2
  140. expect(findProvider('MiniMax-M3')!.name).toBe('minimax-key2'); // 现在用 Key2 了
  141. expect(switchLog).toContain('[EXHAUSTED] MiniMax Key1');
  142. expect(switchLog).toContain('[SWITCH] → MiniMax Key2 (同模型: MiniMax-M3)');
  143. });
  144. it('Key1+Key2 都耗尽 → 切到阿里云百炼(不同名模型)', () => {
  145. markExhausted('minimax-key1');
  146. markExhausted('minimax-key2');
  147. const nextModel = switchToNext('MiniMax-M3');
  148. expect(nextModel).toBe('qwen3.6-plus'); // 回退到百炼的第一个模型
  149. expect(findProvider('qwen3.6-plus')!.name).toBe('ali-bailian');
  150. expect(switchLog).toContain('[SWITCH] → 阿里云百炼 (模型: qwen3.6-plus)');
  151. });
  152. it('全部 4 个供应商耗尽 → 返回 null', () => {
  153. providers.forEach(p => { p.exhausted = true; });
  154. const nextModel = switchToNext('MiniMax-M3');
  155. expect(nextModel).toBeNull();
  156. expect(switchLog).toContain('[SWITCH] 无可用供应商');
  157. });
  158. it('耗尽后若有新供应商恢复 → 可以切过去', () => {
  159. markExhausted('minimax-key1');
  160. // Key2 可用(跳过已耗尽的 Key1)
  161. let next = switchToNext('MiniMax-M3', 'minimax-key1');
  162. expect(next).toBe('MiniMax-M3');
  163. expect(findProvider('MiniMax-M3')!.name).toBe('minimax-key2');
  164. // Key2 也耗尽
  165. markExhausted('minimax-key2');
  166. // Key1 恢复(TTL 到期)
  167. providers[0].exhausted = false;
  168. // 从 Key2 之后找 → 回到 Key1
  169. next = switchToNext('MiniMax-M3', 'minimax-key2');
  170. expect(next).toBe('MiniMax-M3');
  171. expect(findProvider('MiniMax-M3')!.name).toBe('minimax-key1'); // 轮转回到 Key1
  172. });
  173. });