Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/cli/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ they appear in the UI.
| -------- | ------------- | ---------------------------- | ------- |
| IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` |

### Billing

| UI Label | Setting | Description | Default |
| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` |

### Model

| UI Label | Setting | Description | Default |
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,15 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes

#### `billing`

- **`billing.overageStrategy`** (enum):
- **Description:** How to handle quota exhaustion when AI credits are
available. 'ask' prompts each time, 'always' automatically uses credits,
'never' disables credit usage.
- **Default:** `"ask"`
- **Values:** `"ask"`, `"always"`, `"never"`

#### `model`

- **`model.name`** (string):
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,36 @@ const SETTINGS_SCHEMA = {
ref: 'TelemetrySettings',
},

billing: {
type: 'object',
label: 'Billing',
category: 'Advanced',
requiresRestart: false,
default: {},
description: 'Billing and AI credits settings.',
showInDialog: false,
properties: {
overageStrategy: {
type: 'enum',
label: 'Overage Strategy',
category: 'Advanced',
requiresRestart: false,
default: 'ask',
description: oneLine`
How to handle quota exhaustion when AI credits are available.
'ask' prompts each time, 'always' automatically uses credits,
'never' disables credit usage.
`,
showInDialog: true,
options: [
{ value: 'ask', label: 'Ask each time' },
{ value: 'always', label: 'Always use credits' },
{ value: 'never', label: 'Never use credits' },
],
},
},
},

model: {
type: 'object',
label: 'Model',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/test-utils/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,8 @@ const mockUIActions: UIActions = {
handleClearScreen: vi.fn(),
handleProQuotaChoice: vi.fn(),
handleValidationChoice: vi.fn(),
handleOverageMenuChoice: vi.fn(),
handleEmptyWalletChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
type IdeInfo,
type IdeContext,
type UserTierId,
type GeminiUserTier,
type UserFeedbackPayload,
type AgentDefinition,
type ApprovalMode,
Expand Down Expand Up @@ -82,6 +83,8 @@ import {
CoreToolCallStatus,
generateSteeringAckMessage,
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
Expand Down Expand Up @@ -391,6 +394,9 @@ export const AppContainer = (props: AppContainerProps) => {
? { remaining, limit, resetTime }
: undefined;
});
const [paidTier, setPaidTier] = useState<GeminiUserTier | undefined>(
undefined,
);

const [isConfigInitialized, setConfigInitialized] = useState(false);

Expand Down Expand Up @@ -686,10 +692,17 @@ export const AppContainer = (props: AppContainerProps) => {
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
// G1 AI Credits
overageMenuRequest,
handleOverageMenuChoice,
emptyWalletRequest,
handleEmptyWalletChoice,
} = useQuotaAndFallback({
config,
historyManager,
userTier,
paidTier,
settings,
setModelSwitchedFromQuotaError,
onShowAuthSelection: () => setAuthState(AuthState.Updating),
});
Expand Down Expand Up @@ -729,6 +742,8 @@ export const AppContainer = (props: AppContainerProps) => {
const handleAuthSelect = useCallback(
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
if (authType) {
const previousAuthType =
config.getContentGeneratorConfig()?.authType ?? 'unknown';
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
setAuthContext({ requiresRestart: true });
} else {
Expand All @@ -741,6 +756,10 @@ export const AppContainer = (props: AppContainerProps) => {
config.setRemoteAdminSettings(undefined);
await config.refreshAuth(authType);
setAuthState(AuthState.Authenticated);
logBillingEvent(
config,
new ApiKeyUpdatedEvent(previousAuthType, authType),
);
} catch (e) {
if (e instanceof ChangeAuthRequestedError) {
return;
Expand Down Expand Up @@ -803,6 +822,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Only sync when not currently authenticating
if (authState === AuthState.Authenticated) {
setUserTier(config.getUserTier());
setPaidTier(config.getUserPaidTier());
}
}, [config, authState]);

Expand Down Expand Up @@ -2006,6 +2026,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
showIdeRestartPrompt ||
!!proQuotaRequest ||
!!validationRequest ||
!!overageMenuRequest ||
!!emptyWalletRequest ||
isSessionBrowserOpen ||
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
Expand Down Expand Up @@ -2033,6 +2055,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
hasLoopDetectionConfirmationRequest ||
!!proQuotaRequest ||
!!validationRequest ||
!!overageMenuRequest ||
!!emptyWalletRequest ||
!!customDialog;

const allowPlanMode =
Expand Down Expand Up @@ -2243,6 +2267,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
stats: quotaStats,
proQuotaRequest,
validationRequest,
// G1 AI Credits dialog state
overageMenuRequest,
emptyWalletRequest,
},
contextFileNames,
errorCount,
Expand Down Expand Up @@ -2367,6 +2394,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
quotaStats,
proQuotaRequest,
validationRequest,
overageMenuRequest,
emptyWalletRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
Expand Down Expand Up @@ -2448,6 +2477,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
// G1 AI Credits handlers
handleOverageMenuChoice,
handleEmptyWalletChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
Expand Down Expand Up @@ -2534,6 +2566,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
handleOverageMenuChoice,
handleEmptyWalletChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
Expand Down
16 changes: 13 additions & 3 deletions packages/cli/src/ui/commands/statsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@ describe('statsCommand', () => {
mockContext.session.stats.sessionStartTime = startTime;
});

it('should display general session stats when run with no subcommand', () => {
it('should display general session stats when run with no subcommand', async () => {
if (!statsCommand.action) throw new Error('Command has no action');

// eslint-disable-next-line @typescript-eslint/no-floating-promises
statsCommand.action(mockContext, '');
mockContext.services.config = {
refreshUserQuota: vi.fn(),
refreshAvailableCredits: vi.fn(),
getUserTierName: vi.fn(),
getUserPaidTier: vi.fn(),
getModel: vi.fn(),
} as unknown as Config;

await statsCommand.action(mockContext, '');

const expectedDuration = formatDuration(
endTime.getTime() - startTime.getTime(),
Expand All @@ -55,6 +62,7 @@ describe('statsCommand', () => {
tier: undefined,
userEmail: 'mock@example.com',
currentModel: undefined,
creditBalance: undefined,
});
});

Expand All @@ -78,6 +86,8 @@ describe('statsCommand', () => {
getQuotaRemaining: mockGetQuotaRemaining,
getQuotaLimit: mockGetQuotaLimit,
getQuotaResetTime: mockGetQuotaResetTime,
getUserPaidTier: vi.fn(),
refreshAvailableCredits: vi.fn(),
} as unknown as Config;

await statsCommand.action(mockContext, '');
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/ui/commands/statsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import type {
} from '../types.js';
import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js';
import { UserAccountManager } from '@google/gemini-cli-core';
import {
UserAccountManager,
getG1CreditBalance,
} from '@google/gemini-cli-core';
import {
type CommandContext,
type SlashCommand,
Expand All @@ -27,8 +30,10 @@ function getUserIdentity(context: CommandContext) {
const userEmail = cachedAccount ?? undefined;

const tier = context.services.config?.getUserTierName();
const paidTier = context.services.config?.getUserPaidTier();
const creditBalance = getG1CreditBalance(paidTier) ?? undefined;

return { selectedAuthType, userEmail, tier };
return { selectedAuthType, userEmail, tier, creditBalance };
}

async function defaultSessionView(context: CommandContext) {
Expand All @@ -43,7 +48,8 @@ async function defaultSessionView(context: CommandContext) {
}
const wallDuration = now.getTime() - sessionStartTime.getTime();

const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const { selectedAuthType, userEmail, tier, creditBalance } =
getUserIdentity(context);
const currentModel = context.services.config?.getModel();

const statsItem: HistoryItemStats = {
Expand All @@ -53,10 +59,14 @@ async function defaultSessionView(context: CommandContext) {
userEmail,
tier,
currentModel,
creditBalance,
};

if (context.services.config) {
const quota = await context.services.config.refreshUserQuota();
const [quota] = await Promise.all([
context.services.config.refreshUserQuota(),
context.services.config.refreshAvailableCredits(),
]);
if (quota) {
statsItem.quotas = quota;
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/components/DialogManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ describe('DialogManager', () => {
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
shouldShowIdePrompt: false,
isFolderTrustDialogOpen: false,
Expand Down Expand Up @@ -132,6 +134,8 @@ describe('DialogManager', () => {
resolve: vi.fn(),
},
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
'ProQuotaDialog',
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/ui/components/DialogManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { OverageMenuDialog } from './OverageMenuDialog.js';
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
Expand Down Expand Up @@ -152,6 +154,28 @@ export const DialogManager = ({
/>
);
}
if (uiState.quota.overageMenuRequest) {
return (
<OverageMenuDialog
failedModel={uiState.quota.overageMenuRequest.failedModel}
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
resetTime={uiState.quota.overageMenuRequest.resetTime}
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
onChoice={uiActions.handleOverageMenuChoice}
/>
);
}
if (uiState.quota.emptyWalletRequest) {
return (
<EmptyWalletDialog
failedModel={uiState.quota.emptyWalletRequest.failedModel}
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
resetTime={uiState.quota.emptyWalletRequest.resetTime}
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
onChoice={uiActions.handleEmptyWalletChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
Expand Down
Loading
Loading