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.
130 lines
3.7 KiB
JavaScript
130 lines
3.7 KiB
JavaScript
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');
|
|
});
|
|
});
|