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>
This commit is contained in:
195
src/account-manager/strategies/hybrid-strategy.js
Normal file
195
src/account-manager/strategies/hybrid-strategy.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user