diff --git a/src/App.tsx b/src/App.tsx index 50e04b7..0ceefab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { ApiKeysPage } from '@/pages/ApiKeysPage'; import { AiProvidersPage } from '@/pages/AiProvidersPage'; import { AuthFilesPage } from '@/pages/AuthFilesPage'; import { OAuthPage } from '@/pages/OAuthPage'; +import { QuotaPage } from '@/pages/QuotaPage'; import { UsagePage } from '@/pages/UsagePage'; import { ConfigPage } from '@/pages/ConfigPage'; import { LogsPage } from '@/pages/LogsPage'; @@ -88,6 +89,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 1ce5623..d1305a6 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -23,6 +23,7 @@ import { IconSettings, IconShield, IconSlidersHorizontal, + IconTimer, } from '@/components/ui/icons'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { @@ -41,6 +42,7 @@ const sidebarIcons: Record = { aiProviders: , authFiles: , oauth: , + quota: , usage: , config: , logs: , @@ -355,6 +357,7 @@ export function MainLayout() { { path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders }, { path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles }, { path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth }, + { path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota }, { path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage }, { path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config }, ...(config?.loggingToFile diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e66474a..1d6fb17 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -90,6 +90,7 @@ "ai_providers": "AI Providers", "auth_files": "Auth Files", "oauth": "OAuth Login", + "quota_management": "Quota Management", "usage_stats": "Usage Statistics", "config_management": "Config Management", "logs": "Logs Viewer", @@ -704,6 +705,11 @@ "search_prev": "Previous", "search_next": "Next" }, + "quota_management": { + "title": "Quota Management", + "description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.", + "refresh_files": "Refresh auth files" + }, "system_info": { "title": "Management Center Info", "connection_status_title": "Connection Status", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 724b688..edc8b75 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -90,6 +90,7 @@ "ai_providers": "AI 提供商", "auth_files": "认证文件", "oauth": "OAuth 登录", + "quota_management": "配额管理", "usage_stats": "使用统计", "config_management": "配置管理", "logs": "日志查看", @@ -704,6 +705,11 @@ "search_prev": "上一个", "search_next": "下一个" }, + "quota_management": { + "title": "配额管理", + "description": "集中查看 OAuth 额度与剩余情况", + "refresh_files": "刷新认证文件" + }, "system_info": { "title": "管理中心信息", "connection_status_title": "连接状态", diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 5db7336..a1a49a4 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -9,7 +9,7 @@ import { Modal } from '@/components/ui/Modal'; import { EmptyState } from '@/components/ui/EmptyState'; import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons'; import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; -import { apiCallApi, authFilesApi, getApiCallErrorMessage, usageApi } from '@/services/api'; +import { authFilesApi, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; import type { AuthFileItem } from '@/types'; import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage'; @@ -83,615 +83,18 @@ interface ExcludedFormState { provider: string; modelsText: string; } - -interface AntigravityQuotaGroup { - id: string; - label: string; - models: string[]; - remainingFraction: number; - resetTime?: string; -} - -interface AntigravityQuotaState { - status: 'idle' | 'loading' | 'success' | 'error'; - groups: AntigravityQuotaGroup[]; - error?: string; - errorStatus?: number; -} - -interface GeminiCliQuotaBucket { - modelId?: string; - model_id?: string; - tokenType?: string; - token_type?: string; - remainingFraction?: number | string; - remaining_fraction?: number | string; - remainingAmount?: number | string; - remaining_amount?: number | string; - resetTime?: string; - reset_time?: string; -} - -interface GeminiCliQuotaPayload { - buckets?: GeminiCliQuotaBucket[]; -} - -interface GeminiCliQuotaBucketState { - id: string; - label: string; - remainingFraction: number | null; - remainingAmount: number | null; - resetTime: string | undefined; - tokenType: string | null; -} - -interface GeminiCliQuotaState { - status: 'idle' | 'loading' | 'success' | 'error'; - buckets: GeminiCliQuotaBucketState[]; - error?: string; - errorStatus?: number; -} - -interface AntigravityQuotaInfo { - displayName?: string; - quotaInfo?: { - remainingFraction?: number | string; - remaining_fraction?: number | string; - remaining?: number | string; - resetTime?: string; - reset_time?: string; - }; - quota_info?: { - remainingFraction?: number | string; - remaining_fraction?: number | string; - remaining?: number | string; - resetTime?: string; - reset_time?: string; - }; -} - -type AntigravityModelsPayload = Record; - -interface AntigravityQuotaGroupDefinition { - id: string; - label: string; - identifiers: string[]; - labelFromModel?: boolean; -} - -const ANTIGRAVITY_QUOTA_URLS = [ - 'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels', - 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels', - 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels' -]; - -const ANTIGRAVITY_REQUEST_HEADERS = { - Authorization: 'Bearer $TOKEN$', - 'Content-Type': 'application/json', - 'User-Agent': 'antigravity/1.11.5 windows/amd64' -}; - -const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [ - { - id: 'claude-gpt', - label: 'Claude/GPT', - identifiers: [ - 'claude-sonnet-4-5-thinking', - 'claude-opus-4-5-thinking', - 'claude-sonnet-4-5', - 'gpt-oss-120b-medium' - ] - }, - { - id: 'gemini', - label: 'Gemini', - identifiers: [ - 'gemini-3-pro-high', - 'gemini-3-pro-low', - 'gemini-2.5-flash', - 'gemini-2.5-flash-lite', - 'rev19-uic3-1p' - ] - }, - { - id: 'gemini-3-flash', - label: 'Gemini 3 Flash', - identifiers: ['gemini-3-flash'] - }, - { - id: 'gemini-image', - label: 'gemini-3-pro-image', - identifiers: ['gemini-3-pro-image'], - labelFromModel: true - } -]; - -const GEMINI_CLI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; - -const GEMINI_CLI_REQUEST_HEADERS = { - Authorization: 'Bearer $TOKEN$', - 'Content-Type': 'application/json' -}; - -interface CodexUsageWindow { - used_percent?: number | string; - usedPercent?: number | string; - limit_window_seconds?: number | string; - limitWindowSeconds?: number | string; - reset_after_seconds?: number | string; - resetAfterSeconds?: number | string; - reset_at?: number | string; - resetAt?: number | string; -} - -interface CodexRateLimitInfo { - allowed?: boolean; - limit_reached?: boolean; - primary_window?: CodexUsageWindow | null; - primaryWindow?: CodexUsageWindow | null; - secondary_window?: CodexUsageWindow | null; - secondaryWindow?: CodexUsageWindow | null; -} - -interface CodexUsagePayload { - plan_type?: string; - planType?: string; - rate_limit?: CodexRateLimitInfo | null; - rateLimit?: CodexRateLimitInfo | null; - code_review_rate_limit?: CodexRateLimitInfo | null; - codeReviewRateLimit?: CodexRateLimitInfo | null; -} - -interface CodexQuotaWindow { - id: string; - label: string; - usedPercent: number | null; - resetLabel: string; -} - -interface CodexQuotaState { - status: 'idle' | 'loading' | 'success' | 'error'; - windows: CodexQuotaWindow[]; - planType?: string | null; - error?: string; - errorStatus?: number; -} - -const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; - -const CODEX_REQUEST_HEADERS = { - Authorization: 'Bearer $TOKEN$', - 'Content-Type': 'application/json', - 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal' -}; - -const createStatusError = (message: string, status?: number) => { - const error = new Error(message) as Error & { status?: number }; - if (status !== undefined) { - error.status = status; - } - return error; -}; - -const getStatusFromError = (err: unknown): number | undefined => { - if (typeof err === 'object' && err !== null && 'status' in err) { - const rawStatus = (err as { status?: unknown }).status; - if (typeof rawStatus === 'number' && Number.isFinite(rawStatus)) { - return rawStatus; - } - const asNumber = Number(rawStatus); - if (Number.isFinite(asNumber) && asNumber > 0) { - return asNumber; - } - } - return undefined; -}; - - - // 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) function normalizeAuthIndexValue(value: unknown): string | null { if (typeof value === 'number' && Number.isFinite(value)) { return value.toString(); } - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed ? trimmed : null; - } - return null; -} - -function normalizeStringValue(value: unknown): string | null { if (typeof value === 'string') { const trimmed = value.trim(); return trimmed ? trimmed : null; } - if (typeof value === 'number' && Number.isFinite(value)) { - return value.toString(); - } return null; } -function normalizeNumberValue(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) return null; - const parsed = Number(trimmed); - return Number.isFinite(parsed) ? parsed : null; - } - return null; -} - -function normalizePlanType(value: unknown): string | null { - const normalized = normalizeStringValue(value); - return normalized ? normalized.toLowerCase() : null; -} - -function decodeBase64UrlPayload(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) return null; - try { - const normalized = trimmed.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); - if (typeof window !== 'undefined' && typeof window.atob === 'function') { - return window.atob(padded); - } - if (typeof atob === 'function') { - return atob(padded); - } - } catch { - return null; - } - return null; -} - -function parseIdTokenPayload(value: unknown): Record | null { - if (!value) return null; - if (typeof value === 'object') { - return Array.isArray(value) ? null : (value as Record); - } - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - try { - const parsed = JSON.parse(trimmed) as Record; - if (parsed && typeof parsed === 'object') return parsed; - } catch { - } - const segments = trimmed.split('.'); - if (segments.length < 2) return null; - const decoded = decodeBase64UrlPayload(segments[1]); - if (!decoded) return null; - try { - const parsed = JSON.parse(decoded) as Record; - if (parsed && typeof parsed === 'object') return parsed; - } catch { - return null; - } - return null; -} - -function extractCodexChatgptAccountId(value: unknown): string | null { - const payload = parseIdTokenPayload(value); - if (!payload) return null; - return normalizeStringValue(payload.chatgpt_account_id ?? payload.chatgptAccountId); -} - -function resolveCodexChatgptAccountId(file: AuthFileItem): string | null { - const metadata = - file && typeof file.metadata === 'object' && file.metadata !== null - ? (file.metadata as Record) - : null; - const attributes = - file && typeof file.attributes === 'object' && file.attributes !== null - ? (file.attributes as Record) - : null; - - const candidates = [file.id_token, metadata?.id_token, attributes?.id_token]; - - for (const candidate of candidates) { - const id = extractCodexChatgptAccountId(candidate); - if (id) return id; - } - - return null; -} - -function resolveCodexPlanType(file: AuthFileItem): string | null { - const metadata = - file && typeof file.metadata === 'object' && file.metadata !== null - ? (file.metadata as Record) - : null; - const attributes = - file && typeof file.attributes === 'object' && file.attributes !== null - ? (file.attributes as Record) - : null; - const idToken = - file && typeof file.id_token === 'object' && file.id_token !== null - ? (file.id_token as Record) - : null; - const metadataIdToken = - metadata && typeof metadata.id_token === 'object' && metadata.id_token !== null - ? (metadata.id_token as Record) - : null; - const candidates = [ - file.plan_type, - file.planType, - file['plan_type'], - file['planType'], - file.id_token, - idToken?.plan_type, - idToken?.planType, - metadata?.plan_type, - metadata?.planType, - metadata?.id_token, - metadataIdToken?.plan_type, - metadataIdToken?.planType, - attributes?.plan_type, - attributes?.planType, - attributes?.id_token - ]; - - for (const candidate of candidates) { - const planType = normalizePlanType(candidate); - if (planType) return planType; - } - - return null; -} - -function extractGeminiCliProjectId(value: unknown): string | null { - if (typeof value !== 'string') return null; - const matches = Array.from(value.matchAll(/\(([^()]+)\)/g)); - if (matches.length === 0) return null; - const candidate = matches[matches.length - 1]?.[1]?.trim(); - return candidate ? candidate : null; -} - -function resolveGeminiCliProjectId(file: AuthFileItem): string | null { - const metadata = - file && typeof file.metadata === 'object' && file.metadata !== null - ? (file.metadata as Record) - : null; - const attributes = - file && typeof file.attributes === 'object' && file.attributes !== null - ? (file.attributes as Record) - : null; - - const candidates = [ - file.account, - file['account'], - metadata?.account, - attributes?.account - ]; - - for (const candidate of candidates) { - const projectId = extractGeminiCliProjectId(candidate); - if (projectId) return projectId; - } - - return null; -} - -function parseAntigravityPayload(payload: unknown): Record | null { - if (payload === undefined || payload === null) return null; - if (typeof payload === 'string') { - const trimmed = payload.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as Record; - } catch { - return null; - } - } - if (typeof payload === 'object') { - return payload as Record; - } - return null; -} - -function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null { - if (payload === undefined || payload === null) return null; - if (typeof payload === 'string') { - const trimmed = payload.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as CodexUsagePayload; - } catch { - return null; - } - } - if (typeof payload === 'object') { - return payload as CodexUsagePayload; - } - return null; -} - -function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayload | null { - if (payload === undefined || payload === null) return null; - if (typeof payload === 'string') { - const trimmed = payload.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as GeminiCliQuotaPayload; - } catch { - return null; - } - } - if (typeof payload === 'object') { - return payload as GeminiCliQuotaPayload; - } - return null; -} - -function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): { - remainingFraction: number | null; - resetTime?: string; - displayName?: string; -} { - if (!entry) { - return { remainingFraction: null }; - } - const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {}; - const remainingValue = - quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining; - const remainingFraction = Number(remainingValue); - const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time; - const resetTime = typeof resetValue === 'string' ? resetValue : undefined; - const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined; - - return { - remainingFraction: Number.isFinite(remainingFraction) ? remainingFraction : null, - resetTime, - displayName - }; -} - -function findAntigravityModel( - models: AntigravityModelsPayload, - identifier: string -): { id: string; entry: AntigravityQuotaInfo } | null { - const direct = models[identifier]; - if (direct) { - return { id: identifier, entry: direct }; - } - - const match = Object.entries(models).find(([, entry]) => { - const name = typeof entry?.displayName === 'string' ? entry.displayName : ''; - return name.toLowerCase() === identifier.toLowerCase(); - }); - if (match) { - return { id: match[0], entry: match[1] }; - } - - return null; -} - -function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): AntigravityQuotaGroup[] { - const groups: AntigravityQuotaGroup[] = []; - let geminiResetTime: string | undefined; - const [claudeDef, geminiDef, flashDef, imageDef] = ANTIGRAVITY_QUOTA_GROUPS; - - const buildGroup = ( - def: AntigravityQuotaGroupDefinition, - overrideResetTime?: string - ): AntigravityQuotaGroup | null => { - const matches = def.identifiers - .map((identifier) => findAntigravityModel(models, identifier)) - .filter((entry): entry is { id: string; entry: AntigravityQuotaInfo } => Boolean(entry)); - - const quotaEntries = matches - .map(({ id, entry }) => { - const info = getAntigravityQuotaInfo(entry); - if (info.remainingFraction === null) return null; - return { - id, - remainingFraction: info.remainingFraction, - resetTime: info.resetTime, - displayName: info.displayName - }; - }) - .filter((entry): entry is NonNullable => entry !== null); - - if (quotaEntries.length === 0) return null; - - const remainingFraction = Math.min(...quotaEntries.map((entry) => entry.remainingFraction)); - const resetTime = - overrideResetTime ?? quotaEntries.map((entry) => entry.resetTime).find(Boolean); - const displayName = quotaEntries.map((entry) => entry.displayName).find(Boolean); - const label = def.labelFromModel && displayName ? displayName : def.label; - - return { - id: def.id, - label, - models: quotaEntries.map((entry) => entry.id), - remainingFraction, - resetTime - }; - }; - - const claudeGroup = buildGroup(claudeDef); - if (claudeGroup) { - groups.push(claudeGroup); - } - - const geminiGroup = buildGroup(geminiDef); - if (geminiGroup) { - geminiResetTime = geminiGroup.resetTime; - groups.push(geminiGroup); - } - - const flashGroup = buildGroup(flashDef); - if (flashGroup) { - groups.push(flashGroup); - } - - const imageGroup = buildGroup(imageDef, geminiResetTime); - if (imageGroup) { - groups.push(imageGroup); - } - - return groups; -} - -function formatQuotaResetTime(value?: string): string { - if (!value) return '-'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '-'; - return date.toLocaleString(undefined, { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }); -} - -function formatUnixSeconds(value: number | null): string { - if (!value) return '-'; - const date = new Date(value * 1000); - if (Number.isNaN(date.getTime())) return '-'; - return date.toLocaleString(undefined, { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }); -} - -function formatCodexResetLabel(window?: CodexUsageWindow | null): string { - if (!window) return '-'; - const resetAt = normalizeNumberValue(window.reset_at ?? window.resetAt); - if (resetAt !== null && resetAt > 0) { - return formatUnixSeconds(resetAt); - } - const resetAfter = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds); - if (resetAfter !== null && resetAfter > 0) { - const targetSeconds = Math.floor(Date.now() / 1000 + resetAfter); - return formatUnixSeconds(targetSeconds); - } - return '-'; -} - -function resolveAuthProvider(file: AuthFileItem): string { - const raw = file.provider ?? file.type ?? ''; - return String(raw).trim().toLowerCase(); -} - -function isAntigravityFile(file: AuthFileItem): boolean { - return resolveAuthProvider(file) === 'antigravity'; -} - -function isCodexFile(file: AuthFileItem): boolean { - return resolveAuthProvider(file) === 'codex'; -} - -function isGeminiCliFile(file: AuthFileItem): boolean { - return resolveAuthProvider(file) === 'gemini-cli'; -} - function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { const raw = file['runtime_only'] ?? file.runtimeOnly; if (typeof raw === 'boolean') return raw; @@ -751,32 +154,11 @@ export function AuthFilesPage() { const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(9); - const [antigravityPage, setAntigravityPage] = useState(1); - const [antigravityPageSize, setAntigravityPageSize] = useState(6); - const [codexPage, setCodexPage] = useState(1); - const [codexPageSize, setCodexPageSize] = useState(6); - const [geminiCliPage, setGeminiCliPage] = useState(1); - const [geminiCliPageSize, setGeminiCliPageSize] = useState(6); const [uploading, setUploading] = useState(false); const [deleting, setDeleting] = useState(null); const [deletingAll, setDeletingAll] = useState(false); const [keyStats, setKeyStats] = useState({ bySource: {}, byAuthIndex: {} }); const [usageDetails, setUsageDetails] = useState([]); - const [antigravityQuota, setAntigravityQuota] = useState>( - {} - ); - const [antigravityLoading, setAntigravityLoading] = useState(false); - const [antigravityLoadingScope, setAntigravityLoadingScope] = useState< - 'page' | 'all' | null - >(null); - const [codexQuota, setCodexQuota] = useState>({}); - const [codexLoading, setCodexLoading] = useState(false); - const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null); - const [geminiCliQuota, setGeminiCliQuota] = useState>({}); - const [geminiCliLoading, setGeminiCliLoading] = useState(false); - const [geminiCliLoadingScope, setGeminiCliLoadingScope] = useState< - 'page' | 'all' | null - >(null); // 详情弹窗相关 const [detailModalOpen, setDetailModalOpen] = useState(false); @@ -794,17 +176,11 @@ export function AuthFilesPage() { const [excluded, setExcluded] = useState>({}); const [excludedError, setExcludedError] = useState<'unsupported' | null>(null); const [excludedModalOpen, setExcludedModalOpen] = useState(false); - const [excludedForm, setExcludedForm] = useState({ provider: '', modelsText: '' }); - const [savingExcluded, setSavingExcluded] = useState(false); - + const [excludedForm, setExcludedForm] = useState({ provider: '', modelsText: '' }); + const [savingExcluded, setSavingExcluded] = useState(false); + const fileInputRef = useRef(null); const loadingKeyStatsRef = useRef(false); - const antigravityLoadingRef = useRef(false); - const antigravityRequestIdRef = useRef(0); - const codexLoadingRef = useRef(false); - const codexRequestIdRef = useRef(0); - const geminiCliLoadingRef = useRef(false); - const geminiCliRequestIdRef = useRef(0); const excludedUnsupportedRef = useRef(false); const disableControls = connectionStatus !== 'connected'; @@ -882,493 +258,11 @@ export function AuthFilesPage() { } }, [showNotification, t]); - const antigravityFiles = useMemo( - () => files.filter((file) => isAntigravityFile(file)), - [files] - ); - - const antigravityTotalPages = Math.max( - 1, - Math.ceil(antigravityFiles.length / antigravityPageSize) - ); - const antigravityCurrentPage = Math.min(antigravityPage, antigravityTotalPages); - const antigravityStart = (antigravityCurrentPage - 1) * antigravityPageSize; - const antigravityPageItems = antigravityFiles.slice( - antigravityStart, - antigravityStart + antigravityPageSize - ); - - const codexFiles = useMemo(() => files.filter((file) => isCodexFile(file)), [files]); - const codexTotalPages = Math.max(1, Math.ceil(codexFiles.length / codexPageSize)); - const codexCurrentPage = Math.min(codexPage, codexTotalPages); - const codexStart = (codexCurrentPage - 1) * codexPageSize; - const codexPageItems = codexFiles.slice(codexStart, codexStart + codexPageSize); - - const geminiCliFiles = useMemo( - () => files.filter((file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file)), - [files] - ); - const geminiCliTotalPages = Math.max(1, Math.ceil(geminiCliFiles.length / geminiCliPageSize)); - const geminiCliCurrentPage = Math.min(geminiCliPage, geminiCliTotalPages); - const geminiCliStart = (geminiCliCurrentPage - 1) * geminiCliPageSize; - const geminiCliPageItems = geminiCliFiles.slice( - geminiCliStart, - geminiCliStart + geminiCliPageSize - ); - - const fetchAntigravityQuota = useCallback( - async (authIndex: string): Promise => { - let lastError = ''; - let lastStatus: number | undefined; - let priorityStatus: number | undefined; - let hadSuccess = false; - - for (const url of ANTIGRAVITY_QUOTA_URLS) { - try { - const result = await apiCallApi.request({ - authIndex, - method: 'POST', - url, - header: { ...ANTIGRAVITY_REQUEST_HEADERS }, - data: '{}' - }); - - if (result.statusCode < 200 || result.statusCode >= 300) { - lastError = getApiCallErrorMessage(result); - lastStatus = result.statusCode; - if (result.statusCode === 403 || result.statusCode === 404) { - priorityStatus ??= result.statusCode; - } - continue; - } - - hadSuccess = true; - const payload = parseAntigravityPayload(result.body ?? result.bodyText); - const models = payload?.models; - if (!models || typeof models !== 'object' || Array.isArray(models)) { - lastError = t('antigravity_quota.empty_models'); - continue; - } - - const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload); - if (groups.length === 0) { - lastError = t('antigravity_quota.empty_models'); - continue; - } - - return groups; - } catch (err: unknown) { - lastError = err instanceof Error ? err.message : t('common.unknown_error'); - const status = getStatusFromError(err); - if (status) { - lastStatus = status; - if (status === 403 || status === 404) { - priorityStatus ??= status; - } - } - } - } - - if (hadSuccess) { - return []; - } - - throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus); - }, - [t] - ); - - const loadAntigravityQuota = useCallback( - async (targets: AuthFileItem[], scope: 'page' | 'all') => { - if (antigravityLoadingRef.current) return; - antigravityLoadingRef.current = true; - const requestId = ++antigravityRequestIdRef.current; - setAntigravityLoading(true); - setAntigravityLoadingScope(scope); - - try { - if (targets.length === 0) return; - - setAntigravityQuota((prev) => { - const nextState = { ...prev }; - targets.forEach((file) => { - nextState[file.name] = { status: 'loading', groups: [] }; - }); - return nextState; - }); - - const results = await Promise.all( - targets.map(async (file) => { - const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const authIndex = normalizeAuthIndexValue(rawAuthIndex); - if (!authIndex) { - return { - name: file.name, - status: 'error' as const, - error: t('antigravity_quota.missing_auth_index') - }; - } - - try { - const groups = await fetchAntigravityQuota(authIndex); - return { name: file.name, status: 'success' as const, groups }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('common.unknown_error'); - const errorStatus = getStatusFromError(err); - return { name: file.name, status: 'error' as const, error: message, errorStatus }; - } - }) - ); - - if (requestId !== antigravityRequestIdRef.current) return; - - setAntigravityQuota((prev) => { - const nextState = { ...prev }; - results.forEach((result) => { - if (result.status === 'success') { - nextState[result.name] = { - status: 'success', - groups: result.groups - }; - } else { - nextState[result.name] = { - status: 'error', - groups: [], - error: result.error, - errorStatus: result.errorStatus - }; - } - }); - return nextState; - }); - } finally { - if (requestId === antigravityRequestIdRef.current) { - setAntigravityLoading(false); - setAntigravityLoadingScope(null); - antigravityLoadingRef.current = false; - } - } - }, - [fetchAntigravityQuota, t] - ); - - const buildCodexQuotaWindows = useCallback( - (payload: CodexUsagePayload): CodexQuotaWindow[] => { - const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; - const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; - const windows: CodexQuotaWindow[] = []; - const addWindow = (id: string, label: string, window?: CodexUsageWindow | null) => { - if (!window) return; - const usedPercent = normalizeNumberValue(window.used_percent ?? window.usedPercent); - windows.push({ - id, - label, - usedPercent, - resetLabel: formatCodexResetLabel(window) - }); - }; - - addWindow('primary', t('codex_quota.primary_window'), rateLimit?.primary_window ?? rateLimit?.primaryWindow); - addWindow( - 'secondary', - t('codex_quota.secondary_window'), - rateLimit?.secondary_window ?? rateLimit?.secondaryWindow - ); - addWindow( - 'code-review', - t('codex_quota.code_review_window'), - codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow - ); - - return windows; - }, - [t] - ); - - const fetchCodexQuota = useCallback( - async (file: AuthFileItem): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => { - const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const authIndex = normalizeAuthIndexValue(rawAuthIndex); - if (!authIndex) { - throw new Error(t('codex_quota.missing_auth_index')); - } - - const planTypeFromFile = resolveCodexPlanType(file); - const accountId = resolveCodexChatgptAccountId(file); - if (!accountId) { - throw new Error(t('codex_quota.missing_account_id')); - } - - const requestUsage = async (requestHeader: Record) => { - const result = await apiCallApi.request({ - authIndex, - method: 'GET', - url: CODEX_USAGE_URL, - header: requestHeader - }); - if (result.statusCode < 200 || result.statusCode >= 300) { - throw createStatusError(getApiCallErrorMessage(result), result.statusCode); - } - const payload = parseCodexUsagePayload(result.body ?? result.bodyText); - if (!payload) { - throw new Error(t('codex_quota.empty_windows')); - } - return payload; - }; - - const baseHeader: Record = { - ...CODEX_REQUEST_HEADERS, - 'Chatgpt-Account-Id': accountId - }; - - const payload = await requestUsage(baseHeader); - const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType); - const windows = buildCodexQuotaWindows(payload); - return { planType: planTypeFromUsage ?? planTypeFromFile, windows }; - }, - [buildCodexQuotaWindows, t] - ); - - const loadCodexQuota = useCallback( - async (targets: AuthFileItem[], scope: 'page' | 'all') => { - if (codexLoadingRef.current) return; - codexLoadingRef.current = true; - const requestId = ++codexRequestIdRef.current; - setCodexLoading(true); - setCodexLoadingScope(scope); - - try { - if (targets.length === 0) return; - - setCodexQuota((prev) => { - const nextState = { ...prev }; - targets.forEach((file) => { - nextState[file.name] = { status: 'loading', windows: [] }; - }); - return nextState; - }); - - const results = await Promise.all( - targets.map(async (file) => { - try { - const { planType, windows } = await fetchCodexQuota(file); - return { name: file.name, status: 'success' as const, planType, windows }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('common.unknown_error'); - const errorStatus = getStatusFromError(err); - return { name: file.name, status: 'error' as const, error: message, errorStatus }; - } - }) - ); - - if (requestId !== codexRequestIdRef.current) return; - - setCodexQuota((prev) => { - const nextState = { ...prev }; - results.forEach((result) => { - if (result.status === 'success') { - nextState[result.name] = { - status: 'success', - windows: result.windows, - planType: result.planType - }; - } else { - nextState[result.name] = { - status: 'error', - windows: [], - error: result.error, - errorStatus: result.errorStatus - }; - } - }); - return nextState; - }); - } finally { - if (requestId === codexRequestIdRef.current) { - setCodexLoading(false); - setCodexLoadingScope(null); - codexLoadingRef.current = false; - } - } - }, - [fetchCodexQuota, t] - ); - - const fetchGeminiCliQuota = useCallback( - async (file: AuthFileItem): Promise => { - const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const authIndex = normalizeAuthIndexValue(rawAuthIndex); - if (!authIndex) { - throw new Error(t('gemini_cli_quota.missing_auth_index')); - } - - const projectId = resolveGeminiCliProjectId(file); - if (!projectId) { - throw new Error(t('gemini_cli_quota.missing_project_id')); - } - - const result = await apiCallApi.request({ - authIndex, - method: 'POST', - url: GEMINI_CLI_QUOTA_URL, - header: { ...GEMINI_CLI_REQUEST_HEADERS }, - data: JSON.stringify({ project: projectId }) - }); - - if (result.statusCode < 200 || result.statusCode >= 300) { - throw createStatusError(getApiCallErrorMessage(result), result.statusCode); - } - - const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText); - const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : []; - if (buckets.length === 0) return []; - - return buckets - .map((bucket, index) => { - const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id); - if (!modelId) return null; - const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type); - const remainingFraction = normalizeNumberValue( - bucket.remainingFraction ?? bucket.remaining_fraction - ); - const remainingAmount = normalizeNumberValue( - bucket.remainingAmount ?? bucket.remaining_amount - ); - const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined; - return { - id: `${modelId}-${tokenType ?? index}`, - label: modelId, - remainingFraction, - remainingAmount, - resetTime, - tokenType - }; - }) - .filter((bucket): bucket is GeminiCliQuotaBucketState => bucket !== null); - }, - [t] - ); - - const loadGeminiCliQuota = useCallback( - async (targets: AuthFileItem[], scope: 'page' | 'all') => { - if (geminiCliLoadingRef.current) return; - geminiCliLoadingRef.current = true; - const requestId = ++geminiCliRequestIdRef.current; - setGeminiCliLoading(true); - setGeminiCliLoadingScope(scope); - - try { - if (targets.length === 0) return; - - setGeminiCliQuota((prev) => { - const nextState = { ...prev }; - targets.forEach((file) => { - nextState[file.name] = { status: 'loading', buckets: [] }; - }); - return nextState; - }); - - const results = await Promise.all( - targets.map(async (file) => { - try { - const buckets = await fetchGeminiCliQuota(file); - return { name: file.name, status: 'success' as const, buckets }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('common.unknown_error'); - const errorStatus = getStatusFromError(err); - return { name: file.name, status: 'error' as const, error: message, errorStatus }; - } - }) - ); - - if (requestId !== geminiCliRequestIdRef.current) return; - - setGeminiCliQuota((prev) => { - const nextState = { ...prev }; - results.forEach((result) => { - if (result.status === 'success') { - nextState[result.name] = { - status: 'success', - buckets: result.buckets - }; - } else { - nextState[result.name] = { - status: 'error', - buckets: [], - error: result.error, - errorStatus: result.errorStatus - }; - } - }); - return nextState; - }); - } finally { - if (requestId === geminiCliRequestIdRef.current) { - setGeminiCliLoading(false); - setGeminiCliLoadingScope(null); - geminiCliLoadingRef.current = false; - } - } - }, - [fetchGeminiCliQuota, t] - ); - useEffect(() => { loadFiles(); loadKeyStats(); loadExcluded(); }, [loadFiles, loadKeyStats, loadExcluded]); - - useEffect(() => { - if (antigravityFiles.length === 0) { - setAntigravityQuota({}); - return; - } - setAntigravityQuota((prev) => { - const nextState: Record = {}; - antigravityFiles.forEach((file) => { - const cached = prev[file.name]; - if (cached) { - nextState[file.name] = cached; - } - }); - return nextState; - }); - }, [antigravityFiles]); - - useEffect(() => { - if (codexFiles.length === 0) { - setCodexQuota({}); - return; - } - setCodexQuota((prev) => { - const nextState: Record = {}; - codexFiles.forEach((file) => { - const cached = prev[file.name]; - if (cached) { - nextState[file.name] = cached; - } - }); - return nextState; - }); - }, [codexFiles]); - - useEffect(() => { - if (geminiCliFiles.length === 0) { - setGeminiCliQuota({}); - return; - } - setGeminiCliQuota((prev) => { - const nextState: Record = {}; - geminiCliFiles.forEach((file) => { - const cached = prev[file.name]; - if (cached) { - nextState[file.name] = cached; - } - }); - return nextState; - }); - }, [geminiCliFiles]); // 定时刷新状态数据(每240秒) useInterval(loadKeyStats, 240_000); @@ -1666,24 +560,6 @@ export function AuthFilesPage() { const set = TYPE_COLORS[type] || TYPE_COLORS.unknown; return resolvedTheme === 'dark' && set.dark ? set.dark : set.light; }; - - const getCodexPlanLabel = (planType?: string | null): string | null => { - const normalized = normalizePlanType(planType); - if (!normalized) return null; - if (normalized === 'plus') return t('codex_quota.plan_plus'); - if (normalized === 'team') return t('codex_quota.plan_team'); - if (normalized === 'free') return t('codex_quota.plan_free'); - return planType || normalized; - }; - - const getQuotaErrorMessage = useCallback( - (status: number | undefined, fallback: string) => { - if (status === 404) return t('common.quota_update_required'); - if (status === 403) return t('common.quota_check_credential'); - return fallback; - }, - [t] - ); // OAuth 排除相关方法 const openExcludedModal = (provider?: string) => { @@ -1922,274 +798,6 @@ export function AuthFilesPage() { ); }; - const renderAntigravityCard = (item: AuthFileItem) => { - const displayType = item.type || item.provider || 'antigravity'; - const typeColor = getTypeColor(displayType); - const quotaState = antigravityQuota[item.name]; - const quotaStatus = quotaState?.status ?? 'idle'; - const quotaGroups = quotaState?.groups ?? []; - const quotaErrorMessage = getQuotaErrorMessage( - quotaState?.errorStatus, - quotaState?.error || t('common.unknown_error') - ); - - return ( -
-
- - {getTypeLabel(displayType)} - - {item.name} -
- -
- {quotaStatus === 'loading' ? ( -
{t('antigravity_quota.loading')}
- ) : quotaStatus === 'idle' ? ( -
{t('antigravity_quota.idle')}
- ) : quotaStatus === 'error' ? ( -
- {t('antigravity_quota.load_failed', { - message: quotaErrorMessage - })} -
- ) : quotaGroups.length === 0 ? ( -
{t('antigravity_quota.empty_models')}
- ) : ( - quotaGroups.map((group) => { - const clamped = Math.max(0, Math.min(1, group.remainingFraction)); - const percent = Math.round(clamped * 100); - const resetLabel = formatQuotaResetTime(group.resetTime); - const quotaBarClass = - percent >= 60 - ? styles.quotaBarFillHigh - : percent >= 20 - ? styles.quotaBarFillMedium - : styles.quotaBarFillLow; - return ( -
-
- - {group.label} - -
- {percent}% - {resetLabel} -
-
-
-
-
-
- ); - }) - )} -
-
- ); - }; - - const renderCodexCard = (item: AuthFileItem) => { - const displayType = item.type || item.provider || 'codex'; - const typeColor = getTypeColor(displayType); - const quotaState = codexQuota[item.name]; - const quotaStatus = quotaState?.status ?? 'idle'; - const windows = quotaState?.windows ?? []; - const planType = quotaState?.planType ?? null; - const planLabel = getCodexPlanLabel(planType); - const isFreePlan = normalizePlanType(planType) === 'free'; - const quotaErrorMessage = getQuotaErrorMessage( - quotaState?.errorStatus, - quotaState?.error || t('common.unknown_error') - ); - - return ( -
-
- - {getTypeLabel(displayType)} - - {item.name} -
- -
- {quotaStatus === 'loading' ? ( -
{t('codex_quota.loading')}
- ) : quotaStatus === 'idle' ? ( -
{t('codex_quota.idle')}
- ) : quotaStatus === 'error' ? ( -
- {t('codex_quota.load_failed', { - message: quotaErrorMessage - })} -
- ) : ( - <> - {planLabel && ( -
- {t('codex_quota.plan_label')} - {planLabel} -
- )} - {isFreePlan ? ( -
{t('codex_quota.no_access')}
- ) : windows.length === 0 ? ( -
{t('codex_quota.empty_windows')}
- ) : ( - 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 quotaBarClass = - remaining === null - ? styles.quotaBarFillMedium - : remaining >= 80 - ? styles.quotaBarFillHigh - : remaining >= 50 - ? styles.quotaBarFillMedium - : styles.quotaBarFillLow; - - return ( -
-
- {window.label} -
- {percentLabel} - {window.resetLabel} -
-
-
-
-
-
- ); - }) - )} - - )} -
-
- ); - }; - - const renderGeminiCliCard = (item: AuthFileItem) => { - const displayType = item.type || item.provider || 'gemini-cli'; - const typeColor = getTypeColor(displayType); - const quotaState = geminiCliQuota[item.name]; - const quotaStatus = quotaState?.status ?? 'idle'; - const buckets = quotaState?.buckets ?? []; - const quotaErrorMessage = getQuotaErrorMessage( - quotaState?.errorStatus, - quotaState?.error || t('common.unknown_error') - ); - - return ( -
-
- - {getTypeLabel(displayType)} - - {item.name} -
- -
- {quotaStatus === 'loading' ? ( -
{t('gemini_cli_quota.loading')}
- ) : quotaStatus === 'idle' ? ( -
{t('gemini_cli_quota.idle')}
- ) : quotaStatus === 'error' ? ( -
- {t('gemini_cli_quota.load_failed', { - message: quotaErrorMessage - })} -
- ) : buckets.length === 0 ? ( -
{t('gemini_cli_quota.empty_buckets')}
- ) : ( - buckets.map((bucket) => { - const fraction = bucket.remainingFraction; - const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction)); - const percent = clamped === null ? null : Math.round(clamped * 100); - const percentLabel = percent === null ? '--' : `${percent}%`; - const resetLabel = formatQuotaResetTime(bucket.resetTime); - const remainingAmountLabel = - bucket.remainingAmount === null || bucket.remainingAmount === undefined - ? null - : t('gemini_cli_quota.remaining_amount', { - count: bucket.remainingAmount - }); - const quotaBarClass = - percent === null - ? styles.quotaBarFillMedium - : percent >= 60 - ? styles.quotaBarFillHigh - : percent >= 20 - ? styles.quotaBarFillMedium - : styles.quotaBarFillLow; - - return ( -
-
- - {bucket.label} - -
- {percentLabel} - {remainingAmountLabel && ( - {remainingAmountLabel} - )} - {resetLabel} -
-
-
-
-
-
- ); - }) - )} -
-
- ); - }; - return (
@@ -2312,276 +920,6 @@ export function AuthFilesPage() { )} - - - -
- } - > - {antigravityFiles.length === 0 ? ( - - ) : ( - <> -
-
- - -
-
- -
- {antigravityFiles.length} {t('auth_files.files_count')} -
-
-
-
- {antigravityPageItems.map(renderAntigravityCard)} -
- {antigravityFiles.length > antigravityPageSize && ( -
- -
- {t('auth_files.pagination_info', { - current: antigravityCurrentPage, - total: antigravityTotalPages, - count: antigravityFiles.length - })} -
- -
- )} - - )} - - - - - -
- } - > - {codexFiles.length === 0 ? ( - - ) : ( - <> -
-
- - -
-
- -
- {codexFiles.length} {t('auth_files.files_count')} -
-
-
-
{codexPageItems.map(renderCodexCard)}
- {codexFiles.length > codexPageSize && ( -
- -
- {t('auth_files.pagination_info', { - current: codexCurrentPage, - total: codexTotalPages, - count: codexFiles.length - })} -
- -
- )} - - )} - - - - - -
- } - > - {geminiCliFiles.length === 0 ? ( - - ) : ( - <> -
-
- - -
-
- -
- {geminiCliFiles.length} {t('auth_files.files_count')} -
-
-
-
{geminiCliPageItems.map(renderGeminiCliCard)}
- {geminiCliFiles.length > geminiCliPageSize && ( -
- -
- {t('auth_files.pagination_info', { - current: geminiCliCurrentPage, - total: geminiCliTotalPages, - count: geminiCliFiles.length - })} -
- -
- )} - - )} - - {/* OAuth 排除列表卡片 */} = { + qwen: { + light: { bg: '#e8f5e9', text: '#2e7d32' }, + dark: { bg: '#1b5e20', text: '#81c784' } + }, + gemini: { + light: { bg: '#e3f2fd', text: '#1565c0' }, + dark: { bg: '#0d47a1', text: '#64b5f6' } + }, + 'gemini-cli': { + light: { bg: '#e7efff', text: '#1e4fa3' }, + dark: { bg: '#1c3f73', text: '#a8c7ff' } + }, + aistudio: { + light: { bg: '#f0f2f5', text: '#2f343c' }, + dark: { bg: '#373c42', text: '#cfd3db' } + }, + claude: { + light: { bg: '#fce4ec', text: '#c2185b' }, + dark: { bg: '#880e4f', text: '#f48fb1' } + }, + codex: { + light: { bg: '#fff3e0', text: '#ef6c00' }, + dark: { bg: '#e65100', text: '#ffb74d' } + }, + antigravity: { + light: { bg: '#e0f7fa', text: '#006064' }, + dark: { bg: '#004d40', text: '#80deea' } + }, + iflow: { + light: { bg: '#f3e5f5', text: '#7b1fa2' }, + dark: { bg: '#4a148c', text: '#ce93d8' } + }, + empty: { + light: { bg: '#f5f5f5', text: '#616161' }, + dark: { bg: '#424242', text: '#bdbdbd' } + }, + unknown: { + light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' }, + dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' } + } +}; + +interface AntigravityQuotaGroup { + id: string; + label: string; + models: string[]; + remainingFraction: number; + resetTime?: string; +} + +interface AntigravityQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + groups: AntigravityQuotaGroup[]; + error?: string; + errorStatus?: number; +} + +interface GeminiCliQuotaBucket { + modelId?: string; + model_id?: string; + tokenType?: string; + token_type?: string; + remainingFraction?: number | string; + remaining_fraction?: number | string; + remainingAmount?: number | string; + remaining_amount?: number | string; + resetTime?: string; + reset_time?: string; +} + +interface GeminiCliQuotaPayload { + buckets?: GeminiCliQuotaBucket[]; +} + +interface GeminiCliQuotaBucketState { + id: string; + label: string; + remainingFraction: number | null; + remainingAmount: number | null; + resetTime: string | undefined; + tokenType: string | null; +} + +interface GeminiCliQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + buckets: GeminiCliQuotaBucketState[]; + error?: string; + errorStatus?: number; +} + +interface AntigravityQuotaInfo { + displayName?: string; + quotaInfo?: { + remainingFraction?: number | string; + remaining_fraction?: number | string; + remaining?: number | string; + resetTime?: string; + reset_time?: string; + }; + quota_info?: { + remainingFraction?: number | string; + remaining_fraction?: number | string; + remaining?: number | string; + resetTime?: string; + reset_time?: string; + }; +} + +type AntigravityModelsPayload = Record; + +interface AntigravityQuotaGroupDefinition { + id: string; + label: string; + identifiers: string[]; + labelFromModel?: boolean; +} + +const ANTIGRAVITY_QUOTA_URLS = [ + 'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels', + 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels', + 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels' +]; + +const ANTIGRAVITY_REQUEST_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + 'User-Agent': 'antigravity/1.11.5 windows/amd64' +}; + +const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [ + { + id: 'claude-gpt', + label: 'Claude/GPT', + identifiers: [ + 'claude-sonnet-4-5-thinking', + 'claude-opus-4-5-thinking', + 'claude-sonnet-4-5', + 'gpt-oss-120b-medium' + ] + }, + { + id: 'gemini', + label: 'Gemini', + identifiers: [ + 'gemini-3-pro-high', + 'gemini-3-pro-low', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + 'rev19-uic3-1p' + ] + }, + { + id: 'gemini-3-flash', + label: 'Gemini 3 Flash', + identifiers: ['gemini-3-flash'] + }, + { + id: 'gemini-image', + label: 'gemini-3-pro-image', + identifiers: ['gemini-3-pro-image'], + labelFromModel: true + } +]; + +const GEMINI_CLI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + +const GEMINI_CLI_REQUEST_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json' +}; + +interface CodexUsageWindow { + used_percent?: number | string; + usedPercent?: number | string; + limit_window_seconds?: number | string; + limitWindowSeconds?: number | string; + reset_after_seconds?: number | string; + resetAfterSeconds?: number | string; + reset_at?: number | string; + resetAt?: number | string; +} + +interface CodexRateLimitInfo { + allowed?: boolean; + limit_reached?: boolean; + primary_window?: CodexUsageWindow | null; + primaryWindow?: CodexUsageWindow | null; + secondary_window?: CodexUsageWindow | null; + secondaryWindow?: CodexUsageWindow | null; +} + +interface CodexUsagePayload { + plan_type?: string; + planType?: string; + rate_limit?: CodexRateLimitInfo | null; + rateLimit?: CodexRateLimitInfo | null; + code_review_rate_limit?: CodexRateLimitInfo | null; + codeReviewRateLimit?: CodexRateLimitInfo | null; +} + +interface CodexQuotaWindow { + id: string; + label: string; + usedPercent: number | null; + resetLabel: string; +} + +interface CodexQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + windows: CodexQuotaWindow[]; + planType?: string | null; + error?: string; + errorStatus?: number; +} + +const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; + +const CODEX_REQUEST_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal' +}; + +const createStatusError = (message: string, status?: number) => { + const error = new Error(message) as Error & { status?: number }; + if (status !== undefined) { + error.status = status; + } + return error; +}; + +const getStatusFromError = (err: unknown): number | undefined => { + if (typeof err === 'object' && err !== null && 'status' in err) { + const rawStatus = (err as { status?: unknown }).status; + if (typeof rawStatus === 'number' && Number.isFinite(rawStatus)) { + return rawStatus; + } + const asNumber = Number(rawStatus); + if (Number.isFinite(asNumber) && asNumber > 0) { + return asNumber; + } + } + return undefined; +}; + +// Normalize auth_index (align with usage.ts normalizeAuthIndex). +function normalizeAuthIndexValue(value: unknown): string | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + return null; +} + +function normalizeStringValue(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } + return null; +} + +function normalizeNumberValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function normalizePlanType(value: unknown): string | null { + const normalized = normalizeStringValue(value); + return normalized ? normalized.toLowerCase() : null; +} + +function decodeBase64UrlPayload(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const normalized = trimmed.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + if (typeof window !== 'undefined' && typeof window.atob === 'function') { + return window.atob(padded); + } + if (typeof atob === 'function') { + return atob(padded); + } + } catch { + return null; + } + return null; +} + +function parseIdTokenPayload(value: unknown): Record | null { + if (!value) return null; + if (typeof value === 'object') { + return Array.isArray(value) ? null : (value as Record); + } + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed) as Record; + if (parsed && typeof parsed === 'object') return parsed; + } catch { + } + const segments = trimmed.split('.'); + if (segments.length < 2) return null; + const decoded = decodeBase64UrlPayload(segments[1]); + if (!decoded) return null; + try { + const parsed = JSON.parse(decoded) as Record; + if (parsed && typeof parsed === 'object') return parsed; + } catch { + return null; + } + return null; +} + +function extractCodexChatgptAccountId(value: unknown): string | null { + const payload = parseIdTokenPayload(value); + if (!payload) return null; + return normalizeStringValue(payload.chatgpt_account_id ?? payload.chatgptAccountId); +} + +function resolveCodexChatgptAccountId(file: AuthFileItem): string | null { + const metadata = + file && typeof file.metadata === 'object' && file.metadata !== null + ? (file.metadata as Record) + : null; + const attributes = + file && typeof file.attributes === 'object' && file.attributes !== null + ? (file.attributes as Record) + : null; + + const candidates = [file.id_token, metadata?.id_token, attributes?.id_token]; + + for (const candidate of candidates) { + const id = extractCodexChatgptAccountId(candidate); + if (id) return id; + } + + return null; +} + +function resolveCodexPlanType(file: AuthFileItem): string | null { + const metadata = + file && typeof file.metadata === 'object' && file.metadata !== null + ? (file.metadata as Record) + : null; + const attributes = + file && typeof file.attributes === 'object' && file.attributes !== null + ? (file.attributes as Record) + : null; + const idToken = + file && typeof file.id_token === 'object' && file.id_token !== null + ? (file.id_token as Record) + : null; + const metadataIdToken = + metadata && typeof metadata.id_token === 'object' && metadata.id_token !== null + ? (metadata.id_token as Record) + : null; + const candidates = [ + file.plan_type, + file.planType, + file['plan_type'], + file['planType'], + file.id_token, + idToken?.plan_type, + idToken?.planType, + metadata?.plan_type, + metadata?.planType, + metadata?.id_token, + metadataIdToken?.plan_type, + metadataIdToken?.planType, + attributes?.plan_type, + attributes?.planType, + attributes?.id_token + ]; + + for (const candidate of candidates) { + const planType = normalizePlanType(candidate); + if (planType) return planType; + } + + return null; +} + +function extractGeminiCliProjectId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const matches = Array.from(value.matchAll(/\(([^()]+)\)/g)); + if (matches.length === 0) return null; + const candidate = matches[matches.length - 1]?.[1]?.trim(); + return candidate ? candidate : null; +} + +function resolveGeminiCliProjectId(file: AuthFileItem): string | null { + const metadata = + file && typeof file.metadata === 'object' && file.metadata !== null + ? (file.metadata as Record) + : null; + const attributes = + file && typeof file.attributes === 'object' && file.attributes !== null + ? (file.attributes as Record) + : null; + + const candidates = [ + file.account, + file['account'], + metadata?.account, + attributes?.account + ]; + + for (const candidate of candidates) { + const projectId = extractGeminiCliProjectId(candidate); + if (projectId) return projectId; + } + + return null; +} + +function parseAntigravityPayload(payload: unknown): Record | null { + if (payload === undefined || payload === null) return null; + if (typeof payload === 'string') { + const trimmed = payload.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as Record; + } catch { + return null; + } + } + if (typeof payload === 'object') { + return payload as Record; + } + return null; +} + +function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null { + if (payload === undefined || payload === null) return null; + if (typeof payload === 'string') { + const trimmed = payload.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as CodexUsagePayload; + } catch { + return null; + } + } + if (typeof payload === 'object') { + return payload as CodexUsagePayload; + } + return null; +} + +function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayload | null { + if (payload === undefined || payload === null) return null; + if (typeof payload === 'string') { + const trimmed = payload.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as GeminiCliQuotaPayload; + } catch { + return null; + } + } + if (typeof payload === 'object') { + return payload as GeminiCliQuotaPayload; + } + return null; +} + +function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): { + remainingFraction: number | null; + resetTime?: string; + displayName?: string; +} { + if (!entry) { + return { remainingFraction: null }; + } + const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {}; + const remainingValue = + quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining; + const remainingFraction = Number(remainingValue); + const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time; + const resetTime = typeof resetValue === 'string' ? resetValue : undefined; + const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined; + + return { + remainingFraction: Number.isFinite(remainingFraction) ? remainingFraction : null, + resetTime, + displayName + }; +} + +function findAntigravityModel( + models: AntigravityModelsPayload, + identifier: string +): { id: string; entry: AntigravityQuotaInfo } | null { + const direct = models[identifier]; + if (direct) { + return { id: identifier, entry: direct }; + } + + const match = Object.entries(models).find(([, entry]) => { + const name = typeof entry?.displayName === 'string' ? entry.displayName : ''; + return name.toLowerCase() === identifier.toLowerCase(); + }); + if (match) { + return { id: match[0], entry: match[1] }; + } + + return null; +} + +function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): AntigravityQuotaGroup[] { + const groups: AntigravityQuotaGroup[] = []; + let geminiResetTime: string | undefined; + const [claudeDef, geminiDef, flashDef, imageDef] = ANTIGRAVITY_QUOTA_GROUPS; + + const buildGroup = ( + def: AntigravityQuotaGroupDefinition, + overrideResetTime?: string + ): AntigravityQuotaGroup | null => { + const matches = def.identifiers + .map((identifier) => findAntigravityModel(models, identifier)) + .filter((entry): entry is { id: string; entry: AntigravityQuotaInfo } => Boolean(entry)); + + const quotaEntries = matches + .map(({ id, entry }) => { + const info = getAntigravityQuotaInfo(entry); + if (info.remainingFraction === null) return null; + return { + id, + remainingFraction: info.remainingFraction, + resetTime: info.resetTime, + displayName: info.displayName + }; + }) + .filter((entry): entry is NonNullable => entry !== null); + + if (quotaEntries.length === 0) return null; + + const remainingFraction = Math.min(...quotaEntries.map((entry) => entry.remainingFraction)); + const resetTime = + overrideResetTime ?? quotaEntries.map((entry) => entry.resetTime).find(Boolean); + const displayName = quotaEntries.map((entry) => entry.displayName).find(Boolean); + const label = def.labelFromModel && displayName ? displayName : def.label; + + return { + id: def.id, + label, + models: quotaEntries.map((entry) => entry.id), + remainingFraction, + resetTime + }; + }; + + const claudeGroup = buildGroup(claudeDef); + if (claudeGroup) { + groups.push(claudeGroup); + } + + const geminiGroup = buildGroup(geminiDef); + if (geminiGroup) { + geminiResetTime = geminiGroup.resetTime; + groups.push(geminiGroup); + } + + const flashGroup = buildGroup(flashDef); + if (flashGroup) { + groups.push(flashGroup); + } + + const imageGroup = buildGroup(imageDef, geminiResetTime); + if (imageGroup) { + groups.push(imageGroup); + } + + return groups; +} + +function formatQuotaResetTime(value?: string): string { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); +} + +function formatUnixSeconds(value: number | null): string { + if (!value) return '-'; + const date = new Date(value * 1000); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); +} + +function formatCodexResetLabel(window?: CodexUsageWindow | null): string { + if (!window) return '-'; + const resetAt = normalizeNumberValue(window.reset_at ?? window.resetAt); + if (resetAt !== null && resetAt > 0) { + return formatUnixSeconds(resetAt); + } + const resetAfter = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds); + if (resetAfter !== null && resetAfter > 0) { + const targetSeconds = Math.floor(Date.now() / 1000 + resetAfter); + return formatUnixSeconds(targetSeconds); + } + return '-'; +} + +function resolveAuthProvider(file: AuthFileItem): string { + const raw = file.provider ?? file.type ?? ''; + return String(raw).trim().toLowerCase(); +} + +function isAntigravityFile(file: AuthFileItem): boolean { + return resolveAuthProvider(file) === 'antigravity'; +} + +function isCodexFile(file: AuthFileItem): boolean { + return resolveAuthProvider(file) === 'codex'; +} + +function isGeminiCliFile(file: AuthFileItem): boolean { + return resolveAuthProvider(file) === 'gemini-cli'; +} + +function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { + const raw = file['runtime_only'] ?? file.runtimeOnly; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true'; + return false; +} + +export function QuotaPage() { + const { t } = useTranslation(); + const connectionStatus = useAuthStore((state) => state.connectionStatus); + const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); + + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [antigravityPage, setAntigravityPage] = useState(1); + const [antigravityPageSize, setAntigravityPageSize] = useState(6); + const [codexPage, setCodexPage] = useState(1); + const [codexPageSize, setCodexPageSize] = useState(6); + const [geminiCliPage, setGeminiCliPage] = useState(1); + const [geminiCliPageSize, setGeminiCliPageSize] = useState(6); + const [antigravityQuota, setAntigravityQuota] = useState>( + {} + ); + const [antigravityLoading, setAntigravityLoading] = useState(false); + const [antigravityLoadingScope, setAntigravityLoadingScope] = useState< + 'page' | 'all' | null + >(null); + const [codexQuota, setCodexQuota] = useState>({}); + const [codexLoading, setCodexLoading] = useState(false); + const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null); + const [geminiCliQuota, setGeminiCliQuota] = useState>({}); + const [geminiCliLoading, setGeminiCliLoading] = useState(false); + const [geminiCliLoadingScope, setGeminiCliLoadingScope] = useState< + 'page' | 'all' | null + >(null); + + const antigravityLoadingRef = useRef(false); + const antigravityRequestIdRef = useRef(0); + const codexLoadingRef = useRef(false); + const codexRequestIdRef = useRef(0); + const geminiCliLoadingRef = useRef(false); + const geminiCliRequestIdRef = useRef(0); + + const disableControls = connectionStatus !== 'connected'; + + const loadFiles = useCallback(async () => { + setLoading(true); + setError(''); + try { + const data = await authFilesApi.list(); + setFiles(data?.files || []); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed'); + setError(errorMessage); + } finally { + setLoading(false); + } + }, [t]); + + const antigravityFiles = useMemo( + () => files.filter((file) => isAntigravityFile(file)), + [files] + ); + const antigravityTotalPages = Math.max( + 1, + Math.ceil(antigravityFiles.length / antigravityPageSize) + ); + const antigravityCurrentPage = Math.min(antigravityPage, antigravityTotalPages); + const antigravityStart = (antigravityCurrentPage - 1) * antigravityPageSize; + const antigravityPageItems = antigravityFiles.slice( + antigravityStart, + antigravityStart + antigravityPageSize + ); + + const codexFiles = useMemo(() => files.filter((file) => isCodexFile(file)), [files]); + const codexTotalPages = Math.max(1, Math.ceil(codexFiles.length / codexPageSize)); + const codexCurrentPage = Math.min(codexPage, codexTotalPages); + const codexStart = (codexCurrentPage - 1) * codexPageSize; + const codexPageItems = codexFiles.slice(codexStart, codexStart + codexPageSize); + + const geminiCliFiles = useMemo( + () => files.filter((file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file)), + [files] + ); + const geminiCliTotalPages = Math.max(1, Math.ceil(geminiCliFiles.length / geminiCliPageSize)); + const geminiCliCurrentPage = Math.min(geminiCliPage, geminiCliTotalPages); + const geminiCliStart = (geminiCliCurrentPage - 1) * geminiCliPageSize; + const geminiCliPageItems = geminiCliFiles.slice( + geminiCliStart, + geminiCliStart + geminiCliPageSize + ); + + const fetchAntigravityQuota = useCallback( + async (authIndex: string): Promise => { + let lastError = ''; + let lastStatus: number | undefined; + let priorityStatus: number | undefined; + let hadSuccess = false; + + for (const url of ANTIGRAVITY_QUOTA_URLS) { + try { + const result = await apiCallApi.request({ + authIndex, + method: 'POST', + url, + header: { ...ANTIGRAVITY_REQUEST_HEADERS }, + data: '{}' + }); + + if (result.statusCode < 200 || result.statusCode >= 300) { + lastError = getApiCallErrorMessage(result); + lastStatus = result.statusCode; + if (result.statusCode === 403 || result.statusCode === 404) { + priorityStatus ??= result.statusCode; + } + continue; + } + + hadSuccess = true; + const payload = parseAntigravityPayload(result.body ?? result.bodyText); + const models = payload?.models; + if (!models || typeof models !== 'object' || Array.isArray(models)) { + lastError = t('antigravity_quota.empty_models'); + continue; + } + + const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload); + if (groups.length === 0) { + lastError = t('antigravity_quota.empty_models'); + continue; + } + + return groups; + } catch (err: unknown) { + lastError = err instanceof Error ? err.message : t('common.unknown_error'); + const status = getStatusFromError(err); + if (status) { + lastStatus = status; + if (status === 403 || status === 404) { + priorityStatus ??= status; + } + } + } + } + + if (hadSuccess) { + return []; + } + + throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus); + }, + [t] + ); + + const loadAntigravityQuota = useCallback( + async (targets: AuthFileItem[], scope: 'page' | 'all') => { + if (antigravityLoadingRef.current) return; + antigravityLoadingRef.current = true; + const requestId = ++antigravityRequestIdRef.current; + setAntigravityLoading(true); + setAntigravityLoadingScope(scope); + + try { + if (targets.length === 0) return; + + setAntigravityQuota((prev) => { + const nextState = { ...prev }; + targets.forEach((file) => { + nextState[file.name] = { status: 'loading', groups: [] }; + }); + return nextState; + }); + + const results = await Promise.all( + targets.map(async (file) => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndexValue(rawAuthIndex); + if (!authIndex) { + return { + name: file.name, + status: 'error' as const, + error: t('antigravity_quota.missing_auth_index') + }; + } + + try { + const groups = await fetchAntigravityQuota(authIndex); + return { name: file.name, status: 'success' as const, groups }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + const errorStatus = getStatusFromError(err); + return { name: file.name, status: 'error' as const, error: message, errorStatus }; + } + }) + ); + + if (requestId !== antigravityRequestIdRef.current) return; + + setAntigravityQuota((prev) => { + const nextState = { ...prev }; + results.forEach((result) => { + if (result.status === 'success') { + nextState[result.name] = { + status: 'success', + groups: result.groups + }; + } else { + nextState[result.name] = { + status: 'error', + groups: [], + error: result.error, + errorStatus: result.errorStatus + }; + } + }); + return nextState; + }); + } finally { + if (requestId === antigravityRequestIdRef.current) { + setAntigravityLoading(false); + setAntigravityLoadingScope(null); + antigravityLoadingRef.current = false; + } + } + }, + [fetchAntigravityQuota, t] + ); + + const buildCodexQuotaWindows = useCallback( + (payload: CodexUsagePayload): CodexQuotaWindow[] => { + const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; + const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; + const windows: CodexQuotaWindow[] = []; + const addWindow = (id: string, label: string, window?: CodexUsageWindow | null) => { + if (!window) return; + const usedPercent = normalizeNumberValue(window.used_percent ?? window.usedPercent); + windows.push({ + id, + label, + usedPercent, + resetLabel: formatCodexResetLabel(window) + }); + }; + + addWindow('primary', t('codex_quota.primary_window'), rateLimit?.primary_window ?? rateLimit?.primaryWindow); + addWindow( + 'secondary', + t('codex_quota.secondary_window'), + rateLimit?.secondary_window ?? rateLimit?.secondaryWindow + ); + addWindow( + 'code-review', + t('codex_quota.code_review_window'), + codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow + ); + + return windows; + }, + [t] + ); + + const fetchCodexQuota = useCallback( + async (file: AuthFileItem): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndexValue(rawAuthIndex); + if (!authIndex) { + throw new Error(t('codex_quota.missing_auth_index')); + } + + const planTypeFromFile = resolveCodexPlanType(file); + const accountId = resolveCodexChatgptAccountId(file); + if (!accountId) { + throw new Error(t('codex_quota.missing_account_id')); + } + + const requestUsage = async (requestHeader: Record) => { + const result = await apiCallApi.request({ + authIndex, + method: 'GET', + url: CODEX_USAGE_URL, + header: requestHeader + }); + if (result.statusCode < 200 || result.statusCode >= 300) { + throw createStatusError(getApiCallErrorMessage(result), result.statusCode); + } + const payload = parseCodexUsagePayload(result.body ?? result.bodyText); + if (!payload) { + throw new Error(t('codex_quota.empty_windows')); + } + return payload; + }; + + const baseHeader: Record = { + ...CODEX_REQUEST_HEADERS, + 'Chatgpt-Account-Id': accountId + }; + + const payload = await requestUsage(baseHeader); + const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType); + const windows = buildCodexQuotaWindows(payload); + return { planType: planTypeFromUsage ?? planTypeFromFile, windows }; + }, + [buildCodexQuotaWindows, t] + ); + + const loadCodexQuota = useCallback( + async (targets: AuthFileItem[], scope: 'page' | 'all') => { + if (codexLoadingRef.current) return; + codexLoadingRef.current = true; + const requestId = ++codexRequestIdRef.current; + setCodexLoading(true); + setCodexLoadingScope(scope); + + try { + if (targets.length === 0) return; + + setCodexQuota((prev) => { + const nextState = { ...prev }; + targets.forEach((file) => { + nextState[file.name] = { status: 'loading', windows: [] }; + }); + return nextState; + }); + + const results = await Promise.all( + targets.map(async (file) => { + try { + const { planType, windows } = await fetchCodexQuota(file); + return { name: file.name, status: 'success' as const, planType, windows }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + const errorStatus = getStatusFromError(err); + return { name: file.name, status: 'error' as const, error: message, errorStatus }; + } + }) + ); + + if (requestId !== codexRequestIdRef.current) return; + + setCodexQuota((prev) => { + const nextState = { ...prev }; + results.forEach((result) => { + if (result.status === 'success') { + nextState[result.name] = { + status: 'success', + windows: result.windows, + planType: result.planType + }; + } else { + nextState[result.name] = { + status: 'error', + windows: [], + error: result.error, + errorStatus: result.errorStatus + }; + } + }); + return nextState; + }); + } finally { + if (requestId === codexRequestIdRef.current) { + setCodexLoading(false); + setCodexLoadingScope(null); + codexLoadingRef.current = false; + } + } + }, + [fetchCodexQuota, t] + ); + + const fetchGeminiCliQuota = useCallback( + async (file: AuthFileItem): Promise => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndexValue(rawAuthIndex); + if (!authIndex) { + throw new Error(t('gemini_cli_quota.missing_auth_index')); + } + + const projectId = resolveGeminiCliProjectId(file); + if (!projectId) { + throw new Error(t('gemini_cli_quota.missing_project_id')); + } + + const result = await apiCallApi.request({ + authIndex, + method: 'POST', + url: GEMINI_CLI_QUOTA_URL, + header: { ...GEMINI_CLI_REQUEST_HEADERS }, + data: JSON.stringify({ project: projectId }) + }); + + if (result.statusCode < 200 || result.statusCode >= 300) { + throw createStatusError(getApiCallErrorMessage(result), result.statusCode); + } + + const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText); + const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : []; + if (buckets.length === 0) return []; + + return buckets + .map((bucket, index) => { + const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id); + if (!modelId) return null; + const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type); + const remainingFraction = normalizeNumberValue( + bucket.remainingFraction ?? bucket.remaining_fraction + ); + const remainingAmount = normalizeNumberValue( + bucket.remainingAmount ?? bucket.remaining_amount + ); + const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined; + return { + id: `${modelId}-${tokenType ?? index}`, + label: modelId, + remainingFraction, + remainingAmount, + resetTime, + tokenType + }; + }) + .filter((bucket): bucket is GeminiCliQuotaBucketState => bucket !== null); + }, + [t] + ); + + const loadGeminiCliQuota = useCallback( + async (targets: AuthFileItem[], scope: 'page' | 'all') => { + if (geminiCliLoadingRef.current) return; + geminiCliLoadingRef.current = true; + const requestId = ++geminiCliRequestIdRef.current; + setGeminiCliLoading(true); + setGeminiCliLoadingScope(scope); + + try { + if (targets.length === 0) return; + + setGeminiCliQuota((prev) => { + const nextState = { ...prev }; + targets.forEach((file) => { + nextState[file.name] = { status: 'loading', buckets: [] }; + }); + return nextState; + }); + + const results = await Promise.all( + targets.map(async (file) => { + try { + const buckets = await fetchGeminiCliQuota(file); + return { name: file.name, status: 'success' as const, buckets }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + const errorStatus = getStatusFromError(err); + return { name: file.name, status: 'error' as const, error: message, errorStatus }; + } + }) + ); + + if (requestId !== geminiCliRequestIdRef.current) return; + + setGeminiCliQuota((prev) => { + const nextState = { ...prev }; + results.forEach((result) => { + if (result.status === 'success') { + nextState[result.name] = { + status: 'success', + buckets: result.buckets + }; + } else { + nextState[result.name] = { + status: 'error', + buckets: [], + error: result.error, + errorStatus: result.errorStatus + }; + } + }); + return nextState; + }); + } finally { + if (requestId === geminiCliRequestIdRef.current) { + setGeminiCliLoading(false); + setGeminiCliLoadingScope(null); + geminiCliLoadingRef.current = false; + } + } + }, + [fetchGeminiCliQuota, t] + ); + + useEffect(() => { + loadFiles(); + }, [loadFiles]); + + useEffect(() => { + if (antigravityFiles.length === 0) { + setAntigravityQuota({}); + return; + } + setAntigravityQuota((prev) => { + const nextState: Record = {}; + antigravityFiles.forEach((file) => { + const cached = prev[file.name]; + if (cached) { + nextState[file.name] = cached; + } + }); + return nextState; + }); + }, [antigravityFiles]); + + useEffect(() => { + if (codexFiles.length === 0) { + setCodexQuota({}); + return; + } + setCodexQuota((prev) => { + const nextState: Record = {}; + codexFiles.forEach((file) => { + const cached = prev[file.name]; + if (cached) { + nextState[file.name] = cached; + } + }); + return nextState; + }); + }, [codexFiles]); + + useEffect(() => { + if (geminiCliFiles.length === 0) { + setGeminiCliQuota({}); + return; + } + setGeminiCliQuota((prev) => { + const nextState: Record = {}; + geminiCliFiles.forEach((file) => { + const cached = prev[file.name]; + if (cached) { + nextState[file.name] = cached; + } + }); + return nextState; + }); + }, [geminiCliFiles]); + + // Resolve type label text for badges. + const getTypeLabel = (type: string): string => { + const key = `auth_files.filter_${type}`; + const translated = t(key); + if (translated !== key) return translated; + if (type.toLowerCase() === 'iflow') return 'iFlow'; + return type.charAt(0).toUpperCase() + type.slice(1); + }; + + // Resolve type colors for badges. + const getTypeColor = (type: string): ThemeColors => { + const set = TYPE_COLORS[type] || TYPE_COLORS.unknown; + return resolvedTheme === 'dark' && set.dark ? set.dark : set.light; + }; + + const getCodexPlanLabel = (planType?: string | null): string | null => { + const normalized = normalizePlanType(planType); + if (!normalized) return null; + if (normalized === 'plus') return t('codex_quota.plan_plus'); + if (normalized === 'team') return t('codex_quota.plan_team'); + if (normalized === 'free') return t('codex_quota.plan_free'); + return planType || normalized; + }; + + const getQuotaErrorMessage = useCallback( + (status: number | undefined, fallback: string) => { + if (status === 404) return t('common.quota_update_required'); + if (status === 403) return t('common.quota_check_credential'); + return fallback; + }, + [t] + ); + + const renderAntigravityCard = (item: AuthFileItem) => { + const displayType = item.type || item.provider || 'antigravity'; + const typeColor = getTypeColor(displayType); + const quotaState = antigravityQuota[item.name]; + const quotaStatus = quotaState?.status ?? 'idle'; + const quotaGroups = quotaState?.groups ?? []; + const quotaErrorMessage = getQuotaErrorMessage( + quotaState?.errorStatus, + quotaState?.error || t('common.unknown_error') + ); + + return ( +
+
+ + {getTypeLabel(displayType)} + + {item.name} +
+ +
+ {quotaStatus === 'loading' ? ( +
{t('antigravity_quota.loading')}
+ ) : quotaStatus === 'idle' ? ( +
{t('antigravity_quota.idle')}
+ ) : quotaStatus === 'error' ? ( +
+ {t('antigravity_quota.load_failed', { + message: quotaErrorMessage + })} +
+ ) : quotaGroups.length === 0 ? ( +
{t('antigravity_quota.empty_models')}
+ ) : ( + quotaGroups.map((group) => { + const clamped = Math.max(0, Math.min(1, group.remainingFraction)); + const percent = Math.round(clamped * 100); + const resetLabel = formatQuotaResetTime(group.resetTime); + const quotaBarClass = + percent >= 60 + ? styles.quotaBarFillHigh + : percent >= 20 + ? styles.quotaBarFillMedium + : styles.quotaBarFillLow; + return ( +
+
+ + {group.label} + +
+ {percent}% + {resetLabel} +
+
+
+
+
+
+ ); + }) + )} +
+
+ ); + }; + + const renderCodexCard = (item: AuthFileItem) => { + const displayType = item.type || item.provider || 'codex'; + const typeColor = getTypeColor(displayType); + const quotaState = codexQuota[item.name]; + const quotaStatus = quotaState?.status ?? 'idle'; + const windows = quotaState?.windows ?? []; + const planType = quotaState?.planType ?? null; + const planLabel = getCodexPlanLabel(planType); + const isFreePlan = normalizePlanType(planType) === 'free'; + const quotaErrorMessage = getQuotaErrorMessage( + quotaState?.errorStatus, + quotaState?.error || t('common.unknown_error') + ); + + return ( +
+
+ + {getTypeLabel(displayType)} + + {item.name} +
+ +
+ {quotaStatus === 'loading' ? ( +
{t('codex_quota.loading')}
+ ) : quotaStatus === 'idle' ? ( +
{t('codex_quota.idle')}
+ ) : quotaStatus === 'error' ? ( +
+ {t('codex_quota.load_failed', { + message: quotaErrorMessage + })} +
+ ) : ( + <> + {planLabel && ( +
+ {t('codex_quota.plan_label')} + {planLabel} +
+ )} + {isFreePlan ? ( +
{t('codex_quota.no_access')}
+ ) : windows.length === 0 ? ( +
{t('codex_quota.empty_windows')}
+ ) : ( + 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 quotaBarClass = + remaining === null + ? styles.quotaBarFillMedium + : remaining >= 80 + ? styles.quotaBarFillHigh + : remaining >= 50 + ? styles.quotaBarFillMedium + : styles.quotaBarFillLow; + + return ( +
+
+ {window.label} +
+ {percentLabel} + {window.resetLabel} +
+
+
+
+
+
+ ); + }) + )} + + )} +
+
+ ); + }; + + const renderGeminiCliCard = (item: AuthFileItem) => { + const displayType = item.type || item.provider || 'gemini-cli'; + const typeColor = getTypeColor(displayType); + const quotaState = geminiCliQuota[item.name]; + const quotaStatus = quotaState?.status ?? 'idle'; + const buckets = quotaState?.buckets ?? []; + const quotaErrorMessage = getQuotaErrorMessage( + quotaState?.errorStatus, + quotaState?.error || t('common.unknown_error') + ); + + return ( +
+
+ + {getTypeLabel(displayType)} + + {item.name} +
+ +
+ {quotaStatus === 'loading' ? ( +
{t('gemini_cli_quota.loading')}
+ ) : quotaStatus === 'idle' ? ( +
{t('gemini_cli_quota.idle')}
+ ) : quotaStatus === 'error' ? ( +
+ {t('gemini_cli_quota.load_failed', { + message: quotaErrorMessage + })} +
+ ) : buckets.length === 0 ? ( +
{t('gemini_cli_quota.empty_buckets')}
+ ) : ( + buckets.map((bucket) => { + const fraction = bucket.remainingFraction; + const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction)); + const percent = clamped === null ? null : Math.round(clamped * 100); + const percentLabel = percent === null ? '--' : `${percent}%`; + const resetLabel = formatQuotaResetTime(bucket.resetTime); + const remainingAmountLabel = + bucket.remainingAmount === null || bucket.remainingAmount === undefined + ? null + : t('gemini_cli_quota.remaining_amount', { + count: bucket.remainingAmount + }); + const quotaBarClass = + percent === null + ? styles.quotaBarFillMedium + : percent >= 60 + ? styles.quotaBarFillHigh + : percent >= 20 + ? styles.quotaBarFillMedium + : styles.quotaBarFillLow; + + return ( +
+
+ + {bucket.label} + +
+ {percentLabel} + {remainingAmountLabel && ( + {remainingAmountLabel} + )} + {resetLabel} +
+
+
+
+
+
+ ); + }) + )} +
+
+ ); + }; + + return ( +
+
+

{t('quota_management.title')}

+

{t('quota_management.description')}

+
+ +
+
+ + {error &&
{error}
} + + + + +
+ } + > + {antigravityFiles.length === 0 ? ( + + ) : ( + <> +
+
+ + +
+
+ +
+ {antigravityFiles.length} {t('auth_files.files_count')} +
+
+
+
+ {antigravityPageItems.map(renderAntigravityCard)} +
+ {antigravityFiles.length > antigravityPageSize && ( +
+ +
+ {t('auth_files.pagination_info', { + current: antigravityCurrentPage, + total: antigravityTotalPages, + count: antigravityFiles.length + })} +
+ +
+ )} + + )} + + + + + +
+ } + > + {codexFiles.length === 0 ? ( + + ) : ( + <> +
+
+ + +
+
+ +
+ {codexFiles.length} {t('auth_files.files_count')} +
+
+
+
{codexPageItems.map(renderCodexCard)}
+ {codexFiles.length > codexPageSize && ( +
+ +
+ {t('auth_files.pagination_info', { + current: codexCurrentPage, + total: codexTotalPages, + count: codexFiles.length + })} +
+ +
+ )} + + )} + + + + + +
+ } + > + {geminiCliFiles.length === 0 ? ( + + ) : ( + <> +
+
+ + +
+
+ +
+ {geminiCliFiles.length} {t('auth_files.files_count')} +
+
+
+
{geminiCliPageItems.map(renderGeminiCliCard)}
+ {geminiCliFiles.length > geminiCliPageSize && ( +
+ +
+ {t('auth_files.pagination_info', { + current: geminiCliCurrentPage, + total: geminiCliTotalPages, + count: geminiCliFiles.length + })} +
+ +
+ )} + + )} + +
+ ); +}