From 83f6a1a9f9637cc630d0bfc8a8f7105ca5418308 Mon Sep 17 00:00:00 2001 From: Razorback16 Date: Mon, 9 Feb 2026 13:51:35 -0800 Subject: [PATCH] feat(quota): add Claude OAuth usage quota detection Add Claude quota section to the Quota Management page, using the Anthropic OAuth usage API (api.anthropic.com/api/oauth/usage) to display utilization across all rate limit windows (5-hour, 7-day, Opus, Sonnet, etc.) and extra usage credits. --- src/components/quota/index.ts | 2 +- src/components/quota/quotaConfigs.ts | 156 ++++++++++++++++++++++++++- src/i18n/locales/en.json | 20 ++++ src/i18n/locales/ru.json | 20 ++++ src/i18n/locales/zh-CN.json | 20 ++++ src/pages/QuotaPage.module.scss | 9 ++ src/pages/QuotaPage.tsx | 7 ++ src/stores/useQuotaStore.ts | 10 +- src/types/quota.ts | 40 +++++++ src/utils/quota/constants.ts | 19 ++++ src/utils/quota/parsers.ts | 19 +++- src/utils/quota/validators.ts | 17 +++ 12 files changed, 335 insertions(+), 4 deletions(-) diff --git a/src/components/quota/index.ts b/src/components/quota/index.ts index 6e915fa..eb62456 100644 --- a/src/components/quota/index.ts +++ b/src/components/quota/index.ts @@ -5,5 +5,5 @@ export { QuotaSection } from './QuotaSection'; export { QuotaCard } from './QuotaCard'; export { useQuotaLoader } from './useQuotaLoader'; -export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs'; +export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs'; export type { QuotaConfig } from './quotaConfigs'; diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index e9765aa..252b935 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -10,6 +10,10 @@ import type { AntigravityModelsPayload, AntigravityQuotaState, AuthFileItem, + ClaudeExtraUsage, + ClaudeQuotaState, + ClaudeQuotaWindow, + ClaudeUsagePayload, CodexRateLimitInfo, CodexQuotaState, CodexUsageWindow, @@ -23,6 +27,9 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api import { ANTIGRAVITY_QUOTA_URLS, ANTIGRAVITY_REQUEST_HEADERS, + CLAUDE_USAGE_URL, + CLAUDE_REQUEST_HEADERS, + CLAUDE_USAGE_WINDOW_KEYS, CODEX_USAGE_URL, CODEX_REQUEST_HEADERS, GEMINI_CLI_QUOTA_URL, @@ -34,6 +41,7 @@ import { normalizeQuotaFraction, normalizeStringValue, parseAntigravityPayload, + parseClaudeUsagePayload, parseCodexUsagePayload, parseGeminiCliQuotaPayload, resolveCodexChatgptAccountId, @@ -46,6 +54,7 @@ import { createStatusError, getStatusFromError, isAntigravityFile, + isClaudeFile, isCodexFile, isDisabledAuthFile, isGeminiCliFile, @@ -56,15 +65,17 @@ import styles from '@/pages/QuotaPage.module.scss'; type QuotaUpdater = T | ((prev: T) => T); -type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; +type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli'; const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn'; export interface QuotaStore { antigravityQuota: Record; + claudeQuota: Record; codexQuota: Record; geminiCliQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; + setClaudeQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; @@ -558,6 +569,149 @@ const renderGeminiCliItems = ( }); }; +const buildClaudeQuotaWindows = ( + payload: ClaudeUsagePayload, + t: TFunction +): ClaudeQuotaWindow[] => { + const windows: ClaudeQuotaWindow[] = []; + + for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) { + const window = payload[key as keyof ClaudeUsagePayload]; + if (!window || typeof window !== 'object' || !('utilization' in window)) continue; + const typedWindow = window as { utilization: number; resets_at: string }; + const usedPercent = normalizeNumberValue(typedWindow.utilization); + const resetLabel = formatQuotaResetTime(typedWindow.resets_at); + windows.push({ + id, + label: t(labelKey), + labelKey, + usedPercent, + resetLabel, + }); + } + + return windows; +}; + +const fetchClaudeQuota = async ( + file: AuthFileItem, + t: TFunction +): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndexValue(rawAuthIndex); + if (!authIndex) { + throw new Error(t('claude_quota.missing_auth_index')); + } + + const result = await apiCallApi.request({ + authIndex, + method: 'GET', + url: CLAUDE_USAGE_URL, + header: { ...CLAUDE_REQUEST_HEADERS }, + }); + + if (result.statusCode < 200 || result.statusCode >= 300) { + throw createStatusError(getApiCallErrorMessage(result), result.statusCode); + } + + const payload = parseClaudeUsagePayload(result.body ?? result.bodyText); + if (!payload) { + throw new Error(t('claude_quota.empty_windows')); + } + + const windows = buildClaudeQuotaWindows(payload, t); + return { windows, extraUsage: payload.extra_usage }; +}; + +const renderClaudeItems = ( + quota: ClaudeQuotaState, + t: TFunction, + helpers: QuotaRenderHelpers +): ReactNode => { + const { styles: styleMap, QuotaProgressBar } = helpers; + const { createElement: h, Fragment } = React; + const windows = quota.windows ?? []; + const extraUsage = quota.extraUsage ?? null; + const nodes: ReactNode[] = []; + + if (extraUsage && extraUsage.is_enabled) { + const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`; + nodes.push( + h( + 'div', + { key: 'extra', className: styleMap.codexPlan }, + h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')), + h('span', { className: styleMap.codexPlanValue }, usedLabel) + ) + ); + } + + 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.usedPercent; + 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)}%`; + const windowLabel = window.labelKey ? t(window.labelKey) : window.label; + + return h( + 'div', + { key: window.id, className: styleMap.quotaRow }, + h( + 'div', + { className: styleMap.quotaRowHeader }, + h('span', { className: styleMap.quotaModel }, windowLabel), + h( + 'div', + { className: styleMap.quotaMeta }, + h('span', { className: styleMap.quotaPercent }, percentLabel), + h('span', { className: styleMap.quotaReset }, window.resetLabel) + ) + ), + h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 }) + ); + }) + ); + + return h(Fragment, null, ...nodes); +}; + +export const CLAUDE_CONFIG: QuotaConfig< + ClaudeQuotaState, + { windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null } +> = { + type: 'claude', + i18nPrefix: 'claude_quota', + filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file), + fetchQuota: fetchClaudeQuota, + storeSelector: (state) => state.claudeQuota, + storeSetter: 'setClaudeQuota', + buildLoadingState: () => ({ status: 'loading', windows: [] }), + buildSuccessState: (data) => ({ + status: 'success', + windows: data.windows, + extraUsage: data.extraUsage, + }), + buildErrorState: (message, status) => ({ + status: 'error', + windows: [], + error: message, + errorStatus: status, + }), + cardClassName: styles.claudeCard, + controlsClassName: styles.claudeControls, + controlClassName: styles.claudeControl, + gridClassName: styles.claudeGrid, + renderQuotaItems: renderClaudeItems, +}; + export const ANTIGRAVITY_CONFIG: QuotaConfig = { type: 'antigravity', i18nPrefix: 'antigravity_quota', diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a98ff94..0bcfe59 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -434,6 +434,26 @@ "refresh_button": "Refresh Quota", "fetch_all": "Fetch All" }, + "claude_quota": { + "title": "Claude Quota", + "empty_title": "No Claude OAuth Files", + "empty_desc": "Log in with Claude OAuth to view quota.", + "idle": "Click here to refresh quota", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "empty_windows": "No quota data available", + "refresh_button": "Refresh Quota", + "fetch_all": "Fetch All", + "five_hour": "5-hour limit", + "seven_day": "7-day limit", + "seven_day_oauth_apps": "7-day OAuth apps", + "seven_day_opus": "7-day Opus", + "seven_day_sonnet": "7-day Sonnet", + "seven_day_cowork": "7-day Cowork", + "iguana_necktie": "Iguana Necktie", + "extra_usage_label": "Extra Usage" + }, "codex_quota": { "title": "Codex Quota", "empty_title": "No Codex Auth Files", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f657976..8c78697 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -437,6 +437,26 @@ "refresh_button": "Обновить квоту", "fetch_all": "Получить все" }, + "claude_quota": { + "title": "Квота Claude", + "empty_title": "Файлы авторизации Claude OAuth отсутствуют", + "empty_desc": "Войдите через Claude OAuth, чтобы увидеть квоту.", + "idle": "Не загружено. Нажмите \"Обновить квоту\".", + "loading": "Загрузка квоты...", + "load_failed": "Не удалось загрузить квоту: {{message}}", + "missing_auth_index": "В файле авторизации отсутствует auth_index", + "empty_windows": "Данные по квоте отсутствуют", + "refresh_button": "Обновить квоту", + "fetch_all": "Получить все", + "five_hour": "Лимит на 5 часов", + "seven_day": "Лимит на 7 дней", + "seven_day_oauth_apps": "7 дней OAuth приложения", + "seven_day_opus": "7 дней Opus", + "seven_day_sonnet": "7 дней Sonnet", + "seven_day_cowork": "7 дней Cowork", + "iguana_necktie": "Iguana Necktie", + "extra_usage_label": "Дополнительное использование" + }, "codex_quota": { "title": "Квота Codex", "empty_title": "Файлы авторизации Codex отсутствуют", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6bf2332..08e2d73 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -434,6 +434,26 @@ "refresh_button": "刷新额度", "fetch_all": "获取全部" }, + "claude_quota": { + "title": "Claude 额度", + "empty_title": "暂无 Claude OAuth 认证", + "empty_desc": "使用 Claude OAuth 登录后即可查看额度。", + "idle": "点击此处刷新额度", + "loading": "正在加载额度...", + "load_failed": "额度获取失败:{{message}}", + "missing_auth_index": "认证文件缺少 auth_index", + "empty_windows": "暂无额度数据", + "refresh_button": "刷新额度", + "fetch_all": "获取全部", + "five_hour": "5 小时限额", + "seven_day": "7 天限额", + "seven_day_oauth_apps": "7 天 OAuth 应用", + "seven_day_opus": "7 天 Opus", + "seven_day_sonnet": "7 天 Sonnet", + "seven_day_cowork": "7 天 Cowork", + "iguana_necktie": "Iguana Necktie", + "extra_usage_label": "额外用量" + }, "codex_quota": { "title": "Codex 额度", "empty_title": "暂无 Codex 认证", diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index 6995232..9ddcc7a 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -103,6 +103,7 @@ } .antigravityGrid, +.claudeGrid, .codexGrid, .geminiCliGrid { display: grid; @@ -115,6 +116,7 @@ } .antigravityControls, +.claudeControls, .codexControls, .geminiCliControls { display: flex; @@ -125,6 +127,7 @@ } .antigravityControl, +.claudeControl, .codexControl, .geminiCliControl { display: flex; @@ -145,6 +148,12 @@ align-items: center; } +.claudeCard { + background-image: linear-gradient(180deg, + rgba(252, 228, 236, 0.18), + rgba(252, 228, 236, 0)); +} + .antigravityCard { background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index a72d812..17831c7 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -10,6 +10,7 @@ import { authFilesApi, configFileApi } from '@/services/api'; import { QuotaSection, ANTIGRAVITY_CONFIG, + CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota'; @@ -69,6 +70,12 @@ export function QuotaPage() { {error &&
{error}
} + = T | ((prev: T) => T); interface QuotaStoreState { antigravityQuota: Record; + claudeQuota: Record; codexQuota: Record; geminiCliQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; + setClaudeQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; @@ -26,12 +28,17 @@ const resolveUpdater = (updater: QuotaUpdater, prev: T): T => { export const useQuotaStore = create((set) => ({ antigravityQuota: {}, + claudeQuota: {}, codexQuota: {}, geminiCliQuota: {}, setAntigravityQuota: (updater) => set((state) => ({ antigravityQuota: resolveUpdater(updater, state.antigravityQuota) })), + setClaudeQuota: (updater) => + set((state) => ({ + claudeQuota: resolveUpdater(updater, state.claudeQuota) + })), setCodexQuota: (updater) => set((state) => ({ codexQuota: resolveUpdater(updater, state.codexQuota) @@ -43,6 +50,7 @@ export const useQuotaStore = create((set) => ({ clearQuotaCache: () => set({ antigravityQuota: {}, + claudeQuota: {}, codexQuota: {}, geminiCliQuota: {} }) diff --git a/src/types/quota.ts b/src/types/quota.ts index 3e335a0..baf2d0b 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -97,6 +97,46 @@ export interface CodexUsagePayload { codeReviewRateLimit?: CodexRateLimitInfo | null; } +// Claude API payload types +export interface ClaudeUsageWindow { + utilization: number; + resets_at: string; +} + +export interface ClaudeExtraUsage { + is_enabled: boolean; + monthly_limit: number; + used_credits: number; + utilization: number | null; +} + +export interface ClaudeUsagePayload { + five_hour?: ClaudeUsageWindow | null; + seven_day?: ClaudeUsageWindow | null; + seven_day_oauth_apps?: ClaudeUsageWindow | null; + seven_day_opus?: ClaudeUsageWindow | null; + seven_day_sonnet?: ClaudeUsageWindow | null; + seven_day_cowork?: ClaudeUsageWindow | null; + iguana_necktie?: ClaudeUsageWindow | null; + extra_usage?: ClaudeExtraUsage | null; +} + +export interface ClaudeQuotaWindow { + id: string; + label: string; + labelKey?: string; + usedPercent: number | null; + resetLabel: string; +} + +export interface ClaudeQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + windows: ClaudeQuotaWindow[]; + extraUsage?: ClaudeExtraUsage | null; + error?: string; + errorStatus?: number; +} + // Quota state types export interface AntigravityQuotaGroup { id: string; diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index f9b8e1d..2b0fe91 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -151,6 +151,25 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map( export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash']; +// Claude API configuration +export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; + +export const CLAUDE_REQUEST_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + 'anthropic-beta': 'oauth-2025-04-20', +}; + +export const CLAUDE_USAGE_WINDOW_KEYS = [ + { key: 'five_hour', id: 'five-hour', labelKey: 'claude_quota.five_hour' }, + { key: 'seven_day', id: 'seven-day', labelKey: 'claude_quota.seven_day' }, + { key: 'seven_day_oauth_apps', id: 'seven-day-oauth-apps', labelKey: 'claude_quota.seven_day_oauth_apps' }, + { key: 'seven_day_opus', id: 'seven-day-opus', labelKey: 'claude_quota.seven_day_opus' }, + { key: 'seven_day_sonnet', id: 'seven-day-sonnet', labelKey: 'claude_quota.seven_day_sonnet' }, + { key: 'seven_day_cowork', id: 'seven-day-cowork', labelKey: 'claude_quota.seven_day_cowork' }, + { key: 'iguana_necktie', id: 'iguana-necktie', labelKey: 'claude_quota.iguana_necktie' }, +] as const; + // Codex API configuration export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; diff --git a/src/utils/quota/parsers.ts b/src/utils/quota/parsers.ts index 748a3a9..c83c568 100644 --- a/src/utils/quota/parsers.ts +++ b/src/utils/quota/parsers.ts @@ -2,7 +2,7 @@ * Normalization and parsing functions for quota data. */ -import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types'; +import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types'; const GEMINI_CLI_MODEL_SUFFIX = '_vertex'; @@ -129,6 +129,23 @@ export function parseAntigravityPayload(payload: unknown): Record) + : null; + const accessToken = + metadata && typeof metadata.access_token === 'string' + ? metadata.access_token.trim() + : ''; + return accessToken.includes('sk-ant-oat'); +} + export function isCodexFile(file: AuthFileItem): boolean { return resolveAuthProvider(file) === 'codex'; }