@@ -49,6 +49,17 @@ export interface ProviderHealth {
4949 cooldownUntil : number ;
5050 /** Health score 0-1 (higher is better) */
5151 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 ;
5263}
5364
5465export interface ProviderMetrics {
@@ -64,6 +75,8 @@ export interface ProviderMetrics {
6475 totalLatency : number ;
6576 /** Last measured latency */
6677 lastLatency : number ;
78+ /** Tokens consumed this request (for rate-limit tracking) */
79+ tokensUsed : number ;
6780}
6881
6982export interface HealthManagerConfig {
@@ -132,8 +145,11 @@ export class ProviderHealthManager extends EventEmitter {
132145
133146 /**
134147 * 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)
135151 */
136- recordSuccess ( provider : string , latencyMs : number ) : void {
152+ recordSuccess ( provider : string , latencyMs : number , tokensUsed : number = 0 ) : void {
137153 this . ensureProviderExists ( provider ) ;
138154
139155 const now = Date . now ( ) ;
@@ -145,15 +161,37 @@ export class ProviderHealthManager extends EventEmitter {
145161 failedRequests : 0 ,
146162 totalLatency : latencyMs ,
147163 lastLatency : latencyMs ,
164+ tokensUsed,
148165 } ) ;
149166
150167 // Trim to window size
151168 while ( window . length > this . config . windowSize ) {
152169 window . shift ( ) ;
153170 }
154171
155- // Update health state
172+ // === MVT RATE-LIMIT WINDOW MANAGEMENT ===
156173 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
157195 health . lastSuccess = now ;
158196 health . consecutiveErrors = 0 ;
159197 health . cooldownUntil = 0 ;
@@ -180,6 +218,7 @@ export class ProviderHealthManager extends EventEmitter {
180218 failedRequests : 1 ,
181219 totalLatency : 0 ,
182220 lastLatency : 0 ,
221+ tokensUsed : 0 ,
183222 } ) ;
184223
185224 // Trim to window size
@@ -310,6 +349,129 @@ export class ProviderHealthManager extends EventEmitter {
310349 return scored . map ( s => s . provider ) ;
311350 }
312351
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+
313475 /**
314476 * Mark provider as disabled (manual circuit breaker)
315477 */
@@ -410,6 +572,13 @@ export class ProviderHealthManager extends EventEmitter {
410572 isHealthy : true ,
411573 cooldownUntil : 0 ,
412574 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
413582 } ) ;
414583 this . metrics . set ( provider , [ ] ) ;
415584 }
@@ -476,7 +645,68 @@ export class ProviderHealthManager extends EventEmitter {
476645}
477646
478647// ============================================================
479- // Exports
480648// ============================================================
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 ) ;
481709
482- export default ProviderHealthManager ;
710+ // Leave when marginal ≤ break-even (MVT optimality condition)
711+ return marginalRate <= avgRateIncludingSwitch ;
712+ }
0 commit comments