Files
Codewalkers/apps/server/agent/accounts/usage.ts
Lukas May a0152ce238 fix: Convert sync file I/O to async in credential manager and usage module
Replace readFileSync/writeFileSync/mkdirSync with async equivalents from
fs/promises in default-credential-manager.ts and usage.ts to stop blocking
the Node.js event loop during credential read/write operations.
2026-03-04 12:26:37 +01:00

376 lines
12 KiB
TypeScript

import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import type { Account } from '../../db/schema.js';
import type { AgentInfo } from '../types.js';
import type { AccountCredentialManager } from '../credentials/types.js';
import { createModuleLogger } from '../../logger/index.js';
import { getAccountConfigDir } from './paths.js';
import { setupAccountConfigDir } from './setup.js';
const log = createModuleLogger('account-usage');
const USAGE_API_URL = 'https://api.anthropic.com/api/oauth/usage';
const TOKEN_REFRESH_URL = 'https://console.anthropic.com/v1/oauth/token';
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const TOKEN_REFRESH_BUFFER_MS = 300_000; // 5 minutes
export interface OAuthCredentials {
accessToken: string;
refreshToken: string | null;
expiresAt: number | null; // ms epoch, null for setup tokens
subscriptionType: string | null;
rateLimitTier: string | null;
}
export interface UsageTier {
utilization: number;
resets_at: string | null;
}
export interface AccountUsage {
five_hour: UsageTier | null;
seven_day: UsageTier | null;
seven_day_sonnet: UsageTier | null;
seven_day_opus: UsageTier | null;
extra_usage: {
is_enabled: boolean;
monthly_limit: number | null;
used_credits: number | null;
utilization: number | null;
} | null;
}
export interface AccountHealthResult {
id: string;
email: string;
provider: string;
credentialsValid: boolean;
tokenValid: boolean;
tokenExpiresAt: string | null;
subscriptionType: string | null;
error: string | null;
usage: AccountUsage | null;
isExhausted: boolean;
exhaustedUntil: string | null;
lastUsedAt: string | null;
agentCount: number;
activeAgentCount: number;
}
async function readCredentials(configDir: string): Promise<OAuthCredentials | null> {
try {
const credPath = join(configDir, '.credentials.json');
if (!existsSync(credPath)) return null;
const raw = await readFile(credPath, 'utf-8');
const parsed = JSON.parse(raw);
const oauth = parsed.claudeAiOauth;
if (!oauth || !oauth.accessToken) return null;
return {
accessToken: oauth.accessToken,
refreshToken: oauth.refreshToken ?? null,
expiresAt: oauth.expiresAt ?? null,
subscriptionType: oauth.subscriptionType ?? null,
rateLimitTier: oauth.rateLimitTier ?? null,
};
} catch {
return null;
}
}
function isTokenExpired(credentials: OAuthCredentials): boolean {
if (!credentials.expiresAt) return false; // Setup tokens without expiry are treated as non-expired
return credentials.expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS;
}
/**
* Write credentials back to the config directory.
* Matches ccswitch's update_credentials_with_token() behavior.
*/
async function writeCredentials(
configDir: string,
accessToken: string,
refreshToken: string,
expiresIn: number,
): Promise<void> {
const credPath = join(configDir, '.credentials.json');
// Read existing credentials to preserve other fields
let existing: Record<string, unknown> = {};
try {
if (existsSync(credPath)) {
existing = JSON.parse(await readFile(credPath, 'utf-8'));
}
} catch {
// Start fresh if can't read
}
// Calculate expiry in milliseconds (matching ccswitch behavior)
const nowMs = Date.now();
const expiresAt = nowMs + (expiresIn * 1000);
// Update claudeAiOauth section
const claudeAiOauth = (existing.claudeAiOauth as Record<string, unknown>) ?? {};
claudeAiOauth.accessToken = accessToken;
claudeAiOauth.refreshToken = refreshToken;
claudeAiOauth.expiresAt = expiresAt;
existing.claudeAiOauth = claudeAiOauth;
// Ensure directory exists
await mkdir(dirname(credPath), { recursive: true });
// Write back (compact JSON for consistency with ccswitch)
await writeFile(credPath, JSON.stringify(existing));
log.debug({ configDir }, 'credentials written after token refresh');
}
async function refreshToken(
refreshTokenStr: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number } | null> {
try {
const response = await fetch(TOKEN_REFRESH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshTokenStr,
client_id: OAUTH_CLIENT_ID,
scope: 'user:inference user:profile',
}),
});
if (!response.ok) return null;
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
};
} catch {
return null;
}
}
type FetchUsageResult =
| { ok: true; usage: AccountUsage }
| { ok: false; status: number; statusText: string }
| { ok: false; status: 0; statusText: string };
async function fetchUsage(accessToken: string): Promise<FetchUsageResult> {
try {
const response = await fetch(USAGE_API_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'anthropic-beta': 'oauth-2025-04-20',
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return { ok: false, status: response.status, statusText: response.statusText };
}
const data = await response.json();
return {
ok: true,
usage: {
five_hour: data.five_hour ?? null,
seven_day: data.seven_day ?? null,
seven_day_sonnet: data.seven_day_sonnet ?? null,
seven_day_opus: data.seven_day_opus ?? null,
extra_usage: data.extra_usage ?? null,
},
};
} catch (err) {
return { ok: false, status: 0, statusText: err instanceof Error ? err.message : 'Network error' };
}
}
export async function checkAccountHealth(
account: Account,
agents: AgentInfo[],
credentialManager?: AccountCredentialManager,
workspaceRoot?: string,
): Promise<AccountHealthResult> {
const configDir = workspaceRoot ? getAccountConfigDir(workspaceRoot, account.id) : null;
const accountAgents = agents.filter((a) => a.accountId === account.id);
const activeAgents = accountAgents.filter(
(a) => a.status === 'running' || a.status === 'waiting_for_input',
);
const base: AccountHealthResult = {
id: account.id,
email: account.email,
provider: account.provider,
credentialsValid: false,
tokenValid: false,
tokenExpiresAt: null,
subscriptionType: null,
error: null,
usage: null,
isExhausted: account.isExhausted,
exhaustedUntil: account.exhaustedUntil?.toISOString() ?? null,
lastUsedAt: account.lastUsedAt?.toISOString() ?? null,
agentCount: accountAgents.length,
activeAgentCount: activeAgents.length,
};
if (!configDir) {
return { ...base, error: 'Cannot derive config dir: workspaceRoot not provided' };
}
// Ensure DB credentials are written to disk so file-based checks can find them
if (account.configJson && account.credentials) {
try {
setupAccountConfigDir(configDir, {
configJson: JSON.parse(account.configJson),
credentials: account.credentials,
});
} catch (err) {
log.warn({ accountId: account.id, err: err instanceof Error ? err.message : String(err) }, 'failed to sync DB credentials to disk');
}
}
try {
// Use credential manager if provided, otherwise fall back to direct functions
let accessToken: string;
let currentExpiresAt: number | null;
let subscriptionType: string | null = null;
if (credentialManager) {
const result = await credentialManager.ensureValid(configDir, account.id);
if (!result.valid || !result.credentials) {
return {
...base,
credentialsValid: result.credentials !== null,
error: result.error ?? 'Credentials validation failed',
};
}
accessToken = result.credentials.accessToken;
currentExpiresAt = result.credentials.expiresAt;
subscriptionType = result.credentials.subscriptionType;
} else {
// Legacy path: direct function calls
const credentials = await readCredentials(configDir);
if (!credentials) {
return {
...base,
error: 'Credentials file not found or unreadable',
};
}
accessToken = credentials.accessToken;
currentExpiresAt = credentials.expiresAt;
subscriptionType = credentials.subscriptionType;
if (isTokenExpired(credentials)) {
if (!credentials.refreshToken) {
log.warn({ accountId: account.id }, 'setup token expired, no refresh token');
return {
...base,
credentialsValid: true,
error: 'Setup token expired, no refresh token available',
};
}
log.info({ accountId: account.id, email: account.email }, 'token expired, refreshing');
const refreshed = await refreshToken(credentials.refreshToken);
if (!refreshed) {
log.warn({ accountId: account.id }, 'token refresh failed');
return {
...base,
credentialsValid: true,
error: 'Token expired and refresh failed',
};
}
accessToken = refreshed.accessToken;
// Persist the refreshed credentials back to disk
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
await writeCredentials(configDir, accessToken, newRefreshToken, refreshed.expiresIn);
currentExpiresAt = Date.now() + (refreshed.expiresIn * 1000);
log.info({ accountId: account.id, expiresIn: refreshed.expiresIn }, 'token refreshed and persisted');
}
}
const isSetupToken = !currentExpiresAt;
const usageResult = await fetchUsage(accessToken);
if (!usageResult.ok) {
const statusDetail = usageResult.status > 0
? `HTTP ${usageResult.status} ${usageResult.statusText}`
: usageResult.statusText;
if (isSetupToken) {
// Setup tokens often can't query the usage API — not a hard error
return {
...base,
credentialsValid: true,
tokenValid: true,
tokenExpiresAt: null,
subscriptionType,
error: `Usage API unavailable for setup token (${statusDetail}). Run \`claude\` with this account to complete OAuth setup.`,
};
}
return {
...base,
credentialsValid: true,
error: `Usage API request failed: ${statusDetail}`,
};
}
return {
...base,
credentialsValid: true,
tokenValid: true,
tokenExpiresAt: currentExpiresAt ? new Date(currentExpiresAt).toISOString() : null,
subscriptionType,
usage: usageResult.usage,
};
} catch (err) {
return {
...base,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Ensure account credentials are valid and refreshed if needed.
* Call this before spawning an agent to ensure the credentials file
* has fresh tokens that the agent subprocess can use.
*
* Returns true if credentials are valid (or were successfully refreshed).
* Returns false if credentials are missing or refresh failed.
*
* @deprecated Use AccountCredentialManager.ensureValid() instead for event emission support.
*/
export async function ensureAccountCredentials(configDir: string): Promise<boolean> {
const credentials = await readCredentials(configDir);
if (!credentials) {
log.warn({ configDir }, 'no credentials found');
return false;
}
if (!isTokenExpired(credentials)) {
log.debug({ configDir }, 'credentials valid, no refresh needed');
return true;
}
if (!credentials.refreshToken) {
log.error({ configDir }, 'setup token expired, no refresh token available');
return false;
}
log.info({ configDir }, 'credentials expired, refreshing before spawn');
const refreshed = await refreshToken(credentials.refreshToken);
if (!refreshed) {
log.error({ configDir }, 'failed to refresh credentials');
return false;
}
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
await writeCredentials(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn);
log.info({ configDir, expiresIn: refreshed.expiresIn }, 'credentials refreshed before spawn');
return true;
}