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 { 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 { const credPath = join(configDir, '.credentials.json'); // Read existing credentials to preserve other fields let existing: Record = {}; 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) ?? {}; 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 { 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 { 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 { await 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 { 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; }