Files
claude-hud/dist/usage-api.js
2026-03-23 13:36:34 +11:00

945 lines
35 KiB
JavaScript

import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as net from 'net';
import * as tls from 'tls';
import * as https from 'https';
import { execFileSync } from 'child_process';
import { createHash } from 'crypto';
import { createDebug } from './debug.js';
import { getClaudeConfigDir, getHudPluginDir } from './claude-config-dir.js';
const debug = createDebug('usage');
const LEGACY_KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials';
// File-based cache (HUD runs as new process each render, so in-memory cache won't persist)
const CACHE_TTL_MS = 5 * 60_000; // 5 minutes — matches Anthropic usage API rate limit window
const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests
const CACHE_RATE_LIMITED_BASE_MS = 60_000; // 60s base for 429 backoff
const CACHE_RATE_LIMITED_MAX_MS = 5 * 60_000; // 5 min max backoff
const CACHE_LOCK_STALE_MS = 30_000;
const CACHE_LOCK_WAIT_MS = 2_000;
const CACHE_LOCK_POLL_MS = 50;
const KEYCHAIN_TIMEOUT_MS = 3000;
const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting
const USAGE_API_TIMEOUT_MS_DEFAULT = 15_000;
export const USAGE_API_USER_AGENT = 'claude-code/2.1';
/**
* Check if user is using a custom API endpoint instead of the default Anthropic API.
* When using custom providers (e.g., via cc-switch), the OAuth usage API is not applicable.
*/
function isUsingCustomApiEndpoint(env = process.env) {
const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || env.ANTHROPIC_API_BASE_URL?.trim();
// No custom endpoint configured - using default Anthropic API
if (!baseUrl) {
return false;
}
try {
return new URL(baseUrl).origin !== 'https://api.anthropic.com';
}
catch {
return true;
}
}
function getCachePath(homeDir) {
return path.join(getHudPluginDir(homeDir), '.usage-cache.json');
}
function getCacheLockPath(homeDir) {
return path.join(getHudPluginDir(homeDir), '.usage-cache.lock');
}
function hydrateCacheData(data) {
// JSON.stringify converts Date to ISO string, so we need to reconvert on read.
// new Date() handles both Date objects and ISO strings safely.
if (data.fiveHourResetAt) {
data.fiveHourResetAt = new Date(data.fiveHourResetAt);
}
if (data.sevenDayResetAt) {
data.sevenDayResetAt = new Date(data.sevenDayResetAt);
}
return data;
}
function getRateLimitedTtlMs(count) {
// Exponential backoff: 60s, 120s, 240s, capped at 5 min
return Math.min(CACHE_RATE_LIMITED_BASE_MS * Math.pow(2, Math.max(0, count - 1)), CACHE_RATE_LIMITED_MAX_MS);
}
function getRateLimitedRetryUntil(cache) {
if (cache.data.apiError !== 'rate-limited') {
return null;
}
if (cache.retryAfterUntil && cache.retryAfterUntil > cache.timestamp) {
return cache.retryAfterUntil;
}
if (cache.rateLimitedCount && cache.rateLimitedCount > 0) {
return cache.timestamp + getRateLimitedTtlMs(cache.rateLimitedCount);
}
return null;
}
function withRateLimitedSyncing(data) {
return {
...data,
apiError: 'rate-limited',
};
}
function readCacheState(homeDir, now, ttls) {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath))
return null;
const content = fs.readFileSync(cachePath, 'utf8');
const cache = JSON.parse(content);
// Only serve lastGoodData during rate-limit backoff. Other failures should remain visible.
const displayData = (cache.data.apiError === 'rate-limited' && cache.lastGoodData)
? withRateLimitedSyncing(cache.lastGoodData)
: cache.data;
const rateLimitedRetryUntil = getRateLimitedRetryUntil(cache);
if (rateLimitedRetryUntil && now < rateLimitedRetryUntil) {
return { data: hydrateCacheData(displayData), timestamp: cache.timestamp, isFresh: true };
}
const ttl = cache.data.apiUnavailable ? ttls.failureCacheTtlMs : ttls.cacheTtlMs;
return {
data: hydrateCacheData(displayData),
timestamp: cache.timestamp,
isFresh: now - cache.timestamp < ttl,
};
}
catch {
return null;
}
}
function readRateLimitedCount(homeDir) {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath))
return 0;
const content = fs.readFileSync(cachePath, 'utf8');
const cache = JSON.parse(content);
return cache.rateLimitedCount ?? 0;
}
catch {
return 0;
}
}
function readLastGoodData(homeDir) {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath))
return null;
const content = fs.readFileSync(cachePath, 'utf8');
const cache = JSON.parse(content);
return cache.lastGoodData ? hydrateCacheData(cache.lastGoodData) : null;
}
catch {
return null;
}
}
function readCachedPlanName(homeDir) {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath))
return null;
const content = fs.readFileSync(cachePath, 'utf8');
const cache = JSON.parse(content);
return cache.data.planName ?? cache.lastGoodData?.planName ?? null;
}
catch {
return null;
}
}
function readCache(homeDir, now, ttls) {
const cache = readCacheState(homeDir, now, ttls);
return cache?.isFresh ? cache.data : null;
}
function writeCache(homeDir, data, timestamp, opts) {
try {
const cachePath = getCachePath(homeDir);
const cacheDir = path.dirname(cachePath);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const cache = { data, timestamp };
if (opts?.rateLimitedCount && opts.rateLimitedCount > 0) {
cache.rateLimitedCount = opts.rateLimitedCount;
}
if (opts?.retryAfterUntil) {
cache.retryAfterUntil = opts.retryAfterUntil;
}
if (opts?.lastGoodData) {
cache.lastGoodData = opts.lastGoodData;
}
fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
}
catch {
// Ignore cache write failures
}
}
function readLockTimestamp(lockPath) {
try {
if (!fs.existsSync(lockPath))
return null;
const raw = fs.readFileSync(lockPath, 'utf8').trim();
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : null;
}
catch {
return null;
}
}
function tryAcquireCacheLock(homeDir) {
const lockPath = getCacheLockPath(homeDir);
const cacheDir = path.dirname(lockPath);
try {
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const fd = fs.openSync(lockPath, 'wx');
try {
fs.writeFileSync(fd, String(Date.now()), 'utf8');
}
finally {
fs.closeSync(fd);
}
return 'acquired';
}
catch (error) {
const maybeError = error;
if (maybeError.code !== 'EEXIST') {
debug('Usage cache lock unavailable, continuing without coordination:', maybeError.message);
return 'unsupported';
}
}
const lockTimestamp = readLockTimestamp(lockPath);
// Unparseable timestamp — use mtime to distinguish a crash leftover from an active writer.
if (lockTimestamp === null) {
try {
const lockStat = fs.statSync(lockPath);
if (Date.now() - lockStat.mtimeMs < CACHE_LOCK_STALE_MS) {
return 'busy';
}
}
catch {
return tryAcquireCacheLock(homeDir);
}
try {
fs.unlinkSync(lockPath);
}
catch {
return 'busy';
}
return tryAcquireCacheLock(homeDir);
}
if (lockTimestamp != null && Date.now() - lockTimestamp > CACHE_LOCK_STALE_MS) {
try {
fs.unlinkSync(lockPath);
}
catch {
return 'busy';
}
return tryAcquireCacheLock(homeDir);
}
return 'busy';
}
function releaseCacheLock(homeDir) {
try {
const lockPath = getCacheLockPath(homeDir);
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
}
catch {
// Ignore lock cleanup failures
}
}
async function waitForFreshCache(homeDir, now, ttls, timeoutMs = CACHE_LOCK_WAIT_MS) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS));
const cached = readCache(homeDir, now(), ttls);
if (cached) {
return cached;
}
if (!fs.existsSync(getCacheLockPath(homeDir))) {
break;
}
}
return readCache(homeDir, now(), ttls);
}
const defaultDeps = {
homeDir: () => os.homedir(),
fetchApi: fetchUsageApi,
now: () => Date.now(),
readKeychain: readKeychainCredentials,
ttls: { cacheTtlMs: CACHE_TTL_MS, failureCacheTtlMs: CACHE_FAILURE_TTL_MS },
};
/**
* Get OAuth usage data from Anthropic API.
* Returns null if user is an API user (no OAuth credentials) or credentials are expired.
* Returns { apiUnavailable: true, ... } if API call fails (to show warning in HUD).
*
* Uses file-based cache since HUD runs as a new process each render (~300ms).
* Cache TTL is configurable via usage.cacheTtlSeconds / usage.failureCacheTtlSeconds in config.json
* (defaults: 60s for success, 15s for failures).
*/
export async function getUsage(overrides = {}) {
const deps = { ...defaultDeps, ...overrides };
const now = deps.now();
const homeDir = deps.homeDir();
// Skip usage API if user is using a custom provider
if (isUsingCustomApiEndpoint()) {
debug('Skipping usage API: custom API endpoint configured');
return null;
}
// Check file-based cache first
const cacheState = readCacheState(homeDir, now, deps.ttls);
if (cacheState?.isFresh) {
return cacheState.data;
}
let holdsCacheLock = false;
const lockStatus = tryAcquireCacheLock(homeDir);
if (lockStatus === 'busy') {
if (cacheState) {
return cacheState.data;
}
return await waitForFreshCache(homeDir, deps.now, deps.ttls);
}
holdsCacheLock = lockStatus === 'acquired';
try {
const refreshedCache = readCache(homeDir, deps.now(), deps.ttls);
if (refreshedCache) {
return refreshedCache;
}
const credentials = readCredentials(homeDir, now, deps.readKeychain);
if (!credentials) {
return null;
}
const { accessToken, subscriptionType } = credentials;
// Determine plan name from subscriptionType
let planName = getPlanName(subscriptionType);
if (!planName) {
// Only fall back to cache when subscriptionType is genuinely missing or
// empty after an OAuth refresh. Explicit values like "api" should remain
// API users with no usage display.
if (!subscriptionType.trim()) {
const cached = readCacheState(homeDir, now, deps.ttls);
if (cached?.data?.planName) {
planName = cached.data.planName;
}
}
if (!planName) {
return null;
}
}
// Fetch usage from API
const apiResult = await deps.fetchApi(accessToken);
if (!apiResult.data) {
const isRateLimited = apiResult.error === 'rate-limited';
const prevCount = readRateLimitedCount(homeDir);
const rateLimitedCount = isRateLimited ? prevCount + 1 : 0;
const retryAfterUntil = isRateLimited && apiResult.retryAfterSec
? now + apiResult.retryAfterSec * 1000
: undefined;
const backoffOpts = {
rateLimitedCount: isRateLimited ? rateLimitedCount : undefined,
retryAfterUntil,
};
const failureResult = {
planName,
fiveHour: null,
sevenDay: null,
fiveHourResetAt: null,
sevenDayResetAt: null,
apiUnavailable: true,
apiError: apiResult.error,
};
if (isRateLimited) {
const staleCache = readCacheState(homeDir, now, deps.ttls);
const lastGood = readLastGoodData(homeDir);
const goodData = (staleCache && !staleCache.data.apiUnavailable)
? staleCache.data
: lastGood;
if (goodData) {
// Preserve the backoff state in cache, but keep rendering the last successful values
// with a syncing hint so stale data is visible to the user.
writeCache(homeDir, failureResult, now, { ...backoffOpts, lastGoodData: goodData });
return withRateLimitedSyncing(goodData);
}
}
writeCache(homeDir, failureResult, now, backoffOpts);
return failureResult;
}
// Parse response - API returns 0-100 percentage directly
// Clamp to 0-100 and handle NaN/Infinity
const fiveHour = parseUtilization(apiResult.data.five_hour?.utilization);
const sevenDay = parseUtilization(apiResult.data.seven_day?.utilization);
const fiveHourResetAt = parseDate(apiResult.data.five_hour?.resets_at);
const sevenDayResetAt = parseDate(apiResult.data.seven_day?.resets_at);
const result = {
planName,
fiveHour,
sevenDay,
fiveHourResetAt,
sevenDayResetAt,
};
// Write to file cache — also store as lastGoodData for rate-limit resilience
writeCache(homeDir, result, now, { lastGoodData: result });
return result;
}
catch (error) {
debug('getUsage failed:', error);
return null;
}
finally {
if (holdsCacheLock) {
releaseCacheLock(homeDir);
}
}
}
/**
* Get path for keychain failure backoff cache.
* Separate from usage cache to track keychain-specific failures.
*/
function getKeychainBackoffPath(homeDir) {
return path.join(getHudPluginDir(homeDir), '.keychain-backoff');
}
/**
* Check if we're in keychain backoff period (recent failure/timeout).
* Prevents re-prompting user on every render cycle.
*/
function isKeychainBackoff(homeDir, now) {
try {
const backoffPath = getKeychainBackoffPath(homeDir);
if (!fs.existsSync(backoffPath))
return false;
const timestamp = parseInt(fs.readFileSync(backoffPath, 'utf8'), 10);
return now - timestamp < KEYCHAIN_BACKOFF_MS;
}
catch {
return false;
}
}
/**
* Record keychain failure for backoff.
*/
function recordKeychainFailure(homeDir, now) {
try {
const backoffPath = getKeychainBackoffPath(homeDir);
const dir = path.dirname(backoffPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(backoffPath, String(now), 'utf8');
}
catch {
// Ignore write failures
}
}
/**
* Determine the macOS Keychain service name for Claude Code credentials.
* Claude Code uses the default service for ~/.claude and a hashed suffix for custom config directories.
*/
export function getKeychainServiceName(configDir, homeDir) {
const normalizedConfigDir = path.normalize(path.resolve(configDir));
const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude')));
if (normalizedConfigDir === normalizedDefaultDir) {
return LEGACY_KEYCHAIN_SERVICE_NAME;
}
const hash = createHash('sha256').update(normalizedConfigDir).digest('hex').slice(0, 8);
return `${LEGACY_KEYCHAIN_SERVICE_NAME}-${hash}`;
}
export function getKeychainServiceNames(configDir, homeDir, env = process.env) {
const serviceNames = [getKeychainServiceName(configDir, homeDir)];
const envConfigDir = env.CLAUDE_CONFIG_DIR?.trim();
if (envConfigDir) {
const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude')));
const normalizedEnvDir = path.normalize(path.resolve(envConfigDir));
if (normalizedEnvDir === normalizedDefaultDir) {
serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME);
}
else {
const envHash = createHash('sha256').update(envConfigDir).digest('hex').slice(0, 8);
serviceNames.push(`${LEGACY_KEYCHAIN_SERVICE_NAME}-${envHash}`);
}
}
serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME);
return [...new Set(serviceNames)];
}
function isMissingKeychainItemError(error) {
if (!error || typeof error !== 'object')
return false;
const maybeError = error;
if (maybeError.status === 44)
return true;
const message = typeof maybeError.message === 'string' ? maybeError.message.toLowerCase() : '';
if (message.includes('could not be found in the keychain'))
return true;
const stderr = typeof maybeError.stderr === 'string'
? maybeError.stderr.toLowerCase()
: Buffer.isBuffer(maybeError.stderr)
? maybeError.stderr.toString('utf8').toLowerCase()
: '';
return stderr.includes('could not be found in the keychain');
}
export function resolveKeychainCredentials(serviceNames, now, loadService, accountName) {
let shouldBackoff = false;
let allowGenericFallback = Boolean(accountName);
for (const serviceName of serviceNames) {
try {
const keychainData = accountName
? loadService(serviceName, accountName)
: loadService(serviceName);
if (accountName)
allowGenericFallback = false;
const trimmedKeychainData = keychainData.trim();
if (!trimmedKeychainData)
continue;
const data = JSON.parse(trimmedKeychainData);
const credentials = parseCredentialsData(data, now);
if (credentials) {
return { credentials, shouldBackoff: false };
}
}
catch (error) {
if (!isMissingKeychainItemError(error)) {
if (accountName)
allowGenericFallback = false;
shouldBackoff = true;
}
}
}
if (!accountName || !allowGenericFallback) {
return { credentials: null, shouldBackoff };
}
for (const serviceName of serviceNames) {
try {
const keychainData = loadService(serviceName).trim();
if (!keychainData)
continue;
const data = JSON.parse(keychainData);
const credentials = parseCredentialsData(data, now);
if (credentials) {
return { credentials, shouldBackoff: false };
}
}
catch (error) {
if (!isMissingKeychainItemError(error)) {
shouldBackoff = true;
}
}
}
return { credentials: null, shouldBackoff };
}
function getKeychainAccountName() {
try {
const username = os.userInfo().username.trim();
return username || null;
}
catch {
return null;
}
}
/**
* Read credentials from macOS Keychain.
* Claude Code stores OAuth credentials in the macOS Keychain with profile-specific service names.
* Returns null if not on macOS or credentials not found.
*
* Security: Uses execFileSync with absolute path to avoid shell injection and PATH hijacking.
*/
function readKeychainCredentials(now, homeDir) {
// Only available on macOS
if (process.platform !== 'darwin') {
return null;
}
// Check backoff to avoid re-prompting on every render after a failure
if (isKeychainBackoff(homeDir, now)) {
debug('Keychain in backoff period, skipping');
return null;
}
try {
const configDir = getClaudeConfigDir(homeDir);
const serviceNames = getKeychainServiceNames(configDir, homeDir);
const accountName = getKeychainAccountName();
debug('Trying keychain service names:', serviceNames);
if (accountName) {
debug('Trying keychain account name:', accountName);
}
const resolved = resolveKeychainCredentials(serviceNames, now, (serviceName, lookupAccountName) => execFileSync('/usr/bin/security', lookupAccountName
? ['find-generic-password', '-s', serviceName, '-a', lookupAccountName, '-w']
: ['find-generic-password', '-s', serviceName, '-w'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }), accountName);
if (resolved.credentials) {
return resolved.credentials;
}
if (resolved.shouldBackoff) {
recordKeychainFailure(homeDir, now);
}
return null;
}
catch (error) {
// Security: Only log error message, not full error object (may contain stdout/stderr with tokens)
const message = error instanceof Error ? error.message : 'unknown error';
debug('Failed to read from macOS Keychain:', message);
// Record failure for backoff to avoid re-prompting
recordKeychainFailure(homeDir, now);
return null;
}
}
/**
* Read credentials from file (legacy method).
* Older versions of Claude Code stored credentials in {CLAUDE_CONFIG_DIR}/.credentials.json.
*/
function readFileCredentials(homeDir, now) {
const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json');
if (!fs.existsSync(credentialsPath)) {
return null;
}
try {
const content = fs.readFileSync(credentialsPath, 'utf8');
const data = JSON.parse(content);
return parseCredentialsData(data, now);
}
catch (error) {
debug('Failed to read credentials file:', error);
return null;
}
}
function readFileSubscriptionType(homeDir) {
const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json');
if (!fs.existsSync(credentialsPath)) {
return null;
}
try {
const content = fs.readFileSync(credentialsPath, 'utf8');
const data = JSON.parse(content);
const subscriptionType = data.claudeAiOauth?.subscriptionType;
const normalizedSubscriptionType = typeof subscriptionType === 'string'
? subscriptionType.trim()
: '';
if (!normalizedSubscriptionType) {
return null;
}
return normalizedSubscriptionType;
}
catch (error) {
debug('Failed to read file subscriptionType:', error);
return null;
}
}
/**
* Parse and validate credentials data from either Keychain or file.
*/
function parseCredentialsData(data, now) {
const accessToken = data.claudeAiOauth?.accessToken;
const subscriptionType = data.claudeAiOauth?.subscriptionType ?? '';
if (!accessToken) {
return null;
}
// Check if token is expired (expiresAt is Unix ms timestamp)
// Use != null to handle expiresAt=0 correctly (would be expired)
const expiresAt = data.claudeAiOauth?.expiresAt;
if (expiresAt != null && expiresAt <= now) {
debug('OAuth token expired');
return null;
}
return { accessToken, subscriptionType };
}
/**
* Read OAuth credentials, trying macOS Keychain first (Claude Code 2.x),
* then falling back to file-based credentials (older versions).
*
* Token priority: Keychain token is authoritative (Claude Code 2.x stores current token there).
* SubscriptionType: Can be supplemented from file if keychain lacks it (display-only field).
*/
function readCredentials(homeDir, now, readKeychain) {
// Try macOS Keychain first (Claude Code 2.x)
const keychainCreds = readKeychain(now, homeDir);
if (keychainCreds) {
if (keychainCreds.subscriptionType) {
debug('Using credentials from macOS Keychain');
return keychainCreds;
}
// Keychain has token but no subscriptionType - try to supplement from file
const fileSubscriptionType = readFileSubscriptionType(homeDir);
if (fileSubscriptionType) {
debug('Using keychain token with file subscriptionType');
return {
accessToken: keychainCreds.accessToken,
subscriptionType: fileSubscriptionType,
};
}
// No subscriptionType available - use keychain token anyway
debug('Using keychain token without subscriptionType');
return keychainCreds;
}
// Fall back to file-based credentials (older versions or non-macOS)
const fileCreds = readFileCredentials(homeDir, now);
if (fileCreds) {
debug('Using credentials from file');
return fileCreds;
}
return null;
}
function getPlanName(subscriptionType) {
const lower = subscriptionType.toLowerCase();
if (lower.includes('max'))
return 'Max';
if (lower.includes('pro'))
return 'Pro';
if (lower.includes('team'))
return 'Team';
// API users don't have subscriptionType or have 'api'
if (!subscriptionType || lower.includes('api'))
return null;
// Unknown subscription type - show it capitalized
return subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1);
}
export function getUsagePlanNameFallback(homeDir = os.homedir()) {
const subscriptionType = readFileSubscriptionType(homeDir);
if (subscriptionType) {
return getPlanName(subscriptionType);
}
const cachedPlanName = readCachedPlanName(homeDir);
return cachedPlanName ?? null;
}
/** Parse utilization value, clamping to 0-100 and handling NaN/Infinity */
function parseUtilization(value) {
if (value == null)
return null;
if (!Number.isFinite(value))
return null; // Handles NaN and Infinity
return Math.round(Math.max(0, Math.min(100, value)));
}
/** Parse ISO date string safely, returning null for invalid dates */
function parseDate(dateStr) {
if (!dateStr)
return null;
const date = new Date(dateStr);
// Check for Invalid Date
if (isNaN(date.getTime())) {
debug('Invalid date string:', dateStr);
return null;
}
return date;
}
export function getUsageApiTimeoutMs(env = process.env) {
const raw = env.CLAUDE_HUD_USAGE_TIMEOUT_MS?.trim();
if (!raw)
return USAGE_API_TIMEOUT_MS_DEFAULT;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
debug('Invalid CLAUDE_HUD_USAGE_TIMEOUT_MS value:', raw);
return USAGE_API_TIMEOUT_MS_DEFAULT;
}
return parsed;
}
export function isNoProxy(hostname, env = process.env) {
const noProxy = env.NO_PROXY ?? env.no_proxy;
if (!noProxy)
return false;
const host = hostname.toLowerCase();
return noProxy.split(',').some((entry) => {
const pattern = entry.trim().toLowerCase();
if (!pattern)
return false;
if (pattern === '*')
return true;
if (host === pattern)
return true;
const suffix = pattern.startsWith('.') ? pattern : `.${pattern}`;
return host.endsWith(suffix);
});
}
export function getProxyUrl(hostname, env = process.env) {
if (isNoProxy(hostname, env)) {
debug('Proxy bypassed by NO_PROXY for host:', hostname);
return null;
}
const proxyEnv = env.HTTPS_PROXY
?? env.https_proxy
?? env.ALL_PROXY
?? env.all_proxy
?? env.HTTP_PROXY
?? env.http_proxy;
if (!proxyEnv)
return null;
try {
const proxyUrl = new URL(proxyEnv);
if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:') {
debug('Unsupported proxy protocol:', proxyUrl.protocol);
return null;
}
return proxyUrl;
}
catch {
debug('Invalid proxy URL:', proxyEnv);
return null;
}
}
function createProxyTunnelAgent(proxyUrl) {
const proxyHost = proxyUrl.hostname;
const proxyPort = Number.parseInt(proxyUrl.port || (proxyUrl.protocol === 'https:' ? '443' : '80'), 10);
const proxyAuth = proxyUrl.username
? `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password || '')}`).toString('base64')}`
: null;
return new class extends https.Agent {
createConnection(options, callback) {
const targetHost = String(options.host ?? options.hostname ?? 'localhost');
const targetPort = Number(options.port) || 443;
let settled = false;
const settle = (err, socket) => {
if (settled)
return;
settled = true;
callback?.(err, socket);
};
const proxySocket = proxyUrl.protocol === 'https:'
? tls.connect({ host: proxyHost, port: proxyPort, servername: proxyHost })
: net.connect(proxyPort, proxyHost);
proxySocket.once('error', (error) => {
settle(error, proxySocket);
});
proxySocket.once('connect', () => {
const connectHeaders = [
`CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
`Host: ${targetHost}:${targetPort}`,
];
if (proxyAuth) {
connectHeaders.push(`Proxy-Authorization: ${proxyAuth}`);
}
connectHeaders.push('', '');
proxySocket.write(connectHeaders.join('\r\n'));
let responseBuffer = Buffer.alloc(0);
const onData = (chunk) => {
responseBuffer = Buffer.concat([responseBuffer, chunk]);
const headerEndIndex = responseBuffer.indexOf('\r\n\r\n');
if (headerEndIndex === -1)
return;
proxySocket.removeListener('data', onData);
const headerText = responseBuffer.subarray(0, headerEndIndex).toString('utf8');
const statusLine = headerText.split('\r\n')[0] ?? '';
if (!/^HTTP\/1\.[01] 200 /.test(statusLine)) {
const error = new Error(`Proxy CONNECT rejected: ${statusLine || 'unknown status'}`);
proxySocket.destroy(error);
settle(error, proxySocket);
return;
}
const tlsSocket = tls.connect({
socket: proxySocket,
servername: String(options.servername ?? targetHost),
rejectUnauthorized: getProxyTunnelRejectUnauthorized(options.rejectUnauthorized),
}, () => {
settle(null, tlsSocket);
});
tlsSocket.once('error', (error) => {
settle(error, tlsSocket);
});
};
proxySocket.on('data', onData);
});
// Must not return the socket here. In Node.js _http_agent.js, createSocket()
// calls: `if (newSocket) oncreate(null, newSocket)` — returning a truthy value
// causes the HTTP request to be written to the raw proxy socket immediately,
// before the CONNECT tunnel is established. Only deliver the final TLS socket
// asynchronously via the callback after the CONNECT handshake succeeds.
return undefined;
}
}();
}
export function getProxyTunnelRejectUnauthorized(rejectUnauthorized, env = process.env) {
if (rejectUnauthorized === false) {
return false;
}
return env.NODE_TLS_REJECT_UNAUTHORIZED !== '0';
}
function fetchUsageApi(accessToken) {
return new Promise((resolve) => {
const host = 'api.anthropic.com';
const timeoutMs = getUsageApiTimeoutMs();
const proxyUrl = getProxyUrl(host);
const options = {
hostname: host,
path: '/api/oauth/usage',
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'anthropic-beta': 'oauth-2025-04-20',
'User-Agent': USAGE_API_USER_AGENT,
},
timeout: timeoutMs,
agent: proxyUrl ? createProxyTunnelAgent(proxyUrl) : undefined,
};
if (proxyUrl) {
debug('Using proxy for usage API:', proxyUrl.origin);
}
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk.toString();
});
res.on('end', () => {
if (res.statusCode !== 200) {
debug('API returned non-200 status:', res.statusCode);
// Use a distinct error key for 429 so cache/render can handle it specially
const error = res.statusCode === 429
? 'rate-limited'
: res.statusCode ? `http-${res.statusCode}` : 'http-error';
const retryAfterSec = res.statusCode === 429
? parseRetryAfterSeconds(res.headers['retry-after'])
: undefined;
if (retryAfterSec) {
debug('Retry-After:', retryAfterSec, 'seconds');
}
resolve({ data: null, error, retryAfterSec });
return;
}
try {
const parsed = JSON.parse(data);
resolve({ data: parsed });
}
catch (error) {
debug('Failed to parse API response:', error);
resolve({ data: null, error: 'parse' });
}
});
});
req.on('error', (error) => {
debug('API request error:', error);
resolve({ data: null, error: 'network' });
});
req.on('timeout', () => {
debug('API request timeout');
req.destroy();
resolve({ data: null, error: 'timeout' });
});
req.end();
});
}
export function parseRetryAfterSeconds(raw, nowMs = Date.now()) {
const value = Array.isArray(raw) ? raw[0] : raw;
if (!value)
return undefined;
const parsedSeconds = Number.parseInt(value, 10);
if (Number.isFinite(parsedSeconds) && parsedSeconds > 0) {
return parsedSeconds;
}
const retryAtMs = Date.parse(value);
if (!Number.isFinite(retryAtMs)) {
return undefined;
}
const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000);
return retryAfterSeconds > 0 ? retryAfterSeconds : undefined;
}
// Export for testing
export function clearCache(homeDir) {
if (homeDir) {
try {
const cachePath = getCachePath(homeDir);
if (fs.existsSync(cachePath)) {
fs.unlinkSync(cachePath);
}
const lockPath = getCacheLockPath(homeDir);
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
}
catch {
// Ignore
}
}
}
//# sourceMappingURL=usage-api.js.map