Selective fixes from PR #35: Model-specific rate limits & robustness improvements (#37)

* feat: apply local user changes and fixes

* ;D

* Implement OpenAI support, model-specific rate limiting, and robustness fixes

* docs: update pr title

* feat: ensure unique openai models endpoint

* fix: startup banner alignment and removed duplicates

* feat: add model fallback system with --fallback flag

* fix: accounts cli hanging after completion

* feat: add exit option to accounts cli menu

* fix: remove circular dependency warning for fallback flag

* feat: show active modes in banner and hide their flags

* Remove OpenAI compatibility and fallback features from PR #35

Cherry-picked selective fixes from PR #35 while removing:
- OpenAI-compatible API endpoints (/openai/v1/*)
- Model fallback system (fallback-config.js)
- Thinking block skip for Gemini models
- Unnecessary files (pullrequest.md, test-fix.js, test-openai.js)

Retained improvements:
- Network error handling with retry logic
- Model-specific rate limiting
- Enhanced health check with quota info
- CLI fixes (exit option, process.exit)
- Startup banner alignment (debug mode only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* banner alignment fix

* Refactor: Model-specific rate limits and cleanup deprecated code

- Remove global rate limit fields (isRateLimited, rateLimitResetTime) in favor of model-specific limits (modelRateLimits[modelId])
- Remove deprecated wrapper functions (is429Error, isAuthInvalidError) from handlers
- Filter fetchAvailableModels to only return Claude and Gemini models
- Fix getCurrentStickyAccount() to pass model param after waiting
- Update /account-limits endpoint to show model-specific limits
- Remove multi-account OAuth flow to avoid state mismatch errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: show (x/y) limited status in account-limits table

- Status is now "ok" only when all models are available
- Shows "(x/y) limited" when x out of y models are exhausted
- Provides better visibility into partial rate limiting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update CLAUDE.md with model-specific rate limiting

- Document modelRateLimits[modelId] for per-model rate tracking
- Add isNetworkError() helper to utilities section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: M1noa <minoa@minoa.cat>
Co-authored-by: Minoa <altgithub@minoa.cat>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Badri Narayanan S
2026-01-03 15:33:49 +05:30
committed by GitHub
parent 2d05dd5b62
commit 9c4a712a9a
15 changed files with 474 additions and 194 deletions

View File

@@ -14,6 +14,7 @@ import {
import { refreshAccessToken } from '../auth/oauth.js';
import { getAuthStatus } from '../auth/database.js';
import { logger } from '../utils/logger.js';
import { isNetworkError } from '../utils/helpers.js';
/**
* Get OAuth token for an account
@@ -48,6 +49,13 @@ export async function getTokenForAccount(account, tokenCache, onInvalid, onSave)
}
logger.success(`[AccountManager] Refreshed OAuth token for: ${account.email}`);
} catch (error) {
// Check if it's a transient network error
if (isNetworkError(error)) {
logger.warn(`[AccountManager] Failed to refresh token for ${account.email} due to network error: ${error.message}`);
// Do NOT mark as invalid, just throw so caller knows it failed
throw new Error(`AUTH_NETWORK_ERROR: ${error.message}`);
}
logger.error(`[AccountManager] Failed to refresh token for ${account.email}:`, error.message);
// Mark account as invalid (credentials need re-auth)
if (onInvalid) onInvalid(account.email, error.message);

View File

@@ -81,18 +81,20 @@ export class AccountManager {
/**
* Check if all accounts are rate-limited
* @param {string} [modelId] - Optional model ID
* @returns {boolean} True if all accounts are rate-limited
*/
isAllRateLimited() {
return checkAllRateLimited(this.#accounts);
isAllRateLimited(modelId = null) {
return checkAllRateLimited(this.#accounts, modelId);
}
/**
* Get list of available (non-rate-limited, non-invalid) accounts
* @param {string} [modelId] - Optional model ID
* @returns {Array<Object>} Array of available account objects
*/
getAvailableAccounts() {
return getAvailable(this.#accounts);
getAvailableAccounts(modelId = null) {
return getAvailable(this.#accounts, modelId);
}
/**
@@ -127,10 +129,11 @@ export class AccountManager {
/**
* Pick the next available account (fallback when current is unavailable).
* Sets activeIndex to the selected account's index.
* @param {string} [modelId] - Optional model ID
* @returns {Object|null} The next available account or null if none available
*/
pickNext() {
const { account, newIndex } = selectNext(this.#accounts, this.#currentIndex, () => this.saveToDisk());
pickNext(modelId = null) {
const { account, newIndex } = selectNext(this.#accounts, this.#currentIndex, () => this.saveToDisk(), modelId);
this.#currentIndex = newIndex;
return account;
}
@@ -138,10 +141,11 @@ export class AccountManager {
/**
* Get the current account without advancing the index (sticky selection).
* Used for cache continuity - sticks to the same account until rate-limited.
* @param {string} [modelId] - Optional model ID
* @returns {Object|null} The current account or null if unavailable/rate-limited
*/
getCurrentStickyAccount() {
const { account, newIndex } = getSticky(this.#accounts, this.#currentIndex, () => this.saveToDisk());
getCurrentStickyAccount(modelId = null) {
const { account, newIndex } = getSticky(this.#accounts, this.#currentIndex, () => this.saveToDisk(), modelId);
this.#currentIndex = newIndex;
return account;
}
@@ -149,10 +153,11 @@ export class AccountManager {
/**
* Check if we should wait for the current account's rate limit to reset.
* Used for sticky account selection - wait if rate limit is short (≤ threshold).
* @param {string} [modelId] - Optional model ID
* @returns {{shouldWait: boolean, waitMs: number, account: Object|null}}
*/
shouldWaitForCurrentAccount() {
return shouldWait(this.#accounts, this.#currentIndex);
shouldWaitForCurrentAccount(modelId = null) {
return shouldWait(this.#accounts, this.#currentIndex, modelId);
}
/**
@@ -160,10 +165,11 @@ export class AccountManager {
* Prefers the current account for cache continuity, only switches when:
* - Current account is rate-limited for > 2 minutes
* - Current account is invalid
* @param {string} [modelId] - Optional model ID
* @returns {{account: Object|null, waitMs: number}} Account to use and optional wait time
*/
pickStickyAccount() {
const { account, waitMs, newIndex } = selectSticky(this.#accounts, this.#currentIndex, () => this.saveToDisk());
pickStickyAccount(modelId = null) {
const { account, waitMs, newIndex } = selectSticky(this.#accounts, this.#currentIndex, () => this.saveToDisk(), modelId);
this.#currentIndex = newIndex;
return { account, waitMs };
}
@@ -172,9 +178,10 @@ export class AccountManager {
* Mark an account as rate-limited
* @param {string} email - Email of the account to mark
* @param {number|null} resetMs - Time in ms until rate limit resets (optional)
* @param {string} [modelId] - Optional model ID to mark specific limit
*/
markRateLimited(email, resetMs = null) {
markLimited(this.#accounts, email, resetMs, this.#settings);
markRateLimited(email, resetMs = null, modelId = null) {
markLimited(this.#accounts, email, resetMs, this.#settings, modelId);
this.saveToDisk();
}
@@ -190,10 +197,11 @@ export class AccountManager {
/**
* Get the minimum wait time until any account becomes available
* @param {string} [modelId] - Optional model ID
* @returns {number} Wait time in milliseconds
*/
getMinWaitTimeMs() {
return getMinWait(this.#accounts);
getMinWaitTimeMs(modelId = null) {
return getMinWait(this.#accounts, modelId);
}
/**
@@ -251,9 +259,16 @@ export class AccountManager {
*/
getStatus() {
const available = this.getAvailableAccounts();
const rateLimited = this.#accounts.filter(a => a.isRateLimited);
const invalid = this.getInvalidAccounts();
// Count accounts that have any active model-specific rate limits
const rateLimited = this.#accounts.filter(a => {
if (!a.modelRateLimits) return false;
return Object.values(a.modelRateLimits).some(
limit => limit.isRateLimited && limit.resetTime > Date.now()
);
});
return {
total: this.#accounts.length,
available: available.length,
@@ -263,8 +278,7 @@ export class AccountManager {
accounts: this.#accounts.map(a => ({
email: a.email,
source: a.source,
isRateLimited: a.isRateLimited,
rateLimitResetTime: a.rateLimitResetTime,
modelRateLimits: a.modelRateLimits || {},
isInvalid: a.isInvalid || false,
invalidReason: a.invalidReason || null,
lastUsed: a.lastUsed

View File

@@ -2,6 +2,7 @@
* Rate Limit Management
*
* Handles rate limit tracking and state management for accounts.
* All rate limits are model-specific.
*/
import { DEFAULT_COOLDOWN_MS } from '../constants.js';
@@ -9,24 +10,44 @@ import { formatDuration } from '../utils/helpers.js';
import { logger } from '../utils/logger.js';
/**
* Check if all accounts are rate-limited
* Check if all accounts are rate-limited for a specific model
*
* @param {Array} accounts - Array of account objects
* @param {string} modelId - Model ID to check rate limits for
* @returns {boolean} True if all accounts are rate-limited
*/
export function isAllRateLimited(accounts) {
export function isAllRateLimited(accounts, modelId) {
if (accounts.length === 0) return true;
return accounts.every(acc => acc.isRateLimited);
if (!modelId) return false; // No model specified = not rate limited
return accounts.every(acc => {
if (acc.isInvalid) return true; // Invalid accounts count as unavailable
const modelLimits = acc.modelRateLimits || {};
const limit = modelLimits[modelId];
return limit && limit.isRateLimited && limit.resetTime > Date.now();
});
}
/**
* Get list of available (non-rate-limited, non-invalid) accounts
* Get list of available (non-rate-limited, non-invalid) accounts for a model
*
* @param {Array} accounts - Array of account objects
* @param {string} [modelId] - Model ID to filter by
* @returns {Array} Array of available account objects
*/
export function getAvailableAccounts(accounts) {
return accounts.filter(acc => !acc.isRateLimited && !acc.isInvalid);
export function getAvailableAccounts(accounts, modelId = null) {
return accounts.filter(acc => {
if (acc.isInvalid) return false;
if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) {
const limit = acc.modelRateLimits[modelId];
if (limit.isRateLimited && limit.resetTime > Date.now()) {
return false;
}
}
return true;
});
}
/**
@@ -50,11 +71,15 @@ export function clearExpiredLimits(accounts) {
let cleared = 0;
for (const account of accounts) {
if (account.isRateLimited && account.rateLimitResetTime && account.rateLimitResetTime <= now) {
account.isRateLimited = false;
account.rateLimitResetTime = null;
cleared++;
logger.success(`[AccountManager] Rate limit expired for: ${account.email}`);
if (account.modelRateLimits) {
for (const [modelId, limit] of Object.entries(account.modelRateLimits)) {
if (limit.isRateLimited && limit.resetTime <= now) {
limit.isRateLimited = false;
limit.resetTime = null;
cleared++;
logger.success(`[AccountManager] Rate limit expired for: ${account.email} (model: ${modelId})`);
}
}
}
}
@@ -68,31 +93,43 @@ export function clearExpiredLimits(accounts) {
*/
export function resetAllRateLimits(accounts) {
for (const account of accounts) {
account.isRateLimited = false;
account.rateLimitResetTime = null;
if (account.modelRateLimits) {
for (const key of Object.keys(account.modelRateLimits)) {
account.modelRateLimits[key] = { isRateLimited: false, resetTime: null };
}
}
}
logger.warn('[AccountManager] Reset all rate limits for optimistic retry');
}
/**
* Mark an account as rate-limited
* Mark an account as rate-limited for a specific model
*
* @param {Array} accounts - Array of account objects
* @param {string} email - Email of the account to mark
* @param {number|null} resetMs - Time in ms until rate limit resets (optional)
* @param {number|null} resetMs - Time in ms until rate limit resets
* @param {Object} settings - Settings object with cooldownDurationMs
* @param {string} modelId - Model ID to mark rate limit for
* @returns {boolean} True if account was found and marked
*/
export function markRateLimited(accounts, email, resetMs = null, settings = {}) {
export function markRateLimited(accounts, email, resetMs = null, settings = {}, modelId) {
const account = accounts.find(a => a.email === email);
if (!account) return false;
account.isRateLimited = true;
const cooldownMs = resetMs || settings.cooldownDurationMs || DEFAULT_COOLDOWN_MS;
account.rateLimitResetTime = Date.now() + cooldownMs;
const resetTime = Date.now() + cooldownMs;
if (!account.modelRateLimits) {
account.modelRateLimits = {};
}
account.modelRateLimits[modelId] = {
isRateLimited: true,
resetTime: resetTime
};
logger.warn(
`[AccountManager] Rate limited: ${email}. Available in ${formatDuration(cooldownMs)}`
`[AccountManager] Rate limited: ${email} (model: ${modelId}). Available in ${formatDuration(cooldownMs)}`
);
return true;
@@ -128,24 +165,28 @@ export function markInvalid(accounts, email, reason = 'Unknown error') {
}
/**
* Get the minimum wait time until any account becomes available
* Get the minimum wait time until any account becomes available for a model
*
* @param {Array} accounts - Array of account objects
* @param {string} modelId - Model ID to check
* @returns {number} Wait time in milliseconds
*/
export function getMinWaitTimeMs(accounts) {
if (!isAllRateLimited(accounts)) return 0;
export function getMinWaitTimeMs(accounts, modelId) {
if (!isAllRateLimited(accounts, modelId)) return 0;
const now = Date.now();
let minWait = Infinity;
let soonestAccount = null;
for (const account of accounts) {
if (account.rateLimitResetTime) {
const wait = account.rateLimitResetTime - now;
if (wait > 0 && wait < minWait) {
minWait = wait;
soonestAccount = account;
if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
const limit = account.modelRateLimits[modelId];
if (limit.isRateLimited && limit.resetTime) {
const wait = limit.resetTime - now;
if (wait > 0 && wait < minWait) {
minWait = wait;
soonestAccount = account;
}
}
}
}

View File

@@ -2,6 +2,7 @@
* Account Selection
*
* Handles account picking logic (round-robin, sticky) for cache continuity.
* All rate limit checks are model-specific.
*/
import { MAX_WAIT_BEFORE_ERROR_MS } from '../constants.js';
@@ -9,18 +10,38 @@ import { formatDuration } from '../utils/helpers.js';
import { logger } from '../utils/logger.js';
import { clearExpiredLimits, getAvailableAccounts } from './rate-limits.js';
/**
* Check if an account is usable for a specific model
* @param {Object} account - Account object
* @param {string} modelId - Model ID to check
* @returns {boolean} True if account is usable
*/
function isAccountUsable(account, modelId) {
if (!account || account.isInvalid) return false;
if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
const limit = account.modelRateLimits[modelId];
if (limit.isRateLimited && limit.resetTime > Date.now()) {
return false;
}
}
return true;
}
/**
* Pick the next available account (fallback when current is unavailable).
*
* @param {Array} accounts - Array of account objects
* @param {number} currentIndex - Current account index
* @param {Function} onSave - Callback to save changes
* @param {string} [modelId] - Model ID to check rate limits for
* @returns {{account: Object|null, newIndex: number}} The next available account and new index
*/
export function pickNext(accounts, currentIndex, onSave) {
export function pickNext(accounts, currentIndex, onSave, modelId = null) {
clearExpiredLimits(accounts);
const available = getAvailableAccounts(accounts);
const available = getAvailableAccounts(accounts, modelId);
if (available.length === 0) {
return { account: null, newIndex: currentIndex };
}
@@ -36,7 +57,7 @@ export function pickNext(accounts, currentIndex, onSave) {
const idx = (index + i) % accounts.length;
const account = accounts[idx];
if (!account.isRateLimited && !account.isInvalid) {
if (isAccountUsable(account, modelId)) {
account.lastUsed = Date.now();
const position = idx + 1;
@@ -59,9 +80,10 @@ export function pickNext(accounts, currentIndex, onSave) {
* @param {Array} accounts - Array of account objects
* @param {number} currentIndex - Current account index
* @param {Function} onSave - Callback to save changes
* @param {string} [modelId] - Model ID to check rate limits for
* @returns {{account: Object|null, newIndex: number}} The current account and index
*/
export function getCurrentStickyAccount(accounts, currentIndex, onSave) {
export function getCurrentStickyAccount(accounts, currentIndex, onSave, modelId = null) {
clearExpiredLimits(accounts);
if (accounts.length === 0) {
@@ -77,8 +99,7 @@ export function getCurrentStickyAccount(accounts, currentIndex, onSave) {
// Get current account directly (activeIndex = current account)
const account = accounts[index];
// Return if available
if (account && !account.isRateLimited && !account.isInvalid) {
if (isAccountUsable(account, modelId)) {
account.lastUsed = Date.now();
// Trigger save (don't await to avoid blocking)
if (onSave) onSave();
@@ -93,9 +114,10 @@ export function getCurrentStickyAccount(accounts, currentIndex, onSave) {
*
* @param {Array} accounts - Array of account objects
* @param {number} currentIndex - Current account index
* @param {string} [modelId] - Model ID to check rate limits for
* @returns {{shouldWait: boolean, waitMs: number, account: Object|null}}
*/
export function shouldWaitForCurrentAccount(accounts, currentIndex) {
export function shouldWaitForCurrentAccount(accounts, currentIndex, modelId = null) {
if (accounts.length === 0) {
return { shouldWait: false, waitMs: 0, account: null };
}
@@ -113,15 +135,21 @@ export function shouldWaitForCurrentAccount(accounts, currentIndex) {
return { shouldWait: false, waitMs: 0, account: null };
}
if (account.isRateLimited && account.rateLimitResetTime) {
const waitMs = account.rateLimitResetTime - Date.now();
let waitMs = 0;
// If wait time is within threshold, recommend waiting
if (waitMs > 0 && waitMs <= MAX_WAIT_BEFORE_ERROR_MS) {
return { shouldWait: true, waitMs, account };
// Check model-specific limit
if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
const limit = account.modelRateLimits[modelId];
if (limit.isRateLimited && limit.resetTime) {
waitMs = limit.resetTime - Date.now();
}
}
// If wait time is within threshold, recommend waiting
if (waitMs > 0 && waitMs <= MAX_WAIT_BEFORE_ERROR_MS) {
return { shouldWait: true, waitMs, account };
}
return { shouldWait: false, waitMs: 0, account };
}
@@ -132,21 +160,22 @@ export function shouldWaitForCurrentAccount(accounts, currentIndex) {
* @param {Array} accounts - Array of account objects
* @param {number} currentIndex - Current account index
* @param {Function} onSave - Callback to save changes
* @param {string} [modelId] - Model ID to check rate limits for
* @returns {{account: Object|null, waitMs: number, newIndex: number}}
*/
export function pickStickyAccount(accounts, currentIndex, onSave) {
export function pickStickyAccount(accounts, currentIndex, onSave, modelId = null) {
// First try to get the current sticky account
const { account: stickyAccount, newIndex: stickyIndex } = getCurrentStickyAccount(accounts, currentIndex, onSave);
const { account: stickyAccount, newIndex: stickyIndex } = getCurrentStickyAccount(accounts, currentIndex, onSave, modelId);
if (stickyAccount) {
return { account: stickyAccount, waitMs: 0, newIndex: stickyIndex };
}
// Current account is rate-limited or invalid.
// CHECK IF OTHERS ARE AVAILABLE before deciding to wait.
const available = getAvailableAccounts(accounts);
const available = getAvailableAccounts(accounts, modelId);
if (available.length > 0) {
// Found a free account! Switch immediately.
const { account: nextAccount, newIndex } = pickNext(accounts, currentIndex, onSave);
const { account: nextAccount, newIndex } = pickNext(accounts, currentIndex, onSave, modelId);
if (nextAccount) {
logger.info(`[AccountManager] Switched to new account (failover): ${nextAccount.email}`);
return { account: nextAccount, waitMs: 0, newIndex };
@@ -154,14 +183,14 @@ export function pickStickyAccount(accounts, currentIndex, onSave) {
}
// No other accounts available. Now checking if we should wait for current account.
const waitInfo = shouldWaitForCurrentAccount(accounts, currentIndex);
const waitInfo = shouldWaitForCurrentAccount(accounts, currentIndex, modelId);
if (waitInfo.shouldWait) {
logger.info(`[AccountManager] Waiting ${formatDuration(waitInfo.waitMs)} for sticky account: ${waitInfo.account.email}`);
return { account: null, waitMs: waitInfo.waitMs, newIndex: currentIndex };
}
// Current account unavailable for too long/invalid, and no others available?
const { account: nextAccount, newIndex } = pickNext(accounts, currentIndex, onSave);
const { account: nextAccount, newIndex } = pickNext(accounts, currentIndex, onSave, modelId);
if (nextAccount) {
logger.info(`[AccountManager] Switched to new account for cache: ${nextAccount.email}`);
}

View File

@@ -26,12 +26,11 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
const accounts = (config.accounts || []).map(acc => ({
...acc,
isRateLimited: acc.isRateLimited || false,
rateLimitResetTime: acc.rateLimitResetTime || null,
lastUsed: acc.lastUsed || null,
// Reset invalid flag on startup - give accounts a fresh chance to refresh
isInvalid: false,
invalidReason: null
invalidReason: null,
modelRateLimits: acc.modelRateLimits || {}
}));
const settings = config.settings || {};
@@ -69,9 +68,8 @@ export function loadDefaultAccount(dbPath) {
const account = {
email: authData.email || 'default@antigravity',
source: 'database',
isRateLimited: false,
rateLimitResetTime: null,
lastUsed: null
lastUsed: null,
modelRateLimits: {}
};
const tokenCache = new Map();
@@ -114,10 +112,9 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
projectId: acc.projectId || undefined,
addedAt: acc.addedAt || undefined,
isRateLimited: acc.isRateLimited,
rateLimitResetTime: acc.rateLimitResetTime,
isInvalid: acc.isInvalid || false,
invalidReason: acc.invalidReason || null,
modelRateLimits: acc.modelRateLimits || {},
lastUsed: acc.lastUsed
})),
settings: settings,