diff --git a/src/components/AuthFileQuotaPanel.tsx b/src/components/AuthFileQuotaPanel.tsx index f8e56dd..832c197 100644 --- a/src/components/AuthFileQuotaPanel.tsx +++ b/src/components/AuthFileQuotaPanel.tsx @@ -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 ? {t('authFiles.settings.quotaRemaining')} : null} {!compactLayout && quota.plan ? {quota.plan} : null} {dense && resetCreditsLabel ? {resetCreditsLabel} : null} - {showReset ? : null} + {showReset ? : null} {!compactLayout ? @@ -207,15 +207,13 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
{t('authFiles.quota.failed')}
: null} - {quota.status === 'success' && provider === 'codex' ?
+ {quota.status === 'success' && (provider === 'codex' || provider === 'claude') ?
{(quota.creditsUnlimited || quota.creditBalance !== undefined) ? {t('quota.creditBalance')} {quota.creditsUnlimited ? t('quota.creditUnlimited') : quota.creditBalance} : null} {t('quota.resetCredits')} {quota.resetCredits ?? '—'} {quota.resetCreditsApplicable !== undefined ? {t('quota.resetApplicable', { count: quota.resetCreditsApplicable })} : null} {t('quota.earliestExpiry')} {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)} - {quota.subscriptionActiveUntil ? {t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })} : null}
: null} - {quota.status === 'success' && provider === 'devin' && quota.subscriptionActiveUntil ?
{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}
: null} {quota.status === 'success' ?
{quota.rows.map((row, index) => { const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now); return
@@ -224,7 +222,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; {[row.detail, reset].filter(Boolean).join(' · ')}
; })}
: null} - {onReset && (quota.resetCredits ?? 0) > 0 ? : null} + {onReset && ((quota.resetCredits ?? 0) > 0 || hasPendingClaudeReset(file)) ? : null} ); } diff --git a/src/services/claudeResetGrants.ts b/src/services/claudeResetGrants.ts new file mode 100644 index 0000000..367f0b8 --- /dev/null +++ b/src/services/claudeResetGrants.ts @@ -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; data?: string }, options: { timeout: number }) { + const response = await managementApi.post>('/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>; +} + +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 { + 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> { + const out: Partial> = {}; + 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(); + 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> { + 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 { + 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(); +} diff --git a/src/services/claudeResetOperations.ts b/src/services/claudeResetOperations.ts new file mode 100644 index 0000000..40b6ee5 --- /dev/null +++ b/src/services/claudeResetOperations.ts @@ -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(); + const busy = new Set(); + 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(); diff --git a/src/services/quotaActions.ts b/src/services/quotaActions.ts index 1037ba3..166f026 100644 --- a/src/services/quotaActions.ts +++ b/src/services/quotaActions.ts @@ -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): Promise { + 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); +} diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index eca098f..298efff 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -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 { 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 { } 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 { 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): Pr export function consumeCodexResetCredit(file: AuthFile): Promise { return runQuotaMutation(file, () => consumeCodexResetCreditSnapshot(file)); } + +export function consumeClaudeResetCredit(file: AuthFile): Promise { + 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); + }); +} diff --git a/src/services/selectClaudeResetGrant.ts b/src/services/selectClaudeResetGrant.ts new file mode 100644 index 0000000..4793cac --- /dev/null +++ b/src/services/selectClaudeResetGrant.ts @@ -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] + ); +} diff --git a/tests/authFileQuotaDense.test.tsx b/tests/authFileQuotaDense.test.tsx index fedf551..c3b99cb 100644 --- a/tests/authFileQuotaDense.test.tsx +++ b/tests/authFileQuotaDense.test.tsx @@ -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(/]*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'); diff --git a/tests/claudeReset.test.tsx b/tests/claudeReset.test.tsx new file mode 100644 index 0000000..4a93262 --- /dev/null +++ b/tests/claudeReset.test.tsx @@ -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( {}} onReset={() => {}} />), + renderToStaticMarkup( {}} onReset={() => {}} />), + ]) { + 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); +}); diff --git a/tests/fixtures/quota-layout.tsx b/tests/fixtures/quota-layout.tsx index 94092aa..40886c9 100644 --- a/tests/fixtures/quota-layout.tsx +++ b/tests/fixtures/quota-layout.tsx @@ -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(
@@ -20,10 +25,12 @@ createRoot(document.getElementById('root')!).render(
{}} onReset={() => {}} /> + {}} onReset={() => {}} /> {}} /> {}} /> {}} />
+ {}} onReset={() => {}} /> , diff --git a/tests/quota-layout-ui.cjs b/tests/quota-layout-ui.cjs index 4a78ab4..d01e332 100644 --- a/tests/quota-layout-ui.cjs +++ b/tests/quota-layout-ui.cjs @@ -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(); diff --git a/tests/quotaRendering.test.tsx b/tests/quotaRendering.test.tsx index 29794ef..6c87b6c 100644 --- a/tests/quotaRendering.test.tsx +++ b/tests/quotaRendering.test.tsx @@ -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) => {