feat: Claude usage API + multi-account dashboard display

- Replace expect/CLI hack with direct HTTP API call
  (GET api.anthropic.com/api/oauth/usage with OAuth token)
- Add fetchAllClaudeUsage() for per-token usage fetching
- Export getAllTokens() from token-rotation for usage queries
- Dashboard shows each account separately (Claude1, Claude2)
  with  for active token, 🚫 for rate-limited
This commit is contained in:
Eyejoker
2026-03-23 22:15:06 +09:00
parent d378598f2e
commit 83b25fb7bd
4 changed files with 181 additions and 218 deletions

View File

@@ -1,53 +1,28 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseClaudeUsagePanel } from './claude-usage.js'; import type { ClaudeUsageData } from './claude-usage.js';
describe('parseClaudeUsagePanel', () => { describe('ClaudeUsageData', () => {
it('parses session and weekly usage from Claude CLI panel output', () => { it('represents the API response structure correctly', () => {
const sample = ` const data: ClaudeUsageData = {
Settings: Status Config Usage five_hour: { utilization: 45.2, resets_at: '2026-03-23T17:00:00Z' },
Loading usage data... 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 expect(data.five_hour?.utilization).toBe(45.2);
████ 4% used expect(data.seven_day?.utilization).toBe(72.1);
Resets in 8m (Asia/Seoul) expect(data.seven_day_sonnet?.utilization).toBe(60.0);
expect(data.seven_day_opus?.utilization).toBe(80.0);
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)',
},
});
}); });
it('converts percent left into used percent', () => { it('allows partial data (only five_hour)', () => {
const sample = ` const data: ClaudeUsageData = {
Current session five_hour: { utilization: 10, resets_at: '2026-03-23T17:00:00Z' },
60% left };
Resets in 1h
`;
expect(parseClaudeUsagePanel(sample)).toEqual({ expect(data.five_hour?.utilization).toBe(10);
five_hour: { expect(data.seven_day).toBeUndefined();
utilization: 40,
resets_at: 'Resets in 1h',
},
});
}); });
}); });

View File

@@ -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 { logger } from './logger.js';
import { getCurrentToken, getAllTokens } from './token-rotation.js';
export interface ClaudeUsageData { export interface ClaudeUsageData {
five_hour?: { utilization: number; resets_at: string }; five_hour?: { utilization: number; resets_at: string };
seven_day?: { 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 USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; const FETCH_TIMEOUT_MS = 10_000;
const EXPECT_PROGRAM = ` interface UsageApiResponse {
set timeout 20 five_hour?: { utilization: number; resets_at?: string };
log_user 1 seven_day?: { utilization: number; resets_at?: string };
match_max 200000 seven_day_sonnet?: { utilization: number; resets_at?: string };
set binary $env(CLAUDE_BINARY) seven_day_opus?: { utilization: number; resets_at?: string };
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);
} }
function parsePercent(windowText: string): number | null { function mapWindow(
const match = windowText.match(/(\d{1,3})%\s*(used|left)\b/i); w?: { utilization: number; resets_at?: string },
if (!match) return null; ): { utilization: number; resets_at: string } | undefined {
const value = parseInt(match[1], 10); if (!w) return undefined;
if (Number.isNaN(value)) return null; return { utilization: w.utilization, resets_at: w.resets_at || '' };
return match[2].toLowerCase() === 'left' ? 100 - value : value;
} }
function parseWindow( async function fetchUsageForToken(
lines: string[], token: string,
labels: string[], ): Promise<ClaudeUsageData | null> {
): { utilization: number; resets_at: string } | null { const controller = new AbortController();
const normalizedLabels = labels.map((label) => label.toLowerCase()); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].toLowerCase();
if (!normalizedLabels.some((label) => line.includes(label))) continue;
const windowLines = lines.slice(i, i + 6); try {
const windowText = windowLines.join('\n'); const res = await fetch(USAGE_ENDPOINT, {
const utilization = parsePercent(windowText); method: 'GET',
if (utilization === null) continue; headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20',
'User-Agent': 'ejclaw/1.0',
},
signal: controller.signal,
});
const resetLine = windowLines.find((candidate) => if (res.status === 401) {
candidate.toLowerCase().startsWith('resets'), 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 { return {
utilization, five_hour: mapWindow(data.five_hour),
resets_at: resetLine || '', 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); * Fetch Claude usage via the OAuth API.
if (lines.length === 0) return null; * Uses the current active token from rotation.
if ( */
lines.some((line) => export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
line.toLowerCase().includes('failed to load usage data'), const token =
) getCurrentToken() || process.env.CLAUDE_CODE_OAUTH_TOKEN;
) { if (!token) {
logger.debug('No Claude OAuth token available for usage check');
return null; return null;
} }
return fetchUsageForToken(token);
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 } : {}),
};
} }
export async function fetchClaudeUsageViaCli( export interface ClaudeAccountUsage {
binary = 'claude', index: number;
): Promise<ClaudeUsageData | null> { masked: string;
return new Promise((resolve) => { isActive: boolean;
let output = ''; isRateLimited: boolean;
let finished = false; usage: ClaudeUsageData | null;
const finish = (value: ClaudeUsageData | null) => {
if (finished) return;
finished = true;
clearTimeout(timer);
resolve(value);
};
let proc: ReturnType<typeof spawn> | null = null;
try {
proc = spawn('expect', ['-c', EXPECT_PROGRAM], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...(process.env as Record<string, string>),
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);
});
});
} }
/**
* Fetch usage for ALL configured tokens.
* Returns per-account usage for dashboard display.
*/
export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
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;

View File

@@ -29,7 +29,10 @@ export function initTokenRotation(): void {
const single = process.env.CLAUDE_CODE_OAUTH_TOKEN; const single = process.env.CLAUDE_CODE_OAUTH_TOKEN;
const raw = multi const raw = multi
? multi.split(',').map((t) => t.trim()).filter(Boolean) ? multi
.split(',')
.map((t) => t.trim())
.filter(Boolean)
: single : single
? [single] ? [single]
: []; : [];
@@ -99,6 +102,18 @@ export function getTokenCount(): number {
return tokens.length; 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. */ /** Diagnostic info. */
export function getTokenRotationInfo(): { export function getTokenRotationInfo(): {
total: number; total: number;

View File

@@ -5,8 +5,10 @@ import path from 'path';
import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js'; import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
import { import {
fetchClaudeUsageViaCli, fetchClaudeUsage as fetchClaudeUsageApi,
fetchAllClaudeUsage,
type ClaudeUsageData, type ClaudeUsageData,
type ClaudeAccountUsage,
} from './claude-usage.js'; } from './claude-usage.js';
import { import {
composeDashboardContent, composeDashboardContent,
@@ -349,10 +351,10 @@ async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
return null; return null;
} }
const cliUsage = await fetchClaudeUsageViaCli(); const apiUsage = await fetchClaudeUsageApi();
if (cliUsage) { if (apiUsage) {
usageApi429Streak = 0; usageApi429Streak = 0;
return cliUsage; return apiUsage;
} }
try { try {
@@ -520,20 +522,18 @@ async function buildUsageContent(): Promise<string> {
); );
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED; const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
const [liveClaudeUsage, codexUsage] = await Promise.all([ const [liveClaudeAccounts, codexUsage] = await Promise.all([
shouldFetchClaudeUsage && !hasActiveClaudeWork shouldFetchClaudeUsage && !hasActiveClaudeWork
? fetchClaudeUsage() ? fetchAllClaudeUsage()
: Promise.resolve(null), : Promise.resolve(null),
fetchCodexUsage(), fetchCodexUsage(),
]); ]);
const claudeUsage = shouldFetchClaudeUsage // Update cache with first account's data for backward compat
? liveClaudeUsage || cachedClaudeUsageData
: null;
const claudeUsageIsCached = const claudeUsageIsCached =
shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData; shouldFetchClaudeUsage && !liveClaudeAccounts && !!cachedClaudeUsageData;
if (shouldFetchClaudeUsage && liveClaudeUsage) { if (shouldFetchClaudeUsage && liveClaudeAccounts?.length) {
cachedClaudeUsageData = liveClaudeUsage; cachedClaudeUsageData = liveClaudeAccounts[0].usage;
} }
const lines: string[] = ['📊 *사용량*']; const lines: string[] = ['📊 *사용량*'];
@@ -551,11 +551,19 @@ async function buildUsageContent(): Promise<string> {
}; };
const rows: UsageRow[] = []; const rows: UsageRow[] = [];
if (claudeUsage) { const claudeAccounts = liveClaudeAccounts
const h5 = claudeUsage.five_hour; || (cachedClaudeUsageData ? [{ index: 0, masked: '', isActive: true, isRateLimited: false, usage: cachedClaudeUsageData }] : []);
const d7 = claudeUsage.seven_day;
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({ rows.push({
name: claudeUsageIsCached ? 'Claude*' : 'Claude', name: label,
h5pct: h5 h5pct: h5
? h5.utilization > 1 ? h5.utilization > 1
? Math.round(h5.utilization) ? Math.round(h5.utilization)