Files
openchamber/packages/web/server/lib/quota/providers/claude/index.js
T

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;
};