/** * Claude subscription quota. * * Reports the plan limits Claude Code itself is bound by (rolling session * window, weekly windows, model-scoped weekly windows, and paid extra usage) * using the OAuth credential Claude Code already holds. * * @module quota/providers/claude */ import { createHash } from 'crypto'; import { buildResult } from '../../utils/index.js'; import { loadClaudeCredential } from './auth.js'; import { toClaudeUsage } from './transforms.js'; export const providerId = 'claude'; export const providerName = 'Claude'; export const aliases = ['anthropic', 'claude']; const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; const OAUTH_BETA_HEADER = 'oauth-2025-04-20'; const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; const MAX_COOLDOWN_MS = 60 * 60 * 1000; /** * Last good payload, kept only to survive Anthropic's aggressive rate limiting. * Keyed by credential fingerprint so a second account never sees the first * account's numbers. * * @type {{ fingerprint: string, usage: object, planLabel: string|null }|null} */ let cachedUsage = null; let cooldownUntil = 0; const fingerprintOf = (credential) => createHash('sha256').update(`${credential.accessToken}${credential.refreshToken ?? ''}`).digest('hex'); const cooldownFromHeader = (response) => { const retryAfter = Number(response.headers.get('retry-after')); if (!Number.isFinite(retryAfter) || retryAfter <= 0) return DEFAULT_COOLDOWN_MS; return Math.min(retryAfter * 1000, MAX_COOLDOWN_MS); }; const cachedResultFor = (fingerprint, planLabel) => { if (!cachedUsage || cachedUsage.fingerprint !== fingerprint) return null; return buildResult({ providerId, providerName, ok: true, configured: true, usage: cachedUsage.usage, planLabel: planLabel ?? cachedUsage.planLabel }); }; const failure = (error, { configured = true } = {}) => buildResult({ providerId, providerName, ok: false, configured, error }); export const isConfigured = () => Boolean(loadClaudeCredential()); export const fetchQuota = async () => { const credential = loadClaudeCredential(); if (!credential) { return failure('Not configured', { configured: false }); } const fingerprint = fingerprintOf(credential); if (cachedUsage && cachedUsage.fingerprint !== fingerprint) { cachedUsage = null; cooldownUntil = 0; } if (Date.now() < cooldownUntil) { return cachedResultFor(fingerprint, credential.planLabel) ?? failure('Rate limited by Anthropic. Retrying shortly.'); } let response; try { response = await fetch(USAGE_URL, { method: 'GET', headers: { Authorization: `Bearer ${credential.accessToken}`, 'anthropic-beta': OAUTH_BETA_HEADER } }); } catch (error) { return failure(error instanceof Error ? error.message : 'Request failed'); } if (response.status === 429) { cooldownUntil = Date.now() + cooldownFromHeader(response); return cachedResultFor(fingerprint, credential.planLabel) ?? failure('Rate limited by Anthropic. Retrying shortly.'); } if (response.status === 401 || response.status === 403) { return failure('Claude session expired. Open Claude Code to sign in again.'); } if (!response.ok) { return failure(`API error: ${response.status}`); } let payload; try { payload = await response.json(); } catch { return failure('Unexpected response from Anthropic'); } const { windows, models } = toClaudeUsage(payload); const usage = Object.keys(models).length > 0 ? { windows, models } : { windows }; cachedUsage = { fingerprint, usage, planLabel: credential.planLabel }; return buildResult({ providerId, providerName, ok: true, configured: true, usage, planLabel: credential.planLabel }); }; /** Test seam: clears the rate-limit cache between cases. */ export const resetClaudeQuotaCache = () => { cachedUsage = null; cooldownUntil = 0; };