/** * Quota configuration definitions. */ import React from 'react'; import type { ReactNode } from 'react'; import type { TFunction } from 'i18next'; import type { AntigravityQuotaGroup, AntigravityModelsPayload, AntigravityQuotaState, AuthFileItem, CodexRateLimitInfo, CodexQuotaState, CodexUsageWindow, CodexQuotaWindow, CodexUsagePayload, GeminiCliParsedBucket, GeminiCliQuotaBucketState, GeminiCliQuotaState, } from '@/types'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; import { ANTIGRAVITY_QUOTA_URLS, ANTIGRAVITY_REQUEST_HEADERS, CODEX_USAGE_URL, CODEX_REQUEST_HEADERS, GEMINI_CLI_QUOTA_URL, GEMINI_CLI_REQUEST_HEADERS, normalizeAuthIndexValue, normalizeGeminiCliModelId, normalizeNumberValue, normalizePlanType, normalizeQuotaFraction, normalizeStringValue, parseAntigravityPayload, parseCodexUsagePayload, parseGeminiCliQuotaPayload, resolveCodexChatgptAccountId, resolveCodexPlanType, resolveGeminiCliProjectId, formatCodexResetLabel, formatQuotaResetTime, buildAntigravityQuotaGroups, buildGeminiCliQuotaBuckets, createStatusError, getStatusFromError, isAntigravityFile, isCodexFile, isDisabledAuthFile, isGeminiCliFile, isRuntimeOnlyAuthFile, } from '@/utils/quota'; import type { QuotaRenderHelpers } from './QuotaCard'; import styles from '@/pages/QuotaPage.module.scss'; type QuotaUpdater = T | ((prev: T) => T); type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn'; export interface QuotaStore { antigravityQuota: Record; codexQuota: Record; geminiCliQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; } export interface QuotaConfig { type: QuotaType; i18nPrefix: string; filterFn: (file: AuthFileItem) => boolean; fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; storeSelector: (state: QuotaStore) => Record; storeSetter: keyof QuotaStore; buildLoadingState: () => TState; buildSuccessState: (data: TData) => TState; buildErrorState: (message: string, status?: number) => TState; cardClassName: string; controlsClassName: string; controlClassName: string; gridClassName: string; renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode; } const resolveAntigravityProjectId = async (file: AuthFileItem): Promise => { try { const text = await authFilesApi.downloadText(file.name); const trimmed = text.trim(); if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID; const parsed = JSON.parse(trimmed) as Record; const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId); if (topLevel) return topLevel; const installed = parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null ? (parsed.installed as Record) : null; const installedProjectId = installed ? normalizeStringValue(installed.project_id ?? installed.projectId) : null; if (installedProjectId) return installedProjectId; const web = parsed.web && typeof parsed.web === 'object' && parsed.web !== null ? (parsed.web as Record) : null; const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null; if (webProjectId) return webProjectId; } catch { return DEFAULT_ANTIGRAVITY_PROJECT_ID; } return DEFAULT_ANTIGRAVITY_PROJECT_ID; }; const fetchAntigravityQuota = async ( file: AuthFileItem, t: TFunction ): Promise => { const rawAuthIndex = file['auth_index'] ?? file.authIndex; const authIndex = normalizeAuthIndexValue(rawAuthIndex); if (!authIndex) { throw new Error(t('antigravity_quota.missing_auth_index')); } const projectId = await resolveAntigravityProjectId(file); const requestBody = JSON.stringify({ project: projectId }); 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: requestBody, }); 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); }; const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => { const FIVE_HOUR_SECONDS = 18000; const WEEK_SECONDS = 604800; const WINDOW_META = { codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' }, codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' }, codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' }, codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' }, } as const; 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, labelKey: string, window?: CodexUsageWindow | null, limitReached?: boolean, allowed?: boolean ) => { if (!window) return; const resetLabel = formatCodexResetLabel(window); const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent); const isLimitReached = Boolean(limitReached) || allowed === false; const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null); windows.push({ id, label: t(labelKey), labelKey, usedPercent, resetLabel, }); }; const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => { if (!window) return null; return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds); }; const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached; const rawAllowed = rateLimit?.allowed; const pickClassifiedWindows = ( limitInfo?: CodexRateLimitInfo | null ): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => { const rawWindows = [ limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null, limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null, ]; let fiveHourWindow: CodexUsageWindow | null = null; let weeklyWindow: CodexUsageWindow | null = null; for (const window of rawWindows) { if (!window) continue; const seconds = getWindowSeconds(window); if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) { fiveHourWindow = window; } else if (seconds === WEEK_SECONDS && !weeklyWindow) { weeklyWindow = window; } } return { fiveHourWindow, weeklyWindow }; }; const rateWindows = pickClassifiedWindows(rateLimit); addWindow( WINDOW_META.codeFiveHour.id, WINDOW_META.codeFiveHour.labelKey, rateWindows.fiveHourWindow, rawLimitReached, rawAllowed ); addWindow( WINDOW_META.codeWeekly.id, WINDOW_META.codeWeekly.labelKey, rateWindows.weeklyWindow, rawLimitReached, rawAllowed ); const codeReviewWindows = pickClassifiedWindows(codeReviewLimit); const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached; const codeReviewAllowed = codeReviewLimit?.allowed; addWindow( WINDOW_META.codeReviewFiveHour.id, WINDOW_META.codeReviewFiveHour.labelKey, codeReviewWindows.fiveHourWindow, codeReviewLimitReached, codeReviewAllowed ); addWindow( WINDOW_META.codeReviewWeekly.id, WINDOW_META.codeReviewWeekly.labelKey, codeReviewWindows.weeklyWindow, codeReviewLimitReached, codeReviewAllowed ); return windows; }; const fetchCodexQuota = async ( file: AuthFileItem, t: TFunction ): 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 requestHeader: Record = { ...CODEX_REQUEST_HEADERS, 'Chatgpt-Account-Id': accountId, }; 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')); } const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType); const windows = buildCodexQuotaWindows(payload, t); return { planType: planTypeFromUsage ?? planTypeFromFile, windows }; }; const fetchGeminiCliQuota = async ( file: AuthFileItem, t: TFunction ): 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 []; const parsedBuckets = buckets .map((bucket) => { const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id); if (!modelId) return null; const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type); const remainingFractionRaw = normalizeQuotaFraction( bucket.remainingFraction ?? bucket.remaining_fraction ); const remainingAmount = normalizeNumberValue( bucket.remainingAmount ?? bucket.remaining_amount ); const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined; let fallbackFraction: number | null = null; if (remainingAmount !== null) { fallbackFraction = remainingAmount <= 0 ? 0 : null; } else if (resetTime) { fallbackFraction = 0; } const remainingFraction = remainingFractionRaw ?? fallbackFraction; return { modelId, tokenType, remainingFraction, remainingAmount, resetTime, }; }) .filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null); return buildGeminiCliQuotaBuckets(parsedBuckets); }; const renderAntigravityItems = ( quota: AntigravityQuotaState, t: TFunction, helpers: QuotaRenderHelpers ): ReactNode => { const { styles: styleMap, QuotaProgressBar } = helpers; const { createElement: h } = React; const groups = quota.groups ?? []; if (groups.length === 0) { return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models')); } return groups.map((group) => { const clamped = Math.max(0, Math.min(1, group.remainingFraction)); const percent = Math.round(clamped * 100); const resetLabel = formatQuotaResetTime(group.resetTime); return h( 'div', { key: group.id, className: styleMap.quotaRow }, h( 'div', { className: styleMap.quotaRowHeader }, h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label), h( 'div', { className: styleMap.quotaMeta }, h('span', { className: styleMap.quotaPercent }, `${percent}%`), h('span', { className: styleMap.quotaReset }, resetLabel) ) ), h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 }) ); }); }; const renderCodexItems = ( quota: CodexQuotaState, t: TFunction, helpers: QuotaRenderHelpers ): ReactNode => { const { styles: styleMap, QuotaProgressBar } = helpers; const { createElement: h, Fragment } = React; const windows = quota.windows ?? []; const planType = quota.planType ?? null; const getPlanLabel = (pt?: string | null): string | null => { const normalized = normalizePlanType(pt); 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 pt || normalized; }; const planLabel = getPlanLabel(planType); const nodes: ReactNode[] = []; if (planLabel) { nodes.push( h( 'div', { key: 'plan', className: styleMap.codexPlan }, h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')), h('span', { className: styleMap.codexPlanValue }, planLabel) ) ); } if (windows.length === 0) { nodes.push( h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_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); }; const renderGeminiCliItems = ( quota: GeminiCliQuotaState, t: TFunction, helpers: QuotaRenderHelpers ): ReactNode => { const { styles: styleMap, QuotaProgressBar } = helpers; const { createElement: h } = React; const buckets = quota.buckets ?? []; if (buckets.length === 0) { return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets')); } return 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 remainingAmountLabel = bucket.remainingAmount === null || bucket.remainingAmount === undefined ? null : t('gemini_cli_quota.remaining_amount', { count: bucket.remainingAmount, }); const titleBase = bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label; const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase; const resetLabel = formatQuotaResetTime(bucket.resetTime); return h( 'div', { key: bucket.id, className: styleMap.quotaRow }, h( 'div', { className: styleMap.quotaRowHeader }, h('span', { className: styleMap.quotaModel, title }, bucket.label), h( 'div', { className: styleMap.quotaMeta }, h('span', { className: styleMap.quotaPercent }, percentLabel), remainingAmountLabel ? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel) : null, h('span', { className: styleMap.quotaReset }, resetLabel) ) ), h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 }) ); }); }; export const ANTIGRAVITY_CONFIG: QuotaConfig = { type: 'antigravity', i18nPrefix: 'antigravity_quota', filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file), fetchQuota: fetchAntigravityQuota, storeSelector: (state) => state.antigravityQuota, storeSetter: 'setAntigravityQuota', buildLoadingState: () => ({ status: 'loading', groups: [] }), buildSuccessState: (groups) => ({ status: 'success', groups }), buildErrorState: (message, status) => ({ status: 'error', groups: [], error: message, errorStatus: status, }), cardClassName: styles.antigravityCard, controlsClassName: styles.antigravityControls, controlClassName: styles.antigravityControl, gridClassName: styles.antigravityGrid, renderQuotaItems: renderAntigravityItems, }; export const CODEX_CONFIG: QuotaConfig< CodexQuotaState, { planType: string | null; windows: CodexQuotaWindow[] } > = { type: 'codex', i18nPrefix: 'codex_quota', filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file), fetchQuota: fetchCodexQuota, storeSelector: (state) => state.codexQuota, storeSetter: 'setCodexQuota', buildLoadingState: () => ({ status: 'loading', windows: [] }), buildSuccessState: (data) => ({ status: 'success', windows: data.windows, planType: data.planType, }), buildErrorState: (message, status) => ({ status: 'error', windows: [], error: message, errorStatus: status, }), cardClassName: styles.codexCard, controlsClassName: styles.codexControls, controlClassName: styles.codexControl, gridClassName: styles.codexGrid, renderQuotaItems: renderCodexItems, }; export const GEMINI_CLI_CONFIG: QuotaConfig = { type: 'gemini-cli', i18nPrefix: 'gemini_cli_quota', filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file), fetchQuota: fetchGeminiCliQuota, storeSelector: (state) => state.geminiCliQuota, storeSetter: 'setGeminiCliQuota', buildLoadingState: () => ({ status: 'loading', buckets: [] }), buildSuccessState: (buckets) => ({ status: 'success', buckets }), buildErrorState: (message, status) => ({ status: 'error', buckets: [], error: message, errorStatus: status, }), cardClassName: styles.geminiCliCard, controlsClassName: styles.geminiCliControls, controlClassName: styles.geminiCliControl, gridClassName: styles.geminiCliGrid, renderQuotaItems: renderGeminiCliItems, };