feat: launch Gemini 3 in Gemini CLI 🚀🚀🚀 (in main) (#13287)

Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Aishanee Shah <aishaneeshah@gmail.com>
Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: joshualitt <joshualitt@google.com>
Co-authored-by: Jenna Inouye <jinouye@google.com>
This commit is contained in:
Shreya Keshive
2025-11-18 12:01:16 -05:00
committed by GitHub
parent 78075c8a37
commit 86828bb561
79 changed files with 3148 additions and 605 deletions

View File

@@ -25,6 +25,8 @@ import {
makeFakeConfig,
type GoogleApiError,
RetryableQuotaError,
PREVIEW_GEMINI_MODEL,
ModelNotFoundError,
} from '@google/gemini-cli-core';
import { useQuotaAndFallback } from './useQuotaAndFallback.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -87,18 +89,14 @@ describe('useQuotaAndFallback', () => {
describe('Fallback Handler Logic', () => {
// Helper function to render the hook and extract the registered handler
const getRegisteredHandler = (
userTier: UserTierId = UserTierId.FREE,
): FallbackModelHandler => {
renderHook(
(props) =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: props.userTier,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
{ initialProps: { userTier } },
const getRegisteredHandler = (): FallbackModelHandler => {
renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
};
@@ -116,65 +114,8 @@ describe('useQuotaAndFallback', () => {
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
describe('Flash Model Fallback', () => {
it('should show a terminal quota message and stop, without offering a fallback', async () => {
const handler = getRegisteredHandler();
const result = await handler(
'gemini-2.5-flash',
'gemini-2.5-flash',
new TerminalQuotaError('flash quota', mockGoogleApiError),
);
expect(result).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
.text;
expect(message).toContain(
'You have reached your daily gemini-2.5-flash',
);
expect(message).not.toContain('continue with the fallback model');
});
it('should show a capacity message and stop', async () => {
const handler = getRegisteredHandler();
// let result: FallbackIntent | null = null;
const result = await handler(
'gemini-2.5-flash',
'gemini-2.5-flash',
new Error('capacity'),
);
expect(result).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
.text;
expect(message).toContain(
'Pardon Our Congestion! It looks like gemini-2.5-flash is very popular',
);
});
it('should show a capacity message and stop, even when already in fallback mode', async () => {
vi.spyOn(mockConfig, 'isInFallbackMode').mockReturnValue(true);
const handler = getRegisteredHandler();
const result = await handler(
'gemini-2.5-flash',
'gemini-2.5-flash',
new Error('capacity'),
);
expect(result).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
.text;
expect(message).toContain(
'Pardon Our Congestion! It looks like gemini-2.5-flash is very popular',
);
});
});
describe('Interactive Fallback', () => {
// Pro Quota Errors
it('should set an interactive request and wait for user choice', async () => {
it('should set an interactive request for a terminal quota error', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
@@ -187,31 +128,42 @@ describe('useQuotaAndFallback', () => {
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
// Call the handler but do not await it, to check the intermediate state
let promise: Promise<FallbackIntent | null>;
const error = new TerminalQuotaError(
'pro quota',
mockGoogleApiError,
1000 * 60 * 5,
); // 5 minutes
await act(() => {
promise = handler(
'gemini-pro',
'gemini-flash',
new TerminalQuotaError('pro quota', mockGoogleApiError),
);
promise = handler('gemini-pro', 'gemini-flash', error);
});
// The hook should now have a pending request for the UI to handle
expect(result.current.proQuotaRequest).not.toBeNull();
expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro');
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('gemini-pro');
expect(request?.isTerminalQuotaError).toBe(true);
const message = request!.message;
expect(message).toContain('Usage limit reached for gemini-pro.');
expect(message).toContain('Access resets at'); // From getResetTimeMessage
expect(message).toContain('/stats for usage details');
expect(message).toContain('/auth to switch to API key.');
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
// Simulate the user choosing to continue with the fallback model
await act(() => {
result.current.handleProQuotaChoice('retry');
result.current.handleProQuotaChoice('retry_always');
});
// The original promise from the handler should now resolve
const intent = await promise!;
expect(intent).toBe('retry');
expect(intent).toBe('retry_always');
// The pending request should be cleared from the state
expect(result.current.proQuotaRequest).toBeNull();
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
});
it('should handle race conditions by stopping subsequent requests', async () => {
@@ -253,120 +205,129 @@ describe('useQuotaAndFallback', () => {
expect(result.current.proQuotaRequest).toBe(firstRequest);
await act(() => {
result.current.handleProQuotaChoice('retry');
result.current.handleProQuotaChoice('retry_always');
});
const intent1 = await promise1!;
expect(intent1).toBe('retry');
expect(intent1).toBe('retry_always');
expect(result.current.proQuotaRequest).toBeNull();
});
// Non-Quota error test cases
// Non-TerminalQuotaError test cases
const testCases = [
{
description: 'other error for FREE tier',
tier: UserTierId.FREE,
description: 'generic error',
error: new Error('some error'),
expectedMessageSnippets: [
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
'Please retry again later.',
],
},
{
description: 'other error for LEGACY tier',
tier: UserTierId.LEGACY, // Paid tier
error: new Error('some error'),
expectedMessageSnippets: [
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
'Please retry again later.',
],
},
{
description: 'retryable quota error for FREE tier',
tier: UserTierId.FREE,
description: 'retryable quota error',
error: new RetryableQuotaError(
'retryable quota',
mockGoogleApiError,
5,
),
expectedMessageSnippets: [
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
'Please retry again later.',
],
},
{
description: 'retryable quota error for LEGACY tier',
tier: UserTierId.LEGACY, // Paid tier
error: new RetryableQuotaError(
'retryable quota',
mockGoogleApiError,
5,
),
expectedMessageSnippets: [
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
'Please retry again later.',
],
},
];
for (const {
description,
tier,
error,
expectedMessageSnippets,
} of testCases) {
for (const { description, error } of testCases) {
it(`should handle ${description} correctly`, async () => {
const { result } = renderHook(
(props) =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: props.tier,
setModelSwitchedFromQuotaError:
mockSetModelSwitchedFromQuotaError,
}),
{ initialProps: { tier } },
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError:
mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
// Call the handler but do not await it, to check the intermediate state
let promise: Promise<FallbackIntent | null>;
await act(() => {
promise = handler('model-A', 'model-B', error);
});
// The hook should now have a pending request for the UI to handle
expect(result.current.proQuotaRequest).not.toBeNull();
expect(result.current.proQuotaRequest?.failedModel).toBe('model-A');
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('model-A');
expect(request?.isTerminalQuotaError).toBe(false);
// Check that the correct initial message was added
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.INFO }),
expect.any(Number),
// Check that the correct initial message was generated
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
const message = request!.message;
expect(message).toContain(
'model-A is currently experiencing high demand. We apologize and appreciate your patience.',
);
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
.text;
for (const snippet of expectedMessageSnippets) {
expect(message).toContain(snippet);
}
// Simulate the user choosing to continue with the fallback model
await act(() => {
result.current.handleProQuotaChoice('retry');
result.current.handleProQuotaChoice('retry_always');
});
expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(true);
// The original promise from the handler should now resolve
const intent = await promise!;
expect(intent).toBe('retry');
expect(intent).toBe('retry_always');
// The pending request should be cleared from the state
expect(result.current.proQuotaRequest).toBeNull();
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true);
// Check for the "Switched to fallback model" message
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock
.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain('Switched to fallback model.');
});
}
it('should handle ModelNotFoundError correctly', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new ModelNotFoundError('model not found', 404);
await act(() => {
promise = handler('gemini-3-pro-preview', 'gemini-2.5-pro', error);
});
// The hook should now have a pending request for the UI to handle
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('gemini-3-pro-preview');
expect(request?.isTerminalQuotaError).toBe(false);
expect(request?.isModelNotFoundError).toBe(true);
const message = request!.message;
expect(message).toBe(
`It seems like you don't have access to Gemini 3.
Learn more at https://goo.gle/enable-preview-features
To disable Gemini 3, disable "Preview features" in /settings.`,
);
// Simulate the user choosing to switch
await act(() => {
result.current.handleProQuotaChoice('retry_always');
});
const intent = await promise!;
expect(intent).toBe('retry_always');
expect(result.current.proQuotaRequest).toBeNull();
});
});
});
@@ -418,7 +379,7 @@ describe('useQuotaAndFallback', () => {
expect(result.current.proQuotaRequest).toBeNull();
});
it('should resolve intent to "retry" and add info message on continue', async () => {
it('should resolve intent to "retry_always" and add info message on continue', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
@@ -430,7 +391,7 @@ describe('useQuotaAndFallback', () => {
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
// The first `addItem` call is for the initial quota error message
let promise: Promise<FallbackIntent | null>;
await act(() => {
promise = handler(
@@ -441,18 +402,53 @@ describe('useQuotaAndFallback', () => {
});
await act(() => {
result.current.handleProQuotaChoice('retry');
result.current.handleProQuotaChoice('retry_always');
});
const intent = await promise!;
expect(intent).toBe('retry');
expect(intent).toBe('retry_always');
expect(result.current.proQuotaRequest).toBeNull();
// Check for the second "Switched to fallback model" message
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[1][0];
// Check for the "Switched to fallback model" message
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain('Switched to fallback model.');
});
it('should show a special message when falling back from the preview model', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
await act(() => {
promise = handler(
PREVIEW_GEMINI_MODEL,
'gemini-flash',
new Error('preview model failed'),
);
});
await act(() => {
result.current.handleProQuotaChoice('retry_always');
});
await promise!;
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain(
`Switched to fallback model gemini-flash. We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.`,
);
});
});
});