fix(cliproxy): quarantine exhausted quota accounts

This commit is contained in:
Tam Nhu Tran
2026-04-21 12:34:07 -04:00
parent 1422c9a80b
commit 478d64a50a
5 changed files with 326 additions and 4 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap # CCS Project Roadmap
Last Updated: 2026-04-19 Last Updated: 2026-04-21
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes ### Recent Fixes
- **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves.
- **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in. - **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in.
- **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser.<lane>.policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config. - **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser.<lane>.policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config.
- **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default. - **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default.
@@ -257,6 +257,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise<bool
### Overview ### Overview
Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account. Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account.
When CCS detects exhaustion and a healthy fallback exists, it also temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires.
``` ```
+===========================================================================+ +===========================================================================+
@@ -293,6 +294,11 @@ Hybrid quota management enables automatic detection of exhausted accounts and fa
+---> Select best account (not paused, not exhausted) +---> Select best account (not paused, not exhausted)
| |
+---> Auto-failover to next account if current exhausted +---> Auto-failover to next account if current exhausted
|
+---> Temporarily pause exhausted account when fallback exists
| - move token out of live auth discovery
| - persist cooldown expiry across launches
| - auto-resume only CCS-created quota pauses
CLI Commands: CLI Commands:
ccs cliproxy pause <account> --> Set isPaused=true in account-manager ccs cliproxy pause <account> --> Set isPaused=true in account-manager
+146
View File
@@ -37,10 +37,26 @@ interface AutoPausedFile {
sessions: AutoPausedSession[]; sessions: AutoPausedSession[];
} }
interface QuotaPausedEntry {
provider: CLIProxyProvider;
accountId: string;
pausedAt: string;
until: number;
reason: 'quota_exhausted';
}
interface QuotaPausedFile {
entries: QuotaPausedEntry[];
}
function getAutoPausedPath(): string { function getAutoPausedPath(): string {
return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json'); return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json');
} }
function getQuotaPausedPath(): string {
return path.join(getCcsDir(), 'cliproxy', 'quota-paused.json');
}
function loadAutoPaused(): AutoPausedFile { function loadAutoPaused(): AutoPausedFile {
try { try {
const filePath = getAutoPausedPath(); const filePath = getAutoPausedPath();
@@ -69,6 +85,48 @@ function saveAutoPaused(data: AutoPausedFile): void {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
} }
function loadQuotaPaused(): QuotaPausedFile {
try {
const filePath = getQuotaPausedPath();
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
entries?: unknown;
};
if (Array.isArray(data.entries)) {
return {
entries: data.entries.filter(
(entry): entry is QuotaPausedEntry =>
typeof entry === 'object' &&
entry !== null &&
typeof (entry as QuotaPausedEntry).provider === 'string' &&
typeof (entry as QuotaPausedEntry).accountId === 'string' &&
typeof (entry as QuotaPausedEntry).pausedAt === 'string' &&
Number.isFinite((entry as QuotaPausedEntry).until)
),
};
}
}
} catch {
// Corrupted or malformed file — start fresh
}
return { entries: [] };
}
function saveQuotaPaused(data: QuotaPausedFile): void {
const filePath = getQuotaPausedPath();
if (data.entries.length === 0) {
try {
fs.unlinkSync(filePath);
} catch {
/* already gone */
}
return;
}
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
}
/** /**
* Check if a process is alive. NOTE: PIDs can be recycled by the OS. * Check if a process is alive. NOTE: PIDs can be recycled by the OS.
* If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits. * If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits.
@@ -325,6 +383,93 @@ export function cleanupStaleAutoPauses(): void {
} }
} }
/**
* Resume quota-paused accounts whose cooldown windows have expired.
* Auto-resume only applies to pauses created by CCS quota handling.
*/
export function restoreExpiredQuotaPauses(now = Date.now()): number {
const data = loadQuotaPaused();
if (data.entries.length === 0) return 0;
const keep: QuotaPausedEntry[] = [];
let resumed = 0;
const registry = loadAccountsRegistry();
for (const entry of data.entries) {
if (!Number.isFinite(entry.until) || entry.until > now) {
keep.push(entry);
continue;
}
const account = registry.providers[entry.provider]?.accounts[entry.accountId];
if (!account?.paused) {
continue;
}
// Only auto-resume the exact pause CCS created for quota cooldown.
// Missing or changed pausedAt metadata is treated as a mismatch so we do
// not accidentally resume a manually paused account.
if (account.pausedAt !== entry.pausedAt) {
continue;
}
if (resumeAccount(entry.provider, entry.accountId)) {
resumed += 1;
continue;
}
// Resume failures are treated as transient I/O/state issues. Keep the
// quota-pause record so the next restore pass can retry instead of leaving
// the account paused forever without any cooldown metadata.
keep.push(entry);
}
saveQuotaPaused({ entries: keep });
return resumed;
}
/**
* Temporarily remove an exhausted account from CLIProxy rotation for the
* configured cooldown window. Returns false when the account was already paused
* or could not be paused, so callers can fall back to in-memory cooldown only.
*/
export function pauseAccountForQuotaCooldown(
provider: CLIProxyProvider,
accountId: string,
cooldownMinutes: number,
now = Date.now()
): boolean {
const registryBefore = loadAccountsRegistry();
const accountBefore = registryBefore.providers[provider]?.accounts[accountId];
if (!accountBefore || accountBefore.paused) {
return false;
}
if (!pauseAccount(provider, accountId)) {
return false;
}
const registryAfter = loadAccountsRegistry();
const pausedAt = registryAfter.providers[provider]?.accounts[accountId]?.pausedAt;
if (!pausedAt) {
return false;
}
const data = loadQuotaPaused();
data.entries = data.entries.filter(
(entry) => !(entry.provider === provider && entry.accountId === accountId)
);
data.entries.push({
provider,
accountId,
pausedAt,
until: now + cooldownMinutes * 60 * 1000,
reason: 'quota_exhausted',
});
saveQuotaPaused(data);
return true;
}
/** /**
* Enforce provider isolation by auto-pausing conflicting accounts in other providers. * Enforce provider isolation by auto-pausing conflicting accounts in other providers.
* Records paused accounts for crash recovery and session exit restore. * Records paused accounts for crash recovery and session exit restore.
@@ -553,6 +698,7 @@ export async function handleQuotaExhaustion(
const alternative = await findHealthyAccount(provider, [accountId]); const alternative = await findHealthyAccount(provider, [accountId]);
if (alternative) { if (alternative) {
pauseAccountForQuotaCooldown(provider, accountId, cooldownMinutes);
setDefaultAccount(provider, alternative.id); setDefaultAccount(provider, alternative.id);
touchAccount(provider, alternative.id); touchAccount(provider, alternative.id);
writeQuotaExhausted(accountId, alternative.id, cooldownMinutes); writeQuotaExhausted(accountId, alternative.id, cooldownMinutes);
+33 -3
View File
@@ -286,6 +286,9 @@ export async function findHealthyAccount(
return null; return null;
} }
const { restoreExpiredQuotaPauses } = await import('./account-safety');
restoreExpiredQuotaPauses();
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free']; const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free'];
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
@@ -388,6 +391,11 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
return { proceed: true, accountId: defaultAccount?.id || '' }; return { proceed: true, accountId: defaultAccount?.id || '' };
} }
const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import(
'./account-safety'
);
restoreExpiredQuotaPauses();
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
const quotaConfig = config.quota_management; const quotaConfig = config.quota_management;
@@ -442,13 +450,32 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5; const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
if (avgQuota < threshold) { if (avgQuota < threshold) {
// Apply cooldown to exhausted account
applyCooldown(provider, defaultAccount.id, quotaConfig.auto?.cooldown_minutes ?? 5); applyCooldown(provider, defaultAccount.id, quotaConfig.auto?.cooldown_minutes ?? 5);
return await findAndSwitch(
const alternative = await findHealthyAccount(provider, [defaultAccount.id]);
if (!alternative) {
return {
proceed: true,
accountId: defaultAccount.id,
reason: `Quota exhausted (${avgQuota.toFixed(1)}%), no alternatives available`,
quotaPercent,
};
}
pauseAccountForQuotaCooldown(
provider, provider,
defaultAccount.id, defaultAccount.id,
`Quota exhausted (${avgQuota.toFixed(1)}%)` quotaConfig.auto?.cooldown_minutes ?? 5
); );
setDefaultAccount(provider, alternative.id);
touchAccount(provider, alternative.id);
return {
proceed: true,
accountId: alternative.id,
switchedFrom: defaultAccount.id,
reason: `Quota exhausted (${avgQuota.toFixed(1)}%)`,
quotaPercent: alternative.lastQuota,
};
} }
return { return {
@@ -471,6 +498,9 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
isDefault: boolean; isDefault: boolean;
}>; }>;
}> { }> {
const { restoreExpiredQuotaPauses } = await import('./account-safety');
restoreExpiredQuotaPauses();
const accounts = getProviderAccounts(provider); const accounts = getProviderAccounts(provider);
const defaultAccount = getDefaultAccount(provider); const defaultAccount = getDefaultAccount(provider);
@@ -17,6 +17,8 @@ import {
handleQuotaExhaustion, handleQuotaExhaustion,
writeQuotaWarning, writeQuotaWarning,
maskEmail, maskEmail,
pauseAccountForQuotaCooldown,
restoreExpiredQuotaPauses,
} from '../../../src/cliproxy/account-safety'; } from '../../../src/cliproxy/account-safety';
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils'; import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
@@ -84,6 +86,12 @@ function writeClaudeAuth(accountId: string, accessToken: string): void {
); );
} }
function writeAuthToken(tokenFile: string, payload: Record<string, unknown>): void {
const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(payload, null, 2));
}
describe('Quota Exhaustion Handlers', () => { describe('Quota Exhaustion Handlers', () => {
describe('writeQuotaWarning', () => { describe('writeQuotaWarning', () => {
it('should write to stderr with box format', async () => { it('should write to stderr with box format', async () => {
@@ -247,10 +255,13 @@ describe('Quota Exhaustion Handlers', () => {
}); });
const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10); const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
// Should return gracefully with null switched // Should return gracefully with null switched
expect(result.switchedTo).toBeNull(); expect(result.switchedTo).toBeNull();
expect(result.reason).toContain('no alternatives'); expect(result.reason).toContain('no alternatives');
expect(getAccount('agy', 'only@gmail.com')?.paused).not.toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
}); });
it('should switch Claude accounts when fallback quota endpoint returns 404', async () => { it('should switch Claude accounts when fallback quota endpoint returns 404', async () => {
@@ -305,6 +316,18 @@ describe('Quota Exhaustion Handlers', () => {
expect(result.switchedTo).toBe('fallback@example.com'); expect(result.switchedTo).toBe('fallback@example.com');
expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com'); expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com');
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(true);
expect(
fs.existsSync(
path.join(
tmpDir,
'.ccs',
'cliproxy',
'auth-paused',
`claude-${sanitizeEmail('exhausted@example.com')}.json`
)
)
).toBe(true);
}); });
it('should write warning to stderr', async () => { it('should write warning to stderr', async () => {
@@ -389,5 +412,121 @@ describe('Quota Exhaustion Handlers', () => {
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result.switchedTo).toBeNull(); expect(result.switchedTo).toBeNull();
}); });
it('auto-resumes quota-paused accounts after cooldown expiry', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
expect(resumed).toBe(1);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).not.toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
const registryPath = path.join(tmpDir, '.ccs', 'cliproxy', 'accounts.json');
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
providers: {
agy: {
default: string;
accounts: Record<string, { paused?: boolean; pausedAt?: string }>;
};
};
};
delete registry.providers.agy.accounts['cooldown@gmail.com']?.pausedAt;
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
expect(resumed).toBe(0);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
expect(
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
).toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('keeps quota-paused entries when auto-resume fails and retries later', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
fs.rmSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth'), {
recursive: true,
force: true,
});
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json');
const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as {
entries?: Array<{ accountId?: string }>;
};
expect(resumed).toBe(0);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
expect(
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
).toBe(true);
expect(quotaPaused.entries?.map((entry) => entry.accountId)).toContain('cooldown@gmail.com');
});
}); });
}); });