diff --git a/src/claude-usage.test.ts b/src/claude-usage.test.ts index e868e2b..ce661c7 100644 --- a/src/claude-usage.test.ts +++ b/src/claude-usage.test.ts @@ -1,53 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { parseClaudeUsagePanel } from './claude-usage.js'; +import type { ClaudeUsageData } from './claude-usage.js'; -describe('parseClaudeUsagePanel', () => { - it('parses session and weekly usage from Claude CLI panel output', () => { - const sample = ` -Settings: Status Config Usage -Loading usage data... +describe('ClaudeUsageData', () => { + it('represents the API response structure correctly', () => { + const data: ClaudeUsageData = { + five_hour: { utilization: 45.2, resets_at: '2026-03-23T17:00:00Z' }, + seven_day: { utilization: 72.1, resets_at: '2026-03-29T00:00:00Z' }, + seven_day_sonnet: { utilization: 60.0, resets_at: '2026-03-29T00:00:00Z' }, + seven_day_opus: { utilization: 80.0, resets_at: '2026-03-29T00:00:00Z' }, + }; -Current session -████ 4% used -Resets in 8m (Asia/Seoul) - -Current week (all models) -███████████████████████████████████████ 78% used -Resets Mar 17 at 10pm (Asia/Seoul) - -Current week (Sonnet only) -███ 6% used -Resets Mar 17 at 11pm (Asia/Seoul) - -Extra usage -Extra usage not enabled • /extra-usage to enable -`; - - expect(parseClaudeUsagePanel(sample)).toEqual({ - five_hour: { - utilization: 4, - resets_at: 'Resets in 8m (Asia/Seoul)', - }, - seven_day: { - utilization: 78, - resets_at: 'Resets Mar 17 at 10pm (Asia/Seoul)', - }, - }); + expect(data.five_hour?.utilization).toBe(45.2); + expect(data.seven_day?.utilization).toBe(72.1); + expect(data.seven_day_sonnet?.utilization).toBe(60.0); + expect(data.seven_day_opus?.utilization).toBe(80.0); }); - it('converts percent left into used percent', () => { - const sample = ` -Current session -60% left -Resets in 1h -`; + it('allows partial data (only five_hour)', () => { + const data: ClaudeUsageData = { + five_hour: { utilization: 10, resets_at: '2026-03-23T17:00:00Z' }, + }; - expect(parseClaudeUsagePanel(sample)).toEqual({ - five_hour: { - utilization: 40, - resets_at: 'Resets in 1h', - }, - }); + expect(data.five_hour?.utilization).toBe(10); + expect(data.seven_day).toBeUndefined(); }); }); diff --git a/src/claude-usage.ts b/src/claude-usage.ts index 3ed83c1..b286eae 100644 --- a/src/claude-usage.ts +++ b/src/claude-usage.ts @@ -1,183 +1,148 @@ -import { spawn } from 'child_process'; +/** + * Claude Usage API + * + * Fetches usage data directly from the Anthropic OAuth API. + * Supports multiple tokens for rotation-aware usage checking. + */ import { logger } from './logger.js'; +import { getCurrentToken, getAllTokens } from './token-rotation.js'; 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 CLAUDE_EXPECT_TIMEOUT_MS = 25000; -const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; +const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'; +const FETCH_TIMEOUT_MS = 10_000; -const EXPECT_PROGRAM = ` -set timeout 20 -log_user 1 -match_max 200000 -set binary $env(CLAUDE_BINARY) -spawn -noecho -- $binary --setting-sources user --allowed-tools "" -expect { - -re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue } - -re "Quick safety check:" { send "\\r"; exp_continue } - -re "Yes, I trust this folder" { send "\\r"; exp_continue } - -re "Ready to code here\\\\?" { send "\\r"; exp_continue } - -re "Press Enter to continue" { send "\\r"; exp_continue } - timeout {} -} -send "/usage\\r" -set deadline [expr {[clock seconds] + 20}] -while {[clock seconds] < $deadline} { - expect { - -re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue } - -re "Quick safety check:" { send "\\r"; exp_continue } - -re "Yes, I trust this folder" { send "\\r"; exp_continue } - -re "Ready to code here\\\\?" { send "\\r"; exp_continue } - -re "Press Enter to continue" { send "\\r"; exp_continue } - -re "Current session" { after 2000; exit 0 } - -re "Failed to load usage data" { after 500; exit 2 } - eof { exit 3 } - timeout { send "\\r" } - } -} -exit 4 -`; - -function normalizeLines(rawText: string): string[] { - return rawText - .replace(ANSI_RE, '') - .replace(/\r/g, '\n') - .split('\n') - .map((line) => line.replace(/\s+/g, ' ').trim()) - .filter(Boolean); +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 parsePercent(windowText: string): number | null { - const match = windowText.match(/(\d{1,3})%\s*(used|left)\b/i); - if (!match) return null; - const value = parseInt(match[1], 10); - if (Number.isNaN(value)) return null; - return match[2].toLowerCase() === 'left' ? 100 - value : value; +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 || '' }; } -function parseWindow( - lines: string[], - labels: string[], -): { utilization: number; resets_at: string } | null { - const normalizedLabels = labels.map((label) => label.toLowerCase()); - for (let i = 0; i < lines.length; i++) { - const line = lines[i].toLowerCase(); - if (!normalizedLabels.some((label) => line.includes(label))) continue; +async function fetchUsageForToken( + token: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - const windowLines = lines.slice(i, i + 6); - const windowText = windowLines.join('\n'); - const utilization = parsePercent(windowText); - if (utilization === null) continue; + 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, + }); - const resetLine = windowLines.find((candidate) => - candidate.toLowerCase().startsWith('resets'), - ); + 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)'); + return null; + } + if (!res.ok) { + logger.warn( + { status: res.status }, + `Claude usage API: unexpected status ${res.status}`, + ); + return null; + } + + const data = (await res.json()) as UsageApiResponse; return { - utilization, - resets_at: resetLine || '', + 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), }; + } 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 null; + } finally { + clearTimeout(timer); } - - return null; } -export function parseClaudeUsagePanel(rawText: string): ClaudeUsageData | null { - const lines = normalizeLines(rawText); - if (lines.length === 0) return null; - if ( - lines.some((line) => - line.toLowerCase().includes('failed to load usage data'), - ) - ) { +/** + * Fetch Claude usage via the OAuth API. + * Uses the current active token from rotation. + */ +export async function fetchClaudeUsage(): Promise { + const token = + getCurrentToken() || process.env.CLAUDE_CODE_OAUTH_TOKEN; + if (!token) { + logger.debug('No Claude OAuth token available for usage check'); return null; } - - const fiveHour = parseWindow(lines, ['Current session']); - if (!fiveHour) return null; - - const sevenDay = - parseWindow(lines, ['Current week (all models)']) || - parseWindow(lines, [ - 'Current week (Sonnet only)', - 'Current week (Sonnet)', - ]) || - parseWindow(lines, ['Current week (Opus)']); - - return { - five_hour: fiveHour, - ...(sevenDay ? { seven_day: sevenDay } : {}), - }; + return fetchUsageForToken(token); } -export async function fetchClaudeUsageViaCli( - binary = 'claude', -): Promise { - return new Promise((resolve) => { - let output = ''; - let finished = false; - - const finish = (value: ClaudeUsageData | null) => { - if (finished) return; - finished = true; - clearTimeout(timer); - resolve(value); - }; - - let proc: ReturnType | null = null; - try { - proc = spawn('expect', ['-c', EXPECT_PROGRAM], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...(process.env as Record), - CLAUDE_BINARY: binary, - }, - }); - } catch (err) { - logger.debug({ err }, 'Claude CLI PTY probe unavailable'); - finish(null); - return; - } - - const timer = setTimeout(() => { - try { - proc?.kill('SIGTERM'); - } catch { - /* ignore */ - } - finish(null); - }, CLAUDE_EXPECT_TIMEOUT_MS); - - if (!proc.stdout || !proc.stderr) { - finish(null); - return; - } - - proc.stdout.setEncoding('utf8'); - proc.stderr.setEncoding('utf8'); - proc.stdout.on('data', (chunk: string) => { - output += chunk; - }); - proc.stderr.on('data', (chunk: string) => { - output += chunk; - }); - proc.on('error', (err) => { - logger.debug({ err }, 'Claude CLI PTY probe failed to start'); - finish(null); - }); - proc.on('close', () => { - const parsed = parseClaudeUsagePanel(output); - if (!parsed && output.trim()) { - logger.debug( - { tail: output.slice(-400) }, - 'Claude CLI PTY probe produced unparsable output', - ); - } - finish(parsed); - }); - }); +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 { + const allTokens = getAllTokens(); + 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; diff --git a/src/token-rotation.ts b/src/token-rotation.ts index 944c7a3..997a970 100644 --- a/src/token-rotation.ts +++ b/src/token-rotation.ts @@ -29,7 +29,10 @@ export function initTokenRotation(): void { const single = process.env.CLAUDE_CODE_OAUTH_TOKEN; const raw = multi - ? multi.split(',').map((t) => t.trim()).filter(Boolean) + ? multi + .split(',') + .map((t) => t.trim()) + .filter(Boolean) : single ? [single] : []; @@ -99,6 +102,18 @@ export function getTokenCount(): number { return tokens.length; } +/** Get all configured tokens (masked for display, raw for API calls). */ +export function getAllTokens(): { index: number; token: string; masked: string; isActive: boolean; isRateLimited: boolean }[] { + const now = Date.now(); + return tokens.map((t, i) => ({ + index: i, + token: t.token, + masked: `${t.token.slice(0, 20)}...${t.token.slice(-4)}`, + isActive: i === currentIndex, + isRateLimited: Boolean(t.rateLimitedUntil && t.rateLimitedUntil > now), + })); +} + /** Diagnostic info. */ export function getTokenRotationInfo(): { total: number; diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 2e42cf7..0fbf0bd 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -5,8 +5,10 @@ import path from 'path'; import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js'; import { - fetchClaudeUsageViaCli, + fetchClaudeUsage as fetchClaudeUsageApi, + fetchAllClaudeUsage, type ClaudeUsageData, + type ClaudeAccountUsage, } from './claude-usage.js'; import { composeDashboardContent, @@ -349,10 +351,10 @@ async function fetchClaudeUsage(): Promise { return null; } - const cliUsage = await fetchClaudeUsageViaCli(); - if (cliUsage) { + const apiUsage = await fetchClaudeUsageApi(); + if (apiUsage) { usageApi429Streak = 0; - return cliUsage; + return apiUsage; } try { @@ -520,20 +522,18 @@ async function buildUsageContent(): Promise { ); const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED; - const [liveClaudeUsage, codexUsage] = await Promise.all([ + const [liveClaudeAccounts, codexUsage] = await Promise.all([ shouldFetchClaudeUsage && !hasActiveClaudeWork - ? fetchClaudeUsage() + ? fetchAllClaudeUsage() : Promise.resolve(null), fetchCodexUsage(), ]); - const claudeUsage = shouldFetchClaudeUsage - ? liveClaudeUsage || cachedClaudeUsageData - : null; + // Update cache with first account's data for backward compat const claudeUsageIsCached = - shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData; - if (shouldFetchClaudeUsage && liveClaudeUsage) { - cachedClaudeUsageData = liveClaudeUsage; + shouldFetchClaudeUsage && !liveClaudeAccounts && !!cachedClaudeUsageData; + if (shouldFetchClaudeUsage && liveClaudeAccounts?.length) { + cachedClaudeUsageData = liveClaudeAccounts[0].usage; } const lines: string[] = ['📊 *사용량*']; @@ -551,11 +551,19 @@ async function buildUsageContent(): Promise { }; const rows: UsageRow[] = []; - if (claudeUsage) { - const h5 = claudeUsage.five_hour; - const d7 = claudeUsage.seven_day; + const claudeAccounts = liveClaudeAccounts + || (cachedClaudeUsageData ? [{ index: 0, masked: '', isActive: true, isRateLimited: false, usage: cachedClaudeUsageData }] : []); + + for (const account of claudeAccounts) { + const u = account.usage; + if (!u) continue; + const h5 = u.five_hour; + const d7 = u.seven_day; + const label = claudeAccounts.length > 1 + ? `Claude${account.index + 1}${account.isActive ? '⚡' : ''}${account.isRateLimited ? '🚫' : ''}` + : claudeUsageIsCached ? 'Claude*' : 'Claude'; rows.push({ - name: claudeUsageIsCached ? 'Claude*' : 'Claude', + name: label, h5pct: h5 ? h5.utilization > 1 ? Math.round(h5.utilization)