mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
314 lines
7.8 KiB
TypeScript
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;
|
|
}
|