mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-07 04:23:41 +00:00
fix(cliproxy): quarantine exhausted quota accounts
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user