Skip to content

Commit 9a01bec

Browse files
committed
feat(routing): MVT rate-limit rotation (Charnov 1976 optimal foraging)
Step 2 of Agent Council recommendations — MVT applied to the RIGHT substrate (throughput/rate-limits, NOT quality switching — Expert 4 confirmed). What changed in providerHealth.ts: NEW interfaces: - ProviderHealth: added tokensUsedThisWindow, rateLimitWindowStart, rateLimitTokens, rateLimitWindowMs, avgTokensPerRequest - ProviderMetrics: added tokensUsed NEW methods on ProviderHealthManager: - setRateLimitConfig(provider, rateLimitTokens, rateLimitWindowMs): Configure per-provider rate limits. Call during provider registration. - shouldRotateForRateLimit(provider, fallbackProvider): Charnov MVT: g'(t*) = g(t*)/(t* + τ). Returns true when marginal rate ≤ avg rate including switch cost → MVT says LEAVE. - getMarginalRate(provider): Monitoring/debugging for MVT decisions. - recordSuccess(): Updated to track token consumption and detect rate-limit window rollovers (resets counter when window expires). - recordFailure(): Updated to include tokensUsed: 0. NEW stateless helper: - mvtShouldRotate(providerHealth, fallbackLatencyMs, estimatedTokens): Standalone function for callers that only have ProviderHealth state, not the full manager instance. Exported for use by server/SDK layer. NEW singleton: - globalHealthManager: Module-level singleton for app-wide health tracking. The MVT formula is applied CORRECTLY: - g(t) = cumulative tokens used in current rate-limit window - g'(t) = marginal rate = remaining budget / time remaining - τ = cold-start latency for fallback provider - LEAVE when: marginal_rate ≤ cumulative_rate / (elapsed + τ) - STAY when: marginal_rate > cumulative_rate / (elapsed + τ) This is specifically for RATE-LIMIT depletion, NOT quality switching. Expert 4 confirmed: 'Throughput/rate limits DO regenerate. Token buckets refill. This is a textbook depleting-and-regenerating resource.' Quality switching (MVT misapplied) remains RESEARCH-ONLY. Council consensus: this is safe to implement, LOW risk to RouterArena #1.
1 parent 0a5c2b4 commit 9a01bec

1 file changed

Lines changed: 234 additions & 4 deletions

File tree

‎src/routing/providerHealth.ts‎

Lines changed: 234 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

5465
export 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

6982
export 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

Comments
 (0)