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
This commit is contained in:
1 parent
2b81856521
commit
a3f5d2e54e
17 files changed
+729
-34
No files matched your search
@@ -3,7 +3,7 @@ import { useI18n } from '../i18n';
|
||||
import { MessageNotice } from '../appNotice';
|
||||
import { readBoolean } from '../services/managementApi';
|
||||
import { fileName, formatQuotaTimestamp, type AuthFile, type QuotaState } from '../services/quotaService';
|
||||
import { canResetCodexQuota } from '../services/quotaActions';
|
||||
import { canResetQuota, hasPendingClaudeReset } from '../services/quotaActions';
|
||||
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
|
||||
import './AuthFileQuotaPanel.css';
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset,
|
||||
const name = fileName(file);
|
||||
const compactLayout = compact || dense;
|
||||
const fileDisabled = readBoolean(file, 'disabled');
|
||||
const showReset = Boolean(onReset && (quota.resetCredits ?? 0) > 0);
|
||||
const showReset = Boolean(onReset && ((quota.resetCredits ?? 0) > 0 || hasPendingClaudeReset(file)));
|
||||
const resetCreditsLabel = quota.resetCredits !== undefined ? t('authFiles.quota.resets', { count: quota.resetCredits }) : '';
|
||||
const accountCreditsLabel = quota.creditsUnlimited
|
||||
? t('quota.creditUnlimited')
|
||||
@@ -56,8 +56,8 @@ export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset,
|
||||
{!compactLayout ? <strong>{t('authFiles.settings.quotaRemaining')}</strong> : null}
|
||||
{!compactLayout && quota.plan ? <span className="credential-quota-plan">{quota.plan}</span> : null}
|
||||
{dense && resetCreditsLabel ? <small className="credential-quota-reset-credits">{resetCreditsLabel}</small> : null}
|
||||
{showReset ? <button type="button" className="secondary-button compact-button credential-quota-reset" onClick={onReset} disabled={disabled || !canResetCodexQuota(file, quota)}
|
||||
title={t('quota.reset')}>{t('quota.reset')}</button> : null}
|
||||
{showReset ? <button type="button" className="secondary-button compact-button credential-quota-reset" onClick={onReset} disabled={disabled || !canResetQuota(file, quota)}
|
||||
title={t('quota.reset')}>{t(hasPendingClaudeReset(file) ? 'quota.claude.retry' : 'quota.reset')}</button> : null}
|
||||
{!compactLayout ? <button type="button" className="credential-quota-refresh" disabled={disabled || loading} onClick={onRefresh}
|
||||
title={disabled ? t('quota.fileDisabled') : t('authFiles.quota.refresh')}>
|
||||
{loading ? <LoaderCircle size={13} className="spin" /> : <RefreshCw size={13} />}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { resetCodexQuotaWithConfirmation } from '../services/quotaActions';
|
||||
import { fileName, formatQuotaTimestamp, type AuthFile, type QuotaState } from '../services/quotaService';
|
||||
import { hasPendingClaudeReset, resetQuotaWithConfirmation } from '../services/quotaActions';
|
||||
import { fileName, formatQuotaTimestamp, providerForFile, type AuthFile, type QuotaState } from '../services/quotaService';
|
||||
import type { ConfirmationOptions } from './ConfirmationDialog';
|
||||
|
||||
export function useCodexQuotaReset(
|
||||
export function useQuotaReset(
|
||||
askConfirmation: (options: ConfirmationOptions) => Promise<boolean>,
|
||||
setError: (message: string) => void,
|
||||
) {
|
||||
@@ -12,15 +12,15 @@ export function useCodexQuotaReset(
|
||||
return useCallback(async (file: AuthFile, quota: QuotaState) => {
|
||||
setError('');
|
||||
try {
|
||||
await resetCodexQuotaWithConfirmation(file, () => askConfirmation({
|
||||
await resetQuotaWithConfirmation(file, () => askConfirmation({
|
||||
title: t('quota.reset'),
|
||||
message: t('quota.confirm.title', { name: fileName(file) }),
|
||||
confirmText: t('quota.confirm.button'),
|
||||
message: t(providerForFile(file) === 'claude' ? 'quota.claude.confirm' : 'quota.confirm.title', { name: fileName(file) }),
|
||||
confirmText: t(hasPendingClaudeReset(file) ? 'quota.claude.retry' : 'quota.confirm.button'),
|
||||
details: [
|
||||
{ label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') },
|
||||
{ label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) },
|
||||
],
|
||||
warning: t('quota.confirm.warning'),
|
||||
warning: t(providerForFile(file) === 'claude' ? hasPendingClaudeReset(file) ? 'quota.claude.unknown' : 'quota.claude.warning' : 'quota.confirm.warning'),
|
||||
}));
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : String(requestError));
|
||||
@@ -459,6 +459,19 @@ export const jaOverrides = {
|
||||
'quota.service.xaiPaidAccount': '有料 API アカウント',
|
||||
'quota.service.xaiPaidHealth': '有料 API の会話が利用可能です。xAI は現在、この OAuth 認証情報の総クォータを提供していません。',
|
||||
'quota.service.xaiPaidQuotaUnavailable': 'このアカウントの残りクォータは提供されていません。',
|
||||
'quota.claude.confirm': '「{name}」の Claude クォータをリセットしますか?',
|
||||
'quota.claude.warning': 'Claude のリセット権利を 1 回消費し、対象の使用量制限を解除します。',
|
||||
'quota.claude.readError': 'Claude のリセット権利を取得できません。',
|
||||
'quota.claude.blocked': '利用可能な Claude リセット権利がありません。資格、残り回数、待機期間を更新して確認してください。',
|
||||
'quota.claude.unknown': '結果は不明で、権利を消費した可能性があります。10 分以内にこのボタンから再試行してください。再読み込み、アプリ再起動、別ウィンドウでのリセットは避けてください。',
|
||||
'quota.claude.retry': 'リセットを再試行',
|
||||
'quota.claude.not_limited': '未実行:使用量制限に達していません。',
|
||||
'quota.claude.cooldown': '未実行:待機期間中です。',
|
||||
'quota.claude.ineligible': '未実行:アカウントは対象外です。',
|
||||
'quota.claude.unavailable': 'Claude リセットは一時的に利用できません。',
|
||||
'quota.claude.rate_limited': 'リクエストが多すぎます。後ほど再試行してください。',
|
||||
'quota.claude.auth_error': '認証に失敗しました。Claude の認証情報を確認してください。',
|
||||
'quota.claude.expired': '再試行期限が切れました。Claude アカウントの状態を確認してください。このウィンドウではリセットを停止しています。',
|
||||
'quota.confirm.button': 'リセットを実行',
|
||||
'quota.resetting': 'リセットを送信してクォータを更新中…',
|
||||
'quota.resetResult.submitted': 'リセット要求を送信し、クォータを再取得しました。カードの最新クォータを確認してください。',
|
||||
@@ -478,6 +491,7 @@ export const jaOverrides = {
|
||||
'quota.remaining': '残り {percent}%',
|
||||
'quota.resetPassed': 'リセット時刻到達。更新して確認してください',
|
||||
'quota.subscriptionExpiry': 'サブスクリプション期限:{time}',
|
||||
'quota.expiresAt': '{time} 期限',
|
||||
'quota.resetApplicable': '現在適用可能:{count} 回',
|
||||
'quota.resetCreditsWarning': 'リセット回数の詳細取得に失敗:{error}',
|
||||
'quota.service.window.sevenDayFable': '7 日間 Fable 枠',
|
||||
|
||||
@@ -446,6 +446,19 @@ export const en: Record<MessageKey, string> = {
|
||||
'quota.service.xaiPaidAccount': 'Paid API account',
|
||||
'quota.service.xaiPaidHealth': 'Paid API chat is available. xAI does not currently provide total quota data for this OAuth credential.',
|
||||
'quota.service.xaiPaidQuotaUnavailable': 'Remaining quota is not provided for this account.',
|
||||
'quota.claude.confirm': 'Reset the Claude quota for “{name}”?',
|
||||
'quota.claude.warning': 'This consumes one Claude reset grant to clear eligible usage windows.',
|
||||
'quota.claude.readError': 'Unable to read Claude reset grants.',
|
||||
'quota.claude.blocked': 'No usable Claude reset grant. Refresh to check eligibility, balance, and cooldown.',
|
||||
'quota.claude.unknown': 'The outcome is unknown and a reset may have been consumed. Retry this request within 10 minutes using this button. Do not reload, restart the app, or reset in another window.',
|
||||
'quota.claude.retry': 'Retry reset',
|
||||
'quota.claude.not_limited': 'Not reset: the account is not at its limit.',
|
||||
'quota.claude.cooldown': 'Not reset: cooldown is active.',
|
||||
'quota.claude.ineligible': 'Not reset: the account is ineligible.',
|
||||
'quota.claude.unavailable': 'Claude reset is temporarily unavailable.',
|
||||
'quota.claude.rate_limited': 'Too many requests. Try again later.',
|
||||
'quota.claude.auth_error': 'Authentication failed. Check the Claude credential.',
|
||||
'quota.claude.expired': 'The retry window expired. Check the Claude account status; further resets are blocked in this window.',
|
||||
'quota.confirm.button': 'Confirm Reset',
|
||||
'quota.resetting': 'Submitting reset and refreshing quota…',
|
||||
'quota.resetResult.submitted': 'The reset request was submitted and quota was refreshed. Check the latest quota on this card.',
|
||||
@@ -465,6 +478,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'quota.remaining': '{percent}% remaining',
|
||||
'quota.resetPassed': 'Reset time reached; refresh to verify',
|
||||
'quota.subscriptionExpiry': 'Subscription expires: {time}',
|
||||
'quota.expiresAt': 'Expires {time}',
|
||||
'quota.resetApplicable': 'Currently applicable: {count}',
|
||||
'quota.resetCreditsWarning': 'Reset credit details unavailable: {error}',
|
||||
'quota.service.window.sevenDayFable': '7-day Fable window',
|
||||
|
||||
@@ -445,6 +445,19 @@ export const zhCN = {
|
||||
'quota.service.xaiPaidAccount': '付费 API 账号',
|
||||
'quota.service.xaiPaidHealth': '付费 API 对话可用。xAI 暂不为此 OAuth 凭证提供额度总量数据。',
|
||||
'quota.service.xaiPaidQuotaUnavailable': '此账号不提供剩余额度。',
|
||||
'quota.claude.confirm': '确认重置“{name}”的 Claude 额度吗?',
|
||||
'quota.claude.warning': '将消耗 1 次 Claude 储备重置权益,清除符合条件的用量窗口。',
|
||||
'quota.claude.readError': '无法读取 Claude 重置权益。',
|
||||
'quota.claude.blocked': '当前没有可用的 Claude 重置权益,请刷新后检查资格、剩余次数和冷却状态。',
|
||||
'quota.claude.unknown': '结果未知,可能已消耗一次重置。请在 10 分钟内使用此按钮重试原请求;不要刷新页面、重启应用或在其他窗口发起新重置。',
|
||||
'quota.claude.retry': '重试原重置',
|
||||
'quota.claude.not_limited': '未重置:账号当前未达到限制。',
|
||||
'quota.claude.cooldown': '未重置:仍在冷却期。',
|
||||
'quota.claude.ineligible': '未重置:账号不符合条件。',
|
||||
'quota.claude.unavailable': 'Claude 重置暂不可用。',
|
||||
'quota.claude.rate_limited': '请求过于频繁,请稍后重试。',
|
||||
'quota.claude.auth_error': '认证失败,请检查 Claude 凭证。',
|
||||
'quota.claude.expired': '重试窗口已过期。请先检查 Claude 账号状态;当前窗口已禁止继续重置。',
|
||||
'quota.confirm.button': '确认重置',
|
||||
'quota.resetting': '正在提交重置并刷新额度…',
|
||||
'quota.resetResult.submitted': '重置请求已提交,额度已重新获取。请查看卡片中的最新额度。',
|
||||
@@ -464,6 +477,7 @@ export const zhCN = {
|
||||
'quota.remaining': '剩余 {percent}%',
|
||||
'quota.resetPassed': '重置时间已到,请刷新确认',
|
||||
'quota.subscriptionExpiry': '订阅到期:{time}',
|
||||
'quota.expiresAt': '{time} 到期',
|
||||
'quota.resetApplicable': '当前适用:{count} 次',
|
||||
'quota.resetCreditsWarning': '重置次数详情查询失败:{error}',
|
||||
'quota.service.window.sevenDayFable': '7 天 Fable 窗口',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
|
||||
import { AuthFileQuotaPanel } from '../components/AuthFileQuotaPanel';
|
||||
import { AuthFileSettingsDialog } from '../components/AuthFileSettingsDialog';
|
||||
import { useCodexQuotaReset } from '../components/useCodexQuotaReset';
|
||||
import { useQuotaReset } from '../components/useQuotaReset';
|
||||
import './AuthFileManagementPage.css';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
|
||||
@@ -141,7 +141,7 @@ export function AuthFileManagementPage() {
|
||||
const [quotaRefreshing, setQuotaRefreshing] = useState(false);
|
||||
const [cooldownResetting, setCooldownResetting] = useState<Set<string>>(() => new Set());
|
||||
const [error, setError] = useState('');
|
||||
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
|
||||
const resetQuota = useQuotaReset(askConfirmation, setError);
|
||||
const feedback = useAppNotice();
|
||||
const { showNotice } = feedback;
|
||||
const [copied, setCopied] = useState('');
|
||||
@@ -570,7 +570,7 @@ export function AuthFileManagementPage() {
|
||||
<div className="auth-list-cell auth-list-recent" data-label={t('authFiles.list.recent')}><AuthFileRequestStatus file={file} compact /></div>
|
||||
<div className="auth-list-cell auth-list-usage" data-label={t('authFiles.usage.title')}><AuthFileUsageSummary file={file} /></div>
|
||||
<div className="auth-list-cell auth-list-quota" data-label={t('authFiles.list.quota')}>
|
||||
{quotaProvider ? <AuthFileQuotaPanel compact dense quota={quota} file={file} disabled={busy || disabled} onRefresh={() => void refreshQuota(file)} onReset={quotaProvider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} /> : <span className="auth-list-muted">{t('authFiles.list.noQuota')}</span>}
|
||||
{quotaProvider ? <AuthFileQuotaPanel compact dense quota={quota} file={file} disabled={busy || disabled} onRefresh={() => void refreshQuota(file)} onReset={quotaProvider === 'codex' || quotaProvider === 'claude' ? () => void resetQuota(file, quota) : undefined} /> : <span className="auth-list-muted">{t('authFiles.list.noQuota')}</span>}
|
||||
<QuotaActionFeedback quota={quota} name={name} />
|
||||
</div>
|
||||
<footer className="auth-card-actions auth-list-cell" data-label={t('authFiles.list.actions')}>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { AlertCircle, Gauge, ListRestart, LoaderCircle, RefreshCw } from 'lucide
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { OAuthPageToolbar } from '../components/OAuthPageToolbar';
|
||||
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
|
||||
import { canResetCodexQuota } from '../services/quotaActions';
|
||||
import { useCodexQuotaReset } from '../components/useCodexQuotaReset';
|
||||
import { canResetQuota, hasPendingClaudeReset } from '../services/quotaActions';
|
||||
import { useQuotaReset } from '../components/useQuotaReset';
|
||||
import antigravityIcon from '../assets/icons/antigravity.svg';
|
||||
import claudeIcon from '../assets/icons/claude.svg';
|
||||
import codexIcon from '../assets/icons/codex.svg';
|
||||
@@ -99,7 +99,7 @@ export function QuotaPage() {
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
|
||||
const resetQuota = useQuotaReset(askConfirmation, setError);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return;
|
||||
@@ -172,7 +172,7 @@ export function QuotaPage() {
|
||||
<span className="quota-provider-count">{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}</div>
|
||||
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' || provider === 'claude' ? () => void resetQuota(file, quota) : undefined} />)}</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
|
||||
<img src={provider ? providerMeta[provider].icon : ''} alt="" className="quota-account-icon" />
|
||||
<div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : t('quota.unknownProvider')}</span></div>
|
||||
</div>
|
||||
<div className="quota-plan"><strong>{quota.plan || '—'}</strong><span>{quota.subscriptionActiveUntil ? formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) : ''}</span></div>
|
||||
<div className="quota-plan"><strong>{quota.plan || '—'}</strong><span>{quota.subscriptionActiveUntil ? t('quota.expiresAt', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) }) : ''}</span></div>
|
||||
<div className="quota-status"><span className={quota.status === 'success' ? 'quota-status-badge ready' : quota.status === 'error' ? 'quota-status-badge error' : 'quota-status-badge'}>{quota.status === 'success' ? t('common.enabled') : quota.status === 'loading' ? t('quota.querying') : quota.status === 'error' ? t('common.unavailable') : t('quota.notFetched')}</span><small>{t('authFiles.settings.priority')} 0</small></div>
|
||||
<div className="quota-card-actions">
|
||||
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')} aria-label={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} aria-hidden="true" /></button>
|
||||
@@ -207,15 +207,13 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
|
||||
<MessageNotice message={quota.error ? name + ': ' + quota.error : null} />
|
||||
<div className="quota-card-error"><AlertCircle size={18} />{t('authFiles.quota.failed')}</div>
|
||||
</> : null}
|
||||
{quota.status === 'success' && provider === 'codex' ? <div className="quota-reset-credit-summary">
|
||||
{quota.status === 'success' && (provider === 'codex' || provider === 'claude') ? <div className="quota-reset-credit-summary">
|
||||
{(quota.creditsUnlimited || quota.creditBalance !== undefined) ? <span>{t('quota.creditBalance')} <strong>{quota.creditsUnlimited ? t('quota.creditUnlimited') : quota.creditBalance}</strong></span> : null}
|
||||
<span>{t('quota.resetCredits')} <strong>{quota.resetCredits ?? '—'}</strong></span>
|
||||
{quota.resetCreditsApplicable !== undefined ? <span>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</span> : null}
|
||||
<span>{t('quota.earliestExpiry')} <strong>{formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)}</strong></span>
|
||||
{quota.subscriptionActiveUntil ? <span>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span> : null}
|
||||
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
|
||||
</div> : null}
|
||||
{quota.status === 'success' && provider === 'devin' && quota.subscriptionActiveUntil ? <div className="quota-reset-credit-summary"><span>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span></div> : null}
|
||||
{quota.status === 'success' ? <div className="quota-row-list">{quota.rows.map((row, index) => {
|
||||
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
|
||||
return <div className="real-quota-row" key={`${row.label}-${index}`}>
|
||||
@@ -224,7 +222,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
|
||||
<small>{[row.detail, reset].filter(Boolean).join(' · ')}</small>
|
||||
</div>;
|
||||
})}</div> : null}
|
||||
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button quota-reset-footer" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
|
||||
{onReset && ((quota.resetCredits ?? 0) > 0 || hasPendingClaudeReset(file)) ? <button type="button" className="secondary-button compact-button quota-reset-footer" onClick={onReset} disabled={!canResetQuota(file, quota)} title={t('quota.reset')}>{t(hasPendingClaudeReset(file) ? 'quota.claude.retry' : 'quota.reset')}</button> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Upstream cedar_ember contract, following opencodex anthropic-reset-grants.
|
||||
import { managementApi, isRecord as isApiRecord } from './managementApi';
|
||||
|
||||
const CLAUDE_REQUEST_HEADERS = {
|
||||
'User-Agent': 'claude-cli/2.1.280 (external, cli)',
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-beta': 'oauth-2025-04-20',
|
||||
};
|
||||
const apiCallApi = {
|
||||
async request(body: { authIndex: string; method: string; url: string; header: Record<string, string>; data?: string }, options: { timeout: number }) {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', body, { timeoutMs: options.timeout });
|
||||
let payload = response.body ?? response.bodyText;
|
||||
if (typeof payload === 'string') {
|
||||
try { payload = JSON.parse(payload); } catch { payload = null; }
|
||||
}
|
||||
return { statusCode: Number(response.status_code ?? response.statusCode ?? 0), body: isApiRecord(payload) ? payload : null };
|
||||
},
|
||||
};
|
||||
|
||||
export const ANTHROPIC_API_ORIGIN = 'https://api.anthropic.com';
|
||||
export const ANTHROPIC_RESET_GRANT_PROGRAM = 'cedar_ember';
|
||||
export const ANTHROPIC_RESET_GRANT_STATUS_PATH = '/api/oauth/usage?cedar_ember=1&skip_spend=1';
|
||||
export const ANTHROPIC_PROFILE_PATH = '/api/oauth/profile';
|
||||
/** Same bound the Claude Code client uses for the claim. */
|
||||
export const ANTHROPIC_RESET_GRANT_REDEEM_TIMEOUT_MS = 25_000;
|
||||
|
||||
export const ANTHROPIC_RESET_GRANT_ID_RE = /^[a-z0-9_-]{1,40}$/;
|
||||
export const ANTHROPIC_RESET_REQUEST_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
export const ORGANIZATION_UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Usage windows a grant can clear. Others upstream may add are dropped. */
|
||||
export const ANTHROPIC_RESET_WINDOWS = [
|
||||
'five_hour',
|
||||
'seven_day',
|
||||
'seven_day_overage_included',
|
||||
] as const;
|
||||
export type AnthropicResetWindow = (typeof ANTHROPIC_RESET_WINDOWS)[number];
|
||||
|
||||
/** Terminal answers the claim endpoint can give. */
|
||||
export const ANTHROPIC_RESET_RESULTS = [
|
||||
'reset',
|
||||
'already_used',
|
||||
'not_limited',
|
||||
'cooldown',
|
||||
'ineligible',
|
||||
'unavailable',
|
||||
] as const;
|
||||
export type AnthropicResetUpstreamResult = (typeof ANTHROPIC_RESET_RESULTS)[number];
|
||||
/** Upstream results plus the two HTTP refusals that prove nothing was spent. */
|
||||
export type AnthropicResetSettledCode =
|
||||
AnthropicResetUpstreamResult | 'rate_limited' | 'auth_error';
|
||||
|
||||
export interface AnthropicResetGrant {
|
||||
id: string;
|
||||
label: string;
|
||||
resetsTotal: number;
|
||||
resetsLeft: number;
|
||||
startsAt: string | null;
|
||||
endsAt: string | null;
|
||||
clears: AnthropicResetWindow[];
|
||||
paused: boolean;
|
||||
usableNow: boolean;
|
||||
useRequiresLimit: boolean;
|
||||
percentUsed: Partial<Record<AnthropicResetWindow, number>>;
|
||||
}
|
||||
|
||||
export interface AnthropicResetGrantStatus {
|
||||
eligible: boolean;
|
||||
ineligibleReason: string | null;
|
||||
atLimit: boolean;
|
||||
grants: AnthropicResetGrant[];
|
||||
nextGrantId: string | null;
|
||||
weeklyResetsAt: string | null;
|
||||
cooldownUntil: string | null;
|
||||
}
|
||||
|
||||
export type AnthropicResetGrantErrorCode = 'auth' | 'upstream' | 'malformed';
|
||||
|
||||
/** A read failure. `message` is fixed text; upstream detail is never attached. */
|
||||
export class AnthropicResetGrantError extends Error {
|
||||
constructor(readonly code: AnthropicResetGrantErrorCode) {
|
||||
super(`Anthropic reset-grant read failed (${code})`);
|
||||
this.name = 'AnthropicResetGrantError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The claim was sent (or may have been) and no terminal answer came back. */
|
||||
export class AnthropicResetGrantUnknownOutcome extends Error {
|
||||
constructor() {
|
||||
super('Anthropic reset-grant claim outcome is unknown');
|
||||
this.name = 'AnthropicResetGrantUnknownOutcome';
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_INELIGIBLE_REASONS = new Set([
|
||||
'config_off',
|
||||
'tier',
|
||||
'seat',
|
||||
'mobile',
|
||||
'surface',
|
||||
'cli_version',
|
||||
'no_grant',
|
||||
'tenure',
|
||||
'other_experiment',
|
||||
'unavailable',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCount(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
/** Optional ISO timestamp: absent/null → null, anything unparsable rejects the block. */
|
||||
function optionalTimestamp(value: unknown): string | null | undefined {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, fallback: boolean): boolean | undefined {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
/** Upstream display text, stripped of control characters and bounded. */
|
||||
function safeLabel(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return (
|
||||
value
|
||||
// Strip untrusted display control characters.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
);
|
||||
}
|
||||
|
||||
function parseWindows(value: unknown): AnthropicResetWindow[] | undefined {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return ANTHROPIC_RESET_WINDOWS.filter((window) => value.includes(window));
|
||||
}
|
||||
|
||||
function parsePercentUsed(value: unknown): Partial<Record<AnthropicResetWindow, number>> {
|
||||
const out: Partial<Record<AnthropicResetWindow, number>> = {};
|
||||
if (!isRecord(value)) return out;
|
||||
for (const window of ANTHROPIC_RESET_WINDOWS) {
|
||||
const percent = value[window];
|
||||
if (typeof percent === 'number' && Number.isInteger(percent) && percent >= 0 && percent <= 100)
|
||||
out[window] = percent;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseGrant(value: unknown): AnthropicResetGrant | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const { id, resets_total: total, resets_left: left } = value;
|
||||
if (typeof id !== 'string' || !ANTHROPIC_RESET_GRANT_ID_RE.test(id)) return null;
|
||||
if (!isCount(total) || !isCount(left) || left > total) return null;
|
||||
const startsAt = optionalTimestamp(value.starts_at);
|
||||
const endsAt = optionalTimestamp(value.ends_at);
|
||||
const clears = parseWindows(value.clears);
|
||||
const paused = optionalBoolean(value.paused, false);
|
||||
// Missing usability flags default to the refusing side: a grant that does not
|
||||
// say it is usable is not offered for spending.
|
||||
const usableNow = optionalBoolean(value.usable_now, false);
|
||||
const useRequiresLimit = optionalBoolean(value.use_requires_limit, true);
|
||||
if (
|
||||
startsAt === undefined ||
|
||||
endsAt === undefined ||
|
||||
clears === undefined ||
|
||||
paused === undefined ||
|
||||
usableNow === undefined ||
|
||||
useRequiresLimit === undefined
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
id,
|
||||
label: safeLabel(value.label),
|
||||
resetsTotal: total,
|
||||
resetsLeft: left,
|
||||
startsAt,
|
||||
endsAt,
|
||||
clears,
|
||||
paused,
|
||||
usableNow,
|
||||
useRequiresLimit,
|
||||
percentUsed: parsePercentUsed(value.percent_used),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the `cedar_ember` block. Returns null for a missing or malformed block;
|
||||
* one malformed grant, a duplicate id, or a bad timestamp rejects the whole block.
|
||||
*/
|
||||
export function parseAnthropicResetGrantStatus(block: unknown): AnthropicResetGrantStatus | null {
|
||||
if (!isRecord(block) || typeof block.eligible !== 'boolean') return null;
|
||||
const rawGrants = block.grants ?? [];
|
||||
if (!Array.isArray(rawGrants)) return null;
|
||||
const grants: AnthropicResetGrant[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of rawGrants) {
|
||||
const grant = parseGrant(raw);
|
||||
if (!grant || seen.has(grant.id)) return null;
|
||||
seen.add(grant.id);
|
||||
grants.push(grant);
|
||||
}
|
||||
const reason = block.ineligible_reason;
|
||||
if (reason !== undefined && reason !== null && typeof reason !== 'string') return null;
|
||||
const atLimit = optionalBoolean(block.at_limit, false);
|
||||
const weeklyResetsAt = optionalTimestamp(block.weekly_resets_at);
|
||||
const cooldownUntil = optionalTimestamp(block.cooldown_until);
|
||||
if (atLimit === undefined || weeklyResetsAt === undefined || cooldownUntil === undefined)
|
||||
return null;
|
||||
const next = block.next_grant_id;
|
||||
return {
|
||||
eligible: block.eligible,
|
||||
ineligibleReason:
|
||||
typeof reason === 'string'
|
||||
? KNOWN_INELIGIBLE_REASONS.has(reason)
|
||||
? reason
|
||||
: 'unknown'
|
||||
: null,
|
||||
atLimit,
|
||||
grants,
|
||||
nextGrantId: typeof next === 'string' && seen.has(next) ? next : null,
|
||||
weeklyResetsAt,
|
||||
cooldownUntil,
|
||||
};
|
||||
}
|
||||
|
||||
/** Why a grant cannot be spent right now, or null when it can. */
|
||||
export function anthropicResetGrantBlocker(
|
||||
status: AnthropicResetGrantStatus,
|
||||
grantId: string
|
||||
): 'ineligible' | 'unknown_grant' | 'paused' | 'not_usable' | 'exhausted' | 'not_limited' | null {
|
||||
if (!status.eligible) return 'ineligible';
|
||||
const grant = status.grants.find((candidate) => candidate.id === grantId);
|
||||
if (!grant) return 'unknown_grant';
|
||||
if (grant.paused) return 'paused';
|
||||
if (!grant.usableNow) return 'not_usable';
|
||||
if (grant.resetsLeft <= 0) return 'exhausted';
|
||||
if (grant.useRequiresLimit && !status.atLimit) return 'not_limited';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readAccount(authIndex: string, path: string): Promise<Record<string, unknown>> {
|
||||
const response = await apiCallApi.request(
|
||||
{
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: ANTHROPIC_API_ORIGIN + path,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
},
|
||||
{ timeout: 12000 }
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300 || !isRecord(response.body)) {
|
||||
throw new AnthropicResetGrantError('upstream');
|
||||
}
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export async function readClaudeResetGrants(authIndex: string) {
|
||||
const body = await readAccount(authIndex, ANTHROPIC_RESET_GRANT_STATUS_PATH);
|
||||
const status = parseAnthropicResetGrantStatus(body.cedar_ember);
|
||||
if (!status) throw new AnthropicResetGrantError('malformed');
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function readClaudeOrganization(authIndex: string) {
|
||||
const body = await readAccount(authIndex, ANTHROPIC_PROFILE_PATH);
|
||||
const uuid = isRecord(body.organization) ? body.organization.uuid : undefined;
|
||||
if (typeof uuid !== 'string' || !ORGANIZATION_UUID_RE.test(uuid)) {
|
||||
throw new AnthropicResetGrantError('malformed');
|
||||
}
|
||||
return uuid.toLowerCase();
|
||||
}
|
||||
|
||||
export async function claimClaudeResetGrant(
|
||||
authIndex: string,
|
||||
organization: string,
|
||||
grantId: string,
|
||||
requestId: string
|
||||
): Promise<AnthropicResetSettledCode> {
|
||||
if (
|
||||
!ORGANIZATION_UUID_RE.test(organization) ||
|
||||
!ANTHROPIC_RESET_GRANT_ID_RE.test(grantId) ||
|
||||
!ANTHROPIC_RESET_REQUEST_ID_RE.test(requestId)
|
||||
)
|
||||
throw new AnthropicResetGrantError('malformed');
|
||||
try {
|
||||
const response = await apiCallApi.request(
|
||||
{
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url: `${ANTHROPIC_API_ORIGIN}/api/organizations/${organization}/reset_rate_limits`,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
data: JSON.stringify({
|
||||
program: ANTHROPIC_RESET_GRANT_PROGRAM,
|
||||
grant_id: grantId,
|
||||
request_id: requestId,
|
||||
}),
|
||||
},
|
||||
{ timeout: ANTHROPIC_RESET_GRANT_REDEEM_TIMEOUT_MS }
|
||||
);
|
||||
if (response.statusCode === 429) return 'rate_limited';
|
||||
if (response.statusCode === 401 || response.statusCode === 403) return 'auth_error';
|
||||
const result = isRecord(response.body) ? response.body.result : undefined;
|
||||
if (
|
||||
response.statusCode >= 200 &&
|
||||
response.statusCode < 300 &&
|
||||
typeof result === 'string' &&
|
||||
(ANTHROPIC_RESET_RESULTS as readonly string[]).includes(result)
|
||||
) {
|
||||
return result as AnthropicResetUpstreamResult;
|
||||
}
|
||||
} catch {
|
||||
/* The proxy may have sent the claim even if the management request failed. */
|
||||
}
|
||||
throw new AnthropicResetGrantUnknownOutcome();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { captureQuotaCacheGeneration } from './quotaCache';
|
||||
import {
|
||||
anthropicResetGrantBlocker,
|
||||
claimClaudeResetGrant,
|
||||
readClaudeOrganization,
|
||||
readClaudeResetGrants,
|
||||
type AnthropicResetSettledCode,
|
||||
} from './claudeResetGrants';
|
||||
|
||||
export const RETRY_WINDOW_MS = 10 * 60 * 1000;
|
||||
type Operation = {
|
||||
grantId: string;
|
||||
organization: string;
|
||||
requestId: string;
|
||||
createdAt: number;
|
||||
code?: AnthropicResetSettledCode;
|
||||
};
|
||||
const defaultDependencies = {
|
||||
revision: captureQuotaCacheGeneration,
|
||||
readStatus: readClaudeResetGrants,
|
||||
readOrganization: readClaudeOrganization,
|
||||
claim: claimClaudeResetGrant,
|
||||
now: () => Date.now(),
|
||||
requestId: () => crypto.randomUUID() as string,
|
||||
};
|
||||
|
||||
/** Tab-memory journal: survives dialog/card unmounts, rechecks account identity before retries.
|
||||
* No automatic retry. Expired ambiguous operations stay blocked for this app window.
|
||||
* This is not a cross-tab or durable backend spending ledger.
|
||||
*/
|
||||
export function createResetGrantOperations(deps = defaultDependencies) {
|
||||
// Keep uncertain claims even if list pruning invalidates the quota cache.
|
||||
// Identity is verified again before retrying; never silently start another claim.
|
||||
const operations = new Map<string, Operation>();
|
||||
const busy = new Set<string>();
|
||||
const syncSession = () => deps.revision();
|
||||
return {
|
||||
inspect(key: string) {
|
||||
syncSession();
|
||||
return operations.get(key);
|
||||
},
|
||||
async run(key: string, authIndex: string, grantId: string) {
|
||||
const session = syncSession();
|
||||
const assertSession = () => {
|
||||
if (deps.revision() !== session) throw new Error('session');
|
||||
};
|
||||
if (busy.has(key)) throw new Error('busy');
|
||||
busy.add(key);
|
||||
try {
|
||||
let operation = operations.get(key);
|
||||
if (operation?.code) operation = undefined;
|
||||
if (
|
||||
operation &&
|
||||
(operation.grantId !== grantId || deps.now() - operation.createdAt >= RETRY_WINDOW_MS)
|
||||
)
|
||||
throw new Error('expired');
|
||||
const wasRetry = Boolean(operation);
|
||||
const organization = await deps.readOrganization(authIndex);
|
||||
assertSession();
|
||||
if (operation && operation.organization !== organization) throw new Error('identity');
|
||||
if (!operation) {
|
||||
const status = await deps.readStatus(authIndex);
|
||||
assertSession();
|
||||
const grant = status.grants.find((item) => item.id === grantId);
|
||||
const now = deps.now();
|
||||
if (
|
||||
anthropicResetGrantBlocker(status, grantId) ||
|
||||
!grant ||
|
||||
(grant.startsAt && Date.parse(grant.startsAt) > now) ||
|
||||
(grant.endsAt && Date.parse(grant.endsAt) <= now) ||
|
||||
(status.cooldownUntil && Date.parse(status.cooldownUntil) > now)
|
||||
) {
|
||||
throw new Error('blocked');
|
||||
}
|
||||
operation = { grantId, organization, requestId: deps.requestId(), createdAt: now };
|
||||
operations.set(key, operation);
|
||||
}
|
||||
// Re-check immediately before dispatch; never send against a replacement connection.
|
||||
assertSession();
|
||||
if (wasRetry && deps.now() - operation.createdAt >= RETRY_WINDOW_MS) {
|
||||
throw new Error('expired');
|
||||
}
|
||||
const code = await deps.claim(authIndex, organization, grantId, operation.requestId);
|
||||
assertSession();
|
||||
// A refusal on a retry cannot prove that the earlier ambiguous POST did not spend.
|
||||
if (!wasRetry || code === 'reset' || code === 'already_used') operation.code = code;
|
||||
return { code, unresolved: !operation.code };
|
||||
} finally {
|
||||
busy.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const resetGrantOperations = createResetGrantOperations();
|
||||
@@ -1,12 +1,14 @@
|
||||
import { resetGrantOperations, RETRY_WINDOW_MS } from './claudeResetOperations';
|
||||
import { selectResetGrant } from './selectClaudeResetGrant';
|
||||
import {
|
||||
captureQuotaCacheGeneration,
|
||||
commitQuotaCacheIfCurrent,
|
||||
getQuotaCacheSnapshot,
|
||||
updateQuotaCache,
|
||||
} from './quotaCache';
|
||||
import { readBoolean } from './managementApi';
|
||||
import { normalizeAuthIndex, readBoolean } from './managementApi';
|
||||
import {
|
||||
consumeCodexResetCredit, idleQuota, providerForFile, quotaKey,
|
||||
consumeClaudeResetCredit, consumeCodexResetCredit, idleQuota, providerForFile, quotaKey,
|
||||
type AuthFile, type QuotaState,
|
||||
} from './quotaService';
|
||||
|
||||
@@ -63,3 +65,22 @@ export function resetCodexQuotaWithConfirmation(file: AuthFile, confirmReset: ()
|
||||
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);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { captureQuotaCacheGeneration } from './quotaCache';
|
||||
import { readClaudeResetGrants, type AnthropicResetGrantStatus } from './claudeResetGrants';
|
||||
import { selectResetGrant } from './selectClaudeResetGrant';
|
||||
import { resetGrantOperations, RETRY_WINDOW_MS } from './claudeResetOperations';
|
||||
import { buildXaiBillingSummary, mergeXaiBillingSummaries, type XaiBillingConfig } from './xaiBilling';
|
||||
import {
|
||||
apiCallErrorMessage,
|
||||
@@ -34,6 +38,7 @@ export type QuotaState = {
|
||||
plan?: string;
|
||||
creditBalance?: string;
|
||||
creditsUnlimited?: boolean;
|
||||
claudeResetGrants?: AnthropicResetGrantStatus;
|
||||
resetCredits?: number;
|
||||
resetCreditsApplicable?: number;
|
||||
resetCreditsError?: string;
|
||||
@@ -885,10 +890,16 @@ async function loadQuotaSnapshot(file: AuthFile): Promise<QuotaState> {
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
const [payload, detectedPlan, resetCreditDetails] = await Promise.all([
|
||||
const claudeGrantsPromise = provider === 'claude'
|
||||
? readClaudeResetGrants(normalizeAuthIndex(file.auth_index ?? file.authIndex)).catch(() => {
|
||||
resetCreditsError = quotaText('quota.claude.readError');
|
||||
return undefined;
|
||||
}) : Promise.resolve(undefined);
|
||||
const [payload, detectedPlan, resetCreditDetails, claudeResetGrants] = await Promise.all([
|
||||
payloadPromise,
|
||||
planPromise,
|
||||
resetCreditsPromise,
|
||||
claudeGrantsPromise,
|
||||
]);
|
||||
const rows = quotaRowsFor(provider, payload);
|
||||
if (rows.length === 0) {
|
||||
@@ -900,7 +911,7 @@ async function loadQuotaSnapshot(file: AuthFile): Promise<QuotaState> {
|
||||
}
|
||||
const resetCredits = provider === 'codex'
|
||||
? resetCreditDetails?.availableCount ?? codexResetCreditsFor(payload)
|
||||
: undefined;
|
||||
: claudeResetGrants?.grants.reduce((sum, grant) => sum + grant.resetsLeft, 0);
|
||||
const usageCreditDetails = provider === 'codex' && isRecord(payload)
|
||||
? codexResetCreditDetailsFor(payload.rate_limit_reset_credits ?? payload.rateLimitResetCredits)
|
||||
: {};
|
||||
@@ -914,11 +925,12 @@ async function loadQuotaSnapshot(file: AuthFile): Promise<QuotaState> {
|
||||
creditsUnlimited: accountCredits.unlimited,
|
||||
subscriptionActiveUntil: provider === 'devin'
|
||||
? readDevinQuota(payload).subscriptionActiveUntil : codexMetadata?.subscriptionActiveUntil,
|
||||
claudeResetGrants,
|
||||
resetCreditsError,
|
||||
resetCreditsApplicable: usageCreditDetails.applicableAvailableCount
|
||||
resetCreditsApplicable: provider === 'claude' ? undefined : usageCreditDetails.applicableAvailableCount
|
||||
?? resetCreditDetails?.applicableAvailableCount ?? resetCredits,
|
||||
resetCredits,
|
||||
resetCreditsEarliestExpiry: resetCreditDetails?.earliestExpiry,
|
||||
resetCreditsEarliestExpiry: resetCreditDetails?.earliestExpiry ?? claudeResetGrants?.grants.filter(grant => grant.resetsLeft > 0 && grant.endsAt).map(grant => grant.endsAt!).sort((a, b) => Date.parse(a) - Date.parse(b))[0],
|
||||
serverTimeOffsetMs: responseClock.serverTimeOffsetMs,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
@@ -997,3 +1009,30 @@ function runQuotaMutation(file: AuthFile, mutate: () => Promise<QuotaState>): Pr
|
||||
export function consumeCodexResetCredit(file: AuthFile): Promise<QuotaState> {
|
||||
return runQuotaMutation(file, () => consumeCodexResetCreditSnapshot(file));
|
||||
}
|
||||
|
||||
export function consumeClaudeResetCredit(file: AuthFile): Promise<QuotaState> {
|
||||
const generation = captureQuotaCacheGeneration();
|
||||
return runQuotaMutation(file, async () => {
|
||||
if (captureQuotaCacheGeneration() !== generation) throw new Error(quotaText('quota.claude.blocked'));
|
||||
if (providerForFile(file) !== 'claude' || booleanValue(file.disabled) === true)
|
||||
throw new Error(quotaText('quota.claude.blocked'));
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error(quotaText('quota.claude.blocked'));
|
||||
const key = quotaKey(file);
|
||||
const pending = resetGrantOperations.inspect(key);
|
||||
const grantId = pending && !pending.code ? pending.grantId
|
||||
: selectResetGrant(await readClaudeResetGrants(authIndex), Date.now())?.id;
|
||||
if (!grantId || captureQuotaCacheGeneration() !== generation) throw new Error(quotaText('quota.claude.blocked'));
|
||||
try {
|
||||
const answer = await resetGrantOperations.run(key, authIndex, grantId);
|
||||
if (answer.unresolved) throw new Error(quotaText('quota.claude.unknown'));
|
||||
if (answer.code !== 'reset' && answer.code !== 'already_used')
|
||||
throw new Error(quotaText(`quota.claude.${answer.code}`));
|
||||
} catch (error) {
|
||||
const operation = resetGrantOperations.inspect(key);
|
||||
if (operation && !operation.code) throw new Error(quotaText(Date.now() - operation.createdAt >= RETRY_WINDOW_MS ? 'quota.claude.expired' : 'quota.claude.unknown'));
|
||||
throw error;
|
||||
}
|
||||
return loadQuotaSnapshot(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
anthropicResetGrantBlocker,
|
||||
type AnthropicResetGrantStatus,
|
||||
} from './claudeResetGrants';
|
||||
|
||||
/** Prefer the upstream recommendation; otherwise use stable ID ordering. */
|
||||
export function selectResetGrant(status: AnthropicResetGrantStatus, now: number) {
|
||||
if (status.cooldownUntil && Date.parse(status.cooldownUntil) > now) return undefined;
|
||||
const usable = status.grants.filter(
|
||||
(grant) =>
|
||||
!anthropicResetGrantBlocker(status, grant.id) &&
|
||||
(!grant.startsAt || Date.parse(grant.startsAt) <= now) &&
|
||||
(!grant.endsAt || Date.parse(grant.endsAt) > now)
|
||||
);
|
||||
return (
|
||||
usable.find((grant) => grant.id === status.nextGrantId) ??
|
||||
usable.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0]
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,13 @@ it('keeps window percentages, reset times and additional windows available in de
|
||||
expect(html).toContain('extra');
|
||||
});
|
||||
|
||||
it('shows reset credits in the button while preserving applicability and expiry', () => {
|
||||
it('shows reset credits beside the button while preserving applicability and expiry', () => {
|
||||
const html = renderDense({ status: 'success', rows: [{ label: '5h', remainingPercent: 0 }],
|
||||
resetCredits: 2, resetCreditsApplicable: 0, subscriptionActiveUntil: '2030-01-01T00:00:00Z',
|
||||
});
|
||||
const resetButton = html.match(/<button[^>]*credential-quota-reset[^>]*>[\s\S]*?<\/button>/)?.[0];
|
||||
expect(resetButton).toContain('2');
|
||||
expect(resetButton).toContain('Reset');
|
||||
expect(html).toMatch(/credential-quota-reset-credits[^>]*>[^<]*2/);
|
||||
expect(resetButton).not.toContain('disabled=""');
|
||||
expect(html).toContain('Currently applicable: 0');
|
||||
expect(html).toContain('Subscription expires');
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||
import { mockIPC, clearMocks } from '@tauri-apps/api/mocks';
|
||||
import { parseAnthropicResetGrantStatus } from '../src/services/claudeResetGrants';
|
||||
import { createResetGrantOperations, RETRY_WINDOW_MS } from '../src/services/claudeResetOperations';
|
||||
import { selectResetGrant } from '../src/services/selectClaudeResetGrant';
|
||||
import { loadQuota, quotaKey } from '../src/services/quotaService';
|
||||
import { canResetQuota, resetQuotaWithConfirmation } from '../src/services/quotaActions';
|
||||
import { getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache } from '../src/services/quotaCache';
|
||||
import { AuthFileQuotaPanel } from '../src/components/AuthFileQuotaPanel';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
import { QuotaCard } from '../src/pages/QuotaPage';
|
||||
|
||||
const organization = '11111111-2222-3333-4444-555555555555';
|
||||
const block = { eligible: true, at_limit: true, grants: [{ id: 'test', resets_total: 2, resets_left: 2, usable_now: true }] };
|
||||
const status = () => parseAnthropicResetGrantStatus(block)!;
|
||||
let file: { name: string; provider: string; auth_index: string };
|
||||
let requests: { method: string; url: string; data?: string; authIndex: string }[];
|
||||
let mode: 'success' | 'unknown' | 'ineligible' | 'malformed';
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: { navigator: { language: 'en' } }, configurable: true, writable: true });
|
||||
const id = crypto.randomUUID();
|
||||
file = { name: id + '.json', provider: 'claude', auth_index: id };
|
||||
requests = [];
|
||||
mode = 'success';
|
||||
mockIPC((_command, payload) => {
|
||||
const request = (payload as { request: { path: string; body: typeof requests[number] } }).request;
|
||||
expect(request.path).toBe('/requests/api-call');
|
||||
const body = request.body;
|
||||
requests.push(body);
|
||||
if (body.method === 'POST') {
|
||||
if (mode === 'unknown') throw new Error('timeout');
|
||||
return { status_code: 200, body: JSON.stringify({ result: 'reset' }) };
|
||||
}
|
||||
if (body.url.endsWith('/profile')) return { status_code: 200, body: { organization: { uuid: organization } } };
|
||||
return { status_code: 200, body: { five_hour: { utilization: 100 }, cedar_ember: mode === 'malformed' ? {} : { ...block, eligible: mode !== 'ineligible' } } };
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
updateQuotaCache({});
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
|
||||
test('loads grant counts and renders a working reset action on both surfaces', async () => {
|
||||
const quota = await loadQuota(file);
|
||||
expect(quota).toMatchObject({ status: 'success', resetCredits: 2 });
|
||||
expect(canResetQuota(file, quota)).toBe(true);
|
||||
for (const html of [
|
||||
renderToStaticMarkup(<I18nProvider><AuthFileQuotaPanel file={file} quota={quota} disabled={false} compact dense onRefresh={() => {}} onReset={() => {}} /></I18nProvider>),
|
||||
renderToStaticMarkup(<I18nProvider><QuotaCard file={file} quota={quota} onRefresh={() => {}} onReset={() => {}} /></I18nProvider>),
|
||||
]) {
|
||||
expect(html).toContain('Reset Quota');
|
||||
expect(html).not.toContain('disabled=""');
|
||||
expect(html).toContain('2');
|
||||
}
|
||||
updateQuotaCache({ [quotaKey(file)]: quota });
|
||||
expect(await resetQuotaWithConfirmation(file, async () => false)).toBe('cancelled');
|
||||
expect(requests.filter(r => r.method === 'POST')).toHaveLength(0);
|
||||
expect(await resetQuotaWithConfirmation(file, async () => true)).toBe('success');
|
||||
const claims = requests.filter(r => r.method === 'POST');
|
||||
expect(claims).toHaveLength(1);
|
||||
expect(claims[0].url).toBe(`https://api.anthropic.com/api/organizations/${organization}/reset_rate_limits`);
|
||||
expect(JSON.parse(claims[0].data!)).toMatchObject({ program: 'cedar_ember', grant_id: 'test' });
|
||||
expect(getQuotaCacheSnapshot()[quotaKey(file)].actionResult?.status).toBe('success');
|
||||
});
|
||||
|
||||
test('a missing grant block does not break usage and cannot enable reset', async () => {
|
||||
mode = 'malformed';
|
||||
const quota = await loadQuota(file);
|
||||
expect(quota.status).toBe('success');
|
||||
expect(quota.resetCreditsError).toBeTruthy();
|
||||
expect(canResetQuota(file, quota)).toBe(false);
|
||||
});
|
||||
|
||||
test('rechecks eligibility after confirmation before spending', async () => {
|
||||
updateQuotaCache({ [quotaKey(file)]: await loadQuota(file) });
|
||||
mode = 'ineligible';
|
||||
expect(await resetQuotaWithConfirmation(file, async () => true)).toBe('error');
|
||||
expect(requests.filter(r => r.method === 'POST')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ambiguous claims reuse their ID even after quota cache pruning', async () => {
|
||||
updateQuotaCache({ [quotaKey(file)]: await loadQuota(file) });
|
||||
mode = 'unknown';
|
||||
expect(await resetQuotaWithConfirmation(file, async () => true)).toBe('error');
|
||||
pruneQuotaCache(new Set());
|
||||
updateQuotaCache({ [quotaKey(file)]: await loadQuota(file) });
|
||||
mode = 'success';
|
||||
expect(await resetQuotaWithConfirmation(file, async () => true)).toBe('success');
|
||||
const claims = requests.filter(r => r.method === 'POST');
|
||||
expect(claims).toHaveLength(2);
|
||||
expect(claims[0].data).toBe(claims[1].data);
|
||||
});
|
||||
|
||||
test('unusable, exhausted, paused, expired, and not-limited grants are blocked', () => {
|
||||
for (const grant of [
|
||||
{ usableNow: false }, { resetsLeft: 0 }, { paused: true }, { endsAt: '2000-01-01' },
|
||||
]) {
|
||||
const value = status();
|
||||
Object.assign(value.grants[0], grant);
|
||||
expect(selectResetGrant(value, Date.now())).toBeUndefined();
|
||||
}
|
||||
expect(selectResetGrant({ ...status(), atLimit: false }, Date.now())).toBeUndefined();
|
||||
expect(parseAnthropicResetGrantStatus({ ...block, grants: [{ ...block.grants[0], resets_left: -1 }] })).toBeNull();
|
||||
});
|
||||
|
||||
test('retry expiry and account changes never send a new claim', async () => {
|
||||
let now = 1000;
|
||||
let account = organization;
|
||||
let calls = 0;
|
||||
const operations = createResetGrantOperations({ revision: () => 0, now: () => now, requestId: () => 'same-id',
|
||||
readStatus: async () => status(), readOrganization: async () => account,
|
||||
claim: async () => { calls++; throw new Error('timeout'); },
|
||||
});
|
||||
await expect(operations.run('key', 'auth', 'test')).rejects.toThrow('timeout');
|
||||
account = 'aaaaaaaa-2222-3333-4444-555555555555';
|
||||
await expect(operations.run('key', 'auth', 'test')).rejects.toThrow('identity');
|
||||
account = organization;
|
||||
now += RETRY_WINDOW_MS;
|
||||
await expect(operations.run('key', 'auth', 'test')).rejects.toThrow('expired');
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test('cache invalidation during account verification prevents the claim', async () => {
|
||||
let revision = 0;
|
||||
let calls = 0;
|
||||
const operations = createResetGrantOperations({ revision: () => revision, now: Date.now,
|
||||
requestId: () => 'request-id', readStatus: async () => status(),
|
||||
readOrganization: async () => { revision++; return organization; },
|
||||
claim: async () => { calls++; return 'reset'; },
|
||||
});
|
||||
await expect(operations.run('key', 'auth', 'test')).rejects.toThrow('session');
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
Vendored
+7
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { QuotaCard } from '../../src/pages/QuotaPage';
|
||||
import { parseAnthropicResetGrantStatus } from '../../src/services/claudeResetGrants';
|
||||
import { AuthFileQuotaPanel } from '../../src/components/AuthFileQuotaPanel';
|
||||
import { quotaRowsFor } from '../../src/services/quotaService';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
@@ -11,6 +13,9 @@ const rows = quotaRowsFor('kimi', {
|
||||
usage: { limit: 100, remaining: 60 },
|
||||
});
|
||||
|
||||
const claudeFile = { name: 'claude.json', provider: 'claude', auth_index: 'claude-fixture' };
|
||||
const claudeQuota = { status: 'success' as const, rows, resetCredits: 2, claudeResetGrants: parseAnthropicResetGrantStatus({ eligible: true, at_limit: true, grants: [{ id: 'reserve', resets_total: 2, resets_left: 2, usable_now: true }] })! };
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider>
|
||||
<main className="content" style={{ width: 1100, padding: 0 }}>
|
||||
@@ -20,10 +25,12 @@ createRoot(document.getElementById('root')!).render(
|
||||
</div>
|
||||
<div className="real-quota-grid">
|
||||
<QuotaCard file={{ name: 'codex.json', provider: 'codex' }} quota={{ status: 'success', rows, resetCredits: 2, resetCreditsApplicable: 2, plan: 'team', subscriptionActiveUntil: '2030-10-17T13:58:00Z', resetCreditsEarliestExpiry: '2030-10-23T05:10:00Z' }} onRefresh={() => {}} onReset={() => {}} />
|
||||
<QuotaCard file={claudeFile} quota={claudeQuota} onRefresh={() => {}} onReset={() => {}} />
|
||||
<QuotaCard file={{ name: 'failed.json', provider: 'kimi' }} quota={{ status: 'error', rows: [], error: 'Quota request failed' }} onRefresh={() => {}} />
|
||||
<QuotaCard file={{ name: 'loading.json', provider: 'kimi' }} quota={{ status: 'loading', rows: [] }} onRefresh={() => {}} />
|
||||
<QuotaCard file={{ name: 'idle.json', provider: 'kimi' }} quota={{ status: 'idle', rows: [] }} onRefresh={() => {}} />
|
||||
</div>
|
||||
<AuthFileQuotaPanel file={claudeFile} quota={claudeQuota} disabled={false} compact dense onRefresh={() => {}} onReset={() => {}} />
|
||||
</section>
|
||||
</main>
|
||||
</I18nProvider>,
|
||||
|
||||
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
|
||||
const base = process.env.QUOTA_TEST_BASE || 'http://127.0.0.1:1421';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
|
||||
@@ -42,6 +42,10 @@ const base = process.env.QUOTA_TEST_BASE || 'http://127.0.0.1:1421';
|
||||
assert.ok(text.includes('80%') && text.includes('60%'), `${context}: both Kimi windows remain rendered`);
|
||||
}
|
||||
}
|
||||
const claudeCard = page.locator('.real-quota-card').filter({ has: page.locator('strong[title="claude.json"]') });
|
||||
assert.equal(await claudeCard.getByRole('button', { name: 'Reset Quota', exact: true }).isEnabled(), true);
|
||||
assert.equal(await page.locator('.credential-quota-reset').isEnabled(), true);
|
||||
assert.match(await page.locator('.credential-quota-reset-credits').innerText(), /2/);
|
||||
console.log('Quota layout: 11 viewport/content combinations passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
import { QuotaCard } from '../src/pages/QuotaPage';
|
||||
import { quotaRowsFor } from '../src/services/quotaService';
|
||||
import { quotaRowsFor, formatQuotaTimestamp } from '../src/services/quotaService';
|
||||
import type { QuotaState } from '../src/services/quotaService';
|
||||
|
||||
const render = (quota: QuotaState, provider = 'codex') => renderToStaticMarkup(
|
||||
@@ -20,7 +20,8 @@ describe('quota card rendering', () => {
|
||||
expect(html).not.toContain('No applicable reset credits');
|
||||
expect(html).toContain('Currently applicable: 0');
|
||||
expect(html).not.toContain('temporary failure');
|
||||
expect(html).toContain('Subscription expires');
|
||||
expect(html).toContain('Expires 01/01/2030, 00:00');
|
||||
expect(html).not.toContain('Subscription expires:');
|
||||
});
|
||||
|
||||
it.each(['error', 'refresh-error'] as const)('重置结果为 %s 时仍可再次点击重置', (status) => {
|
||||
|
||||
Reference in new issue
Block a user