Skip to content

Commit 0c682da

Browse files
committed
feat: implement G1 AI credits overage flow with billing telemetry
Adds end-to-end support for Google One AI credits in quota exhaustion flows: - New billing module (packages/core/src/billing/) with credit balance checking, overage strategy management, and G1 URL construction - OverageMenuDialog and EmptyWalletDialog UI components for quota exhaustion with credit purchase options - Credits flow handler extracted to creditsFlowHandler.ts with overage menu, empty wallet, and auto-use-credits logic - Server-side credit tracking: enabledCreditTypes on requests, consumed/remaining credits from streaming responses - Billing telemetry events (overage menu shown, option selected, credits used, credit purchase click, API key updated) - OpenTelemetry metrics for overage option and credit purchase counters - Credit balance display in /stats command with refresh support - Settings: general.overageStrategy (ask/always/never) for credit usage - Error handling: INSUFFICIENT_G1_CREDITS_BALANCE as terminal error regardless of domain field presence - Persistent info message after
1 parent 29e8f2a commit 0c682da

52 files changed

Lines changed: 3167 additions & 22 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: true,
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
@@ -553,6 +553,8 @@ const mockUIActions: UIActions = {
553553
handleClearScreen: vi.fn(),
554554
handleProQuotaChoice: vi.fn(),
555555
handleValidationChoice: vi.fn(),
556+
handleOverageMenuChoice: vi.fn(),
557+
handleEmptyWalletChoice: vi.fn(),
556558
setQueueErrorMessage: vi.fn(),
557559
popAllMessages: vi.fn(),
558560
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';
@@ -414,6 +417,9 @@ export const AppContainer = (props: AppContainerProps) => {
414417
? { remaining, limit, resetTime }
415418
: undefined;
416419
});
420+
const [paidTier, setPaidTier] = useState<GeminiUserTier | undefined>(
421+
undefined,
422+
);
417423

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

@@ -709,10 +715,17 @@ export const AppContainer = (props: AppContainerProps) => {
709715
handleProQuotaChoice,
710716
validationRequest,
711717
handleValidationChoice,
718+
// G1 AI Credits
719+
overageMenuRequest,
720+
handleOverageMenuChoice,
721+
emptyWalletRequest,
722+
handleEmptyWalletChoice,
712723
} = useQuotaAndFallback({
713724
config,
714725
historyManager,
715726
userTier,
727+
paidTier,
728+
settings,
716729
setModelSwitchedFromQuotaError,
717730
onShowAuthSelection: () => setAuthState(AuthState.Updating),
718731
});
@@ -752,6 +765,8 @@ export const AppContainer = (props: AppContainerProps) => {
752765
const handleAuthSelect = useCallback(
753766
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
754767
if (authType) {
768+
const previousAuthType =
769+
config.getContentGeneratorConfig()?.authType ?? 'unknown';
755770
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
756771
setAuthContext({ requiresRestart: true });
757772
} else {
@@ -764,6 +779,10 @@ export const AppContainer = (props: AppContainerProps) => {
764779
config.setRemoteAdminSettings(undefined);
765780
await config.refreshAuth(authType);
766781
setAuthState(AuthState.Authenticated);
782+
logBillingEvent(
783+
config,
784+
new ApiKeyUpdatedEvent(previousAuthType, authType),
785+
);
767786
} catch (e) {
768787
if (e instanceof ChangeAuthRequestedError) {
769788
return;
@@ -826,6 +845,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
826845
// Only sync when not currently authenticating
827846
if (authState === AuthState.Authenticated) {
828847
setUserTier(config.getUserTier());
848+
setPaidTier(config.getUserPaidTier());
829849
}
830850
}, [config, authState]);
831851

@@ -2056,6 +2076,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
20562076
showIdeRestartPrompt ||
20572077
!!proQuotaRequest ||
20582078
!!validationRequest ||
2079+
!!overageMenuRequest ||
2080+
!!emptyWalletRequest ||
20592081
isSessionBrowserOpen ||
20602082
authState === AuthState.AwaitingApiKeyInput ||
20612083
!!newAgents;
@@ -2083,6 +2105,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
20832105
hasLoopDetectionConfirmationRequest ||
20842106
!!proQuotaRequest ||
20852107
!!validationRequest ||
2108+
!!overageMenuRequest ||
2109+
!!emptyWalletRequest ||
20862110
!!customDialog;
20872111

20882112
const allowPlanMode =
@@ -2293,6 +2317,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
22932317
stats: quotaStats,
22942318
proQuotaRequest,
22952319
validationRequest,
2320+
// G1 AI Credits dialog state
2321+
overageMenuRequest,
2322+
emptyWalletRequest,
22962323
},
22972324
contextFileNames,
22982325
errorCount,
@@ -2417,6 +2444,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
24172444
quotaStats,
24182445
proQuotaRequest,
24192446
validationRequest,
2447+
overageMenuRequest,
2448+
emptyWalletRequest,
24202449
contextFileNames,
24212450
errorCount,
24222451
availableTerminalHeight,
@@ -2498,6 +2527,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
24982527
handleClearScreen,
24992528
handleProQuotaChoice,
25002529
handleValidationChoice,
2530+
// G1 AI Credits handlers
2531+
handleOverageMenuChoice,
2532+
handleEmptyWalletChoice,
25012533
openSessionBrowser,
25022534
closeSessionBrowser,
25032535
handleResumeSession,
@@ -2583,6 +2615,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
25832615
handleClearScreen,
25842616
handleProQuotaChoice,
25852617
handleValidationChoice,
2618+
handleOverageMenuChoice,
2619+
handleEmptyWalletChoice,
25862620
openSessionBrowser,
25872621
closeSessionBrowser,
25882622
handleResumeSession,

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,19 @@ 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+
54+
await statsCommand.action(mockContext, '');
4755

4856
const expectedDuration = formatDuration(
4957
endTime.getTime() - startTime.getTime(),
@@ -55,6 +63,7 @@ describe('statsCommand', () => {
5563
tier: undefined,
5664
userEmail: 'mock@example.com',
5765
currentModel: undefined,
66+
creditBalance: null,
5867
});
5968
});
6069

@@ -78,6 +87,8 @@ describe('statsCommand', () => {
7887
getQuotaRemaining: mockGetQuotaRemaining,
7988
getQuotaLimit: mockGetQuotaLimit,
8089
getQuotaResetTime: mockGetQuotaResetTime,
90+
getUserPaidTier: vi.fn(),
91+
refreshAvailableCredits: vi.fn(),
8192
} as unknown as Config;
8293

8394
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);
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';
@@ -151,6 +153,28 @@ export const DialogManager = ({
151153
/>
152154
);
153155
}
156+
if (uiState.quota.overageMenuRequest) {
157+
return (
158+
<OverageMenuDialog
159+
failedModel={uiState.quota.overageMenuRequest.failedModel}
160+
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
161+
resetTime={uiState.quota.overageMenuRequest.resetTime}
162+
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
163+
onChoice={uiActions.handleOverageMenuChoice}
164+
/>
165+
);
166+
}
167+
if (uiState.quota.emptyWalletRequest) {
168+
return (
169+
<EmptyWalletDialog
170+
failedModel={uiState.quota.emptyWalletRequest.failedModel}
171+
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
172+
resetTime={uiState.quota.emptyWalletRequest.resetTime}
173+
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
174+
onChoice={uiActions.handleEmptyWalletChoice}
175+
/>
176+
);
177+
}
154178
if (uiState.shouldShowIdePrompt) {
155179
return (
156180
<IdeIntegrationNudge

0 commit comments

Comments
 (0)