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:
jgor20
2026-02-01 11:45:46 +00:00
committed by GitHub
parent 33584d31bb
commit a43d2332ca
23 changed files with 806 additions and 31 deletions

View File

@@ -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 || {}
}))
};
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}
/**

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}