Files
gemini-cli/packages/cli/src/ui/hooks/creditsFlowHandler.ts

314 lines
7.8 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
type FallbackIntent,
type GeminiUserTier,
type OverageOption,
getG1CreditBalance,
shouldAutoUseCredits,
shouldShowOverageMenu,
shouldShowEmptyWalletMenu,
openBrowserSecurely,
shouldLaunchBrowser,
logBillingEvent,
OverageMenuShownEvent,
OverageOptionSelectedEvent,
EmptyWalletMenuShownEvent,
CreditPurchaseClickEvent,
buildG1Url,
G1_UTM_CAMPAIGNS,
UserAccountManager,
recordOverageOptionSelected,
recordCreditPurchaseClick,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type {
OverageMenuIntent,
EmptyWalletIntent,
EmptyWalletDialogRequest,
} from '../contexts/UIStateContext.js';
interface CreditsFlowArgs {
config: Config;
paidTier: GeminiUserTier;
overageStrategy: 'ask' | 'always' | 'never';
failedModel: string;
fallbackModel: string;
usageLimitReachedModel: string;
resetTime: string | undefined;
historyManager: UseHistoryManagerReturn;
setModelSwitchedFromQuotaError: (value: boolean) => void;
isDialogPending: React.MutableRefObject<boolean>;
setOverageMenuRequest: (
req: {
failedModel: string;
fallbackModel: string;
resetTime: string | undefined;
creditBalance: number;
resolve: (intent: OverageMenuIntent) => void;
} | null,
) => void;
setEmptyWalletRequest: (req: EmptyWalletDialogRequest | null) => void;
}
/**
* Handles the G1 AI Credits flow when a quota error occurs.
* Returns a FallbackIntent if the credits flow handled the error,
* or null to fall through to the default ProQuotaDialog.
*/
export async function handleCreditsFlow(
args: CreditsFlowArgs,
): Promise<FallbackIntent | null> {
const creditBalance = getG1CreditBalance(args.paidTier);
// creditBalance is null when user is not eligible for G1 credits.
if (creditBalance == null) {
return null;
}
const { overageStrategy } = args;
// If credits are already auto-enabled (strategy='always'), the request
// that just failed already included enabledCreditTypes — credits didn't
// help. Fall through to ProQuotaDialog which offers the Flash downgrade.
if (shouldAutoUseCredits(overageStrategy, creditBalance)) {
return null;
}
// Show overage menu when strategy is 'ask' and credits > 0
if (shouldShowOverageMenu(overageStrategy, creditBalance)) {
return handleOverageMenu(args, creditBalance);
}
// Show empty wallet when credits === 0 and strategy isn't 'never'
if (shouldShowEmptyWalletMenu(overageStrategy, creditBalance)) {
return handleEmptyWalletMenu(args);
}
return null;
}
// ---------------------------------------------------------------------------
// Overage menu flow
// ---------------------------------------------------------------------------
async function handleOverageMenu(
args: CreditsFlowArgs,
creditBalance: number,
): Promise<FallbackIntent> {
const {
config,
fallbackModel,
usageLimitReachedModel,
overageStrategy,
resetTime,
isDialogPending,
setOverageMenuRequest,
setModelSwitchedFromQuotaError,
} = args;
logBillingEvent(
config,
new OverageMenuShownEvent(
usageLimitReachedModel,
creditBalance,
overageStrategy,
),
);
if (isDialogPending.current) {
return 'stop';
}
isDialogPending.current = true;
setModelSwitchedFromQuotaError(true);
config.setQuotaErrorOccurred(true);
const overageIntent = await new Promise<OverageMenuIntent>((resolve) => {
setOverageMenuRequest({
failedModel: usageLimitReachedModel,
fallbackModel,
resetTime,
creditBalance,
resolve,
});
});
setOverageMenuRequest(null);
isDialogPending.current = false;
logOverageOptionSelected(
config,
usageLimitReachedModel,
overageIntent,
creditBalance,
);
switch (overageIntent) {
case 'use_credits':
setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false);
config.setOverageStrategy('always');
return 'retry_with_credits';
case 'use_fallback':
return 'retry_always';
case 'manage': {
logCreditPurchaseClick(config, 'manage', usageLimitReachedModel);
const manageUrl = await openG1Url(
'activity',
G1_UTM_CAMPAIGNS.MANAGE_ACTIVITY,
);
if (manageUrl) {
args.historyManager.addItem(
{
type: MessageType.INFO,
text: `Please open this URL in a browser: ${manageUrl}`,
},
Date.now(),
);
}
return 'stop';
}
case 'stop':
default:
return 'stop';
}
}
// ---------------------------------------------------------------------------
// Empty wallet flow
// ---------------------------------------------------------------------------
async function handleEmptyWalletMenu(
args: CreditsFlowArgs,
): Promise<FallbackIntent> {
const {
config,
fallbackModel,
usageLimitReachedModel,
resetTime,
isDialogPending,
setEmptyWalletRequest,
setModelSwitchedFromQuotaError,
} = args;
logBillingEvent(
config,
new EmptyWalletMenuShownEvent(usageLimitReachedModel),
);
if (isDialogPending.current) {
return 'stop';
}
isDialogPending.current = true;
setModelSwitchedFromQuotaError(true);
config.setQuotaErrorOccurred(true);
const emptyWalletIntent = await new Promise<EmptyWalletIntent>((resolve) => {
setEmptyWalletRequest({
failedModel: usageLimitReachedModel,
fallbackModel,
resetTime,
onGetCredits: async () => {
logCreditPurchaseClick(
config,
'empty_wallet_menu',
usageLimitReachedModel,
);
const creditsUrl = await openG1Url(
'credits',
G1_UTM_CAMPAIGNS.EMPTY_WALLET_ADD_CREDITS,
);
if (creditsUrl) {
args.historyManager.addItem(
{
type: MessageType.INFO,
text: `Please open this URL in a browser: ${creditsUrl}`,
},
Date.now(),
);
}
},
resolve,
});
});
setEmptyWalletRequest(null);
isDialogPending.current = false;
switch (emptyWalletIntent) {
case 'get_credits':
args.historyManager.addItem(
{
type: MessageType.INFO,
text: 'Newly purchased AI credits may take a few minutes to update. Run /stats to check your balance.',
},
Date.now(),
);
return 'stop';
case 'use_fallback':
return 'retry_always';
case 'stop':
default:
return 'stop';
}
}
// ---------------------------------------------------------------------------
// Telemetry helpers
// ---------------------------------------------------------------------------
function logOverageOptionSelected(
config: Config,
model: string,
option: OverageOption,
creditBalance: number,
): void {
logBillingEvent(
config,
new OverageOptionSelectedEvent(model, option, creditBalance),
);
recordOverageOptionSelected(config, {
selected_option: option,
model,
});
}
function logCreditPurchaseClick(
config: Config,
source: 'overage_menu' | 'empty_wallet_menu' | 'manage',
model: string,
): void {
logBillingEvent(config, new CreditPurchaseClickEvent(source, model));
recordCreditPurchaseClick(config, { source, model });
}
async function openG1Url(
path: 'activity' | 'credits',
campaign: string,
): Promise<string | undefined> {
try {
const userEmail = new UserAccountManager().getCachedGoogleAccount() ?? '';
const url = buildG1Url(path, userEmail, campaign);
if (!shouldLaunchBrowser()) {
return url;
}
await openBrowserSecurely(url);
} catch {
// Ignore browser open errors
}
return undefined;
}