adaptive-memory-multi-model-router 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Provider Health Manager Tests
3
+ *
4
+ * Tests for:
5
+ * - Health recording (success/failure)
6
+ * - Error tracking and consecutive errors
7
+ * - Circuit breaker trigger (3 consecutive errors → 60s cooldown)
8
+ * - Cooldown behavior
9
+ * - Fallback chain sorting by health score
10
+ * - Probe after cooldown
11
+ */
12
+
13
+ import {
14
+ ProviderHealthManager,
15
+ ProviderHealth,
16
+ ProviderMetrics,
17
+ HealthEvent,
18
+ } from '../src/routing/providerHealth';
19
+
20
+ // Test configuration
21
+ const TEST_CONFIG = {
22
+ verbose: process.argv.includes('--verbose') || process.argv.includes('-v'),
23
+ timeout: 10000,
24
+ };
25
+
26
+ // Test utilities
27
+ function log(message: string, level: 'info' | 'error' | 'debug' = 'info') {
28
+ if (level === 'error') console.error(message);
29
+ else if (TEST_CONFIG.verbose || level !== 'debug') console.log(message);
30
+ }
31
+
32
+ let passed = 0;
33
+ let failed = 0;
34
+ let skipped = 0;
35
+
36
+ function test(name: string, fn: () => void | Promise<void>) {
37
+ try {
38
+ const result = fn();
39
+ if (result instanceof Promise) {
40
+ result.then(() => {
41
+ log(` ✅ ${name}`, 'success');
42
+ passed++;
43
+ }).catch((e) => {
44
+ log(` ❌ ${name}: ${e.message}`, 'error');
45
+ failed++;
46
+ });
47
+ } else {
48
+ log(` ✅ ${name}`, 'success');
49
+ passed++;
50
+ }
51
+ } catch (e: any) {
52
+ log(` ❌ ${name}: ${e.message}`, 'error');
53
+ failed++;
54
+ }
55
+ }
56
+
57
+ function assert(condition: boolean, message: string) {
58
+ if (!condition) throw new Error(message);
59
+ }
60
+
61
+ function assertEqual(actual: any, expected: any, message: string) {
62
+ if (actual !== expected) {
63
+ throw new Error(`${message}: expected ${expected}, got ${actual}`);
64
+ }
65
+ }
66
+
67
+ function assertApprox(actual: number, expected: number, tolerance: number, message: string) {
68
+ if (Math.abs(actual - expected) > tolerance) {
69
+ throw new Error(`${message}: expected ~${expected}, got ${actual}`);
70
+ }
71
+ }
72
+
73
+ // ============================================================
74
+ // TEST SUITE
75
+ // ============================================================
76
+
77
+ console.log('\n═══════════════════════════════════════════════════════════════');
78
+ console.log('🧪 Provider Health Manager Tests');
79
+ console.log('═══════════════════════════════════════════════════════════════\n');
80
+
81
+ // 1. Basic Health Recording
82
+ console.log('📊 1. Health Recording');
83
+ console.log('─────────────────────────────────────────────────────────────');
84
+
85
+ test('recordSuccess updates metrics correctly', () => {
86
+ const manager = new ProviderHealthManager({ windowSize: 10 });
87
+
88
+ manager.recordSuccess('openai/gpt-4o', 150);
89
+ manager.recordSuccess('openai/gpt-4o', 200);
90
+
91
+ const health = manager.getHealth('openai/gpt-4o');
92
+ assert(health !== undefined, 'health should exist');
93
+ assertEqual(health!.consecutiveErrors, 0, 'consecutive errors should be 0');
94
+ assert(health!.lastSuccess > 0, 'lastSuccess should be set');
95
+ assertApprox(health!.latency, 175, 5, 'latency should be average of 150 and 200');
96
+ });
97
+
98
+ test('recordFailure updates metrics correctly', () => {
99
+ const manager = new ProviderHealthManager({ windowSize: 10 });
100
+
101
+ manager.recordFailure('openai/gpt-4o', 'timeout');
102
+
103
+ const health = manager.getHealth('openai/gpt-4o');
104
+ assert(health !== undefined, 'health should exist');
105
+ assertEqual(health!.consecutiveErrors, 1, 'consecutive errors should be 1');
106
+ assert(health!.lastError > 0, 'lastError should be set');
107
+ assertApprox(health!.errorRate, 1.0, 0.01, 'error rate should be 1.0');
108
+ });
109
+
110
+ test('recordSuccess resets consecutive errors', () => {
111
+ const manager = new ProviderHealthManager({ windowSize: 10 });
112
+
113
+ manager.recordFailure('openai/gpt-4o', 'error1');
114
+ manager.recordFailure('openai/gpt-4o', 'error2');
115
+ manager.recordFailure('openai/gpt-4o', 'error3');
116
+ manager.recordSuccess('openai/gpt-4o', 200);
117
+
118
+ const health = manager.getHealth('openai/gpt-4o');
119
+ assertEqual(health!.consecutiveErrors, 0, 'consecutive errors should be reset');
120
+ assertEqual(health!.lastSuccess > 0, true, 'lastSuccess should be set');
121
+ });
122
+
123
+ test('rolling window limits metrics to windowSize', () => {
124
+ const manager = new ProviderHealthManager({ windowSize: 5 });
125
+
126
+ for (let i = 0; i < 10; i++) {
127
+ manager.recordSuccess('openai/gpt-4o', 100 + i);
128
+ }
129
+
130
+ const health = manager.getHealth('openai/gpt-4o');
131
+ // After 10 successes with windowSize=5, we should have only 5 metrics
132
+ // Health score should be based on recent window
133
+ assert(health !== undefined, 'health should exist');
134
+ assert(health!.healthScore > 0, 'healthScore should be > 0');
135
+ });
136
+
137
+ // 2. Error Tracking
138
+ console.log('\n❌ 2. Error Tracking');
139
+ console.log('─────────────────────────────────────────────────────────────');
140
+
141
+ test('error rate calculated correctly over window', () => {
142
+ const manager = new ProviderHealthManager({ windowSize: 10 });
143
+
144
+ // 3 successes, 1 failure
145
+ manager.recordSuccess('openai/gpt-4o', 100);
146
+ manager.recordSuccess('openai/gpt-4o', 100);
147
+ manager.recordFailure('openai/gpt-4o', 'error');
148
+ manager.recordSuccess('openai/gpt-4o', 100);
149
+
150
+ const health = manager.getHealth('openai/gpt-4o');
151
+ assertApprox(health!.errorRate, 0.25, 0.01, 'error rate should be 0.25 (1/4)');
152
+ });
153
+
154
+ test('consecutive errors increment correctly', () => {
155
+ const manager = new ProviderHealthManager({ windowSize: 10 });
156
+
157
+ manager.recordFailure('openai/gpt-4o', 'error1');
158
+ let health = manager.getHealth('openai/gpt-4o');
159
+ assertEqual(health!.consecutiveErrors, 1, 'first failure');
160
+
161
+ manager.recordFailure('openai/gpt-4o', 'error2');
162
+ health = manager.getHealth('openai/gpt-4o');
163
+ assertEqual(health!.consecutiveErrors, 2, 'second failure');
164
+
165
+ manager.recordFailure('openai/gpt-4o', 'error3');
166
+ health = manager.getHealth('openai/gpt-4o');
167
+ assertEqual(health!.consecutiveErrors, 3, 'third failure');
168
+ });
169
+
170
+ // 3. Circuit Breaker
171
+ console.log('\n🔌 3. Circuit Breaker');
172
+ console.log('─────────────────────────────────────────────────────────────');
173
+
174
+ test('3 consecutive errors trigger circuit breaker', () => {
175
+ const manager = new ProviderHealthManager({
176
+ windowSize: 10,
177
+ circuitBreakerThreshold: 3,
178
+ cooldownMs: 60000,
179
+ });
180
+
181
+ manager.recordFailure('openai/gpt-4o', 'error1');
182
+ manager.recordFailure('openai/gpt-4o', 'error2');
183
+ manager.recordFailure('openai/gpt-4o', 'error3');
184
+
185
+ const health = manager.getHealth('openai/gpt-4o');
186
+ assertEqual(health!.consecutiveErrors, 3, 'consecutive errors should be 3');
187
+ assert(health!.cooldownUntil > Date.now(), 'cooldownUntil should be set in future');
188
+ assertEqual(health!.isHealthy, false, 'provider should be unhealthy');
189
+ });
190
+
191
+ test('provider unavailable during cooldown', () => {
192
+ const manager = new ProviderHealthManager({
193
+ circuitBreakerThreshold: 3,
194
+ cooldownMs: 60000,
195
+ });
196
+
197
+ manager.recordFailure('openai/gpt-4o', 'error1');
198
+ manager.recordFailure('openai/gpt-4o', 'error2');
199
+ manager.recordFailure('openai/gpt-4o', 'error3');
200
+
201
+ const isAvailable = manager.isAvailable('openai/gpt-4o');
202
+ assertEqual(isAvailable, false, 'provider should be unavailable');
203
+ });
204
+
205
+ test('circuit breaker emits events', () => {
206
+ const manager = new ProviderHealthManager({
207
+ circuitBreakerThreshold: 3,
208
+ cooldownMs: 60000,
209
+ });
210
+
211
+ let circuitOpened = false;
212
+ let cooldownStarted = false;
213
+
214
+ manager.on(HealthEvent.CIRCUIT_OPENED, () => { circuitOpened = true; });
215
+ manager.on(HealthEvent.COOLDOWN_STARTED, () => { cooldownStarted = true; });
216
+
217
+ manager.recordFailure('openai/gpt-4o', 'error1');
218
+ manager.recordFailure('openai/gpt-4o', 'error2');
219
+ manager.recordFailure('openai/gpt-4o', 'error3');
220
+
221
+ assertEqual(circuitOpened, true, 'CIRCUIT_OPENED event should fire');
222
+ assertEqual(cooldownStarted, true, 'COOLDOWN_STARTED event should fire');
223
+ });
224
+
225
+ // 4. Cooldown
226
+ console.log('\n⏱️ 4. Cooldown');
227
+ console.log('─────────────────────────────────────────────────────────────');
228
+
229
+ test('cooldown expires after configured duration', async () => {
230
+ const manager = new ProviderHealthManager({
231
+ circuitBreakerThreshold: 3,
232
+ cooldownMs: 100, // 100ms for testing
233
+ });
234
+
235
+ manager.recordFailure('openai/gpt-4o', 'error1');
236
+ manager.recordFailure('openai/gpt-4o', 'error2');
237
+ manager.recordFailure('openai/gpt-4o', 'error3');
238
+
239
+ // Provider should be unavailable initially
240
+ assertEqual(manager.isAvailable('openai/gpt-4o'), false, 'unavailable during cooldown');
241
+
242
+ // Wait for cooldown to expire
243
+ await new Promise(resolve => setTimeout(resolve, 150));
244
+
245
+ // After cooldown, probe should be allowed
246
+ assertEqual(manager.isProbeAllowed('openai/gpt-4o'), true, 'probe should be allowed after cooldown');
247
+ });
248
+
249
+ test('success after cooldown resets state', async () => {
250
+ const manager = new ProviderHealthManager({
251
+ circuitBreakerThreshold: 3,
252
+ cooldownMs: 100,
253
+ });
254
+
255
+ manager.recordFailure('openai/gpt-4o', 'error1');
256
+ manager.recordFailure('openai/gpt-4o', 'error2');
257
+ manager.recordFailure('openai/gpt-4o', 'error3');
258
+
259
+ // Wait for cooldown
260
+ await new Promise(resolve => setTimeout(resolve, 150));
261
+
262
+ // Record success (probe request)
263
+ manager.recordSuccess('openai/gpt-4o', 150);
264
+
265
+ const health = manager.getHealth('openai/gpt-4o');
266
+ assertEqual(health!.consecutiveErrors, 0, 'consecutive errors should be reset');
267
+ assertEqual(health!.isHealthy, true, 'provider should be healthy');
268
+ assertEqual(health!.cooldownUntil, 0, 'cooldownUntil should be cleared');
269
+ });
270
+
271
+ // 5. Fallback Chain
272
+ console.log('\n🔀 5. Fallback Chain');
273
+ console.log('─────────────────────────────────────────────────────────────');
274
+
275
+ test('getFallbackChain sorts by health score', () => {
276
+ const manager = new ProviderHealthManager({ windowSize: 10 });
277
+
278
+ // Set up different health for each provider
279
+ manager.recordSuccess('fast-provider', 50); // Low latency
280
+ manager.recordSuccess('slow-provider', 500); // High latency
281
+ manager.recordFailure('broken-provider', 'error');
282
+ manager.recordFailure('broken-provider', 'error');
283
+ manager.recordFailure('broken-provider', 'error');
284
+
285
+ const chain = manager.getFallbackChain([
286
+ 'fast-provider',
287
+ 'slow-provider',
288
+ 'broken-provider',
289
+ ]);
290
+
291
+ // fast-provider should be first (healthy, low latency)
292
+ assertEqual(chain[0], 'fast-provider', 'fast-provider should be first');
293
+ // broken-provider should be last (unhealthy)
294
+ assertEqual(chain[chain.length - 1], 'broken-provider', 'broken-provider should be last');
295
+ });
296
+
297
+ test('getFallbackChain puts unavailable providers at end', () => {
298
+ const manager = new ProviderHealthManager({
299
+ circuitBreakerThreshold: 3,
300
+ cooldownMs: 60000,
301
+ });
302
+
303
+ manager.recordSuccess('healthy', 100);
304
+ manager.recordFailure('unhealthy', 'error1');
305
+ manager.recordFailure('unhealthy', 'error2');
306
+ manager.recordFailure('unhealthy', 'error3');
307
+
308
+ const chain = manager.getFallbackChain(['healthy', 'unhealthy']);
309
+
310
+ // unhealthy should be at the end
311
+ assertEqual(chain[chain.length - 1], 'unhealthy', 'unhealthy should be last');
312
+ });
313
+
314
+ test('getBestProvider returns highest scoring healthy provider', () => {
315
+ const manager = new ProviderHealthManager({ windowSize: 10 });
316
+
317
+ // Set up different latencies
318
+ manager.recordSuccess('slow', 2000);
319
+ manager.recordSuccess('fast', 100);
320
+ manager.recordSuccess('medium', 500);
321
+
322
+ const best = manager.getBestProvider(['slow', 'fast', 'medium']);
323
+ assertEqual(best, 'fast', 'fast provider should be best');
324
+ });
325
+
326
+ test('getBestProvider returns null when all unavailable', () => {
327
+ const manager = new ProviderHealthManager({
328
+ circuitBreakerThreshold: 3,
329
+ cooldownMs: 60000,
330
+ });
331
+
332
+ manager.recordFailure('provider1', 'error1');
333
+ manager.recordFailure('provider1', 'error2');
334
+ manager.recordFailure('provider1', 'error3');
335
+
336
+ manager.recordFailure('provider2', 'error1');
337
+ manager.recordFailure('provider2', 'error2');
338
+ manager.recordFailure('provider2', 'error3');
339
+
340
+ const best = manager.getBestProvider(['provider1', 'provider2']);
341
+ assertEqual(best, null, 'no provider should be available');
342
+ });
343
+
344
+ // 6. Probe After Cooldown
345
+ console.log('\n🔍 6. Probe After Cooldown');
346
+ console.log('─────────────────────────────────────────────────────────────');
347
+
348
+ test('probe allowed after cooldown expires', async () => {
349
+ const manager = new ProviderHealthManager({
350
+ circuitBreakerThreshold: 3,
351
+ cooldownMs: 50,
352
+ });
353
+
354
+ manager.recordFailure('openai/gpt-4o', 'error1');
355
+ manager.recordFailure('openai/gpt-4o', 'error2');
356
+ manager.recordFailure('openai/gpt-4o', 'error3');
357
+
358
+ // Wait for cooldown
359
+ await new Promise(resolve => setTimeout(resolve, 75));
360
+
361
+ assertEqual(manager.isProbeAllowed('openai/gpt-4o'), true, 'probe should be allowed');
362
+ });
363
+
364
+ test('probe success marks provider healthy', async () => {
365
+ const manager = new ProviderHealthManager({
366
+ circuitBreakerThreshold: 3,
367
+ cooldownMs: 50,
368
+ });
369
+
370
+ manager.recordFailure('openai/gpt-4o', 'error1');
371
+ manager.recordFailure('openai/gpt-4o', 'error2');
372
+ manager.recordFailure('openai/gpt-4o', 'error3');
373
+
374
+ // Wait for cooldown
375
+ await new Promise(resolve => setTimeout(resolve, 75));
376
+
377
+ // Probe success
378
+ manager.recordSuccess('openai/gpt-4o', 100);
379
+
380
+ const health = manager.getHealth('openai/gpt-4o');
381
+ assertEqual(health!.isHealthy, true, 'should be healthy after probe success');
382
+ assertEqual(manager.isAvailable('openai/gpt-4o'), true, 'should be available after probe success');
383
+ });
384
+
385
+ test('isAvailable checks manual disable', () => {
386
+ const manager = new ProviderHealthManager({});
387
+
388
+ manager.disableProvider('manual-disabled', 'testing');
389
+
390
+ assertEqual(manager.isAvailable('manual-disabled'), false, 'manually disabled provider unavailable');
391
+ });
392
+
393
+ // 7. Manual Enable/Disable
394
+ console.log('\n🔧 7. Manual Enable/Disable');
395
+ console.log('─────────────────────────────────────────────────────────────');
396
+
397
+ test('disableProvider marks provider unhealthy', () => {
398
+ const manager = new ProviderHealthManager({});
399
+
400
+ manager.recordSuccess('provider', 100);
401
+ manager.disableProvider('provider', 'maintenance');
402
+
403
+ const health = manager.getHealth('provider');
404
+ assertEqual(health!.isHealthy, false, 'provider should be unhealthy after disable');
405
+ });
406
+
407
+ test('enableProvider re-enables provider', () => {
408
+ const manager = new ProviderHealthManager({});
409
+
410
+ manager.recordSuccess('provider', 100);
411
+ manager.disableProvider('provider', 'maintenance');
412
+ manager.enableProvider('provider');
413
+
414
+ const health = manager.getHealth('provider');
415
+ assertEqual(health!.isHealthy, true, 'provider should be healthy after enable');
416
+ assertEqual(health!.consecutiveErrors, 0, 'consecutive errors should be reset');
417
+ });
418
+
419
+ test('enableProvider emits event', () => {
420
+ const manager = new ProviderHealthManager({});
421
+
422
+ manager.disableProvider('provider', 'maintenance');
423
+
424
+ let enabled = false;
425
+ manager.on(HealthEvent.PROVIDER_ENABLED, () => { enabled = true; });
426
+
427
+ manager.enableProvider('provider');
428
+
429
+ assertEqual(enabled, true, 'PROVIDER_ENABLED event should fire');
430
+ });
431
+
432
+ // 8. Health Score Calculation
433
+ console.log('\n📈 8. Health Score Calculation');
434
+ console.log('─────────────────────────────────────────────────────────────');
435
+
436
+ test('health score decreases with errors', () => {
437
+ const manager = new ProviderHealthManager({ windowSize: 10 });
438
+
439
+ manager.recordSuccess('provider', 100);
440
+ const healthyHealth = manager.getHealth('provider');
441
+
442
+ manager.recordFailure('provider', 'error');
443
+ manager.recordFailure('provider', 'error');
444
+ manager.recordFailure('provider', 'error');
445
+ manager.recordSuccess('provider', 100); // Reset consecutive errors
446
+ const errorHealth = manager.getHealth('provider');
447
+
448
+ // Health score should be lower with higher error rate
449
+ // Note: after reset, consecutive errors are 0, but error rate is still 3/4
450
+ assert(errorHealth!.healthScore < healthyHealth!.healthScore,
451
+ 'health score should decrease with error rate');
452
+ });
453
+
454
+ test('health score decreases with latency', () => {
455
+ const manager = new ProviderHealthManager({ windowSize: 10 });
456
+
457
+ manager.recordSuccess('fast', 50);
458
+ const fastHealth = manager.getHealth('fast');
459
+
460
+ manager.recordSuccess('slow', 5000);
461
+ const slowHealth = manager.getHealth('slow');
462
+
463
+ assert(fastHealth!.healthScore > slowHealth!.healthScore,
464
+ 'fast provider should have higher health score');
465
+ });
466
+
467
+ // 9. getAllHealth
468
+ console.log('\n📋 9. Health Stats');
469
+ console.log('─────────────────────────────────────────────────────────────');
470
+
471
+ test('getAllHealth returns all providers', () => {
472
+ const manager = new ProviderHealthManager({});
473
+
474
+ manager.recordSuccess('provider1', 100);
475
+ manager.recordSuccess('provider2', 100);
476
+ manager.recordSuccess('provider3', 100);
477
+
478
+ const allHealth = manager.getAllHealth();
479
+ assertEqual(allHealth.size, 3, 'should have 3 providers');
480
+ });
481
+
482
+ test('getStats returns correct counts', () => {
483
+ const manager = new ProviderHealthManager({});
484
+
485
+ manager.recordSuccess('healthy1', 100);
486
+ manager.recordSuccess('healthy2', 100);
487
+ manager.recordSuccess('healthy3', 100);
488
+
489
+ manager.recordFailure('cooldown1', 'error');
490
+ manager.recordFailure('cooldown1', 'error');
491
+ manager.recordFailure('cooldown1', 'error');
492
+
493
+ manager.disableProvider('disabled', 'test');
494
+
495
+ const stats = manager.getStats();
496
+ assertEqual(stats.totalProviders, 5, 'total providers should be 5');
497
+ assertEqual(stats.healthyProviders, 3, 'healthy should be 3');
498
+ assertEqual(stats.cooldownProviders, 1, 'cooldown should be 1');
499
+ assertEqual(stats.disabledProviders, 1, 'disabled should be 1');
500
+ });
501
+
502
+ // ============================================================
503
+ // SUMMARY
504
+ // ============================================================
505
+
506
+ setTimeout(() => {
507
+ console.log('\n═══════════════════════════════════════════════════════════════');
508
+ console.log('📊 Test Summary');
509
+ console.log('═══════════════════════════════════════════════════════════════');
510
+ console.log(` Total: ${passed + failed + skipped}`);
511
+ console.log(` Passed: ${passed} ✅`);
512
+ console.log(` Failed: ${failed}${failed > 0 ? ' ❌' : ''}`);
513
+ console.log(` Skipped: ${skipped}${skipped > 0 ? ' ⏭️' : ''}`);
514
+ console.log('');
515
+
516
+ if (failed > 0) {
517
+ console.log('❌ Some tests failed');
518
+ process.exit(1);
519
+ } else {
520
+ console.log('✅ All tests passed!');
521
+ process.exit(0);
522
+ }
523
+ }, 100);