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:
lzt404 committed 2026-10-05 00:45:17 +08:00
1 parent 2b81856521
commit a3f5d2e54e
17 files changed
+729 -34

No files matched your search

+4 -4
View File
@@ -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));
+14
View File
@@ -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 枠',
+14
View File
@@ -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',
+14
View File
@@ -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 -3
View File
@@ -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')}>
+7 -9
View File
@@ -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>
);
}
+328
View File
@@ -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();
}
+95
View File
@@ -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();
+23 -2
View File
@@ -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);
}
+43 -4
View File
@@ -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);
});
}
+19
View 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]
);
}
+3 -2
View File
@@ -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');
+140
View File
@@ -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);
});
+7
View File
@@ -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>,
+5 -1
View File
@@ -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();
+3 -2
View File
@@ -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) => {