feat(auth-files): add Gemini CLI quota card and API call

This commit is contained in:
Supra4E8C
2025-12-30 12:18:20 +08:00
parent 215ce61b48
commit 4e26b6c92d
5 changed files with 516 additions and 2 deletions

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ usage.json
CLAUDE.md
AGENTS.md
antigravity_usage.json
codex_usage.json
node_modules
dist

View File

@@ -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-<project>.json using the same rules as the CLI vertex-import helper.",

View File

@@ -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-<project>.json。",

View File

@@ -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);

View File

@@ -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<string, unknown>)
: null;
const attributes =
file && typeof file.attributes === 'object' && file.attributes !== null
? (file.attributes as Record<string, unknown>)
: 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<string, unknown> | 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,6 +688,10 @@ 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;
@@ -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<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
@@ -676,6 +772,11 @@ export function AuthFilesPage() {
const [codexQuota, setCodexQuota] = useState<Record<string, CodexQuotaState>>({});
const [codexLoading, setCodexLoading] = useState(false);
const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null);
const [geminiCliQuota, setGeminiCliQuota] = useState<Record<string, GeminiCliQuotaState>>({});
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<AntigravityQuotaGroup[]> => {
let lastError = '';
@@ -1079,6 +1194,125 @@ export function AuthFilesPage() {
[fetchCodexQuota, t]
);
const fetchGeminiCliQuota = useCallback(
async (file: AuthFileItem): Promise<GeminiCliQuotaBucketState[]> => {
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();
@@ -1119,6 +1353,23 @@ export function AuthFilesPage() {
});
}, [codexFiles]);
useEffect(() => {
if (geminiCliFiles.length === 0) {
setGeminiCliQuota({});
return;
}
setGeminiCliQuota((prev) => {
const nextState: Record<string, GeminiCliQuotaState> = {};
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 (
<div key={item.name} className={`${styles.fileCard} ${styles.geminiCliCard}`}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t('gemini_cli_quota.loading')}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t('gemini_cli_quota.idle')}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t('gemini_cli_quota.load_failed', {
message: quotaErrorMessage
})}
</div>
) : buckets.length === 0 ? (
<div className={styles.quotaMessage}>{t('gemini_cli_quota.empty_buckets')}</div>
) : (
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 (
<div key={bucket.id} className={styles.quotaRow}>
<div className={styles.quotaRowHeader}>
<span
className={styles.quotaModel}
title={
bucket.tokenType ? `${bucket.label} (${bucket.tokenType})` : bucket.label
}
>
{bucket.label}
</span>
<div className={styles.quotaMeta}>
<span className={styles.quotaPercent}>{percentLabel}</span>
{remainingAmountLabel && (
<span className={styles.quotaAmount}>{remainingAmountLabel}</span>
)}
<span className={styles.quotaReset}>{resetLabel}</span>
</div>
</div>
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${quotaBarClass}`}
style={{ width: `${percent ?? 0}%` }}
/>
</div>
</div>
);
})
)}
</div>
</div>
);
};
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
@@ -2144,6 +2491,97 @@ export function AuthFilesPage() {
)}
</Card>
<Card
title={t('gemini_cli_quota.title')}
extra={
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={() => loadGeminiCliQuota(geminiCliPageItems, 'page')}
disabled={disableControls || geminiCliLoading || geminiCliPageItems.length === 0}
loading={geminiCliLoading && geminiCliLoadingScope === 'page'}
>
{t('gemini_cli_quota.refresh_button')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => loadGeminiCliQuota(geminiCliFiles, 'all')}
disabled={disableControls || geminiCliLoading || geminiCliFiles.length === 0}
loading={geminiCliLoading && geminiCliLoadingScope === 'all'}
>
{t('gemini_cli_quota.fetch_all')}
</Button>
</div>
}
>
{geminiCliFiles.length === 0 ? (
<EmptyState
title={t('gemini_cli_quota.empty_title')}
description={t('gemini_cli_quota.empty_desc')}
/>
) : (
<>
<div className={styles.geminiCliControls}>
<div className={styles.geminiCliControl}>
<label>{t('auth_files.page_size_label')}</label>
<select
className={styles.pageSizeSelect}
value={geminiCliPageSize}
onChange={(e) => {
setGeminiCliPageSize(Number(e.target.value) || 6);
setGeminiCliPage(1);
}}
>
<option value={6}>6</option>
<option value={9}>9</option>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
</select>
</div>
<div className={styles.geminiCliControl}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{geminiCliFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={styles.geminiCliGrid}>{geminiCliPageItems.map(renderGeminiCliCard)}</div>
{geminiCliFiles.length > geminiCliPageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={() => setGeminiCliPage(Math.max(1, geminiCliCurrentPage - 1))}
disabled={geminiCliCurrentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: geminiCliCurrentPage,
total: geminiCliTotalPages,
count: geminiCliFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() =>
setGeminiCliPage(Math.min(geminiCliTotalPages, geminiCliCurrentPage + 1))
}
disabled={geminiCliCurrentPage >= geminiCliTotalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
</Card>
{/* OAuth 排除列表卡片 */}
<Card
title={t('oauth_excluded.title')}