mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
feat: add Claude reset grants functionality
- Implemented ClaudeResetGrants component to manage reset grants for Claude usage. - Integrated reset grant operations with session management and eligibility checks. - Added API service for reading and claiming reset grants from the Anthropic API. - Enhanced UI with localized messages for reset grant actions. - Created tests for grant parsing, claiming, and session handling to ensure reliability.
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
import { bindQuotaClasses } from '../types';
|
||||
import { QUOTA_ADAPTERS, type QuotaCardState } from '../providers';
|
||||
import { isQuotaRefreshDisabled, type QuotaFileEntry } from '../logic';
|
||||
import { ClaudeResetGrants } from '../providers/claude/ClaudeResetGrants';
|
||||
import bodyStyles from './QuotaBody.module.scss';
|
||||
import styles from './QuotaCard.module.scss';
|
||||
|
||||
@@ -138,6 +139,13 @@ export function QuotaCard(props: QuotaCardProps) {
|
||||
|
||||
{status !== 'idle' && (
|
||||
<footer className={styles.actionRow}>
|
||||
{entry.type === 'claude' && (
|
||||
<ClaudeResetGrants
|
||||
file={file}
|
||||
disabled={!canRefresh || loading || resetting}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)}
|
||||
{showReset && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import {
|
||||
anthropicResetGrantBlocker,
|
||||
readClaudeResetGrants,
|
||||
type AnthropicResetGrantStatus,
|
||||
} from '@/services/api/claudeResetGrants';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { normalizeAuthIndex } from '@/utils/quota';
|
||||
import { resetGrantOperations, RETRY_WINDOW_MS } from './resetGrantOperations';
|
||||
|
||||
export function ClaudeResetGrants({
|
||||
file,
|
||||
disabled: externallyDisabled,
|
||||
onRefresh,
|
||||
}: {
|
||||
file: AuthFileItem;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const [session] = useState(() => apiClient.getConnectionRevision());
|
||||
const sessionActive =
|
||||
connectionStatus === 'connected' && session === apiClient.getConnectionRevision();
|
||||
const disabled = externallyDisabled || !sessionActive;
|
||||
const now = useNow();
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [status, setStatus] = useState<AnthropicResetGrantStatus | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const lock = useRef(false);
|
||||
const generation = useRef(0);
|
||||
useEffect(
|
||||
() => () => {
|
||||
generation.current += 1;
|
||||
},
|
||||
[authIndex, file.name]
|
||||
);
|
||||
const key = JSON.stringify([file.name, authIndex]);
|
||||
const operation = resetGrantOperations.inspect(key);
|
||||
const pending = operation && !operation.code ? operation : undefined;
|
||||
const expired = pending && now - pending.createdAt >= RETRY_WINDOW_MS;
|
||||
|
||||
const load = async () => {
|
||||
if (!authIndex || disabled || lock.current) return;
|
||||
lock.current = true;
|
||||
setBusy(true);
|
||||
setOpen(true);
|
||||
setStatus(null);
|
||||
setSelected(null);
|
||||
setMessage('');
|
||||
const revision = apiClient.getConnectionRevision();
|
||||
const version = generation.current;
|
||||
const current = () =>
|
||||
revision === apiClient.getConnectionRevision() && version === generation.current;
|
||||
try {
|
||||
const result = await readClaudeResetGrants(authIndex);
|
||||
if (current()) setStatus(result);
|
||||
} catch {
|
||||
if (current()) setMessage('read_error');
|
||||
} finally {
|
||||
lock.current = false;
|
||||
if (current()) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const consume = async () => {
|
||||
if (!authIndex || !selected || disabled || lock.current) return;
|
||||
lock.current = true;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
const revision = apiClient.getConnectionRevision();
|
||||
const version = generation.current;
|
||||
const current = () =>
|
||||
revision === apiClient.getConnectionRevision() && version === generation.current;
|
||||
try {
|
||||
const answer = await resetGrantOperations.run(key, authIndex, selected);
|
||||
if (!current()) return;
|
||||
setMessage(answer.unresolved ? 'unknown' : answer.code);
|
||||
setSelected(null);
|
||||
setStatus(null);
|
||||
onRefresh();
|
||||
} catch {
|
||||
if (current()) {
|
||||
const unresolved = resetGrantOperations.inspect(key);
|
||||
setMessage(unresolved && !unresolved.code ? 'unknown' : 'blocked');
|
||||
setSelected(null);
|
||||
}
|
||||
} finally {
|
||||
lock.current = false;
|
||||
if (current()) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={disabled || !authIndex || busy}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t('claude_reset.title')}
|
||||
</Button>
|
||||
<Modal
|
||||
open={open && sessionActive}
|
||||
title={t('claude_reset.title')}
|
||||
onClose={() => setOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={
|
||||
selected ? (
|
||||
<Button disabled={disabled || busy} loading={busy} onClick={() => void consume()}>
|
||||
{t('claude_reset.confirm')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<p>{t('claude_reset.scope')}</p>
|
||||
<div role="status" aria-live="polite">
|
||||
{busy && <p>{t('claude_reset.loading')}</p>}
|
||||
{message && <p>{t(`claude_reset.${message}`)}</p>}
|
||||
</div>
|
||||
{pending ? (
|
||||
<>
|
||||
<p>{t(expired ? 'claude_reset.expired' : 'claude_reset.unknown')}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={Boolean(expired) || busy || disabled}
|
||||
onClick={() => setSelected(pending.grantId)}
|
||||
>
|
||||
{t('claude_reset.retry')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
status && (
|
||||
<>
|
||||
{!status.eligible && <p>{t('claude_reset.ineligible')}</p>}
|
||||
{status.grants.length === 0 && <p>{t('claude_reset.empty')}</p>}
|
||||
{status.grants.map((grant) => (
|
||||
<div key={grant.id}>
|
||||
<p>
|
||||
{grant.label || grant.id} —{' '}
|
||||
{t('claude_reset.count', { left: grant.resetsLeft, total: grant.resetsTotal })}
|
||||
</p>
|
||||
{grant.endsAt && (
|
||||
<p>
|
||||
{t('claude_reset.ends', { date: new Date(grant.endsAt).toLocaleString() })}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={
|
||||
busy || disabled || Boolean(anthropicResetGrantBlocker(status, grant.id))
|
||||
}
|
||||
onClick={() => setSelected(grant.id)}
|
||||
>
|
||||
{t('claude_reset.use')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{selected && (
|
||||
<p>
|
||||
{t('claude_reset.confirm_text', {
|
||||
grant: status?.grants.find((grant) => grant.id === selected)?.label || selected,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import {
|
||||
anthropicResetGrantBlocker,
|
||||
claimClaudeResetGrant,
|
||||
readClaudeOrganization,
|
||||
readClaudeResetGrants,
|
||||
type AnthropicResetSettledCode,
|
||||
} from '@/services/api/claudeResetGrants';
|
||||
|
||||
export const RETRY_WINDOW_MS = 10 * 60 * 1000;
|
||||
type Operation = {
|
||||
grantId: string;
|
||||
organization: string;
|
||||
requestId: string;
|
||||
createdAt: number;
|
||||
code?: AnthropicResetSettledCode;
|
||||
};
|
||||
const defaultDependencies = {
|
||||
revision: () => apiClient.getConnectionRevision(),
|
||||
readStatus: readClaudeResetGrants,
|
||||
readOrganization: readClaudeOrganization,
|
||||
claim: claimClaudeResetGrant,
|
||||
now: () => Date.now(),
|
||||
requestId: () => crypto.randomUUID() as string,
|
||||
};
|
||||
|
||||
/** Tab-memory journal: survives dialog/card unmounts, never crosses connections.
|
||||
* No automatic retry. Expired ambiguous operations stay blocked until session end.
|
||||
* This is not a cross-tab or durable backend spending ledger.
|
||||
*/
|
||||
export function createResetGrantOperations(deps = defaultDependencies) {
|
||||
let revision = deps.revision();
|
||||
const operations = new Map<string, Operation>();
|
||||
const busy = new Set<string>();
|
||||
const syncSession = () => {
|
||||
if (revision !== deps.revision()) {
|
||||
revision = deps.revision();
|
||||
operations.clear();
|
||||
busy.clear();
|
||||
}
|
||||
return 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 {
|
||||
if (deps.revision() === session) busy.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const resetGrantOperations = createResetGrantOperations();
|
||||
@@ -2199,5 +2199,29 @@
|
||||
"modelCatalog": {
|
||||
"notLoaded": "Not loaded"
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"title": "Banked resets",
|
||||
"scope": "Uses a limited reset grant to clear eligible Claude usage windows. Only confirm when you want to spend a reset. Recovery is limited to this tab/session; do not reload or use another tab while an outcome is unknown.",
|
||||
"loading": "Loading…",
|
||||
"read_error": "Reset grants could not be read. Nothing was submitted.",
|
||||
"blocked": "Reset not submitted: eligibility, account identity, or session could not be verified. Refresh and check again.",
|
||||
"unknown": "The outcome is unknown. A reset may have been spent. Retry within 10 minutes using the same request ID; do not start another claim elsewhere.",
|
||||
"expired": "The retry window has expired. Further claims are blocked in this session. Check your Claude account before taking any further action.",
|
||||
"retry": "Retry the same claim",
|
||||
"empty": "No banked reset grants.",
|
||||
"count": "{{left}} / {{total}} resets remaining",
|
||||
"ends": "Expires: {{date}}",
|
||||
"use": "Use reset",
|
||||
"confirm": "Confirm reset",
|
||||
"confirm_text": "Spend one reset from “{{grant}}”? An unknown claim will be retried with its original request ID.",
|
||||
"reset": "Usage limits reset.",
|
||||
"already_used": "This grant was already used.",
|
||||
"not_limited": "No reset: the account is not currently limited.",
|
||||
"cooldown": "No reset: cooldown is active.",
|
||||
"ineligible": "This account is not eligible.",
|
||||
"unavailable": "Resets are currently unavailable.",
|
||||
"rate_limited": "Request rate limited.",
|
||||
"auth_error": "Authorization was refused."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2193,5 +2193,29 @@
|
||||
"modelCatalog": {
|
||||
"notLoaded": "Не загружено"
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"title": "Запас сбросов",
|
||||
"scope": "Использует ограниченное право на сброс лимитов Claude. Подтверждайте только если хотите потратить сброс. Восстановление доступно только в этой вкладке и сессии; при неизвестном результате не перезагружайте страницу и не используйте другую вкладку.",
|
||||
"loading": "Загрузка…",
|
||||
"read_error": "Не удалось прочитать права на сброс. Запрос сброса не отправлен.",
|
||||
"blocked": "Сброс не отправлен: не удалось проверить условия, учётную запись или сессию. Обновите данные.",
|
||||
"unknown": "Результат неизвестен. Сброс мог быть потрачен. Повторите в течение 10 минут с тем же ID запроса; не создавайте новый запрос в другом месте.",
|
||||
"expired": "Окно повтора истекло. Дальнейшие сбросы в этой сессии заблокированы. Сначала проверьте учётную запись Claude.",
|
||||
"retry": "Повторить тот же запрос",
|
||||
"empty": "Нет доступных запасных сбросов.",
|
||||
"count": "Осталось сбросов: {{left}} / {{total}}",
|
||||
"ends": "Истекает: {{date}}",
|
||||
"use": "Использовать сброс",
|
||||
"confirm": "Подтвердить сброс",
|
||||
"confirm_text": "Потратить один сброс «{{grant}}»? Запрос с неизвестным результатом будет повторён с исходным ID.",
|
||||
"reset": "Лимиты использования сброшены.",
|
||||
"already_used": "Это право уже использовано.",
|
||||
"not_limited": "Сброс не выполнен: лимит ещё не достигнут.",
|
||||
"cooldown": "Сброс не выполнен: действует период ожидания.",
|
||||
"ineligible": "Эта учётная запись не соответствует условиям.",
|
||||
"unavailable": "Сбросы временно недоступны.",
|
||||
"rate_limited": "Слишком много запросов.",
|
||||
"auth_error": "Авторизация отклонена."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2197,5 +2197,29 @@
|
||||
"modelCatalog": {
|
||||
"notLoaded": "未加载"
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"title": "储备重置次数",
|
||||
"scope": "使用有限的重置权益清除符合条件的 Claude 用量窗口。仅在确定要消耗一次重置时确认。恢复仅限当前标签页和会话;结果未知时,请勿刷新页面或使用其他标签页。",
|
||||
"loading": "加载中…",
|
||||
"read_error": "无法读取重置权益,未提交重置。",
|
||||
"blocked": "未提交重置:无法验证资格、账户身份或会话。请刷新后检查。",
|
||||
"unknown": "结果未知,可能已消耗一次重置。请在 10 分钟内使用相同请求 ID 重试,不要在其他位置发起新的重置。",
|
||||
"expired": "重试窗口已过期,当前会话已阻止进一步重置。请先检查 Claude 账户状态。",
|
||||
"retry": "重试同一请求",
|
||||
"empty": "没有储备重置权益。",
|
||||
"count": "剩余 {{left}} / {{total}} 次重置",
|
||||
"ends": "到期:{{date}}",
|
||||
"use": "使用重置",
|
||||
"confirm": "确认重置",
|
||||
"confirm_text": "消耗“{{grant}}”的一次重置?结果未知的请求将使用原请求 ID 重试。",
|
||||
"reset": "已重置用量限制。",
|
||||
"already_used": "该权益已使用。",
|
||||
"not_limited": "未重置:账户当前未达到限制。",
|
||||
"cooldown": "未重置:仍在冷却期。",
|
||||
"ineligible": "此账户不符合资格。",
|
||||
"unavailable": "重置暂不可用。",
|
||||
"rate_limited": "请求过于频繁。",
|
||||
"auth_error": "授权被拒绝。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2223,5 +2223,29 @@
|
||||
"modelCatalog": {
|
||||
"notLoaded": "未載入"
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"title": "儲備重置次數",
|
||||
"scope": "使用有限的重置權益清除符合條件的 Claude 用量視窗。僅在確定要消耗一次重置時確認。復原僅限目前分頁和工作階段;結果未知時,請勿重新載入頁面或使用其他分頁。",
|
||||
"loading": "載入中…",
|
||||
"read_error": "無法讀取重置權益,未提交重置。",
|
||||
"blocked": "未提交重置:無法驗證資格、帳戶身分或工作階段。請重新整理後檢查。",
|
||||
"unknown": "結果未知,可能已消耗一次重置。請在 10 分鐘內使用相同請求 ID 重試,不要在其他位置發起新的重置。",
|
||||
"expired": "重試視窗已過期,目前工作階段已阻止進一步重置。請先檢查 Claude 帳戶狀態。",
|
||||
"retry": "重試同一請求",
|
||||
"empty": "沒有儲備重置權益。",
|
||||
"count": "剩餘 {{left}} / {{total}} 次重置",
|
||||
"ends": "到期:{{date}}",
|
||||
"use": "使用重置",
|
||||
"confirm": "確認重置",
|
||||
"confirm_text": "消耗「{{grant}}」的一次重置?結果未知的請求將使用原請求 ID 重試。",
|
||||
"reset": "已重置用量限制。",
|
||||
"already_used": "該權益已使用。",
|
||||
"not_limited": "未重置:帳戶目前未達到限制。",
|
||||
"cooldown": "未重置:仍在冷卻期。",
|
||||
"ineligible": "此帳戶不符合資格。",
|
||||
"unavailable": "重置暫不可用。",
|
||||
"rate_limited": "請求過於頻繁。",
|
||||
"auth_error": "授權遭拒。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// Upstream cedar_ember contract, following opencodex anthropic-reset-grants.
|
||||
import { apiCallApi } from './apiCall';
|
||||
import { CLAUDE_REQUEST_HEADERS } from '@/utils/quota/constants';
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
||||
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
||||
|
||||
export 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',
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
anthropicResetGrantBlocker,
|
||||
claimClaudeResetGrant,
|
||||
parseAnthropicResetGrantStatus,
|
||||
readClaudeOrganization,
|
||||
readClaudeResetGrants,
|
||||
AnthropicResetGrantUnknownOutcome,
|
||||
ANTHROPIC_RESET_RESULTS,
|
||||
type AnthropicResetSettledCode,
|
||||
} from '../src/services/api/claudeResetGrants';
|
||||
import { apiCallApi, type ApiCallRequest } from '../src/services/api/apiCall';
|
||||
import {
|
||||
createResetGrantOperations,
|
||||
RETRY_WINDOW_MS,
|
||||
} from '../src/features/quota/providers/claude/resetGrantOperations';
|
||||
|
||||
const organization = '11111111-2222-3333-4444-555555555555';
|
||||
const grant = { id: 'test-grant', resets_total: 2, resets_left: 2, usable_now: true };
|
||||
const block = { eligible: true, at_limit: true, grants: [grant] };
|
||||
const status = () => parseAnthropicResetGrantStatus(block)!;
|
||||
const originalRequest = apiCallApi.request;
|
||||
afterEach(() => {
|
||||
apiCallApi.request = originalRequest;
|
||||
});
|
||||
|
||||
describe('Claude reset grant fail-closed parsing', () => {
|
||||
test('valid block and refusing defaults', () => {
|
||||
expect(status().grants[0].useRequiresLimit).toBe(true);
|
||||
expect(anthropicResetGrantBlocker(status(), grant.id)).toBeNull();
|
||||
expect(anthropicResetGrantBlocker({ ...status(), atLimit: false }, grant.id)).toBe(
|
||||
'not_limited'
|
||||
);
|
||||
expect(anthropicResetGrantBlocker({ ...status(), eligible: false }, grant.id)).toBe(
|
||||
'ineligible'
|
||||
);
|
||||
const missing = parseAnthropicResetGrantStatus({
|
||||
...block,
|
||||
grants: [{ ...grant, usable_now: undefined }],
|
||||
})!;
|
||||
expect(anthropicResetGrantBlocker(missing, grant.id)).toBe('not_usable');
|
||||
});
|
||||
test('rejects malformed blocks and grants', () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
{},
|
||||
{ ...block, eligible: 1 },
|
||||
{ ...block, grants: {} },
|
||||
{ ...block, grants: [grant, grant] },
|
||||
{ ...block, cooldown_until: 'bad' },
|
||||
]) {
|
||||
expect(parseAnthropicResetGrantStatus(value)).toBeNull();
|
||||
}
|
||||
for (const fields of [
|
||||
{ id: '../bad' },
|
||||
{ resets_left: -1 },
|
||||
{ resets_left: 3 },
|
||||
{ resets_total: 1.5 },
|
||||
{ paused: 'false' },
|
||||
{ starts_at: 'bad' },
|
||||
{ clears: {} },
|
||||
]) {
|
||||
expect(
|
||||
parseAnthropicResetGrantStatus({ ...block, grants: [{ ...grant, ...fields }] })
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('exact proxied GET/profile/claim contract, no provider token in frontend', async () => {
|
||||
const calls: ApiCallRequest[] = [];
|
||||
apiCallApi.request = async (request) => {
|
||||
calls.push(request);
|
||||
const body =
|
||||
request.method === 'POST'
|
||||
? { result: 'reset' }
|
||||
: request.url.endsWith('profile')
|
||||
? { organization: { uuid: organization } }
|
||||
: { cedar_ember: block };
|
||||
return { statusCode: 200, body, bodyText: '', header: {} };
|
||||
};
|
||||
await readClaudeResetGrants('index');
|
||||
expect(await readClaudeOrganization('index')).toBe(organization);
|
||||
expect(await claimClaudeResetGrant('index', organization, grant.id, 'request-1')).toBe('reset');
|
||||
expect(calls[0].url).toBe('https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1');
|
||||
expect(calls[1].url).toBe('https://api.anthropic.com/api/oauth/profile');
|
||||
expect(calls[2].url).toBe(
|
||||
`https://api.anthropic.com/api/organizations/${organization}/reset_rate_limits`
|
||||
);
|
||||
expect(JSON.parse(calls[2].data!)).toEqual({
|
||||
program: 'cedar_ember',
|
||||
grant_id: grant.id,
|
||||
request_id: 'request-1',
|
||||
});
|
||||
for (const call of calls) {
|
||||
expect(call.authIndex).toBe('index');
|
||||
expect(call.header?.Authorization).toBe('Bearer $TOKEN$');
|
||||
expect(call.header?.['anthropic-beta']).toBe('oauth-2025-04-20');
|
||||
expect(call.header?.['User-Agent']).toBe('claude-cli/2.1.280 (external, cli)');
|
||||
}
|
||||
});
|
||||
|
||||
test('claim terminal mappings and ambiguous outcomes', async () => {
|
||||
for (const result of ANTHROPIC_RESET_RESULTS) {
|
||||
apiCallApi.request = async () => ({
|
||||
statusCode: 200,
|
||||
body: { result },
|
||||
bodyText: '',
|
||||
header: {},
|
||||
});
|
||||
expect(await claimClaudeResetGrant('a', organization, grant.id, 'r')).toBe(result);
|
||||
}
|
||||
for (const [statusCode, code] of [
|
||||
[401, 'auth_error'],
|
||||
[403, 'auth_error'],
|
||||
[429, 'rate_limited'],
|
||||
] as const) {
|
||||
apiCallApi.request = async () => ({ statusCode, body: null, bodyText: '', header: {} });
|
||||
expect(await claimClaudeResetGrant('a', organization, grant.id, 'r')).toBe(code);
|
||||
}
|
||||
for (const statusCode of [200, 400, 500]) {
|
||||
apiCallApi.request = async () => ({
|
||||
statusCode,
|
||||
body: { result: 'unexpected' },
|
||||
bodyText: '',
|
||||
header: {},
|
||||
});
|
||||
await expect(claimClaudeResetGrant('a', organization, grant.id, 'r')).rejects.toBeInstanceOf(
|
||||
AnthropicResetGrantUnknownOutcome
|
||||
);
|
||||
}
|
||||
apiCallApi.request = async () => {
|
||||
throw new Error('private upstream detail');
|
||||
};
|
||||
await expect(claimClaudeResetGrant('a', organization, grant.id, 'r')).rejects.toThrow(
|
||||
'outcome is unknown'
|
||||
);
|
||||
});
|
||||
|
||||
function setup() {
|
||||
let revision = 1;
|
||||
let now = 100;
|
||||
let count = 0;
|
||||
const ids: string[] = [];
|
||||
const deps = {
|
||||
revision: () => revision,
|
||||
now: () => now,
|
||||
requestId: () => `request-${++count}`,
|
||||
readStatus: async () => status(),
|
||||
readOrganization: async () => organization,
|
||||
claim: async (
|
||||
_auth: string,
|
||||
_org: string,
|
||||
_grant: string,
|
||||
id: string
|
||||
): Promise<AnthropicResetSettledCode> => {
|
||||
ids.push(id);
|
||||
throw new AnthropicResetGrantUnknownOutcome();
|
||||
},
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
ids,
|
||||
nextSession: () => {
|
||||
revision++;
|
||||
},
|
||||
advance: () => {
|
||||
now += RETRY_WINDOW_MS;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('unknown outcome retains request ID across retries; expiry blocks', async () => {
|
||||
const h = setup();
|
||||
const operations = createResetGrantOperations(h.deps);
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow();
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow();
|
||||
expect(h.ids).toEqual(['request-1', 'request-1']);
|
||||
await expect(operations.run('account', 'a', 'other-grant')).rejects.toThrow('expired');
|
||||
h.advance();
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow('expired');
|
||||
expect(h.ids).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('fresh spend rechecks eligibility and blocks concurrent clicks', async () => {
|
||||
const h = setup();
|
||||
h.deps.readStatus = async () => ({ ...status(), eligible: false });
|
||||
const operations = createResetGrantOperations(h.deps);
|
||||
const first = operations.run('account', 'a', grant.id);
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow('busy');
|
||||
await expect(first).rejects.toThrow('blocked');
|
||||
expect(h.ids).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('session change during profile read prevents POST and isolates journal', async () => {
|
||||
const h = setup();
|
||||
const operations = createResetGrantOperations(h.deps);
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow();
|
||||
h.nextSession();
|
||||
expect(operations.inspect('account')).toBeUndefined();
|
||||
h.deps.readOrganization = async () => {
|
||||
h.nextSession();
|
||||
return organization;
|
||||
};
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow('session');
|
||||
expect(h.ids).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('retry binds original organization', async () => {
|
||||
const h = setup();
|
||||
const operations = createResetGrantOperations(h.deps);
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow();
|
||||
h.deps.readOrganization = async () => 'aaaaaaaa-2222-3333-4444-555555555555';
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow('identity');
|
||||
expect(h.ids).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a retry refusal keeps the ambiguous original operation open', async () => {
|
||||
const h = setup();
|
||||
const operations = createResetGrantOperations(h.deps);
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow();
|
||||
h.deps.claim = async (_auth, _org, _grant, id) => {
|
||||
h.ids.push(id);
|
||||
return 'rate_limited';
|
||||
};
|
||||
expect(await operations.run('account', 'a', grant.id)).toEqual({
|
||||
code: 'rate_limited',
|
||||
unresolved: true,
|
||||
});
|
||||
expect(operations.inspect('account')?.code).toBeUndefined();
|
||||
h.deps.claim = async (_auth, _org, _grant, id) => {
|
||||
h.ids.push(id);
|
||||
return 'already_used';
|
||||
};
|
||||
expect(await operations.run('account', 'a', grant.id)).toEqual({
|
||||
code: 'already_used',
|
||||
unresolved: false,
|
||||
});
|
||||
expect(h.ids).toEqual(['request-1', 'request-1', 'request-1']);
|
||||
});
|
||||
|
||||
test('no retry after the deadline expires during profile lookup', async () => {
|
||||
const h = setup();
|
||||
const operations = createResetGrantOperations(h.deps);
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow();
|
||||
h.deps.readOrganization = async () => {
|
||||
h.advance();
|
||||
return organization;
|
||||
};
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow('expired');
|
||||
expect(h.ids).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('date, pause, limit, balance, usability and cooldown gates refuse new spending', async () => {
|
||||
for (const fields of [
|
||||
{ paused: true },
|
||||
{ resetsLeft: 0 },
|
||||
{ usableNow: false },
|
||||
{ startsAt: '2099-01-01T00:00:00Z' },
|
||||
{ endsAt: '1970-01-01T00:00:00Z' },
|
||||
]) {
|
||||
const h = setup();
|
||||
h.deps.readStatus = async () => ({
|
||||
...status(),
|
||||
grants: [{ ...status().grants[0], ...fields }],
|
||||
});
|
||||
await expect(createResetGrantOperations(h.deps).run('account', 'a', grant.id)).rejects.toThrow(
|
||||
'blocked'
|
||||
);
|
||||
expect(h.ids).toHaveLength(0);
|
||||
}
|
||||
for (const fields of [{ atLimit: false }, { cooldownUntil: '2099-01-01T00:00:00Z' }]) {
|
||||
const h = setup();
|
||||
h.deps.readStatus = async () => ({ ...status(), ...fields });
|
||||
await expect(createResetGrantOperations(h.deps).run('account', 'a', grant.id)).rejects.toThrow(
|
||||
'blocked'
|
||||
);
|
||||
expect(h.ids).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale claim answer cannot settle the replacement session operation', async () => {
|
||||
const h = setup();
|
||||
const operations = createResetGrantOperations(h.deps);
|
||||
h.deps.claim = async () => {
|
||||
h.nextSession();
|
||||
expect(operations.inspect('account')).toBeUndefined();
|
||||
return 'reset';
|
||||
};
|
||||
await expect(operations.run('account', 'a', grant.id)).rejects.toThrow('session');
|
||||
expect(operations.inspect('account')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('all grant messages and confirmation are translated in four locales', async () => {
|
||||
const locales = await Promise.all(
|
||||
['en', 'zh-CN', 'zh-TW', 'ru'].map(
|
||||
async (locale) => (await Bun.file(`src/i18n/locales/${locale}.json`).json()).claude_reset
|
||||
)
|
||||
);
|
||||
for (const locale of locales) {
|
||||
expect(Object.keys(locale).sort()).toEqual(Object.keys(locales[0]).sort());
|
||||
expect(locale.confirm_text).toContain('{{grant}}');
|
||||
expect(locale.count).toContain('{{left}}');
|
||||
for (const key of [
|
||||
...ANTHROPIC_RESET_RESULTS,
|
||||
'auth_error',
|
||||
'rate_limited',
|
||||
'unknown',
|
||||
'expired',
|
||||
]) {
|
||||
expect(typeof locale[key]).toBe('string');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user