mirror of
https://github.com/NoeFabris/opencode-antigravity-auth.git
synced 2026-05-13 23:53:18 +00:00
- Add enabled field to AccountMetadataV3 for account enable/disable - Add getEnabledAccounts/getTotalAccountCount methods to AccountManager - Add quota.ts with quota checking logic via fetchAvailableModels API - Integrate 'Check quotas' and 'Manage accounts' options into auth menu - Display quota status for Claude, Gemini 3 Pro, and Gemini 3 Flash - Add standalone check-quota.mjs script for manual verification - Update tsconfig to exclude temp_research folder
912 lines
30 KiB
TypeScript
912 lines
30 KiB
TypeScript
import { formatRefreshParts, parseRefreshParts } from "./auth";
|
|
import { loadAccounts, saveAccounts, type AccountStorageV3, type RateLimitStateV3, type ModelFamily, type HeaderStyle, type CooldownReason } from "./storage";
|
|
import type { OAuthAuthDetails, RefreshParts } from "./types";
|
|
import type { AccountSelectionStrategy } from "./config/schema";
|
|
import { getHealthTracker, getTokenTracker, selectHybridAccount, type AccountWithMetrics } from "./rotation";
|
|
import { generateFingerprint, type Fingerprint, type FingerprintVersion, MAX_FINGERPRINT_HISTORY } from "./fingerprint";
|
|
|
|
export type { ModelFamily, HeaderStyle, CooldownReason } from "./storage";
|
|
export type { AccountSelectionStrategy } from "./config/schema";
|
|
|
|
export type RateLimitReason =
|
|
| "QUOTA_EXHAUSTED"
|
|
| "RATE_LIMIT_EXCEEDED"
|
|
| "MODEL_CAPACITY_EXHAUSTED"
|
|
| "SERVER_ERROR"
|
|
| "UNKNOWN";
|
|
|
|
export interface RateLimitBackoffResult {
|
|
backoffMs: number;
|
|
reason: RateLimitReason;
|
|
}
|
|
|
|
const QUOTA_EXHAUSTED_BACKOFFS = [60_000, 300_000, 1_800_000, 7_200_000] as const;
|
|
const RATE_LIMIT_EXCEEDED_BACKOFF = 30_000;
|
|
// Increased from 15s to 45s base + jitter to reduce retry pressure on capacity errors
|
|
const MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF = 45_000;
|
|
const MODEL_CAPACITY_EXHAUSTED_JITTER_MAX = 30_000; // ±15s jitter range
|
|
const SERVER_ERROR_BACKOFF = 20_000;
|
|
const UNKNOWN_BACKOFF = 60_000;
|
|
const MIN_BACKOFF_MS = 2_000;
|
|
|
|
/**
|
|
* Generate a random jitter value for backoff timing.
|
|
* Helps prevent thundering herd problem when multiple clients retry simultaneously.
|
|
*/
|
|
function generateJitter(maxJitterMs: number): number {
|
|
return Math.random() * maxJitterMs - (maxJitterMs / 2);
|
|
}
|
|
|
|
export function parseRateLimitReason(
|
|
reason: string | undefined,
|
|
message: string | undefined,
|
|
status?: number
|
|
): RateLimitReason {
|
|
// 1. Status Code Checks (Rust parity)
|
|
// 529 = Site Overloaded, 503 = Service Unavailable -> Capacity issues
|
|
if (status === 529 || status === 503) return "MODEL_CAPACITY_EXHAUSTED";
|
|
// 500 = Internal Server Error -> Treat as Server Error (soft wait)
|
|
if (status === 500) return "SERVER_ERROR";
|
|
|
|
// 2. Explicit Reason String
|
|
if (reason) {
|
|
switch (reason.toUpperCase()) {
|
|
case "QUOTA_EXHAUSTED": return "QUOTA_EXHAUSTED";
|
|
case "RATE_LIMIT_EXCEEDED": return "RATE_LIMIT_EXCEEDED";
|
|
case "MODEL_CAPACITY_EXHAUSTED": return "MODEL_CAPACITY_EXHAUSTED";
|
|
}
|
|
}
|
|
|
|
// 3. Message Text Scanning (Rust Regex parity)
|
|
if (message) {
|
|
const lower = message.toLowerCase();
|
|
|
|
// Capacity / Overloaded (Transient) - Check FIRST before "exhausted"
|
|
if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("resource exhausted")) {
|
|
return "MODEL_CAPACITY_EXHAUSTED";
|
|
}
|
|
|
|
// RPM / TPM (Short Wait)
|
|
// "per minute", "rate limit", "too many requests"
|
|
// "presque" (French: almost) - retained for i18n parity with Rust reference
|
|
if (lower.includes("per minute") || lower.includes("rate limit") || lower.includes("too many requests") || lower.includes("presque")) {
|
|
return "RATE_LIMIT_EXCEEDED";
|
|
}
|
|
|
|
// Quota (Long Wait)
|
|
if (lower.includes("exhausted") || lower.includes("quota")) {
|
|
return "QUOTA_EXHAUSTED";
|
|
}
|
|
}
|
|
|
|
// Default fallback for 429 without clearer info
|
|
if (status === 429) {
|
|
return "UNKNOWN";
|
|
}
|
|
|
|
return "UNKNOWN";
|
|
}
|
|
|
|
export function calculateBackoffMs(
|
|
reason: RateLimitReason,
|
|
consecutiveFailures: number,
|
|
retryAfterMs?: number | null
|
|
): number {
|
|
// Respect explicit Retry-After header if reasonable
|
|
if (retryAfterMs && retryAfterMs > 0) {
|
|
// Rust uses 2s min buffer, we keep 2s
|
|
return Math.max(retryAfterMs, MIN_BACKOFF_MS);
|
|
}
|
|
|
|
switch (reason) {
|
|
case "QUOTA_EXHAUSTED": {
|
|
const index = Math.min(consecutiveFailures, QUOTA_EXHAUSTED_BACKOFFS.length - 1);
|
|
return QUOTA_EXHAUSTED_BACKOFFS[index] ?? UNKNOWN_BACKOFF;
|
|
}
|
|
case "RATE_LIMIT_EXCEEDED":
|
|
return RATE_LIMIT_EXCEEDED_BACKOFF; // 30s
|
|
case "MODEL_CAPACITY_EXHAUSTED":
|
|
// Apply jitter to prevent thundering herd on capacity errors
|
|
return MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF + generateJitter(MODEL_CAPACITY_EXHAUSTED_JITTER_MAX);
|
|
case "SERVER_ERROR":
|
|
return SERVER_ERROR_BACKOFF; // 20s
|
|
case "UNKNOWN":
|
|
default:
|
|
return UNKNOWN_BACKOFF; // 60s
|
|
}
|
|
}
|
|
|
|
export type BaseQuotaKey = "claude" | "gemini-antigravity" | "gemini-cli";
|
|
export type QuotaKey = BaseQuotaKey | `${BaseQuotaKey}:${string}`;
|
|
|
|
export interface ManagedAccount {
|
|
index: number;
|
|
email?: string;
|
|
addedAt: number;
|
|
lastUsed: number;
|
|
parts: RefreshParts;
|
|
access?: string;
|
|
expires?: number;
|
|
enabled: boolean;
|
|
rateLimitResetTimes: RateLimitStateV3;
|
|
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
|
|
coolingDownUntil?: number;
|
|
cooldownReason?: CooldownReason;
|
|
touchedForQuota: Record<string, number>;
|
|
consecutiveFailures?: number;
|
|
/** Timestamp of last failure for TTL-based reset of consecutiveFailures */
|
|
lastFailureTime?: number;
|
|
/** Per-account device fingerprint for rate limit mitigation */
|
|
fingerprint?: import("./fingerprint").Fingerprint;
|
|
/** History of previous fingerprints for this account */
|
|
fingerprintHistory?: FingerprintVersion[];
|
|
}
|
|
|
|
function nowMs(): number {
|
|
return Date.now();
|
|
}
|
|
|
|
function clampNonNegativeInt(value: unknown, fallback: number): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
return fallback;
|
|
}
|
|
return value < 0 ? 0 : Math.floor(value);
|
|
}
|
|
|
|
function getQuotaKey(family: ModelFamily, headerStyle: HeaderStyle, model?: string | null): QuotaKey {
|
|
if (family === "claude") {
|
|
return "claude";
|
|
}
|
|
const base = headerStyle === "gemini-cli" ? "gemini-cli" : "gemini-antigravity";
|
|
if (model) {
|
|
return `${base}:${model}`;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function isRateLimitedForQuotaKey(account: ManagedAccount, key: QuotaKey): boolean {
|
|
const resetTime = account.rateLimitResetTimes[key];
|
|
return resetTime !== undefined && nowMs() < resetTime;
|
|
}
|
|
|
|
function isRateLimitedForFamily(account: ManagedAccount, family: ModelFamily, model?: string | null): boolean {
|
|
if (family === "claude") {
|
|
return isRateLimitedForQuotaKey(account, "claude");
|
|
}
|
|
|
|
const antigravityIsLimited = isRateLimitedForHeaderStyle(account, family, "antigravity", model);
|
|
const cliIsLimited = isRateLimitedForHeaderStyle(account, family, "gemini-cli", model);
|
|
|
|
return antigravityIsLimited && cliIsLimited;
|
|
}
|
|
|
|
function isRateLimitedForHeaderStyle(account: ManagedAccount, family: ModelFamily, headerStyle: HeaderStyle, model?: string | null): boolean {
|
|
clearExpiredRateLimits(account);
|
|
|
|
if (family === "claude") {
|
|
return isRateLimitedForQuotaKey(account, "claude");
|
|
}
|
|
|
|
// Check model-specific quota first if provided
|
|
if (model) {
|
|
const modelKey = getQuotaKey(family, headerStyle, model);
|
|
if (isRateLimitedForQuotaKey(account, modelKey)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Then check base family quota
|
|
const baseKey = getQuotaKey(family, headerStyle);
|
|
return isRateLimitedForQuotaKey(account, baseKey);
|
|
}
|
|
|
|
function clearExpiredRateLimits(account: ManagedAccount): void {
|
|
const now = nowMs();
|
|
const keys = Object.keys(account.rateLimitResetTimes) as QuotaKey[];
|
|
for (const key of keys) {
|
|
const resetTime = account.rateLimitResetTimes[key];
|
|
if (resetTime !== undefined && now >= resetTime) {
|
|
delete account.rateLimitResetTimes[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory multi-account manager with sticky account selection.
|
|
*
|
|
* Uses the same account until it hits a rate limit (429), then switches.
|
|
* Rate limits are tracked per-model-family (claude/gemini) so an account
|
|
* rate-limited for Claude can still be used for Gemini.
|
|
*
|
|
* Source of truth for the pool is `antigravity-accounts.json`.
|
|
*/
|
|
export class AccountManager {
|
|
private accounts: ManagedAccount[] = [];
|
|
private cursor = 0;
|
|
private currentAccountIndexByFamily: Record<ModelFamily, number> = {
|
|
claude: -1,
|
|
gemini: -1,
|
|
};
|
|
private sessionOffsetApplied: Record<ModelFamily, boolean> = {
|
|
claude: false,
|
|
gemini: false,
|
|
};
|
|
private lastToastAccountIndex = -1;
|
|
private lastToastTime = 0;
|
|
|
|
private savePending = false;
|
|
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
private savePromiseResolvers: Array<() => void> = [];
|
|
|
|
static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise<AccountManager> {
|
|
const stored = await loadAccounts();
|
|
return new AccountManager(authFallback, stored);
|
|
}
|
|
|
|
constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV3 | null) {
|
|
const authParts = authFallback ? parseRefreshParts(authFallback.refresh) : null;
|
|
|
|
if (stored && stored.accounts.length === 0) {
|
|
this.accounts = [];
|
|
this.cursor = 0;
|
|
return;
|
|
}
|
|
|
|
if (stored && stored.accounts.length > 0) {
|
|
const baseNow = nowMs();
|
|
this.accounts = stored.accounts
|
|
.map((acc, index): ManagedAccount | null => {
|
|
if (!acc.refreshToken || typeof acc.refreshToken !== "string") {
|
|
return null;
|
|
}
|
|
const matchesFallback = !!(
|
|
authFallback &&
|
|
authParts &&
|
|
authParts.refreshToken &&
|
|
acc.refreshToken === authParts.refreshToken
|
|
);
|
|
|
|
return {
|
|
index,
|
|
email: acc.email,
|
|
addedAt: clampNonNegativeInt(acc.addedAt, baseNow),
|
|
lastUsed: clampNonNegativeInt(acc.lastUsed, 0),
|
|
parts: {
|
|
refreshToken: acc.refreshToken,
|
|
projectId: acc.projectId,
|
|
managedProjectId: acc.managedProjectId,
|
|
},
|
|
access: matchesFallback ? authFallback?.access : undefined,
|
|
expires: matchesFallback ? authFallback?.expires : undefined,
|
|
enabled: acc.enabled !== false,
|
|
rateLimitResetTimes: acc.rateLimitResetTimes ?? {},
|
|
lastSwitchReason: acc.lastSwitchReason,
|
|
coolingDownUntil: acc.coolingDownUntil,
|
|
cooldownReason: acc.cooldownReason,
|
|
touchedForQuota: {},
|
|
// Use stored fingerprint or generate new one for rate limit mitigation
|
|
fingerprint: acc.fingerprint ?? generateFingerprint(),
|
|
};
|
|
})
|
|
.filter((a): a is ManagedAccount => a !== null);
|
|
|
|
this.cursor = clampNonNegativeInt(stored.activeIndex, 0);
|
|
if (this.accounts.length > 0) {
|
|
this.cursor = this.cursor % this.accounts.length;
|
|
const defaultIndex = this.cursor;
|
|
this.currentAccountIndexByFamily.claude = clampNonNegativeInt(
|
|
stored.activeIndexByFamily?.claude,
|
|
defaultIndex
|
|
) % this.accounts.length;
|
|
this.currentAccountIndexByFamily.gemini = clampNonNegativeInt(
|
|
stored.activeIndexByFamily?.gemini,
|
|
defaultIndex
|
|
) % this.accounts.length;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// If we have stored accounts, check if we need to add the current auth
|
|
if (authFallback && this.accounts.length > 0) {
|
|
const authParts = parseRefreshParts(authFallback.refresh);
|
|
const hasMatching = this.accounts.some(acc => acc.parts.refreshToken === authParts.refreshToken);
|
|
if (!hasMatching && authParts.refreshToken) {
|
|
const now = nowMs();
|
|
const newAccount: ManagedAccount = {
|
|
index: this.accounts.length,
|
|
email: undefined,
|
|
addedAt: now,
|
|
lastUsed: 0,
|
|
parts: authParts,
|
|
access: authFallback.access,
|
|
expires: authFallback.expires,
|
|
enabled: true,
|
|
rateLimitResetTimes: {},
|
|
touchedForQuota: {},
|
|
};
|
|
this.accounts.push(newAccount);
|
|
// Update indices to include the new account
|
|
this.currentAccountIndexByFamily.claude = Math.min(this.currentAccountIndexByFamily.claude, this.accounts.length - 1);
|
|
this.currentAccountIndexByFamily.gemini = Math.min(this.currentAccountIndexByFamily.gemini, this.accounts.length - 1);
|
|
}
|
|
}
|
|
|
|
if (authFallback) {
|
|
const parts = parseRefreshParts(authFallback.refresh);
|
|
if (parts.refreshToken) {
|
|
const now = nowMs();
|
|
this.accounts = [
|
|
{
|
|
index: 0,
|
|
email: undefined,
|
|
addedAt: now,
|
|
lastUsed: 0,
|
|
parts,
|
|
access: authFallback.access,
|
|
expires: authFallback.expires,
|
|
enabled: true,
|
|
rateLimitResetTimes: {},
|
|
touchedForQuota: {},
|
|
},
|
|
];
|
|
this.cursor = 0;
|
|
this.currentAccountIndexByFamily.claude = 0;
|
|
this.currentAccountIndexByFamily.gemini = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
getAccountCount(): number {
|
|
return this.getEnabledAccounts().length;
|
|
}
|
|
|
|
getTotalAccountCount(): number {
|
|
return this.accounts.length;
|
|
}
|
|
|
|
getEnabledAccounts(): ManagedAccount[] {
|
|
return this.accounts.filter((account) => account.enabled);
|
|
}
|
|
|
|
getAccountsSnapshot(): ManagedAccount[] {
|
|
return this.accounts.map((a) => ({ ...a, parts: { ...a.parts }, rateLimitResetTimes: { ...a.rateLimitResetTimes } }));
|
|
}
|
|
|
|
getCurrentAccountForFamily(family: ModelFamily): ManagedAccount | null {
|
|
const currentIndex = this.currentAccountIndexByFamily[family];
|
|
if (currentIndex >= 0 && currentIndex < this.accounts.length) {
|
|
return this.accounts[currentIndex] ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
markSwitched(account: ManagedAccount, reason: "rate-limit" | "initial" | "rotation", family: ModelFamily): void {
|
|
account.lastSwitchReason = reason;
|
|
this.currentAccountIndexByFamily[family] = account.index;
|
|
}
|
|
|
|
/**
|
|
* Check if we should show an account switch toast.
|
|
* Debounces repeated toasts for the same account.
|
|
*/
|
|
shouldShowAccountToast(accountIndex: number, debounceMs = 30000): boolean {
|
|
const now = nowMs();
|
|
if (accountIndex !== this.lastToastAccountIndex) {
|
|
return true;
|
|
}
|
|
return now - this.lastToastTime >= debounceMs;
|
|
}
|
|
|
|
markToastShown(accountIndex: number): void {
|
|
this.lastToastAccountIndex = accountIndex;
|
|
this.lastToastTime = nowMs();
|
|
}
|
|
|
|
getCurrentOrNextForFamily(
|
|
family: ModelFamily,
|
|
model?: string | null,
|
|
strategy: AccountSelectionStrategy = 'sticky',
|
|
headerStyle: HeaderStyle = 'antigravity',
|
|
pidOffsetEnabled: boolean = false,
|
|
): ManagedAccount | null {
|
|
const quotaKey = getQuotaKey(family, headerStyle, model);
|
|
|
|
if (strategy === 'round-robin') {
|
|
const next = this.getNextForFamily(family, model, headerStyle);
|
|
if (next) {
|
|
this.markTouchedForQuota(next, quotaKey);
|
|
this.currentAccountIndexByFamily[family] = next.index;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
if (strategy === 'hybrid') {
|
|
const healthTracker = getHealthTracker();
|
|
const tokenTracker = getTokenTracker();
|
|
|
|
const accountsWithMetrics: AccountWithMetrics[] = this.accounts.map(acc => {
|
|
clearExpiredRateLimits(acc);
|
|
return {
|
|
index: acc.index,
|
|
lastUsed: acc.lastUsed,
|
|
healthScore: healthTracker.getScore(acc.index),
|
|
isRateLimited: isRateLimitedForFamily(acc, family, model),
|
|
isCoolingDown: this.isAccountCoolingDown(acc),
|
|
};
|
|
});
|
|
|
|
// Get current account index for stickiness
|
|
const currentIndex = this.currentAccountIndexByFamily[family] ?? null;
|
|
|
|
const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex);
|
|
if (selectedIndex !== null) {
|
|
const selected = this.accounts[selectedIndex];
|
|
if (selected) {
|
|
selected.lastUsed = nowMs();
|
|
this.markTouchedForQuota(selected, quotaKey);
|
|
this.currentAccountIndexByFamily[family] = selected.index;
|
|
return selected;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: sticky selection (used when hybrid finds no candidates)
|
|
// PID-based offset for multi-session distribution (opt-in)
|
|
// Different sessions (PIDs) will prefer different starting accounts
|
|
if (pidOffsetEnabled && !this.sessionOffsetApplied[family] && this.accounts.length > 1) {
|
|
const pidOffset = process.pid % this.accounts.length;
|
|
const baseIndex = this.currentAccountIndexByFamily[family] ?? 0;
|
|
this.currentAccountIndexByFamily[family] = (baseIndex + pidOffset) % this.accounts.length;
|
|
this.sessionOffsetApplied[family] = true;
|
|
}
|
|
|
|
const current = this.getCurrentAccountForFamily(family);
|
|
if (current) {
|
|
clearExpiredRateLimits(current);
|
|
const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, model);
|
|
if (!isLimitedForRequestedStyle && !this.isAccountCoolingDown(current)) {
|
|
this.markTouchedForQuota(current, quotaKey);
|
|
return current;
|
|
}
|
|
}
|
|
|
|
const next = this.getNextForFamily(family, model, headerStyle);
|
|
if (next) {
|
|
this.markTouchedForQuota(next, quotaKey);
|
|
this.currentAccountIndexByFamily[family] = next.index;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
getNextForFamily(family: ModelFamily, model?: string | null, headerStyle: HeaderStyle = "antigravity"): ManagedAccount | null {
|
|
const available = this.accounts.filter((a) => {
|
|
clearExpiredRateLimits(a);
|
|
return !isRateLimitedForHeaderStyle(a, family, headerStyle, model) && !this.isAccountCoolingDown(a);
|
|
});
|
|
|
|
if (available.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const account = available[this.cursor % available.length];
|
|
if (!account) {
|
|
return null;
|
|
}
|
|
|
|
this.cursor++;
|
|
// Note: lastUsed is now updated after successful request via markAccountUsed()
|
|
return account;
|
|
}
|
|
|
|
markRateLimited(
|
|
account: ManagedAccount,
|
|
retryAfterMs: number,
|
|
family: ModelFamily,
|
|
headerStyle: HeaderStyle = "antigravity",
|
|
model?: string | null
|
|
): void {
|
|
const key = getQuotaKey(family, headerStyle, model);
|
|
account.rateLimitResetTimes[key] = nowMs() + retryAfterMs;
|
|
}
|
|
|
|
/**
|
|
* Mark an account as used after a successful API request.
|
|
* This updates the lastUsed timestamp for freshness calculations.
|
|
* Should be called AFTER request completion, not during account selection.
|
|
*/
|
|
markAccountUsed(accountIndex: number): void {
|
|
const account = this.accounts.find(a => a.index === accountIndex);
|
|
if (account) {
|
|
account.lastUsed = nowMs();
|
|
}
|
|
}
|
|
|
|
markRateLimitedWithReason(
|
|
account: ManagedAccount,
|
|
family: ModelFamily,
|
|
headerStyle: HeaderStyle,
|
|
model: string | null | undefined,
|
|
reason: RateLimitReason,
|
|
retryAfterMs?: number | null,
|
|
failureTtlMs: number = 3600_000, // Default 1 hour TTL
|
|
): number {
|
|
const now = nowMs();
|
|
|
|
// TTL-based reset: if last failure was more than failureTtlMs ago, reset count
|
|
if (account.lastFailureTime !== undefined && (now - account.lastFailureTime) > failureTtlMs) {
|
|
account.consecutiveFailures = 0;
|
|
}
|
|
|
|
const failures = (account.consecutiveFailures ?? 0) + 1;
|
|
account.consecutiveFailures = failures;
|
|
account.lastFailureTime = now;
|
|
|
|
const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs);
|
|
const key = getQuotaKey(family, headerStyle, model);
|
|
account.rateLimitResetTimes[key] = now + backoffMs;
|
|
|
|
return backoffMs;
|
|
}
|
|
|
|
markRequestSuccess(account: ManagedAccount): void {
|
|
if (account.consecutiveFailures) {
|
|
account.consecutiveFailures = 0;
|
|
}
|
|
}
|
|
|
|
clearAllRateLimitsForFamily(family: ModelFamily, model?: string | null): void {
|
|
for (const account of this.accounts) {
|
|
if (family === "claude") {
|
|
delete account.rateLimitResetTimes.claude;
|
|
} else {
|
|
const antigravityKey = getQuotaKey(family, "antigravity", model);
|
|
const cliKey = getQuotaKey(family, "gemini-cli", model);
|
|
delete account.rateLimitResetTimes[antigravityKey];
|
|
delete account.rateLimitResetTimes[cliKey];
|
|
}
|
|
account.consecutiveFailures = 0;
|
|
}
|
|
}
|
|
|
|
shouldTryOptimisticReset(family: ModelFamily, model?: string | null): boolean {
|
|
const minWaitMs = this.getMinWaitTimeForFamily(family, model);
|
|
return minWaitMs > 0 && minWaitMs <= 2_000;
|
|
}
|
|
|
|
markAccountCoolingDown(account: ManagedAccount, cooldownMs: number, reason: CooldownReason): void {
|
|
account.coolingDownUntil = nowMs() + cooldownMs;
|
|
account.cooldownReason = reason;
|
|
}
|
|
|
|
isAccountCoolingDown(account: ManagedAccount): boolean {
|
|
if (account.coolingDownUntil === undefined) {
|
|
return false;
|
|
}
|
|
if (nowMs() >= account.coolingDownUntil) {
|
|
this.clearAccountCooldown(account);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
clearAccountCooldown(account: ManagedAccount): void {
|
|
delete account.coolingDownUntil;
|
|
delete account.cooldownReason;
|
|
}
|
|
|
|
getAccountCooldownReason(account: ManagedAccount): CooldownReason | undefined {
|
|
return this.isAccountCoolingDown(account) ? account.cooldownReason : undefined;
|
|
}
|
|
|
|
markTouchedForQuota(account: ManagedAccount, quotaKey: string): void {
|
|
account.touchedForQuota[quotaKey] = nowMs();
|
|
}
|
|
|
|
isFreshForQuota(account: ManagedAccount, quotaKey: string): boolean {
|
|
const touchedAt = account.touchedForQuota[quotaKey];
|
|
if (!touchedAt) return true;
|
|
|
|
const resetTime = account.rateLimitResetTimes[quotaKey as QuotaKey];
|
|
if (resetTime && touchedAt < resetTime) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
getFreshAccountsForQuota(quotaKey: string, family: ModelFamily, model?: string | null): ManagedAccount[] {
|
|
return this.accounts.filter(acc => {
|
|
clearExpiredRateLimits(acc);
|
|
return this.isFreshForQuota(acc, quotaKey) &&
|
|
!isRateLimitedForFamily(acc, family, model) &&
|
|
!this.isAccountCoolingDown(acc);
|
|
});
|
|
}
|
|
|
|
isRateLimitedForHeaderStyle(
|
|
account: ManagedAccount,
|
|
family: ModelFamily,
|
|
headerStyle: HeaderStyle,
|
|
model?: string | null
|
|
): boolean {
|
|
return isRateLimitedForHeaderStyle(account, family, headerStyle, model);
|
|
}
|
|
|
|
getAvailableHeaderStyle(account: ManagedAccount, family: ModelFamily, model?: string | null): HeaderStyle | null {
|
|
clearExpiredRateLimits(account);
|
|
if (family === "claude") {
|
|
return isRateLimitedForHeaderStyle(account, family, "antigravity") ? null : "antigravity";
|
|
}
|
|
if (!isRateLimitedForHeaderStyle(account, family, "antigravity", model)) {
|
|
return "antigravity";
|
|
}
|
|
if (!isRateLimitedForHeaderStyle(account, family, "gemini-cli", model)) {
|
|
return "gemini-cli";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
removeAccount(account: ManagedAccount): boolean {
|
|
const idx = this.accounts.indexOf(account);
|
|
if (idx < 0) {
|
|
return false;
|
|
}
|
|
|
|
this.accounts.splice(idx, 1);
|
|
this.accounts.forEach((acc, index) => {
|
|
acc.index = index;
|
|
});
|
|
|
|
if (this.accounts.length === 0) {
|
|
this.cursor = 0;
|
|
this.currentAccountIndexByFamily.claude = -1;
|
|
this.currentAccountIndexByFamily.gemini = -1;
|
|
return true;
|
|
}
|
|
|
|
if (this.cursor > idx) {
|
|
this.cursor -= 1;
|
|
}
|
|
this.cursor = this.cursor % this.accounts.length;
|
|
|
|
for (const family of ["claude", "gemini"] as ModelFamily[]) {
|
|
if (this.currentAccountIndexByFamily[family] > idx) {
|
|
this.currentAccountIndexByFamily[family] -= 1;
|
|
}
|
|
if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
|
|
this.currentAccountIndexByFamily[family] = -1;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
updateFromAuth(account: ManagedAccount, auth: OAuthAuthDetails): void {
|
|
const parts = parseRefreshParts(auth.refresh);
|
|
// Preserve existing projectId/managedProjectId if not in the new parts
|
|
account.parts = {
|
|
...parts,
|
|
projectId: parts.projectId ?? account.parts.projectId,
|
|
managedProjectId: parts.managedProjectId ?? account.parts.managedProjectId,
|
|
};
|
|
account.access = auth.access;
|
|
account.expires = auth.expires;
|
|
}
|
|
|
|
toAuthDetails(account: ManagedAccount): OAuthAuthDetails {
|
|
return {
|
|
type: "oauth",
|
|
refresh: formatRefreshParts(account.parts),
|
|
access: account.access,
|
|
expires: account.expires,
|
|
};
|
|
}
|
|
|
|
getMinWaitTimeForFamily(
|
|
family: ModelFamily,
|
|
model?: string | null,
|
|
headerStyle?: HeaderStyle,
|
|
strict?: boolean,
|
|
): number {
|
|
const available = this.accounts.filter((a) => {
|
|
clearExpiredRateLimits(a);
|
|
return strict && headerStyle
|
|
? !isRateLimitedForHeaderStyle(a, family, headerStyle, model)
|
|
: !isRateLimitedForFamily(a, family, model);
|
|
});
|
|
if (available.length > 0) {
|
|
return 0;
|
|
}
|
|
|
|
const waitTimes: number[] = [];
|
|
for (const a of this.accounts) {
|
|
if (family === "claude") {
|
|
const t = a.rateLimitResetTimes.claude;
|
|
if (t !== undefined) waitTimes.push(Math.max(0, t - nowMs()));
|
|
} else if (strict && headerStyle) {
|
|
const key = getQuotaKey(family, headerStyle, model);
|
|
const t = a.rateLimitResetTimes[key];
|
|
if (t !== undefined) waitTimes.push(Math.max(0, t - nowMs()));
|
|
} else {
|
|
// For Gemini, account becomes available when EITHER pool expires for this model/family
|
|
const antigravityKey = getQuotaKey(family, "antigravity", model);
|
|
const cliKey = getQuotaKey(family, "gemini-cli", model);
|
|
|
|
const t1 = a.rateLimitResetTimes[antigravityKey];
|
|
const t2 = a.rateLimitResetTimes[cliKey];
|
|
|
|
const accountWait = Math.min(
|
|
t1 !== undefined ? Math.max(0, t1 - nowMs()) : Infinity,
|
|
t2 !== undefined ? Math.max(0, t2 - nowMs()) : Infinity
|
|
);
|
|
if (accountWait !== Infinity) waitTimes.push(accountWait);
|
|
}
|
|
}
|
|
|
|
return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
|
|
}
|
|
|
|
getAccounts(): ManagedAccount[] {
|
|
return [...this.accounts];
|
|
}
|
|
|
|
async saveToDisk(): Promise<void> {
|
|
const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude);
|
|
const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini);
|
|
|
|
const storage: AccountStorageV3 = {
|
|
version: 3,
|
|
accounts: this.accounts.map((a) => ({
|
|
email: a.email,
|
|
refreshToken: a.parts.refreshToken,
|
|
projectId: a.parts.projectId,
|
|
managedProjectId: a.parts.managedProjectId,
|
|
addedAt: a.addedAt,
|
|
lastUsed: a.lastUsed,
|
|
lastSwitchReason: a.lastSwitchReason,
|
|
rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? a.rateLimitResetTimes : undefined,
|
|
coolingDownUntil: a.coolingDownUntil,
|
|
cooldownReason: a.cooldownReason,
|
|
fingerprint: a.fingerprint,
|
|
fingerprintHistory: a.fingerprintHistory?.length ? a.fingerprintHistory : undefined,
|
|
})),
|
|
activeIndex: claudeIndex,
|
|
activeIndexByFamily: {
|
|
claude: claudeIndex,
|
|
gemini: geminiIndex,
|
|
},
|
|
};
|
|
|
|
await saveAccounts(storage);
|
|
}
|
|
|
|
requestSaveToDisk(): void {
|
|
if (this.savePending) {
|
|
return;
|
|
}
|
|
this.savePending = true;
|
|
this.saveTimeout = setTimeout(() => {
|
|
void this.executeSave();
|
|
}, 1000);
|
|
}
|
|
|
|
async flushSaveToDisk(): Promise<void> {
|
|
if (!this.savePending) {
|
|
return;
|
|
}
|
|
return new Promise<void>((resolve) => {
|
|
this.savePromiseResolvers.push(resolve);
|
|
});
|
|
}
|
|
|
|
private async executeSave(): Promise<void> {
|
|
this.savePending = false;
|
|
this.saveTimeout = null;
|
|
|
|
try {
|
|
await this.saveToDisk();
|
|
} catch {
|
|
// best-effort persistence; avoid unhandled rejection from timer-driven saves
|
|
} finally {
|
|
const resolvers = this.savePromiseResolvers;
|
|
this.savePromiseResolvers = [];
|
|
for (const resolve of resolvers) {
|
|
resolve();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========== Fingerprint Management ==========
|
|
|
|
/**
|
|
* Regenerate fingerprint for an account, saving the old one to history.
|
|
* @param accountIndex - Index of the account to regenerate fingerprint for
|
|
* @returns The new fingerprint, or null if account not found
|
|
*/
|
|
regenerateAccountFingerprint(accountIndex: number): Fingerprint | null {
|
|
const account = this.accounts[accountIndex];
|
|
if (!account) return null;
|
|
|
|
// Save current fingerprint to history if it exists
|
|
if (account.fingerprint) {
|
|
const historyEntry: FingerprintVersion = {
|
|
fingerprint: account.fingerprint,
|
|
timestamp: nowMs(),
|
|
reason: 'regenerated',
|
|
};
|
|
|
|
if (!account.fingerprintHistory) {
|
|
account.fingerprintHistory = [];
|
|
}
|
|
|
|
// Add to beginning of history (most recent first)
|
|
account.fingerprintHistory.unshift(historyEntry);
|
|
|
|
// Trim to max history size
|
|
if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
|
|
account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
|
|
}
|
|
}
|
|
|
|
// Generate and assign new fingerprint
|
|
account.fingerprint = generateFingerprint();
|
|
this.requestSaveToDisk();
|
|
|
|
return account.fingerprint;
|
|
}
|
|
|
|
/**
|
|
* Restore a fingerprint from history for an account.
|
|
* @param accountIndex - Index of the account
|
|
* @param historyIndex - Index in the fingerprint history to restore from (0 = most recent)
|
|
* @returns The restored fingerprint, or null if account/history not found
|
|
*/
|
|
restoreAccountFingerprint(accountIndex: number, historyIndex: number): Fingerprint | null {
|
|
const account = this.accounts[accountIndex];
|
|
if (!account) return null;
|
|
|
|
const history = account.fingerprintHistory;
|
|
if (!history || historyIndex < 0 || historyIndex >= history.length) {
|
|
return null;
|
|
}
|
|
|
|
// Capture the fingerprint to restore BEFORE modifying history
|
|
const fingerprintToRestore = history[historyIndex]!.fingerprint;
|
|
|
|
// Save current fingerprint to history before restoring (if it exists)
|
|
if (account.fingerprint) {
|
|
const historyEntry: FingerprintVersion = {
|
|
fingerprint: account.fingerprint,
|
|
timestamp: nowMs(),
|
|
reason: 'restored',
|
|
};
|
|
|
|
account.fingerprintHistory!.unshift(historyEntry);
|
|
|
|
// Trim to max history size
|
|
if (account.fingerprintHistory!.length > MAX_FINGERPRINT_HISTORY) {
|
|
account.fingerprintHistory = account.fingerprintHistory!.slice(0, MAX_FINGERPRINT_HISTORY);
|
|
}
|
|
}
|
|
|
|
// Restore the fingerprint
|
|
account.fingerprint = { ...fingerprintToRestore, createdAt: nowMs() };
|
|
|
|
this.requestSaveToDisk();
|
|
|
|
return account.fingerprint;
|
|
}
|
|
|
|
/**
|
|
* Get fingerprint history for an account.
|
|
* @param accountIndex - Index of the account
|
|
* @returns Array of fingerprint versions, or empty array if not found
|
|
*/
|
|
getAccountFingerprintHistory(accountIndex: number): FingerprintVersion[] {
|
|
const account = this.accounts[accountIndex];
|
|
if (!account || !account.fingerprintHistory) {
|
|
return [];
|
|
}
|
|
return [...account.fingerprintHistory];
|
|
}
|
|
}
|