Files
antigravity-claude-proxy/src/account-manager/strategies/hybrid-strategy.js
jgor20 2f5babba99 feat(strategy): add quota-awareness to hybrid account selection
The hybrid strategy now considers account quota levels when selecting
accounts, preventing any single account from being drained to 0%.

- Add QuotaTracker class to track per-account quota levels
- Exclude accounts with critical quota (<5%) from selection
- Add quota component to scoring formula (weight: 3)
- Fall back to critical accounts when no alternatives exist
- Add 18 new tests for quota-aware selection

Scoring formula: Health×2 + Tokens×5 + Quota×3 + LRU×0.1

An attempt at resolving  badrisnarayanan/antigravity-claude-proxy#171
2026-01-21 11:15:38 +00:00

238 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hybrid Strategy
*
* Smart selection based on health score, token bucket, quota, and LRU freshness.
* Combines multiple signals for optimal account distribution.
*
* Scoring formula:
* score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (Quota × 3) + (LRU × 0.1)
*
* Filters accounts that are:
* - Not rate-limited
* - Not invalid or disabled
* - Health score >= minUsable
* - Has tokens available
* - Quota not critically low (< 5%)
*/
import { BaseStrategy } from './base-strategy.js';
import { HealthTracker, TokenBucketTracker, QuotaTracker } from './trackers/index.js';
import { logger } from '../../utils/logger.js';
// Default weights for scoring
const DEFAULT_WEIGHTS = {
health: 2,
tokens: 5,
quota: 3,
lru: 0.1
};
export class HybridStrategy extends BaseStrategy {
#healthTracker;
#tokenBucketTracker;
#quotaTracker;
#weights;
/**
* Create a new HybridStrategy
* @param {Object} config - Strategy configuration
* @param {Object} [config.healthScore] - Health tracker configuration
* @param {Object} [config.tokenBucket] - Token bucket configuration
* @param {Object} [config.quota] - Quota tracker configuration
* @param {Object} [config.weights] - Scoring weights
*/
constructor(config = {}) {
super(config);
this.#healthTracker = new HealthTracker(config.healthScore || {});
this.#tokenBucketTracker = new TokenBucketTracker(config.tokenBucket || {});
this.#quotaTracker = new QuotaTracker(config.quota || {});
this.#weights = { ...DEFAULT_WEIGHTS, ...config.weights };
}
/**
* Select an account based on combined health, tokens, and LRU score
*
* @param {Array} accounts - Array of account objects
* @param {string} modelId - The model ID for the request
* @param {Object} options - Additional options
* @returns {SelectionResult} The selected account and index
*/
selectAccount(accounts, modelId, options = {}) {
const { onSave } = options;
if (accounts.length === 0) {
return { account: null, index: 0, waitMs: 0 };
}
// Get candidates that pass all filters
const candidates = this.#getCandidates(accounts, modelId);
if (candidates.length === 0) {
logger.debug('[HybridStrategy] No candidates available');
return { account: null, index: 0, waitMs: 0 };
}
// Score and sort candidates
const scored = candidates.map(({ account, index }) => ({
account,
index,
score: this.#calculateScore(account, modelId)
}));
scored.sort((a, b) => b.score - a.score);
// Select the best candidate
const best = scored[0];
best.account.lastUsed = Date.now();
// Consume a token from the bucket
this.#tokenBucketTracker.consume(best.account.email);
if (onSave) onSave();
const position = best.index + 1;
const total = accounts.length;
logger.info(`[HybridStrategy] Using account: ${best.account.email} (${position}/${total}, score: ${best.score.toFixed(1)})`);
return { account: best.account, index: best.index, waitMs: 0 };
}
/**
* Called after a successful request
*/
onSuccess(account, modelId) {
if (account && account.email) {
this.#healthTracker.recordSuccess(account.email);
}
}
/**
* Called when a request is rate-limited
*/
onRateLimit(account, modelId) {
if (account && account.email) {
this.#healthTracker.recordRateLimit(account.email);
}
}
/**
* Called when a request fails
*/
onFailure(account, modelId) {
if (account && account.email) {
this.#healthTracker.recordFailure(account.email);
// Refund the token since the request didn't complete
this.#tokenBucketTracker.refund(account.email);
}
}
/**
* Get candidates that pass all filters
* @private
*/
#getCandidates(accounts, modelId) {
const candidates = accounts
.map((account, index) => ({ account, index }))
.filter(({ account }) => {
// Basic usability check
if (!this.isAccountUsable(account, modelId)) {
return false;
}
// Health score check
if (!this.#healthTracker.isUsable(account.email)) {
return false;
}
// Token availability check
if (!this.#tokenBucketTracker.hasTokens(account.email)) {
return false;
}
// Quota availability check (exclude critically low quota)
if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId}`);
return false;
}
return true;
});
// If no candidates after quota filter, fall back to all usable accounts
// (better to use critical quota than fail entirely)
if (candidates.length === 0) {
const fallback = accounts
.map((account, index) => ({ account, index }))
.filter(({ account }) => {
if (!this.isAccountUsable(account, modelId)) return false;
if (!this.#healthTracker.isUsable(account.email)) return false;
if (!this.#tokenBucketTracker.hasTokens(account.email)) return false;
return true;
});
if (fallback.length > 0) {
logger.warn('[HybridStrategy] All accounts have critical quota, using fallback');
return fallback;
}
}
return candidates;
}
/**
* Calculate the combined score for an account
* @private
*/
#calculateScore(account, modelId) {
const email = account.email;
// Health component (0-100 scaled by weight)
const health = this.#healthTracker.getScore(email);
const healthComponent = health * this.#weights.health;
// Token component (0-100 scaled by weight)
const tokens = this.#tokenBucketTracker.getTokens(email);
const maxTokens = this.#tokenBucketTracker.getMaxTokens();
const tokenRatio = tokens / maxTokens;
const tokenComponent = (tokenRatio * 100) * this.#weights.tokens;
// Quota component (0-100 scaled by weight)
const quotaScore = this.#quotaTracker.getScore(account, modelId);
const quotaComponent = quotaScore * this.#weights.quota;
// LRU component (older = higher score)
// Use time since last use, capped at 1 hour for scoring
const lastUsed = account.lastUsed || 0;
const timeSinceLastUse = Math.min(Date.now() - lastUsed, 3600000); // Cap at 1 hour
const lruMinutes = timeSinceLastUse / 60000;
const lruComponent = lruMinutes * this.#weights.lru;
return healthComponent + tokenComponent + quotaComponent + lruComponent;
}
/**
* Get the health tracker (for testing/debugging)
* @returns {HealthTracker} The health tracker instance
*/
getHealthTracker() {
return this.#healthTracker;
}
/**
* Get the token bucket tracker (for testing/debugging)
* @returns {TokenBucketTracker} The token bucket tracker instance
*/
getTokenBucketTracker() {
return this.#tokenBucketTracker;
}
/**
* Get the quota tracker (for testing/debugging)
* @returns {QuotaTracker} The quota tracker instance
*/
getQuotaTracker() {
return this.#quotaTracker;
}
}
export default HybridStrategy;