diff --git a/docs/cli/commands.md b/docs/cli/commands.md index e34745bc56..c5ba8a6d82 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -165,13 +165,15 @@ Slash commands provide meta-level control over the CLI itself. [settings](../get-started/configuration.md). See [Checkpointing documentation](../cli/checkpointing.md) for more details. -- **`/settings`** +- [**`/settings`**](./settings.md) - **Description:** Open the settings editor to view and modify Gemini CLI settings. - **Details:** This command provides a user-friendly interface for changing settings that control the behavior and appearance of Gemini CLI. It is equivalent to manually editing the `.gemini/settings.json` file, but with - validation and guidance to prevent errors. + validation and guidance to prevent errors. See the + [settings documentation](./settings.md) for a full list of available + settings. - **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, diff --git a/docs/cli/index.md b/docs/cli/index.md index 6344be4eb9..464647fca7 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -7,11 +7,14 @@ overview of Gemini CLI, see the [main documentation page](../index.md). ## Basic features - **[Commands](./commands.md):** A reference for all built-in slash commands - (e.g., `/help`, `/chat`, `/tools`). - **[Custom Commands](./custom-commands.md):** Create your own commands and shortcuts for frequently used prompts. - **[Headless Mode](./headless.md):** Use Gemini CLI programmatically for scripting and automation. +- **[Model Selection](./model.md):** Configure the Gemini AI model used by the + CLI. +- **[Settings](./settings.md):** Configure various aspects of the CLI's behavior + and appearance. - **[Themes](./themes.md):** Customizing the CLI's appearance with different themes. - **[Keyboard Shortcuts](./keyboard-shortcuts.md):** A reference for all diff --git a/docs/cli/model.md b/docs/cli/model.md index c7a9da9db6..305cbce1ed 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -14,14 +14,28 @@ Use the following command in Gemini CLI: Running this command will open a dialog with your model options: -- **Auto (recommended):** Let the system choose the best model for your task. - Typically, this is the best option. -- **Pro:** For complex tasks that require deep reasoning and creativity. The Pro - model may take longer to return a response. -- **Flash:** For tasks that need a balance of speed and reasoning. The Flash - model will usually return a faster response than Pro. -- **Flash-Lite:** For simple tasks that need to be done quickly. The Flash-Lite - model is typically the fastest. +| Option | Description | Models | +| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Auto (recommended) | Let the system choose the best model for your task. | gemini-3-pro-preview (if enabled), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite | +| Pro | For complex tasks that require deep reasoning and creativity. | gemini-3-pro-preview (if enabled), gemini-2.5-pro | +| Flash | For tasks that need a balance of speed and reasoning. | gemini-2.5-flash | +| Flash-Lite | For simple tasks that need to be done quickly. | gemini-2.5-flash-lite | + +### Gemini 3 Pro and Preview Features + +Note: Gemini 3 is not currently available on all account types. To learn more +about Gemini 3 access, refer to +[Gemini 3 Pro on Gemini CLI](../get-started/gemini-3). + +To enable Gemini 3 Pro (if available), enable +[**Preview features** by using the `settings` command](../cli/settings). Once +enabled, Gemini CLI will attempt to use Gemini 3 Pro when you select **Auto** or +**Pro**. Both **Auto** and **Pro** will try to use Gemini 3 Pro before falling +back to Gemini 2.5 Pro. + +You can also use the `--model` flag to specify a particular Gemini model on +startup. For more details, refer to the +[configuration documentation](./configuration.md). Changes to these settings will be applied to all subsequent interactions with Gemini CLI. diff --git a/docs/cli/settings.md b/docs/cli/settings.md new file mode 100644 index 0000000000..9c5784108d --- /dev/null +++ b/docs/cli/settings.md @@ -0,0 +1,113 @@ +# Gemini CLI Settings (`/settings` Command) + +Control your Gemini CLI experience with the `/settings` command. The `/settings` +command opens a dialog to view and edit all your Gemini CLI settings, including +your UI experience, keybindings, and accessibility features. + +Your Gemini CLI settings are stored in a `settings.json` file. In addition to +using the `/settings` command, you can also edit them in one of the following +locations: + +- **User settings**: `~/.gemini/settings.json` +- **Workspace settings**: `your-project/.gemini/settings.json` + +Note: Workspace settings override user settings. + +## Settings reference + +Here is a list of all the available settings, grouped by category and ordered as +they appear in the UI. + +### General + +| UI Label | Setting | Description | Default | +| ------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- | ----------- | +| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` | +| Vim Mode | `general.vimMode` | Enable Vim keybindings. | `false` | +| Disable Auto Update | `general.disableAutoUpdate` | Disable automatic updates. | `false` | +| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Session Retention | `general.sessionRetention` | Settings for automatic session cleanup. This feature is disabled by default. | `undefined` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup. | `false` | + +### Output + +| UI Label | Setting | Description | Default | +| ------------- | --------------- | ------------------------------------------------------ | ------- | +| Output Format | `output.format` | The format of the CLI output. Can be `text` or `json`. | `text` | + +### UI + +| UI Label | Setting | Description | Default | +| ------------------------------ | ---------------------------------------- | -------------------------------------------------------------------- | ------- | +| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar. | `false` | +| Show Status in Title | `ui.showStatusInTitle` | Show Gemini CLI status and thoughts in the terminal window title. | `false` | +| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI. | `false` | +| Hide Banner | `ui.hideBanner` | Hide the application banner. | `false` | +| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | +| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` | +| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | +| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` | +| Hide Footer | `ui.hideFooter` | Hide the footer from the UI. | `false` | +| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI. | `false` | +| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `false` | +| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | +| Use Full Width | `ui.useFullWidth` | Use the entire width of the terminal for output. | `true` | +| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `true` | +| Disable Loading Phrases | `ui.accessibility.disableLoadingPhrases` | Disable loading phrases for accessibility. | `false` | +| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible. | `false` | + +### IDE + +| UI Label | Setting | Description | Default | +| -------- | ------------- | ---------------------------- | ------- | +| IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` | + +### Model + +| UI Label | Setting | Description | Default | +| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- | +| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | +| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.2` | +| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | + +### Context + +| UI Label | Setting | Description | Default | +| ------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | +| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | +| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | +| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | +| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | +| Disable Fuzzy Search | `context.fileFiltering.disableFuzzySearch` | Disable fuzzy search when searching for files. | `false` | + +### Tools + +| UI Label | Setting | Description | Default | +| -------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------- | +| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | +| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | +| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` | +| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | +| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` | +| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `10000` | +| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `100` | +| Enable Message Bus Integration | `tools.enableMessageBusIntegration` | Enable policy-based tool confirmation via message bus integration. | `false` | + +### Security + +| UI Label | Setting | Description | Default | +| -------------------------- | ------------------------------ | -------------------------------------------------- | ------- | +| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | +| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | +| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` | + +### Experimental + +| UI Label | Setting | Description | Default | +| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | ------- | +| Use Model Router | `experimental.useModelRouter` | Enable model routing to route requests to the best model based on complexity. | `true` | +| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` | +| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 3bd6bda748..827361a4da 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -98,6 +98,11 @@ their corresponding top-level category object in your `settings.json` file. #### `general` +- **`general.previewFeatures`** (boolean): + - **Description:** Enable preview features (e.g., preview models). + - **Default:** `false` + - **Requires restart:** Yes + - **`general.preferredEditor`** (string): - **Description:** The preferred editor to open files in. - **Default:** `undefined` diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md new file mode 100644 index 0000000000..dd1b19c245 --- /dev/null +++ b/docs/get-started/gemini-3.md @@ -0,0 +1,107 @@ +# Gemini 3 Pro on Gemini CLI (Join the Waitlist) + +We’re excited to bring Gemini 3 Pro to Gemini CLI. For Google AI Ultra +subscribers and paid Gemini and Vertex API key holders, Gemini 3 Pro is already +available and ready to enable. For everyone else, we're gradually expanding +access through a waitlist. Sign up for the waitlist now to access Gemini 3 Pro +once approved. + +Note: Please wait until you have been approved to use Gemini 3 Pro to enable +**Preview Features**. If enabled early, the CLI will fallback to Gemini 2.5 Pro. + +## Do I need to join the waitlist? + +The following users will be **automatically granted access** to Gemini 3 Pro on +Gemini CLI: + +- Google AI Ultra subscribers. +- Gemini API key users + [with access to Gemini 3](https://ai.google.dev/gemini-api/docs/rate-limits). +- Vertex API key users + [with access to Gemini 3](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/quotas). + +For **Gemini Code Assist Enterprise users**, access is coming soon. + +Users not automatically granted access through one of these account types will +need to join the waitlist. This includes Google AI Pro, Gemini Code Assist +standard, and free tier users. + +Note: Whether you’re automatically granted access or accepted from the waitlist, +you’ll still need to enable Gemini 3 Pro +[using the `/settings` command](../cli/settings). + +## How to join the waitlist + +Users not automatically granted access will need to join the waitlist. Follow +these instructions to sign up: + +- Install Gemini CLI. +- Authenticate using the **Login with Google** option. You’ll see a banner that + says “Gemini 3 is now available.” If you do not see this banner, update your + installation of Gemini CLI to the most recent version. +- Fill out this Google form: + [Access Gemini 3 in Gemini CLI](https://goo.gle/geminicli-waitlist-signup). + Provide the email address of the account you used to authenticate with Gemini + CLI. + +Users will be onboarded in batches, subject to availability. When you’ve been +granted access to Gemini 3 Pro, you’ll receive an acceptance email to your +submitted email address. + +## How to use Gemini 3 Pro with Gemini CLI + +Once you receive your acceptance email–or if you are automatically granted +access–you still need to enable Gemini 3 Pro within Gemini CLI. + +To enable Gemini 3 Pro, use the `/settings` command in Gemini CLI and set +**Preview Features** to `true`. + +For more information, see [Gemini CLI Settings](../cli/settings). + +### Usage limits and fallback + +Gemini CLI will tell you when you reach your Gemini 3 Pro daily usage limit. +When you encounter that limit, you’ll be given the option to switch to Gemini +2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage +limit resets and Gemini 3 Pro can be used again. + +Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see +a message prompting fallback to Gemini 2.5 Flash. + +### Capacity errors + +There may be times when the Gemini 3 Pro model is overloaded. When that happens, +Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro +or fallback to Gemini 2.5 Pro. + +Note: The **Keep trying** option uses exponential backoff, in which Gemini CLI +waits longer between each retry, when the system is busy. If the retry doesn't +happen immediately, please wait a few minutes for the request to process. + +## Model selection & routing types + +When using Gemini CLI, you may want to control how your requests are routed +between models. By default, Gemini CLI uses **Auto** routing. + +When using Gemini 3 Pro, you may want to use Auto routing or Pro routing to +manage your usage limits: + +- **Auto routing:** Auto routing first determines whether a prompt involves a + complex or simple operation. For simple prompts, it will automatically use + Gemini 2.5 Flash. For complex prompts, if Gemini 3 Pro is enabled, it will use + Gemini 3 Pro; otherwise, it will use Gemini 2.5 Pro. +- **Pro routing:** If you want to ensure your task is processed by the most + capable model, use `/model` and select **Pro**. Gemini CLI will prioritize the + most capable model available, including Gemini 3 Pro if it has been enabled. + +To learn more about selecting a model and routing, refer to +[Gemini CLI Model Selection](../cli/model.md). + +## Need help? + +If you need help, we recommend searching for an existing +[GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you +cannot find a GitHub issue that matches your concern, you can +[create a new issue](https://github.com/google-gemini/gemini-cli/issues/new/choose). +For comments and feedback, consider opening a +[GitHub discussion](https://github.com/google-gemini/gemini-cli/discussions). diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 30d5bbdad5..5139b7423f 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -63,3 +63,4 @@ To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md). - Find out more about [Gemini CLI's tools](../tools/index.md). - Review [Gemini CLI's commands](../cli/commands.md). +- Learn how to [get started with Gemini 3](./gemini-3.md). diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index 4bb4982938..67e751d2e6 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -4,8 +4,8 @@ Gemini CLI can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing. -Currently, the only supported IDE is -[Visual Studio Code](https://code.visualstudio.com/) and other editors that +Currently, the supported IDEs are [Antigravity](https://antigravity.google), +[Visual Studio Code](https://code.visualstudio.com/), and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](./ide-companion-spec.md). @@ -191,7 +191,7 @@ messages and how to resolve them. - **Cause:** You are running Gemini CLI in a terminal or environment that is not a supported IDE. - **Solution:** Run Gemini CLI from the integrated terminal of a supported - IDE, like VS Code. + IDE, like Antigravity or VS Code. - **Message:** `No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.` diff --git a/docs/index.md b/docs/index.md index 042c9ae24b..e7e05ab775 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,12 +28,18 @@ This documentation is organized into the following sections: - **[Configuration](./get-started/configuration.md):** Information on configuring the CLI. - **[Examples](./get-started/examples.md):** Example usage of Gemini CLI. +- **[Get started with Gemini 3](./get-started/gemini-3.md):** Learn how to + enable and use Gemini 3. ### CLI - **[CLI overview](./cli/index.md):** Overview of the command-line interface. - **[Commands](./cli/commands.md):** Description of available CLI commands. - **[Enterprise](./cli/enterprise.md):** Gemini CLI for enterprise. +- **[Model Selection](./cli/model.md):** Select the model used to process your + commands with `/model`. +- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's + behavior and appearance with `/settings`. - **[Themes](./cli/themes.md):** Themes for Gemini CLI. - **[Token Caching](./cli/token-caching.md):** Token caching and optimization. - **[Tutorials](./cli/tutorials.md):** Tutorials for Gemini CLI. diff --git a/docs/sidebar.json b/docs/sidebar.json index a4d0ba0e75..1a93aacf54 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -23,6 +23,10 @@ "label": "Gemini CLI Quickstart", "slug": "docs/get-started" }, + { + "label": "Gemini 3 Pro on Gemini CLI", + "slug": "docs/get-started/gemini-3" + }, { "label": "Authentication", "slug": "docs/get-started/authentication" @@ -80,6 +84,10 @@ "label": "Sandbox", "slug": "docs/cli/sandbox" }, + { + "label": "Settings", + "slug": "docs/cli/settings" + }, { "label": "Telemetry", "slug": "docs/cli/telemetry" diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 507ffbccfd..e83939420c 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -308,6 +308,7 @@ export class TestRig { // Nightly releases sometimes becomes out of sync with local code and // triggers auto-update, which causes tests to fail. disableAutoUpdate: true, + previewFeatures: false, }, telemetry: { enabled: true, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c26d7ba2c4..cca736179f 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 421d51a7f8..fe2503029b 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -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, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 38e3d84fca..462c1592ed 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index de9213a3f6..3e28a94174 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -72,6 +72,10 @@ describe('App', () => { }, history: [], pendingHistoryItems: [], + bannerData: { + defaultText: 'Mock Banner Text', + warningText: '', + }, }; const mockConfig = makeFakeConfig(); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index bcff805fba..acbf175d35 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -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(); + }); + }); + }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1d29f72a80..22e4537cec 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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, ], ); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 9ce4086809..3cb82a201b 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -121,11 +121,12 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{ async function setIdeModeAndSyncConnection( config: Config, value: boolean, + options: { logToConsole?: boolean } = {}, ): Promise { 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 => { ({ 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 => { ); // 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 diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index 2d6b880318..c1f488d902 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -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, }, diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx new file mode 100644 index 0000000000..a6f9103f18 --- /dev/null +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -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('', () => { + 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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(, { + 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( + , + { config: mockConfig, uiState }, + ); + + expect(lastFrame()).not.toContain('First line\\nSecond line'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 9b6c5fc3bd..d66fad4a95 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -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 ( {!(settings.merged.ui?.hideBanner || config.getScreenReader()) && ( -
+ <> +
+ {bannerVisible && unescapedBannerText && ( + + )} + )} {!(settings.merged.ui?.hideTips || config.getScreenReader()) && ( diff --git a/packages/cli/src/ui/components/Banner.tsx b/packages/cli/src/ui/components/Banner.tsx new file mode 100644 index 0000000000..8003abc949 --- /dev/null +++ b/packages/cli/src/ui/components/Banner.tsx @@ -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 ( + + + {bannerText} + + + ); +}; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 15558082a6..b4559a997f 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -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 || []} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d81690a89a..f603b4a18f 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -53,8 +53,13 @@ export const DialogManager = ({ if (uiState.proQuotaRequest) { return ( ); } diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index 55434fdf9d..ed15376de1 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -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 { diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index c7c1a35268..4313d0ced6 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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(); + + await act(async () => { + stdin.write('\x0C'); // Ctrl+L + }); + + await waitFor(() => { + expect(props.setBannerVisible).toHaveBeenCalledWith(false); + }); + unmount(); + }); + describe('cursor-based completion trigger', () => { it.each([ { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 629b50a7a9..cc5af33d61 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ streamingState, popAllMessages, suggestionsPosition = 'below', + setBannerVisible, }) => { const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); @@ -525,6 +527,7 @@ export const InputPrompt: React.FC = ({ } if (keyMatchers[Command.CLEAR_SCREEN](key)) { + setBannerVisible(false); onClearScreen(); return; } @@ -819,6 +822,7 @@ export const InputPrompt: React.FC = ({ commandSearchCompletion, kittyProtocol.supported, tryLoadQueuedMessages, + setBannerVisible, ], ); diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 447f84302e..140ed9248e 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -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('', () => { 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('', () => { 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('', () => { 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('', () => { 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( - + , ); 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( diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 73c36089ae..e61f34bf6d 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -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 ( Select Model + + + + {header} + + {subheader} + + - + - {'> To use a specific Gemini model on startup, use the --model flag.'} + {'To use a specific Gemini model on startup, use the --model flag.'} diff --git a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx index 949f0d6077..51c3de02cd 100644 --- a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx +++ b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx @@ -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( - {}} />, - ); + describe('for flash model failures', () => { + it('should render "Keep trying" and "Stop" options', () => { + const { unmount } = render( + , + ); - 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( - , - ); + 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( + , + ); - // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( - , - ); + describe('onChoice handling', () => { + it('should call onChoice with the selected value', () => { + const { unmount } = render( + , + ); - // 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( + , + ); + + 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( + , + ); + + const output = lastFrame(); + expect(output).toContain( + 'Note: You can always use /model to select a different option.', + ); + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/ProQuotaDialog.tsx b/packages/cli/src/ui/components/ProQuotaDialog.tsx index 7fc90ac808..34de2747c6 100644 --- a/packages/cli/src/ui/components/ProQuotaDialog.tsx +++ b/packages/cli/src/ui/components/ProQuotaDialog.tsx @@ -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 ( - + + + {message} + - + - 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.'} ); diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 884bc218ea..19dc46499e 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -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(['general.disableAutoUpdate']), + new Set(['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 diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap new file mode 100644 index 0000000000..6da8b523f2 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap @@ -0,0 +1,118 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > 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[` > 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[` > 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[` > 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[` > 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[` > 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." +`; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 4e56e5bde3..04ef7abaf9 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -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 │ diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 2504ee6ad1..e276f38bea 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -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; handleApiKeyCancel: () => void; + setBannerVisible: (visible: boolean) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 9a6f536c66..907a374cb1 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -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; } diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts index ae83ae7904..5a9b2e3147 100644 --- a/packages/cli/src/ui/editors/editorSettingsManager.ts +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -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 = { - cursor: 'Cursor', - emacs: 'Emacs', - neovim: 'Neovim', - vim: 'Vim', - vscode: 'VS Code', - vscodium: 'VSCodium', - windsurf: 'Windsurf', - zed: 'Zed', -}; - class EditorSettingsManager { private readonly availableEditors: EditorDisplay[]; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 595498b2e5..76539e5a2f 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => { }, new Map(), // extensionsUpdateState true, // isConfigInitialized + vi.fn(), // setBannerVisible vi.fn(), // setCustomDialog ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 3484436fa4..fbceb3f664 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -77,6 +77,7 @@ export const useSlashCommandProcessor = ( actions: SlashCommandProcessorActions, extensionsUpdateState: Map, 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, ], ); diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx index da532c4d01..012daa4c35 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -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(); const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim']; + const displayNames: Record = { + 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), ); diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts index aebe9fe642..962d085f12 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.ts @@ -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(), ); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index 968e41e0e2..13c0fd838b 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -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; + 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; 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; + 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; 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; + 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.`, + ); + }); }); }); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index 63f9fe8d29..87f768d69c 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -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)}.`; +} diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts new file mode 100644 index 0000000000..6e4a8810b0 --- /dev/null +++ b/packages/cli/src/utils/persistentState.ts @@ -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( + key: K, + ): PersistentStateData[K] | undefined { + return this.load()[key]; + } + + set( + key: K, + value: PersistentStateData[K], + ): void { + this.load(); // ensure loaded + this.cache![key] = value; + this.save(); + } +} + +export const persistentState = new PersistentState(); diff --git a/packages/core/index.ts b/packages/core/index.ts index 4b2b0c38ff..eff91f2228 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -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'; diff --git a/packages/core/src/code_assist/experiments/flagNames.ts b/packages/core/src/code_assist/experiments/flagNames.ts index 5425a8e833..5fa70e7c20 100644 --- a/packages/core/src/code_assist/experiments/flagNames.ts +++ b/packages/core/src/code_assist/experiments/flagNames.ts @@ -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 = diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3b66301319..97ab851ade 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9953c88741..c7d1f60edd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 | 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 { + await this.ensureExperimentsLoaded(); + return ( + this.experiments?.flags[ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES] + ?.stringValue ?? '' + ); + } + + async getBannerTextCapacityIssues(): Promise { + await this.ensureExperimentsLoaded(); + return ( + this.experiments?.flags[ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES] + ?.stringValue ?? '' + ); + } + private async ensureExperimentsLoaded(): Promise { if (!this.experimentsPromise) { return; diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts index 8c790dd1ae..48a6f80030 100644 --- a/packages/core/src/config/models.test.ts +++ b/packages/core/src/config/models.test.ts @@ -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); + }); + }); }); }); diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index 9add9fa736..f3afad61bc 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -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); +} diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 746c20d134..f66f4d5e9f 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -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']), }), diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index aadc847756..a563bc4111 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -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( diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 906c04bf84..9e99c5617d 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -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); + }); + }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index cf3b0df50c..cdfe9db47c 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -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> { 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((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> { + 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; } diff --git a/packages/core/src/fallback/handler.test.ts b/packages/core/src/fallback/handler.test.ts index 133ac7f3f2..6f8e7de99f 100644 --- a/packages/core/src/fallback/handler.test.ts +++ b/packages/core/src/fallback/handler.test.ts @@ -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 => ({ 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(); + }); }); diff --git a/packages/core/src/fallback/handler.ts b/packages/core/src/fallback/handler.ts index 77bff07798..6da44c8769 100644 --- a/packages/core/src/fallback/handler.ts +++ b/packages/core/src/fallback/handler.ts @@ -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. + } +} diff --git a/packages/core/src/fallback/types.ts b/packages/core/src/fallback/types.ts index 53e9646245..b4f21e515a 100644 --- a/packages/core/src/fallback/types.ts +++ b/packages/core/src/fallback/types.ts @@ -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) diff --git a/packages/core/src/ide/detect-ide.test.ts b/packages/core/src/ide/detect-ide.test.ts index 66993314c5..96bda36aad 100644 --- a/packages/core/src/ide/detect-ide.test.ts +++ b/packages/core/src/ide/detect-ide.test.ts @@ -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', () => { diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index 951abbf710..c7945593b2 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -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; } diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 7190228eb0..f9b105372b 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -137,11 +137,12 @@ export class IdeClient { this.trustChangeListeners.delete(listener); } - async connect(): Promise { + async connect(options: { logToConsole?: boolean } = {}): Promise { + 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, ); } diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts index ff2afce27f..ff19e6be2b 100644 --- a/packages/core/src/ide/ide-installer.test.ts +++ b/packages/core/src/ide/ide-installer.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/ide/ide-installer.ts b/packages/core/src/ide/ide-installer.ts index 62d5382b4c..f1fd50fa4d 100644 --- a/packages/core/src/ide/ide-installer.ts +++ b/packages/core/src/ide/ide-installer.ts @@ -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; } @@ -25,15 +21,15 @@ export interface InstallResult { message: string; } -async function findVsCodeCommand( +async function findCommand( + command: string, platform: NodeJS.Platform = process.platform, ): Promise { // 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 { @@ -147,6 +146,59 @@ class VsCodeInstaller implements IdeInstaller { } } +class AntigravityInstaller implements IdeInstaller { + constructor( + readonly ideInfo: IdeInfo, + readonly platform = process.platform, + ) {} + + async install(): Promise { + 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; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a867354c64..67e80a7692 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/routing/modelRouterService.test.ts b/packages/core/src/routing/modelRouterService.test.ts index 39199a688c..da1b659d7c 100644 --- a/packages/core/src/routing/modelRouterService.test.ts +++ b/packages/core/src/routing/modelRouterService.test.ts @@ -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); + }); }); }); diff --git a/packages/core/src/routing/modelRouterService.ts b/packages/core/src/routing/modelRouterService.ts index 3898ff4100..901c565cde 100644 --- a/packages/core/src/routing/modelRouterService.ts +++ b/packages/core/src/routing/modelRouterService.ts @@ -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, diff --git a/packages/core/src/routing/strategies/classifierStrategy.test.ts b/packages/core/src/routing/strategies/classifierStrategy.test.ts index 56cd9b5d36..8d89b4b375 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.test.ts @@ -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(), diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 6d92363a65..17a35b65c0 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -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 { 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, diff --git a/packages/core/src/routing/strategies/fallbackStrategy.test.ts b/packages/core/src/routing/strategies/fallbackStrategy.test.ts index dfda72d4ca..8723c483f2 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.test.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.test.ts @@ -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( diff --git a/packages/core/src/routing/strategies/fallbackStrategy.ts b/packages/core/src/routing/strategies/fallbackStrategy.ts index aef01743aa..d61c92a5cb 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.ts @@ -30,6 +30,7 @@ export class FallbackStrategy implements RoutingStrategy { const effectiveModel = getEffectiveModel( isInFallbackMode, config.getModel(), + config.getPreviewFeatures(), ); return { model: effectiveModel, diff --git a/packages/core/src/routing/strategies/overrideStrategy.test.ts b/packages/core/src/routing/strategies/overrideStrategy.test.ts index bc80a99ad3..f1ec54098d 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.test.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.test.ts @@ -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); diff --git a/packages/core/src/routing/strategies/overrideStrategy.ts b/packages/core/src/routing/strategies/overrideStrategy.ts index 06d6b7f3dd..79050c01e6 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.ts @@ -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, diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index acc9e1a1a9..27a2456b7f 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -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 () => { diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 896a348255..d5624ea580 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -15,7 +15,24 @@ export type EditorType = | 'vim' | 'neovim' | 'zed' - | 'emacs'; + | 'emacs' + | 'antigravity'; + +export const EDITOR_DISPLAY_NAMES: Record = { + 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': diff --git a/packages/core/src/utils/flashFallback.test.ts b/packages/core/src/utils/flashFallback.test.ts index a3f08f5df6..522f5230f1 100644 --- a/packages/core/src/utils/flashFallback.test.ts +++ b/packages/core/src/utils/flashFallback.test.ts @@ -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. diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index f09d8b2474..9cd318ade8 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -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. diff --git a/packages/core/src/utils/httpErrors.ts b/packages/core/src/utils/httpErrors.ts new file mode 100644 index 0000000000..a29732737b --- /dev/null +++ b/packages/core/src/utils/httpErrors.ts @@ -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; + } +} diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index e0297e8903..2af05fb148 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -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); + }); }); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index edb8f9bb85..d196a00283 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -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( } 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( 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( 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( 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. diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 38dc843d5a..16a4d550db 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -23,6 +23,13 @@ "default": {}, "type": "object", "properties": { + "previewFeatures": { + "title": "Preview Features (e.g., models)", + "description": "Enable preview features (e.g., preview models).", + "markdownDescription": "Enable preview features (e.g., preview models).\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "preferredEditor": { "title": "Preferred Editor", "description": "The preferred editor to open files in.",