Files
openchamber/packages/web/server/lib/quota/providers/claude/index.js
T
Bohdan Triapitsyn b77a30cd88 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.
2026-08-14 20:49:59 +03:00

132 lines
3.9 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;
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;
};