feat: per-account quota threshold protection (#212)
feat: per-account quota threshold protection Resolves #135 - Adds configurable quota protection with three-tier threshold resolution (per-model → per-account → global) - New global Minimum Quota Level slider in Settings - Per-account threshold settings via Account Settings modal - Draggable per-account threshold markers on model quota bars - Backend: PATCH /api/accounts/:email endpoint, globalQuotaThreshold config - i18n: quota protection keys for all 5 languages
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { ACCOUNT_CONFIG_PATH } from '../constants.js';
|
||||
import { config } from '../config.js';
|
||||
import { loadAccounts, loadDefaultAccount, saveAccounts } from './storage.js';
|
||||
import {
|
||||
isAllRateLimited as checkAllRateLimited,
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
} from './credentials.js';
|
||||
import { createStrategy, getStrategyLabel, DEFAULT_STRATEGY } from './strategies/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export class AccountManager {
|
||||
#accounts = [];
|
||||
@@ -433,7 +433,10 @@ export class AccountManager {
|
||||
modelRateLimits: a.modelRateLimits || {},
|
||||
isInvalid: a.isInvalid || false,
|
||||
invalidReason: a.invalidReason || null,
|
||||
lastUsed: a.lastUsed
|
||||
lastUsed: a.lastUsed,
|
||||
// Include quota threshold settings
|
||||
quotaThreshold: a.quotaThreshold,
|
||||
modelQuotaThresholds: a.modelQuotaThresholds || {}
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
|
||||
modelRateLimits: acc.modelRateLimits || {},
|
||||
// New fields for subscription and quota tracking
|
||||
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
|
||||
quota: acc.quota || { models: {}, lastChecked: null }
|
||||
quota: acc.quota || { models: {}, lastChecked: null },
|
||||
// Quota threshold settings (per-account and per-model overrides)
|
||||
quotaThreshold: acc.quotaThreshold, // undefined means use global
|
||||
modelQuotaThresholds: acc.modelQuotaThresholds || {}
|
||||
}));
|
||||
|
||||
const settings = config.settings || {};
|
||||
@@ -123,7 +126,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
|
||||
lastUsed: acc.lastUsed,
|
||||
// Persist subscription and quota data
|
||||
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
|
||||
quota: acc.quota || { models: {}, lastChecked: null }
|
||||
quota: acc.quota || { models: {}, lastChecked: null },
|
||||
// Persist quota threshold settings
|
||||
quotaThreshold: acc.quotaThreshold, // undefined omitted from JSON
|
||||
modelQuotaThresholds: Object.keys(acc.modelQuotaThresholds || {}).length > 0 ? acc.modelQuotaThresholds : undefined
|
||||
})),
|
||||
settings: settings,
|
||||
activeIndex: activeIndex
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { BaseStrategy } from './base-strategy.js';
|
||||
import { HealthTracker, TokenBucketTracker, QuotaTracker } from './trackers/index.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
// Default weights for scoring
|
||||
const DEFAULT_WEIGHTS = {
|
||||
@@ -168,8 +169,12 @@ export class HybridStrategy extends BaseStrategy {
|
||||
}
|
||||
|
||||
// Quota availability check (exclude critically low quota)
|
||||
if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
|
||||
logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId}`);
|
||||
// Threshold priority: per-model > per-account > global > default
|
||||
const effectiveThreshold = account.modelQuotaThresholds?.[modelId]
|
||||
?? account.quotaThreshold
|
||||
?? (config.globalQuotaThreshold || undefined);
|
||||
if (this.#quotaTracker.isQuotaCritical(account, modelId, effectiveThreshold)) {
|
||||
logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId} (threshold: ${effectiveThreshold ?? 'default'})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -311,7 +316,10 @@ export class HybridStrategy extends BaseStrategy {
|
||||
accountsWithoutTokens.push(account.email);
|
||||
continue;
|
||||
}
|
||||
if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
|
||||
const diagThreshold = account.modelQuotaThresholds?.[modelId]
|
||||
?? account.quotaThreshold
|
||||
?? (config.globalQuotaThreshold || undefined);
|
||||
if (this.#quotaTracker.isQuotaCritical(account, modelId, diagThreshold)) {
|
||||
criticalQuotaCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -51,15 +51,19 @@ export class QuotaTracker {
|
||||
* Check if an account has critically low quota for a model
|
||||
* @param {Object} account - Account object
|
||||
* @param {string} modelId - Model ID to check
|
||||
* @param {number} [thresholdOverride] - Optional threshold to use instead of default criticalThreshold
|
||||
* @returns {boolean} True if quota is at or below critical threshold
|
||||
*/
|
||||
isQuotaCritical(account, modelId) {
|
||||
isQuotaCritical(account, modelId, thresholdOverride) {
|
||||
const fraction = this.getQuotaFraction(account, modelId);
|
||||
// Unknown quota = not critical (assume OK)
|
||||
if (fraction === null) return false;
|
||||
// Only apply critical check if data is fresh
|
||||
if (!this.isQuotaFresh(account)) return false;
|
||||
return fraction <= this.#config.criticalThreshold;
|
||||
const threshold = (typeof thresholdOverride === 'number' && thresholdOverride > 0)
|
||||
? thresholdOverride
|
||||
: this.#config.criticalThreshold;
|
||||
return fraction <= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_CONFIG = {
|
||||
defaultCooldownMs: 10000, // 10 seconds
|
||||
maxWaitBeforeErrorMs: 120000, // 2 minutes
|
||||
maxAccounts: 10, // Maximum number of accounts allowed
|
||||
globalQuotaThreshold: 0, // 0 = disabled, 0.01-0.99 = minimum quota fraction before switching accounts
|
||||
// Rate limit handling (matches opencode-antigravity-auth)
|
||||
rateLimitDedupWindowMs: 2000, // 2 seconds - prevents concurrent retry storms
|
||||
maxConsecutiveFailures: 3, // Before applying extended cooldown
|
||||
|
||||
@@ -557,6 +557,7 @@ app.get('/account-limits', async (req, res) => {
|
||||
totalAccounts: allAccounts.length,
|
||||
models: sortedModels,
|
||||
modelConfig: config.modelMapping || {},
|
||||
globalQuotaThreshold: config.globalQuotaThreshold || 0,
|
||||
accounts: accountLimits.map(acc => {
|
||||
// Merge quota data with account metadata
|
||||
const metadata = accountMetadataMap.get(acc.email) || {};
|
||||
@@ -572,6 +573,9 @@ app.get('/account-limits', async (req, res) => {
|
||||
invalidReason: metadata.invalidReason || null,
|
||||
lastUsed: metadata.lastUsed || null,
|
||||
modelRateLimits: metadata.modelRateLimits || {},
|
||||
// Quota threshold settings
|
||||
quotaThreshold: metadata.quotaThreshold,
|
||||
modelQuotaThresholds: metadata.modelQuotaThresholds || {},
|
||||
// Subscription data (new)
|
||||
subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
|
||||
// Quota limits
|
||||
|
||||
@@ -232,6 +232,76 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/accounts/:email - Update account settings (thresholds)
|
||||
*/
|
||||
app.patch('/api/accounts/:email', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.params;
|
||||
const { quotaThreshold, modelQuotaThresholds } = req.body;
|
||||
|
||||
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
||||
const account = accounts.find(a => a.email === email);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({ status: 'error', error: `Account ${email} not found` });
|
||||
}
|
||||
|
||||
// Validate and update quotaThreshold (0-0.99 or null/undefined to clear)
|
||||
if (quotaThreshold !== undefined) {
|
||||
if (quotaThreshold === null) {
|
||||
delete account.quotaThreshold;
|
||||
} else if (typeof quotaThreshold === 'number' && quotaThreshold >= 0 && quotaThreshold < 1) {
|
||||
account.quotaThreshold = quotaThreshold;
|
||||
} else {
|
||||
return res.status(400).json({ status: 'error', error: 'quotaThreshold must be 0-0.99 or null' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and update modelQuotaThresholds (full replacement, not merge)
|
||||
if (modelQuotaThresholds !== undefined) {
|
||||
if (modelQuotaThresholds === null || (typeof modelQuotaThresholds === 'object' && Object.keys(modelQuotaThresholds).length === 0)) {
|
||||
// Clear all model thresholds
|
||||
delete account.modelQuotaThresholds;
|
||||
} else if (typeof modelQuotaThresholds === 'object') {
|
||||
// Validate all thresholds first
|
||||
for (const [modelId, threshold] of Object.entries(modelQuotaThresholds)) {
|
||||
if (typeof threshold !== 'number' || threshold < 0 || threshold >= 1) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
error: `Invalid threshold for model ${modelId}: must be 0-0.99`
|
||||
});
|
||||
}
|
||||
}
|
||||
// Replace entire object (not merge)
|
||||
account.modelQuotaThresholds = { ...modelQuotaThresholds };
|
||||
} else {
|
||||
return res.status(400).json({ status: 'error', error: 'modelQuotaThresholds must be an object or null' });
|
||||
}
|
||||
}
|
||||
|
||||
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
|
||||
|
||||
// Reload AccountManager to pick up changes
|
||||
await accountManager.reload();
|
||||
|
||||
logger.info(`[WebUI] Account ${email} thresholds updated`);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: `Account ${email} thresholds updated`,
|
||||
account: {
|
||||
email: account.email,
|
||||
quotaThreshold: account.quotaThreshold,
|
||||
modelQuotaThresholds: account.modelQuotaThresholds || {}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[WebUI] Error updating account thresholds:', error);
|
||||
res.status(500).json({ status: 'error', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/accounts/reload - Reload accounts from disk
|
||||
*/
|
||||
@@ -387,7 +457,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
*/
|
||||
app.post('/api/config', (req, res) => {
|
||||
try {
|
||||
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries } = req.body;
|
||||
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, globalQuotaThreshold, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries } = req.body;
|
||||
|
||||
// Only allow updating specific fields (security)
|
||||
const updates = {};
|
||||
@@ -416,6 +486,9 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
|
||||
updates.maxAccounts = maxAccounts;
|
||||
}
|
||||
if (typeof globalQuotaThreshold === 'number' && globalQuotaThreshold >= 0 && globalQuotaThreshold < 1) {
|
||||
updates.globalQuotaThreshold = globalQuotaThreshold;
|
||||
}
|
||||
if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) {
|
||||
updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user