feat(quota): add Claude quota reset support

- Add reset counts and actions to auth file and quota pages
- Reuse the Codex confirmation flow with Claude eligibility and cooldown checks
- Preserve request IDs on retries to prevent duplicate credit consumption
- Add translations, regression tests, and layout checks
This commit is contained in:
lzt404 committed 2026-10-05 00:45:17 +08:00
1 parent 2b81856521
commit a3f5d2e54e
17 files changed
+729 -34

No files matched your search

+3 -2
View File
@@ -24,12 +24,13 @@ it('keeps window percentages, reset times and additional windows available in de
expect(html).toContain('extra');
});
it('shows reset credits in the button while preserving applicability and expiry', () => {
it('shows reset credits beside the button while preserving applicability and expiry', () => {
const html = renderDense({ status: 'success', rows: [{ label: '5h', remainingPercent: 0 }],
resetCredits: 2, resetCreditsApplicable: 0, subscriptionActiveUntil: '2030-01-01T00:00:00Z',
});
const resetButton = html.match(/<button[^>]*credential-quota-reset[^>]*>[\s\S]*?<\/button>/)?.[0];
expect(resetButton).toContain('2');
expect(resetButton).toContain('Reset');
expect(html).toMatch(/credential-quota-reset-credits[^>]*>[^<]*2/);
expect(resetButton).not.toContain('disabled=""');
expect(html).toContain('Currently applicable: 0');
expect(html).toContain('Subscription expires');
+140
View File
@@ -0,0 +1,140 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { mockIPC, clearMocks } from '@tauri-apps/api/mocks';
import { parseAnthropicResetGrantStatus } from '../src/services/claudeResetGrants';
import { createResetGrantOperations, RETRY_WINDOW_MS } from '../src/services/claudeResetOperations';
import { selectResetGrant } from '../src/services/selectClaudeResetGrant';
import { loadQuota, quotaKey } from '../src/services/quotaService';
import { canResetQuota, resetQuotaWithConfirmation } from '../src/services/quotaActions';
import { getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache } from '../src/services/quotaCache';
import { AuthFileQuotaPanel } from '../src/components/AuthFileQuotaPanel';
import { I18nProvider } from '../src/i18n';
import { QuotaCard } from '../src/pages/QuotaPage';
const organization = '11111111-2222-3333-4444-555555555555';
const block = { eligible: true, at_limit: true, grants: [{ id: 'test', resets_total: 2, resets_left: 2, usable_now: true }] };
const status = () => parseAnthropicResetGrantStatus(block)!;
let file: { name: string; provider: string; auth_index: string };
let requests: { method: string; url: string; data?: string; authIndex: string }[];
let mode: 'success' | 'unknown' | 'ineligible' | 'malformed';
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: { navigator: { language: 'en' } }, configurable: true, writable: true });
const id = crypto.randomUUID();
file = { name: id + '.json', provider: 'claude', auth_index: id };
requests = [];
mode = 'success';
mockIPC((_command, payload) => {
const request = (payload as { request: { path: string; body: typeof requests[number] } }).request;
expect(request.path).toBe('/requests/api-call');
const body = request.body;
requests.push(body);
if (body.method === 'POST') {
if (mode === 'unknown') throw new Error('timeout');
return { status_code: 200, body: JSON.stringify({ result: 'reset' }) };
}
if (body.url.endsWith('/profile')) return { status_code: 200, body: { organization: { uuid: organization } } };
return { status_code: 200, body: { five_hour: { utilization: 100 }, cedar_ember: mode === 'malformed' ? {} : { ...block, eligible: mode !== 'ineligible' } } };
});
});
afterEach(() => {
clearMocks();
updateQuotaCache({});
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
});
test('loads grant counts and renders a working reset action on both surfaces', async () => {
const quota = await loadQuota(file);
expect(quota).toMatchObject({ status: 'success', resetCredits: 2 });
expect(canResetQuota(file, quota)).toBe(true);
for (const html of [
renderToStaticMarkup(<I18nProvider><AuthFileQuotaPanel file={file} quota={quota} disabled={false} compact dense onRefresh={() => {}} onReset={() => {}} /></I18nProvider>),
renderToStaticMarkup(<I18nProvider><QuotaCard file={file} quota={quota} onRefresh={() => {}} onReset={() => {}} /></I18nProvider>),
]) {
expect(html).toContain('Reset Quota');
expect(html).not.toContain('disabled=""');
expect(html).toContain('2');
}
updateQuotaCache({ [quotaKey(file)]: quota });
expect(await resetQuotaWithConfirmation(file, async () => false)).toBe('cancelled');
expect(requests.filter(r => r.method === 'POST')).toHaveLength(0);
expect(await resetQuotaWithConfirmation(file, async () => true)).toBe('success');
const claims = requests.filter(r => r.method === 'POST');
expect(claims).toHaveLength(1);
expect(claims[0].url).toBe(`https://api.anthropic.com/api/organizations/${organization}/reset_rate_limits`);
expect(JSON.parse(claims[0].data!)).toMatchObject({ program: 'cedar_ember', grant_id: 'test' });
expect(getQuotaCacheSnapshot()[quotaKey(file)].actionResult?.status).toBe('success');
});
test('a missing grant block does not break usage and cannot enable reset', async () => {
mode = 'malformed';
const quota = await loadQuota(file);
expect(quota.status).toBe('success');
expect(quota.resetCreditsError).toBeTruthy();
expect(canResetQuota(file, quota)).toBe(false);
});
test('rechecks eligibility after confirmation before spending', async () => {
updateQuotaCache({ [quotaKey(file)]: await loadQuota(file) });
mode = 'ineligible';
expect(await resetQuotaWithConfirmation(file, async () => true)).toBe('error');
expect(requests.filter(r => r.method === 'POST')).toHaveLength(0);
});
test('ambiguous claims reuse their ID even after quota cache pruning', async () => {
updateQuotaCache({ [quotaKey(file)]: await loadQuota(file) });
mode = 'unknown';
expect(await resetQuotaWithConfirmation(file, async () => true)).toBe('error');
pruneQuotaCache(new Set());
updateQuotaCache({ [quotaKey(file)]: await loadQuota(file) });
mode = 'success';
expect(await resetQuotaWithConfirmation(file, async () => true)).toBe('success');
const claims = requests.filter(r => r.method === 'POST');
expect(claims).toHaveLength(2);
expect(claims[0].data).toBe(claims[1].data);
});
test('unusable, exhausted, paused, expired, and not-limited grants are blocked', () => {
for (const grant of [
{ usableNow: false }, { resetsLeft: 0 }, { paused: true }, { endsAt: '2000-01-01' },
]) {
const value = status();
Object.assign(value.grants[0], grant);
expect(selectResetGrant(value, Date.now())).toBeUndefined();
}
expect(selectResetGrant({ ...status(), atLimit: false }, Date.now())).toBeUndefined();
expect(parseAnthropicResetGrantStatus({ ...block, grants: [{ ...block.grants[0], resets_left: -1 }] })).toBeNull();
});
test('retry expiry and account changes never send a new claim', async () => {
let now = 1000;
let account = organization;
let calls = 0;
const operations = createResetGrantOperations({ revision: () => 0, now: () => now, requestId: () => 'same-id',
readStatus: async () => status(), readOrganization: async () => account,
claim: async () => { calls++; throw new Error('timeout'); },
});
await expect(operations.run('key', 'auth', 'test')).rejects.toThrow('timeout');
account = 'aaaaaaaa-2222-3333-4444-555555555555';
await expect(operations.run('key', 'auth', 'test')).rejects.toThrow('identity');
account = organization;
now += RETRY_WINDOW_MS;
await expect(operations.run('key', 'auth', 'test')).rejects.toThrow('expired');
expect(calls).toBe(1);
});
test('cache invalidation during account verification prevents the claim', async () => {
let revision = 0;
let calls = 0;
const operations = createResetGrantOperations({ revision: () => revision, now: Date.now,
requestId: () => 'request-id', readStatus: async () => status(),
readOrganization: async () => { revision++; return organization; },
claim: async () => { calls++; return 'reset'; },
});
await expect(operations.run('key', 'auth', 'test')).rejects.toThrow('session');
expect(calls).toBe(0);
});
+7
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { I18nProvider } from '../../src/i18n';
import { QuotaCard } from '../../src/pages/QuotaPage';
import { parseAnthropicResetGrantStatus } from '../../src/services/claudeResetGrants';
import { AuthFileQuotaPanel } from '../../src/components/AuthFileQuotaPanel';
import { quotaRowsFor } from '../../src/services/quotaService';
import '../../src/styles/index.css';
@@ -11,6 +13,9 @@ const rows = quotaRowsFor('kimi', {
usage: { limit: 100, remaining: 60 },
});
const claudeFile = { name: 'claude.json', provider: 'claude', auth_index: 'claude-fixture' };
const claudeQuota = { status: 'success' as const, rows, resetCredits: 2, claudeResetGrants: parseAnthropicResetGrantStatus({ eligible: true, at_limit: true, grants: [{ id: 'reserve', resets_total: 2, resets_left: 2, usable_now: true }] })! };
createRoot(document.getElementById('root')!).render(
<I18nProvider>
<main className="content" style={{ width: 1100, padding: 0 }}>
@@ -20,10 +25,12 @@ createRoot(document.getElementById('root')!).render(
</div>
<div className="real-quota-grid">
<QuotaCard file={{ name: 'codex.json', provider: 'codex' }} quota={{ status: 'success', rows, resetCredits: 2, resetCreditsApplicable: 2, plan: 'team', subscriptionActiveUntil: '2030-10-17T13:58:00Z', resetCreditsEarliestExpiry: '2030-10-23T05:10:00Z' }} onRefresh={() => {}} onReset={() => {}} />
<QuotaCard file={claudeFile} quota={claudeQuota} onRefresh={() => {}} onReset={() => {}} />
<QuotaCard file={{ name: 'failed.json', provider: 'kimi' }} quota={{ status: 'error', rows: [], error: 'Quota request failed' }} onRefresh={() => {}} />
<QuotaCard file={{ name: 'loading.json', provider: 'kimi' }} quota={{ status: 'loading', rows: [] }} onRefresh={() => {}} />
<QuotaCard file={{ name: 'idle.json', provider: 'kimi' }} quota={{ status: 'idle', rows: [] }} onRefresh={() => {}} />
</div>
<AuthFileQuotaPanel file={claudeFile} quota={claudeQuota} disabled={false} compact dense onRefresh={() => {}} onReset={() => {}} />
</section>
</main>
</I18nProvider>,
+5 -1
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
const base = process.env.QUOTA_TEST_BASE || 'http://127.0.0.1:1421';
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage();
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
@@ -42,6 +42,10 @@ const base = process.env.QUOTA_TEST_BASE || 'http://127.0.0.1:1421';
assert.ok(text.includes('80%') && text.includes('60%'), `${context}: both Kimi windows remain rendered`);
}
}
const claudeCard = page.locator('.real-quota-card').filter({ has: page.locator('strong[title="claude.json"]') });
assert.equal(await claudeCard.getByRole('button', { name: 'Reset Quota', exact: true }).isEnabled(), true);
assert.equal(await page.locator('.credential-quota-reset').isEnabled(), true);
assert.match(await page.locator('.credential-quota-reset-credits').innerText(), /2/);
console.log('Quota layout: 11 viewport/content combinations passed');
} finally {
await browser.close();
+3 -2
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../src/i18n';
import { QuotaCard } from '../src/pages/QuotaPage';
import { quotaRowsFor } from '../src/services/quotaService';
import { quotaRowsFor, formatQuotaTimestamp } from '../src/services/quotaService';
import type { QuotaState } from '../src/services/quotaService';
const render = (quota: QuotaState, provider = 'codex') => renderToStaticMarkup(
@@ -20,7 +20,8 @@ describe('quota card rendering', () => {
expect(html).not.toContain('No applicable reset credits');
expect(html).toContain('Currently applicable: 0');
expect(html).not.toContain('temporary failure');
expect(html).toContain('Subscription expires');
expect(html).toContain('Expires 01/01/2030, 00:00');
expect(html).not.toContain('Subscription expires:');
});
it.each(['error', 'refresh-error'] as const)('重置结果为 %s 时仍可再次点击重置', (status) => {