From 4e26b6c92db5c34894fda2efeaa3281d233c6a53 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Tue, 30 Dec 2025 12:18:20 +0800 Subject: [PATCH] feat(auth-files): add Gemini CLI quota card and API call --- .gitignore | 1 + src/i18n/locales/en.json | 14 + src/i18n/locales/zh-CN.json | 14 + src/pages/AuthFilesPage.module.scss | 47 +++ src/pages/AuthFilesPage.tsx | 442 +++++++++++++++++++++++++++- 5 files changed, 516 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c1d7510..455a394 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ usage.json CLAUDE.md AGENTS.md antigravity_usage.json +codex_usage.json node_modules dist diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e647242..e66474a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -392,6 +392,20 @@ "plan_team": "Team", "plan_free": "Free" }, + "gemini_cli_quota": { + "title": "Gemini CLI Quota", + "empty_title": "No Gemini CLI Auth Files", + "empty_desc": "Upload a Gemini CLI credential to view remaining quota.", + "idle": "Not loaded. Click Refresh Button.", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "missing_project_id": "Gemini CLI credential missing project ID", + "empty_buckets": "No quota data available", + "refresh_button": "Refresh Quota", + "fetch_all": "Fetch All", + "remaining_amount": "Remaining {{count}}" + }, "vertex_import": { "title": "Vertex JSON Login", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-.json using the same rules as the CLI vertex-import helper.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 38f8d57..724b688 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -392,6 +392,20 @@ "plan_team": "Team", "plan_free": "Free" }, + "gemini_cli_quota": { + "title": "Gemini CLI 额度", + "empty_title": "暂无 Gemini CLI 认证", + "empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。", + "idle": "尚未加载额度,请点击刷新按钮。", + "loading": "正在加载额度...", + "load_failed": "额度获取失败:{{message}}", + "missing_auth_index": "认证文件缺少 auth_index", + "missing_project_id": "Gemini CLI 凭证缺少 Project ID", + "empty_buckets": "暂无额度数据", + "refresh_button": "刷新额度", + "fetch_all": "获取全部", + "remaining_amount": "剩余 {{count}}" + }, "vertex_import": { "title": "Vertex JSON 登录", "description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-.json。", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 7b5a5d6..98d82a9 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -190,6 +190,20 @@ } } +.geminiCliGrid { + display: grid; + gap: $spacing-md; + grid-template-columns: repeat(3, minmax(0, 1fr)); + + @include tablet { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @include mobile { + grid-template-columns: 1fr; + } +} + .antigravityControls { display: flex; gap: $spacing-md; @@ -232,6 +246,27 @@ } } +.geminiCliControls { + display: flex; + gap: $spacing-md; + flex-wrap: wrap; + align-items: flex-end; + margin-bottom: $spacing-md; +} + +.geminiCliControl { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; + white-space: nowrap; + } +} + .antigravityCard { background-image: linear-gradient( 180deg, @@ -248,6 +283,14 @@ ); } +.geminiCliCard { + background-image: linear-gradient( + 180deg, + rgba(231, 239, 255, 0.2), + rgba(231, 239, 255, 0) + ); +} + .quotaSection { display: flex; flex-direction: column; @@ -338,6 +381,10 @@ color: var(--text-tertiary); } +.quotaAmount { + color: var(--text-secondary); +} + .quotaMessage { font-size: 12px; color: var(--text-tertiary); diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index d36b58c..5db7336 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -99,6 +99,39 @@ interface AntigravityQuotaState { 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?: { @@ -173,6 +206,13 @@ const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [ } ]; +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; @@ -401,6 +441,39 @@ function resolveCodexPlanType(file: AuthFileItem): string | null { 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') { @@ -435,6 +508,23 @@ function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null { 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; @@ -598,9 +688,13 @@ 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 === 'boolean') return raw; if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true'; return false; } @@ -661,6 +755,8 @@ export function AuthFilesPage() { 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); @@ -676,6 +772,11 @@ export function AuthFilesPage() { 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); @@ -702,6 +803,8 @@ export function AuthFilesPage() { 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'; @@ -801,6 +904,18 @@ export function AuthFilesPage() { 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 = ''; @@ -1079,6 +1194,125 @@ export function AuthFilesPage() { [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(); @@ -1118,6 +1352,23 @@ export function AuthFilesPage() { 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); @@ -1843,6 +2094,102 @@ export function AuthFilesPage() { ); }; + 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 (
@@ -2144,11 +2491,102 @@ export function AuthFilesPage() { )} + + + +
+ } + > + {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 排除列表卡片 */} openExcludedModal()} disabled={disableControls || excludedError === 'unsupported'}