/** * @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; 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 { 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 { 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((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 { 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((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 { 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; }