adaptive-memory-multi-model-router 2.15.2 → 2.15.4

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.
@@ -16,6 +16,86 @@ import { estimateCost } from "../utils/tokenUtils";
16
16
  import { logScaleCostScore } from "../utils/costUtils";
17
17
  import { quickselectTopK, selectTop } from "../utils/sorting";
18
18
 
19
+ // ============================================================
20
+ // TRAFFIC SHARES FOR ADAPTIVE DIVERSITY WEIGHT (EXP3-inspired)
21
+ // Based on negative frequency-dependent selection — as provider i's traffic
22
+ // share f_i grows above uniform (1/n), impose a diversity penalty.
23
+ // Formula: gamma = sqrt(n * log(n) / (T * G^2)) [Auer et al. 2002, EXP3]
24
+ // diversityPenalty_i = gamma * (f_i - 1/n)
25
+ // ============================================================
26
+
27
+ /** Number of times each provider has been selected (cumulative) */
28
+ const _selectionCount: Record<string, number> = {};
29
+
30
+ /** Total routing decisions since last reset */
31
+ let _totalDecisions = 0;
32
+
33
+ /** Reward range estimate for gamma computation (quality_score scale 0-1) */
34
+ const _REWARD_RANGE = 1.0;
35
+
36
+ /**
37
+ * Compute the EXP3-inspired diversity penalty for a provider.
38
+ * Based on: Auer et al. 2002 — "The Nonstochastic Multiarmed Bandit Problem"
39
+ * and the insight that as provider share f_i grows above uniform (1/n),
40
+ * imposing a penalty proportional to (f_i - 1/n) prevents monoculture
41
+ * (competitive exclusion — negative frequency-dependent selection).
42
+ *
43
+ * @param providerName - provider key
44
+ * @param nProviders - total number of available providers
45
+ * @param recentDecayFactor - optional decay for non-stationary environments
46
+ */
47
+ function computeDiversityPenalty(
48
+ providerName: string,
49
+ nProviders: number,
50
+ recentDecayFactor = 0.0
51
+ ): number {
52
+ if (nProviders < 2) return 0;
53
+ if (_totalDecisions < 2) return 0;
54
+
55
+ const share = _selectionCount[providerName] || 0;
56
+ const uniform = 1.0 / nProviders;
57
+
58
+ // Deviation from uniform distribution (can be negative if under-used)
59
+ const deviation = share - uniform;
60
+ if (Math.abs(deviation) < 1e-6) return 0;
61
+
62
+ // EXP3 learning rate: gamma = sqrt(n * log(n) / (T * G^2))
63
+ // G = reward range (quality_score in [0,1] → G = 1)
64
+ const T = Math.max(_totalDecisions, 10);
65
+ const gamma = Math.sqrt((nProviders * Math.log(nProviders)) / (T * _REWARD_RANGE * _REWARD_RANGE));
66
+
67
+ // Clamp gamma to prevent extreme penalties when T is very small
68
+ const clampedGamma = Math.min(gamma, 0.5);
69
+
70
+ // Optional: decay factor for non-stationary environments
71
+ // (higher share = stronger penalty, but decays over time)
72
+ // Currently disabled (recentDecayFactor=0) — re-enable if providers change frequently
73
+ const effectiveGamma = clampedGamma * (1.0 - recentDecayFactor);
74
+
75
+ // Penalty is proportional to how far above uniform the provider's share is
76
+ // (negative deviation = under-used = reward, not penalty)
77
+ if (deviation <= 0) return 0; // Reward already captured implicitly by below-uniform penalty
78
+
79
+ return effectiveGamma * deviation;
80
+ }
81
+
82
+ /**
83
+ * Record that a provider was selected (call after each routing decision).
84
+ * Used to compute the diversity penalty on subsequent decisions.
85
+ */
86
+ function recordSelection(providerName: string): void {
87
+ _selectionCount[providerName] = (_selectionCount[providerName] || 0) + 1;
88
+ _totalDecisions += 1;
89
+ }
90
+
91
+ /**
92
+ * Reset traffic tracking (call when provider pool changes or for eval resets).
93
+ */
94
+ function resetDiversityState(): void {
95
+ Object.keys(_selectionCount).forEach(k => delete _selectionCount[k]);
96
+ _totalDecisions = 0;
97
+ }
98
+
19
99
  // ============================================================
20
100
  // CACHE FOR MODEL PROFILES (avoids O(n*m) rebuild on every routeQuery)
21
101
  // ============================================================
@@ -767,6 +847,24 @@ export function routeQuery(prompt: string, available_models?: string[], budget_m
767
847
 
768
848
  let topCandidates = quickselectTopK(candidates, 4, scoreFn);
769
849
 
850
+ // === DIVERSITY PENALTY (EXP3-inspired, negative frequency-dependence) ===
851
+ // As provider share f_i grows above uniform (1/n), impose a diversity penalty.
852
+ // This prevents provider monoculture (competitive exclusion) — the core insight
853
+ // from the Paradox of the Plankton / negative frequency-dependent selection.
854
+ // Penalty is applied only to the quality dimension (complexity_bias-weighted),
855
+ // since cost already naturally distributes across providers.
856
+ // See: Auer et al. 2002, "The Nonstochastic Multiarmed Bandit Problem"
857
+ const nProviders = candidates.length;
858
+ for (const c of candidates) {
859
+ const divPenalty = computeDiversityPenalty(c.name, nProviders);
860
+ // Diversity penalty applies to quality dimension only (cost already disperses traffic)
861
+ c.total_score -= divPenalty * complexity_bias;
862
+ }
863
+
864
+ // Re-rank after diversity adjustment
865
+ candidates.sort((a, b) => b.total_score - a.total_score);
866
+ topCandidates = candidates.slice(0, 4);
867
+
770
868
  // Adaptive quality floor: for complex queries, prefer models above the floor
771
869
  if (adaptiveQualityFloor > 0) {
772
870
  const qualified = topCandidates.filter(c => c.quality_score >= adaptiveQualityFloor);
@@ -779,6 +877,9 @@ export function routeQuery(prompt: string, available_models?: string[], budget_m
779
877
  const primary = topCandidates[0];
780
878
  const secondary = topCandidates.slice(1, 3);
781
879
 
880
+ // Record selection for diversity tracking (after final decision, before return)
881
+ recordSelection(primary.name);
882
+
782
883
  // Calculate confidence based on score gap
783
884
  let confidence = 0.5;
784
885
  if (candidates.length > 1) {
@@ -49,6 +49,17 @@ export interface ProviderHealth {
49
49
  cooldownUntil: number;
50
50
  /** Health score 0-1 (higher is better) */
51
51
  healthScore: number;
52
+ /** === MVT RATE-LIMIT TRACKING (Charnov 1976 optimal foraging) === */
53
+ /** Tokens used in current rate-limit window */
54
+ tokensUsedThisWindow: number;
55
+ /** Timestamp when current rate-limit window started */
56
+ rateLimitWindowStart: number;
57
+ /** Maximum tokens per rate-limit window */
58
+ rateLimitTokens: number;
59
+ /** Rate-limit window duration in ms (default: 60000 = 1 min) */
60
+ rateLimitWindowMs: number;
61
+ /** Rolling average tokens per successful request */
62
+ avgTokensPerRequest: number;
52
63
  }
53
64
 
54
65
  export interface ProviderMetrics {
@@ -64,6 +75,8 @@ export interface ProviderMetrics {
64
75
  totalLatency: number;
65
76
  /** Last measured latency */
66
77
  lastLatency: number;
78
+ /** Tokens consumed this request (for rate-limit tracking) */
79
+ tokensUsed: number;
67
80
  }
68
81
 
69
82
  export interface HealthManagerConfig {
@@ -132,8 +145,11 @@ export class ProviderHealthManager extends EventEmitter {
132
145
 
133
146
  /**
134
147
  * Record a successful request
148
+ * @param provider - provider name
149
+ * @param latencyMs - response latency in ms
150
+ * @param tokensUsed - tokens consumed this request (for MVT rate-limit tracking)
135
151
  */
136
- recordSuccess(provider: string, latencyMs: number): void {
152
+ recordSuccess(provider: string, latencyMs: number, tokensUsed: number = 0): void {
137
153
  this.ensureProviderExists(provider);
138
154
 
139
155
  const now = Date.now();
@@ -145,6 +161,7 @@ export class ProviderHealthManager extends EventEmitter {
145
161
  failedRequests: 0,
146
162
  totalLatency: latencyMs,
147
163
  lastLatency: latencyMs,
164
+ tokensUsed,
148
165
  });
149
166
 
150
167
  // Trim to window size
@@ -152,8 +169,29 @@ export class ProviderHealthManager extends EventEmitter {
152
169
  window.shift();
153
170
  }
154
171
 
155
- // Update health state
172
+ // === MVT RATE-LIMIT WINDOW MANAGEMENT ===
156
173
  const health = this.health.get(provider)!;
174
+ const windowElapsed = now - health.rateLimitWindowStart;
175
+
176
+ // If window has elapsed (rolled over), reset the token counter
177
+ if (windowElapsed >= health.rateLimitWindowMs) {
178
+ health.tokensUsedThisWindow = 0;
179
+ health.rateLimitWindowStart = now;
180
+ }
181
+
182
+ // Accumulate tokens used
183
+ if (tokensUsed > 0) {
184
+ health.tokensUsedThisWindow += tokensUsed;
185
+ }
186
+
187
+ // Update rolling average tokens per request
188
+ const successfulReqs = window.filter(m => m.successfulRequests > 0);
189
+ if (successfulReqs.length > 0) {
190
+ const totalTokens = successfulReqs.reduce((s, m) => s + (m.tokensUsed || 0), 0);
191
+ health.avgTokensPerRequest = totalTokens / successfulReqs.length;
192
+ }
193
+
194
+ // Update health state
157
195
  health.lastSuccess = now;
158
196
  health.consecutiveErrors = 0;
159
197
  health.cooldownUntil = 0;
@@ -180,6 +218,7 @@ export class ProviderHealthManager extends EventEmitter {
180
218
  failedRequests: 1,
181
219
  totalLatency: 0,
182
220
  lastLatency: 0,
221
+ tokensUsed: 0,
183
222
  });
184
223
 
185
224
  // Trim to window size
@@ -310,6 +349,129 @@ export class ProviderHealthManager extends EventEmitter {
310
349
  return scored.map(s => s.provider);
311
350
  }
312
351
 
352
+ // ================================================================
353
+ // MVT RATE-LIMIT ROTATION (Charnov 1976 Optimal Foraging)
354
+ // ================================================================
355
+
356
+ /**
357
+ * Configure rate-limit parameters for a provider.
358
+ * Call this once during provider registration with the provider's actual limits.
359
+ *
360
+ * @param provider - provider name
361
+ * @param rateLimitTokens - max tokens per window (e.g., 1000000 for 1M)
362
+ * @param rateLimitWindowMs - window duration in ms (e.g., 60000 for 1 min)
363
+ */
364
+ setRateLimitConfig(provider: string, rateLimitTokens: number, rateLimitWindowMs: number): void {
365
+ this.ensureProviderExists(provider);
366
+ const health = this.health.get(provider)!;
367
+ health.rateLimitTokens = rateLimitTokens;
368
+ health.rateLimitWindowMs = rateLimitWindowMs;
369
+ // Reset window on config change
370
+ health.tokensUsedThisWindow = 0;
371
+ health.rateLimitWindowStart = Date.now();
372
+ }
373
+
374
+ /**
375
+ * Estimate cold-start latency for switching to a fallback provider.
376
+ * Based on the provider's average latency as a proxy.
377
+ * In production, this would include TLS handshake, DNS, and model warmup costs.
378
+ */
379
+ private estimateColdStartLatency(fallbackProvider: string): number {
380
+ const fallbackHealth = this.health.get(fallbackProvider);
381
+ if (!fallbackHealth) return 1000; // conservative default
382
+
383
+ const baseLatency = fallbackHealth.latency || 500;
384
+ // Cold start typically 1.5-3x warm latency depending on provider
385
+ // Add TLS + DNS overhead (typically 50-200ms)
386
+ const coldStartMultiplier = 2.0;
387
+ const tlsOverhead = 100;
388
+ return baseLatency * coldStartMultiplier + tlsOverhead;
389
+ }
390
+
391
+ /**
392
+ * Should we rotate away from this provider due to rate-limit depletion?
393
+ *
394
+ * Implements Charnov's Marginal Value Theorem (1976):
395
+ * g'(t*) = g(t*) / (t* + τ)
396
+ *
397
+ * where:
398
+ * g(t*) = cumulative successful tokens used so far in this window
399
+ * g'(t*) = marginal rate = remaining tokens / time remaining in window
400
+ * τ = cold-start latency for the fallback provider
401
+ *
402
+ * LEAVE when marginal rate ≤ average rate (including switch cost).
403
+ * STAY when marginal rate > average rate (still worth staying).
404
+ *
405
+ * @param provider - current provider to evaluate
406
+ * @param fallbackProvider - candidate fallback provider
407
+ * @returns true if rotation is recommended (marginal rate ≤ break-even rate)
408
+ */
409
+ shouldRotateForRateLimit(provider: string, fallbackProvider: string): boolean {
410
+ const health = this.health.get(provider);
411
+ if (!health) return false;
412
+
413
+ const now = Date.now();
414
+ const windowElapsed = now - health.rateLimitWindowStart;
415
+
416
+ // If window hasn't started or is fresh, don't rotate
417
+ if (health.rateLimitWindowStart === 0 || windowElapsed < 100) return false;
418
+
419
+ // If already depleted (tokens used ≥ limit), recommend rotation
420
+ if (health.tokensUsedThisWindow >= health.rateLimitTokens) return true;
421
+
422
+ // Remaining token budget in current window
423
+ const remainingBudget = Math.max(0, health.rateLimitTokens - health.tokensUsedThisWindow);
424
+ const remainingTimeMs = Math.max(1, health.rateLimitWindowMs - windowElapsed);
425
+
426
+ // Marginal rate: tokens per ms we can still consume this window
427
+ // High marginal rate = plenty of budget left = stay
428
+ // Low marginal rate = running out = consider leaving
429
+ const marginalRate = remainingBudget / remainingTimeMs;
430
+
431
+ // Cumulative successful tokens so far
432
+ const g_t = health.tokensUsedThisWindow;
433
+
434
+ // Cold-start cost for switching to fallback
435
+ const tau = this.estimateColdStartLatency(fallbackProvider);
436
+
437
+ // Break-even rate: the rate at which staying = switching
438
+ // From MVT: g'(t*) = g(t*) / (t* + τ)
439
+ // In our terms: marginal_rate = cumulative_rate * (t* / (t* + τ))
440
+ // But here we use: avg_rate_including_switch = g_t / (windowElapsed + τ)
441
+ // This is the rate INCLUDING the cost of switching (we lose τ ms of this window)
442
+ const avgRateIncludingSwitch = g_t / (windowElapsed + tau);
443
+
444
+ // MVT says: LEAVE when marginal_rate ≤ avg_rate_including_switch
445
+ // (the marginal gain from staying ≤ the average gain achievable including switch cost)
446
+ // STAY when marginal_rate > avg_rate_including_switch
447
+ // (we can still get more from this window than the switch costs us)
448
+ const ROTATION_THRESHOLD_FACTOR = 1.0; // 1.0 = exact MVT; >1 = leave earlier, <1 = stay longer
449
+
450
+ if (marginalRate <= avgRateIncludingSwitch * ROTATION_THRESHOLD_FACTOR) {
451
+ return true; // MVT says: leave this patch
452
+ }
453
+
454
+ return false; // MVT says: stay in this patch
455
+ }
456
+
457
+ /**
458
+ * Get the marginal rate for a provider (tokens/ms remaining in window).
459
+ * Useful for monitoring and debugging MVT decisions.
460
+ */
461
+ getMarginalRate(provider: string): { marginalRate: number; remainingBudget: number; remainingTimeMs: number; utilizationPct: number } | null {
462
+ const health = this.health.get(provider);
463
+ if (!health || health.rateLimitWindowStart === 0) return null;
464
+
465
+ const now = Date.now();
466
+ const windowElapsed = now - health.rateLimitWindowStart;
467
+ const remainingBudget = Math.max(0, health.rateLimitTokens - health.tokensUsedThisWindow);
468
+ const remainingTimeMs = Math.max(1, health.rateLimitWindowMs - windowElapsed);
469
+ const marginalRate = remainingBudget / remainingTimeMs;
470
+ const utilizationPct = (health.tokensUsedThisWindow / health.rateLimitTokens) * 100;
471
+
472
+ return { marginalRate, remainingBudget, remainingTimeMs, utilizationPct };
473
+ }
474
+
313
475
  /**
314
476
  * Mark provider as disabled (manual circuit breaker)
315
477
  */
@@ -410,6 +572,13 @@ export class ProviderHealthManager extends EventEmitter {
410
572
  isHealthy: true,
411
573
  cooldownUntil: 0,
412
574
  healthScore: 1.0,
575
+ // === MVT RATE-LIMIT DEFAULTS ===
576
+ // Conservative defaults: 1M tokens/min (most free tier providers)
577
+ tokensUsedThisWindow: 0,
578
+ rateLimitWindowStart: now,
579
+ rateLimitTokens: 1_000_000,
580
+ rateLimitWindowMs: 60_000,
581
+ avgTokensPerRequest: 500, // conservative default estimate
413
582
  });
414
583
  this.metrics.set(provider, []);
415
584
  }
@@ -476,7 +645,68 @@ export class ProviderHealthManager extends EventEmitter {
476
645
  }
477
646
 
478
647
  // ============================================================
479
- // Exports
480
648
  // ============================================================
649
+ // EXPORTS
650
+ // ============================================================
651
+
652
+ export default ProviderHealthManager;
653
+
654
+ /** Singleton instance for use across the app without DI */
655
+ export const globalHealthManager = new ProviderHealthManager();
656
+
657
+ /**
658
+ * Stateless MVT rate-limit rotation helper.
659
+ * Call this after routeQuery returns to check if the selected provider
660
+ * should be rotated away from due to rate-limit depletion.
661
+ *
662
+ * Uses Charnov (1976): g'(t*) = g(t*) / (t* + τ)
663
+ * Leave when marginal rate ≤ avg rate including switch cost.
664
+ *
665
+ * @param providerHealth - current provider health state (from healthManager.getHealth())
666
+ * @param fallbackProviderLatencyMs - estimated cold-start latency for fallback
667
+ * @param estimatedTokensThisCall - estimated tokens for this request
668
+ * @returns true if MVT recommends rotation
669
+ */
670
+ export function mvtShouldRotate(
671
+ providerHealth: ProviderHealth,
672
+ fallbackProviderLatencyMs: number,
673
+ estimatedTokensThisCall: number = 500,
674
+ ): boolean {
675
+ const now = Date.now();
676
+
677
+ // No window started yet — stay
678
+ if (providerHealth.rateLimitWindowStart === 0) return false;
679
+
680
+ const windowElapsed = now - providerHealth.rateLimitWindowStart;
681
+
682
+ // Window is fresh — stay
683
+ if (windowElapsed < 100) return false;
684
+
685
+ // Already depleted — rotate immediately
686
+ if (providerHealth.tokensUsedThisWindow >= providerHealth.rateLimitTokens) return true;
687
+
688
+ // Remaining budget after this call
689
+ const budgetAfter = providerHealth.rateLimitTokens - providerHealth.tokensUsedThisWindow - estimatedTokensThisCall;
690
+
691
+ // If this call would exceed the limit, recommend rotation
692
+ if (budgetAfter < 0) return true;
693
+
694
+ const remainingTimeMs = Math.max(1, providerHealth.rateLimitWindowMs - windowElapsed);
695
+
696
+ // Marginal rate: tokens/ms we can still consume this window after this call
697
+ const marginalRate = budgetAfter / remainingTimeMs;
698
+
699
+ // Cumulative tokens used so far (proxy for g(t*))
700
+ const g_t = providerHealth.tokensUsedThisWindow;
701
+
702
+ // τ = cold-start latency for fallback
703
+ const tau = fallbackProviderLatencyMs;
704
+
705
+ // Break-even rate: avg rate including switch cost
706
+ // From MVT: g'(t*) = g(t*) / (t* + τ)
707
+ // Our marginal rate should exceed this to justify staying
708
+ const avgRateIncludingSwitch = g_t / (windowElapsed + tau);
481
709
 
482
- export default ProviderHealthManager;
710
+ // Leave when marginal ≤ break-even (MVT optimality condition)
711
+ return marginalRate <= avgRateIncludingSwitch;
712
+ }