Files
EasyCLIProxyAPI/src/services/quotaActions.ts
T
lzt404 a3f5d2e54e feat(quota): add Claude quota reset support
- Add reset counts and actions to auth file and quota pages
- Reuse the Codex confirmation flow with Claude eligibility and cooldown checks
- Preserve request IDs on retries to prevent duplicate credit consumption
- Add translations, regression tests, and layout checks
2026-10-05 00:45:17 +08:00

87 lines
4.0 KiB
TypeScript

import { resetGrantOperations, RETRY_WINDOW_MS } from './claudeResetOperations';
import { selectResetGrant } from './selectClaudeResetGrant';
import {
captureQuotaCacheGeneration,
commitQuotaCacheIfCurrent,
getQuotaCacheSnapshot,
updateQuotaCache,
} from './quotaCache';
import { normalizeAuthIndex, readBoolean } from './managementApi';
import {
consumeClaudeResetCredit, consumeCodexResetCredit, idleQuota, providerForFile, quotaKey,
type AuthFile, type QuotaState,
} from './quotaService';
const pendingActions = new Set<string>();
type QuotaActionOutcome = 'cancelled' | 'success' | 'refresh-error' | 'error';
export const canResetCodexQuota = (file: AuthFile, quota: QuotaState): boolean =>
providerForFile(file) === 'codex'
&& !readBoolean(file, 'disabled')
&& quota.status !== 'loading'
&& (quota.resetCredits ?? 0) > 0;
async function runConfirmedQuotaAction(
file: AuthFile,
action: 'reset',
confirmAction: () => Promise<boolean>,
execute: (file: AuthFile) => Promise<QuotaState>,
): Promise<QuotaActionOutcome> {
const key = quotaKey(file);
const original = getQuotaCacheSnapshot()[key];
const previous = original ?? idleQuota();
if (pendingActions.has(key) || previous.status === 'loading' || readBoolean(file, 'disabled')) return 'cancelled';
const generation = captureQuotaCacheGeneration();
pendingActions.add(key);
let pending: QuotaState | undefined;
const commit = (quota: QuotaState) => {
commitQuotaCacheIfCurrent(generation, () => {
updateQuotaCache((current) => current[key] === pending ? { ...current, [key]: quota } : current);
});
};
try {
if (await confirmAction() !== true || captureQuotaCacheGeneration() !== generation
|| getQuotaCacheSnapshot()[key] !== original) return 'cancelled';
pending = { ...previous, status: 'loading', pendingAction: action, actionResult: undefined };
updateQuotaCache((current) => ({ ...current, [key]: pending! }));
const result = await execute(file);
if (result.status === 'error') {
const status = 'refresh-error';
commit({ ...previous, actionResult: { action, status, error: result.error } });
return status;
}
commit({ ...result, actionResult: { action, status: 'success' } });
return 'success';
} catch (error) {
if (!pending) throw error;
commit({ ...previous, actionResult: { action, status: 'error', error: error instanceof Error ? error.message : String(error) } });
return 'error';
} finally {
pendingActions.delete(key);
}
}
export function resetCodexQuotaWithConfirmation(file: AuthFile, confirmReset: () => Promise<boolean>): Promise<QuotaActionOutcome> {
if (!canResetCodexQuota(file, getQuotaCacheSnapshot()[quotaKey(file)] ?? idleQuota())) return Promise.resolve('cancelled');
return runConfirmedQuotaAction(file, 'reset', confirmReset, consumeCodexResetCredit);
}
export function hasPendingClaudeReset(file: AuthFile): boolean {
const operation = resetGrantOperations.inspect(quotaKey(file));
return Boolean(operation && !operation.code);
}
export function canResetQuota(file: AuthFile, quota: QuotaState): boolean {
if (providerForFile(file) !== 'claude') return canResetCodexQuota(file, quota);
if (readBoolean(file, 'disabled') || quota.status === 'loading' || !normalizeAuthIndex(file.auth_index ?? file.authIndex)) return false;
const operation = resetGrantOperations.inspect(quotaKey(file));
if (operation && !operation.code) return Date.now() - operation.createdAt < RETRY_WINDOW_MS;
return Boolean(quota.claudeResetGrants && selectResetGrant(quota.claudeResetGrants, Date.now()));
}
export function resetQuotaWithConfirmation(file: AuthFile, confirmReset: () => Promise<boolean>): Promise<QuotaActionOutcome> {
if (providerForFile(file) !== 'claude') return resetCodexQuotaWithConfirmation(file, confirmReset);
if (!canResetQuota(file, getQuotaCacheSnapshot()[quotaKey(file)] ?? idleQuota())) return Promise.resolve('cancelled');
return runConfirmedQuotaAction(file, 'reset', confirmReset, consumeClaudeResetCredit);
}