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.
- package/README.md +149 -22
- package/dist/cache/semanticCache.d.ts +54 -22
- package/dist/cache/semanticCache.js +230 -86
- package/dist/cache/semanticCache.js.map +1 -1
- package/dist/cost/budgetEnforcer.d.ts +108 -0
- package/dist/cost/budgetEnforcer.js +295 -0
- package/dist/cost/budgetEnforcer.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -1
- package/dist/routing/providerHealth.d.ts +154 -0
- package/dist/routing/providerHealth.js +371 -0
- package/dist/routing/providerHealth.js.map +1 -0
- package/dist/routing/providerRetry.d.ts +110 -0
- package/dist/routing/providerRetry.js +460 -0
- package/dist/routing/providerRetry.js.map +1 -0
- package/dist/sdk.d.ts +124 -0
- package/dist/sdk.js +109 -100
- package/package.json +3 -2
- package/src/cache/semanticCache.ts +293 -103
- package/src/cost/budgetEnforcer.ts +358 -0
- package/src/index.ts +20 -0
- package/src/routing/providerHealth.ts +483 -0
- package/src/routing/providerRetry.ts +578 -0
- package/test/test_budgetEnforcer.ts +310 -0
- package/test/test_providerHealth.ts +523 -0
- package/test/test_providerRetry.ts +348 -0
- package/test/test_semanticCache.ts +507 -0
|
@@ -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;
|