Files
EJClaw/src/claude-usage.ts

280 lines
7.6 KiB
TypeScript

/**
* Claude Usage API
*
* Fetches usage data directly from the Anthropic OAuth API.
* Supports multiple tokens for rotation-aware usage checking.
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import { logger } from './logger.js';
import { getCurrentToken, getAllTokens } from './token-rotation.js';
const USAGE_CACHE_FILE = path.join(DATA_DIR, 'claude-usage-cache.json');
const PROFILE_ENDPOINT = 'https://api.anthropic.com/api/oauth/profile';
export interface ClaudeUsageData {
five_hour?: { utilization: number; resets_at: string };
seven_day?: { utilization: number; resets_at: string };
seven_day_sonnet?: { utilization: number; resets_at: string };
seven_day_opus?: { utilization: number; resets_at: string };
}
const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
const FETCH_TIMEOUT_MS = 10_000;
interface UsageApiResponse {
five_hour?: { utilization: number; resets_at?: string };
seven_day?: { utilization: number; resets_at?: string };
seven_day_sonnet?: { utilization: number; resets_at?: string };
seven_day_opus?: { utilization: number; resets_at?: string };
}
function mapWindow(w?: {
utilization: number;
resets_at?: string;
}): { utilization: number; resets_at: string } | undefined {
if (!w) return undefined;
return { utilization: w.utilization, resets_at: w.resets_at || '' };
}
// ── Disk cache for usage data (survives restarts, 429s) ──
interface UsageCacheEntry {
usage: ClaudeUsageData;
fetchedAt: number;
}
let usageDiskCache: Record<string, UsageCacheEntry> = {};
let diskCacheLoaded = false;
function loadUsageDiskCache(): void {
if (diskCacheLoaded) return;
diskCacheLoaded = true;
try {
if (fs.existsSync(USAGE_CACHE_FILE)) {
usageDiskCache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf-8'));
}
} catch {
/* start fresh */
}
}
function saveUsageDiskCache(): void {
try {
fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(usageDiskCache));
} catch {
/* best effort */
}
}
function cacheKey(token: string): string {
return token.slice(0, 12);
}
// Rate limit: at most one API call per token per 5 minutes
const MIN_FETCH_INTERVAL_MS = 300_000;
async function fetchUsageForToken(
token: string,
): Promise<ClaudeUsageData | null> {
loadUsageDiskCache();
// Return cached data if fetched recently (avoid API rate-limit)
const key = cacheKey(token);
const cached = usageDiskCache[key];
if (cached && Date.now() - cached.fetchedAt < MIN_FETCH_INTERVAL_MS) {
return cached.usage;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(USAGE_ENDPOINT, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20',
'User-Agent': 'ejclaw/1.0',
},
signal: controller.signal,
});
if (res.status === 401) {
logger.warn('Claude usage API: token expired or invalid (401)');
return null;
}
if (res.status === 429) {
logger.warn(
'Claude usage API: rate limited (429), returning cached data',
);
return usageDiskCache[cacheKey(token)]?.usage ?? null;
}
if (!res.ok) {
logger.warn(
{ status: res.status },
`Claude usage API: unexpected status ${res.status}`,
);
return usageDiskCache[cacheKey(token)]?.usage ?? null;
}
const data = (await res.json()) as UsageApiResponse;
const result: ClaudeUsageData = {
five_hour: mapWindow(data.five_hour),
seven_day: mapWindow(data.seven_day),
seven_day_sonnet: mapWindow(data.seven_day_sonnet),
seven_day_opus: mapWindow(data.seven_day_opus),
};
// Persist to disk cache
usageDiskCache[cacheKey(token)] = { usage: result, fetchedAt: Date.now() };
saveUsageDiskCache();
return result;
} catch (err) {
if ((err as Error).name === 'AbortError') {
logger.warn('Claude usage API: request timed out');
} else {
logger.warn({ err }, 'Claude usage API: fetch failed');
}
return usageDiskCache[cacheKey(token)]?.usage ?? null;
} finally {
clearTimeout(timer);
}
}
/**
* Fetch Claude usage via the OAuth API.
* Uses the current active token from rotation.
*/
export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
const token = getCurrentToken() || process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (!token) {
logger.debug('No Claude OAuth token available for usage check');
return null;
}
return fetchUsageForToken(token);
}
export interface ClaudeAccountProfile {
email: string;
planType: string; // "max", "pro", "free"
}
const profileCache = new Map<number, ClaudeAccountProfile>();
async function fetchProfileForToken(
token: string,
): Promise<ClaudeAccountProfile | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(PROFILE_ENDPOINT, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20',
},
signal: controller.signal,
});
if (!res.ok) return null;
const data = (await res.json()) as {
account?: {
email?: string;
has_claude_max?: boolean;
has_claude_pro?: boolean;
};
organization?: { organization_type?: string };
};
const orgType = data.organization?.organization_type || '';
const planType = data.account?.has_claude_max
? 'max'
: data.account?.has_claude_pro
? 'pro'
: orgType.replace('claude_', '') || 'free';
return {
email: data.account?.email || '?',
planType,
};
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/**
* Fetch profiles for all Claude tokens (cached, called once on startup).
*/
export async function fetchAllClaudeProfiles(): Promise<void> {
const allTokens = getAllTokens();
for (const t of allTokens) {
const profile = await fetchProfileForToken(t.token);
if (profile) {
profileCache.set(t.index, profile);
logger.info(
{ account: t.index + 1, plan: profile.planType, email: profile.email },
`Claude account #${t.index + 1}: ${profile.planType}`,
);
}
}
}
export function getClaudeProfile(
index: number,
): ClaudeAccountProfile | undefined {
return profileCache.get(index);
}
export interface ClaudeAccountUsage {
index: number;
masked: string;
isActive: boolean;
isRateLimited: boolean;
usage: ClaudeUsageData | null;
}
/**
* Fetch usage for ALL configured tokens.
* Returns per-account usage for dashboard display.
*/
export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
const allTokens = getAllTokens();
logger.debug({ tokenCount: allTokens.length }, 'fetchAllClaudeUsage called');
if (allTokens.length === 0) {
// Single token fallback
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (!token) return [];
const usage = await fetchUsageForToken(token);
return [
{
index: 0,
masked: `${token.slice(0, 20)}...${token.slice(-4)}`,
isActive: true,
isRateLimited: false,
usage,
},
];
}
const results: ClaudeAccountUsage[] = [];
for (const t of allTokens) {
const usage = await fetchUsageForToken(t.token);
results.push({
index: t.index,
masked: t.masked,
isActive: t.isActive,
isRateLimited: t.isRateLimited,
usage,
});
}
return results;
}
// Legacy alias
export const fetchClaudeUsageViaCli = fetchClaudeUsage;