mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-05 10:51:12 -07:00
fix(ui): hide model quota in /stats and refactor quota display (#24206)
This commit is contained in:
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatDuration, formatResetTime } from '../utils/formatters.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import {
|
||||
useSessionStats,
|
||||
type ModelMetrics,
|
||||
@@ -19,25 +19,10 @@ import {
|
||||
TOOL_SUCCESS_RATE_MEDIUM,
|
||||
USER_AGREEMENT_RATE_HIGH,
|
||||
USER_AGREEMENT_RATE_MEDIUM,
|
||||
CACHE_EFFICIENCY_HIGH,
|
||||
CACHE_EFFICIENCY_MEDIUM,
|
||||
getUsedStatusColor,
|
||||
QUOTA_USED_WARNING_THRESHOLD,
|
||||
QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
} from '../utils/displayUtils.js';
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import {
|
||||
type Config,
|
||||
type RetrieveUserQuotaResponse,
|
||||
isActiveModel,
|
||||
getDisplayString,
|
||||
isAutoModel,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import type { QuotaStats } from '../types.js';
|
||||
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
|
||||
|
||||
// A more flexible and powerful StatRow component
|
||||
interface StatRowProps {
|
||||
@@ -87,433 +72,94 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
);
|
||||
|
||||
// Logic for building the unified list of table rows
|
||||
const buildModelRows = (
|
||||
models: Record<string, ModelMetrics>,
|
||||
config: Config,
|
||||
quotas?: RetrieveUserQuotaResponse,
|
||||
useGemini3_1 = false,
|
||||
useGemini3_1FlashLite = false,
|
||||
useCustomToolModel = false,
|
||||
) => {
|
||||
const getBaseModelName = (name: string) => name.replace('-001', '');
|
||||
const usedModelNames = new Set(
|
||||
Object.keys(models)
|
||||
.map(getBaseModelName)
|
||||
.map((name) => getDisplayString(name, config)),
|
||||
);
|
||||
|
||||
// 1. Models with active usage
|
||||
const activeRows = Object.entries(models).map(([name, metrics]) => {
|
||||
const modelName = getBaseModelName(name);
|
||||
const cachedTokens = metrics.tokens.cached;
|
||||
const inputTokens = metrics.tokens.input;
|
||||
return {
|
||||
key: name,
|
||||
modelName: getDisplayString(modelName, config),
|
||||
requests: metrics.api.totalRequests,
|
||||
cachedTokens: cachedTokens.toLocaleString(),
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
outputTokens: metrics.tokens.candidates.toLocaleString(),
|
||||
bucket: quotas?.buckets?.find((b) => b.modelId === modelName),
|
||||
isActive: true,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Models with quota only
|
||||
const quotaRows =
|
||||
quotas?.buckets
|
||||
?.filter(
|
||||
(b) =>
|
||||
b.modelId &&
|
||||
isActiveModel(
|
||||
b.modelId,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
) &&
|
||||
!usedModelNames.has(getDisplayString(b.modelId, config)),
|
||||
)
|
||||
.map((bucket) => ({
|
||||
key: bucket.modelId!,
|
||||
modelName: getDisplayString(bucket.modelId!, config),
|
||||
requests: '-',
|
||||
cachedTokens: '-',
|
||||
inputTokens: '-',
|
||||
outputTokens: '-',
|
||||
bucket,
|
||||
isActive: false,
|
||||
})) || [];
|
||||
|
||||
return [...activeRows, ...quotaRows];
|
||||
};
|
||||
|
||||
const ModelUsageTable: React.FC<{
|
||||
interface ModelUsageTableProps {
|
||||
models: Record<string, ModelMetrics>;
|
||||
config: Config;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
cacheEfficiency: number;
|
||||
totalCachedTokens: number;
|
||||
currentModel?: string;
|
||||
pooledRemaining?: number;
|
||||
pooledLimit?: number;
|
||||
pooledResetTime?: string;
|
||||
useGemini3_1?: boolean;
|
||||
useGemini3_1FlashLite?: boolean;
|
||||
useCustomToolModel?: boolean;
|
||||
}> = ({
|
||||
models,
|
||||
config,
|
||||
quotas,
|
||||
cacheEfficiency,
|
||||
totalCachedTokens,
|
||||
currentModel,
|
||||
pooledRemaining,
|
||||
pooledLimit,
|
||||
pooledResetTime,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 84;
|
||||
const rows = buildModelRows(
|
||||
models,
|
||||
config,
|
||||
quotas,
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomToolModel,
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
|
||||
|
||||
const nameWidth = 23;
|
||||
const requestsWidth = 5;
|
||||
const uncachedWidth = 15;
|
||||
const cachedWidth = 14;
|
||||
const outputTokensWidth = 15;
|
||||
const percentageWidth = showQuotaColumn ? 6 : 0;
|
||||
const resetWidth = 22;
|
||||
|
||||
// Total width of other columns (including parent box paddingX={2})
|
||||
const fixedWidth = nameWidth + requestsWidth + percentageWidth + resetWidth;
|
||||
const outerPadding = 4;
|
||||
const availableForUsage = terminalWidth - outerPadding - fixedWidth;
|
||||
|
||||
const usageLimitWidth = showQuotaColumn
|
||||
? Math.max(10, Math.min(24, availableForUsage))
|
||||
: 0;
|
||||
const progressBarWidth = Math.max(2, usageLimitWidth - 4);
|
||||
|
||||
const renderProgressBar = (
|
||||
usedFraction: number,
|
||||
color: string,
|
||||
totalSteps = 20,
|
||||
) => {
|
||||
let filledSteps = Math.round(usedFraction * totalSteps);
|
||||
|
||||
// If something is used (fraction > 0) but rounds to 0, show 1 tick.
|
||||
// If < 100% (fraction < 1) but rounds to 20, show 19 ticks.
|
||||
if (usedFraction > 0 && usedFraction < 1) {
|
||||
filledSteps = Math.min(Math.max(filledSteps, 1), totalSteps - 1);
|
||||
}
|
||||
|
||||
const emptySteps = Math.max(0, totalSteps - filledSteps);
|
||||
return (
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={color}>{'▬'.repeat(filledSteps)}</Text>
|
||||
<Text color={theme.border.default}>{'▬'.repeat(emptySteps)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
|
||||
green: CACHE_EFFICIENCY_HIGH,
|
||||
yellow: CACHE_EFFICIENCY_MEDIUM,
|
||||
});
|
||||
|
||||
const totalWidth =
|
||||
nameWidth +
|
||||
requestsWidth +
|
||||
(showQuotaColumn
|
||||
? usageLimitWidth + percentageWidth + resetWidth
|
||||
: uncachedWidth + cachedWidth + outputTokensWidth);
|
||||
|
||||
const isAuto = currentModel && isAutoModel(currentModel);
|
||||
const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
|
||||
const nameWidth = 28;
|
||||
const requestsWidth = 8;
|
||||
const inputTokensWidth = 14;
|
||||
const cacheReadsWidth = 14;
|
||||
const outputTokensWidth = 14;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{isAuto &&
|
||||
showQuotaColumn &&
|
||||
pooledRemaining !== undefined &&
|
||||
pooledLimit !== undefined &&
|
||||
pooledLimit > 0 && (
|
||||
<Box flexDirection="column" marginTop={0} marginBottom={1}>
|
||||
<QuotaStatsInfo
|
||||
remaining={pooledRemaining}
|
||||
limit={pooledLimit}
|
||||
resetTime={pooledResetTime}
|
||||
/>
|
||||
<Text color={theme.text.primary}>
|
||||
For a full token breakdown, run `/stats model`.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Model Usage
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Use /model to view model quota information
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
|
||||
<Box alignItems="flex-end">
|
||||
<Box width={nameWidth} flexShrink={0}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Model
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={requestsWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Reqs
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{!showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={uncachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Input Tokens
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={cachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Cache Reads
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={outputTokensWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Output Tokens
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={4}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Model usage
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={percentageWidth} flexShrink={0} />
|
||||
<Box
|
||||
width={resetWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary} wrap="truncate-end">
|
||||
Usage resets
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
{/* Header */}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderBottom={true}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
borderTop={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={theme.border.default}
|
||||
width={totalWidth}
|
||||
></Box>
|
||||
|
||||
{rows.map((row) => {
|
||||
let effectiveUsedFraction = 0;
|
||||
let usedPercentage = 0;
|
||||
let statusColor = theme.ui.comment;
|
||||
let percentageText = '';
|
||||
|
||||
if (row.bucket && row.bucket.remainingFraction != null) {
|
||||
const actualUsedFraction = 1 - row.bucket.remainingFraction;
|
||||
effectiveUsedFraction =
|
||||
actualUsedFraction === 0 && row.isActive
|
||||
? 0.001
|
||||
: actualUsedFraction;
|
||||
usedPercentage = effectiveUsedFraction * 100;
|
||||
statusColor =
|
||||
getUsedStatusColor(usedPercentage, {
|
||||
warning: QUOTA_USED_WARNING_THRESHOLD,
|
||||
critical: QUOTA_USED_CRITICAL_THRESHOLD,
|
||||
}) ?? (row.isActive ? theme.text.primary : theme.ui.comment);
|
||||
percentageText =
|
||||
usedPercentage > 0 && usedPercentage < 1
|
||||
? `${usedPercentage.toFixed(1)}%`
|
||||
: `${usedPercentage.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={row.key}>
|
||||
<Box width={nameWidth} flexShrink={0}>
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{row.modelName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={requestsWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
{row.requests}
|
||||
</Text>
|
||||
</Box>
|
||||
{!showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={uncachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
row.isActive ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{row.inputTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={cachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text color={theme.text.secondary}>{row.cachedTokens}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={outputTokensWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
row.isActive ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{row.outputTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={4}
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.bucket && row.bucket.remainingFraction != null && (
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
{renderProgressBar(
|
||||
effectiveUsedFraction,
|
||||
statusColor,
|
||||
progressBarWidth,
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
width={percentageWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.bucket && row.bucket.remainingFraction != null && (
|
||||
<Box>
|
||||
{row.bucket.remainingFraction === 0 ? (
|
||||
<Text color={theme.status.error} wrap="truncate-end">
|
||||
Limit
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={statusColor} wrap="truncate-end">
|
||||
{percentageText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
width={resetWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{row.bucket?.resetTime &&
|
||||
formatResetTime(row.bucket.resetTime, 'column')
|
||||
? formatResetTime(row.bucket.resetTime, 'column')
|
||||
: ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{cacheEfficiency > 0 && !showQuotaColumn && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={theme.status.success}>Savings Highlight:</Text>{' '}
|
||||
{totalCachedTokens.toLocaleString()} (
|
||||
<Text color={cacheEfficiencyColor}>
|
||||
{cacheEfficiency.toFixed(1)}%
|
||||
</Text>
|
||||
) of input tokens were served from the cache, reducing costs.
|
||||
>
|
||||
<Box width={nameWidth}>
|
||||
<Text bold color={theme.text.secondary}>
|
||||
Model
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box width={requestsWidth} justifyContent="flex-end">
|
||||
<Text bold color={theme.text.secondary}>
|
||||
Reqs
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={inputTokensWidth} justifyContent="flex-end">
|
||||
<Text bold color={theme.text.secondary}>
|
||||
Input Tokens
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={cacheReadsWidth} justifyContent="flex-end">
|
||||
<Text bold color={theme.text.secondary}>
|
||||
Cache Reads
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={outputTokensWidth} justifyContent="flex-end">
|
||||
<Text bold color={theme.text.secondary}>
|
||||
Output Tokens
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Rows */}
|
||||
{Object.entries(models).map(([name, modelMetrics]) => (
|
||||
<Box key={name}>
|
||||
<Box width={nameWidth}>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={requestsWidth} justifyContent="flex-end">
|
||||
<Text color={theme.text.primary}>
|
||||
{modelMetrics.api.totalRequests}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={inputTokensWidth} justifyContent="flex-end">
|
||||
<Text color={theme.text.primary}>
|
||||
{modelMetrics.tokens.prompt.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={cacheReadsWidth} justifyContent="flex-end">
|
||||
<Text color={theme.text.primary}>
|
||||
{modelMetrics.tokens.cached.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={outputTokensWidth} justifyContent="flex-end">
|
||||
<Text color={theme.text.primary}>
|
||||
{modelMetrics.tokens.candidates.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -521,7 +167,6 @@ const ModelUsageTable: React.FC<{
|
||||
interface StatsDisplayProps {
|
||||
duration: string;
|
||||
title?: string;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
footer?: string;
|
||||
selectedAuthType?: string;
|
||||
userEmail?: string;
|
||||
@@ -534,30 +179,17 @@ interface StatsDisplayProps {
|
||||
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
duration,
|
||||
title,
|
||||
quotas,
|
||||
footer,
|
||||
selectedAuthType,
|
||||
userEmail,
|
||||
tier,
|
||||
currentModel,
|
||||
quotaStats,
|
||||
creditBalance,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { metrics } = stats;
|
||||
const { models, tools, files } = metrics;
|
||||
const { tools, files, models } = metrics;
|
||||
const computed = computeSessionStats(metrics);
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini3_1FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const useCustomToolModel =
|
||||
useGemini3_1 &&
|
||||
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
|
||||
const pooledRemaining = quotaStats?.remaining;
|
||||
const pooledLimit = quotaStats?.limit;
|
||||
const pooledResetTime = quotaStats?.resetTime;
|
||||
|
||||
const showUserIdentity = settings.merged.ui.showUserIdentity;
|
||||
|
||||
@@ -697,20 +329,9 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
</Text>
|
||||
</SubStatRow>
|
||||
</Section>
|
||||
<ModelUsageTable
|
||||
models={models}
|
||||
config={config}
|
||||
quotas={quotas}
|
||||
cacheEfficiency={computed.cacheEfficiency}
|
||||
totalCachedTokens={computed.totalCachedTokens}
|
||||
currentModel={currentModel}
|
||||
pooledRemaining={pooledRemaining}
|
||||
pooledLimit={pooledLimit}
|
||||
pooledResetTime={pooledResetTime}
|
||||
useGemini3_1={useGemini3_1}
|
||||
useGemini3_1FlashLite={useGemini3_1FlashLite}
|
||||
useCustomToolModel={useCustomToolModel}
|
||||
/>
|
||||
|
||||
{Object.keys(models).length > 0 && <ModelUsageTable models={models} />}
|
||||
|
||||
{renderFooter()}
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user