adaptive-memory-multi-model-router 2.5.5 → 2.7.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,483 @@
1
+ /**
2
+ * Provider Health Manager with Circuit Breaker
3
+ *
4
+ * Intelligent failover system for A3M Router providing:
5
+ * - Rolling window metrics tracking (latency, error rate)
6
+ * - Health scoring based on latency percentile + error rate
7
+ * - Circuit breaker: 3 consecutive errors → 60s cooldown
8
+ * - Probe mode after cooldown for recovery
9
+ * - Sorted fallback chain based on health scores
10
+ *
11
+ * Usage:
12
+ * import { ProviderHealthManager, ProviderHealth } from './routing/providerHealth';
13
+ *
14
+ * const healthManager = new ProviderHealthManager();
15
+ *
16
+ * // Record outcomes
17
+ * healthManager.recordSuccess('openai/gpt-4o', 150);
18
+ * healthManager.recordFailure('anthropic/claude-3-5-sonnet', 'rate_limit');
19
+ *
20
+ * // Get health status
21
+ * const health = healthManager.getHealth('openai/gpt-4o');
22
+ *
23
+ * // Get sorted fallback chain
24
+ * const chain = healthManager.getFallbackChain(['openai/gpt-4o', 'anthropic/claude-3-5-sonnet']);
25
+ */
26
+
27
+ import { EventEmitter } from 'events';
28
+
29
+ // ============================================================
30
+ // Types
31
+ // ============================================================
32
+
33
+ export interface ProviderHealth {
34
+ /** Provider name (e.g., "openai/gpt-4o") */
35
+ name: string;
36
+ /** Rolling average latency in ms */
37
+ latency: number;
38
+ /** Error rate 0-1 */
39
+ errorRate: number;
40
+ /** Timestamp of last successful request */
41
+ lastSuccess: number;
42
+ /** Timestamp of last failed request */
43
+ lastError: number;
44
+ /** Consecutive error count */
45
+ consecutiveErrors: number;
46
+ /** Whether provider is healthy (not in cooldown) */
47
+ isHealthy: boolean;
48
+ /** Timestamp when cooldown ends (0 if not in cooldown) */
49
+ cooldownUntil: number;
50
+ /** Health score 0-1 (higher is better) */
51
+ healthScore: number;
52
+ }
53
+
54
+ export interface ProviderMetrics {
55
+ /** Provider name */
56
+ name: string;
57
+ /** Total requests sent */
58
+ totalRequests: number;
59
+ /** Successful requests */
60
+ successfulRequests: number;
61
+ /** Failed requests */
62
+ failedRequests: number;
63
+ /** Sum of latencies for averaging */
64
+ totalLatency: number;
65
+ /** Last measured latency */
66
+ lastLatency: number;
67
+ }
68
+
69
+ export interface HealthManagerConfig {
70
+ /** Window size for rolling metrics (default: 100 requests) */
71
+ windowSize?: number;
72
+ /** Consecutive errors before circuit break (default: 3) */
73
+ circuitBreakerThreshold?: number;
74
+ /** Cooldown duration in ms (default: 60000 = 60s) */
75
+ cooldownMs?: number;
76
+ /** Latency percentile for health scoring (default: 95) */
77
+ latencyPercentile?: number;
78
+ /** Weights for health score components */
79
+ weights?: {
80
+ latency: number;
81
+ errorRate: number;
82
+ consecutiveErrors: number;
83
+ };
84
+ }
85
+
86
+ // ============================================================
87
+ // Events
88
+ // ============================================================
89
+
90
+ export enum HealthEvent {
91
+ HEALTH_CHANGED = 'healthChanged',
92
+ CIRCUIT_OPENED = 'circuitOpened',
93
+ CIRCUIT_CLOSED = 'circuitClosed',
94
+ COOLDOWN_STARTED = 'cooldownStarted',
95
+ COOLDOWN_ENDED = 'cooldownEnded',
96
+ PROVIDER_DISABLED = 'providerDisabled',
97
+ PROVIDER_ENABLED = 'providerEnabled',
98
+ PROBE_ALLOWED = 'probeAllowed',
99
+ }
100
+
101
+ // ============================================================
102
+ // ProviderHealthManager
103
+ // ============================================================
104
+
105
+ export class ProviderHealthManager extends EventEmitter {
106
+ // Rolling window metrics per provider
107
+ private metrics: Map<string, ProviderMetrics[]> = new Map();
108
+
109
+ // Current health state per provider
110
+ private health: Map<string, ProviderHealth> = new Map();
111
+
112
+ // Disabled providers (manual disable)
113
+ private disabled: Map<string, { reason: string; until: number }> = new Map();
114
+
115
+ // Config
116
+ private config: Required<HealthManagerConfig>;
117
+
118
+ constructor(config: HealthManagerConfig = {}) {
119
+ super();
120
+ this.config = {
121
+ windowSize: config.windowSize ?? 100,
122
+ circuitBreakerThreshold: config.circuitBreakerThreshold ?? 3,
123
+ cooldownMs: config.cooldownMs ?? 60000,
124
+ latencyPercentile: config.latencyPercentile ?? 95,
125
+ weights: {
126
+ latency: config.weights?.latency ?? 0.3,
127
+ errorRate: config.weights?.errorRate ?? 0.5,
128
+ consecutiveErrors: config.weights?.consecutiveErrors ?? 0.2,
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Record a successful request
135
+ */
136
+ recordSuccess(provider: string, latencyMs: number): void {
137
+ this.ensureProviderExists(provider);
138
+
139
+ const now = Date.now();
140
+ const window = this.getMetricsWindow(provider);
141
+ window.push({
142
+ name: provider,
143
+ totalRequests: 1,
144
+ successfulRequests: 1,
145
+ failedRequests: 0,
146
+ totalLatency: latencyMs,
147
+ lastLatency: latencyMs,
148
+ });
149
+
150
+ // Trim to window size
151
+ while (window.length > this.config.windowSize) {
152
+ window.shift();
153
+ }
154
+
155
+ // Update health state
156
+ const health = this.health.get(provider)!;
157
+ health.lastSuccess = now;
158
+ health.consecutiveErrors = 0;
159
+ health.cooldownUntil = 0;
160
+ health.isHealthy = true;
161
+
162
+ // Recalculate health score
163
+ this.recalculateHealthScore(provider);
164
+
165
+ this.emit(HealthEvent.HEALTH_CHANGED, health);
166
+ }
167
+
168
+ /**
169
+ * Record a failed request
170
+ */
171
+ recordFailure(provider: string, error: string): void {
172
+ this.ensureProviderExists(provider);
173
+
174
+ const now = Date.now();
175
+ const window = this.getMetricsWindow(provider);
176
+ window.push({
177
+ name: provider,
178
+ totalRequests: 1,
179
+ successfulRequests: 0,
180
+ failedRequests: 1,
181
+ totalLatency: 0,
182
+ lastLatency: 0,
183
+ });
184
+
185
+ // Trim to window size
186
+ while (window.length > this.config.windowSize) {
187
+ window.shift();
188
+ }
189
+
190
+ // Update health state
191
+ const health = this.health.get(provider)!;
192
+ health.lastError = now;
193
+ health.consecutiveErrors++;
194
+
195
+ // Check circuit breaker
196
+ if (health.consecutiveErrors >= this.config.circuitBreakerThreshold) {
197
+ health.cooldownUntil = now + this.config.cooldownMs;
198
+ health.isHealthy = false;
199
+ this.emit(HealthEvent.CIRCUIT_OPENED, {
200
+ provider,
201
+ consecutiveErrors: health.consecutiveErrors,
202
+ cooldownUntil: health.cooldownUntil,
203
+ reason: error,
204
+ });
205
+ this.emit(HealthEvent.COOLDOWN_STARTED, {
206
+ provider,
207
+ duration: this.config.cooldownMs,
208
+ reason: error,
209
+ });
210
+ }
211
+
212
+ this.recalculateHealthScore(provider);
213
+ this.emit(HealthEvent.HEALTH_CHANGED, health);
214
+ }
215
+
216
+ /**
217
+ * Get current health for a provider
218
+ */
219
+ getHealth(provider: string): ProviderHealth | undefined {
220
+ this.ensureProviderExists(provider);
221
+ return { ...this.health.get(provider)! };
222
+ }
223
+
224
+ /**
225
+ * Get all provider health statuses
226
+ */
227
+ getAllHealth(): Map<string, ProviderHealth> {
228
+ const result = new Map<string, ProviderHealth>();
229
+ for (const [name, health] of this.health.entries()) {
230
+ result.set(name, { ...health });
231
+ }
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * Check if a provider is available (healthy and not in cooldown/manual disable)
237
+ */
238
+ isAvailable(provider: string): boolean {
239
+ const health = this.health.get(provider);
240
+ if (!health) return false;
241
+
242
+ // Check manual disable
243
+ const disabled = this.disabled.get(provider);
244
+ if (disabled && disabled.until > Date.now()) {
245
+ return false;
246
+ }
247
+
248
+ // Check cooldown
249
+ if (health.cooldownUntil > Date.now()) {
250
+ return false;
251
+ }
252
+
253
+ return health.isHealthy;
254
+ }
255
+
256
+ /**
257
+ * Check if cooldown has expired and probe is allowed
258
+ */
259
+ isProbeAllowed(provider: string): boolean {
260
+ const health = this.health.get(provider);
261
+ if (!health) return false;
262
+
263
+ // If not in cooldown, no probe needed
264
+ if (health.cooldownUntil === 0) return true;
265
+
266
+ // If cooldown has expired
267
+ if (health.cooldownUntil <= Date.now()) {
268
+ // Only allow one probe request per cooldown period
269
+ // After probe (marked by consecutiveErrors reset), normal requests allowed
270
+ return true;
271
+ }
272
+
273
+ return false;
274
+ }
275
+
276
+ /**
277
+ * Get the best provider from a list based on health scores
278
+ */
279
+ getBestProvider(providers: string[]): string | null {
280
+ const available = providers.filter(p => this.isAvailable(p));
281
+ if (available.length === 0) return null;
282
+
283
+ return available.reduce((best, current) => {
284
+ const health = this.health.get(current);
285
+ const bestHealth = this.health.get(best);
286
+ if (!health || !bestHealth) return current;
287
+ return health.healthScore >= bestHealth.healthScore ? current : best;
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Get sorted fallback chain based on health scores
293
+ * Returns providers sorted by health score (descending)
294
+ */
295
+ getFallbackChain(providers: string[]): string[] {
296
+ // Score each provider
297
+ const scored = providers.map(p => ({
298
+ provider: p,
299
+ score: this.isAvailable(p) ? (this.health.get(p)?.healthScore ?? 0) : -1,
300
+ }));
301
+
302
+ // Sort by health score (descending), unavailable at end
303
+ scored.sort((a, b) => {
304
+ if (a.score === -1 && b.score === -1) return 0;
305
+ if (a.score === -1) return 1;
306
+ if (b.score === -1) return -1;
307
+ return b.score - a.score;
308
+ });
309
+
310
+ return scored.map(s => s.provider);
311
+ }
312
+
313
+ /**
314
+ * Mark provider as disabled (manual circuit breaker)
315
+ */
316
+ disableProvider(provider: string, reason: string): void {
317
+ const until = Number.MAX_SAFE_INTEGER; // Manual disable until explicitly enabled
318
+ this.disabled.set(provider, { reason, until });
319
+
320
+ const health = this.health.get(provider);
321
+ if (health) {
322
+ health.isHealthy = false;
323
+ }
324
+
325
+ this.emit(HealthEvent.PROVIDER_DISABLED, { provider, reason });
326
+ }
327
+
328
+ /**
329
+ * Enable a previously disabled provider
330
+ */
331
+ enableProvider(provider: string): void {
332
+ this.disabled.delete(provider);
333
+
334
+ const health = this.health.get(provider);
335
+ if (health) {
336
+ health.isHealthy = true;
337
+ health.consecutiveErrors = 0;
338
+ health.cooldownUntil = 0;
339
+ }
340
+
341
+ this.emit(HealthEvent.PROVIDER_ENABLED, { provider });
342
+ }
343
+
344
+ /**
345
+ * Clear cooldown and reset circuit breaker for a provider
346
+ */
347
+ resetCircuitBreaker(provider: string): void {
348
+ const health = this.health.get(provider);
349
+ if (health) {
350
+ health.consecutiveErrors = 0;
351
+ health.cooldownUntil = 0;
352
+ health.isHealthy = true;
353
+ this.emit(HealthEvent.CIRCUIT_CLOSED, { provider });
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Get health stats for monitoring
359
+ */
360
+ getStats(): {
361
+ totalProviders: number;
362
+ healthyProviders: number;
363
+ cooldownProviders: number;
364
+ disabledProviders: number;
365
+ avgHealthScore: number;
366
+ } {
367
+ let healthyCount = 0;
368
+ let cooldownCount = 0;
369
+ let disabledCount = 0;
370
+ let totalScore = 0;
371
+
372
+ for (const [name, health] of this.health.entries()) {
373
+ totalScore += health.healthScore;
374
+
375
+ if (!health.isHealthy && health.cooldownUntil > Date.now()) {
376
+ cooldownCount++;
377
+ } else if (health.isHealthy) {
378
+ healthyCount++;
379
+ }
380
+
381
+ if (this.disabled.has(name)) {
382
+ disabledCount++;
383
+ }
384
+ }
385
+
386
+ const total = this.health.size;
387
+ return {
388
+ totalProviders: total,
389
+ healthyProviders: healthyCount,
390
+ cooldownProviders: cooldownCount,
391
+ disabledProviders: disabledCount,
392
+ avgHealthScore: total > 0 ? totalScore / total : 0,
393
+ };
394
+ }
395
+
396
+ // ============================================================
397
+ // Private Methods
398
+ // ============================================================
399
+
400
+ private ensureProviderExists(provider: string): void {
401
+ if (!this.health.has(provider)) {
402
+ const now = Date.now();
403
+ this.health.set(provider, {
404
+ name: provider,
405
+ latency: 0,
406
+ errorRate: 0,
407
+ lastSuccess: 0,
408
+ lastError: 0,
409
+ consecutiveErrors: 0,
410
+ isHealthy: true,
411
+ cooldownUntil: 0,
412
+ healthScore: 1.0,
413
+ });
414
+ this.metrics.set(provider, []);
415
+ }
416
+ }
417
+
418
+ private getMetricsWindow(provider: string): ProviderMetrics[] {
419
+ return this.metrics.get(provider) ?? [];
420
+ }
421
+
422
+ private recalculateHealthScore(provider: string): void {
423
+ const window = this.getMetricsWindow(provider);
424
+ const health = this.health.get(provider);
425
+ if (!health || window.length === 0) return;
426
+
427
+ // Calculate error rate
428
+ const totalRequests = window.reduce((sum, m) => sum + m.totalRequests, 0);
429
+ const failedRequests = window.reduce((sum, m) => sum + m.failedRequests, 0);
430
+ const errorRate = totalRequests > 0 ? failedRequests / totalRequests : 0;
431
+ health.errorRate = errorRate;
432
+
433
+ // Calculate latency metrics
434
+ const latencies = window.filter(m => m.totalLatency > 0).map(m => m.lastLatency);
435
+ const avgLatency = latencies.length > 0
436
+ ? latencies.reduce((a, b) => a + b, 0) / latencies.length
437
+ : 0;
438
+ health.latency = avgLatency;
439
+
440
+ // Percentile latency (simplified: use avg latency as proxy)
441
+ // For true percentile, we'd need raw data points
442
+ const latencyScore = this.calculateLatencyScore(avgLatency);
443
+
444
+ // Health score: weighted combination
445
+ // Higher error rate = lower score, higher latency = lower score
446
+ const errorScore = 1 - errorRate;
447
+ const consecutiveScore = Math.max(0, 1 - (health.consecutiveErrors / this.config.circuitBreakerThreshold));
448
+
449
+ const score =
450
+ this.config.weights.latency * latencyScore +
451
+ this.config.weights.errorRate * errorScore +
452
+ this.config.weights.consecutiveErrors * consecutiveScore;
453
+
454
+ health.healthScore = Math.max(0, Math.min(1, score));
455
+ }
456
+
457
+ private calculateLatencyScore(avgLatency: number): number {
458
+ // Latency score: 1 at 0ms, 0 at 10000ms+, with exponential decay
459
+ // Configurable thresholds could be passed in
460
+ const latencyThresholds = {
461
+ excellent: 100, // 100ms - score 1.0
462
+ good: 500, // 500ms - score 0.8
463
+ acceptable: 1000, // 1s - score 0.6
464
+ poor: 3000, // 3s - score 0.3
465
+ terrible: 10000, // 10s+ - score 0.0
466
+ };
467
+
468
+ if (avgLatency <= 0) return 1.0;
469
+ if (avgLatency <= latencyThresholds.excellent) return 1.0;
470
+ if (avgLatency >= latencyThresholds.terrible) return 0.0;
471
+
472
+ // Exponential interpolation
473
+ const k = 0.003; // decay constant
474
+ return Math.exp(-k * avgLatency);
475
+ }
476
+ }
477
+
478
+ // ============================================================
479
+ // Exports
480
+ // ============================================================
481
+
482
+ export { ProviderHealthManager };
483
+ export default ProviderHealthManager;