diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index e9765aa..75a5e98 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -18,6 +18,9 @@ import type { GeminiCliParsedBucket, GeminiCliQuotaBucketState, GeminiCliQuotaState, + ClaudeQuotaState, + ClaudeProfileResponse, + ClaudeUsageResponse, } from '@/types'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; import { @@ -50,13 +53,18 @@ import { isDisabledAuthFile, isGeminiCliFile, isRuntimeOnlyAuthFile, + isClaudeFile, + CLAUDE_REQUEST_HEADERS, + CLAUDE_PROFILE_URL, + CLAUDE_USAGE_URL, + formatUnixSeconds, } from '@/utils/quota'; import type { QuotaRenderHelpers } from './QuotaCard'; import styles from '@/pages/QuotaPage.module.scss'; type QuotaUpdater = T | ((prev: T) => T); -type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; +type QuotaType = 'antigravity' | 'codex' | 'gemini-cli' | 'claude'; const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn'; @@ -64,9 +72,11 @@ export interface QuotaStore { antigravityQuota: Record; codexQuota: Record; geminiCliQuota: Record; + claudeQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; + setClaudeQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; } @@ -399,6 +409,52 @@ const fetchGeminiCliQuota = async ( return buildGeminiCliQuotaBuckets(parsedBuckets); }; +const fetchClaudeQuota = async ( + file: AuthFileItem, + t: TFunction +): Promise<{ planType: string; usage: ClaudeUsageResponse }> => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndexValue(rawAuthIndex); + if (!authIndex) { + throw new Error(t('claude_quota.missing_auth_index')); + } + + const [profileRes, usageRes] = await Promise.all([ + apiCallApi.request({ + authIndex, + method: 'GET', + url: CLAUDE_PROFILE_URL, + header: CLAUDE_REQUEST_HEADERS, + }), + apiCallApi.request({ + authIndex, + method: 'GET', + url: CLAUDE_USAGE_URL, + header: CLAUDE_REQUEST_HEADERS, + }) + ]) + + if (profileRes.statusCode < 200 || profileRes.statusCode >= 300) { + throw createStatusError(getApiCallErrorMessage(profileRes), profileRes.statusCode); + } + if (usageRes.statusCode < 200 || usageRes.statusCode >= 300) { + throw createStatusError(getApiCallErrorMessage(usageRes), usageRes.statusCode); + } + + const { organization } = JSON.parse(profileRes.bodyText) as ClaudeProfileResponse; + const tier = organization?.rate_limit_tier + const tierToPlanMap: Record = { + 'default_claude_max_5x': 'plan_max5', + 'default_claude_max_20x': 'plan_max20', + 'default_claude_pro': 'plan_pro', + 'default_claude_ai': 'plan_free', + }; + const planType = tierToPlanMap[tier ?? ''] ?? 'plan_unknown'; + const usage = JSON.parse(usageRes.bodyText) as ClaudeUsageResponse + + return { planType, usage }; +}; + const renderAntigravityItems = ( quota: AntigravityQuotaState, t: TFunction, @@ -558,6 +614,95 @@ const renderGeminiCliItems = ( }); }; +const renderClaudeItems = ( + { planType, usage }: ClaudeQuotaState, + t: TFunction, + helpers: QuotaRenderHelpers +): ReactNode => { + if (!usage) return null + + const { styles: styleMap, QuotaProgressBar } = helpers; + const { createElement: h, Fragment } = React; + + const planLabel = t('claude_quota.' + planType); + const nodes: ReactNode[] = []; + + if (planLabel) { + nodes.push( + h( + 'div', + { key: 'plan', className: styleMap.claudePlan }, + h('span', { className: styleMap.claudePlanLabel }, t('claude_quota.plan_label')), + h('span', { className: styleMap.claudePlanValue }, planLabel) + ) + ); + } + + type Window = { + key: string + usage: number | null + resetDate: Date + } + const windows: Window[] = [] + + if (usage.five_hour) { + windows.push({ + key: 'primary_window', + usage: usage.five_hour.utilization, + resetDate: new Date(usage.five_hour.resets_at) + }) + } + if (usage.seven_day) { + windows.push({ + key: 'secondary_window', + usage: usage.seven_day.utilization, + resetDate: new Date(usage.seven_day.resets_at) + }) + } + if (usage.seven_day_sonnet) { + windows.push({ + key: 'sonnet_window', + usage: usage.seven_day_sonnet.utilization, + resetDate: new Date(usage.seven_day_sonnet.resets_at) + }) + } + + if (windows.length === 0) { + nodes.push( + h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_quota.empty_windows')) + ); + return h(Fragment, null, ...nodes); + } + + nodes.push( + ...windows.map((window) => { + const used = window.usage; + const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used)); + const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed)); + const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`; + + return h( + 'div', + { key: window.key, className: styleMap.quotaRow }, + h( + 'div', + { className: styleMap.quotaRowHeader }, + h('span', { className: styleMap.quotaModel }, t('claude_quota.' + window.key)), + h( + 'div', + { className: styleMap.quotaMeta }, + h('span', { className: styleMap.quotaPercent }, percentLabel), + h('span', { className: styleMap.quotaReset }, formatUnixSeconds(window.resetDate)) + ) + ), + h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 }) + ); + }) + ); + + return h(Fragment, null, ...nodes); +}; + export const ANTIGRAVITY_CONFIG: QuotaConfig = { type: 'antigravity', i18nPrefix: 'antigravity_quota', @@ -631,3 +776,25 @@ export const GEMINI_CLI_CONFIG: QuotaConfig = { + type: 'claude', + i18nPrefix: 'claude_quota', + filterFn: (file) => + isClaudeFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file), + fetchQuota: fetchClaudeQuota, + storeSelector: (state) => state.claudeQuota, + storeSetter: 'setClaudeQuota', + buildLoadingState: () => ({ status: 'loading' }), + buildSuccessState: ({ planType, usage }) => ({ status: 'success', planType, usage }), + buildErrorState: (message, status) => ({ + status: 'error', + error: message, + errorStatus: status, + }), + cardClassName: styles.claudeCard, + controlsClassName: styles.claudeControls, + controlClassName: styles.claudeControl, + gridClassName: styles.claudeGrid, + renderQuotaItems: renderClaudeItems, +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a98ff94..ad2ce2e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -456,6 +456,28 @@ "plan_team": "Team", "plan_free": "Free" }, + "claude_quota": { + "title": "Claude Code Quota", + "empty_title": "No Claude Code Auth Files", + "empty_desc": "Upload a Claude Code credential to view quota.", + "idle": "Not loaded. Click Refresh Button.", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "empty_windows": "No quota data available", + "no_access": "This credential has no Claude Code access (plan: free).", + "refresh_button": "Refresh Quota", + "fetch_all": "Fetch All", + "primary_window": "5-hour limit", + "secondary_window": "Weekly limit", + "sonnet_window": "Weekly Sonnet limit", + "plan_label": "Plan", + "plan_unknown": "Unknown", + "plan_free": "Free", + "plan_pro": "Pro", + "plan_max5": "Max 5x", + "plan_max20": "Max 20x" + }, "gemini_cli_quota": { "title": "Gemini CLI Quota", "empty_title": "No Gemini CLI Auth Files", @@ -1122,4 +1144,4 @@ "version": "Management UI Version", "author": "Author" } -} +} \ No newline at end of file diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 4954ae6..206b326 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -36,6 +36,7 @@ import { } from '@/utils/usage'; import { formatFileSize } from '@/utils/format'; import styles from './AuthFilesPage.module.scss'; +import { CLAUDE_CONFIG } from '../components/quota/quotaConfigs.ts'; type ThemeColors = { bg: string; text: string; border?: string }; type TypeColorSet = { light: ThemeColors; dark?: ThemeColors }; @@ -98,9 +99,9 @@ const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState'; const clampCardPageSize = (value: number) => Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value))); -type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli'; +type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli' | 'claude'; -const QUOTA_PROVIDER_TYPES = new Set(['antigravity', 'codex', 'gemini-cli']); +const QUOTA_PROVIDER_TYPES = new Set(['antigravity', 'codex', 'gemini-cli', 'claude']); const resolveQuotaErrorMessage = ( t: TFunction, @@ -248,9 +249,11 @@ export function AuthFilesPage() { const antigravityQuota = useQuotaStore((state) => state.antigravityQuota); const codexQuota = useQuotaStore((state) => state.codexQuota); const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota); + const claudeQuota = useQuotaStore((state) => state.claudeQuota); const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota); const setCodexQuota = useQuotaStore((state) => state.setCodexQuota); const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota); + const setClaudeQuota = useQuotaStore((state) => state.setClaudeQuota); const navigate = useNavigate(); const [files, setFiles] = useState([]); @@ -1468,12 +1471,14 @@ export function AuthFilesPage() { const getQuotaConfig = (type: QuotaProviderType) => { if (type === 'antigravity') return ANTIGRAVITY_CONFIG; if (type === 'codex') return CODEX_CONFIG; + if (type === 'claude') return CLAUDE_CONFIG; return GEMINI_CLI_CONFIG; }; const getQuotaState = (type: QuotaProviderType, fileName: string) => { if (type === 'antigravity') return antigravityQuota[fileName]; if (type === 'codex') return codexQuota[fileName]; + if (type === 'claude') return claudeQuota[fileName]; return geminiCliQuota[fileName]; }; @@ -1490,9 +1495,13 @@ export function AuthFilesPage() { setCodexQuota(updater as never); return; } + if (type === 'claude') { + setClaudeQuota(updater as never); + return; + } setGeminiCliQuota(updater as never); }, - [setAntigravityQuota, setCodexQuota, setGeminiCliQuota] + [setAntigravityQuota, setClaudeQuota, setCodexQuota, setGeminiCliQuota] ); const refreshQuotaForFile = useCallback( diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index 6995232..8810a56 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -104,6 +104,7 @@ .antigravityGrid, .codexGrid, +.claudeGrid, .geminiCliGrid { display: grid; gap: $spacing-md; @@ -116,6 +117,7 @@ .antigravityControls, .codexControls, +.claudeControls, .geminiCliControls { display: flex; gap: $spacing-md; @@ -126,6 +128,7 @@ .antigravityControl, .codexControl, +.claudeControl, .geminiCliControl { display: flex; flex-direction: column; @@ -157,6 +160,12 @@ rgba(255, 243, 224, 0)); } +.claudeCard { + background-image: linear-gradient(180deg, + rgba(255, 231, 245, 0.2), + rgba(231, 239, 255, 0)); +} + .geminiCliCard { background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), @@ -282,7 +291,8 @@ padding: $spacing-xs $spacing-sm; } -.codexPlan { +.codexPlan, +.claudePlan { display: flex; align-items: center; gap: 6px; @@ -290,11 +300,13 @@ color: var(--text-secondary); } -.codexPlanLabel { +.codexPlanLabel, +.claudePlanLabel { color: var(--text-tertiary); } -.codexPlanValue { +.codexPlanValue, +.claudePlanValue { font-weight: 600; color: var(--text-primary); text-transform: capitalize; diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index a72d812..1ece7ac 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -15,6 +15,7 @@ import { } from '@/components/quota'; import type { AuthFileItem } from '@/types'; import styles from './QuotaPage.module.scss'; +import { CLAUDE_CONFIG } from '../components/quota/quotaConfigs.ts'; export function QuotaPage() { const { t } = useTranslation(); @@ -87,6 +88,12 @@ export function QuotaPage() { loading={loading} disabled={disableControls} /> + ); } diff --git a/src/stores/useQuotaStore.ts b/src/stores/useQuotaStore.ts index 4f6b1a6..612b535 100644 --- a/src/stores/useQuotaStore.ts +++ b/src/stores/useQuotaStore.ts @@ -3,7 +3,7 @@ */ import { create } from 'zustand'; -import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types'; +import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types'; type QuotaUpdater = T | ((prev: T) => T); @@ -11,9 +11,11 @@ interface QuotaStoreState { antigravityQuota: Record; codexQuota: Record; geminiCliQuota: Record; + claudeQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; + setClaudeQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; } @@ -28,6 +30,7 @@ export const useQuotaStore = create((set) => ({ antigravityQuota: {}, codexQuota: {}, geminiCliQuota: {}, + claudeQuota: {}, setAntigravityQuota: (updater) => set((state) => ({ antigravityQuota: resolveUpdater(updater, state.antigravityQuota) @@ -40,10 +43,15 @@ export const useQuotaStore = create((set) => ({ set((state) => ({ geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota) })), + setClaudeQuota: (updater) => + set((state) => ({ + claudeQuota: resolveUpdater(updater, state.claudeQuota) + })), clearQuotaCache: () => set({ antigravityQuota: {}, codexQuota: {}, - geminiCliQuota: {} + geminiCliQuota: {}, + claudeQuota: {} }) })); diff --git a/src/types/quota.ts b/src/types/quota.ts index 3e335a0..6f3f8a1 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -145,3 +145,54 @@ export interface CodexQuotaState { error?: string; errorStatus?: number; } + +export interface ClaudeProfileResponse { + account: { + uuid: string + full_name: string + display_name: string + email: string + has_claude_max: boolean + has_claude_pro: boolean + created_at: string + } + organization: { + uuid: string + name: string + organization_type: string + billing_type: string + rate_limit_tier: string + has_extra_usage_enabled: boolean + subscription_status: string + subscription_created_at: string + } +} + +export interface ClaudeUsageWindow { + utilization: number | null + resets_at: string +} + +export interface ClaudeUsageResponse { + five_hour: ClaudeUsageWindow | null + seven_day: ClaudeUsageWindow | null + seven_day_oauth_apps: ClaudeUsageWindow | null // currently unused + seven_day_opus: ClaudeUsageWindow | null // currently unused + seven_day_sonnet: ClaudeUsageWindow | null + seven_day_cowork: ClaudeUsageWindow | null // currently unused + extra_usage: { + is_enabled: boolean + monthly_limit: number + used_credits: number + utilization: number | null + } +} + +export interface ClaudeQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + usage?: ClaudeUsageResponse; + planType?: string | null; + error?: string; + errorStatus?: number; +} + diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index f9b8e1d..066474c 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -159,3 +159,16 @@ export const CODEX_REQUEST_HEADERS = { 'Content-Type': 'application/json', 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal', }; + +// Claude Code configuration +export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' +export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile' + +export const CLAUDE_REQUEST_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + 'User-Agent': 'claude-cli/1.0.83 (external, cli)', + 'Anthropic-Beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,prompt-caching-2024-07-31', + 'Anthropic-Version': '2023-06-01', + 'x-app': 'cli', +}; \ No newline at end of file diff --git a/src/utils/quota/formatters.ts b/src/utils/quota/formatters.ts index 1425eba..94e6971 100644 --- a/src/utils/quota/formatters.ts +++ b/src/utils/quota/formatters.ts @@ -18,9 +18,9 @@ export function formatQuotaResetTime(value?: string): string { }); } -export function formatUnixSeconds(value: number | null): string { +export function formatUnixSeconds(value: Date | number | null): string { if (!value) return '-'; - const date = new Date(value * 1000); + const date = typeof value === 'number' ? new Date(value * 1000) : value; if (Number.isNaN(date.getTime())) return '-'; return date.toLocaleString(undefined, { month: '2-digit', diff --git a/src/utils/quota/validators.ts b/src/utils/quota/validators.ts index ba90c3c..714e051 100644 --- a/src/utils/quota/validators.ts +++ b/src/utils/quota/validators.ts @@ -22,6 +22,10 @@ export function isGeminiCliFile(file: AuthFileItem): boolean { return resolveAuthProvider(file) === 'gemini-cli'; } +export function isClaudeFile(file: AuthFileItem): boolean { + return resolveAuthProvider(file) === 'claude'; +} + export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { const raw = file['runtime_only'] ?? file.runtimeOnly; if (typeof raw === 'boolean') return raw;