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

@@ -581,6 +581,7 @@ export async function loadCliConfig(
settings.context?.loadMemoryFromIncludeDirectories || false,
debugMode,
question,
previewFeatures: settings.general?.previewFeatures,
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,

View File

@@ -322,6 +322,30 @@ describe('SettingsSchema', () => {
).toBe('Enable debug logging of keystrokes to the console.');
});
it('should have previewFeatures setting in schema', () => {
expect(
getSettingsSchema().general.properties.previewFeatures,
).toBeDefined();
expect(getSettingsSchema().general.properties.previewFeatures.type).toBe(
'boolean',
);
expect(
getSettingsSchema().general.properties.previewFeatures.category,
).toBe('General');
expect(
getSettingsSchema().general.properties.previewFeatures.default,
).toBe(false);
expect(
getSettingsSchema().general.properties.previewFeatures.requiresRestart,
).toBe(true);
expect(
getSettingsSchema().general.properties.previewFeatures.showInDialog,
).toBe(true);
expect(
getSettingsSchema().general.properties.previewFeatures.description,
).toBe('Enable preview features (e.g., preview models).');
});
it('should have useModelRouter setting in schema', () => {
expect(
getSettingsSchema().experimental.properties.useModelRouter,

View File

@@ -160,6 +160,15 @@ const SETTINGS_SCHEMA = {
description: 'General application settings.',
showInDialog: false,
properties: {
previewFeatures: {
type: 'boolean',
label: 'Preview Features (e.g., models)',
category: 'General',
requiresRestart: true,
default: false,
description: 'Enable preview features (e.g., preview models).',
showInDialog: true,
},
preferredEditor: {
type: 'string',
label: 'Preferred Editor',
@@ -251,6 +260,7 @@ const SETTINGS_SCHEMA = {
category: 'General',
requiresRestart: false,
default: undefined as SessionRetentionSettings | undefined,
showInDialog: false,
properties: {
enabled: {
type: 'boolean',

View File

@@ -72,6 +72,10 @@ describe('App', () => {
},
history: [],
pendingHistoryItems: [],
bannerData: {
defaultText: 'Mock Banner Text',
warningText: '',
},
};
const mockConfig = makeFakeConfig();

View File

@@ -25,6 +25,7 @@ import {
CoreEvent,
type UserFeedbackPayload,
type ResumedSessionData,
AuthType,
} from '@google/gemini-cli-core';
// Mock coreEvents
@@ -1796,4 +1797,21 @@ describe('AppContainer State Management', () => {
unmount();
});
});
describe('Banner Text', () => {
it('should render placeholder banner text for USE_GEMINI auth type', async () => {
const config = makeFakeConfig();
vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_GEMINI,
apiKey: 'fake-key',
});
const { unmount } = renderAppContainer();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
await vi.waitFor(() => {
expect(capturedUIState.bannerData.defaultText).toBeDefined();
unmount();
});
});
});
});

View File

@@ -181,6 +181,10 @@ export const AppContainer = (props: AppContainerProps) => {
null,
);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
const [bannerVisible, setBannerVisible] = useState(true);
const extensionManager = config.getExtensionLoader() as ExtensionManager;
// We are in the interactive CLI, update how we request consent and settings.
extensionManager.setRequestConsent((description) =>
@@ -596,6 +600,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
slashCommandActions,
extensionsUpdateStateInternal,
isConfigInitialized,
setBannerVisible,
setCustomDialog,
);
@@ -1305,6 +1310,38 @@ Logging in with Google... Please restart Gemini CLI to continue.
};
}, []);
useEffect(() => {
let isMounted = true;
const fetchBannerTexts = async () => {
const [defaultBanner, warningBanner] = await Promise.all([
config.getBannerTextNoCapacityIssues(),
config.getBannerTextCapacityIssues(),
]);
if (isMounted) {
setDefaultBannerText(defaultBanner);
setWarningBannerText(warningBanner);
setBannerVisible(true);
refreshStatic();
const authType = config.getContentGeneratorConfig()?.authType;
if (
authType === AuthType.USE_GEMINI ||
authType === AuthType.USE_VERTEX_AI
) {
setDefaultBannerText(
'Gemini 3 is now available.\nTo use Gemini 3, enable "Preview features" in /settings\nLearn more at https://goo.gle/enable-preview-features',
);
}
}
};
fetchBannerTexts();
return () => {
isMounted = false;
};
}, [config, refreshStatic]);
const uiState: UIState = useMemo(
() => ({
history: historyManager.history,
@@ -1394,6 +1431,11 @@ Logging in with Google... Please restart Gemini CLI to continue.
customDialog,
copyModeEnabled,
warningMessage,
bannerData: {
defaultText: defaultBannerText,
warningText: warningBannerText,
},
bannerVisible,
}),
[
isThemeDialogOpen,
@@ -1482,6 +1524,9 @@ Logging in with Google... Please restart Gemini CLI to continue.
authState,
copyModeEnabled,
warningMessage,
defaultBannerText,
warningBannerText,
bannerVisible,
],
);
@@ -1519,6 +1564,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
}),
[
handleThemeSelect,
@@ -1548,6 +1594,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
],
);

View File

@@ -121,11 +121,12 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
async function setIdeModeAndSyncConnection(
config: Config,
value: boolean,
options: { logToConsole?: boolean } = {},
): Promise<void> {
config.setIdeMode(value);
const ideClient = await IdeClient.getInstance();
if (value) {
await ideClient.connect();
await ideClient.connect(options);
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
} else {
await ideClient.disconnect();
@@ -144,7 +145,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
({
type: 'message',
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks.`,
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
}) as const,
};
}
@@ -212,7 +213,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
);
// Poll for up to 5 seconds for the extension to activate.
for (let i = 0; i < 10; i++) {
await setIdeModeAndSyncConnection(context.services.config!, true);
await setIdeModeAndSyncConnection(context.services.config!, true, {
logToConsole: false,
});
if (
ideClient.getConnectionStatus().status ===
IDEConnectionStatus.Connected

View File

@@ -12,6 +12,10 @@ import { Text } from 'ink';
import { renderWithProviders } from '../../test-utils/render.js';
import type { Config } from '@google/gemini-cli-core';
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: () => null,
}));
vi.mock('../contexts/AppContext.js', () => ({
useAppContext: () => ({
version: '0.10.0',
@@ -85,6 +89,11 @@ const mockConfig = {
getTargetDir: () => '/tmp',
getDebugMode: () => false,
getGeminiMdFileCount: () => 0,
getExperiments: () => ({
flags: {},
experimentIds: [],
}),
getPreviewFeatures: () => false,
} as unknown as Config;
describe('AlternateBufferQuittingDisplay', () => {
@@ -101,6 +110,10 @@ describe('AlternateBufferQuittingDisplay', () => {
activePtyId: undefined,
embeddedShellFocused: false,
renderMarkdown: false,
bannerData: {
defaultText: '',
warningText: '',
},
},
config: mockConfig,
},

View File

@@ -0,0 +1,189 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
const persistentStateMock = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
}));
vi.mock('../../utils/persistentState.js', () => ({
persistentState: persistentStateMock,
}));
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: () => null,
}));
describe('<AppHeader />', () => {
beforeEach(() => {
vi.clearAllMocks();
persistentStateMock.get.mockReturnValue(0);
});
it('should render the banner with default text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render the banner with warning text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: 'There are capacity issues',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('There are capacity issues');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the banner when no flags are set', () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: '',
warningText: '',
},
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('Banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render the banner when previewFeatures is disabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: false });
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the banner when previewFeatures is enabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: true });
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the default banner if shown count is 5 or more', () => {
persistentStateMock.get.mockReturnValue(5);
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should increment the shown count when default banner is displayed', () => {
persistentStateMock.get.mockReturnValue(0);
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
};
const { unmount } = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
uiState,
});
expect(persistentStateMock.set).toHaveBeenCalledWith(
'defaultBannerShownCount',
1,
);
unmount();
});
it('should render banner text with unescaped newlines', () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'First line\\nSecond line',
warningText: '',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
});
});

View File

@@ -10,6 +10,11 @@ import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Banner } from './Banner.js';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import { persistentState } from '../../utils/persistentState.js';
import { useState, useEffect, useRef } from 'react';
interface AppHeaderProps {
version: string;
@@ -18,12 +23,46 @@ interface AppHeaderProps {
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly } = useUIState();
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
const [defaultBannerShownCount] = useState(
() => persistentState.get('defaultBannerShownCount') || 0,
);
const { defaultText, warningText } = bannerData;
const showDefaultBanner =
warningText === '' &&
!config.getPreviewFeatures() &&
defaultBannerShownCount < 5;
const bannerText = showDefaultBanner ? defaultText : warningText;
const unescapedBannerText = bannerText.replace(/\\n/g, '\n');
const defaultColor = Colors.AccentBlue;
const fontColor = warningText === '' ? defaultColor : theme.status.warning;
const hasIncrementedRef = useRef(false);
useEffect(() => {
if (showDefaultBanner && defaultText && !hasIncrementedRef.current) {
hasIncrementedRef.current = true;
const current = persistentState.get('defaultBannerShownCount') || 0;
persistentState.set('defaultBannerShownCount', current + 1);
}
}, [showDefaultBanner, defaultText]);
return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
<Header version={version} nightly={nightly} />
<>
<Header version={version} nightly={nightly} />
{bannerVisible && unescapedBannerText && (
<Banner
width={mainAreaWidth}
bannerText={unescapedBannerText}
color={fontColor}
/>
)}
</>
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />

View File

@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { theme } from '../semantic-colors.js';
interface BannerProps {
bannerText: string;
color: string;
width: number;
}
export const Banner = ({ bannerText, color, width }: BannerProps) => {
const gradient = theme.ui.gradient;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={color}
width={width}
paddingLeft={1}
paddingRight={1}
>
<Gradient colors={gradient}>
<Text>{bannerText}</Text>
</Gradient>
</Box>
);
};

View File

@@ -157,6 +157,7 @@ export const Composer = () => {
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
setBannerVisible={uiActions.setBannerVisible}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands || []}

View File

@@ -53,8 +53,13 @@ export const DialogManager = ({
if (uiState.proQuotaRequest) {
return (
<ProQuotaDialog
failedModel={uiState.proQuotaRequest.failedModel}
fallbackModel={uiState.proQuotaRequest.fallbackModel}
message={uiState.proQuotaRequest.message}
isTerminalQuotaError={uiState.proQuotaRequest.isTerminalQuotaError}
isModelNotFoundError={!!uiState.proQuotaRequest.isModelNotFoundError}
onChoice={uiActions.handleProQuotaChoice}
userTier={uiState.userTier}
/>
);
}

View File

@@ -9,7 +9,6 @@ import { useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
EDITOR_DISPLAY_NAMES,
editorSettingsManager,
type EditorDisplay,
} from '../editors/editorSettingsManager.js';
@@ -19,8 +18,11 @@ import type {
LoadedSettings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import type { EditorType } from '@google/gemini-cli-core';
import { isEditorAvailable } from '@google/gemini-cli-core';
import {
type EditorType,
isEditorAvailable,
EDITOR_DISPLAY_NAMES,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
interface EditorDialogProps {

View File

@@ -235,6 +235,7 @@ describe('InputPrompt', () => {
focus: true,
setQueueErrorMessage: vi.fn(),
streamingState: StreamingState.Idle,
setBannerVisible: vi.fn(),
};
});
@@ -812,6 +813,19 @@ describe('InputPrompt', () => {
unmount();
});
it('should call setBannerVisible(false) when clear screen key is pressed', async () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\x0C'); // Ctrl+L
});
await waitFor(() => {
expect(props.setBannerVisible).toHaveBeenCalledWith(false);
});
unmount();
});
describe('cursor-based completion trigger', () => {
it.each([
{

View File

@@ -80,6 +80,7 @@ export interface InputPromptProps {
streamingState: StreamingState;
popAllMessages?: (onPop: (messages: string | undefined) => void) => void;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
}
// The input content, input container, and input suggestions list may have different widths
@@ -121,6 +122,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState,
popAllMessages,
suggestionsPosition = 'below',
setBannerVisible,
}) => {
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
@@ -525,6 +527,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
setBannerVisible(false);
onClearScreen();
return;
}
@@ -819,6 +822,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandSearchCompletion,
kittyProtocol.supported,
tryLoadQueuedMessages,
setBannerVisible,
],
);

View File

@@ -8,9 +8,9 @@ import { render } from '../../test-utils/render.js';
import { cleanup } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
GEMINI_MODEL_ALIAS_FLASH_LITE,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_PRO,
DEFAULT_GEMINI_MODEL_AUTO,
} from '@google/gemini-cli-core';
import { ModelDialog } from './ModelDialog.js';
@@ -43,6 +43,7 @@ const renderComponent = (
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO),
setModel: vi.fn(),
getPreviewFeatures: vi.fn(() => false),
// --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true),
@@ -86,7 +87,7 @@ describe('<ModelDialog />', () => {
expect(lastFrame()).toContain('Select Model');
expect(lastFrame()).toContain('(Press Esc to close)');
expect(lastFrame()).toContain(
'> To use a specific Gemini model on startup, use the --model flag.',
'To use a specific Gemini model on startup, use the --model flag.',
);
unmount();
});
@@ -98,15 +99,15 @@ describe('<ModelDialog />', () => {
const props = mockedSelect.mock.calls[0][0];
expect(props.items).toHaveLength(4);
expect(props.items[0].value).toBe(DEFAULT_GEMINI_MODEL_AUTO);
expect(props.items[1].value).toBe(DEFAULT_GEMINI_MODEL);
expect(props.items[2].value).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(props.items[3].value).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
expect(props.items[1].value).toBe(GEMINI_MODEL_ALIAS_PRO);
expect(props.items[2].value).toBe(GEMINI_MODEL_ALIAS_FLASH);
expect(props.items[3].value).toBe(GEMINI_MODEL_ALIAS_FLASH_LITE);
expect(props.showNumbers).toBe(true);
unmount();
});
it('initializes with the model from ConfigContext', () => {
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_FLASH_MODEL);
const mockGetModel = vi.fn(() => GEMINI_MODEL_ALIAS_FLASH);
const { unmount } = renderComponent({}, { getModel: mockGetModel });
expect(mockGetModel).toHaveBeenCalled();
@@ -157,10 +158,10 @@ describe('<ModelDialog />', () => {
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
expect(childOnSelect).toBeDefined();
childOnSelect(DEFAULT_GEMINI_MODEL);
childOnSelect(GEMINI_MODEL_ALIAS_PRO);
// Assert against the default mock provided by renderComponent
expect(mockConfig?.setModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
expect(mockConfig?.setModel).toHaveBeenCalledWith(GEMINI_MODEL_ALIAS_PRO);
expect(props.onClose).toHaveBeenCalledTimes(1);
unmount();
});
@@ -209,18 +210,23 @@ describe('<ModelDialog />', () => {
it('updates initialIndex when config context changes', () => {
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO);
const oldMockConfig = {
getModel: mockGetModel,
getPreviewFeatures: vi.fn(() => false),
} as unknown as Config;
const { rerender, unmount } = render(
<ConfigContext.Provider
value={{ getModel: mockGetModel } as unknown as Config}
>
<ConfigContext.Provider value={oldMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
);
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
mockGetModel.mockReturnValue(DEFAULT_GEMINI_FLASH_LITE_MODEL);
const newMockConfig = { getModel: mockGetModel } as unknown as Config;
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_FLASH_LITE);
const newMockConfig = {
getModel: mockGetModel,
getPreviewFeatures: vi.fn(() => false),
} as unknown as Config;
rerender(
<ConfigContext.Provider value={newMockConfig}>

View File

@@ -8,10 +8,14 @@ import type React from 'react';
import { useCallback, useContext, useMemo } from 'react';
import { Box, Text } from 'ink';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
GEMINI_MODEL_ALIAS_PRO,
ModelSlashCommandEvent,
logModelSlashCommand,
} from '@google/gemini-cli-core';
@@ -19,38 +23,12 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import Gradient from 'ink-gradient';
interface ModelDialogProps {
onClose: () => void;
}
const MODEL_OPTIONS = [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: 'Auto (recommended)',
description: 'Let the system choose the best model for your task',
key: DEFAULT_GEMINI_MODEL_AUTO,
},
{
value: DEFAULT_GEMINI_MODEL,
title: 'Pro',
description: 'For complex tasks that require deep reasoning and creativity',
key: DEFAULT_GEMINI_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: 'Flash',
description: 'For tasks that need a balance of speed and reasoning',
key: DEFAULT_GEMINI_FLASH_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: 'Flash-Lite',
description: 'For simple tasks that need to be done quickly',
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
},
];
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
@@ -66,10 +44,43 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{ isActive: true },
);
const options = useMemo(
() => [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: 'Auto',
description: 'Let the system choose the best model for your task.',
key: DEFAULT_GEMINI_MODEL_AUTO,
},
{
value: GEMINI_MODEL_ALIAS_PRO,
title: config?.getPreviewFeatures()
? `Pro (${PREVIEW_GEMINI_MODEL}, ${DEFAULT_GEMINI_MODEL})`
: `Pro (${DEFAULT_GEMINI_MODEL})`,
description:
'For complex tasks that require deep reasoning and creativity',
key: GEMINI_MODEL_ALIAS_PRO,
},
{
value: GEMINI_MODEL_ALIAS_FLASH,
title: `Flash (${DEFAULT_GEMINI_FLASH_MODEL})`,
description: 'For tasks that need a balance of speed and reasoning',
key: GEMINI_MODEL_ALIAS_FLASH,
},
{
value: GEMINI_MODEL_ALIAS_FLASH_LITE,
title: `Flash-Lite (${DEFAULT_GEMINI_FLASH_LITE_MODEL})`,
description: 'For simple tasks that need to be done quickly',
key: GEMINI_MODEL_ALIAS_FLASH_LITE,
},
],
[config],
);
// Calculate the initial index based on the preferred model.
const initialIndex = useMemo(
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
[preferredModel],
() => options.findIndex((option) => option.value === preferredModel),
[preferredModel, options],
);
// Handle selection internally (Autonomous Dialog).
@@ -85,6 +96,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
[config, onClose],
);
const header = config?.getPreviewFeatures()
? 'Gemini 3 is now enabled.'
: 'Gemini 3 is now available.';
const subheader = config?.getPreviewFeatures()
? `To disable Gemini 3, disable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features\n\nWhen you select Auto or Pro, Gemini CLI will attempt to use ${PREVIEW_GEMINI_MODEL} first, before falling back to ${DEFAULT_GEMINI_MODEL}.`
: `To use Gemini 3, enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features`;
return (
<Box
borderStyle="round"
@@ -94,17 +113,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
width="100%"
>
<Text bold>Select Model</Text>
<Box marginTop={1} marginBottom={1} flexDirection="column">
<Gradient colors={theme.ui.gradient}>
<Text>{header}</Text>
</Gradient>
<Text>{subheader}</Text>
</Box>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
items={options}
onSelect={handleSelect}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
<Box flexDirection="column">
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{'> To use a specific Gemini model on startup, use the --model flag.'}
{'To use a specific Gemini model on startup, use the --model flag.'}
</Text>
</Box>
<Box marginTop={1} flexDirection="column">

View File

@@ -10,86 +10,297 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
PREVIEW_GEMINI_MODEL,
UserTierId,
DEFAULT_GEMINI_FLASH_MODEL,
} from '@google/gemini-cli-core';
// Mock the child component to make it easier to test the parent
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(),
}));
describe('ProQuotaDialog', () => {
const mockOnChoice = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should render with correct title and options', () => {
const { lastFrame, unmount } = render(
<ProQuotaDialog fallbackModel="gemini-2.5-flash" onChoice={() => {}} />,
);
describe('for flash model failures', () => {
it('should render "Keep trying" and "Stop" options', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel={DEFAULT_GEMINI_FLASH_MODEL}
fallbackModel="gemini-2.5-pro"
message="flash error"
isTerminalQuotaError={true} // should not matter
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
const output = lastFrame();
expect(output).toContain(
'Note: You can always use /model to select a different option.',
);
// Check that RadioButtonSelect was called with the correct items
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Try again later',
value: 'retry_later' as const,
key: 'retry_later',
},
{
label: `Switch to gemini-2.5-flash for the rest of this session`,
value: 'retry' as const,
key: 'retry',
},
],
}),
undefined,
);
unmount();
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Keep trying',
value: 'retry_once',
key: 'retry_once',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
});
it('should call onChoice with "auth" when "Change auth" is selected', () => {
const mockOnChoice = vi.fn();
const { unmount } = render(
<ProQuotaDialog
fallbackModel="gemini-2.5-flash"
onChoice={mockOnChoice}
/>,
);
describe('for non-flash model failures', () => {
describe('when it is a terminal quota error', () => {
it('should render switch and stop options for paid tiers', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="paid tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
onChoice={mockOnChoice}
userTier={UserTierId.LEGACY}
/>,
);
// Get the onSelect function passed to RadioButtonSelect
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-flash',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
// Simulate the selection
act(() => {
onSelect('auth');
it('should render switch, upgrade, and stop options for free tier', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="free tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-flash',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Upgrade for higher limits',
value: 'upgrade',
key: 'upgrade',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
});
expect(mockOnChoice).toHaveBeenCalledWith('auth');
unmount();
describe('when it is a capacity error', () => {
it('should render keep trying, switch, and stop options', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="capacity error"
isTerminalQuotaError={false}
isModelNotFoundError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Keep trying',
value: 'retry_once',
key: 'retry_once',
},
{
label: 'Switch to gemini-2.5-flash',
value: 'retry_always',
key: 'retry_always',
},
{ label: 'Stop', value: 'retry_later', key: 'retry_later' },
],
}),
undefined,
);
unmount();
});
});
describe('when it is a model not found error', () => {
it('should render switch and stop options regardless of tier', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
fallbackModel="gemini-2.5-pro"
message="You don't have access to gemini-3-pro-preview yet."
isTerminalQuotaError={false}
isModelNotFoundError={true}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-pro',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
it('should render switch and stop options for paid tier as well', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
fallbackModel="gemini-2.5-pro"
message="You don't have access to gemini-3-pro-preview yet."
isTerminalQuotaError={false}
isModelNotFoundError={true}
onChoice={mockOnChoice}
userTier={UserTierId.LEGACY}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-pro',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
});
});
it('should call onChoice with "continue" when "Continue with flash" is selected', () => {
const mockOnChoice = vi.fn();
const { unmount } = render(
<ProQuotaDialog
fallbackModel="gemini-2.5-flash"
onChoice={mockOnChoice}
/>,
);
describe('onChoice handling', () => {
it('should call onChoice with the selected value', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message=""
isTerminalQuotaError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
// Get the onSelect function passed to RadioButtonSelect
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
onSelect('retry_always');
});
// Simulate the selection
act(() => {
onSelect('retry');
expect(mockOnChoice).toHaveBeenCalledWith('retry_always');
unmount();
});
});
describe('footer note', () => {
it('should show a special note for PREVIEW_GEMINI_MODEL', () => {
const { lastFrame, unmount } = render(
<ProQuotaDialog
failedModel={PREVIEW_GEMINI_MODEL}
fallbackModel="gemini-2.5-pro"
message=""
isTerminalQuotaError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
const output = lastFrame();
expect(output).toContain(
'Note: We will periodically retry Preview Model to see if congestion has cleared.',
);
unmount();
});
expect(mockOnChoice).toHaveBeenCalledWith('retry');
unmount();
it('should show the default note for other models', () => {
const { lastFrame, unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message=""
isTerminalQuotaError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
const output = lastFrame();
expect(output).toContain(
'Note: You can always use /model to select a different option.',
);
unmount();
});
});
});

View File

@@ -9,43 +9,127 @@ import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
UserTierId,
} from '@google/gemini-cli-core';
interface ProQuotaDialogProps {
failedModel: string;
fallbackModel: string;
onChoice: (choice: 'retry_later' | 'retry') => void;
message: string;
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
onChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
userTier: UserTierId | undefined;
}
export function ProQuotaDialog({
failedModel,
fallbackModel,
message,
isTerminalQuotaError,
isModelNotFoundError,
onChoice,
userTier,
}: ProQuotaDialogProps): React.JSX.Element {
const items = [
{
label: 'Try again later',
value: 'retry_later' as const,
key: 'retry_later',
},
{
label: `Switch to ${fallbackModel} for the rest of this session`,
value: 'retry' as const,
key: 'retry',
},
];
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
const isPaidTier =
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
let items;
// flash and flash lite don't have options to switch or upgrade.
if (
failedModel === DEFAULT_GEMINI_FLASH_MODEL ||
failedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL
) {
items = [
{
label: 'Keep trying',
value: 'retry_once' as const,
key: 'retry_once',
},
{
label: 'Stop',
value: 'retry_later' as const,
key: 'retry_later',
},
];
} else if (isModelNotFoundError || (isTerminalQuotaError && isPaidTier)) {
// out of quota
items = [
{
label: `Switch to ${fallbackModel}`,
value: 'retry_always' as const,
key: 'retry_always',
},
{
label: `Stop`,
value: 'retry_later' as const,
key: 'retry_later',
},
];
} else if (isTerminalQuotaError && !isPaidTier) {
// free user gets an option to upgrade
items = [
{
label: `Switch to ${fallbackModel}`,
value: 'retry_always' as const,
key: 'retry_always',
},
{
label: 'Upgrade for higher limits',
value: 'upgrade' as const,
key: 'upgrade',
},
{
label: `Stop`,
value: 'retry_later' as const,
key: 'retry_later',
},
];
} else {
// capacity error
items = [
{
label: 'Keep trying',
value: 'retry_once' as const,
key: 'retry_once',
},
{
label: `Switch to ${fallbackModel}`,
value: 'retry_always' as const,
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later' as const,
key: 'retry_later',
},
];
}
const handleSelect = (choice: 'retry_later' | 'retry') => {
const handleSelect = (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => {
onChoice(choice);
};
return (
<Box borderStyle="round" flexDirection="column" paddingX={1}>
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text>{message}</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect
items={items}
initialIndex={1}
onSelect={handleSelect}
/>
<RadioButtonSelect items={items} onSelect={handleSelect} />
</Box>
<Text color={theme.text.primary}>
Note: You can always use /model to select a different option.
{failedModel === PREVIEW_GEMINI_MODEL && !isModelNotFoundError
? 'Note: We will periodically retry Preview Model to see if congestion has cleared.'
: 'Note: You can always use /model to select a different option.'}
</Text>
</Box>
);

View File

@@ -367,17 +367,17 @@ describe('SettingsDialog', () => {
const { stdin, unmount, lastFrame } = renderDialog(settings, onSelect);
// Wait for initial render and verify we're on Vim Mode (first setting)
// Wait for initial render and verify we're on Preview Features (first setting)
await waitFor(() => {
expect(lastFrame()).toContain('Vim Mode');
expect(lastFrame()).toContain('Preview Features (e.g., models)');
});
// Navigate to Disable Auto Update setting and verify we're there
// Navigate to Vim Mode setting and verify we're there
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string);
});
await waitFor(() => {
expect(lastFrame()).toContain('Disable Auto Update');
expect(lastFrame()).toContain('Vim Mode');
});
// Toggle the setting
@@ -397,10 +397,10 @@ describe('SettingsDialog', () => {
});
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['general.disableAutoUpdate']),
new Set<string>(['general.vimMode']),
expect.objectContaining({
general: expect.objectContaining({
disableAutoUpdate: true,
vimMode: true,
}),
}),
expect.any(LoadedSettings),
@@ -571,7 +571,7 @@ describe('SettingsDialog', () => {
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
expect(lastFrame()).toContain('Vim Mode');
});
// Verify the dialog is rendered properly

View File

@@ -0,0 +1,118 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should not render the banner when previewFeatures is enabled 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should render the banner when previewFeatures is disabled 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should render the banner with default text 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;

View File

@@ -6,7 +6,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false │
│ ● Preview Features (e.g., models) false │
│ │
│ Vim Mode false │
│ │
│ Disable Auto Update false │
│ │
@@ -14,8 +16,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ │
│ Debug Keystroke Logging false │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │
@@ -41,7 +41,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode true*
│ ● Preview Features (e.g., models) false
│ │
│ Vim Mode true* │
│ │
│ Disable Auto Update false │
│ │
@@ -49,8 +51,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ │
│ Debug Keystroke Logging false │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │
@@ -76,7 +76,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false*
│ ● Preview Features (e.g., models) false
│ │
│ Vim Mode false* │
│ │
│ Disable Auto Update false* │
│ │
@@ -84,8 +86,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ │
│ Debug Keystroke Logging false* │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │
@@ -111,7 +111,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false │
│ ● Preview Features (e.g., models) false │
│ │
│ Vim Mode false │
│ │
│ Disable Auto Update false │
│ │
@@ -119,8 +121,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ │
│ Debug Keystroke Logging false │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │
@@ -146,7 +146,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false │
│ ● Preview Features (e.g., models) false │
│ │
│ Vim Mode false │
│ │
│ Disable Auto Update false │
│ │
@@ -154,8 +156,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ │
│ Debug Keystroke Logging false │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │
@@ -181,6 +181,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Settings │
│ │
│ ▲ │
│ Preview Features (e.g., models) false │
│ │
│ Vim Mode false │
│ │
│ Disable Auto Update false │
@@ -189,8 +191,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ │
│ Debug Keystroke Logging false │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │
@@ -216,7 +216,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false*
│ ● Preview Features (e.g., models) false
│ │
│ Vim Mode false* │
│ │
│ Disable Auto Update true* │
│ │
@@ -224,8 +226,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ │
│ Debug Keystroke Logging false │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │
@@ -251,7 +251,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false │
│ ● Preview Features (e.g., models) false │
│ │
│ Vim Mode false │
│ │
│ Disable Auto Update false │
│ │
@@ -259,8 +261,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ │
│ Debug Keystroke Logging false │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │
@@ -286,7 +286,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode true*
│ ● Preview Features (e.g., models) false
│ │
│ Vim Mode true* │
│ │
│ Disable Auto Update true* │
│ │
@@ -294,8 +296,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ │
│ Debug Keystroke Logging true* │
│ │
│ Session Retention undefined │
│ │
│ Enable Session Cleanup false │
│ │
│ Output Format Text │

View File

@@ -42,11 +42,14 @@ export interface UIActions {
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleClearScreen: () => void;
handleProQuotaChoice: (choice: 'retry_later' | 'retry') => void;
handleProQuotaChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
setQueueErrorMessage: (message: string | null) => void;
popAllMessages: (onPop: (messages: string | undefined) => void) => void;
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -32,6 +32,9 @@ import type { UpdateObject } from '../utils/updateCheck.js';
export interface ProQuotaDialogRequest {
failedModel: string;
fallbackModel: string;
message: string;
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
resolve: (intent: FallbackIntent) => void;
}
@@ -126,6 +129,11 @@ export interface UIState {
showFullTodos: boolean;
copyModeEnabled: boolean;
warningMessage: string | null;
bannerData: {
defaultText: string;
warningText: string;
};
bannerVisible: boolean;
customDialog: React.ReactNode | null;
}

View File

@@ -8,6 +8,7 @@ import {
allowEditorTypeInSandbox,
checkHasEditorType,
type EditorType,
EDITOR_DISPLAY_NAMES,
} from '@google/gemini-cli-core';
export interface EditorDisplay {
@@ -16,17 +17,6 @@ export interface EditorDisplay {
disabled: boolean;
}
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
cursor: 'Cursor',
emacs: 'Emacs',
neovim: 'Neovim',
vim: 'Vim',
vscode: 'VS Code',
vscodium: 'VSCodium',
windsurf: 'Windsurf',
zed: 'Zed',
};
class EditorSettingsManager {
private readonly availableEditors: EditorDisplay[];

View File

@@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => {
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
vi.fn(), // setBannerVisible
vi.fn(), // setCustomDialog
),
);

View File

@@ -77,6 +77,7 @@ export const useSlashCommandProcessor = (
actions: SlashCommandProcessorActions,
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
isConfigInitialized: boolean,
setBannerVisible: (visible: boolean) => void,
setCustomDialog: (dialog: React.ReactNode | null) => void,
) => {
const session = useSessionStats();
@@ -203,6 +204,7 @@ export const useSlashCommandProcessor = (
console.clear();
}
refreshStatic();
setBannerVisible(false);
},
loadHistory,
setDebugMessage: actions.setDebugMessage,
@@ -241,6 +243,7 @@ export const useSlashCommandProcessor = (
sessionShellAllowlist,
reloadCommands,
extensionsUpdateState,
setBannerVisible,
setCustomDialog,
],
);

View File

@@ -123,7 +123,7 @@ describe('useEditorSettings', () => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Editor preference set to "vscode" in User settings.',
text: 'Editor preference set to "VS Code" in User settings.',
},
expect.any(Number),
);
@@ -164,6 +164,11 @@ describe('useEditorSettings', () => {
render(<TestComponent />);
const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim'];
const displayNames: Record<string, string> = {
cursor: 'Cursor',
windsurf: 'Windsurf',
vim: 'Vim',
};
const scope = SettingScope.User;
editorTypes.forEach((editorType) => {
@@ -180,7 +185,7 @@ describe('useEditorSettings', () => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Editor preference set to "${editorType}" in User settings.`,
text: `Editor preference set to "${displayNames[editorType]}" in User settings.`,
},
expect.any(Number),
);
@@ -210,7 +215,7 @@ describe('useEditorSettings', () => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Editor preference set to "vscode" in ${scope} settings.`,
text: `Editor preference set to "VS Code" in ${scope} settings.`,
},
expect.any(Number),
);

View File

@@ -14,6 +14,7 @@ import type { EditorType } from '@google/gemini-cli-core';
import {
allowEditorTypeInSandbox,
checkHasEditorType,
getEditorDisplayName,
} from '@google/gemini-cli-core';
import { SettingPaths } from '../../config/settingPaths.js';
@@ -58,7 +59,7 @@ export const useEditorSettings = (
addItem(
{
type: MessageType.INFO,
text: `Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`,
text: `Editor preference ${editorType ? `set to "${getEditorDisplayName(editorType)}"` : 'cleared'} in ${scope} settings.`,
},
Date.now(),
);

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.`,
);
});
});
});

View File

@@ -10,8 +10,9 @@ import {
type FallbackModelHandler,
type FallbackIntent,
TerminalQuotaError,
UserTierId,
DEFAULT_GEMINI_FLASH_MODEL,
ModelNotFoundError,
type UserTierId,
PREVIEW_GEMINI_MODEL,
} from '@google/gemini-cli-core';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -51,56 +52,29 @@ export function useQuotaAndFallback({
return null;
}
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
const isPaidTier =
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
const isFallbackModel = failedModel === DEFAULT_GEMINI_FLASH_MODEL;
let message: string;
let isTerminalQuotaError = false;
let isModelNotFoundError = false;
if (error instanceof TerminalQuotaError) {
isTerminalQuotaError = true;
// Common part of the message for both tiers
const messageLines = [
`⚡ You have reached your daily ${failedModel} quota limit.`,
`⚡ You can choose to authenticate with a paid API key${
isFallbackModel ? '.' : ' or continue with the fallback model.'
}`,
`Usage limit reached for ${failedModel}.`,
error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null,
`/stats for usage details`,
`/auth to switch to API key.`,
].filter(Boolean);
message = messageLines.join('\n');
} else if (error instanceof ModelNotFoundError) {
isModelNotFoundError = true;
const messageLines = [
`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.`,
];
// Tier-specific part
if (isPaidTier) {
messageLines.push(
`⚡ Increase your limits by using a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key`,
`⚡ You can switch authentication methods by typing /auth`,
);
} else {
messageLines.push(
`⚡ Increase your limits by `,
`⚡ - signing up for a plan with higher limits at https://goo.gle/set-up-gemini-code-assist`,
`⚡ - or using a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key`,
`⚡ You can switch authentication methods by typing /auth`,
);
}
message = messageLines.join('\n');
} else {
// Capacity error
message = [
`🚦Pardon Our Congestion! It looks like ${failedModel} is very popular at the moment.`,
`Please retry again later.`,
].join('\n');
}
// Add message to UI history
historyManager.addItem(
{
type: MessageType.INFO,
text: message,
},
Date.now(),
);
if (isFallbackModel) {
return 'stop';
message = `${failedModel} is currently experiencing high demand. We apologize and appreciate your patience.`;
}
setModelSwitchedFromQuotaError(true);
@@ -117,6 +91,9 @@ export function useQuotaAndFallback({
failedModel,
fallbackModel,
resolve,
message,
isTerminalQuotaError,
isModelNotFoundError,
});
},
);
@@ -136,14 +113,25 @@ export function useQuotaAndFallback({
setProQuotaRequest(null);
isDialogPending.current = false; // Reset the flag here
if (choice === 'retry') {
historyManager.addItem(
{
type: MessageType.INFO,
text: 'Switched to fallback model. Tip: Press Ctrl+P (or Up Arrow) to recall your previous prompt and submit it again if you wish.',
},
Date.now(),
);
if (choice === 'retry_always') {
// If we were recovering from a Preview Model failure, show a specific message.
if (proQuotaRequest.failedModel === PREVIEW_GEMINI_MODEL) {
historyManager.addItem(
{
type: MessageType.INFO,
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}. ${!proQuotaRequest.isModelNotFoundError ? `We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.` : ''}`,
},
Date.now(),
);
} else {
historyManager.addItem(
{
type: MessageType.INFO,
text: 'Switched to fallback model.',
},
Date.now(),
);
}
}
},
[proQuotaRequest, historyManager],
@@ -154,3 +142,15 @@ export function useQuotaAndFallback({
handleProQuotaChoice,
};
}
function getResetTimeMessage(delayMs: number): string {
const resetDate = new Date(Date.now() + delayMs);
const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
});
return `Access resets at ${timeFormatter.format(resetDate)}.`;
}

View File

@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Storage, debugLogger } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
const STATE_FILENAME = 'state.json';
interface PersistentStateData {
defaultBannerShownCount?: number;
// Add other persistent state keys here as needed
}
export class PersistentState {
private cache: PersistentStateData | null = null;
private filePath: string | null = null;
private getPath(): string {
if (!this.filePath) {
this.filePath = path.join(Storage.getGlobalGeminiDir(), STATE_FILENAME);
}
return this.filePath;
}
private load(): PersistentStateData {
if (this.cache) {
return this.cache;
}
try {
const filePath = this.getPath();
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
this.cache = JSON.parse(content);
} else {
this.cache = {};
}
} catch (error) {
debugLogger.warn('Failed to load persistent state:', error);
// If error reading (e.g. corrupt JSON), start fresh
this.cache = {};
}
return this.cache!;
}
private save() {
if (!this.cache) return;
try {
const filePath = this.getPath();
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(this.cache, null, 2));
} catch (error) {
debugLogger.warn('Failed to save persistent state:', error);
}
}
get<K extends keyof PersistentStateData>(
key: K,
): PersistentStateData[K] | undefined {
return this.load()[key];
}
set<K extends keyof PersistentStateData>(
key: K,
value: PersistentStateData[K],
): void {
this.load(); // ensure loaded
this.cache![key] = value;
this.save();
}
}
export const persistentState = new PersistentState();

View File

@@ -12,6 +12,9 @@ export {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
} from './src/config/models.js';
export {
serializeTerminalToObject,
@@ -49,3 +52,4 @@ export * from './src/utils/googleQuotaErrors.js';
export type { GoogleApiError } from './src/utils/googleErrors.js';
export { getCodeAssistServer } from './src/code_assist/codeAssist.js';
export { getExperiments } from './src/code_assist/experiments/experiments.js';
export { getErrorStatus, ModelNotFoundError } from './src/utils/httpErrors.js';

View File

@@ -7,6 +7,9 @@
export const ExperimentFlags = {
CONTEXT_COMPRESSION_THRESHOLD: 45740197,
USER_CACHING: 45740198,
BANNER_TEXT_NO_CAPACITY_ISSUES: 45740199,
BANNER_TEXT_CAPACITY_ISSUES: 45740200,
ENABLE_PREVIEW: 45740196,
} as const;
export type ExperimentFlagName =

View File

@@ -160,11 +160,16 @@ vi.mock('../utils/fetch.js', () => ({
import { BaseLlmClient } from '../core/baseLlmClient.js';
import { tokenLimit } from '../core/tokenLimits.js';
import { uiTelemetryService } from '../telemetry/index.js';
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
import { getExperiments } from '../code_assist/experiments/experiments.js';
import type { CodeAssistServer } from '../code_assist/server.js';
vi.mock('../core/baseLlmClient.js');
vi.mock('../core/tokenLimits.js', () => ({
tokenLimit: vi.fn(),
}));
vi.mock('../code_assist/codeAssist.js');
vi.mock('../code_assist/experiments/experiments.js');
describe('Server Config (config.ts)', () => {
const MODEL = 'gemini-pro';
@@ -362,6 +367,23 @@ describe('Server Config (config.ts)', () => {
).toHaveBeenCalledWith();
});
it('should strip thoughts when switching from GenAI to Vertex AI', async () => {
const config = new Config(baseParams);
vi.mocked(createContentGeneratorConfig).mockImplementation(
async (_: Config, authType: AuthType | undefined) =>
({ authType }) as unknown as ContentGeneratorConfig,
);
await config.refreshAuth(AuthType.USE_GEMINI);
await config.refreshAuth(AuthType.USE_VERTEX_AI);
expect(
config.getGeminiClient().stripThoughtsFromHistory,
).toHaveBeenCalledWith();
});
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
const config = new Config(baseParams);
@@ -380,6 +402,78 @@ describe('Server Config (config.ts)', () => {
});
});
describe('Preview Features Logic in refreshAuth', () => {
beforeEach(() => {
// Set up default mock behavior for these functions before each test
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
vi.mocked(getExperiments).mockResolvedValue({
flags: {},
experimentIds: [],
});
});
it('should enable preview features for Google auth when remote flag is true', async () => {
// Override the default mock for this specific test
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer); // Simulate Google auth by returning a truthy value
vi.mocked(getExperiments).mockResolvedValue({
flags: {
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: true },
},
experimentIds: [],
});
const config = new Config({ ...baseParams, previewFeatures: undefined });
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getPreviewFeatures()).toBe(true);
});
it('should disable preview features for Google auth when remote flag is false', async () => {
// Override the default mock
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer);
vi.mocked(getExperiments).mockResolvedValue({
flags: {
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: false },
},
experimentIds: [],
});
const config = new Config({ ...baseParams, previewFeatures: undefined });
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getPreviewFeatures()).toBe(undefined);
});
it('should disable preview features for Google auth when remote flag is missing', async () => {
// Override the default mock for getCodeAssistServer, the getExperiments mock is already correct
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer);
const config = new Config({ ...baseParams, previewFeatures: undefined });
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getPreviewFeatures()).toBe(undefined);
});
it('should not change preview features or model if it is already set to true', async () => {
const initialModel = 'some-other-model';
const config = new Config({
...baseParams,
previewFeatures: true,
model: initialModel,
});
// It doesn't matter which auth method we use here, the logic should exit early
await config.refreshAuth(AuthType.USE_GEMINI);
expect(config.getPreviewFeatures()).toBe(true);
expect(config.getModel()).toBe(initialModel);
});
it('should not change preview features or model if it is already set to false', async () => {
const initialModel = 'some-other-model';
const config = new Config({
...baseParams,
previewFeatures: false,
model: initialModel,
});
await config.refreshAuth(AuthType.USE_GEMINI);
expect(config.getPreviewFeatures()).toBe(false);
expect(config.getModel()).toBe(initialModel);
});
});
it('Config constructor should store userMemory correctly', () => {
const config = new Config(baseParams);

View File

@@ -305,6 +305,7 @@ export interface ConfigParameters {
hooks?: {
[K in HookEventName]?: HookDefinition[];
};
previewFeatures?: boolean;
}
export class Config {
@@ -357,6 +358,7 @@ export class Config {
private readonly cwd: string;
private readonly bugCommand: BugCommandSettings | undefined;
private model: string;
private previewFeatures: boolean | undefined;
private readonly noBrowser: boolean;
private readonly folderTrust: boolean;
private ideMode: boolean;
@@ -419,6 +421,9 @@ export class Config {
private experiments: Experiments | undefined;
private experimentsPromise: Promise<void> | undefined;
private previewModelFallbackMode = false;
private previewModelBypassMode = false;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this.embeddingModel =
@@ -475,6 +480,7 @@ export class Config {
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = params.model;
this.previewFeatures = params.previewFeatures ?? undefined;
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
@@ -649,7 +655,7 @@ export class Config {
// thoughtSignature from Genai to Vertex will fail, we need to strip them
if (
this.contentGeneratorConfig?.authType === AuthType.USE_GEMINI &&
authMethod === AuthType.LOGIN_WITH_GOOGLE
authMethod !== AuthType.USE_GEMINI
) {
// Restore the conversation history to the new client
this.geminiClient.stripThoughtsFromHistory();
@@ -670,11 +676,22 @@ export class Config {
// Initialize BaseLlmClient now that the ContentGenerator is available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
const previewFeatures = this.getPreviewFeatures();
const codeAssistServer = getCodeAssistServer(this);
if (codeAssistServer) {
this.experimentsPromise = getExperiments(codeAssistServer)
.then((experiments) => {
this.setExperiments(experiments);
// If preview features have not been set and the user authenticated through Google, we enable preview based on remote config only if it's true
if (previewFeatures === undefined) {
const remotePreviewFeatures =
experiments.flags[ExperimentFlags.ENABLE_PREVIEW]?.boolValue;
if (remotePreviewFeatures === true) {
this.setPreviewFeatures(remotePreviewFeatures);
}
}
})
.catch((e) => {
debugLogger.error('Failed to fetch experiments', e);
@@ -760,6 +777,26 @@ export class Config {
this.fallbackModelHandler = handler;
}
getFallbackModelHandler(): FallbackModelHandler | undefined {
return this.fallbackModelHandler;
}
isPreviewModelFallbackMode(): boolean {
return this.previewModelFallbackMode;
}
setPreviewModelFallbackMode(active: boolean): void {
this.previewModelFallbackMode = active;
}
isPreviewModelBypassMode(): boolean {
return this.previewModelBypassMode;
}
setPreviewModelBypassMode(active: boolean): void {
this.previewModelBypassMode = active;
}
getMaxSessionTurns(): number {
return this.maxSessionTurns;
}
@@ -822,6 +859,14 @@ export class Config {
return this.question;
}
getPreviewFeatures(): boolean | undefined {
return this.previewFeatures;
}
setPreviewFeatures(previewFeatures: boolean) {
this.previewFeatures = previewFeatures;
}
getCoreTools(): string[] | undefined {
return this.coreTools;
}
@@ -1169,6 +1214,22 @@ export class Config {
return this.experiments?.flags[ExperimentFlags.USER_CACHING]?.boolValue;
}
async getBannerTextNoCapacityIssues(): Promise<string> {
await this.ensureExperimentsLoaded();
return (
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES]
?.stringValue ?? ''
);
}
async getBannerTextCapacityIssues(): Promise<string> {
await this.ensureExperimentsLoaded();
return (
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES]
?.stringValue ?? ''
);
}
private async ensureExperimentsLoaded(): Promise<void> {
if (!this.experimentsPromise) {
return;

View File

@@ -8,8 +8,12 @@ import { describe, it, expect } from 'vitest';
import {
getEffectiveModel,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
} from './models.js';
describe('getEffectiveModel', () => {
@@ -17,7 +21,11 @@ describe('getEffectiveModel', () => {
const isInFallbackMode = false;
it('should return the Pro model when Pro is requested', () => {
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
@@ -25,6 +33,7 @@ describe('getEffectiveModel', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
@@ -33,22 +42,92 @@ describe('getEffectiveModel', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return a custom model name when requested', () => {
const customModel = 'custom-model-v1';
const model = getEffectiveModel(isInFallbackMode, customModel);
const model = getEffectiveModel(isInFallbackMode, customModel, false);
expect(model).toBe(customModel);
});
describe('with preview features', () => {
it('should return the preview model when pro alias is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_PRO,
true,
);
expect(model).toBe(PREVIEW_GEMINI_MODEL);
});
it('should return the default pro model when pro alias is requested and preview is off', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_PRO,
false,
);
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
it('should return the flash model when flash is requested and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_FLASH,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the flash model when lite is requested and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_FLASH_LITE,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return the flash model when the flash model name is explicitly requested and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the lite model when the lite model name is requested and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return the default gemini model when the model is explicitly set and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
});
});
describe('When IN fallback mode', () => {
const isInFallbackMode = true;
it('should downgrade the Pro model to the Flash model', () => {
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
@@ -56,6 +135,7 @@ describe('getEffectiveModel', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
@@ -64,20 +144,83 @@ describe('getEffectiveModel', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should HONOR any model with "lite" in its name', () => {
const customLiteModel = 'gemini-2.5-custom-lite-vNext';
const model = getEffectiveModel(isInFallbackMode, customLiteModel);
const model = getEffectiveModel(isInFallbackMode, customLiteModel, false);
expect(model).toBe(customLiteModel);
});
it('should downgrade any other custom model to the Flash model', () => {
const customModel = 'custom-model-v1-unlisted';
const model = getEffectiveModel(isInFallbackMode, customModel);
const model = getEffectiveModel(isInFallbackMode, customModel, false);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
describe('with preview features', () => {
it('should downgrade the Pro alias to the Flash model', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_PRO,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the Flash alias when requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_FLASH,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the Lite alias when requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_FLASH_LITE,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should downgrade the default Gemini model to the Flash model', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the default Flash model when requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the default Lite model when requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should downgrade any other custom model to the Flash model', () => {
const customModel = 'custom-model-v1-unlisted';
const model = getEffectiveModel(isInFallbackMode, customModel, true);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
});
});
});

View File

@@ -4,17 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
// Model aliases for user convenience.
export const GEMINI_MODEL_ALIAS_PRO = 'pro';
export const GEMINI_MODEL_ALIAS_FLASH = 'flash';
export const GEMINI_MODEL_ALIAS_FLASH_LITE = 'flash-lite';
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Cap the thinking at 8192 to prevent run-away thinking loops.
export const DEFAULT_THINKING_MODE = 8192;
/**
* Resolves the requested model alias (e.g., 'auto', 'pro', 'flash', 'flash-lite')
* to a concrete model name, considering preview features.
*
* @param requestedModel The model alias or concrete model name requested by the user.
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled.
* @returns The resolved concrete model name.
*/
export function resolveModel(
requestedModel: string,
previewFeaturesEnabled: boolean | undefined,
): string {
switch (requestedModel) {
case DEFAULT_GEMINI_MODEL_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
return previewFeaturesEnabled
? PREVIEW_GEMINI_MODEL
: DEFAULT_GEMINI_MODEL;
}
case GEMINI_MODEL_ALIAS_FLASH: {
return DEFAULT_GEMINI_FLASH_MODEL;
}
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
}
default: {
return requestedModel;
}
}
}
/**
* Determines the effective model to use, applying fallback logic if necessary.
*
@@ -26,23 +63,37 @@ export const DEFAULT_THINKING_MODE = 8192;
*
* @param isInFallbackMode Whether the application is in fallback mode.
* @param requestedModel The model that was originally requested.
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled.
* @returns The effective model name.
*/
export function getEffectiveModel(
isInFallbackMode: boolean,
requestedModel: string,
previewFeaturesEnabled: boolean | undefined,
): string {
// If we are not in fallback mode, simply use the requested model.
const resolvedModel = resolveModel(requestedModel, previewFeaturesEnabled);
// If we are not in fallback mode, simply use the resolved model.
if (!isInFallbackMode) {
return requestedModel;
return resolvedModel;
}
// If a "lite" model is requested, honor it. This allows for variations of
// lite models without needing to list them all as constants.
if (requestedModel.includes('lite')) {
return requestedModel;
if (resolvedModel.includes('lite')) {
return resolvedModel;
}
// Default fallback for Gemini CLI.
return DEFAULT_GEMINI_FLASH_MODEL;
}
/**
* Checks if the model is a Gemini 2.x model.
*
* @param model The model name to check.
* @returns True if the model is a Gemini 2.x model.
*/
export function isGemini2Model(model: string): boolean {
return /^gemini-2(\.|$)/.test(model);
}

View File

@@ -15,11 +15,7 @@ import {
} from 'vitest';
import type { Content, GenerateContentResponse, Part } from '@google/genai';
import {
isThinkingDefault,
isThinkingSupported,
GeminiClient,
} from './client.js';
import { isThinkingSupported, GeminiClient } from './client.js';
import {
AuthType,
type ContentGenerator,
@@ -147,31 +143,16 @@ describe('isThinkingSupported', () => {
expect(isThinkingSupported('gemini-2.5-pro')).toBe(true);
});
it('should return true for gemini-3-pro', () => {
expect(isThinkingSupported('gemini-3-pro')).toBe(true);
});
it('should return false for other models', () => {
expect(isThinkingSupported('gemini-1.5-flash')).toBe(false);
expect(isThinkingSupported('some-other-model')).toBe(false);
});
});
describe('isThinkingDefault', () => {
it('should return false for gemini-2.5-flash-lite', () => {
expect(isThinkingDefault('gemini-2.5-flash-lite')).toBe(false);
});
it('should return true for gemini-2.5', () => {
expect(isThinkingDefault('gemini-2.5')).toBe(true);
});
it('should return true for gemini-2.5-pro', () => {
expect(isThinkingDefault('gemini-2.5-pro')).toBe(true);
});
it('should return false for other models', () => {
expect(isThinkingDefault('gemini-1.5-flash')).toBe(false);
expect(isThinkingDefault('some-other-model')).toBe(false);
});
});
describe('Gemini Client (client.ts)', () => {
let mockContentGenerator: ContentGenerator;
let mockConfig: Config;
@@ -241,6 +222,7 @@ describe('Gemini Client (client.ts)', () => {
getIdeModeFeature: vi.fn().mockReturnValue(false),
getIdeMode: vi.fn().mockReturnValue(true),
getDebugMode: vi.fn().mockReturnValue(false),
getPreviewFeatures: vi.fn().mockReturnValue(false),
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: vi.fn().mockReturnValue(['/test/dir']),
}),

View File

@@ -33,7 +33,6 @@ import type {
import type { ContentGenerator } from './contentGenerator.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_THINKING_MODE,
getEffectiveModel,
@@ -57,14 +56,11 @@ import { debugLogger } from '../utils/debugLogger.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
export function isThinkingSupported(model: string) {
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
}
export function isThinkingDefault(model: string) {
if (model.startsWith('gemini-2.5-flash-lite')) {
return false;
}
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
return (
model.startsWith('gemini-2.5') ||
model.startsWith('gemini-3') ||
model === DEFAULT_GEMINI_MODEL_AUTO
);
}
const MAX_TURNS = 100;
@@ -409,11 +405,11 @@ export class GeminiClient {
}
const configModel = this.config.getModel();
const model: string =
configModel === DEFAULT_GEMINI_MODEL_AUTO
? DEFAULT_GEMINI_MODEL
: configModel;
return getEffectiveModel(this.config.isInFallbackMode(), model);
return getEffectiveModel(
this.config.isInFallbackMode(),
configModel,
this.config.getPreviewFeatures(),
);
}
async *sendMessageStream(

View File

@@ -16,13 +16,19 @@ import {
GeminiChat,
InvalidStreamError,
StreamEventType,
SYNTHETIC_THOUGHT_SIGNATURE,
type StreamEvent,
} from './geminiChat.js';
import type { Config } from '../config/config.js';
import { setSimulate429 } from '../utils/testUtils.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
} from '../config/models.js';
import { AuthType } from './contentGenerator.js';
import { type RetryOptions } from '../utils/retry.js';
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
import { retryWithBackoff, type RetryOptions } from '../utils/retry.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
// Mock fs module to prevent actual file system operations during tests
@@ -109,6 +115,7 @@ describe('GeminiChat', () => {
getTelemetryLogPromptsEnabled: () => true,
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getPreviewFeatures: () => false,
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'oauth-personal', // Ensure this is set for fallback tests
model: 'test-model',
@@ -128,6 +135,10 @@ describe('GeminiChat', () => {
}),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
isPreviewModelBypassMode: vi.fn().mockReturnValue(false),
setPreviewModelBypassMode: vi.fn(),
isPreviewModelFallbackMode: vi.fn().mockReturnValue(false),
setPreviewModelFallbackMode: vi.fn(),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config;
@@ -247,7 +258,7 @@ describe('GeminiChat', () => {
// 2. Action & Assert: The stream should fail because there's no finish reason.
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test message' },
'prompt-id-no-finish-empty-end',
);
@@ -471,6 +482,126 @@ describe('GeminiChat', () => {
'This is the visible text that should not be lost.',
);
});
it('should use maxAttempts=1 for retryWithBackoff when in Preview Model Fallback Mode', async () => {
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
PREVIEW_GEMINI_MODEL,
{ message: 'test' },
'prompt-id-fast-retry',
);
for await (const _ of stream) {
// consume stream
}
expect(mockRetryWithBackoff).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
maxAttempts: 1,
}),
);
});
it('should NOT use maxAttempts=1 for other models even in Preview Model Fallback Mode', async () => {
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
DEFAULT_GEMINI_FLASH_MODEL,
{ message: 'test' },
'prompt-id-normal-retry',
);
for await (const _ of stream) {
// consume stream
}
expect(mockRetryWithBackoff).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
maxAttempts: undefined, // Should use default
}),
);
});
it('should pass DEFAULT_GEMINI_MODEL to handleFallback when Preview Model is bypassed (downgraded)', async () => {
// ARRANGE
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
// Mock retryWithBackoff to simulate catching the error and calling onPersistent429
vi.mocked(retryWithBackoff).mockImplementation(
async (apiCall, options) => {
const onPersistent429 = options?.onPersistent429;
try {
await apiCall();
} catch (error) {
if (onPersistent429) {
await onPersistent429(AuthType.LOGIN_WITH_GOOGLE, error);
}
throw error;
}
},
);
// We need the API call to fail so retryWithBackoff calls the callback.
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
new TerminalQuotaError('Simulated Quota Error', {
code: 429,
message: 'Simulated Quota Error',
details: [],
}),
);
// ACT
const consumeStream = async () => {
const stream = await chat.sendMessageStream(
PREVIEW_GEMINI_MODEL,
{ message: 'test' },
'prompt-id-bypass',
);
// Consume the stream to trigger execution
for await (const _ of stream) {
// do nothing
}
};
await expect(consumeStream()).rejects.toThrow('Simulated Quota Error');
expect(retryWithBackoff).toHaveBeenCalled();
// ASSERT
// handleFallback is called via onPersistent429Callback
// We verify it was called with DEFAULT_GEMINI_MODEL
expect(mockHandleFallback).toHaveBeenCalledWith(
expect.anything(),
DEFAULT_GEMINI_MODEL, // This is the key assertion
expect.anything(),
expect.anything(),
);
});
it('should throw an error when a tool call is followed by an empty stream response', async () => {
// 1. Setup: A history where the model has just made a function call.
const initialHistory: Content[] = [
@@ -491,7 +622,6 @@ describe('GeminiChat', () => {
},
];
chat.setHistory(initialHistory);
// 2. Mock the API to return an empty/thought-only stream.
const emptyStreamResponse = (async function* () {
yield {
@@ -509,7 +639,7 @@ describe('GeminiChat', () => {
// 3. Action: Send the function response back to the model and consume the stream.
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{
message: {
functionResponse: {
@@ -595,7 +725,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-1',
);
@@ -630,7 +760,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-1',
);
@@ -701,7 +831,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.5-pro',
{ message: 'test' },
'prompt-id-malformed',
);
@@ -747,7 +877,7 @@ describe('GeminiChat', () => {
// 2. Send a message
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.5-pro',
{ message: 'test retry' },
'prompt-id-retry-malformed',
);
@@ -858,6 +988,38 @@ describe('GeminiChat', () => {
});
describe('sendMessageStream with retries', () => {
it('should not retry on invalid content if model does not start with gemini-2', async () => {
// Mock the stream to fail.
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async () =>
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: '' }] } }],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
'gemini-1.5-pro',
{ message: 'test' },
'prompt-id-no-retry',
);
await expect(
(async () => {
for await (const _ of stream) {
// Must loop to trigger the internal logic that throws.
}
})(),
).rejects.toThrow(InvalidStreamError);
// Should be called only 1 time (no retry)
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
1,
);
expect(mockLogContentRetry).not.toHaveBeenCalled();
});
it('should yield a RETRY event when an invalid stream is encountered', async () => {
// ARRANGE: Mock the stream to fail once, then succeed.
vi.mocked(mockContentGenerator.generateContentStream)
@@ -885,7 +1047,7 @@ describe('GeminiChat', () => {
// ACT: Send a message and collect all events from the stream.
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-yield-retry',
);
@@ -926,7 +1088,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-retry-success',
);
@@ -997,7 +1159,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test', config: { temperature: 0.5 } },
'prompt-id-retry-temperature',
);
@@ -1055,7 +1217,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-retry-fail',
);
@@ -1120,7 +1282,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-400',
);
@@ -1325,7 +1487,7 @@ describe('GeminiChat', () => {
// 3. Send a new message
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'Second question' },
'prompt-id-retry-existing',
);
@@ -1396,7 +1558,7 @@ describe('GeminiChat', () => {
// 2. Call the method and consume the stream.
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test empty stream' },
'prompt-id-empty-stream',
);
@@ -1665,7 +1827,7 @@ describe('GeminiChat', () => {
mockHandleFallback.mockResolvedValue(false);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test stop' },
'prompt-id-fb2',
);
@@ -1723,7 +1885,7 @@ describe('GeminiChat', () => {
// Send a message and consume the stream
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-discard-test',
);
@@ -1785,4 +1947,177 @@ describe('GeminiChat', () => {
]);
});
});
describe('Preview Model Fallback Logic', () => {
it('should reset previewModelBypassMode to false at the start of sendMessageStream', async () => {
const stream = (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
stream,
);
await chat.sendMessageStream(
'test-model',
{ message: 'test' },
'prompt-id-preview-model-reset',
);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(false);
});
it('should reset previewModelFallbackMode to false upon successful Preview Model usage', async () => {
const stream = (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
stream,
);
const resultStream = await chat.sendMessageStream(
PREVIEW_GEMINI_MODEL,
{ message: 'test' },
'prompt-id-preview-model-healing',
);
for await (const _ of resultStream) {
// consume stream
}
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(
false,
);
});
it('should NOT reset previewModelFallbackMode if Preview Model was bypassed (downgraded)', async () => {
const stream = (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
stream,
);
// Simulate bypass mode being active (downgrade happened)
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
const resultStream = await chat.sendMessageStream(
PREVIEW_GEMINI_MODEL,
{ message: 'test' },
'prompt-id-bypass-no-healing',
);
for await (const _ of resultStream) {
// consume stream
}
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
});
});
describe('ensureActiveLoopHasThoughtSignatures', () => {
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
const chat = new GeminiChat(mockConfig, {}, []);
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Old message' }] },
{
role: 'model',
parts: [{ functionCall: { name: 'old_tool', args: {} } }],
},
{ role: 'user', parts: [{ text: 'Find a restaurant' }] }, // active loop starts here
{
role: 'model',
parts: [
{ functionCall: { name: 'find_restaurant', args: {} } }, // This one gets a signature
{ functionCall: { name: 'find_restaurant_2', args: {} } }, // This one does NOT
],
},
{
role: 'user',
parts: [
{ functionResponse: { name: 'find_restaurant', response: {} } },
],
},
{
role: 'model',
parts: [
{
functionCall: { name: 'tool_with_sig', args: {} },
thoughtSignature: 'existing-sig',
},
{ functionCall: { name: 'another_tool', args: {} } }, // This one does NOT get a signature
],
},
];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
// Outside active loop - unchanged
expect(newContents[1]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
// Inside active loop, first model turn
// First function call gets a signature
expect(newContents[3]?.parts?.[0]?.thoughtSignature).toBe(
SYNTHETIC_THOUGHT_SIGNATURE,
);
// Second function call does NOT
expect(newContents[3]?.parts?.[1]).not.toHaveProperty('thoughtSignature');
// User functionResponse part - unchanged (this is not a model turn)
expect(newContents[4]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
// Inside active loop, second model turn
// First function call already has a signature, so nothing changes
expect(newContents[5]?.parts?.[0]?.thoughtSignature).toBe('existing-sig');
// Second function call does NOT get a signature
expect(newContents[5]?.parts?.[1]).not.toHaveProperty('thoughtSignature');
});
it('should not modify contents if there is no user text message', () => {
const chat = new GeminiChat(mockConfig, {}, []);
const history: Content[] = [
{
role: 'user',
parts: [{ functionResponse: { name: 'tool1', response: {} } }],
},
{
role: 'model',
parts: [{ functionCall: { name: 'tool2', args: {} } }],
},
];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
expect(newContents).toEqual(history);
expect(newContents[1]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
});
it('should handle an empty history', () => {
const chat = new GeminiChat(mockConfig, {}, []);
const history: Content[] = [];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
expect(newContents).toEqual([]);
});
it('should handle history with only a user message', () => {
const chat = new GeminiChat(mockConfig, {}, []);
const history: Content[] = [{ role: 'user', parts: [{ text: 'Hello' }] }];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
expect(newContents).toEqual(history);
});
});
});

View File

@@ -20,8 +20,10 @@ import { createUserContent, FinishReason } from '@google/genai';
import { retryWithBackoff } from '../utils/retry.js';
import type { Config } from '../config/config.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
getEffectiveModel,
isGemini2Model,
} from '../config/models.js';
import { hasCycleInSchema } from '../tools/tools.js';
import type { StructuredError } from './turn.js';
@@ -69,6 +71,8 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
initialDelayMs: 500,
};
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
/**
* Returns true if the response is valid, false otherwise.
*/
@@ -243,6 +247,11 @@ export class GeminiChat {
): Promise<AsyncGenerator<StreamEvent>> {
await this.sendPromise;
// Preview Model Bypass mode for the new request.
// This ensures that we attempt to use Preview Model for every new user turn
// (unless the "Always" fallback mode is active, which is handled separately).
this.config.setPreviewModelBypassMode(false);
let streamDoneResolver: () => void;
const streamDonePromise = new Promise<void>((resolve) => {
streamDoneResolver = resolve;
@@ -275,11 +284,17 @@ export class GeminiChat {
try {
let lastError: unknown = new Error('Request failed after all retries.');
for (
let attempt = 0;
attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
attempt++
let maxAttempts = INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
// If we are in Preview Model Fallback Mode, we want to fail fast (1 attempt)
// when probing the Preview Model.
if (
self.config.isPreviewModelFallbackMode() &&
model === PREVIEW_GEMINI_MODEL
) {
maxAttempts = 1;
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
if (attempt > 0) {
yield { type: StreamEventType.RETRY };
@@ -311,9 +326,9 @@ export class GeminiChat {
lastError = error;
const isContentError = error instanceof InvalidStreamError;
if (isContentError) {
if (isContentError && isGemini2Model(model)) {
// Check if we have more attempts left.
if (attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts - 1) {
if (attempt < maxAttempts - 1) {
logContentRetry(
self.config,
new ContentRetryEvent(
@@ -338,17 +353,29 @@ export class GeminiChat {
}
if (lastError) {
if (lastError instanceof InvalidStreamError) {
if (
lastError instanceof InvalidStreamError &&
isGemini2Model(model)
) {
logContentRetryFailure(
self.config,
new ContentRetryFailureEvent(
INVALID_CONTENT_RETRY_OPTIONS.maxAttempts,
maxAttempts,
(lastError as InvalidStreamError).type,
model,
),
);
}
throw lastError;
} else {
// Preview Model successfully used, disable fallback mode.
// We only do this if we didn't bypass Preview Model (i.e. we actually used it).
if (
model === PREVIEW_GEMINI_MODEL &&
!self.config.isPreviewModelBypassMode()
) {
self.config.setPreviewModelFallbackMode(false);
}
}
} finally {
streamDoneResolver!();
@@ -362,25 +389,35 @@ export class GeminiChat {
params: SendMessageParameters,
prompt_id: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
let effectiveModel = model;
const contentsForPreviewModel =
this.ensureActiveLoopHasThoughtSignatures(requestContents);
const apiCall = () => {
const modelToUse = getEffectiveModel(
let modelToUse = getEffectiveModel(
this.config.isInFallbackMode(),
model,
this.config.getPreviewFeatures(),
);
// Preview Model Bypass Logic:
// If we are in "Preview Model Bypass Mode" (transient failure), we force downgrade to 2.5 Pro
// IF the effective model is currently Preview Model.
if (
this.config.getQuotaErrorOccurred() &&
modelToUse === DEFAULT_GEMINI_FLASH_MODEL
this.config.isPreviewModelBypassMode() &&
modelToUse === PREVIEW_GEMINI_MODEL
) {
throw new Error(
'Please submit a new query to continue with the Flash model.',
);
modelToUse = DEFAULT_GEMINI_MODEL;
}
effectiveModel = modelToUse;
return this.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
contents: requestContents,
contents:
modelToUse === PREVIEW_GEMINI_MODEL
? contentsForPreviewModel
: requestContents,
config: { ...this.generationConfig, ...params.config },
},
prompt_id,
@@ -390,13 +427,18 @@ export class GeminiChat {
const onPersistent429Callback = async (
authType?: string,
error?: unknown,
) => await handleFallback(this.config, model, authType, error);
) => await handleFallback(this.config, effectiveModel, authType, error);
const streamResponse = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
signal: params.config?.abortSignal,
maxAttempts:
this.config.isPreviewModelFallbackMode() &&
model === PREVIEW_GEMINI_MODEL
? 1
: undefined,
});
return this.processStreamResponse(model, streamResponse);
@@ -469,6 +511,55 @@ export class GeminiChat {
});
}
// To ensure our requests validate, the first function call in every model
// turn within the active loop must have a `thoughtSignature` property.
// If we do not do this, we will get back 400 errors from the API.
ensureActiveLoopHasThoughtSignatures(requestContents: Content[]): Content[] {
// First, find the start of the active loop by finding the last user turn
// with a text message, i.e. that is not a function response.
let activeLoopStartIndex = -1;
for (let i = requestContents.length - 1; i >= 0; i--) {
const content = requestContents[i];
if (content.role === 'user' && content.parts?.some((part) => part.text)) {
activeLoopStartIndex = i;
break;
}
}
if (activeLoopStartIndex === -1) {
return requestContents;
}
// Iterate through every message in the active loop, ensuring that the first
// function call in each message's list of parts has a valid
// thoughtSignature property. If it does not we replace the function call
// with a copy that uses the synthetic thought signature.
const newContents = requestContents.slice(); // Shallow copy the array
for (let i = activeLoopStartIndex; i < newContents.length; i++) {
const content = newContents[i];
if (content.role === 'model' && content.parts) {
const newParts = content.parts.slice();
for (let j = 0; j < newParts.length; j++) {
const part = newParts[j]!;
if (part.functionCall) {
if (!part.thoughtSignature) {
newParts[j] = {
...part,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
};
newContents[i] = {
...content,
parts: newParts,
};
}
break; // Only consider the first function call
}
}
}
}
return newContents;
}
setTools(tools: Tool[]): void {
this.generationConfig.tools = tools;
}

View File

@@ -20,9 +20,11 @@ import { AuthType } from '../core/contentGenerator.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
} from '../config/models.js';
import { logFlashFallback } from '../telemetry/index.js';
import type { FallbackModelHandler } from './types.js';
import { ModelNotFoundError } from '../utils/httpErrors.js';
// Mock the telemetry logger and event class
vi.mock('../telemetry/index.js', () => ({
@@ -39,7 +41,12 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
({
isInFallbackMode: vi.fn(() => false),
setFallbackMode: vi.fn(),
isPreviewModelFallbackMode: vi.fn(() => false),
setPreviewModelFallbackMode: vi.fn(),
isPreviewModelBypassMode: vi.fn(() => false),
setPreviewModelBypassMode: vi.fn(),
fallbackHandler: undefined,
getFallbackModelHandler: vi.fn(),
isInteractive: vi.fn(() => false),
...overrides,
}) as unknown as Config;
@@ -99,7 +106,7 @@ describe('handleFallback', () => {
describe('when handler returns "retry"', () => {
it('should activate fallback mode, log telemetry, and return true', async () => {
mockHandler.mockResolvedValue('retry');
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(
mockConfig,
@@ -152,7 +159,7 @@ describe('handleFallback', () => {
it('should pass the correct context (failedModel, fallbackModel, error) to the handler', async () => {
const mockError = new Error('Quota Exceeded');
mockHandler.mockResolvedValue('retry');
mockHandler.mockResolvedValue('retry_always');
await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH, mockError);
@@ -171,7 +178,7 @@ describe('handleFallback', () => {
setFallbackMode: vi.fn(),
});
mockHandler.mockResolvedValue('retry');
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(
activeFallbackConfig,
@@ -201,4 +208,107 @@ describe('handleFallback', () => {
);
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
});
describe('Preview Model Fallback Logic', () => {
const previewModel = PREVIEW_GEMINI_MODEL;
it('should always set Preview Model bypass mode on failure', async () => {
await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
});
it('should silently retry if Preview Model fallback mode is already active', async () => {
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(true);
const result = await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
expect(result).toBe(true);
expect(mockHandler).not.toHaveBeenCalled();
});
it('should activate Preview Model fallback mode when handler returns "retry_always"', async () => {
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
expect(result).toBe(true);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(true);
});
it('should NOT set fallback mode if user chooses "retry_once"', async () => {
mockHandler.mockResolvedValue('retry_once');
const result = await handleFallback(
mockConfig,
PREVIEW_GEMINI_MODEL,
AuthType.LOGIN_WITH_GOOGLE,
new Error('Capacity'),
);
expect(result).toBe(true);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
});
it('should set fallback mode if user chooses "retry_always"', async () => {
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(
mockConfig,
PREVIEW_GEMINI_MODEL,
AuthType.LOGIN_WITH_GOOGLE,
new Error('Capacity'),
);
expect(result).toBe(true);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(true);
});
it('should pass DEFAULT_GEMINI_MODEL as fallback when Preview Model fails', async () => {
const mockFallbackHandler = vi.fn().mockResolvedValue('stop');
vi.mocked(mockConfig.fallbackModelHandler!).mockImplementation(
mockFallbackHandler,
);
await handleFallback(
mockConfig,
PREVIEW_GEMINI_MODEL,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
undefined,
);
});
});
it('should return null if ModelNotFoundError occurs for a non-preview model', async () => {
const modelNotFoundError = new ModelNotFoundError('Not found');
const result = await handleFallback(
mockConfig,
DEFAULT_GEMINI_MODEL, // Not preview model
AUTH_OAUTH,
modelNotFoundError,
);
expect(result).toBeNull();
expect(mockHandler).not.toHaveBeenCalled();
});
it('should consult handler if ModelNotFoundError occurs for preview model', async () => {
const modelNotFoundError = new ModelNotFoundError('Not found');
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(
mockConfig,
PREVIEW_GEMINI_MODEL,
AUTH_OAUTH,
modelNotFoundError,
);
expect(result).toBe(true);
expect(mockHandler).toHaveBeenCalled();
});
});

View File

@@ -6,9 +6,19 @@
import type { Config } from '../config/config.js';
import { AuthType } from '../core/contentGenerator.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
} from '../config/models.js';
import { logFlashFallback, FlashFallbackEvent } from '../telemetry/index.js';
import { coreEvents } from '../utils/events.js';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getErrorMessage } from '../utils/errors.js';
import { ModelNotFoundError } from '../utils/httpErrors.js';
const UPGRADE_URL_PAGE = 'https://goo.gle/set-up-gemini-code-assist';
export async function handleFallback(
config: Config,
@@ -19,7 +29,31 @@ export async function handleFallback(
// Applicability Checks
if (authType !== AuthType.LOGIN_WITH_GOOGLE) return null;
const fallbackModel = DEFAULT_GEMINI_FLASH_MODEL;
// Guardrail: If it's a ModelNotFoundError but NOT the preview model, do not handle it.
if (
error instanceof ModelNotFoundError &&
failedModel !== PREVIEW_GEMINI_MODEL
) {
return null;
}
// Preview Model Specific Logic
if (failedModel === PREVIEW_GEMINI_MODEL) {
// Always set bypass mode for the immediate retry.
// This ensures the next attempt uses 2.5 Pro.
config.setPreviewModelBypassMode(true);
// If we are already in Preview Model fallback mode (user previously said "Always"),
// we silently retry (which will use 2.5 Pro due to bypass mode).
if (config.isPreviewModelFallbackMode()) {
return true;
}
}
const fallbackModel =
failedModel === PREVIEW_GEMINI_MODEL
? DEFAULT_GEMINI_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
// Consult UI Handler for Intent
const fallbackModelHandler = config.fallbackModelHandler;
@@ -35,11 +69,18 @@ export async function handleFallback(
// Process Intent and Update State
switch (intent) {
case 'retry':
// Activate fallback mode. The NEXT retry attempt will pick this up.
activateFallbackMode(config, authType);
case 'retry_always':
if (failedModel === PREVIEW_GEMINI_MODEL) {
activatePreviewModelFallbackMode(config);
} else {
activateFallbackMode(config, authType);
}
return true; // Signal retryWithBackoff to continue.
case 'retry_once':
// Just retry this time, do NOT set sticky fallback mode.
return true;
case 'stop':
activateFallbackMode(config, authType);
return false;
@@ -47,6 +88,10 @@ export async function handleFallback(
case 'retry_later':
return false;
case 'upgrade':
await handleUpgrade();
return false;
default:
throw new Error(
`Unexpected fallback intent received from fallbackModelHandler: "${intent}"`,
@@ -58,6 +103,17 @@ export async function handleFallback(
}
}
async function handleUpgrade() {
try {
await openBrowserSecurely(UPGRADE_URL_PAGE);
} catch (error) {
debugLogger.warn(
'Failed to open browser automatically:',
getErrorMessage(error),
);
}
}
function activateFallbackMode(config: Config, authType: string | undefined) {
if (!config.isInFallbackMode()) {
config.setFallbackMode(true);
@@ -67,3 +123,10 @@ function activateFallbackMode(config: Config, authType: string | undefined) {
}
}
}
function activatePreviewModelFallbackMode(config: Config) {
if (!config.isPreviewModelFallbackMode()) {
config.setPreviewModelFallbackMode(true);
// We might want a specific event for Preview Model fallback, but for now we just set the mode.
}
}

View File

@@ -8,9 +8,11 @@
* Defines the intent returned by the UI layer during a fallback scenario.
*/
export type FallbackIntent =
| 'retry' // Immediately retry the current request with the fallback model.
| 'retry_always' // Retry with fallback model and stick to it for future requests.
| 'retry_once' // Retry with fallback model for this request only.
| 'stop' // Switch to fallback for future requests, but stop the current request.
| 'retry_later'; // Stop the current request and do not fallback. Intend to try again later with the same model.
| 'retry_later' // Stop the current request and do not fallback. Intend to try again later with the same model.
| 'upgrade'; // Give user an option to upgrade the tier.
/**
* The interface for the handler provided by the UI layer (e.g., the CLI)

View File

@@ -88,6 +88,12 @@ describe('detectIde', () => {
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
});
it('should detect AntiGravity', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
});
});
describe('detectIde with ideInfoFromFile', () => {

View File

@@ -14,6 +14,7 @@ export const IDE_DEFINITIONS = {
trae: { name: 'trae', displayName: 'Trae' },
vscode: { name: 'vscode', displayName: 'VS Code' },
vscodefork: { name: 'vscodefork', displayName: 'IDE' },
antigravity: { name: 'antigravity', displayName: 'Antigravity' },
} as const;
export interface IdeInfo {
@@ -26,6 +27,9 @@ export function isCloudShell(): boolean {
}
export function detectIdeFromEnv(): IdeInfo {
if (process.env['ANTIGRAVITY_CLI_ALIAS']) {
return IDE_DEFINITIONS.antigravity;
}
if (process.env['__COG_BASHRC_SOURCED']) {
return IDE_DEFINITIONS.devin;
}

View File

@@ -137,11 +137,12 @@ export class IdeClient {
this.trustChangeListeners.delete(listener);
}
async connect(): Promise<void> {
async connect(options: { logToConsole?: boolean } = {}): Promise<void> {
const logError = options.logToConsole ?? true;
if (!this.currentIde) {
this.setState(
IDEConnectionStatus.Disconnected,
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks`,
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
false,
);
return;
@@ -163,7 +164,7 @@ export class IdeClient {
);
if (!isValid) {
this.setState(IDEConnectionStatus.Disconnected, error, true);
this.setState(IDEConnectionStatus.Disconnected, error, logError);
return;
}
@@ -205,7 +206,7 @@ export class IdeClient {
this.setState(
IDEConnectionStatus.Disconnected,
`Failed to connect to IDE companion extension in ${this.currentIde.displayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
true,
logError,
);
}

View File

@@ -47,6 +47,13 @@ describe('ide-installer', () => {
expect(installer).not.toBeNull();
expect(installer?.install).toEqual(expect.any(Function));
});
it('returns an AntigravityInstaller for "antigravity"', () => {
const installer = getIdeInstaller(IDE_DEFINITIONS.antigravity);
expect(installer).not.toBeNull();
expect(installer?.install).toEqual(expect.any(Function));
});
});
describe('VsCodeInstaller', () => {
@@ -188,3 +195,59 @@ describe('ide-installer', () => {
});
});
});
describe('AntigravityInstaller', () => {
function setup({
execSync = () => '',
platform = 'linux' as NodeJS.Platform,
}: {
execSync?: () => string;
platform?: NodeJS.Platform;
} = {}) {
vi.spyOn(child_process, 'execSync').mockImplementation(execSync);
const installer = getIdeInstaller(IDE_DEFINITIONS.antigravity, platform)!;
return { installer };
}
it('installs the extension using the alias', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
const { installer } = setup({});
const result = await installer.install();
expect(result.success).toBe(true);
expect(child_process.spawnSync).toHaveBeenCalledWith(
'agy',
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: false },
);
});
it('returns a failure message if the alias is not set', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
const { installer } = setup({});
const result = await installer.install();
expect(result.success).toBe(false);
expect(result.message).toContain(
'ANTIGRAVITY_CLI_ALIAS environment variable not set',
);
});
it('returns a failure message if the command is not found', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'not-a-command');
const { installer } = setup({
execSync: () => {
throw new Error('Command not found');
},
});
const result = await installer.install();
expect(result.success).toBe(false);
expect(result.message).toContain('not-a-command not found');
});
});

View File

@@ -12,10 +12,6 @@ import * as os from 'node:os';
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
import { GEMINI_CLI_COMPANION_EXTENSION_NAME } from './constants.js';
function getVsCodeCommand(platform: NodeJS.Platform = process.platform) {
return platform === 'win32' ? 'code.cmd' : 'code';
}
export interface IdeInstaller {
install(): Promise<InstallResult>;
}
@@ -25,15 +21,15 @@ export interface InstallResult {
message: string;
}
async function findVsCodeCommand(
async function findCommand(
command: string,
platform: NodeJS.Platform = process.platform,
): Promise<string | null> {
// 1. Check PATH first.
const vscodeCommand = getVsCodeCommand(platform);
try {
if (platform === 'win32') {
const result = child_process
.execSync(`where.exe ${vscodeCommand}`)
.execSync(`where.exe ${command}`)
.toString()
.trim();
// `where.exe` can return multiple paths. Return the first one.
@@ -42,10 +38,10 @@ async function findVsCodeCommand(
return firstPath;
}
} else {
child_process.execSync(`command -v ${vscodeCommand}`, {
child_process.execSync(`command -v ${command}`, {
stdio: 'ignore',
});
return vscodeCommand;
return command;
}
} catch {
// Not in PATH, continue to check common locations.
@@ -55,38 +51,40 @@ async function findVsCodeCommand(
const locations: string[] = [];
const homeDir = os.homedir();
if (platform === 'darwin') {
// macOS
locations.push(
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
path.join(homeDir, 'Library/Application Support/Code/bin/code'),
);
} else if (platform === 'linux') {
// Linux
locations.push(
'/usr/share/code/bin/code',
'/snap/bin/code',
path.join(homeDir, '.local/share/code/bin/code'),
);
} else if (platform === 'win32') {
// Windows
locations.push(
path.join(
process.env['ProgramFiles'] || 'C:\\Program Files',
'Microsoft VS Code',
'bin',
'code.cmd',
),
path.join(
homeDir,
'AppData',
'Local',
'Programs',
'Microsoft VS Code',
'bin',
'code.cmd',
),
);
if (command === 'code' || command === 'code.cmd') {
if (platform === 'darwin') {
// macOS
locations.push(
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
path.join(homeDir, 'Library/Application Support/Code/bin/code'),
);
} else if (platform === 'linux') {
// Linux
locations.push(
'/usr/share/code/bin/code',
'/snap/bin/code',
path.join(homeDir, '.local/share/code/bin/code'),
);
} else if (platform === 'win32') {
// Windows
locations.push(
path.join(
process.env['ProgramFiles'] || 'C:\\Program Files',
'Microsoft VS Code',
'bin',
'code.cmd',
),
path.join(
homeDir,
'AppData',
'Local',
'Programs',
'Microsoft VS Code',
'bin',
'code.cmd',
),
);
}
}
for (const location of locations) {
@@ -105,7 +103,8 @@ class VsCodeInstaller implements IdeInstaller {
readonly ideInfo: IdeInfo,
readonly platform = process.platform,
) {
this.vsCodeCommand = findVsCodeCommand(platform);
const command = platform === 'win32' ? 'code.cmd' : 'code';
this.vsCodeCommand = findCommand(command, platform);
}
async install(): Promise<InstallResult> {
@@ -147,6 +146,59 @@ class VsCodeInstaller implements IdeInstaller {
}
}
class AntigravityInstaller implements IdeInstaller {
constructor(
readonly ideInfo: IdeInfo,
readonly platform = process.platform,
) {}
async install(): Promise<InstallResult> {
const command = process.env['ANTIGRAVITY_CLI_ALIAS'];
if (!command) {
return {
success: false,
message: 'ANTIGRAVITY_CLI_ALIAS environment variable not set.',
};
}
const commandPath = await findCommand(command, this.platform);
if (!commandPath) {
return {
success: false,
message: `${command} not found. Please ensure it is in your system's PATH.`,
};
}
try {
const result = child_process.spawnSync(
commandPath,
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: this.platform === 'win32' },
);
if (result.status !== 0) {
throw new Error(
`Failed to install extension: ${result.stderr?.toString()}`,
);
}
return {
success: true,
message: `${this.ideInfo.displayName} companion extension was installed successfully.`,
};
} catch (_error) {
return {
success: false,
message: `Failed to install ${this.ideInfo.displayName} companion extension. Please try installing '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' manually from the ${this.ideInfo.displayName} extension marketplace.`,
};
}
}
}
export function getIdeInstaller(
ide: IdeInfo,
platform = process.platform,
@@ -155,6 +207,8 @@ export function getIdeInstaller(
case IDE_DEFINITIONS.vscode.name:
case IDE_DEFINITIONS.firebasestudio.name:
return new VsCodeInstaller(ide, platform);
case IDE_DEFINITIONS.antigravity.name:
return new AntigravityInstaller(ide, platform);
default:
return null;
}

View File

@@ -7,6 +7,7 @@
// Export config
export * from './config/config.js';
export * from './config/defaultModelConfigs.js';
export * from './config/models.js';
export * from './output/types.js';
export * from './output/json-formatter.js';
export * from './output/stream-json-formatter.js';

View File

@@ -7,6 +7,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ModelRouterService } from './modelRouterService.js';
import { Config } from '../config/config.js';
import {
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
} from '../config/models.js';
import type { BaseLlmClient } from '../core/baseLlmClient.js';
import type { RoutingContext, RoutingDecision } from './routingStrategy.js';
import { DefaultStrategy } from './strategies/defaultStrategy.js';
@@ -147,5 +151,81 @@ describe('ModelRouterService', () => {
expect.any(ModelRoutingEvent),
);
});
it('should upgrade to Preview Model when preview features are enabled and model is 2.5 Pro', async () => {
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
model: DEFAULT_GEMINI_MODEL,
metadata: { source: 'test', latencyMs: 0, reasoning: 'test' },
});
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
const decision = await service.route(mockContext);
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
});
it('should NOT upgrade to Preview Model when preview features are disabled', async () => {
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
model: DEFAULT_GEMINI_MODEL,
metadata: { source: 'test', latencyMs: 0, reasoning: 'test' },
});
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(false);
const decision = await service.route(mockContext);
expect(decision.model).toBe(DEFAULT_GEMINI_MODEL);
});
it('should upgrade to Preview Model when preview features are enabled and model is explicitly set to Pro', async () => {
// Simulate OverrideStrategy returning Preview Model (as resolveModel would do for "pro")
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
model: PREVIEW_GEMINI_MODEL,
metadata: {
source: 'override',
latencyMs: 0,
reasoning: 'User selected',
},
});
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
const decision = await service.route(mockContext);
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
});
it('should NOT upgrade to Preview Model when preview features are enabled and model is explicitly set to a specific string', async () => {
// Simulate OverrideStrategy returning a specific model (e.g. "gemini-2.5-pro")
// This happens when user explicitly sets model to "gemini-2.5-pro" instead of "pro"
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
model: DEFAULT_GEMINI_MODEL,
metadata: {
source: 'override',
latencyMs: 0,
reasoning: 'User selected',
},
});
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
const decision = await service.route(mockContext);
// Should NOT upgrade to Preview Model because source is 'override' and model is specific
expect(decision.model).toBe(DEFAULT_GEMINI_MODEL);
});
it('should upgrade to Preview Model even if fallback mode is active (probing behavior)', async () => {
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
model: DEFAULT_GEMINI_MODEL,
metadata: { source: 'default', latencyMs: 0, reasoning: 'Default' },
});
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(true);
const decision = await service.route(mockContext);
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
});
});
});

View File

@@ -5,6 +5,10 @@
*/
import type { Config } from '../config/config.js';
import {
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
} from '../config/models.js';
import type {
RoutingContext,
RoutingDecision,
@@ -62,6 +66,23 @@ export class ModelRouterService {
this.config.getBaseLlmClient(),
);
// Unified Preview Model Logic:
// If the decision is to use 'gemini-2.5-pro' and preview features are enabled,
// we attempt to upgrade to 'gemini-3.0-pro' (Preview Model).
if (
decision.model === DEFAULT_GEMINI_MODEL &&
this.config.getPreviewFeatures() &&
decision.metadata.source !== 'override'
) {
// We ALWAYS attempt to upgrade to Preview Model here.
// If we are in fallback mode, the 'previewModelBypassMode' flag (handled in handler.ts/geminiChat.ts)
// will ensure we downgrade to 2.5 Pro for the actual API call if needed.
// This allows us to "probe" Preview Model periodically (i.e., every new request tries Preview Model first).
decision.model = PREVIEW_GEMINI_MODEL;
decision.metadata.source += ' (Preview Model)';
decision.metadata.reasoning += ' (Upgraded to Preview Model)';
}
const event = new ModelRoutingEvent(
decision.model,
decision.metadata.source,

View File

@@ -40,6 +40,7 @@ describe('ClassifierStrategy', () => {
request: [{ text: 'simple task' }],
signal: new AbortController().signal,
};
mockResolvedConfig = {
model: 'classifier',
generateContentConfig: {},
@@ -48,6 +49,7 @@ describe('ClassifierStrategy', () => {
modelConfigService: {
getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig),
},
getPreviewFeatures: () => false,
} as unknown as Config;
mockBaseLlmClient = {
generateJson: vi.fn(),

View File

@@ -13,8 +13,9 @@ import type {
RoutingStrategy,
} from '../routingStrategy.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_PRO,
resolveModel,
} from '../../config/models.js';
import { createUserContent, Type } from '@google/genai';
import type { Config } from '../../config/config.js';
@@ -131,7 +132,7 @@ export class ClassifierStrategy implements RoutingStrategy {
async route(
context: RoutingContext,
_config: Config,
config: Config,
baseLlmClient: BaseLlmClient,
): Promise<RoutingDecision | null> {
const startTime = Date.now();
@@ -173,7 +174,10 @@ export class ClassifierStrategy implements RoutingStrategy {
if (routerResponse.model_choice === FLASH_MODEL) {
return {
model: DEFAULT_GEMINI_FLASH_MODEL,
model: resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
config.getPreviewFeatures(),
),
metadata: {
source: 'Classifier',
latencyMs,
@@ -182,7 +186,10 @@ export class ClassifierStrategy implements RoutingStrategy {
};
} else {
return {
model: DEFAULT_GEMINI_MODEL,
model: resolveModel(
GEMINI_MODEL_ALIAS_PRO,
config.getPreviewFeatures(),
),
metadata: {
source: 'Classifier',
reasoning,

View File

@@ -24,6 +24,7 @@ describe('FallbackStrategy', () => {
const mockConfig = {
isInFallbackMode: () => false,
getModel: () => DEFAULT_GEMINI_MODEL,
getPreviewFeatures: () => false,
} as Config;
const decision = await strategy.route(mockContext, mockConfig, mockClient);
@@ -35,6 +36,7 @@ describe('FallbackStrategy', () => {
const mockConfig = {
isInFallbackMode: () => true,
getModel: () => DEFAULT_GEMINI_MODEL,
getPreviewFeatures: () => false,
} as Config;
const decision = await strategy.route(
@@ -53,6 +55,7 @@ describe('FallbackStrategy', () => {
const mockConfig = {
isInFallbackMode: () => true,
getModel: () => DEFAULT_GEMINI_FLASH_LITE_MODEL,
getPreviewFeatures: () => false,
} as Config;
const decision = await strategy.route(
@@ -70,6 +73,7 @@ describe('FallbackStrategy', () => {
const mockConfig = {
isInFallbackMode: () => true,
getModel: () => DEFAULT_GEMINI_FLASH_MODEL,
getPreviewFeatures: () => false,
} as Config;
const decision = await strategy.route(

View File

@@ -30,6 +30,7 @@ export class FallbackStrategy implements RoutingStrategy {
const effectiveModel = getEffectiveModel(
isInFallbackMode,
config.getModel(),
config.getPreviewFeatures(),
);
return {
model: effectiveModel,

View File

@@ -19,6 +19,7 @@ describe('OverrideStrategy', () => {
it('should return null when the override model is auto', async () => {
const mockConfig = {
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
getPreviewFeatures: () => false,
} as Config;
const decision = await strategy.route(mockContext, mockConfig, mockClient);
@@ -29,6 +30,7 @@ describe('OverrideStrategy', () => {
const overrideModel = 'gemini-2.5-pro-custom';
const mockConfig = {
getModel: () => overrideModel,
getPreviewFeatures: () => false,
} as Config;
const decision = await strategy.route(mockContext, mockConfig, mockClient);
@@ -46,6 +48,7 @@ describe('OverrideStrategy', () => {
const overrideModel = 'gemini-2.5-flash-experimental';
const mockConfig = {
getModel: () => overrideModel,
getPreviewFeatures: () => false,
} as Config;
const decision = await strategy.route(mockContext, mockConfig, mockClient);

View File

@@ -5,7 +5,10 @@
*/
import type { Config } from '../../config/config.js';
import { DEFAULT_GEMINI_MODEL_AUTO } from '../../config/models.js';
import {
DEFAULT_GEMINI_MODEL_AUTO,
resolveModel,
} from '../../config/models.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import type {
RoutingContext,
@@ -31,7 +34,7 @@ export class OverrideStrategy implements RoutingStrategy {
// Return the overridden model name.
return {
model: overrideModel,
model: resolveModel(overrideModel, config.getPreviewFeatures()),
metadata: {
source: this.name,
latencyMs: 0,

View File

@@ -72,6 +72,11 @@ describe('editor utils', () => {
{ editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{ editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] },
{
editor: 'antigravity',
commands: ['agy'],
win32Commands: ['agy.cmd'],
},
];
for (const { editor, commands, win32Commands } of testCases) {
@@ -171,6 +176,11 @@ describe('editor utils', () => {
},
{ editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{
editor: 'antigravity',
commands: ['agy'],
win32Commands: ['agy.cmd'],
},
];
for (const { editor, commands, win32Commands } of guiEditors) {
@@ -430,6 +440,7 @@ describe('editor utils', () => {
'windsurf',
'cursor',
'zed',
'antigravity',
];
for (const editor of guiEditors) {
it(`should not call onEditorClose for ${editor}`, async () => {

View File

@@ -15,7 +15,24 @@ export type EditorType =
| 'vim'
| 'neovim'
| 'zed'
| 'emacs';
| 'emacs'
| 'antigravity';
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
vscode: 'VS Code',
vscodium: 'VSCodium',
windsurf: 'Windsurf',
cursor: 'Cursor',
vim: 'Vim',
neovim: 'Neovim',
zed: 'Zed',
emacs: 'Emacs',
antigravity: 'Antigravity',
};
export function getEditorDisplayName(editor: EditorType): string {
return EDITOR_DISPLAY_NAMES[editor] || editor;
}
function isValidEditorType(editor: string): editor is EditorType {
return [
@@ -27,6 +44,7 @@ function isValidEditorType(editor: string): editor is EditorType {
'neovim',
'zed',
'emacs',
'antigravity',
].includes(editor);
}
@@ -63,6 +81,7 @@ const editorCommands: Record<
neovim: { win32: ['nvim'], default: ['nvim'] },
zed: { win32: ['zed'], default: ['zed', 'zeditor'] },
emacs: { win32: ['emacs.exe'], default: ['emacs'] },
antigravity: { win32: ['agy.cmd'], default: ['agy'] },
};
export function checkHasEditorType(editor: EditorType): boolean {
@@ -74,7 +93,11 @@ export function checkHasEditorType(editor: EditorType): boolean {
export function allowEditorTypeInSandbox(editor: EditorType): boolean {
const notUsingSandbox = !process.env['SANDBOX'];
if (['vscode', 'vscodium', 'windsurf', 'cursor', 'zed'].includes(editor)) {
if (
['vscode', 'vscodium', 'windsurf', 'cursor', 'zed', 'antigravity'].includes(
editor,
)
) {
return notUsingSandbox;
}
// For terminal-based editors like vim and emacs, allow in sandbox.
@@ -116,6 +139,7 @@ export function getDiffCommand(
case 'windsurf':
case 'cursor':
case 'zed':
case 'antigravity':
return { command, args: ['--wait', '--diff', oldPath, newPath] };
case 'vim':
case 'neovim':

View File

@@ -54,7 +54,7 @@ describe('Retry Utility Fallback Integration', () => {
// This test validates the Config's ability to store and execute the handler contract.
it('should execute the injected FallbackHandler contract correctly', async () => {
// Set up a minimal handler for testing, ensuring it matches the new type.
const fallbackHandler: FallbackModelHandler = async () => 'retry';
const fallbackHandler: FallbackModelHandler = async () => 'retry_always';
// Use the generalized setter
config.setFallbackModelHandler(fallbackHandler);
@@ -67,7 +67,7 @@ describe('Retry Utility Fallback Integration', () => {
);
// Verify it returns the correct intent
expect(result).toBe('retry');
expect(result).toBe('retry_always');
});
// This test validates the retry utility's logic for triggering the callback.

View File

@@ -11,17 +11,22 @@ import type {
RetryInfo,
} from './googleErrors.js';
import { parseGoogleApiError } from './googleErrors.js';
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
/**
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
*/
export class TerminalQuotaError extends Error {
retryDelayMs?: number;
constructor(
message: string,
override readonly cause: GoogleApiError,
retryDelayMs?: number,
) {
super(message);
this.name = 'TerminalQuotaError';
this.retryDelayMs = retryDelayMs ? retryDelayMs * 1000 : undefined;
}
}
@@ -75,6 +80,14 @@ function parseDurationInSeconds(duration: string): number | null {
*/
export function classifyGoogleError(error: unknown): unknown {
const googleApiError = parseGoogleApiError(error);
const status = googleApiError?.code ?? getErrorStatus(error);
if (status === 404) {
const message =
googleApiError?.message ||
(error instanceof Error ? error.message : 'Model not found');
return new ModelNotFoundError(message, status);
}
if (!googleApiError || googleApiError.code !== 429) {
// Fallback: try to parse the error message for a retry delay
@@ -125,6 +138,14 @@ export function classifyGoogleError(error: unknown): unknown {
}
}
}
let delaySeconds;
if (retryInfo?.retryDelay) {
const parsedDelay = parseDurationInSeconds(retryInfo.retryDelay);
if (parsedDelay) {
delaySeconds = parsedDelay;
}
}
if (errorInfo) {
// New Cloud Code API quota handling
@@ -136,23 +157,17 @@ export function classifyGoogleError(error: unknown): unknown {
];
if (validDomains.includes(errorInfo.domain)) {
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
let delaySeconds = 10; // Default retry of 10s
if (retryInfo?.retryDelay) {
const parsedDelay = parseDurationInSeconds(retryInfo.retryDelay);
if (parsedDelay) {
delaySeconds = parsedDelay;
}
}
return new RetryableQuotaError(
`${googleApiError.message}`,
googleApiError,
delaySeconds,
delaySeconds ?? 10,
);
}
if (errorInfo.reason === 'QUOTA_EXHAUSTED') {
return new TerminalQuotaError(
`${googleApiError.message}`,
googleApiError,
delaySeconds,
);
}
}
@@ -170,12 +185,12 @@ export function classifyGoogleError(error: unknown): unknown {
// 2. Check for long delays in RetryInfo
if (retryInfo?.retryDelay) {
const delaySeconds = parseDurationInSeconds(retryInfo.retryDelay);
if (delaySeconds) {
if (delaySeconds > 120) {
return new TerminalQuotaError(
`${googleApiError.message}\nSuggested retry after ${retryInfo.retryDelay}.`,
googleApiError,
delaySeconds,
);
}
// This is a retryable error with a specific delay.

View File

@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface HttpError extends Error {
status?: number;
}
/**
* Extracts the HTTP status code from an error object.
* @param error The error object.
* @returns The HTTP status code, or undefined if not found.
*/
export function getErrorStatus(error: unknown): number | undefined {
if (typeof error === 'object' && error !== null) {
if ('status' in error && typeof error.status === 'number') {
return error.status;
}
// Check for error.response.status (common in axios errors)
if (
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: unknown }).response !== null
) {
const response = (
error as { response: { status?: unknown; headers?: unknown } }
).response;
if ('status' in response && typeof response.status === 'number') {
return response.status;
}
}
}
return undefined;
}
export class ModelNotFoundError extends Error {
code: number;
constructor(message: string, code?: number) {
super(message);
this.name = 'ModelNotFoundError';
this.code = code ? code : 404;
}
}

View File

@@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApiError } from '@google/genai';
import { AuthType } from '../core/contentGenerator.js';
import type { HttpError } from './retry.js';
import { type HttpError, ModelNotFoundError } from './httpErrors.js';
import { retryWithBackoff } from './retry.js';
import { setSimulate429 } from './testUtils.js';
import { debugLogger } from './debugLogger.js';
@@ -16,6 +16,7 @@ import {
TerminalQuotaError,
RetryableQuotaError,
} from './googleQuotaErrors.js';
import { PREVIEW_GEMINI_MODEL } from '../config/models.js';
// Helper to create a mock function that fails a certain number of times
const createFailingFunction = (
@@ -433,4 +434,68 @@ describe('retryWithBackoff', () => {
);
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should trigger fallback for OAuth personal users on persistent 500 errors', async () => {
const fallbackCallback = vi.fn().mockResolvedValue('gemini-2.5-flash');
let fallbackOccurred = false;
const mockFn = vi.fn().mockImplementation(async () => {
if (!fallbackOccurred) {
const error: HttpError = new Error('Internal Server Error');
error.status = 500;
throw error;
}
return 'success';
});
const promise = retryWithBackoff(mockFn, {
maxAttempts: 3,
initialDelayMs: 100,
onPersistent429: async (authType?: string, error?: unknown) => {
fallbackOccurred = true;
return await fallbackCallback(authType, error);
},
authType: AuthType.LOGIN_WITH_GOOGLE,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(fallbackCallback).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
expect.objectContaining({ status: 500 }),
);
// 3 attempts (initial + 2 retries) fail with 500, then fallback triggers, then 1 success
expect(mockFn).toHaveBeenCalledTimes(4);
});
it('should trigger fallback for OAuth personal users on ModelNotFoundError', async () => {
const fallbackCallback = vi.fn().mockResolvedValue(PREVIEW_GEMINI_MODEL);
let fallbackOccurred = false;
const mockFn = vi.fn().mockImplementation(async () => {
if (!fallbackOccurred) {
throw new ModelNotFoundError('Requested entity was not found.', 404);
}
return 'success';
});
const promise = retryWithBackoff(mockFn, {
maxAttempts: 3,
initialDelayMs: 100,
onPersistent429: async (authType?: string, error?: unknown) => {
fallbackOccurred = true;
return await fallbackCallback(authType, error);
},
authType: AuthType.LOGIN_WITH_GOOGLE,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(fallbackCallback).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
expect.any(ModelNotFoundError),
);
expect(mockFn).toHaveBeenCalledTimes(2);
});
});

View File

@@ -14,14 +14,11 @@ import {
} from './googleQuotaErrors.js';
import { delay, createAbortError } from './delay.js';
import { debugLogger } from './debugLogger.js';
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
const FETCH_FAILED_MESSAGE =
'exception TypeError: fetch failed sending request';
export interface HttpError extends Error {
status?: number;
}
export interface RetryOptions {
maxAttempts: number;
initialDelayMs: number;
@@ -146,8 +143,12 @@ export async function retryWithBackoff<T>(
}
const classifiedError = classifyGoogleError(error);
const errorCode = getErrorStatus(error);
if (classifiedError instanceof TerminalQuotaError) {
if (
classifiedError instanceof TerminalQuotaError ||
classifiedError instanceof ModelNotFoundError
) {
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
try {
const fallbackModel = await onPersistent429(
@@ -166,7 +167,10 @@ export async function retryWithBackoff<T>(
throw classifiedError; // Throw if no fallback or fallback failed.
}
if (classifiedError instanceof RetryableQuotaError) {
const is500 =
errorCode !== undefined && errorCode >= 500 && errorCode < 600;
if (classifiedError instanceof RetryableQuotaError || is500) {
if (attempt >= maxAttempts) {
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
try {
@@ -183,13 +187,28 @@ export async function retryWithBackoff<T>(
console.warn('Model fallback failed:', fallbackError);
}
}
throw classifiedError;
throw classifiedError instanceof RetryableQuotaError
? classifiedError
: error;
}
if (classifiedError instanceof RetryableQuotaError) {
console.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
await delay(classifiedError.retryDelayMs, signal);
continue;
} else {
const errorStatus = getErrorStatus(error);
logRetryAttempt(attempt, error, errorStatus);
// Exponential backoff with jitter for non-quota errors
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
await delay(delayWithJitter, signal);
currentDelay = Math.min(maxDelayMs, currentDelay * 2);
continue;
}
console.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
await delay(classifiedError.retryDelayMs, signal);
continue;
}
// Generic retry logic for other errors
@@ -214,33 +233,6 @@ export async function retryWithBackoff<T>(
throw new Error('Retry attempts exhausted');
}
/**
* Extracts the HTTP status code from an error object.
* @param error The error object.
* @returns The HTTP status code, or undefined if not found.
*/
export function getErrorStatus(error: unknown): number | undefined {
if (typeof error === 'object' && error !== null) {
if ('status' in error && typeof error.status === 'number') {
return error.status;
}
// Check for error.response.status (common in axios errors)
if (
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: unknown }).response !== null
) {
const response = (
error as { response: { status?: unknown; headers?: unknown } }
).response;
if ('status' in response && typeof response.status === 'number') {
return response.status;
}
}
}
return undefined;
}
/**
* Logs a message for a retry attempt when using exponential backoff.
* @param attempt The current attempt number.