Files
antigravity-claude-proxy/src/account-manager/strategies/hybrid-strategy.js
Badri Narayanan S 5ae19a5b72 feat: add configurable account selection strategies
Refactor account selection into a strategy pattern with three options:
- Sticky: cache-optimized, stays on same account until rate-limited
- Round-robin: load-balanced, rotates every request
- Hybrid (default): smart distribution using health scores, token buckets, and LRU

The hybrid strategy uses multiple signals for optimal account selection:
health tracking for reliability, client-side token buckets for rate limiting,
and LRU freshness to prefer rested accounts.

Includes WebUI settings for strategy selection and unit tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 03:48:43 +05:30

196 lines
5.9 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, and LRU freshness.
* Combines multiple signals for optimal account distribution.
*
* Scoring formula:
* score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (LRU × 0.1)
*
* Filters accounts that are:
* - Not rate-limited
* - Not invalid or disabled
* - Health score >= minUsable
* - Has tokens available
*/
import { BaseStrategy } from './base-strategy.js';
import { HealthTracker, TokenBucketTracker } from './trackers/index.js';
import { logger } from '../../utils/logger.js';
// Default weights for scoring
const DEFAULT_WEIGHTS = {
health: 2,
tokens: 5,
lru: 0.1
};
export class HybridStrategy extends BaseStrategy {
#healthTracker;
#tokenBucketTracker;
#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.weights] - Scoring weights
*/
constructor(config = {}) {
super(config);
this.#healthTracker = new HealthTracker(config.healthScore || {});
this.#tokenBucketTracker = new TokenBucketTracker(config.tokenBucket || {});
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)
}));
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) {
return 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;
}
return true;
});
}
/**
* Calculate the combined score for an account
* @private
*/
#calculateScore(account) {
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;
// 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 + 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;
}
}
export default HybridStrategy;