feat: fetch Claude account profiles (plan type) on startup

- Call /api/oauth/profile for each token, cache in memory
- Display plan type (max/pro/free) next to Claude account names
- Profiles fetched once on startup, no periodic refresh needed
This commit is contained in:
Eyejoker
2026-03-24 01:41:23 +09:00
parent 16f4e6ccb9
commit 2ab6c25f0e
2 changed files with 70 additions and 3 deletions

View File

@@ -8,6 +8,8 @@
import { logger } from './logger.js';
import { getCurrentToken, getAllTokens } from './token-rotation.js';
const PROFILE_ENDPOINT = 'https://api.anthropic.com/api/oauth/profile';
export interface ClaudeUsageData {
five_hour?: { utilization: number; resets_at: string };
seven_day?: { utilization: number; resets_at: string };
@@ -99,6 +101,66 @@ export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
return fetchUsageForToken(token);
}
export interface ClaudeAccountProfile {
email: string;
planType: string; // "max", "pro", "free"
}
const profileCache = new Map<number, ClaudeAccountProfile>();
async function fetchProfileForToken(token: string): Promise<ClaudeAccountProfile | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(PROFILE_ENDPOINT, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20',
},
signal: controller.signal,
});
if (!res.ok) return null;
const data = await res.json() as {
account?: { email?: string; has_claude_max?: boolean; has_claude_pro?: boolean };
organization?: { organization_type?: string };
};
const orgType = data.organization?.organization_type || '';
const planType = data.account?.has_claude_max ? 'max'
: data.account?.has_claude_pro ? 'pro'
: orgType.replace('claude_', '') || 'free';
return {
email: data.account?.email || '?',
planType,
};
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/**
* Fetch profiles for all Claude tokens (cached, called once on startup).
*/
export async function fetchAllClaudeProfiles(): Promise<void> {
const allTokens = getAllTokens();
for (const t of allTokens) {
const profile = await fetchProfileForToken(t.token);
if (profile) {
profileCache.set(t.index, profile);
logger.info(
{ account: t.index + 1, plan: profile.planType, email: profile.email },
`Claude account #${t.index + 1}: ${profile.planType}`,
);
}
}
}
export function getClaudeProfile(index: number): ClaudeAccountProfile | undefined {
return profileCache.get(index);
}
export interface ClaudeAccountUsage {
index: number;
masked: string;

View File

@@ -7,6 +7,8 @@ import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
import {
fetchClaudeUsage as fetchClaudeUsageApi,
fetchAllClaudeUsage,
fetchAllClaudeProfiles,
getClaudeProfile,
type ClaudeUsageData,
type ClaudeAccountUsage,
} from './claude-usage.js';
@@ -600,12 +602,14 @@ async function buildUsageContent(): Promise<string> {
if (!u) continue;
const h5 = u.five_hour;
const d7 = u.seven_day;
const profile = getClaudeProfile(account.index);
const planSuffix = profile ? ` ${profile.planType}` : '';
const label =
claudeAccounts.length > 1
? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}`
? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`
: claudeUsageIsCached
? 'Claude*'
: 'Claude';
? `Claude*${planSuffix}`
: `Claude${planSuffix}`;
rows.push({
name: label,
h5pct: h5
@@ -863,6 +867,7 @@ export async function startUnifiedDashboard(
}
if (isRenderer) {
await fetchAllClaudeProfiles();
await refreshUsageCache();
}