feat(quota): read Claude plan limits from the Claude Code login
Claude quota only worked when the user had signed into Anthropic through OpenCode. Credentials are now discovered from Claude Code itself first: the macOS Keychain entry, then the Linux/WSL credentials file (honouring CLAUDE_CONFIG_DIR), then OpenCode auth.json, then CLAUDE_CODE_OAUTH_TOKEN. All sources stay read-only and the OAuth token is never refreshed: Anthropic allows one live refresh token per client_id, so refreshing here would sign the user out of Claude Code. Credentials are re-read per request instead, and an expired token reports that Claude Code needs a sign-in rather than a bare 401. Usage is now read from the limits[] array, so model-scoped weekly limits work again after Anthropic stopped populating seven_day_sonnet/seven_day_opus, and new limit kinds no longer need a code change. Adds extra-usage spend and the plan name, and holds the last good values through Anthropic's 429s with a cooldown and an account-keyed cache.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const credential = vi.fn();
|
||||
|
||||
vi.mock('./auth.js', () => ({
|
||||
loadClaudeCredential: () => credential(),
|
||||
}));
|
||||
|
||||
import { fetchQuota, isConfigured, resetClaudeQuotaCache } from './index.js';
|
||||
|
||||
const CREDENTIAL = {
|
||||
accessToken: 'access-a',
|
||||
refreshToken: 'refresh-a',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
planLabel: 'max',
|
||||
source: 'keychain',
|
||||
};
|
||||
|
||||
const PAYLOAD = {
|
||||
limits: [
|
||||
{ kind: 'session', percent: 5, resets_at: '2026-08-14T19:10:00Z', scope: null },
|
||||
{ kind: 'weekly_all', percent: 4, resets_at: '2026-08-20T15:00:00Z', scope: null },
|
||||
],
|
||||
};
|
||||
|
||||
const jsonResponse = (body) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
const errorResponse = (status, headers = {}) => ({
|
||||
ok: false,
|
||||
status,
|
||||
headers: new Headers(headers),
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetClaudeQuotaCache();
|
||||
credential.mockReturnValue(CREDENTIAL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('Claude quota provider', () => {
|
||||
it('reports not configured when no credential source has a token', async () => {
|
||||
credential.mockReturnValue(null);
|
||||
|
||||
expect(isConfigured()).toBe(false);
|
||||
const result = await fetchQuota();
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('returns windows and the plan label from the credential', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(PAYLOAD)));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.planLabel).toBe('max');
|
||||
expect(result.usage.windows['5h'].usedPercent).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps serving the last good values while Anthropic rate limits', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(PAYLOAD))
|
||||
.mockResolvedValueOnce(errorResponse(429, { 'retry-after': '120' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchQuota();
|
||||
const rateLimited = await fetchQuota();
|
||||
|
||||
expect(rateLimited.ok).toBe(true);
|
||||
expect(rateLimited.usage.windows['5h'].usedPercent).toBe(5);
|
||||
});
|
||||
|
||||
it('does not call Anthropic again while the rate-limit cooldown is active', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(PAYLOAD))
|
||||
.mockResolvedValueOnce(errorResponse(429));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchQuota();
|
||||
await fetchQuota();
|
||||
await fetchQuota();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never shows one account cached values after the credential changes', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(PAYLOAD))
|
||||
.mockResolvedValueOnce(errorResponse(429));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchQuota();
|
||||
credential.mockReturnValue({ ...CREDENTIAL, accessToken: 'access-b', refreshToken: 'refresh-b', planLabel: null });
|
||||
const afterSwitch = await fetchQuota();
|
||||
|
||||
expect(afterSwitch.ok).toBe(false);
|
||||
expect(afterSwitch.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('explains an expired session instead of reporting a bare 401', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(errorResponse(401)));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toContain('Claude Code');
|
||||
});
|
||||
|
||||
it('surfaces a network failure instead of an empty successful result', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe('socket hang up');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user