Skip to content

Commit b2d6844

Browse files
authored
feat(billing): implement G1 AI credits overage flow with billing telemetry (#18590)
1 parent fdd844b commit b2d6844

55 files changed

Lines changed: 3182 additions & 23 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/cli/settings.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ they appear in the UI.
8080
| -------- | ------------- | ---------------------------- | ------- |
8181
| IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` |
8282

83+
### Billing
84+
85+
| UI Label | Setting | Description | Default |
86+
| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
87+
| 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"` |
88+
8389
### Model
8490

8591
| UI Label | Setting | Description | Default |

docs/reference/configuration.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ their corresponding top-level category object in your `settings.json` file.
357357
- **Default:** `true`
358358
- **Requires restart:** Yes
359359

360+
#### `billing`
361+
362+
- **`billing.overageStrategy`** (enum):
363+
- **Description:** How to handle quota exhaustion when AI credits are
364+
available. 'ask' prompts each time, 'always' automatically uses credits,
365+
'never' disables credit usage.
366+
- **Default:** `"ask"`
367+
- **Values:** `"ask"`, `"always"`, `"never"`
368+
360369
#### `model`
361370

362371
- **`model.name`** (string):

packages/cli/src/config/settingsSchema.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,36 @@ const SETTINGS_SCHEMA = {
828828
ref: 'TelemetrySettings',
829829
},
830830

831+
billing: {
832+
type: 'object',
833+
label: 'Billing',
834+
category: 'Advanced',
835+
requiresRestart: false,
836+
default: {},
837+
description: 'Billing and AI credits settings.',
838+
showInDialog: false,
839+
properties: {
840+
overageStrategy: {
841+
type: 'enum',
842+
label: 'Overage Strategy',
843+
category: 'Advanced',
844+
requiresRestart: false,
845+
default: 'ask',
846+
description: oneLine`
847+
How to handle quota exhaustion when AI credits are available.
848+
'ask' prompts each time, 'always' automatically uses credits,
849+
'never' disables credit usage.
850+
`,
851+
showInDialog: true,
852+
options: [
853+
{ value: 'ask', label: 'Ask each time' },
854+
{ value: 'always', label: 'Always use credits' },
855+
{ value: 'never', label: 'Never use credits' },
856+
],
857+
},
858+
},
859+
},
860+
831861
model: {
832862
type: 'object',
833863
label: 'Model',

packages/cli/src/test-utils/render.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,8 @@ const mockUIActions: UIActions = {
591591
handleClearScreen: vi.fn(),
592592
handleProQuotaChoice: vi.fn(),
593593
handleValidationChoice: vi.fn(),
594+
handleOverageMenuChoice: vi.fn(),
595+
handleEmptyWalletChoice: vi.fn(),
594596
setQueueErrorMessage: vi.fn(),
595597
popAllMessages: vi.fn(),
596598
handleApiKeySubmit: vi.fn(),

packages/cli/src/ui/AppContainer.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
type IdeInfo,
4848
type IdeContext,
4949
type UserTierId,
50+
type GeminiUserTier,
5051
type UserFeedbackPayload,
5152
type AgentDefinition,
5253
type ApprovalMode,
@@ -82,6 +83,8 @@ import {
8283
CoreToolCallStatus,
8384
generateSteeringAckMessage,
8485
buildUserSteeringHintPrompt,
86+
logBillingEvent,
87+
ApiKeyUpdatedEvent,
8588
} from '@google/gemini-cli-core';
8689
import { validateAuthMethod } from '../config/auth.js';
8790
import process from 'node:process';
@@ -391,6 +394,9 @@ export const AppContainer = (props: AppContainerProps) => {
391394
? { remaining, limit, resetTime }
392395
: undefined;
393396
});
397+
const [paidTier, setPaidTier] = useState<GeminiUserTier | undefined>(
398+
undefined,
399+
);
394400

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

@@ -686,10 +692,17 @@ export const AppContainer = (props: AppContainerProps) => {
686692
handleProQuotaChoice,
687693
validationRequest,
688694
handleValidationChoice,
695+
// G1 AI Credits
696+
overageMenuRequest,
697+
handleOverageMenuChoice,
698+
emptyWalletRequest,
699+
handleEmptyWalletChoice,
689700
} = useQuotaAndFallback({
690701
config,
691702
historyManager,
692703
userTier,
704+
paidTier,
705+
settings,
693706
setModelSwitchedFromQuotaError,
694707
onShowAuthSelection: () => setAuthState(AuthState.Updating),
695708
});
@@ -729,6 +742,8 @@ export const AppContainer = (props: AppContainerProps) => {
729742
const handleAuthSelect = useCallback(
730743
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
731744
if (authType) {
745+
const previousAuthType =
746+
config.getContentGeneratorConfig()?.authType ?? 'unknown';
732747
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
733748
setAuthContext({ requiresRestart: true });
734749
} else {
@@ -741,6 +756,10 @@ export const AppContainer = (props: AppContainerProps) => {
741756
config.setRemoteAdminSettings(undefined);
742757
await config.refreshAuth(authType);
743758
setAuthState(AuthState.Authenticated);
759+
logBillingEvent(
760+
config,
761+
new ApiKeyUpdatedEvent(previousAuthType, authType),
762+
);
744763
} catch (e) {
745764
if (e instanceof ChangeAuthRequestedError) {
746765
return;
@@ -803,6 +822,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
803822
// Only sync when not currently authenticating
804823
if (authState === AuthState.Authenticated) {
805824
setUserTier(config.getUserTier());
825+
setPaidTier(config.getUserPaidTier());
806826
}
807827
}, [config, authState]);
808828

@@ -2006,6 +2026,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
20062026
showIdeRestartPrompt ||
20072027
!!proQuotaRequest ||
20082028
!!validationRequest ||
2029+
!!overageMenuRequest ||
2030+
!!emptyWalletRequest ||
20092031
isSessionBrowserOpen ||
20102032
authState === AuthState.AwaitingApiKeyInput ||
20112033
!!newAgents;
@@ -2033,6 +2055,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
20332055
hasLoopDetectionConfirmationRequest ||
20342056
!!proQuotaRequest ||
20352057
!!validationRequest ||
2058+
!!overageMenuRequest ||
2059+
!!emptyWalletRequest ||
20362060
!!customDialog;
20372061

20382062
const allowPlanMode =
@@ -2243,6 +2267,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
22432267
stats: quotaStats,
22442268
proQuotaRequest,
22452269
validationRequest,
2270+
// G1 AI Credits dialog state
2271+
overageMenuRequest,
2272+
emptyWalletRequest,
22462273
},
22472274
contextFileNames,
22482275
errorCount,
@@ -2367,6 +2394,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
23672394
quotaStats,
23682395
proQuotaRequest,
23692396
validationRequest,
2397+
overageMenuRequest,
2398+
emptyWalletRequest,
23702399
contextFileNames,
23712400
errorCount,
23722401
availableTerminalHeight,
@@ -2448,6 +2477,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
24482477
handleClearScreen,
24492478
handleProQuotaChoice,
24502479
handleValidationChoice,
2480+
// G1 AI Credits handlers
2481+
handleOverageMenuChoice,
2482+
handleEmptyWalletChoice,
24512483
openSessionBrowser,
24522484
closeSessionBrowser,
24532485
handleResumeSession,
@@ -2534,6 +2566,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
25342566
handleClearScreen,
25352567
handleProQuotaChoice,
25362568
handleValidationChoice,
2569+
handleOverageMenuChoice,
2570+
handleEmptyWalletChoice,
25372571
openSessionBrowser,
25382572
closeSessionBrowser,
25392573
handleResumeSession,

packages/cli/src/ui/commands/statsCommand.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,18 @@ describe('statsCommand', () => {
3939
mockContext.session.stats.sessionStartTime = startTime;
4040
});
4141

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

45-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
46-
statsCommand.action(mockContext, '');
45+
mockContext.services.config = {
46+
refreshUserQuota: vi.fn(),
47+
refreshAvailableCredits: vi.fn(),
48+
getUserTierName: vi.fn(),
49+
getUserPaidTier: vi.fn(),
50+
getModel: vi.fn(),
51+
} as unknown as Config;
52+
53+
await statsCommand.action(mockContext, '');
4754

4855
const expectedDuration = formatDuration(
4956
endTime.getTime() - startTime.getTime(),
@@ -55,6 +62,7 @@ describe('statsCommand', () => {
5562
tier: undefined,
5663
userEmail: 'mock@example.com',
5764
currentModel: undefined,
65+
creditBalance: undefined,
5866
});
5967
});
6068

@@ -78,6 +86,8 @@ describe('statsCommand', () => {
7886
getQuotaRemaining: mockGetQuotaRemaining,
7987
getQuotaLimit: mockGetQuotaLimit,
8088
getQuotaResetTime: mockGetQuotaResetTime,
89+
getUserPaidTier: vi.fn(),
90+
refreshAvailableCredits: vi.fn(),
8191
} as unknown as Config;
8292

8393
await statsCommand.action(mockContext, '');

packages/cli/src/ui/commands/statsCommand.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import type {
1111
} from '../types.js';
1212
import { MessageType } from '../types.js';
1313
import { formatDuration } from '../utils/formatters.js';
14-
import { UserAccountManager } from '@google/gemini-cli-core';
14+
import {
15+
UserAccountManager,
16+
getG1CreditBalance,
17+
} from '@google/gemini-cli-core';
1518
import {
1619
type CommandContext,
1720
type SlashCommand,
@@ -27,8 +30,10 @@ function getUserIdentity(context: CommandContext) {
2730
const userEmail = cachedAccount ?? undefined;
2831

2932
const tier = context.services.config?.getUserTierName();
33+
const paidTier = context.services.config?.getUserPaidTier();
34+
const creditBalance = getG1CreditBalance(paidTier) ?? undefined;
3035

31-
return { selectedAuthType, userEmail, tier };
36+
return { selectedAuthType, userEmail, tier, creditBalance };
3237
}
3338

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

46-
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
51+
const { selectedAuthType, userEmail, tier, creditBalance } =
52+
getUserIdentity(context);
4753
const currentModel = context.services.config?.getModel();
4854

4955
const statsItem: HistoryItemStats = {
@@ -53,10 +59,14 @@ async function defaultSessionView(context: CommandContext) {
5359
userEmail,
5460
tier,
5561
currentModel,
62+
creditBalance,
5663
};
5764

5865
if (context.services.config) {
59-
const quota = await context.services.config.refreshUserQuota();
66+
const [quota] = await Promise.all([
67+
context.services.config.refreshUserQuota(),
68+
context.services.config.refreshAvailableCredits(),
69+
]);
6070
if (quota) {
6171
statsItem.quotas = quota;
6272
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();

packages/cli/src/ui/components/DialogManager.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ describe('DialogManager', () => {
8080
stats: undefined,
8181
proQuotaRequest: null,
8282
validationRequest: null,
83+
overageMenuRequest: null,
84+
emptyWalletRequest: null,
8385
},
8486
shouldShowIdePrompt: false,
8587
isFolderTrustDialogOpen: false,
@@ -132,6 +134,8 @@ describe('DialogManager', () => {
132134
resolve: vi.fn(),
133135
},
134136
validationRequest: null,
137+
overageMenuRequest: null,
138+
emptyWalletRequest: null,
135139
},
136140
},
137141
'ProQuotaDialog',

packages/cli/src/ui/components/DialogManager.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { EditorSettingsDialog } from './EditorSettingsDialog.js';
1818
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
1919
import { ProQuotaDialog } from './ProQuotaDialog.js';
2020
import { ValidationDialog } from './ValidationDialog.js';
21+
import { OverageMenuDialog } from './OverageMenuDialog.js';
22+
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
2123
import { runExitCleanup } from '../../utils/cleanup.js';
2224
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
2325
import { SessionBrowser } from './SessionBrowser.js';
@@ -152,6 +154,28 @@ export const DialogManager = ({
152154
/>
153155
);
154156
}
157+
if (uiState.quota.overageMenuRequest) {
158+
return (
159+
<OverageMenuDialog
160+
failedModel={uiState.quota.overageMenuRequest.failedModel}
161+
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
162+
resetTime={uiState.quota.overageMenuRequest.resetTime}
163+
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
164+
onChoice={uiActions.handleOverageMenuChoice}
165+
/>
166+
);
167+
}
168+
if (uiState.quota.emptyWalletRequest) {
169+
return (
170+
<EmptyWalletDialog
171+
failedModel={uiState.quota.emptyWalletRequest.failedModel}
172+
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
173+
resetTime={uiState.quota.emptyWalletRequest.resetTime}
174+
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
175+
onChoice={uiActions.handleEmptyWalletChoice}
176+
/>
177+
);
178+
}
155179
if (uiState.shouldShowIdePrompt) {
156180
return (
157181
<IdeIntegrationNudge

0 commit comments

Comments
 (0)