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

@@ -153,7 +153,8 @@ public/
│ ├── settings-store.js # Settings management store
│ ├── components/ # UI Components
│ │ ├── dashboard.js # Main dashboard orchestrator
│ │ ├── account-manager.js # Account list & OAuth handling
│ │ ├── account-manager.js # Account list, OAuth, & threshold settings
│ │ ├── models.js # Model list with draggable quota threshold markers
│ │ ├── logs-viewer.js # Live log streaming
│ │ ├── claude-config.js # CLI settings editor
│ │ ├── server-config.js # Server settings UI
@@ -184,6 +185,7 @@ public/
- Strategies: `sticky` (cache-optimized), `round-robin` (load-balanced), `hybrid` (smart distribution)
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
- **src/config.js**: Runtime configuration with defaults (`globalQuotaThreshold`, `maxAccounts`, `accountSelection`, etc.)
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
- **src/modules/usage-stats.js**: Tracks request volume by model/family, persists 30-day history to JSON, and auto-prunes old data.
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
@@ -217,13 +219,24 @@ public/
- Scoring formula: `score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (Quota × 1) + (LRU × 0.1)`
- Health scores: Track success/failure patterns with passive recovery
- Token buckets: Client-side rate limiting (50 tokens, 6 per minute regeneration)
- Quota awareness: Accounts with critical quota (<5%) are deprioritized
- Quota awareness: Accounts below configurable quota threshold are deprioritized
- LRU freshness: Prefer accounts that have rested longer
- **Emergency/Last Resort Fallback**: When all accounts are exhausted:
- Emergency fallback: Bypasses health check, adds 250ms throttle delay
- Last resort fallback: Bypasses both health and token checks, adds 500ms throttle delay
- Configuration in `src/config.js` under `accountSelection`
**Quota Threshold (Quota Protection):**
- Configurable minimum quota level before the proxy switches to another account
- Three-tier threshold resolution (highest priority first):
1. **Per-model**: `account.modelQuotaThresholds[modelId]` - override for specific models
2. **Per-account**: `account.quotaThreshold` - account-level default
3. **Global**: `config.globalQuotaThreshold` - server-wide default (0 = disabled)
- All thresholds are stored as fractions (0-0.99), displayed as percentages (0-99%) in the UI
- Global threshold configurable via WebUI Settings → Quota Protection
- Per-account and per-model thresholds configurable via Account Settings modal or draggable markers on model quota bars
- Used by `QuotaTracker.isQuotaCritical()` in the hybrid strategy to exclude low-quota accounts
**Account Data Model:**
Each account object in `accounts.json` contains:
- **Basic Info**: `email`, `source` (oauth/manual/database), `enabled`, `lastUsed`
@@ -232,6 +245,9 @@ Each account object in `accounts.json` contains:
- `tier`: 'free' | 'pro' | 'ultra' (detected from `paidTier` or `currentTier`)
- **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache
- `models[modelId]`: `{ remainingFraction, resetTime }` from `fetchAvailableModels` API
- **Quota Thresholds**: Per-account quota protection settings
- `quotaThreshold`: Account-level minimum quota fraction (0-0.99, `undefined` = use global)
- `modelQuotaThresholds`: `{ [modelId]: fraction }` - per-model overrides (takes priority over account-level)
- **Rate Limits**: `modelRateLimits[modelId]` - temporary rate limit state (in-memory during runtime)
- **Validity**: `isInvalid`, `invalidReason` - tracks accounts needing re-authentication
@@ -287,7 +303,8 @@ Each account object in `accounts.json` contains:
- Layered architecture: Service Layer (`account-actions.js`) → Component Layer → UI
- **Features**:
- Real-time dashboard with Chart.js visualization and subscription tier distribution
- Account list with tier badges (Ultra/Pro/Free) and quota progress bars
- Account list with tier badges (Ultra/Pro/Free), quota progress bars, and per-account threshold settings
- Model quota bars with draggable per-account threshold markers (color-coded, with overlap handling)
- OAuth flow handling via popup window
- Live log streaming via Server-Sent Events (SSE)
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
@@ -300,7 +317,7 @@ Each account object in `accounts.json` contains:
- **Security**: Optional password protection via `WEBUI_PASSWORD` env var
- **Config Redaction**: Sensitive values (passwords, tokens) are redacted in API responses
- **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden)
- **i18n Support**: English, Chinese (中文), Indonesian (Bahasa), Portuguese (PT-BR)
- **i18n Support**: English, Chinese (中文), Indonesian (Bahasa), Portuguese (PT-BR), Turkish (Türkçe)
## Testing Notes
@@ -353,15 +370,16 @@ Each account object in `accounts.json` contains:
**WebUI APIs:**
- `/api/accounts/*` - Account management (list, add, remove, refresh)
- `/api/config/*` - Server configuration (read/write)
- `/api/accounts/*` - Account management (list, add, remove, refresh, threshold settings)
- `PATCH /api/accounts/:email` - Update account quota thresholds (`quotaThreshold`, `modelQuotaThresholds`)
- `/api/config/*` - Server configuration (read/write, includes `globalQuotaThreshold`)
- `/api/claude/config` - Claude CLI settings
- `/api/claude/mode` - Switch between Proxy/Paid mode (updates settings.json)
- `/api/logs/stream` - SSE endpoint for real-time logs
- `/api/stats/history` - Retrieve 30-day request history (sorted chronologically)
- `/api/auth/url` - Generate Google OAuth URL
- `/account-limits` - Fetch account quotas and subscription data
- Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }`
- Returns: `{ accounts: [{ email, subscription, limits, quotaThreshold, modelQuotaThresholds, ... }], models: [...], globalQuotaThreshold }`
- Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats)
## Frontend Development

View File

@@ -313,7 +313,8 @@ antigravity-claude-proxy start --strategy=round-robin # Load-balanced
- **Health Score Tracking**: Accounts earn points for successful requests and lose points for failures/rate-limits
- **Token Bucket Rate Limiting**: Client-side throttling with regenerating tokens (50 max, 6/minute)
- **Quota Awareness**: Accounts with critical quota (<5%) are deprioritized; exhausted accounts trigger emergency fallback
- **Quota Awareness**: Accounts below configurable quota thresholds are deprioritized; exhausted accounts trigger emergency fallback
- **Quota Protection**: Set minimum quota levels globally, per-account, or per-model to switch accounts before quota runs out
- **Emergency Fallback**: When all accounts appear exhausted, bypasses checks with throttle delays (250-500ms)
- **Automatic Cooldown**: Rate-limited accounts recover automatically after reset time expires
- **Invalid Account Detection**: Accounts needing re-authentication are marked and skipped
@@ -355,16 +356,17 @@ The proxy includes a built-in, modern web interface for real-time monitoring and
### Key Features
- **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution.
- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators.
- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra) and quota status at a glance.
- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators and draggable per-account threshold markers.
- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra), quota status, and per-account threshold settings.
- **Manual OAuth Mode**: Add accounts on headless servers by copying the OAuth URL and pasting the authorization code.
- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
- **Persistent History**: Tracks request volume by model family for 30 days, persisting across server restarts.
- **Time Range Filtering**: Analyze usage trends over 1H, 6H, 24H, 7D, or All Time periods.
- **Smart Analysis**: Auto-select top 5 most used models or toggle between Family/Model views.
- **Live Logs**: Stream server logs with level-based filtering and search.
- **Quota Protection**: Set global or per-account minimum quota thresholds to proactively switch accounts before quota runs out.
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
- **Multi-language Interface**: Full support for English, Chinese (中文), Indonesian (Bahasa), and Portuguese (PT-BR).
- **Multi-language Interface**: Full support for English, Chinese (中文), Indonesian (Bahasa), Portuguese (PT-BR), and Turkish (Türkçe).
---
@@ -382,6 +384,7 @@ While most users can use the default settings, you can tune the proxy behavior v
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
- **Max Accounts**: Set `maxAccounts` (1-100) to limit the number of Google accounts. Default: 10.
- **Quota Threshold**: Set `globalQuotaThreshold` (0-0.99) to switch accounts before quota drops below a minimum level. Supports per-account and per-model overrides.
- **Endpoint Fallback**: Automatic 403/404 endpoint fallback for API compatibility.
Refer to `config.example.json` for a complete list of fields and documentation.

2
public/css/style.css generated

File diff suppressed because one or more lines are too long

View File

@@ -182,6 +182,129 @@ window.Components.accountManager = () => ({
document.getElementById('quota_modal').showModal();
},
// Threshold settings
thresholdDialog: {
email: '',
quotaThreshold: null, // null means use global
modelQuotaThresholds: {},
saving: false,
addingModel: false,
newModelId: '',
newModelThreshold: 10
},
openThresholdModal(account) {
this.thresholdDialog = {
email: account.email,
// Convert from fraction (0-1) to percentage (0-99) for display
quotaThreshold: account.quotaThreshold !== undefined ? Math.round(account.quotaThreshold * 100) : null,
modelQuotaThresholds: Object.fromEntries(
Object.entries(account.modelQuotaThresholds || {}).map(([k, v]) => [k, Math.round(v * 100)])
),
saving: false,
addingModel: false,
newModelId: '',
newModelThreshold: 10
};
document.getElementById('threshold_modal').showModal();
},
async saveAccountThreshold() {
const store = Alpine.store('global');
this.thresholdDialog.saving = true;
try {
// Convert percentage back to fraction
const quotaThreshold = this.thresholdDialog.quotaThreshold !== null && this.thresholdDialog.quotaThreshold !== ''
? parseFloat(this.thresholdDialog.quotaThreshold) / 100
: null;
// Convert model thresholds from percentage to fraction
const modelQuotaThresholds = {};
for (const [modelId, pct] of Object.entries(this.thresholdDialog.modelQuotaThresholds)) {
modelQuotaThresholds[modelId] = parseFloat(pct) / 100;
}
const { response, newPassword } = await window.utils.request(
`/api/accounts/${encodeURIComponent(this.thresholdDialog.email)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quotaThreshold, modelQuotaThresholds })
},
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
store.showToast('Settings saved', 'success');
Alpine.store('data').fetchData();
document.getElementById('threshold_modal').close();
} else {
throw new Error(data.error || 'Failed to save settings');
}
} catch (e) {
store.showToast('Failed to save settings: ' + e.message, 'error');
} finally {
this.thresholdDialog.saving = false;
}
},
clearAccountThreshold() {
this.thresholdDialog.quotaThreshold = null;
},
// Per-model threshold methods
addModelThreshold() {
this.thresholdDialog.addingModel = true;
this.thresholdDialog.newModelId = '';
this.thresholdDialog.newModelThreshold = 10;
},
updateModelThreshold(modelId, value) {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue >= 0 && numValue <= 99) {
this.thresholdDialog.modelQuotaThresholds[modelId] = numValue;
}
},
removeModelThreshold(modelId) {
delete this.thresholdDialog.modelQuotaThresholds[modelId];
},
confirmAddModelThreshold() {
const modelId = this.thresholdDialog.newModelId;
const threshold = parseInt(this.thresholdDialog.newModelThreshold) || 10;
if (modelId && threshold >= 0 && threshold <= 99) {
this.thresholdDialog.modelQuotaThresholds[modelId] = threshold;
this.thresholdDialog.addingModel = false;
this.thresholdDialog.newModelId = '';
this.thresholdDialog.newModelThreshold = 10;
}
},
getAvailableModelsForThreshold() {
// Get models from data store, exclude already configured ones
const allModels = Alpine.store('data').models || [];
const configured = Object.keys(this.thresholdDialog.modelQuotaThresholds);
return allModels.filter(m => !configured.includes(m));
},
getEffectiveThreshold(account) {
// Return display string for effective threshold
if (account.quotaThreshold !== undefined) {
return Math.round(account.quotaThreshold * 100) + '%';
}
// If no per-account threshold, show global value
const globalThreshold = Alpine.store('data').globalQuotaThreshold;
if (globalThreshold > 0) {
return Math.round(globalThreshold * 100) + '% (global)';
}
return 'Global';
},
/**
* Get main model quota for display
* Prioritizes flagship models (Opus > Sonnet > Flash)

View File

@@ -6,6 +6,33 @@
window.Components = window.Components || {};
window.Components.models = () => ({
// Color palette for per-account threshold markers
thresholdColors: [
{ bg: '#eab308', shadow: 'rgba(234,179,8,0.5)' }, // yellow
{ bg: '#06b6d4', shadow: 'rgba(6,182,212,0.5)' }, // cyan
{ bg: '#a855f7', shadow: 'rgba(168,85,247,0.5)' }, // purple
{ bg: '#22c55e', shadow: 'rgba(34,197,94,0.5)' }, // green
{ bg: '#ef4444', shadow: 'rgba(239,68,68,0.5)' }, // red
{ bg: '#f97316', shadow: 'rgba(249,115,22,0.5)' }, // orange
{ bg: '#ec4899', shadow: 'rgba(236,72,153,0.5)' }, // pink
{ bg: '#8b5cf6', shadow: 'rgba(139,92,246,0.5)' }, // violet
],
getThresholdColor(index) {
return this.thresholdColors[index % this.thresholdColors.length];
},
// Drag state for threshold markers
dragging: {
active: false,
email: null,
modelId: null,
barRect: null,
currentPct: 0,
originalPct: 0
},
// Model editing state (from main)
editingModelId: null,
newMapping: '',
@@ -21,6 +48,188 @@ window.Components.models = () => ({
this.editingModelId = null;
},
/**
* Start dragging a threshold marker
*/
startDrag(event, q, row) {
// Find the progress bar element (closest .relative container)
const markerEl = event.currentTarget;
const barContainer = markerEl.parentElement;
const barRect = barContainer.getBoundingClientRect();
this.dragging = {
active: true,
email: q.fullEmail,
modelId: row.modelId,
barRect,
currentPct: q.thresholdPct,
originalPct: q.thresholdPct
};
// Prevent text selection while dragging
document.body.classList.add('select-none');
// Bind document-level listeners for smooth dragging outside the marker
this._onDrag = (e) => this.onDrag(e);
this._endDrag = () => this.endDrag();
document.addEventListener('mousemove', this._onDrag);
document.addEventListener('mouseup', this._endDrag);
document.addEventListener('touchmove', this._onDrag, { passive: false });
document.addEventListener('touchend', this._endDrag);
},
/**
* Handle drag movement — compute percentage from mouse position
*/
onDrag(event) {
if (!this.dragging.active) return;
event.preventDefault();
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const { left, width } = this.dragging.barRect;
let pct = Math.round((clientX - left) / width * 100);
pct = Math.max(0, Math.min(99, pct));
this.dragging.currentPct = pct;
},
/**
* End drag — save the new threshold value
*/
endDrag() {
if (!this.dragging.active) return;
// Clean up listeners
document.removeEventListener('mousemove', this._onDrag);
document.removeEventListener('mouseup', this._endDrag);
document.removeEventListener('touchmove', this._onDrag);
document.removeEventListener('touchend', this._endDrag);
document.body.classList.remove('select-none');
const { email, modelId, currentPct, originalPct } = this.dragging;
// Only save if value actually changed
if (currentPct !== originalPct) {
// Optimistic in-place update: mutate existing quotaInfo entries directly
// to avoid full DOM rebuild from computeQuotaRows()
const dataStore = Alpine.store('data');
const account = dataStore.accounts.find(a => a.email === email);
if (account) {
if (!account.modelQuotaThresholds) account.modelQuotaThresholds = {};
if (currentPct === 0) {
delete account.modelQuotaThresholds[modelId];
} else {
account.modelQuotaThresholds[modelId] = currentPct / 100;
}
}
// Patch quotaRows in-place so Alpine updates without tearing down DOM
const rows = dataStore.quotaRows || [];
for (const row of rows) {
if (row.modelId !== modelId) continue;
for (const q of row.quotaInfo) {
if (q.fullEmail !== email) continue;
q.thresholdPct = currentPct;
}
// Recompute row-level threshold stats
const activePcts = row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0);
row.effectiveThresholdPct = activePcts.length > 0 ? Math.max(...activePcts) : 0;
row.hasVariedThresholds = new Set(activePcts).size > 1;
}
this.dragging.active = false;
this.saveModelThreshold(email, modelId, currentPct);
} else {
this.dragging.active = false;
}
},
/**
* Save a per-model threshold for an account via PATCH
*/
async saveModelThreshold(email, modelId, pct) {
const store = Alpine.store('global');
const dataStore = Alpine.store('data');
const account = dataStore.accounts.find(a => a.email === email);
if (!account) return;
// Snapshot for rollback on failure
const previousModelThresholds = account.modelQuotaThresholds ? { ...account.modelQuotaThresholds } : {};
// Build full modelQuotaThresholds for API (full replacement, not merge)
const existingModelThresholds = { ...(account.modelQuotaThresholds || {}) };
// Preserve the account-level quotaThreshold
const quotaThreshold = account.quotaThreshold !== undefined ? account.quotaThreshold : null;
try {
const { response, newPassword } = await window.utils.request(
`/api/accounts/${encodeURIComponent(email)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quotaThreshold, modelQuotaThresholds: existingModelThresholds })
},
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
const label = pct === 0 ? 'removed' : pct + '%';
store.showToast(`${email.split('@')[0]} ${modelId} threshold: ${label}`, 'success');
// Skip fetchData() — optimistic update is already applied,
// next polling cycle will sync server state
} else {
throw new Error(data.error || 'Failed to save threshold');
}
} catch (e) {
// Revert optimistic update on failure
account.modelQuotaThresholds = previousModelThresholds;
dataStore.computeQuotaRows();
store.showToast('Failed to save threshold: ' + e.message, 'error');
}
},
/**
* Check if a specific marker is currently being dragged
*/
isDragging(q, row) {
return this.dragging.active && this.dragging.email === q.fullEmail && this.dragging.modelId === row.modelId;
},
/**
* Get the display percentage for a marker (live during drag, stored otherwise)
*/
getMarkerPct(q, row) {
if (this.isDragging(q, row)) return this.dragging.currentPct;
return q.thresholdPct;
},
/**
* Compute pixel offset for overlapping markers so stacked ones fan out.
* Markers within 2% of each other are considered overlapping.
* Returns a CSS pixel offset string (e.g., '6px' or '-6px').
*/
getMarkerOffset(q, row, qIdx) {
const pct = this.getMarkerPct(q, row);
const visible = row.quotaInfo.filter(item => item.thresholdPct > 0 || this.isDragging(item, row));
// Find all markers within 2% of this one
const cluster = [];
visible.forEach((item, idx) => {
const itemPct = this.getMarkerPct(item, row);
if (Math.abs(itemPct - pct) <= 2) {
cluster.push({ item, idx });
}
});
if (cluster.length <= 1) return '0px';
// Find position of this marker within its cluster
const posInCluster = cluster.findIndex(c => c.item.fullEmail === q.fullEmail);
// Spread markers 10px apart, centered on the base position
const spread = 10;
const totalWidth = (cluster.length - 1) * spread;
return (posInCluster * spread - totalWidth / 2) + 'px';
},
init() {
// Ensure data is fetched when this tab becomes active (skip initial trigger)
this.$watch('$store.global.activeTab', (val, oldVal) => {

View File

@@ -250,7 +250,47 @@ window.Components.serverConfig = () => ({
(v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX));
},
toggleMaxAccounts(value) {
toggleGlobalQuotaThreshold(value) {
const { GLOBAL_QUOTA_THRESHOLD_MIN, GLOBAL_QUOTA_THRESHOLD_MAX } = window.AppConstants.VALIDATION;
const store = Alpine.store('global');
const pct = parseInt(value);
if (isNaN(pct) || pct < GLOBAL_QUOTA_THRESHOLD_MIN || pct > GLOBAL_QUOTA_THRESHOLD_MAX) return;
// Store as percentage in UI, convert to fraction for backend
const fraction = pct / 100;
if (this.debounceTimers['globalQuotaThreshold']) {
clearTimeout(this.debounceTimers['globalQuotaThreshold']);
}
const previousValue = this.serverConfig.globalQuotaThreshold;
this.serverConfig.globalQuotaThreshold = fraction;
this.debounceTimers['globalQuotaThreshold'] = setTimeout(async () => {
try {
const { response, newPassword } = await window.utils.request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ globalQuotaThreshold: fraction })
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
store.showToast(store.t('fieldUpdated', { displayName: 'Minimum Quota Level', value: pct + '%' }), 'success');
await this.fetchServerConfig();
} else {
throw new Error(data.error || store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }));
}
} catch (e) {
this.serverConfig.globalQuotaThreshold = previousValue;
store.showToast(store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }) + ': ' + e.message, 'error');
}
}, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
},
toggleMaxAccounts(value) {
const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('maxAccounts', value, 'Max Accounts',
(v) => window.Validators.validateRange(v, MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX, 'Max Accounts'));

View File

@@ -87,7 +87,11 @@ window.AppConstants.VALIDATION = {
// Capacity retries (1 - 10)
MAX_CAPACITY_RETRIES_MIN: 1,
MAX_CAPACITY_RETRIES_MAX: 10
MAX_CAPACITY_RETRIES_MAX: 10,
// Global quota threshold (0 - 99%)
GLOBAL_QUOTA_THRESHOLD_MIN: 0,
GLOBAL_QUOTA_THRESHOLD_MAX: 99
};
/**

View File

@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
modelConfig: {}, // Model metadata (hidden, pinned, alias)
quotaRows: [], // Filtered view
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
globalQuotaThreshold: 0, // Global minimum quota threshold (fraction 0-0.99)
maxAccounts: 10, // Maximum number of accounts allowed (from config)
loading: false,
initialLoad: true, // Track first load for skeleton screen
@@ -116,6 +117,7 @@ document.addEventListener('alpine:init', () => {
this.models = data.models;
}
this.modelConfig = data.modelConfig || {};
this.globalQuotaThreshold = data.globalQuotaThreshold || 0;
// Store usage history if included (for dashboard)
if (data.history) {
@@ -236,6 +238,8 @@ document.addEventListener('alpine:init', () => {
let totalQuotaSum = 0;
let validAccountCount = 0;
let minResetTime = null;
let maxEffectiveThreshold = 0;
const globalThreshold = this.globalQuotaThreshold || 0;
this.accounts.forEach(acc => {
if (acc.enabled === false) return;
@@ -255,11 +259,26 @@ document.addEventListener('alpine:init', () => {
minResetTime = limit.resetTime;
}
// Resolve effective threshold: per-model > per-account > global
const accModelThreshold = acc.modelQuotaThresholds?.[modelId];
const accThreshold = acc.quotaThreshold;
const effective = accModelThreshold ?? accThreshold ?? globalThreshold;
if (effective > maxEffectiveThreshold) {
maxEffectiveThreshold = effective;
}
// Determine threshold source for display
let thresholdSource = 'global';
if (accModelThreshold !== undefined) thresholdSource = 'model';
else if (accThreshold !== undefined) thresholdSource = 'account';
quotaInfo.push({
email: acc.email.split('@')[0],
fullEmail: acc.email,
pct: pct,
resetTime: limit.resetTime
resetTime: limit.resetTime,
thresholdPct: Math.round(effective * 100),
thresholdSource
});
});
@@ -268,6 +287,10 @@ document.addEventListener('alpine:init', () => {
if (!showExhausted && minQuota === 0) return;
// Check if thresholds vary across accounts
const uniqueThresholds = new Set(quotaInfo.map(q => q.thresholdPct));
const hasVariedThresholds = uniqueThresholds.size > 1;
rows.push({
modelId,
displayName: modelId, // Simplified: no longer using alias
@@ -279,7 +302,9 @@ document.addEventListener('alpine:init', () => {
quotaInfo,
pinned: !!config.pinned,
hidden: !!isHidden, // Use computed visibility
activeCount: quotaInfo.filter(q => q.pct > 0).length
activeCount: quotaInfo.filter(q => q.pct > 0).length,
effectiveThresholdPct: Math.round(maxEffectiveThreshold * 100),
hasVariedThresholds
});
});

View File

@@ -237,6 +237,11 @@ window.translations.en = {
defaultCooldownDesc: "Fallback cooldown when API doesn't provide a reset time.",
maxWaitThreshold: "Max Wait Before Error",
maxWaitDesc: "If all accounts are rate-limited longer than this, error immediately instead of waiting.",
// Quota Protection
quotaProtection: "Quota Protection",
minimumQuotaLevel: "Minimum Quota Level",
minimumQuotaLevelDesc: "Switch accounts when quota drops below this level. Per-account overrides take priority.",
quotaDisabled: "Disabled",
// Error Handling Tuning
errorHandlingTuning: "Error Handling Tuning",
rateLimitDedupWindow: "Rate Limit Dedup Window",

View File

@@ -270,6 +270,11 @@ window.translations.id = {
defaultCooldownDesc: "Cooldown bawaan jika API tidak memberikan waktu reset.",
maxWaitThreshold: "Batas Tunggu Maksimal",
maxWaitDesc: "Jika semua akun terkena rate limit lebih lama dari ini, langsung gagal.",
// Perlindungan Kuota
quotaProtection: "Perlindungan Kuota",
minimumQuotaLevel: "Level Kuota Minimum",
minimumQuotaLevelDesc: "Ganti akun ketika kuota turun di bawah level ini. Pengaturan per-akun lebih diutamakan.",
quotaDisabled: "Nonaktif",
// Error Handling Tuning
errorHandlingTuning: "Penyetelan Penanganan Error",
rateLimitDedupWindow: "Jendela Deduplikasi Rate Limit",

View File

@@ -215,6 +215,11 @@ window.translations.pt = {
defaultCooldownDesc: "Resfriamento de fallback quando a API não fornece tempo de reset.",
maxWaitThreshold: "Limiar Máximo de Espera (Sticky)",
maxWaitDesc: "Tempo máximo para aguardar uma conta sticky resetar antes de trocar.",
// Proteção de Cota
quotaProtection: "Proteção de Cota",
minimumQuotaLevel: "Nível Mínimo de Cota",
minimumQuotaLevelDesc: "Trocar de conta quando a cota cair abaixo deste nível. Configurações por conta têm prioridade.",
quotaDisabled: "Desativado",
// Ajuste de Tratamento de Erros
errorHandlingTuning: "Ajuste de Tratamento de Erros",
rateLimitDedupWindow: "Janela de Deduplicação de Rate Limit",

View File

@@ -219,6 +219,11 @@ window.translations.tr = {
defaultCooldownDesc: "API sıfırlama zamanı sağlamadığında yedek soğuma süresi.",
maxWaitThreshold: "Maksimum Bekleme Eşiği (Yapışkan)",
maxWaitDesc: "Yapışkan bir hesabın değiştirmeden önce sıfırlanması için beklenecek maksimum süre.",
// Kota Koruması
quotaProtection: "Kota Koruması",
minimumQuotaLevel: "Minimum Kota Seviyesi",
minimumQuotaLevelDesc: "Kota bu seviyenin altına düştüğünde hesap değiştir. Hesap bazlı ayarlar önceliklidir.",
quotaDisabled: "Devre Dışı",
// Hata İşleme Ayarları
errorHandlingTuning: "Hata İşleme Ayarları",
rateLimitDedupWindow: "Hız Sınırı Tekilleştirme Penceresi",

View File

@@ -237,6 +237,11 @@ window.translations.zh = {
defaultCooldownDesc: "当 API 未提供重置时间时的备用冷却时间。",
maxWaitThreshold: "最大等待阈值",
maxWaitDesc: "如果所有账号的限流时间超过此阈值,立即返回错误而非等待。",
// 配额保护
quotaProtection: "配额保护",
minimumQuotaLevel: "最低配额水平",
minimumQuotaLevelDesc: "当配额低于此水平时切换账号。每个账号的单独设置优先。",
quotaDisabled: "已禁用",
// 错误处理调优
errorHandlingTuning: "错误处理调优",
rateLimitDedupWindow: "限流去重窗口",

View File

@@ -180,6 +180,9 @@
<template x-if="quota.percent === null">
<span class="text-xs text-gray-600">-</span>
</template>
<div x-show="acc.quotaThreshold !== undefined"
class="text-[10px] font-mono text-neon-yellow/60 mt-0.5"
x-text="'min: ' + getEffectiveThreshold(acc)"></div>
</div>
</td>
<td class="py-4">
@@ -204,6 +207,17 @@
x-text="$store.global.t('fix')">
FIX
</button>
<!-- Settings Button (threshold) -->
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-neon-yellow transition-colors"
@click="openThresholdModal(acc)"
title="Account Settings">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors"
@click="refreshAccount(acc.email)"
:disabled="refreshing"
@@ -357,4 +371,154 @@
<button x-text="$store.global.t('close')">close</button>
</form>
</dialog>
<!-- Threshold Settings Modal -->
<dialog id="threshold_modal" class="modal backdrop-blur-sm">
<div class="modal-box max-w-md w-full bg-space-900 border border-space-border text-gray-300 shadow-2xl p-6">
<h3 class="font-bold text-xl text-white mb-2 flex items-center gap-2">
<svg class="w-6 h-6 text-neon-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Account Settings</span>
</h3>
<p class="text-sm text-gray-500 font-mono mb-6" x-text="thresholdDialog.email"></p>
<div class="space-y-4">
<!-- Info Alert -->
<div class="bg-space-800/50 border border-space-border/30 rounded-lg p-3">
<p class="text-xs text-gray-400 leading-relaxed">
Set a minimum quota level for this account. When the account's quota falls below this threshold,
the proxy will switch to another account with more quota remaining.
</p>
</div>
<!-- Threshold Input -->
<div class="form-control">
<label class="label">
<span class="label-text text-gray-400">Minimum Quota</span>
<span class="label-text-alt font-mono text-neon-yellow"
x-text="thresholdDialog.quotaThreshold !== null ? thresholdDialog.quotaThreshold + '%' : 'Default'"></span>
</label>
<div class="flex gap-3 items-center">
<input type="range" min="0" max="99" step="1"
class="custom-range custom-range-yellow flex-1"
:value="thresholdDialog.quotaThreshold || 0"
:style="`background-size: ${thresholdDialog.quotaThreshold || 0}% 100%`"
@input="thresholdDialog.quotaThreshold = parseInt($event.target.value)"
aria-label="Minimum quota threshold slider">
<div class="flex items-center gap-1">
<input type="number" min="0" max="99" step="1"
class="input input-sm input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="thresholdDialog.quotaThreshold ?? ''"
placeholder="-"
@input="thresholdDialog.quotaThreshold = $event.target.value === '' ? null : parseInt($event.target.value)"
aria-label="Minimum quota threshold value">
<span class="text-gray-500 text-xs">%</span>
</div>
</div>
<p class="text-[10px] text-gray-600 mt-1">
Leave empty to use the global threshold from Settings.
</p>
</div>
<!-- Clear Button -->
<button class="btn btn-sm btn-ghost text-gray-500 hover:text-gray-300 w-full"
@click="clearAccountThreshold()"
x-show="thresholdDialog.quotaThreshold !== null">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Reset to Default</span>
</button>
<!-- Per-Model Thresholds -->
<div class="pt-4 border-t border-space-border/30">
<div class="flex items-center justify-between mb-3">
<span class="text-xs text-gray-400 font-semibold">Per-Model Overrides</span>
<button class="btn btn-xs btn-ghost text-gray-500 hover:text-neon-yellow gap-1"
@click="addModelThreshold()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
<!-- Model threshold list -->
<div class="space-y-2 max-h-40 overflow-y-auto custom-scrollbar">
<template x-for="(threshold, modelId) in thresholdDialog.modelQuotaThresholds" :key="modelId">
<div class="flex items-center gap-2 bg-space-800/50 rounded px-2 py-1.5">
<span class="text-xs text-gray-300 flex-1 truncate font-mono" x-text="modelId"></span>
<div class="flex items-center gap-1">
<input type="number" min="0" max="99" step="1"
class="input input-xs input-bordered w-14 bg-space-900 border-space-border text-white font-mono text-center"
:value="threshold"
@input="updateModelThreshold(modelId, $event.target.value)"
aria-label="Model threshold">
<span class="text-gray-500 text-[10px]">%</span>
</div>
<button class="btn btn-xs btn-ghost text-gray-500 hover:text-red-400 px-1"
@click="removeModelThreshold(modelId)">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
<template x-if="Object.keys(thresholdDialog.modelQuotaThresholds).length === 0">
<p class="text-[10px] text-gray-600 text-center py-2">
No per-model overrides configured.
</p>
</template>
</div>
</div>
<!-- Add Model Dialog (inline) -->
<div x-show="thresholdDialog.addingModel" class="pt-3 border-t border-space-border/30" x-cloak>
<div class="flex gap-2">
<select class="select select-sm select-bordered flex-1 bg-space-800 border-space-border text-white text-xs"
x-model="thresholdDialog.newModelId">
<option value="">Select model...</option>
<template x-for="model in getAvailableModelsForThreshold()" :key="model">
<option :value="model" x-text="model"></option>
</template>
</select>
<input type="number" min="0" max="99" step="1"
class="input input-sm input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
x-model="thresholdDialog.newModelThreshold"
placeholder="10">
<span class="text-gray-500 text-xs self-center">%</span>
</div>
<div class="flex gap-2 mt-2">
<button class="btn btn-xs btn-ghost text-gray-500 flex-1"
@click="thresholdDialog.addingModel = false">
Cancel
</button>
<button class="btn btn-xs btn-warning text-black flex-1"
@click="confirmAddModelThreshold()"
:disabled="!thresholdDialog.newModelId">
Add Override
</button>
</div>
</div>
</div>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-ghost hover:bg-white/10">Cancel</button>
</form>
<button class="btn btn-warning border-none text-black font-semibold"
@click="saveAccountThreshold()"
:disabled="thresholdDialog.saving"
:class="{ 'loading': thresholdDialog.saving }">
<span>Save</span>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>

View File

@@ -190,9 +190,41 @@
<span class="text-gray-500 text-[10px]"
x-text="row.quotaInfo.filter(q => q.pct > 0).length + '/' + row.quotaInfo.length"></span>
</div>
<progress class="progress w-full h-1 bg-space-800"
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
:value="row.avgQuota" max="100"></progress>
<div class="relative">
<progress class="progress w-full h-1.5 bg-space-800"
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
:value="row.avgQuota" max="100"></progress>
<!-- Per-account draggable quota threshold markers -->
<template x-for="(q, qIdx) in row.quotaInfo.filter(q => q.thresholdPct > 0 || isDragging(q, row))" :key="q.fullEmail + '-threshold'">
<div class="absolute -top-1 -bottom-1 group/marker"
:class="isDragging(q, row) ? 'cursor-grabbing z-30' : 'cursor-grab'"
:style="'left: calc(' + getMarkerPct(q, row) + '% - 4px); width: 9px; transform: translateX(' + (isDragging(q, row) ? '0px' : getMarkerOffset(q, row, qIdx)) + ');'"
:title="q.email + ' min: ' + getMarkerPct(q, row) + '%'"
@mousedown.prevent="startDrag($event, q, row)"
@touchstart.prevent="startDrag($event, q, row)">
<!-- Visible marker line -->
<div class="absolute top-1 bottom-1 left-1 w-[3px] rounded-full"
:class="isDragging(q, row) ? 'scale-y-150 brightness-150' : 'transition-all group-hover/marker:scale-y-150 group-hover/marker:brightness-125'"
:style="'background-color: ' + getThresholdColor(qIdx).bg + '; box-shadow: 0 0 ' + (isDragging(q, row) ? '10px' : '6px') + ' ' + getThresholdColor(qIdx).shadow"></div>
<!-- Tooltip popup (visible on hover or during drag) -->
<div class="absolute -top-8 left-1/2 -translate-x-1/2 items-center gap-1.5
whitespace-nowrap text-xs font-mono font-semibold px-2.5 py-1 rounded-md bg-space-900 border border-space-border shadow-xl shadow-black/40 z-20"
:class="isDragging(q, row) ? 'flex' : 'hidden group-hover/marker:flex'"
:style="'color: ' + getThresholdColor(qIdx).bg + '; border-color: ' + getThresholdColor(qIdx).bg + '40'">
<span class="w-2 h-2 rounded-full flex-shrink-0" :style="'background-color: ' + getThresholdColor(qIdx).bg"></span>
<span x-text="q.email"></span>
<span class="text-gray-500">min</span>
<span x-text="getMarkerPct(q, row) + '%'"></span>
</div>
</div>
</template>
</div>
<!-- Threshold text under bar -->
<div x-show="row.effectiveThresholdPct > 0"
class="text-[10px] font-mono text-gray-500 -mt-0.5"
x-text="row.hasVariedThresholds
? 'min: ' + Math.min(...row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0)) + '' + row.effectiveThresholdPct + '%'
: 'min: ' + row.effectiveThresholdPct + '%'"></div>
</div>
</td>
<td class="font-mono text-xs">
@@ -219,7 +251,8 @@
<div class="flex flex-wrap gap-1 justify-start">
<!-- Visible accounts (limited to maxVisible) -->
<template x-for="(q, idx) in row.quotaInfo.slice(0, maxVisible)" :key="q.fullEmail">
<div class="tooltip tooltip-left" :data-tip="`${q.fullEmail} (${q.pct}%)`">
<div class="tooltip tooltip-left"
:data-tip="`${q.fullEmail} (${q.pct}%)` + (q.thresholdPct > 0 ? ` · min: ${q.thresholdPct}%` : '')">
<div class="w-3 h-3 rounded-[2px] transition-all hover:scale-125 cursor-help"
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
</div>
@@ -228,7 +261,7 @@
<!-- Overflow indicator -->
<template x-if="row.quotaInfo.length > maxVisible">
<div class="tooltip tooltip-left"
:data-tip="row.quotaInfo.slice(maxVisible).map(q => `${q.fullEmail} (${q.pct}%)`).join('\n')">
:data-tip="row.quotaInfo.slice(maxVisible).map(q => `${q.fullEmail} (${q.pct}%)` + (q.thresholdPct > 0 ? ` · min: ${q.thresholdPct}%` : '')).join('\n')">
<div class="w-3 h-3 rounded-[2px] bg-gray-700/50 border border-gray-600 flex items-center justify-center cursor-help hover:bg-gray-600/70 transition-colors">
<span class="text-[8px] text-gray-400 font-bold leading-none" x-text="`+${row.quotaInfo.length - maxVisible}`"></span>
</div>

View File

@@ -1222,6 +1222,38 @@
</div>
</div>
<!-- Quota Protection -->
<div class="space-y-4 pt-2 border-t border-space-border/10">
<div class="flex items-center gap-2 mb-2">
<span class="text-[10px] text-gray-500 font-bold uppercase tracking-widest"
x-text="$store.global.t('quotaProtection')">Quota Protection</span>
</div>
<div class="form-control">
<label class="label pt-0">
<span class="label-text text-gray-400 text-xs"
x-text="$store.global.t('minimumQuotaLevel')">Minimum Quota Level</span>
<span class="label-text-alt font-mono text-neon-green text-xs font-semibold"
x-text="Math.round((serverConfig.globalQuotaThreshold || 0) * 100) > 0 ? Math.round((serverConfig.globalQuotaThreshold || 0) * 100) + '%' : $store.global.t('quotaDisabled')"></span>
</label>
<div class="flex gap-3 items-center">
<input type="range" min="0" max="99" step="1"
class="custom-range custom-range-green flex-1"
:value="Math.round((serverConfig.globalQuotaThreshold || 0) * 100)"
:style="`background-size: ${Math.round((serverConfig.globalQuotaThreshold || 0) * 100) / 99 * 100}% 100%`"
@input="toggleGlobalQuotaThreshold($event.target.value)"
aria-label="Minimum quota level slider">
<input type="number" min="0" max="99"
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="Math.round((serverConfig.globalQuotaThreshold || 0) * 100)"
@change="toggleGlobalQuotaThreshold($event.target.value)"
aria-label="Minimum quota level value">
</div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('minimumQuotaLevelDesc')">Switch accounts when quota drops below this level. Per-account overrides take priority.</p>
</div>
</div>
<!-- Error Handling Tuning -->
<div class="space-y-4 pt-2 border-t border-space-border/10">
<div class="flex items-center gap-2 mb-2">

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