mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
feat: implement Claude reset grants functionality with demo and tests
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Original-app Claude reset demo
|
||||
|
||||
Open `http://localhost:5179/demo/claude-reset.html#/quota` with Vite running on port 5179
|
||||
(`bun run dev -- --host 127.0.0.1 --port 5179`).
|
||||
|
||||
This entry loads the real `src/main.tsx`, App, MainLayout and quota route. There is
|
||||
no demo UI, toolbar or stylesheet. Four synthetic Claude accounts populate the
|
||||
normal quota cards and timeline:
|
||||
|
||||
- `alex.max`: usable grant; confirmation clears usage and spends one reset.
|
||||
- `design.team`: exhausted grant.
|
||||
- `sam.free`: ineligible account.
|
||||
- `retry.max`: first claim has an unknown outcome; retry must reuse its request ID.
|
||||
|
||||
Use the existing “储备重置次数” controls. Reload the page to reset all fixtures.
|
||||
Other providers are intentionally empty. This is a quota-focused fixture, not a
|
||||
complete mock of every route in the app; unsupported requests fail closed.
|
||||
|
||||
Before any application import, the HTML replaces local/session storage with
|
||||
memory-only stores and blocks fetch/XHR. CSP permits only the local Vite socket.
|
||||
The entry installs an Axios adapter before loading the API client. All mock
|
||||
credentials use `example.invalid`; no real management key is supplied. Both the
|
||||
initial quota cache and refresh actions use the real quota parser and API modules.
|
||||
|
||||
Regression check: `bun test demo/claude-reset-mocks.test.ts`.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import axios from 'axios';
|
||||
import { demoFiles, installDemoTransport } from './claude-reset-mocks';
|
||||
|
||||
describe('original-app demo transport', () => {
|
||||
test('lists only synthetic accounts and rejects unimplemented requests', async () => {
|
||||
const client = axios.create({ adapter: installDemoTransport() });
|
||||
expect((await client.get('/credentials')).data.files).toEqual(demoFiles);
|
||||
await expect(client.get('https://example.invalid/real-api')).rejects.toThrow('blocked');
|
||||
await expect(client.delete('/credentials')).rejects.toThrow('blocked');
|
||||
});
|
||||
|
||||
test('unknown outcome requires the same request identity; refresh reflects a single spend', async () => {
|
||||
const client = axios.create({ adapter: installDemoTransport() });
|
||||
const call = async (path: string, data?: string) =>
|
||||
(
|
||||
await client.post('/requests/api-call', {
|
||||
authIndex: 'demo-ambiguous',
|
||||
method: data ? 'POST' : 'GET',
|
||||
url: `https://api.anthropic.com${path}`,
|
||||
data,
|
||||
})
|
||||
).data.body;
|
||||
const path = '/api/organizations/00000000-0000-4000-8000-000000000001/reset_rate_limits';
|
||||
const claim = JSON.stringify({ grant_id: 'demo_grant', request_id: 'same-request' });
|
||||
await expect(call(path, claim)).rejects.toThrow('unknown outcome');
|
||||
await expect(
|
||||
call(path, JSON.stringify({ grant_id: 'demo_grant', request_id: 'different' }))
|
||||
).rejects.toThrow('identity changed');
|
||||
expect(await call(path, claim)).toEqual({ result: 'reset' });
|
||||
expect(await call(path, claim)).toEqual({ result: 'already_used' });
|
||||
const usage = await call('/api/oauth/usage');
|
||||
expect(usage.five_hour.utilization).toBe(0);
|
||||
expect(usage.cedar_ember.grants[0].resets_left).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { AxiosAdapter } from 'axios';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import type { ApiCallRequest } from '@/services/api/apiCall';
|
||||
|
||||
type Scenario = 'available' | 'exhausted' | 'ineligible' | 'ambiguous';
|
||||
const accounts: { email: string; scenario: Scenario }[] = [
|
||||
{ email: 'alex.max@example.invalid', scenario: 'available' },
|
||||
{ email: 'design.team@example.invalid', scenario: 'exhausted' },
|
||||
{ email: 'sam.free@example.invalid', scenario: 'ineligible' },
|
||||
{ email: 'retry.max@example.invalid', scenario: 'ambiguous' },
|
||||
];
|
||||
export const demoFiles: AuthFileItem[] = accounts.map(({ email, scenario }, index) => ({
|
||||
name: `claude-${email}.json`,
|
||||
email,
|
||||
type: 'claude',
|
||||
provider: 'claude',
|
||||
authIndex: `demo-${scenario}`,
|
||||
status: 'active',
|
||||
disabled: false,
|
||||
size: 1824 + index * 128,
|
||||
}));
|
||||
const isoAfter = (hours: number) => new Date(Date.now() + hours * 3600000).toISOString();
|
||||
const organization = '00000000-0000-4000-8000-000000000001';
|
||||
|
||||
/** In-memory, fail-closed Axios transport. No request is forwarded to an adapter. */
|
||||
export function installDemoTransport(): AxiosAdapter {
|
||||
const states = new Map(
|
||||
accounts.map(({ scenario }) => [
|
||||
`demo-${scenario}`,
|
||||
{
|
||||
scenario,
|
||||
left: scenario === 'exhausted' ? 0 : 2,
|
||||
reset: false,
|
||||
pending: '',
|
||||
completed: new Set<string>(),
|
||||
},
|
||||
])
|
||||
);
|
||||
return async (config) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const method = config.method?.toUpperCase();
|
||||
const path = config.url;
|
||||
let data: unknown;
|
||||
if (method === 'GET' && path === '/config') {
|
||||
data = {};
|
||||
} else if (method === 'GET' && path === '/credentials') {
|
||||
data = { files: demoFiles, total: demoFiles.length };
|
||||
} else if (method === 'GET' && path === '/plugins') {
|
||||
data = { plugins: [] };
|
||||
} else if (method === 'POST' && path === '/requests/api-call') {
|
||||
const payload = (
|
||||
typeof config.data === 'string' ? JSON.parse(config.data) : config.data
|
||||
) as ApiCallRequest;
|
||||
const state = states.get(payload.authIndex ?? '');
|
||||
if (!state) throw new Error('Demo: unknown account');
|
||||
const url = new URL(payload.url);
|
||||
if (url.origin !== 'https://api.anthropic.com') throw new Error('Demo: blocked upstream');
|
||||
let body: unknown;
|
||||
if (payload.method === 'GET' && url.pathname === '/api/oauth/profile') {
|
||||
body = {
|
||||
organization: {
|
||||
uuid: organization,
|
||||
organization_type: state.scenario === 'exhausted' ? 'claude_team' : 'claude_pro',
|
||||
subscription_status: 'active',
|
||||
},
|
||||
account: { has_claude_max: state.scenario !== 'ineligible', has_claude_pro: false },
|
||||
};
|
||||
} else if (payload.method === 'GET' && url.pathname === '/api/oauth/usage') {
|
||||
body = {
|
||||
five_hour: {
|
||||
utilization: state.reset ? 0 : state.scenario === 'ineligible' ? 38 : 100,
|
||||
resets_at: isoAfter(2.5),
|
||||
},
|
||||
seven_day: {
|
||||
utilization: state.reset ? 0 : state.scenario === 'exhausted' ? 96 : 72,
|
||||
resets_at: isoAfter(96),
|
||||
},
|
||||
seven_day_sonnet: { utilization: state.reset ? 0 : 43, resets_at: isoAfter(96) },
|
||||
extra_usage: {
|
||||
is_enabled: state.scenario === 'available',
|
||||
monthly_limit: 5000,
|
||||
used_credits: 1250,
|
||||
utilization: 25,
|
||||
},
|
||||
cedar_ember: {
|
||||
eligible: state.scenario !== 'ineligible',
|
||||
ineligible_reason: state.scenario === 'ineligible' ? 'tier' : null,
|
||||
next_grant_id: 'demo_grant',
|
||||
at_limit: !state.reset && state.scenario !== 'ineligible',
|
||||
grants:
|
||||
state.scenario === 'ineligible'
|
||||
? []
|
||||
: [
|
||||
...(state.scenario === 'available'
|
||||
? [
|
||||
{
|
||||
id: 'a_backup',
|
||||
label: '备用重置',
|
||||
resets_total: 1,
|
||||
resets_left: 1,
|
||||
usable_now: true,
|
||||
use_requires_limit: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'demo_grant',
|
||||
label: 'Claude Max · 每周储备重置',
|
||||
resets_total: 3,
|
||||
resets_left: state.left,
|
||||
ends_at: isoAfter(168),
|
||||
clears: ['five_hour', 'seven_day'],
|
||||
usable_now: true,
|
||||
use_requires_limit: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
} else if (
|
||||
payload.method === 'POST' &&
|
||||
url.pathname === `/api/organizations/${organization}/reset_rate_limits`
|
||||
) {
|
||||
const claim = JSON.parse(payload.data ?? '{}') as {
|
||||
grant_id?: string;
|
||||
request_id?: string;
|
||||
};
|
||||
if (claim.grant_id !== 'demo_grant' || !claim.request_id)
|
||||
throw new Error('Demo: invalid claim');
|
||||
if (state.completed.has(claim.request_id)) {
|
||||
body = { result: 'already_used' };
|
||||
} else if (state.scenario === 'ineligible' || state.left <= 0 || state.reset) {
|
||||
throw new Error('Demo: unavailable grant');
|
||||
} else {
|
||||
if (state.scenario === 'ambiguous' && !state.pending) {
|
||||
state.pending = claim.request_id;
|
||||
throw new Error('Demo: simulated unknown outcome');
|
||||
}
|
||||
if (state.pending && state.pending !== claim.request_id)
|
||||
throw new Error('Demo: retry identity changed');
|
||||
state.completed.add(claim.request_id);
|
||||
state.left -= 1;
|
||||
state.reset = true;
|
||||
body = { result: 'reset' };
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Demo: unsupported upstream route ${payload.method} ${url.pathname}`);
|
||||
}
|
||||
data = { status_code: 200, header: {}, body };
|
||||
} else {
|
||||
throw new Error(`Demo: blocked management route ${method} ${path}`);
|
||||
}
|
||||
return { data, status: 200, statusText: 'OK', headers: {}, config };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CLI Proxy API Management Center · Local Demo</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<!-- Only Vite's local development socket may use a live connection. -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="connect-src ws://127.0.0.1:5179 ws://localhost:5179; form-action 'none'"
|
||||
/>
|
||||
<script>
|
||||
// Isolate persisted authentication before any application module is evaluated.
|
||||
for (const name of ['localStorage', 'sessionStorage']) {
|
||||
const values = new Map();
|
||||
Object.defineProperty(window, name, {
|
||||
value: {
|
||||
getItem: (key) => values.get(String(key)) ?? null,
|
||||
setItem: (key, value) => values.set(String(key), String(value)),
|
||||
removeItem: (key) => values.delete(String(key)),
|
||||
clear: () => values.clear(),
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// Application API calls are mocked; fail closed if another transport is used.
|
||||
window.fetch = async () => {
|
||||
throw new Error('Preview: network transport blocked');
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
throw new Error('Preview: network transport blocked');
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/demo/claude-reset.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
// Install the transport before importing any application module (including apiClient).
|
||||
import axios from 'axios';
|
||||
import { installDemoTransport, demoFiles } from './claude-reset-mocks';
|
||||
|
||||
axios.defaults.adapter = installDemoTransport();
|
||||
|
||||
async function start() {
|
||||
const [
|
||||
{ useAuthStore },
|
||||
{ useThemeStore },
|
||||
{ useLanguageStore },
|
||||
{ useQuotaStore },
|
||||
{ CLAUDE_CONFIG },
|
||||
{ default: i18n },
|
||||
] = await Promise.all([
|
||||
import('@/stores/useAuthStore'),
|
||||
import('@/stores/useThemeStore'),
|
||||
import('@/stores/useLanguageStore'),
|
||||
import('@/stores/useQuotaStore'),
|
||||
import('@/features/quota/providers/claude/data'),
|
||||
import('@/i18n'),
|
||||
]);
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
connectionStatus: 'connected',
|
||||
apiBase: 'http://demo.invalid',
|
||||
managementKey: '',
|
||||
rememberPassword: false,
|
||||
serverVersion: 'v8.0.0-demo',
|
||||
supportsPlugin: false,
|
||||
});
|
||||
useLanguageStore.getState().setLanguage('zh-CN');
|
||||
useThemeStore.getState().setTheme('light');
|
||||
// Populate the original quota cache through the same parser and mock transport
|
||||
// used by the page's refresh actions, rather than replacing any UI component.
|
||||
for (const file of demoFiles) {
|
||||
const data = await CLAUDE_CONFIG.fetchQuota(file, i18n.t);
|
||||
useQuotaStore.getState().setClaudeQuota((previous) => ({
|
||||
...previous,
|
||||
[file.name]: CLAUDE_CONFIG.buildSuccessState(data),
|
||||
}));
|
||||
}
|
||||
if (!location.hash) location.hash = '/quota';
|
||||
await import('@/main');
|
||||
}
|
||||
|
||||
void start();
|
||||
@@ -22,7 +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 { useClaudeResetGrants } from '../providers/claude/ClaudeResetGrants';
|
||||
import bodyStyles from './QuotaBody.module.scss';
|
||||
import styles from './QuotaCard.module.scss';
|
||||
|
||||
@@ -66,6 +66,13 @@ export function QuotaCard(props: QuotaCardProps) {
|
||||
|
||||
const status = quota?.status ?? 'idle';
|
||||
const loading = status === 'loading';
|
||||
const claudeReset = useClaudeResetGrants(
|
||||
file,
|
||||
entry.type === 'claude' && status !== 'idle',
|
||||
!canRefresh || loading || resetting,
|
||||
quota,
|
||||
onRefresh
|
||||
);
|
||||
const iconSrc = getAuthFileIcon(entry.type, resolvedTheme);
|
||||
const typeLabel = getTypeLabel(t, entry.type);
|
||||
const errorMessage = resolveQuotaErrorMessage(
|
||||
@@ -106,6 +113,21 @@ export function QuotaCard(props: QuotaCardProps) {
|
||||
</header>
|
||||
|
||||
<div className={styles.body}>
|
||||
{entry.type === 'claude' && status === 'success' && (
|
||||
<>
|
||||
<div className={quotaClasses.codexPlan}>
|
||||
<span className={quotaClasses.codexPlanItem}>
|
||||
<span className={quotaClasses.codexPlanLabel}>{t('claude_reset.remaining')}</span>
|
||||
<span className={quotaClasses.codexPlanValue}>{claudeReset.count ?? '--'}</span>
|
||||
</span>
|
||||
</div>
|
||||
{claudeReset.message && (
|
||||
<div role="status" className={quotaClasses.codexResetCreditsError}>
|
||||
{t(`claude_reset.${claudeReset.message}`)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{status === 'idle' ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -140,11 +162,16 @@ export function QuotaCard(props: QuotaCardProps) {
|
||||
{status !== 'idle' && (
|
||||
<footer className={styles.actionRow}>
|
||||
{entry.type === 'claude' && (
|
||||
<ClaudeResetGrants
|
||||
file={file}
|
||||
disabled={!canRefresh || loading || resetting}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionPill}
|
||||
disabled={claudeReset.blocked}
|
||||
onClick={claudeReset.confirm}
|
||||
title={t(`claude_reset.${claudeReset.buttonLabel}`)}
|
||||
>
|
||||
<IconRefreshCw size={13} className={claudeReset.busy ? styles.spinning : undefined} />
|
||||
{t(`claude_reset.${claudeReset.buttonLabel}`)}
|
||||
</button>
|
||||
)}
|
||||
{showReset && (
|
||||
<button
|
||||
@@ -162,7 +189,7 @@ export function QuotaCard(props: QuotaCardProps) {
|
||||
type="button"
|
||||
className={styles.actionPill}
|
||||
onClick={onRefresh}
|
||||
disabled={isQuotaRefreshDisabled(canRefresh, loading, resetting)}
|
||||
disabled={isQuotaRefreshDisabled(canRefresh, loading, resetting || claudeReset.busy)}
|
||||
title={t('auth_files.quota_refresh_hint')}
|
||||
>
|
||||
<IconRefreshCw size={13} className={loading ? styles.spinning : undefined} />
|
||||
|
||||
@@ -1,183 +1,121 @@
|
||||
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 { useNotificationStore } from '@/stores';
|
||||
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';
|
||||
import { selectResetGrant } from './selectResetGrant';
|
||||
|
||||
export function ClaudeResetGrants({
|
||||
file,
|
||||
disabled: externallyDisabled,
|
||||
onRefresh,
|
||||
}: {
|
||||
file: AuthFileItem;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
/** Card-owned reads; the session-scoped journal owns spending and ambiguous retries. */
|
||||
export function useClaudeResetGrants(
|
||||
file: AuthFileItem,
|
||||
enabled: boolean,
|
||||
disabled: boolean,
|
||||
refreshToken: unknown,
|
||||
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 showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const now = useNow();
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
const [open, setOpen] = useState(false);
|
||||
const key = JSON.stringify([file.name, authIndex]);
|
||||
const [status, setStatus] = useState<AnthropicResetGrantStatus | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [reload, setReload] = useState(0);
|
||||
const lock = useRef(false);
|
||||
const generation = useRef(0);
|
||||
useEffect(
|
||||
() => () => {
|
||||
useEffect(() => {
|
||||
const version = ++generation.current;
|
||||
setStatus(null);
|
||||
if (!enabled || disabled || !sessionActive || !authIndex) return;
|
||||
let cancelled = false;
|
||||
const current = () =>
|
||||
!cancelled && version === generation.current && session === apiClient.getConnectionRevision();
|
||||
void readClaudeResetGrants(authIndex).then(
|
||||
(result) => {
|
||||
if (current()) {
|
||||
setStatus(result);
|
||||
setMessage('');
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (current()) setMessage('read_error');
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
generation.current += 1;
|
||||
},
|
||||
[authIndex, file.name]
|
||||
);
|
||||
const key = JSON.stringify([file.name, authIndex]);
|
||||
};
|
||||
}, [authIndex, key, enabled, disabled, sessionActive, session, refreshToken, reload]);
|
||||
|
||||
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 expired = Boolean(pending && now - pending.createdAt >= RETRY_WINDOW_MS);
|
||||
const selected = pending?.grantId ?? (status ? selectResetGrant(status, now)?.id : undefined);
|
||||
const blocked = disabled || !sessionActive || !authIndex || busy || expired || !selected;
|
||||
const confirm = () => {
|
||||
if (blocked || lock.current || !selected || !authIndex) return;
|
||||
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
|
||||
session === apiClient.getConnectionRevision() && version === generation.current;
|
||||
showConfirmation({
|
||||
title: t('claude_reset.title'),
|
||||
message: t(pending ? 'claude_reset.retry_confirm' : 'claude_reset.confirm_text', {
|
||||
name: file.name,
|
||||
}),
|
||||
confirmText: t(pending ? 'claude_reset.retry' : 'claude_reset.confirm'),
|
||||
variant: 'primary',
|
||||
onConfirm: async () => {
|
||||
if (!current() || lock.current || useAuthStore.getState().connectionStatus !== 'connected')
|
||||
return;
|
||||
lock.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const answer = await resetGrantOperations.run(key, authIndex, selected);
|
||||
if (!current()) return;
|
||||
showNotification(
|
||||
t(`claude_reset.${answer.unresolved ? 'unknown' : answer.code}`),
|
||||
!answer.unresolved && (answer.code === 'reset' || answer.code === 'already_used')
|
||||
? 'success'
|
||||
: 'error'
|
||||
);
|
||||
} catch {
|
||||
if (!current()) return;
|
||||
const unresolved = resetGrantOperations.inspect(key);
|
||||
showNotification(
|
||||
t(`claude_reset.${unresolved && !unresolved.code ? 'unknown' : 'blocked'}`),
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
lock.current = false;
|
||||
// A concurrent page-wide refresh can invalidate this read generation.
|
||||
// Release the local lock regardless, but never refresh a replacement account.
|
||||
setBusy(false);
|
||||
setReload((value) => value + 1);
|
||||
if (current()) onRefresh();
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
count: status?.grants.reduce((sum, grant) => sum + grant.resetsLeft, 0) ?? null,
|
||||
busy,
|
||||
blocked,
|
||||
confirm,
|
||||
message: pending ? (expired ? 'expired' : 'unknown') : message,
|
||||
buttonLabel: pending ? 'retry' : 'use',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
anthropicResetGrantBlocker,
|
||||
type AnthropicResetGrantStatus,
|
||||
} from '@/services/api/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]
|
||||
);
|
||||
}
|
||||
@@ -2201,6 +2201,8 @@
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"retry_confirm": "Retry the unknown reset for “{{name}}” with its original request ID? A reset may already have been spent. Retry within 10 minutes; do not reload or start another claim in another tab.",
|
||||
"remaining": "Resets remaining",
|
||||
"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…",
|
||||
@@ -2212,9 +2214,9 @@
|
||||
"empty": "No banked reset grants.",
|
||||
"count": "{{left}} / {{total}} resets remaining",
|
||||
"ends": "Expires: {{date}}",
|
||||
"use": "Use reset",
|
||||
"use": "Reset limits",
|
||||
"confirm": "Confirm reset",
|
||||
"confirm_text": "Spend one reset from “{{grant}}”? An unknown claim will be retried with its original request ID.",
|
||||
"confirm_text": "Spend one reset to clear eligible usage limits for “{{name}}”? This uses a limited reset grant.",
|
||||
"reset": "Usage limits reset.",
|
||||
"already_used": "This grant was already used.",
|
||||
"not_limited": "No reset: the account is not currently limited.",
|
||||
|
||||
@@ -2195,6 +2195,8 @@
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"retry_confirm": "Повторить сброс «{{name}}» с неизвестным результатом и исходным ID запроса? Сброс мог быть потрачен. Повторите в течение 10 минут; не перезагружайте страницу и не создавайте запрос в другой вкладке.",
|
||||
"remaining": "Осталось сбросов",
|
||||
"title": "Запас сбросов",
|
||||
"scope": "Использует ограниченное право на сброс лимитов Claude. Подтверждайте только если хотите потратить сброс. Восстановление доступно только в этой вкладке и сессии; при неизвестном результате не перезагружайте страницу и не используйте другую вкладку.",
|
||||
"loading": "Загрузка…",
|
||||
@@ -2206,9 +2208,9 @@
|
||||
"empty": "Нет доступных запасных сбросов.",
|
||||
"count": "Осталось сбросов: {{left}} / {{total}}",
|
||||
"ends": "Истекает: {{date}}",
|
||||
"use": "Использовать сброс",
|
||||
"use": "Сбросить лимиты",
|
||||
"confirm": "Подтвердить сброс",
|
||||
"confirm_text": "Потратить один сброс «{{grant}}»? Запрос с неизвестным результатом будет повторён с исходным ID.",
|
||||
"confirm_text": "Потратить один сброс для снятия доступных лимитов «{{name}}»? Число сбросов ограничено.",
|
||||
"reset": "Лимиты использования сброшены.",
|
||||
"already_used": "Это право уже использовано.",
|
||||
"not_limited": "Сброс не выполнен: лимит ещё не достигнут.",
|
||||
|
||||
@@ -2199,6 +2199,8 @@
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"retry_confirm": "使用原请求 ID 重试“{{name}}”结果未知的重置?可能已消耗一次重置。请在 10 分钟内重试;请勿重新加载页面或在其他标签页发起新请求。",
|
||||
"remaining": "剩余重置次数",
|
||||
"title": "储备重置次数",
|
||||
"scope": "使用有限的重置权益清除符合条件的 Claude 用量窗口。仅在确定要消耗一次重置时确认。恢复仅限当前标签页和会话;结果未知时,请勿刷新页面或使用其他标签页。",
|
||||
"loading": "加载中…",
|
||||
@@ -2210,9 +2212,9 @@
|
||||
"empty": "没有储备重置权益。",
|
||||
"count": "剩余 {{left}} / {{total}} 次重置",
|
||||
"ends": "到期:{{date}}",
|
||||
"use": "使用重置",
|
||||
"use": "重置额度",
|
||||
"confirm": "确认重置",
|
||||
"confirm_text": "消耗“{{grant}}”的一次重置?结果未知的请求将使用原请求 ID 重试。",
|
||||
"confirm_text": "确定消耗一次重置,清除“{{name}}”符合条件的用量限制?重置次数有限。",
|
||||
"reset": "已重置用量限制。",
|
||||
"already_used": "该权益已使用。",
|
||||
"not_limited": "未重置:账户当前未达到限制。",
|
||||
|
||||
@@ -2225,6 +2225,8 @@
|
||||
}
|
||||
},
|
||||
"claude_reset": {
|
||||
"retry_confirm": "使用原請求 ID 重試「{{name}}」結果未知的重置?可能已消耗一次重置。請在 10 分鐘內重試;請勿重新載入頁面或在其他分頁發起新請求。",
|
||||
"remaining": "剩餘重置次數",
|
||||
"title": "儲備重置次數",
|
||||
"scope": "使用有限的重置權益清除符合條件的 Claude 用量視窗。僅在確定要消耗一次重置時確認。復原僅限目前分頁和工作階段;結果未知時,請勿重新載入頁面或使用其他分頁。",
|
||||
"loading": "載入中…",
|
||||
@@ -2236,9 +2238,9 @@
|
||||
"empty": "沒有儲備重置權益。",
|
||||
"count": "剩餘 {{left}} / {{total}} 次重置",
|
||||
"ends": "到期:{{date}}",
|
||||
"use": "使用重置",
|
||||
"use": "重置額度",
|
||||
"confirm": "確認重置",
|
||||
"confirm_text": "消耗「{{grant}}」的一次重置?結果未知的請求將使用原請求 ID 重試。",
|
||||
"confirm_text": "確定消耗一次重置,清除「{{name}}」符合條件的用量限制?重置次數有限。",
|
||||
"reset": "已重置用量限制。",
|
||||
"already_used": "該權益已使用。",
|
||||
"not_limited": "未重置:帳戶目前未達到限制。",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { selectResetGrant } from '../src/features/quota/providers/claude/selectResetGrant';
|
||||
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
anthropicResetGrantBlocker,
|
||||
@@ -299,7 +301,9 @@ test('all grant messages and confirmation are translated in four locales', async
|
||||
);
|
||||
for (const locale of locales) {
|
||||
expect(Object.keys(locale).sort()).toEqual(Object.keys(locales[0]).sort());
|
||||
expect(locale.confirm_text).toContain('{{grant}}');
|
||||
expect(locale.confirm_text).toContain('{{name}}');
|
||||
expect(locale.retry_confirm).toContain('{{name}}');
|
||||
expect(typeof locale.remaining).toBe('string');
|
||||
expect(locale.count).toContain('{{left}}');
|
||||
for (const key of [
|
||||
...ANTHROPIC_RESET_RESULTS,
|
||||
@@ -312,3 +316,39 @@ test('all grant messages and confirmation are translated in four locales', async
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('card selection prefers usable recommendation and has deterministic fallback', () => {
|
||||
const base = status();
|
||||
const a = { ...base.grants[0], id: 'a' };
|
||||
const b = { ...a, id: 'b' };
|
||||
const multiple = { ...base, grants: [b, a], nextGrantId: 'b' };
|
||||
expect(selectResetGrant(multiple, 0)?.id).toBe('b');
|
||||
expect(selectResetGrant({ ...multiple, nextGrantId: null }, 0)?.id).toBe('a');
|
||||
expect(selectResetGrant({ ...multiple, grants: [a, { ...b, paused: true }] }, 0)?.id).toBe('a');
|
||||
expect(selectResetGrant({ ...multiple, eligible: false }, 0)).toBeUndefined();
|
||||
expect(selectResetGrant({ ...multiple, atLimit: false }, 0)).toBeUndefined();
|
||||
expect(
|
||||
selectResetGrant({ ...multiple, cooldownUntil: new Date(1000).toISOString() }, 0)
|
||||
).toBeUndefined();
|
||||
for (const changed of [
|
||||
{ resetsLeft: 0 },
|
||||
{ usableNow: false },
|
||||
{ paused: true },
|
||||
{ startsAt: new Date(1000).toISOString() },
|
||||
{ endsAt: new Date(0).toISOString() },
|
||||
]) {
|
||||
expect(selectResetGrant({ ...base, grants: [{ ...a, ...changed }] }, 0)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('Claude card uses Codex count and action styles and shared confirmation, not a grant dialog', async () => {
|
||||
const card = await Bun.file('src/features/quota/components/QuotaCard.tsx').text();
|
||||
const hook = await Bun.file('src/features/quota/providers/claude/ClaudeResetGrants.tsx').text();
|
||||
expect(card).toContain('quotaClasses.codexPlanValue}>{claudeReset.count');
|
||||
expect(card).toContain('disabled={claudeReset.blocked}');
|
||||
expect(card).toContain('onClick={claudeReset.confirm}');
|
||||
expect(hook).toContain('showConfirmation({');
|
||||
expect(hook).toContain("pending ? 'claude_reset.retry_confirm'");
|
||||
expect(hook).not.toContain('<Modal');
|
||||
expect(hook).not.toContain('status.grants.map');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user