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:
1 parent
2b81856521
commit
a3f5d2e54e
17 files changed
+729
-34
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+7
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user