152 lines
4.4 KiB
JavaScript
152 lines
4.4 KiB
JavaScript
/**
|
|
* 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;
|
|
let pendingFetch = null;
|
|
|
|
const fingerprintOf = (credential) =>
|
|
createHash('sha256').update(`${credential.accessToken}\0${credential.refreshToken ?? ''}`).digest('hex');
|
|
|
|
const cooldownFromHeader = (response) => {
|
|
const raw = response.headers.get('retry-after');
|
|
const retryAfter = Number(raw);
|
|
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
return Math.min(retryAfter * 1000, MAX_COOLDOWN_MS);
|
|
}
|
|
const retryAt = raw ? Date.parse(raw) : Number.NaN;
|
|
if (Number.isFinite(retryAt) && retryAt > Date.now()) {
|
|
return Math.min(retryAt - Date.now(), MAX_COOLDOWN_MS);
|
|
}
|
|
return DEFAULT_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());
|
|
|
|
const fetchQuotaUncoalesced = 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. Retrying soon.');
|
|
}
|
|
|
|
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. Retrying soon.');
|
|
}
|
|
|
|
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
|
|
});
|
|
};
|
|
|
|
export const fetchQuota = () => {
|
|
if (pendingFetch) return pendingFetch;
|
|
|
|
const request = fetchQuotaUncoalesced();
|
|
const pending = request.finally(() => {
|
|
if (pendingFetch === pending) pendingFetch = null;
|
|
});
|
|
pendingFetch = pending;
|
|
return pendingFetch;
|
|
};
|
|
|
|
/** Test seam: clears the rate-limit cache between cases. */
|
|
export const resetClaudeQuotaCache = () => {
|
|
cachedUsage = null;
|
|
cooldownUntil = 0;
|
|
pendingFetch = null;
|
|
};
|