feat: launch Gemini 3 in Gemini CLI 🚀🚀🚀 (#13279)

Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Aishanee Shah <aishaneeshah@gmail.com>
Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: joshualitt <joshualitt@google.com>
Co-authored-by: Jenna Inouye <jinouye@google.com>
This commit is contained in:
Shreya Keshive
2025-11-18 10:51:49 -05:00
committed by GitHub
parent 670f13cff8
commit 56f9e597c6
79 changed files with 3148 additions and 605 deletions
+4 -2
View File
@@ -158,13 +158,15 @@ Slash commands provide meta-level control over the CLI itself.
option or configured via [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,
+4 -1
View File
@@ -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
+22 -8
View File
@@ -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.
+113
View File
@@ -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` |
+5
View File
@@ -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`
+107
View File
@@ -0,0 +1,107 @@
# Gemini 3 Pro on Gemini CLI (Join the Waitlist)
Were 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 youre automatically granted access or accepted from the waitlist,
youll 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. Youll 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 youve been
granted access to Gemini 3 Pro, youll receive an acceptance email to your
submitted email address.
## How to use Gemini 3 Pro with Gemini CLI
Once you receive your acceptance emailor if you are automatically granted
accessyou 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, youll be given the option to switch to Gemini
2.5 Pro, upgrade for higher limits, or stop. Youll 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, youll 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).
+1
View File
@@ -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).
+3 -3
View File
@@ -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.`
+6
View File
@@ -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.
+8
View File
@@ -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"
+1
View File
@@ -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,
+1
View File
@@ -583,6 +583,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,
@@ -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,
+10
View File
@@ -160,6 +160,15 @@ const SETTINGS_SCHEMA = {
description: 'General application settings.',
showInDialog: false,
properties: {
previewFeatures: {
type: 'boolean',
label: 'Preview Features (e.g., models)',
category: 'General',
requiresRestart: true,
default: false,
description: 'Enable preview features (e.g., preview models).',
showInDialog: true,
},
preferredEditor: {
type: 'string',
label: 'Preferred Editor',
@@ -251,6 +260,7 @@ const SETTINGS_SCHEMA = {
category: 'General',
requiresRestart: false,
default: undefined as SessionRetentionSettings | undefined,
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
+4
View File
@@ -72,6 +72,10 @@ describe('App', () => {
},
history: [],
pendingHistoryItems: [],
bannerData: {
defaultText: 'Mock Banner Text',
warningText: '',
},
};
const mockConfig = makeFakeConfig();
+18
View File
@@ -25,6 +25,7 @@ import {
CoreEvent,
type UserFeedbackPayload,
type ResumedSessionData,
AuthType,
} from '@google/gemini-cli-core';
// Mock coreEvents
@@ -1796,4 +1797,21 @@ describe('AppContainer State Management', () => {
unmount();
});
});
describe('Banner Text', () => {
it('should render placeholder banner text for USE_GEMINI auth type', async () => {
const config = makeFakeConfig();
vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_GEMINI,
apiKey: 'fake-key',
});
const { unmount } = renderAppContainer();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
await vi.waitFor(() => {
expect(capturedUIState.bannerData.defaultText).toBeDefined();
unmount();
});
});
});
});
+47
View File
@@ -176,6 +176,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) =>
@@ -585,6 +589,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
slashCommandActions,
extensionsUpdateStateInternal,
isConfigInitialized,
setBannerVisible,
);
const performMemoryRefresh = useCallback(async () => {
@@ -1290,6 +1295,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,
@@ -1377,6 +1414,11 @@ Logging in with Google... Please restart Gemini CLI to continue.
showDebugProfiler,
copyModeEnabled,
warningMessage,
bannerData: {
defaultText: defaultBannerText,
warningText: warningBannerText,
},
bannerVisible,
}),
[
isThemeDialogOpen,
@@ -1463,6 +1505,9 @@ Logging in with Google... Please restart Gemini CLI to continue.
authState,
copyModeEnabled,
warningMessage,
defaultBannerText,
warningBannerText,
bannerVisible,
],
);
@@ -1499,6 +1544,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
}),
[
handleThemeSelect,
@@ -1527,6 +1573,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
],
);
+6 -3
View File
@@ -121,11 +121,12 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
async function setIdeModeAndSyncConnection(
config: Config,
value: boolean,
options: { logToConsole?: boolean } = {},
): Promise<void> {
config.setIdeMode(value);
const ideClient = await IdeClient.getInstance();
if (value) {
await ideClient.connect();
await ideClient.connect(options);
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
} else {
await ideClient.disconnect();
@@ -144,7 +145,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
({
type: 'message',
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks.`,
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
}) as const,
};
}
@@ -212,7 +213,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
);
// Poll for up to 5 seconds for the extension to activate.
for (let i = 0; i < 10; i++) {
await setIdeModeAndSyncConnection(context.services.config!, true);
await setIdeModeAndSyncConnection(context.services.config!, true, {
logToConsole: false,
});
if (
ideClient.getConnectionStatus().status ===
IDEConnectionStatus.Connected
@@ -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,
},
@@ -0,0 +1,189 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
const persistentStateMock = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
}));
vi.mock('../../utils/persistentState.js', () => ({
persistentState: persistentStateMock,
}));
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: () => null,
}));
describe('<AppHeader />', () => {
beforeEach(() => {
vi.clearAllMocks();
persistentStateMock.get.mockReturnValue(0);
});
it('should render the banner with default text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render the banner with warning text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: 'There are capacity issues',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('There are capacity issues');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the banner when no flags are set', () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: '',
warningText: '',
},
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('Banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render the banner when previewFeatures is disabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: false });
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the banner when previewFeatures is enabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: true });
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the default banner if shown count is 5 or more', () => {
persistentStateMock.get.mockReturnValue(5);
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should increment the shown count when default banner is displayed', () => {
persistentStateMock.get.mockReturnValue(0);
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
},
};
const { unmount } = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
uiState,
});
expect(persistentStateMock.set).toHaveBeenCalledWith(
'defaultBannerShownCount',
1,
);
unmount();
});
it('should render banner text with unescaped newlines', () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: 'First line\\nSecond line',
warningText: '',
},
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{ config: mockConfig, uiState },
);
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
});
});
+40 -1
View File
@@ -10,6 +10,11 @@ import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Banner } from './Banner.js';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import { persistentState } from '../../utils/persistentState.js';
import { useState, useEffect, useRef } from 'react';
interface AppHeaderProps {
version: string;
@@ -18,12 +23,46 @@ interface AppHeaderProps {
export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly } = useUIState();
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
const [defaultBannerShownCount] = useState(
() => persistentState.get('defaultBannerShownCount') || 0,
);
const { defaultText, warningText } = bannerData;
const showDefaultBanner =
warningText === '' &&
!config.getPreviewFeatures() &&
defaultBannerShownCount < 5;
const bannerText = showDefaultBanner ? defaultText : warningText;
const unescapedBannerText = bannerText.replace(/\\n/g, '\n');
const defaultColor = Colors.AccentBlue;
const fontColor = warningText === '' ? defaultColor : theme.status.warning;
const hasIncrementedRef = useRef(false);
useEffect(() => {
if (showDefaultBanner && defaultText && !hasIncrementedRef.current) {
hasIncrementedRef.current = true;
const current = persistentState.get('defaultBannerShownCount') || 0;
persistentState.set('defaultBannerShownCount', current + 1);
}
}, [showDefaultBanner, defaultText]);
return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
<>
<Header version={version} nightly={nightly} />
{bannerVisible && unescapedBannerText && (
<Banner
width={mainAreaWidth}
bannerText={unescapedBannerText}
color={fontColor}
/>
)}
</>
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />
+33
View File
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { theme } from '../semantic-colors.js';
interface BannerProps {
bannerText: string;
color: string;
width: number;
}
export const Banner = ({ bannerText, color, width }: BannerProps) => {
const gradient = theme.ui.gradient;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={color}
width={width}
paddingLeft={1}
paddingRight={1}
>
<Gradient colors={gradient}>
<Text>{bannerText}</Text>
</Gradient>
</Box>
);
};
@@ -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 || []}
@@ -53,8 +53,13 @@ export const DialogManager = ({
if (uiState.proQuotaRequest) {
return (
<ProQuotaDialog
failedModel={uiState.proQuotaRequest.failedModel}
fallbackModel={uiState.proQuotaRequest.fallbackModel}
message={uiState.proQuotaRequest.message}
isTerminalQuotaError={uiState.proQuotaRequest.isTerminalQuotaError}
isModelNotFoundError={!!uiState.proQuotaRequest.isModelNotFoundError}
onChoice={uiActions.handleProQuotaChoice}
userTier={uiState.userTier}
/>
);
}
@@ -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 {
@@ -232,6 +232,7 @@ describe('InputPrompt', () => {
focus: true,
setQueueErrorMessage: vi.fn(),
streamingState: StreamingState.Idle,
setBannerVisible: vi.fn(),
};
});
@@ -782,6 +783,19 @@ describe('InputPrompt', () => {
unmount();
});
it('should call setBannerVisible(false) when clear screen key is pressed', async () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await act(async () => {
stdin.write('\x0C'); // Ctrl+L
});
await waitFor(() => {
expect(props.setBannerVisible).toHaveBeenCalledWith(false);
});
unmount();
});
describe('cursor-based completion trigger', () => {
it.each([
{
@@ -79,6 +79,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
@@ -120,6 +121,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState,
popAllMessages,
suggestionsPosition = 'below',
setBannerVisible,
}) => {
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
@@ -524,6 +526,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
setBannerVisible(false);
onClearScreen();
return;
}
@@ -818,6 +821,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandSearchCompletion,
kittyProtocol.supported,
tryLoadQueuedMessages,
setBannerVisible,
],
);
@@ -8,9 +8,9 @@ import { render } from '../../test-utils/render.js';
import { cleanup } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
GEMINI_MODEL_ALIAS_FLASH_LITE,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_PRO,
DEFAULT_GEMINI_MODEL_AUTO,
} from '@google/gemini-cli-core';
import { ModelDialog } from './ModelDialog.js';
@@ -43,6 +43,7 @@ const renderComponent = (
// --- Functions used by ModelDialog ---
getModel: vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO),
setModel: vi.fn(),
getPreviewFeatures: vi.fn(() => false),
// --- Functions used by ClearcutLogger ---
getUsageStatisticsEnabled: vi.fn(() => true),
@@ -86,7 +87,7 @@ describe('<ModelDialog />', () => {
expect(lastFrame()).toContain('Select Model');
expect(lastFrame()).toContain('(Press Esc to close)');
expect(lastFrame()).toContain(
'> To use a specific Gemini model on startup, use the --model flag.',
'To use a specific Gemini model on startup, use the --model flag.',
);
unmount();
});
@@ -98,15 +99,15 @@ describe('<ModelDialog />', () => {
const props = mockedSelect.mock.calls[0][0];
expect(props.items).toHaveLength(4);
expect(props.items[0].value).toBe(DEFAULT_GEMINI_MODEL_AUTO);
expect(props.items[1].value).toBe(DEFAULT_GEMINI_MODEL);
expect(props.items[2].value).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(props.items[3].value).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
expect(props.items[1].value).toBe(GEMINI_MODEL_ALIAS_PRO);
expect(props.items[2].value).toBe(GEMINI_MODEL_ALIAS_FLASH);
expect(props.items[3].value).toBe(GEMINI_MODEL_ALIAS_FLASH_LITE);
expect(props.showNumbers).toBe(true);
unmount();
});
it('initializes with the model from ConfigContext', () => {
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_FLASH_MODEL);
const mockGetModel = vi.fn(() => GEMINI_MODEL_ALIAS_FLASH);
const { unmount } = renderComponent({}, { getModel: mockGetModel });
expect(mockGetModel).toHaveBeenCalled();
@@ -157,10 +158,10 @@ describe('<ModelDialog />', () => {
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
expect(childOnSelect).toBeDefined();
childOnSelect(DEFAULT_GEMINI_MODEL);
childOnSelect(GEMINI_MODEL_ALIAS_PRO);
// Assert against the default mock provided by renderComponent
expect(mockConfig?.setModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
expect(mockConfig?.setModel).toHaveBeenCalledWith(GEMINI_MODEL_ALIAS_PRO);
expect(props.onClose).toHaveBeenCalledTimes(1);
unmount();
});
@@ -209,18 +210,23 @@ describe('<ModelDialog />', () => {
it('updates initialIndex when config context changes', () => {
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO);
const oldMockConfig = {
getModel: mockGetModel,
getPreviewFeatures: vi.fn(() => false),
} as unknown as Config;
const { rerender, unmount } = render(
<ConfigContext.Provider
value={{ getModel: mockGetModel } as unknown as Config}
>
<ConfigContext.Provider value={oldMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
);
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
mockGetModel.mockReturnValue(DEFAULT_GEMINI_FLASH_LITE_MODEL);
const newMockConfig = { getModel: mockGetModel } as unknown as Config;
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_FLASH_LITE);
const newMockConfig = {
getModel: mockGetModel,
getPreviewFeatures: vi.fn(() => false),
} as unknown as Config;
rerender(
<ConfigContext.Provider value={newMockConfig}>
+61 -34
View File
@@ -8,10 +8,14 @@ import type React from 'react';
import { useCallback, useContext, useMemo } from 'react';
import { Box, Text } from 'ink';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
GEMINI_MODEL_ALIAS_PRO,
ModelSlashCommandEvent,
logModelSlashCommand,
} from '@google/gemini-cli-core';
@@ -19,38 +23,12 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import Gradient from 'ink-gradient';
interface ModelDialogProps {
onClose: () => void;
}
const MODEL_OPTIONS = [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: 'Auto (recommended)',
description: 'Let the system choose the best model for your task',
key: DEFAULT_GEMINI_MODEL_AUTO,
},
{
value: DEFAULT_GEMINI_MODEL,
title: 'Pro',
description: 'For complex tasks that require deep reasoning and creativity',
key: DEFAULT_GEMINI_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: 'Flash',
description: 'For tasks that need a balance of speed and reasoning',
key: DEFAULT_GEMINI_FLASH_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: 'Flash-Lite',
description: 'For simple tasks that need to be done quickly',
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
},
];
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
@@ -66,10 +44,43 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{ isActive: true },
);
const options = useMemo(
() => [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: 'Auto',
description: 'Let the system choose the best model for your task.',
key: DEFAULT_GEMINI_MODEL_AUTO,
},
{
value: GEMINI_MODEL_ALIAS_PRO,
title: config?.getPreviewFeatures()
? `Pro (${PREVIEW_GEMINI_MODEL}, ${DEFAULT_GEMINI_MODEL})`
: `Pro (${DEFAULT_GEMINI_MODEL})`,
description:
'For complex tasks that require deep reasoning and creativity',
key: GEMINI_MODEL_ALIAS_PRO,
},
{
value: GEMINI_MODEL_ALIAS_FLASH,
title: `Flash (${DEFAULT_GEMINI_FLASH_MODEL})`,
description: 'For tasks that need a balance of speed and reasoning',
key: GEMINI_MODEL_ALIAS_FLASH,
},
{
value: GEMINI_MODEL_ALIAS_FLASH_LITE,
title: `Flash-Lite (${DEFAULT_GEMINI_FLASH_LITE_MODEL})`,
description: 'For simple tasks that need to be done quickly',
key: GEMINI_MODEL_ALIAS_FLASH_LITE,
},
],
[config],
);
// Calculate the initial index based on the preferred model.
const initialIndex = useMemo(
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
[preferredModel],
() => options.findIndex((option) => option.value === preferredModel),
[preferredModel, options],
);
// Handle selection internally (Autonomous Dialog).
@@ -85,6 +96,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
[config, onClose],
);
const header = config?.getPreviewFeatures()
? 'Gemini 3 is now enabled.'
: 'Gemini 3 is now available.';
const subheader = config?.getPreviewFeatures()
? `To disable Gemini 3, disable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features\n\nWhen you select Auto or Pro, Gemini CLI will attempt to use ${PREVIEW_GEMINI_MODEL} first, before falling back to ${DEFAULT_GEMINI_MODEL}.`
: `To use Gemini 3, enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features`;
return (
<Box
borderStyle="round"
@@ -94,17 +113,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
width="100%"
>
<Text bold>Select Model</Text>
<Box marginTop={1} marginBottom={1} flexDirection="column">
<Gradient colors={theme.ui.gradient}>
<Text>{header}</Text>
</Gradient>
<Text>{subheader}</Text>
</Box>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
items={options}
onSelect={handleSelect}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
<Box flexDirection="column">
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{'> To use a specific Gemini model on startup, use the --model flag.'}
{'To use a specific Gemini model on startup, use the --model flag.'}
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
@@ -10,39 +10,85 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
PREVIEW_GEMINI_MODEL,
UserTierId,
DEFAULT_GEMINI_FLASH_MODEL,
} from '@google/gemini-cli-core';
// Mock the child component to make it easier to test the parent
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(),
}));
describe('ProQuotaDialog', () => {
const mockOnChoice = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should render with correct title and options', () => {
const { lastFrame, unmount } = render(
<ProQuotaDialog fallbackModel="gemini-2.5-flash" onChoice={() => {}} />,
describe('for flash model failures', () => {
it('should render "Keep trying" and "Stop" options', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel={DEFAULT_GEMINI_FLASH_MODEL}
fallbackModel="gemini-2.5-pro"
message="flash error"
isTerminalQuotaError={true} // should not matter
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
const output = lastFrame();
expect(output).toContain(
'Note: You can always use /model to select a different option.',
);
// Check that RadioButtonSelect was called with the correct items
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Try again later',
value: 'retry_later' as const,
key: 'retry_later',
label: 'Keep trying',
value: 'retry_once',
key: 'retry_once',
},
{
label: `Switch to gemini-2.5-flash for the rest of this session`,
value: 'retry' as const,
key: 'retry',
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
});
describe('for non-flash model failures', () => {
describe('when it is a terminal quota error', () => {
it('should render switch and stop options for paid tiers', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="paid tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
onChoice={mockOnChoice}
userTier={UserTierId.LEGACY}
/>,
);
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',
},
],
}),
@@ -51,45 +97,210 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should call onChoice with "auth" when "Change auth" is selected', () => {
const mockOnChoice = vi.fn();
it('should render switch, upgrade, and stop options for free tier', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="free tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
// Get the onSelect function passed to RadioButtonSelect
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
// Simulate the selection
act(() => {
onSelect('auth');
});
expect(mockOnChoice).toHaveBeenCalledWith('auth');
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();
});
});
it('should call onChoice with "continue" when "Continue with flash" is selected', () => {
const mockOnChoice = vi.fn();
describe('when it is a capacity error', () => {
it('should render keep trying, switch, and stop options', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="capacity error"
isTerminalQuotaError={false}
isModelNotFoundError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
// Get the onSelect function passed to RadioButtonSelect
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
// Simulate the selection
act(() => {
onSelect('retry');
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();
});
});
expect(mockOnChoice).toHaveBeenCalledWith('retry');
describe('when it is a model not found error', () => {
it('should render switch and stop options regardless of tier', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
fallbackModel="gemini-2.5-pro"
message="You don't have access to gemini-3-pro-preview yet."
isTerminalQuotaError={false}
isModelNotFoundError={true}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-pro',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
it('should render switch and stop options for paid tier as well', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
fallbackModel="gemini-2.5-pro"
message="You don't have access to gemini-3-pro-preview yet."
isTerminalQuotaError={false}
isModelNotFoundError={true}
onChoice={mockOnChoice}
userTier={UserTierId.LEGACY}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-pro',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
});
});
describe('onChoice handling', () => {
it('should call onChoice with the selected value', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message=""
isTerminalQuotaError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
onSelect('retry_always');
});
expect(mockOnChoice).toHaveBeenCalledWith('retry_always');
unmount();
});
});
describe('footer note', () => {
it('should show a special note for PREVIEW_GEMINI_MODEL', () => {
const { lastFrame, unmount } = render(
<ProQuotaDialog
failedModel={PREVIEW_GEMINI_MODEL}
fallbackModel="gemini-2.5-pro"
message=""
isTerminalQuotaError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
const output = lastFrame();
expect(output).toContain(
'Note: We will periodically retry Preview Model to see if congestion has cleared.',
);
unmount();
});
it('should show the default note for other models', () => {
const { lastFrame, unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message=""
isTerminalQuotaError={false}
onChoice={mockOnChoice}
userTier={UserTierId.FREE}
/>,
);
const output = lastFrame();
expect(output).toContain(
'Note: You can always use /model to select a different option.',
);
unmount();
});
});
});
@@ -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 = [
// 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: 'Try again later',
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} for the rest of this session`,
value: 'retry' as const,
key: 'retry',
label: `Switch to ${fallbackModel}`,
value: 'retry_always' as const,
key: 'retry_always',
},
{
label: `Stop`,
value: 'retry_later' as const,
key: 'retry_later',
},
];
} else if (isTerminalQuotaError && !isPaidTier) {
// free user gets an option to upgrade
items = [
{
label: `Switch to ${fallbackModel}`,
value: 'retry_always' as const,
key: 'retry_always',
},
{
label: 'Upgrade for higher limits',
value: 'upgrade' as const,
key: 'upgrade',
},
{
label: `Stop`,
value: 'retry_later' as const,
key: 'retry_later',
},
];
} else {
// capacity error
items = [
{
label: 'Keep trying',
value: 'retry_once' as const,
key: 'retry_once',
},
{
label: `Switch to ${fallbackModel}`,
value: 'retry_always' as const,
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later' as const,
key: 'retry_later',
},
];
}
const handleSelect = (choice: 'retry_later' | 'retry') => {
const handleSelect = (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => {
onChoice(choice);
};
return (
<Box borderStyle="round" flexDirection="column" paddingX={1}>
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text>{message}</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect
items={items}
initialIndex={1}
onSelect={handleSelect}
/>
<RadioButtonSelect items={items} onSelect={handleSelect} />
</Box>
<Text color={theme.text.primary}>
Note: You can always use /model to select a different option.
{failedModel === PREVIEW_GEMINI_MODEL && !isModelNotFoundError
? 'Note: We will periodically retry Preview Model to see if congestion has cleared.'
: 'Note: You can always use /model to select a different option.'}
</Text>
</Box>
);
@@ -367,17 +367,17 @@ describe('SettingsDialog', () => {
const { stdin, unmount, lastFrame } = renderDialog(settings, onSelect);
// Wait for initial render and verify we're on Vim Mode (first setting)
// Wait for initial render and verify we're on Preview Features (first setting)
await waitFor(() => {
expect(lastFrame()).toContain('Vim Mode');
expect(lastFrame()).toContain('Preview Features (e.g., models)');
});
// Navigate to Disable Auto Update setting and verify we're there
// Navigate to Vim Mode setting and verify we're there
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string);
});
await waitFor(() => {
expect(lastFrame()).toContain('Disable Auto Update');
expect(lastFrame()).toContain('Vim Mode');
});
// Toggle the setting
@@ -397,10 +397,10 @@ describe('SettingsDialog', () => {
});
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['general.disableAutoUpdate']),
new Set<string>(['general.vimMode']),
expect.objectContaining({
general: expect.objectContaining({
disableAutoUpdate: true,
vimMode: true,
}),
}),
expect.any(LoadedSettings),
@@ -571,7 +571,7 @@ describe('SettingsDialog', () => {
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
expect(lastFrame()).toContain('Vim Mode');
});
// Verify the dialog is rendered properly
@@ -0,0 +1,118 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should not render the banner when previewFeatures is enabled 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should render the banner when previewFeatures is disabled 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should render the banner with default text 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
`;
@@ -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 │
@@ -40,11 +40,14 @@ export interface UIActions {
refreshStatic: () => void;
handleFinalSubmit: (value: string) => void;
handleClearScreen: () => void;
handleProQuotaChoice: (choice: 'retry_later' | 'retry') => void;
handleProQuotaChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
setQueueErrorMessage: (message: string | null) => void;
popAllMessages: (onPop: (messages: string | undefined) => void) => void;
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);
@@ -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;
}
@@ -125,6 +128,11 @@ export interface UIState {
showFullTodos: boolean;
copyModeEnabled: boolean;
warningMessage: string | null;
bannerData: {
defaultText: string;
warningText: string;
};
bannerVisible: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);
@@ -8,6 +8,7 @@ import {
allowEditorTypeInSandbox,
checkHasEditorType,
type EditorType,
EDITOR_DISPLAY_NAMES,
} from '@google/gemini-cli-core';
export interface EditorDisplay {
@@ -16,17 +17,6 @@ export interface EditorDisplay {
disabled: boolean;
}
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
cursor: 'Cursor',
emacs: 'Emacs',
neovim: 'Neovim',
vim: 'Vim',
vscode: 'VS Code',
vscodium: 'VSCodium',
windsurf: 'Windsurf',
zed: 'Zed',
};
class EditorSettingsManager {
private readonly availableEditors: EditorDisplay[];
@@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => {
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
vi.fn(), // setBannerVisible
),
);
result = hook.result;
@@ -77,6 +77,7 @@ export const useSlashCommandProcessor = (
actions: SlashCommandProcessorActions,
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
isConfigInitialized: boolean,
setBannerVisible: (visible: boolean) => void,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
@@ -202,6 +203,7 @@ export const useSlashCommandProcessor = (
console.clear();
}
refreshStatic();
setBannerVisible(false);
},
loadHistory,
setDebugMessage: actions.setDebugMessage,
@@ -239,6 +241,7 @@ export const useSlashCommandProcessor = (
sessionShellAllowlist,
reloadCommands,
extensionsUpdateState,
setBannerVisible,
],
);
@@ -123,7 +123,7 @@ describe('useEditorSettings', () => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Editor preference set to "vscode" in User settings.',
text: 'Editor preference set to "VS Code" in User settings.',
},
expect.any(Number),
);
@@ -164,6 +164,11 @@ describe('useEditorSettings', () => {
render(<TestComponent />);
const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim'];
const displayNames: Record<string, string> = {
cursor: 'Cursor',
windsurf: 'Windsurf',
vim: 'Vim',
};
const scope = SettingScope.User;
editorTypes.forEach((editorType) => {
@@ -180,7 +185,7 @@ describe('useEditorSettings', () => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Editor preference set to "${editorType}" in User settings.`,
text: `Editor preference set to "${displayNames[editorType]}" in User settings.`,
},
expect.any(Number),
);
@@ -210,7 +215,7 @@ describe('useEditorSettings', () => {
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Editor preference set to "vscode" in ${scope} settings.`,
text: `Editor preference set to "VS Code" in ${scope} settings.`,
},
expect.any(Number),
);
@@ -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(),
);
@@ -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) =>
const getRegisteredHandler = (): FallbackModelHandler => {
renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: props.userTier,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
{ initialProps: { userTier } },
);
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
};
@@ -116,65 +114,8 @@ describe('useQuotaAndFallback', () => {
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
});
describe('Flash Model Fallback', () => {
it('should show a terminal quota message and stop, without offering a fallback', async () => {
const handler = getRegisteredHandler();
const result = await handler(
'gemini-2.5-flash',
'gemini-2.5-flash',
new TerminalQuotaError('flash quota', mockGoogleApiError),
);
expect(result).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
.text;
expect(message).toContain(
'You have reached your daily gemini-2.5-flash',
);
expect(message).not.toContain('continue with the fallback model');
});
it('should show a capacity message and stop', async () => {
const handler = getRegisteredHandler();
// let result: FallbackIntent | null = null;
const result = await handler(
'gemini-2.5-flash',
'gemini-2.5-flash',
new Error('capacity'),
);
expect(result).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
.text;
expect(message).toContain(
'Pardon Our Congestion! It looks like gemini-2.5-flash is very popular',
);
});
it('should show a capacity message and stop, even when already in fallback mode', async () => {
vi.spyOn(mockConfig, 'isInFallbackMode').mockReturnValue(true);
const handler = getRegisteredHandler();
const result = await handler(
'gemini-2.5-flash',
'gemini-2.5-flash',
new Error('capacity'),
);
expect(result).toBe('stop');
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
.text;
expect(message).toContain(
'Pardon Our Congestion! It looks like gemini-2.5-flash is very popular',
);
});
});
describe('Interactive Fallback', () => {
// Pro Quota Errors
it('should set an interactive request and wait for user choice', async () => {
it('should set an interactive request for a terminal quota error', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
@@ -187,31 +128,42 @@ describe('useQuotaAndFallback', () => {
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
// Call the handler but do not await it, to check the intermediate state
let promise: Promise<FallbackIntent | null>;
const error = new TerminalQuotaError(
'pro quota',
mockGoogleApiError,
1000 * 60 * 5,
); // 5 minutes
await act(() => {
promise = handler(
'gemini-pro',
'gemini-flash',
new TerminalQuotaError('pro quota', mockGoogleApiError),
);
promise = handler('gemini-pro', 'gemini-flash', error);
});
// The hook should now have a pending request for the UI to handle
expect(result.current.proQuotaRequest).not.toBeNull();
expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro');
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('gemini-pro');
expect(request?.isTerminalQuotaError).toBe(true);
const message = request!.message;
expect(message).toContain('Usage limit reached for gemini-pro.');
expect(message).toContain('Access resets at'); // From getResetTimeMessage
expect(message).toContain('/stats for usage details');
expect(message).toContain('/auth to switch to API key.');
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
// Simulate the user choosing to continue with the fallback model
await act(() => {
result.current.handleProQuotaChoice('retry');
result.current.handleProQuotaChoice('retry_always');
});
// The original promise from the handler should now resolve
const intent = await promise!;
expect(intent).toBe('retry');
expect(intent).toBe('retry_always');
// The pending request should be cleared from the state
expect(result.current.proQuotaRequest).toBeNull();
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
});
it('should handle race conditions by stopping subsequent requests', async () => {
@@ -253,120 +205,129 @@ describe('useQuotaAndFallback', () => {
expect(result.current.proQuotaRequest).toBe(firstRequest);
await act(() => {
result.current.handleProQuotaChoice('retry');
result.current.handleProQuotaChoice('retry_always');
});
const intent1 = await promise1!;
expect(intent1).toBe('retry');
expect(intent1).toBe('retry_always');
expect(result.current.proQuotaRequest).toBeNull();
});
// Non-Quota error test cases
// Non-TerminalQuotaError test cases
const testCases = [
{
description: 'other error for FREE tier',
tier: UserTierId.FREE,
description: 'generic error',
error: new Error('some error'),
expectedMessageSnippets: [
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
'Please retry again later.',
],
},
{
description: 'other error for LEGACY tier',
tier: UserTierId.LEGACY, // Paid tier
error: new Error('some error'),
expectedMessageSnippets: [
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
'Please retry again later.',
],
},
{
description: 'retryable quota error for FREE tier',
tier: UserTierId.FREE,
description: 'retryable quota error',
error: new RetryableQuotaError(
'retryable quota',
mockGoogleApiError,
5,
),
expectedMessageSnippets: [
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
'Please retry again later.',
],
},
{
description: 'retryable quota error for LEGACY tier',
tier: UserTierId.LEGACY, // Paid tier
error: new RetryableQuotaError(
'retryable quota',
mockGoogleApiError,
5,
),
expectedMessageSnippets: [
'🚦Pardon Our Congestion! It looks like model-A is very popular at the moment.',
'Please retry again later.',
],
},
];
for (const {
description,
tier,
error,
expectedMessageSnippets,
} of testCases) {
for (const { description, error } of testCases) {
it(`should handle ${description} correctly`, async () => {
const { result } = renderHook(
(props) =>
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: props.tier,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError:
mockSetModelSwitchedFromQuotaError,
}),
{ initialProps: { tier } },
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
// Call the handler but do not await it, to check the intermediate state
let promise: Promise<FallbackIntent | null>;
await act(() => {
promise = handler('model-A', 'model-B', error);
});
// The hook should now have a pending request for the UI to handle
expect(result.current.proQuotaRequest).not.toBeNull();
expect(result.current.proQuotaRequest?.failedModel).toBe('model-A');
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('model-A');
expect(request?.isTerminalQuotaError).toBe(false);
// Check that the correct initial message was added
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.INFO }),
expect.any(Number),
// Check that the correct initial message was generated
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
const message = request!.message;
expect(message).toContain(
'model-A is currently experiencing high demand. We apologize and appreciate your patience.',
);
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
.text;
for (const snippet of expectedMessageSnippets) {
expect(message).toContain(snippet);
}
// Simulate the user choosing to continue with the fallback model
await act(() => {
result.current.handleProQuotaChoice('retry');
result.current.handleProQuotaChoice('retry_always');
});
expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(true);
// The original promise from the handler should now resolve
const intent = await promise!;
expect(intent).toBe('retry');
expect(intent).toBe('retry_always');
// The pending request should be cleared from the state
expect(result.current.proQuotaRequest).toBeNull();
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true);
// Check for the "Switched to fallback model" message
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock
.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain('Switched to fallback model.');
});
}
it('should handle ModelNotFoundError correctly', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new ModelNotFoundError('model not found', 404);
await act(() => {
promise = handler('gemini-3-pro-preview', 'gemini-2.5-pro', error);
});
// The hook should now have a pending request for the UI to handle
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('gemini-3-pro-preview');
expect(request?.isTerminalQuotaError).toBe(false);
expect(request?.isModelNotFoundError).toBe(true);
const message = request!.message;
expect(message).toBe(
`It seems like you don't have access to Gemini 3.
Learn more at https://goo.gle/enable-preview-features
To disable Gemini 3, disable "Preview features" in /settings.`,
);
// Simulate the user choosing to switch
await act(() => {
result.current.handleProQuotaChoice('retry_always');
});
const intent = await promise!;
expect(intent).toBe('retry_always');
expect(result.current.proQuotaRequest).toBeNull();
});
});
});
@@ -418,7 +379,7 @@ describe('useQuotaAndFallback', () => {
expect(result.current.proQuotaRequest).toBeNull();
});
it('should resolve intent to "retry" and add info message on continue', async () => {
it('should resolve intent to "retry_always" and add info message on continue', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
@@ -430,7 +391,7 @@ describe('useQuotaAndFallback', () => {
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
// The first `addItem` call is for the initial quota error message
let promise: Promise<FallbackIntent | null>;
await act(() => {
promise = handler(
@@ -441,18 +402,53 @@ describe('useQuotaAndFallback', () => {
});
await act(() => {
result.current.handleProQuotaChoice('retry');
result.current.handleProQuotaChoice('retry_always');
});
const intent = await promise!;
expect(intent).toBe('retry');
expect(intent).toBe('retry_always');
expect(result.current.proQuotaRequest).toBeNull();
// Check for the second "Switched to fallback model" message
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[1][0];
// Check for the "Switched to fallback model" message
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain('Switched to fallback model.');
});
it('should show a special message when falling back from the preview model', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
await act(() => {
promise = handler(
PREVIEW_GEMINI_MODEL,
'gemini-flash',
new Error('preview model failed'),
);
});
await act(() => {
result.current.handleProQuotaChoice('retry_always');
});
await promise!;
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
expect(lastCall.type).toBe(MessageType.INFO);
expect(lastCall.text).toContain(
`Switched to fallback model gemini-flash. We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.`,
);
});
});
});
@@ -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') {
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. Tip: Press Ctrl+P (or Up Arrow) to recall your previous prompt and submit it again if you wish.',
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)}.`;
}
+79
View File
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Storage, debugLogger } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
const STATE_FILENAME = 'state.json';
interface PersistentStateData {
defaultBannerShownCount?: number;
// Add other persistent state keys here as needed
}
export class PersistentState {
private cache: PersistentStateData | null = null;
private filePath: string | null = null;
private getPath(): string {
if (!this.filePath) {
this.filePath = path.join(Storage.getGlobalGeminiDir(), STATE_FILENAME);
}
return this.filePath;
}
private load(): PersistentStateData {
if (this.cache) {
return this.cache;
}
try {
const filePath = this.getPath();
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
this.cache = JSON.parse(content);
} else {
this.cache = {};
}
} catch (error) {
debugLogger.warn('Failed to load persistent state:', error);
// If error reading (e.g. corrupt JSON), start fresh
this.cache = {};
}
return this.cache!;
}
private save() {
if (!this.cache) return;
try {
const filePath = this.getPath();
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(this.cache, null, 2));
} catch (error) {
debugLogger.warn('Failed to save persistent state:', error);
}
}
get<K extends keyof PersistentStateData>(
key: K,
): PersistentStateData[K] | undefined {
return this.load()[key];
}
set<K extends keyof PersistentStateData>(
key: K,
value: PersistentStateData[K],
): void {
this.load(); // ensure loaded
this.cache![key] = value;
this.save();
}
}
export const persistentState = new PersistentState();
+4
View File
@@ -12,6 +12,9 @@ export {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
} from './src/config/models.js';
export {
serializeTerminalToObject,
@@ -49,3 +52,4 @@ export * from './src/utils/googleQuotaErrors.js';
export type { GoogleApiError } from './src/utils/googleErrors.js';
export { getCodeAssistServer } from './src/code_assist/codeAssist.js';
export { getExperiments } from './src/code_assist/experiments/experiments.js';
export { getErrorStatus, ModelNotFoundError } from './src/utils/httpErrors.js';
@@ -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 =
+94
View File
@@ -160,11 +160,16 @@ vi.mock('../utils/fetch.js', () => ({
import { BaseLlmClient } from '../core/baseLlmClient.js';
import { tokenLimit } from '../core/tokenLimits.js';
import { uiTelemetryService } from '../telemetry/index.js';
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
import { getExperiments } from '../code_assist/experiments/experiments.js';
import type { CodeAssistServer } from '../code_assist/server.js';
vi.mock('../core/baseLlmClient.js');
vi.mock('../core/tokenLimits.js', () => ({
tokenLimit: vi.fn(),
}));
vi.mock('../code_assist/codeAssist.js');
vi.mock('../code_assist/experiments/experiments.js');
describe('Server Config (config.ts)', () => {
const MODEL = 'gemini-pro';
@@ -362,6 +367,23 @@ describe('Server Config (config.ts)', () => {
).toHaveBeenCalledWith();
});
it('should strip thoughts when switching from GenAI to Vertex AI', async () => {
const config = new Config(baseParams);
vi.mocked(createContentGeneratorConfig).mockImplementation(
async (_: Config, authType: AuthType | undefined) =>
({ authType }) as unknown as ContentGeneratorConfig,
);
await config.refreshAuth(AuthType.USE_GEMINI);
await config.refreshAuth(AuthType.USE_VERTEX_AI);
expect(
config.getGeminiClient().stripThoughtsFromHistory,
).toHaveBeenCalledWith();
});
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
const config = new Config(baseParams);
@@ -380,6 +402,78 @@ describe('Server Config (config.ts)', () => {
});
});
describe('Preview Features Logic in refreshAuth', () => {
beforeEach(() => {
// Set up default mock behavior for these functions before each test
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
vi.mocked(getExperiments).mockResolvedValue({
flags: {},
experimentIds: [],
});
});
it('should enable preview features for Google auth when remote flag is true', async () => {
// Override the default mock for this specific test
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer); // Simulate Google auth by returning a truthy value
vi.mocked(getExperiments).mockResolvedValue({
flags: {
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: true },
},
experimentIds: [],
});
const config = new Config({ ...baseParams, previewFeatures: undefined });
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getPreviewFeatures()).toBe(true);
});
it('should disable preview features for Google auth when remote flag is false', async () => {
// Override the default mock
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer);
vi.mocked(getExperiments).mockResolvedValue({
flags: {
[ExperimentFlags.ENABLE_PREVIEW]: { boolValue: false },
},
experimentIds: [],
});
const config = new Config({ ...baseParams, previewFeatures: undefined });
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getPreviewFeatures()).toBe(undefined);
});
it('should disable preview features for Google auth when remote flag is missing', async () => {
// Override the default mock for getCodeAssistServer, the getExperiments mock is already correct
vi.mocked(getCodeAssistServer).mockReturnValue({} as CodeAssistServer);
const config = new Config({ ...baseParams, previewFeatures: undefined });
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getPreviewFeatures()).toBe(undefined);
});
it('should not change preview features or model if it is already set to true', async () => {
const initialModel = 'some-other-model';
const config = new Config({
...baseParams,
previewFeatures: true,
model: initialModel,
});
// It doesn't matter which auth method we use here, the logic should exit early
await config.refreshAuth(AuthType.USE_GEMINI);
expect(config.getPreviewFeatures()).toBe(true);
expect(config.getModel()).toBe(initialModel);
});
it('should not change preview features or model if it is already set to false', async () => {
const initialModel = 'some-other-model';
const config = new Config({
...baseParams,
previewFeatures: false,
model: initialModel,
});
await config.refreshAuth(AuthType.USE_GEMINI);
expect(config.getPreviewFeatures()).toBe(false);
expect(config.getModel()).toBe(initialModel);
});
});
it('Config constructor should store userMemory correctly', () => {
const config = new Config(baseParams);
+62 -1
View File
@@ -305,6 +305,7 @@ export interface ConfigParameters {
hooks?: {
[K in HookEventName]?: HookDefinition[];
};
previewFeatures?: boolean;
}
export class Config {
@@ -357,6 +358,7 @@ export class Config {
private readonly cwd: string;
private readonly bugCommand: BugCommandSettings | undefined;
private model: string;
private previewFeatures: boolean | undefined;
private readonly noBrowser: boolean;
private readonly folderTrust: boolean;
private ideMode: boolean;
@@ -418,6 +420,9 @@ export class Config {
private experiments: Experiments | undefined;
private experimentsPromise: Promise<void> | undefined;
private previewModelFallbackMode = false;
private previewModelBypassMode = false;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this.embeddingModel =
@@ -475,6 +480,7 @@ export class Config {
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = params.model;
this.previewFeatures = params.previewFeatures ?? undefined;
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
@@ -649,7 +655,7 @@ export class Config {
// thoughtSignature from Genai to Vertex will fail, we need to strip them
if (
this.contentGeneratorConfig?.authType === AuthType.USE_GEMINI &&
authMethod === AuthType.LOGIN_WITH_GOOGLE
authMethod !== AuthType.USE_GEMINI
) {
// Restore the conversation history to the new client
this.geminiClient.stripThoughtsFromHistory();
@@ -670,11 +676,22 @@ export class Config {
// Initialize BaseLlmClient now that the ContentGenerator is available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
const previewFeatures = this.getPreviewFeatures();
const codeAssistServer = getCodeAssistServer(this);
if (codeAssistServer) {
this.experimentsPromise = getExperiments(codeAssistServer)
.then((experiments) => {
this.setExperiments(experiments);
// If preview features have not been set and the user authenticated through Google, we enable preview based on remote config only if it's true
if (previewFeatures === undefined) {
const remotePreviewFeatures =
experiments.flags[ExperimentFlags.ENABLE_PREVIEW]?.boolValue;
if (remotePreviewFeatures === true) {
this.setPreviewFeatures(remotePreviewFeatures);
}
}
})
.catch((e) => {
debugLogger.error('Failed to fetch experiments', e);
@@ -760,6 +777,26 @@ export class Config {
this.fallbackModelHandler = handler;
}
getFallbackModelHandler(): FallbackModelHandler | undefined {
return this.fallbackModelHandler;
}
isPreviewModelFallbackMode(): boolean {
return this.previewModelFallbackMode;
}
setPreviewModelFallbackMode(active: boolean): void {
this.previewModelFallbackMode = active;
}
isPreviewModelBypassMode(): boolean {
return this.previewModelBypassMode;
}
setPreviewModelBypassMode(active: boolean): void {
this.previewModelBypassMode = active;
}
getMaxSessionTurns(): number {
return this.maxSessionTurns;
}
@@ -822,6 +859,14 @@ export class Config {
return this.question;
}
getPreviewFeatures(): boolean | undefined {
return this.previewFeatures;
}
setPreviewFeatures(previewFeatures: boolean) {
this.previewFeatures = previewFeatures;
}
getCoreTools(): string[] | undefined {
return this.coreTools;
}
@@ -1161,6 +1206,22 @@ export class Config {
return this.experiments?.flags[ExperimentFlags.USER_CACHING]?.boolValue;
}
async getBannerTextNoCapacityIssues(): Promise<string> {
await this.ensureExperimentsLoaded();
return (
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_NO_CAPACITY_ISSUES]
?.stringValue ?? ''
);
}
async getBannerTextCapacityIssues(): Promise<string> {
await this.ensureExperimentsLoaded();
return (
this.experiments?.flags[ExperimentFlags.BANNER_TEXT_CAPACITY_ISSUES]
?.stringValue ?? ''
);
}
private async ensureExperimentsLoaded(): Promise<void> {
if (!this.experimentsPromise) {
return;
+148 -5
View File
@@ -8,8 +8,12 @@ import { describe, it, expect } from 'vitest';
import {
getEffectiveModel,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
} from './models.js';
describe('getEffectiveModel', () => {
@@ -17,7 +21,11 @@ describe('getEffectiveModel', () => {
const isInFallbackMode = false;
it('should return the Pro model when Pro is requested', () => {
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
@@ -25,6 +33,7 @@ describe('getEffectiveModel', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
@@ -33,22 +42,92 @@ describe('getEffectiveModel', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return a custom model name when requested', () => {
const customModel = 'custom-model-v1';
const model = getEffectiveModel(isInFallbackMode, customModel);
const model = getEffectiveModel(isInFallbackMode, customModel, false);
expect(model).toBe(customModel);
});
describe('with preview features', () => {
it('should return the preview model when pro alias is requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_PRO,
true,
);
expect(model).toBe(PREVIEW_GEMINI_MODEL);
});
it('should return the default pro model when pro alias is requested and preview is off', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_PRO,
false,
);
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
it('should return the flash model when flash is requested and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_FLASH,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the flash model when lite is requested and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_FLASH_LITE,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return the flash model when the flash model name is explicitly requested and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the lite model when the lite model name is requested and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return the default gemini model when the model is explicitly set and preview is on', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_MODEL);
});
});
});
describe('When IN fallback mode', () => {
const isInFallbackMode = true;
it('should downgrade the Pro model to the Flash model', () => {
const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL);
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
@@ -56,6 +135,7 @@ describe('getEffectiveModel', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
@@ -64,20 +144,83 @@ describe('getEffectiveModel', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should HONOR any model with "lite" in its name', () => {
const customLiteModel = 'gemini-2.5-custom-lite-vNext';
const model = getEffectiveModel(isInFallbackMode, customLiteModel);
const model = getEffectiveModel(isInFallbackMode, customLiteModel, false);
expect(model).toBe(customLiteModel);
});
it('should downgrade any other custom model to the Flash model', () => {
const customModel = 'custom-model-v1-unlisted';
const model = getEffectiveModel(isInFallbackMode, customModel);
const model = getEffectiveModel(isInFallbackMode, customModel, false);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
describe('with preview features', () => {
it('should downgrade the Pro alias to the Flash model', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_PRO,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the Flash alias when requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_FLASH,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the Lite alias when requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
GEMINI_MODEL_ALIAS_FLASH_LITE,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should downgrade the default Gemini model to the Flash model', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the default Flash model when requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the default Lite model when requested', () => {
const model = getEffectiveModel(
isInFallbackMode,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
true,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should downgrade any other custom model to the Flash model', () => {
const customModel = 'custom-model-v1-unlisted';
const model = getEffectiveModel(isInFallbackMode, customModel, true);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
});
});
});
+55 -4
View File
@@ -4,17 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
// Model aliases for user convenience.
export const GEMINI_MODEL_ALIAS_PRO = 'pro';
export const GEMINI_MODEL_ALIAS_FLASH = 'flash';
export const GEMINI_MODEL_ALIAS_FLASH_LITE = 'flash-lite';
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Cap the thinking at 8192 to prevent run-away thinking loops.
export const DEFAULT_THINKING_MODE = 8192;
/**
* Resolves the requested model alias (e.g., 'auto', 'pro', 'flash', 'flash-lite')
* to a concrete model name, considering preview features.
*
* @param requestedModel The model alias or concrete model name requested by the user.
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled.
* @returns The resolved concrete model name.
*/
export function resolveModel(
requestedModel: string,
previewFeaturesEnabled: boolean | undefined,
): string {
switch (requestedModel) {
case DEFAULT_GEMINI_MODEL_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
return previewFeaturesEnabled
? PREVIEW_GEMINI_MODEL
: DEFAULT_GEMINI_MODEL;
}
case GEMINI_MODEL_ALIAS_FLASH: {
return DEFAULT_GEMINI_FLASH_MODEL;
}
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
}
default: {
return requestedModel;
}
}
}
/**
* Determines the effective model to use, applying fallback logic if necessary.
*
@@ -26,23 +63,37 @@ export const DEFAULT_THINKING_MODE = 8192;
*
* @param isInFallbackMode Whether the application is in fallback mode.
* @param requestedModel The model that was originally requested.
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled.
* @returns The effective model name.
*/
export function getEffectiveModel(
isInFallbackMode: boolean,
requestedModel: string,
previewFeaturesEnabled: boolean | undefined,
): string {
// If we are not in fallback mode, simply use the requested model.
const resolvedModel = resolveModel(requestedModel, previewFeaturesEnabled);
// If we are not in fallback mode, simply use the resolved model.
if (!isInFallbackMode) {
return requestedModel;
return resolvedModel;
}
// If a "lite" model is requested, honor it. This allows for variations of
// lite models without needing to list them all as constants.
if (requestedModel.includes('lite')) {
return requestedModel;
if (resolvedModel.includes('lite')) {
return resolvedModel;
}
// Default fallback for Gemini CLI.
return DEFAULT_GEMINI_FLASH_MODEL;
}
/**
* Checks if the model is a Gemini 2.x model.
*
* @param model The model name to check.
* @returns True if the model is a Gemini 2.x model.
*/
export function isGemini2Model(model: string): boolean {
return /^gemini-2(\.|$)/.test(model);
}
+6 -24
View File
@@ -15,11 +15,7 @@ import {
} from 'vitest';
import type { Content, GenerateContentResponse, Part } from '@google/genai';
import {
isThinkingDefault,
isThinkingSupported,
GeminiClient,
} from './client.js';
import { isThinkingSupported, GeminiClient } from './client.js';
import {
AuthType,
type ContentGenerator,
@@ -147,31 +143,16 @@ describe('isThinkingSupported', () => {
expect(isThinkingSupported('gemini-2.5-pro')).toBe(true);
});
it('should return true for gemini-3-pro', () => {
expect(isThinkingSupported('gemini-3-pro')).toBe(true);
});
it('should return false for other models', () => {
expect(isThinkingSupported('gemini-1.5-flash')).toBe(false);
expect(isThinkingSupported('some-other-model')).toBe(false);
});
});
describe('isThinkingDefault', () => {
it('should return false for gemini-2.5-flash-lite', () => {
expect(isThinkingDefault('gemini-2.5-flash-lite')).toBe(false);
});
it('should return true for gemini-2.5', () => {
expect(isThinkingDefault('gemini-2.5')).toBe(true);
});
it('should return true for gemini-2.5-pro', () => {
expect(isThinkingDefault('gemini-2.5-pro')).toBe(true);
});
it('should return false for other models', () => {
expect(isThinkingDefault('gemini-1.5-flash')).toBe(false);
expect(isThinkingDefault('some-other-model')).toBe(false);
});
});
describe('Gemini Client (client.ts)', () => {
let mockContentGenerator: ContentGenerator;
let mockConfig: Config;
@@ -241,6 +222,7 @@ describe('Gemini Client (client.ts)', () => {
getIdeModeFeature: vi.fn().mockReturnValue(false),
getIdeMode: vi.fn().mockReturnValue(true),
getDebugMode: vi.fn().mockReturnValue(false),
getPreviewFeatures: vi.fn().mockReturnValue(false),
getWorkspaceContext: vi.fn().mockReturnValue({
getDirectories: vi.fn().mockReturnValue(['/test/dir']),
}),
+10 -14
View File
@@ -33,7 +33,6 @@ import type {
import type { ContentGenerator } from './contentGenerator.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_THINKING_MODE,
getEffectiveModel,
@@ -57,14 +56,11 @@ import { debugLogger } from '../utils/debugLogger.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
export function isThinkingSupported(model: string) {
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
}
export function isThinkingDefault(model: string) {
if (model.startsWith('gemini-2.5-flash-lite')) {
return false;
}
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
return (
model.startsWith('gemini-2.5') ||
model.startsWith('gemini-3') ||
model === DEFAULT_GEMINI_MODEL_AUTO
);
}
const MAX_TURNS = 100;
@@ -409,11 +405,11 @@ export class GeminiClient {
}
const configModel = this.config.getModel();
const model: string =
configModel === DEFAULT_GEMINI_MODEL_AUTO
? DEFAULT_GEMINI_MODEL
: configModel;
return getEffectiveModel(this.config.isInFallbackMode(), model);
return getEffectiveModel(
this.config.isInFallbackMode(),
configModel,
this.config.getPreviewFeatures(),
);
}
async *sendMessageStream(
+353 -18
View File
@@ -16,13 +16,19 @@ import {
GeminiChat,
InvalidStreamError,
StreamEventType,
SYNTHETIC_THOUGHT_SIGNATURE,
type StreamEvent,
} from './geminiChat.js';
import type { Config } from '../config/config.js';
import { setSimulate429 } from '../utils/testUtils.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
} from '../config/models.js';
import { AuthType } from './contentGenerator.js';
import { type RetryOptions } from '../utils/retry.js';
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
import { retryWithBackoff, type RetryOptions } from '../utils/retry.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
// Mock fs module to prevent actual file system operations during tests
@@ -109,6 +115,7 @@ describe('GeminiChat', () => {
getTelemetryLogPromptsEnabled: () => true,
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getPreviewFeatures: () => false,
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'oauth-personal', // Ensure this is set for fallback tests
model: 'test-model',
@@ -128,6 +135,10 @@ describe('GeminiChat', () => {
}),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
isPreviewModelBypassMode: vi.fn().mockReturnValue(false),
setPreviewModelBypassMode: vi.fn(),
isPreviewModelFallbackMode: vi.fn().mockReturnValue(false),
setPreviewModelFallbackMode: vi.fn(),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config;
@@ -247,7 +258,7 @@ describe('GeminiChat', () => {
// 2. Action & Assert: The stream should fail because there's no finish reason.
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test message' },
'prompt-id-no-finish-empty-end',
);
@@ -471,6 +482,126 @@ describe('GeminiChat', () => {
'This is the visible text that should not be lost.',
);
});
it('should use maxAttempts=1 for retryWithBackoff when in Preview Model Fallback Mode', async () => {
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
PREVIEW_GEMINI_MODEL,
{ message: 'test' },
'prompt-id-fast-retry',
);
for await (const _ of stream) {
// consume stream
}
expect(mockRetryWithBackoff).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
maxAttempts: 1,
}),
);
});
it('should NOT use maxAttempts=1 for other models even in Preview Model Fallback Mode', async () => {
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
DEFAULT_GEMINI_FLASH_MODEL,
{ message: 'test' },
'prompt-id-normal-retry',
);
for await (const _ of stream) {
// consume stream
}
expect(mockRetryWithBackoff).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
maxAttempts: undefined, // Should use default
}),
);
});
it('should pass DEFAULT_GEMINI_MODEL to handleFallback when Preview Model is bypassed (downgraded)', async () => {
// ARRANGE
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
// Mock retryWithBackoff to simulate catching the error and calling onPersistent429
vi.mocked(retryWithBackoff).mockImplementation(
async (apiCall, options) => {
const onPersistent429 = options?.onPersistent429;
try {
await apiCall();
} catch (error) {
if (onPersistent429) {
await onPersistent429(AuthType.LOGIN_WITH_GOOGLE, error);
}
throw error;
}
},
);
// We need the API call to fail so retryWithBackoff calls the callback.
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
new TerminalQuotaError('Simulated Quota Error', {
code: 429,
message: 'Simulated Quota Error',
details: [],
}),
);
// ACT
const consumeStream = async () => {
const stream = await chat.sendMessageStream(
PREVIEW_GEMINI_MODEL,
{ message: 'test' },
'prompt-id-bypass',
);
// Consume the stream to trigger execution
for await (const _ of stream) {
// do nothing
}
};
await expect(consumeStream()).rejects.toThrow('Simulated Quota Error');
expect(retryWithBackoff).toHaveBeenCalled();
// ASSERT
// handleFallback is called via onPersistent429Callback
// We verify it was called with DEFAULT_GEMINI_MODEL
expect(mockHandleFallback).toHaveBeenCalledWith(
expect.anything(),
DEFAULT_GEMINI_MODEL, // This is the key assertion
expect.anything(),
expect.anything(),
);
});
it('should throw an error when a tool call is followed by an empty stream response', async () => {
// 1. Setup: A history where the model has just made a function call.
const initialHistory: Content[] = [
@@ -491,7 +622,6 @@ describe('GeminiChat', () => {
},
];
chat.setHistory(initialHistory);
// 2. Mock the API to return an empty/thought-only stream.
const emptyStreamResponse = (async function* () {
yield {
@@ -509,7 +639,7 @@ describe('GeminiChat', () => {
// 3. Action: Send the function response back to the model and consume the stream.
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{
message: {
functionResponse: {
@@ -595,7 +725,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-1',
);
@@ -630,7 +760,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-1',
);
@@ -701,7 +831,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.5-pro',
{ message: 'test' },
'prompt-id-malformed',
);
@@ -747,7 +877,7 @@ describe('GeminiChat', () => {
// 2. Send a message
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.5-pro',
{ message: 'test retry' },
'prompt-id-retry-malformed',
);
@@ -858,6 +988,38 @@ describe('GeminiChat', () => {
});
describe('sendMessageStream with retries', () => {
it('should not retry on invalid content if model does not start with gemini-2', async () => {
// Mock the stream to fail.
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async () =>
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: '' }] } }],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
'gemini-1.5-pro',
{ message: 'test' },
'prompt-id-no-retry',
);
await expect(
(async () => {
for await (const _ of stream) {
// Must loop to trigger the internal logic that throws.
}
})(),
).rejects.toThrow(InvalidStreamError);
// Should be called only 1 time (no retry)
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
1,
);
expect(mockLogContentRetry).not.toHaveBeenCalled();
});
it('should yield a RETRY event when an invalid stream is encountered', async () => {
// ARRANGE: Mock the stream to fail once, then succeed.
vi.mocked(mockContentGenerator.generateContentStream)
@@ -885,7 +1047,7 @@ describe('GeminiChat', () => {
// ACT: Send a message and collect all events from the stream.
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-yield-retry',
);
@@ -926,7 +1088,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-retry-success',
);
@@ -997,7 +1159,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test', config: { temperature: 0.5 } },
'prompt-id-retry-temperature',
);
@@ -1055,7 +1217,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-retry-fail',
);
@@ -1120,7 +1282,7 @@ describe('GeminiChat', () => {
);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-400',
);
@@ -1325,7 +1487,7 @@ describe('GeminiChat', () => {
// 3. Send a new message
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'Second question' },
'prompt-id-retry-existing',
);
@@ -1396,7 +1558,7 @@ describe('GeminiChat', () => {
// 2. Call the method and consume the stream.
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test empty stream' },
'prompt-id-empty-stream',
);
@@ -1665,7 +1827,7 @@ describe('GeminiChat', () => {
mockHandleFallback.mockResolvedValue(false);
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test stop' },
'prompt-id-fb2',
);
@@ -1723,7 +1885,7 @@ describe('GeminiChat', () => {
// Send a message and consume the stream
const stream = await chat.sendMessageStream(
'test-model',
'gemini-2.0-flash',
{ message: 'test' },
'prompt-id-discard-test',
);
@@ -1785,4 +1947,177 @@ describe('GeminiChat', () => {
]);
});
});
describe('Preview Model Fallback Logic', () => {
it('should reset previewModelBypassMode to false at the start of sendMessageStream', async () => {
const stream = (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
stream,
);
await chat.sendMessageStream(
'test-model',
{ message: 'test' },
'prompt-id-preview-model-reset',
);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(false);
});
it('should reset previewModelFallbackMode to false upon successful Preview Model usage', async () => {
const stream = (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
stream,
);
const resultStream = await chat.sendMessageStream(
PREVIEW_GEMINI_MODEL,
{ message: 'test' },
'prompt-id-preview-model-healing',
);
for await (const _ of resultStream) {
// consume stream
}
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(
false,
);
});
it('should NOT reset previewModelFallbackMode if Preview Model was bypassed (downgraded)', async () => {
const stream = (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
stream,
);
// Simulate bypass mode being active (downgrade happened)
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
const resultStream = await chat.sendMessageStream(
PREVIEW_GEMINI_MODEL,
{ message: 'test' },
'prompt-id-bypass-no-healing',
);
for await (const _ of resultStream) {
// consume stream
}
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
});
});
describe('ensureActiveLoopHasThoughtSignatures', () => {
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
const chat = new GeminiChat(mockConfig, {}, []);
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Old message' }] },
{
role: 'model',
parts: [{ functionCall: { name: 'old_tool', args: {} } }],
},
{ role: 'user', parts: [{ text: 'Find a restaurant' }] }, // active loop starts here
{
role: 'model',
parts: [
{ functionCall: { name: 'find_restaurant', args: {} } }, // This one gets a signature
{ functionCall: { name: 'find_restaurant_2', args: {} } }, // This one does NOT
],
},
{
role: 'user',
parts: [
{ functionResponse: { name: 'find_restaurant', response: {} } },
],
},
{
role: 'model',
parts: [
{
functionCall: { name: 'tool_with_sig', args: {} },
thoughtSignature: 'existing-sig',
},
{ functionCall: { name: 'another_tool', args: {} } }, // This one does NOT get a signature
],
},
];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
// Outside active loop - unchanged
expect(newContents[1]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
// Inside active loop, first model turn
// First function call gets a signature
expect(newContents[3]?.parts?.[0]?.thoughtSignature).toBe(
SYNTHETIC_THOUGHT_SIGNATURE,
);
// Second function call does NOT
expect(newContents[3]?.parts?.[1]).not.toHaveProperty('thoughtSignature');
// User functionResponse part - unchanged (this is not a model turn)
expect(newContents[4]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
// Inside active loop, second model turn
// First function call already has a signature, so nothing changes
expect(newContents[5]?.parts?.[0]?.thoughtSignature).toBe('existing-sig');
// Second function call does NOT get a signature
expect(newContents[5]?.parts?.[1]).not.toHaveProperty('thoughtSignature');
});
it('should not modify contents if there is no user text message', () => {
const chat = new GeminiChat(mockConfig, {}, []);
const history: Content[] = [
{
role: 'user',
parts: [{ functionResponse: { name: 'tool1', response: {} } }],
},
{
role: 'model',
parts: [{ functionCall: { name: 'tool2', args: {} } }],
},
];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
expect(newContents).toEqual(history);
expect(newContents[1]?.parts?.[0]).not.toHaveProperty('thoughtSignature');
});
it('should handle an empty history', () => {
const chat = new GeminiChat(mockConfig, {}, []);
const history: Content[] = [];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
expect(newContents).toEqual([]);
});
it('should handle history with only a user message', () => {
const chat = new GeminiChat(mockConfig, {}, []);
const history: Content[] = [{ role: 'user', parts: [{ text: 'Hello' }] }];
const newContents = chat.ensureActiveLoopHasThoughtSignatures(history);
expect(newContents).toEqual(history);
});
});
});
+108 -17
View File
@@ -20,8 +20,10 @@ import { createUserContent, FinishReason } from '@google/genai';
import { retryWithBackoff } from '../utils/retry.js';
import type { Config } from '../config/config.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
getEffectiveModel,
isGemini2Model,
} from '../config/models.js';
import { hasCycleInSchema } from '../tools/tools.js';
import type { StructuredError } from './turn.js';
@@ -69,6 +71,8 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
initialDelayMs: 500,
};
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
/**
* Returns true if the response is valid, false otherwise.
*/
@@ -243,6 +247,11 @@ export class GeminiChat {
): Promise<AsyncGenerator<StreamEvent>> {
await this.sendPromise;
// Preview Model Bypass mode for the new request.
// This ensures that we attempt to use Preview Model for every new user turn
// (unless the "Always" fallback mode is active, which is handled separately).
this.config.setPreviewModelBypassMode(false);
let streamDoneResolver: () => void;
const streamDonePromise = new Promise<void>((resolve) => {
streamDoneResolver = resolve;
@@ -275,11 +284,17 @@ export class GeminiChat {
try {
let lastError: unknown = new Error('Request failed after all retries.');
for (
let attempt = 0;
attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
attempt++
let maxAttempts = INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
// If we are in Preview Model Fallback Mode, we want to fail fast (1 attempt)
// when probing the Preview Model.
if (
self.config.isPreviewModelFallbackMode() &&
model === PREVIEW_GEMINI_MODEL
) {
maxAttempts = 1;
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
if (attempt > 0) {
yield { type: StreamEventType.RETRY };
@@ -311,9 +326,9 @@ export class GeminiChat {
lastError = error;
const isContentError = error instanceof InvalidStreamError;
if (isContentError) {
if (isContentError && isGemini2Model(model)) {
// Check if we have more attempts left.
if (attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts - 1) {
if (attempt < maxAttempts - 1) {
logContentRetry(
self.config,
new ContentRetryEvent(
@@ -338,17 +353,29 @@ export class GeminiChat {
}
if (lastError) {
if (lastError instanceof InvalidStreamError) {
if (
lastError instanceof InvalidStreamError &&
isGemini2Model(model)
) {
logContentRetryFailure(
self.config,
new ContentRetryFailureEvent(
INVALID_CONTENT_RETRY_OPTIONS.maxAttempts,
maxAttempts,
(lastError as InvalidStreamError).type,
model,
),
);
}
throw lastError;
} else {
// Preview Model successfully used, disable fallback mode.
// We only do this if we didn't bypass Preview Model (i.e. we actually used it).
if (
model === PREVIEW_GEMINI_MODEL &&
!self.config.isPreviewModelBypassMode()
) {
self.config.setPreviewModelFallbackMode(false);
}
}
} finally {
streamDoneResolver!();
@@ -362,25 +389,35 @@ export class GeminiChat {
params: SendMessageParameters,
prompt_id: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
let effectiveModel = model;
const contentsForPreviewModel =
this.ensureActiveLoopHasThoughtSignatures(requestContents);
const apiCall = () => {
const modelToUse = getEffectiveModel(
let modelToUse = getEffectiveModel(
this.config.isInFallbackMode(),
model,
this.config.getPreviewFeatures(),
);
// Preview Model Bypass Logic:
// If we are in "Preview Model Bypass Mode" (transient failure), we force downgrade to 2.5 Pro
// IF the effective model is currently Preview Model.
if (
this.config.getQuotaErrorOccurred() &&
modelToUse === DEFAULT_GEMINI_FLASH_MODEL
this.config.isPreviewModelBypassMode() &&
modelToUse === PREVIEW_GEMINI_MODEL
) {
throw new Error(
'Please submit a new query to continue with the Flash model.',
);
modelToUse = DEFAULT_GEMINI_MODEL;
}
effectiveModel = modelToUse;
return this.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
contents: requestContents,
contents:
modelToUse === PREVIEW_GEMINI_MODEL
? contentsForPreviewModel
: requestContents,
config: { ...this.generationConfig, ...params.config },
},
prompt_id,
@@ -390,13 +427,18 @@ export class GeminiChat {
const onPersistent429Callback = async (
authType?: string,
error?: unknown,
) => await handleFallback(this.config, model, authType, error);
) => await handleFallback(this.config, effectiveModel, authType, error);
const streamResponse = await retryWithBackoff(apiCall, {
onPersistent429: onPersistent429Callback,
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
signal: params.config?.abortSignal,
maxAttempts:
this.config.isPreviewModelFallbackMode() &&
model === PREVIEW_GEMINI_MODEL
? 1
: undefined,
});
return this.processStreamResponse(model, streamResponse);
@@ -469,6 +511,55 @@ export class GeminiChat {
});
}
// To ensure our requests validate, the first function call in every model
// turn within the active loop must have a `thoughtSignature` property.
// If we do not do this, we will get back 400 errors from the API.
ensureActiveLoopHasThoughtSignatures(requestContents: Content[]): Content[] {
// First, find the start of the active loop by finding the last user turn
// with a text message, i.e. that is not a function response.
let activeLoopStartIndex = -1;
for (let i = requestContents.length - 1; i >= 0; i--) {
const content = requestContents[i];
if (content.role === 'user' && content.parts?.some((part) => part.text)) {
activeLoopStartIndex = i;
break;
}
}
if (activeLoopStartIndex === -1) {
return requestContents;
}
// Iterate through every message in the active loop, ensuring that the first
// function call in each message's list of parts has a valid
// thoughtSignature property. If it does not we replace the function call
// with a copy that uses the synthetic thought signature.
const newContents = requestContents.slice(); // Shallow copy the array
for (let i = activeLoopStartIndex; i < newContents.length; i++) {
const content = newContents[i];
if (content.role === 'model' && content.parts) {
const newParts = content.parts.slice();
for (let j = 0; j < newParts.length; j++) {
const part = newParts[j]!;
if (part.functionCall) {
if (!part.thoughtSignature) {
newParts[j] = {
...part,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
};
newContents[i] = {
...content,
parts: newParts,
};
}
break; // Only consider the first function call
}
}
}
}
return newContents;
}
setTools(tools: Tool[]): void {
this.generationConfig.tools = tools;
}
+113 -3
View File
@@ -20,9 +20,11 @@ import { AuthType } from '../core/contentGenerator.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
} from '../config/models.js';
import { logFlashFallback } from '../telemetry/index.js';
import type { FallbackModelHandler } from './types.js';
import { ModelNotFoundError } from '../utils/httpErrors.js';
// Mock the telemetry logger and event class
vi.mock('../telemetry/index.js', () => ({
@@ -39,7 +41,12 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
({
isInFallbackMode: vi.fn(() => false),
setFallbackMode: vi.fn(),
isPreviewModelFallbackMode: vi.fn(() => false),
setPreviewModelFallbackMode: vi.fn(),
isPreviewModelBypassMode: vi.fn(() => false),
setPreviewModelBypassMode: vi.fn(),
fallbackHandler: undefined,
getFallbackModelHandler: vi.fn(),
isInteractive: vi.fn(() => false),
...overrides,
}) as unknown as Config;
@@ -99,7 +106,7 @@ describe('handleFallback', () => {
describe('when handler returns "retry"', () => {
it('should activate fallback mode, log telemetry, and return true', async () => {
mockHandler.mockResolvedValue('retry');
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(
mockConfig,
@@ -152,7 +159,7 @@ describe('handleFallback', () => {
it('should pass the correct context (failedModel, fallbackModel, error) to the handler', async () => {
const mockError = new Error('Quota Exceeded');
mockHandler.mockResolvedValue('retry');
mockHandler.mockResolvedValue('retry_always');
await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH, mockError);
@@ -171,7 +178,7 @@ describe('handleFallback', () => {
setFallbackMode: vi.fn(),
});
mockHandler.mockResolvedValue('retry');
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(
activeFallbackConfig,
@@ -201,4 +208,107 @@ describe('handleFallback', () => {
);
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
});
describe('Preview Model Fallback Logic', () => {
const previewModel = PREVIEW_GEMINI_MODEL;
it('should always set Preview Model bypass mode on failure', async () => {
await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
});
it('should silently retry if Preview Model fallback mode is already active', async () => {
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(true);
const result = await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
expect(result).toBe(true);
expect(mockHandler).not.toHaveBeenCalled();
});
it('should activate Preview Model fallback mode when handler returns "retry_always"', async () => {
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(mockConfig, previewModel, AUTH_OAUTH);
expect(result).toBe(true);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(true);
});
it('should NOT set fallback mode if user chooses "retry_once"', async () => {
mockHandler.mockResolvedValue('retry_once');
const result = await handleFallback(
mockConfig,
PREVIEW_GEMINI_MODEL,
AuthType.LOGIN_WITH_GOOGLE,
new Error('Capacity'),
);
expect(result).toBe(true);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
});
it('should set fallback mode if user chooses "retry_always"', async () => {
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(
mockConfig,
PREVIEW_GEMINI_MODEL,
AuthType.LOGIN_WITH_GOOGLE,
new Error('Capacity'),
);
expect(result).toBe(true);
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(true);
});
it('should pass DEFAULT_GEMINI_MODEL as fallback when Preview Model fails', async () => {
const mockFallbackHandler = vi.fn().mockResolvedValue('stop');
vi.mocked(mockConfig.fallbackModelHandler!).mockImplementation(
mockFallbackHandler,
);
await handleFallback(
mockConfig,
PREVIEW_GEMINI_MODEL,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
undefined,
);
});
});
it('should return null if ModelNotFoundError occurs for a non-preview model', async () => {
const modelNotFoundError = new ModelNotFoundError('Not found');
const result = await handleFallback(
mockConfig,
DEFAULT_GEMINI_MODEL, // Not preview model
AUTH_OAUTH,
modelNotFoundError,
);
expect(result).toBeNull();
expect(mockHandler).not.toHaveBeenCalled();
});
it('should consult handler if ModelNotFoundError occurs for preview model', async () => {
const modelNotFoundError = new ModelNotFoundError('Not found');
mockHandler.mockResolvedValue('retry_always');
const result = await handleFallback(
mockConfig,
PREVIEW_GEMINI_MODEL,
AUTH_OAUTH,
modelNotFoundError,
);
expect(result).toBe(true);
expect(mockHandler).toHaveBeenCalled();
});
});
+67 -4
View File
@@ -6,9 +6,19 @@
import type { Config } from '../config/config.js';
import { AuthType } from '../core/contentGenerator.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
} from '../config/models.js';
import { logFlashFallback, FlashFallbackEvent } from '../telemetry/index.js';
import { coreEvents } from '../utils/events.js';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getErrorMessage } from '../utils/errors.js';
import { ModelNotFoundError } from '../utils/httpErrors.js';
const UPGRADE_URL_PAGE = 'https://goo.gle/set-up-gemini-code-assist';
export async function handleFallback(
config: Config,
@@ -19,7 +29,31 @@ export async function handleFallback(
// Applicability Checks
if (authType !== AuthType.LOGIN_WITH_GOOGLE) return null;
const fallbackModel = DEFAULT_GEMINI_FLASH_MODEL;
// Guardrail: If it's a ModelNotFoundError but NOT the preview model, do not handle it.
if (
error instanceof ModelNotFoundError &&
failedModel !== PREVIEW_GEMINI_MODEL
) {
return null;
}
// Preview Model Specific Logic
if (failedModel === PREVIEW_GEMINI_MODEL) {
// Always set bypass mode for the immediate retry.
// This ensures the next attempt uses 2.5 Pro.
config.setPreviewModelBypassMode(true);
// If we are already in Preview Model fallback mode (user previously said "Always"),
// we silently retry (which will use 2.5 Pro due to bypass mode).
if (config.isPreviewModelFallbackMode()) {
return true;
}
}
const fallbackModel =
failedModel === PREVIEW_GEMINI_MODEL
? DEFAULT_GEMINI_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
// Consult UI Handler for Intent
const fallbackModelHandler = config.fallbackModelHandler;
@@ -35,11 +69,18 @@ export async function handleFallback(
// Process Intent and Update State
switch (intent) {
case 'retry':
// Activate fallback mode. The NEXT retry attempt will pick this up.
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.
}
}
+4 -2
View File
@@ -8,9 +8,11 @@
* Defines the intent returned by the UI layer during a fallback scenario.
*/
export type FallbackIntent =
| 'retry' // Immediately retry the current request with the fallback model.
| 'retry_always' // Retry with fallback model and stick to it for future requests.
| 'retry_once' // Retry with fallback model for this request only.
| 'stop' // Switch to fallback for future requests, but stop the current request.
| 'retry_later'; // Stop the current request and do not fallback. Intend to try again later with the same model.
| 'retry_later' // Stop the current request and do not fallback. Intend to try again later with the same model.
| 'upgrade'; // Give user an option to upgrade the tier.
/**
* The interface for the handler provided by the UI layer (e.g., the CLI)
+6
View File
@@ -88,6 +88,12 @@ describe('detectIde', () => {
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
});
it('should detect AntiGravity', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
});
});
describe('detectIde with ideInfoFromFile', () => {
+4
View File
@@ -14,6 +14,7 @@ export const IDE_DEFINITIONS = {
trae: { name: 'trae', displayName: 'Trae' },
vscode: { name: 'vscode', displayName: 'VS Code' },
vscodefork: { name: 'vscodefork', displayName: 'IDE' },
antigravity: { name: 'antigravity', displayName: 'Antigravity' },
} as const;
export interface IdeInfo {
@@ -26,6 +27,9 @@ export function isCloudShell(): boolean {
}
export function detectIdeFromEnv(): IdeInfo {
if (process.env['ANTIGRAVITY_CLI_ALIAS']) {
return IDE_DEFINITIONS.antigravity;
}
if (process.env['__COG_BASHRC_SOURCED']) {
return IDE_DEFINITIONS.devin;
}
+5 -4
View File
@@ -137,11 +137,12 @@ export class IdeClient {
this.trustChangeListeners.delete(listener);
}
async connect(): Promise<void> {
async connect(options: { logToConsole?: boolean } = {}): Promise<void> {
const logError = options.logToConsole ?? true;
if (!this.currentIde) {
this.setState(
IDEConnectionStatus.Disconnected,
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks`,
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
false,
);
return;
@@ -163,7 +164,7 @@ export class IdeClient {
);
if (!isValid) {
this.setState(IDEConnectionStatus.Disconnected, error, true);
this.setState(IDEConnectionStatus.Disconnected, error, logError);
return;
}
@@ -205,7 +206,7 @@ export class IdeClient {
this.setState(
IDEConnectionStatus.Disconnected,
`Failed to connect to IDE companion extension in ${this.currentIde.displayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
true,
logError,
);
}
@@ -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');
});
});
+64 -10
View File
@@ -12,10 +12,6 @@ import * as os from 'node:os';
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
import { GEMINI_CLI_COMPANION_EXTENSION_NAME } from './constants.js';
function getVsCodeCommand(platform: NodeJS.Platform = process.platform) {
return platform === 'win32' ? 'code.cmd' : 'code';
}
export interface IdeInstaller {
install(): Promise<InstallResult>;
}
@@ -25,15 +21,15 @@ export interface InstallResult {
message: string;
}
async function findVsCodeCommand(
async function findCommand(
command: string,
platform: NodeJS.Platform = process.platform,
): Promise<string | null> {
// 1. Check PATH first.
const vscodeCommand = getVsCodeCommand(platform);
try {
if (platform === 'win32') {
const result = child_process
.execSync(`where.exe ${vscodeCommand}`)
.execSync(`where.exe ${command}`)
.toString()
.trim();
// `where.exe` can return multiple paths. Return the first one.
@@ -42,10 +38,10 @@ async function findVsCodeCommand(
return firstPath;
}
} else {
child_process.execSync(`command -v ${vscodeCommand}`, {
child_process.execSync(`command -v ${command}`, {
stdio: 'ignore',
});
return vscodeCommand;
return command;
}
} catch {
// Not in PATH, continue to check common locations.
@@ -55,6 +51,7 @@ async function findVsCodeCommand(
const locations: string[] = [];
const homeDir = os.homedir();
if (command === 'code' || command === 'code.cmd') {
if (platform === 'darwin') {
// macOS
locations.push(
@@ -88,6 +85,7 @@ async function findVsCodeCommand(
),
);
}
}
for (const location of locations) {
if (fs.existsSync(location)) {
@@ -105,7 +103,8 @@ class VsCodeInstaller implements IdeInstaller {
readonly ideInfo: IdeInfo,
readonly platform = process.platform,
) {
this.vsCodeCommand = findVsCodeCommand(platform);
const command = platform === 'win32' ? 'code.cmd' : 'code';
this.vsCodeCommand = findCommand(command, platform);
}
async install(): Promise<InstallResult> {
@@ -147,6 +146,59 @@ class VsCodeInstaller implements IdeInstaller {
}
}
class AntigravityInstaller implements IdeInstaller {
constructor(
readonly ideInfo: IdeInfo,
readonly platform = process.platform,
) {}
async install(): Promise<InstallResult> {
const command = process.env['ANTIGRAVITY_CLI_ALIAS'];
if (!command) {
return {
success: false,
message: 'ANTIGRAVITY_CLI_ALIAS environment variable not set.',
};
}
const commandPath = await findCommand(command, this.platform);
if (!commandPath) {
return {
success: false,
message: `${command} not found. Please ensure it is in your system's PATH.`,
};
}
try {
const result = child_process.spawnSync(
commandPath,
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: this.platform === 'win32' },
);
if (result.status !== 0) {
throw new Error(
`Failed to install extension: ${result.stderr?.toString()}`,
);
}
return {
success: true,
message: `${this.ideInfo.displayName} companion extension was installed successfully.`,
};
} catch (_error) {
return {
success: false,
message: `Failed to install ${this.ideInfo.displayName} companion extension. Please try installing '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' manually from the ${this.ideInfo.displayName} extension marketplace.`,
};
}
}
}
export function getIdeInstaller(
ide: IdeInfo,
platform = process.platform,
@@ -155,6 +207,8 @@ export function getIdeInstaller(
case IDE_DEFINITIONS.vscode.name:
case IDE_DEFINITIONS.firebasestudio.name:
return new VsCodeInstaller(ide, platform);
case IDE_DEFINITIONS.antigravity.name:
return new AntigravityInstaller(ide, platform);
default:
return null;
}
+1
View File
@@ -7,6 +7,7 @@
// Export config
export * from './config/config.js';
export * from './config/defaultModelConfigs.js';
export * from './config/models.js';
export * from './output/types.js';
export * from './output/json-formatter.js';
export * from './output/stream-json-formatter.js';
@@ -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);
});
});
});
@@ -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,
@@ -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(),
@@ -13,8 +13,9 @@ import type {
RoutingStrategy,
} from '../routingStrategy.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_PRO,
resolveModel,
} from '../../config/models.js';
import { createUserContent, Type } from '@google/genai';
import type { Config } from '../../config/config.js';
@@ -131,7 +132,7 @@ export class ClassifierStrategy implements RoutingStrategy {
async route(
context: RoutingContext,
_config: Config,
config: Config,
baseLlmClient: BaseLlmClient,
): Promise<RoutingDecision | null> {
const startTime = Date.now();
@@ -173,7 +174,10 @@ export class ClassifierStrategy implements RoutingStrategy {
if (routerResponse.model_choice === FLASH_MODEL) {
return {
model: DEFAULT_GEMINI_FLASH_MODEL,
model: resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
config.getPreviewFeatures(),
),
metadata: {
source: 'Classifier',
latencyMs,
@@ -182,7 +186,10 @@ export class ClassifierStrategy implements RoutingStrategy {
};
} else {
return {
model: DEFAULT_GEMINI_MODEL,
model: resolveModel(
GEMINI_MODEL_ALIAS_PRO,
config.getPreviewFeatures(),
),
metadata: {
source: 'Classifier',
reasoning,
@@ -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(
@@ -30,6 +30,7 @@ export class FallbackStrategy implements RoutingStrategy {
const effectiveModel = getEffectiveModel(
isInFallbackMode,
config.getModel(),
config.getPreviewFeatures(),
);
return {
model: effectiveModel,
@@ -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);
@@ -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,
+11
View File
@@ -72,6 +72,11 @@ describe('editor utils', () => {
{ editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{ editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] },
{
editor: 'antigravity',
commands: ['agy'],
win32Commands: ['agy.cmd'],
},
];
for (const { editor, commands, win32Commands } of testCases) {
@@ -171,6 +176,11 @@ describe('editor utils', () => {
},
{ editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{
editor: 'antigravity',
commands: ['agy'],
win32Commands: ['agy.cmd'],
},
];
for (const { editor, commands, win32Commands } of guiEditors) {
@@ -430,6 +440,7 @@ describe('editor utils', () => {
'windsurf',
'cursor',
'zed',
'antigravity',
];
for (const editor of guiEditors) {
it(`should not call onEditorClose for ${editor}`, async () => {
+26 -2
View File
@@ -15,7 +15,24 @@ export type EditorType =
| 'vim'
| 'neovim'
| 'zed'
| 'emacs';
| 'emacs'
| 'antigravity';
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
vscode: 'VS Code',
vscodium: 'VSCodium',
windsurf: 'Windsurf',
cursor: 'Cursor',
vim: 'Vim',
neovim: 'Neovim',
zed: 'Zed',
emacs: 'Emacs',
antigravity: 'Antigravity',
};
export function getEditorDisplayName(editor: EditorType): string {
return EDITOR_DISPLAY_NAMES[editor] || editor;
}
function isValidEditorType(editor: string): editor is EditorType {
return [
@@ -27,6 +44,7 @@ function isValidEditorType(editor: string): editor is EditorType {
'neovim',
'zed',
'emacs',
'antigravity',
].includes(editor);
}
@@ -63,6 +81,7 @@ const editorCommands: Record<
neovim: { win32: ['nvim'], default: ['nvim'] },
zed: { win32: ['zed'], default: ['zed', 'zeditor'] },
emacs: { win32: ['emacs.exe'], default: ['emacs'] },
antigravity: { win32: ['agy.cmd'], default: ['agy'] },
};
export function checkHasEditorType(editor: EditorType): boolean {
@@ -74,7 +93,11 @@ export function checkHasEditorType(editor: EditorType): boolean {
export function allowEditorTypeInSandbox(editor: EditorType): boolean {
const notUsingSandbox = !process.env['SANDBOX'];
if (['vscode', 'vscodium', 'windsurf', 'cursor', 'zed'].includes(editor)) {
if (
['vscode', 'vscodium', 'windsurf', 'cursor', 'zed', 'antigravity'].includes(
editor,
)
) {
return notUsingSandbox;
}
// For terminal-based editors like vim and emacs, allow in sandbox.
@@ -116,6 +139,7 @@ export function getDiffCommand(
case 'windsurf':
case 'cursor':
case 'zed':
case 'antigravity':
return { command, args: ['--wait', '--diff', oldPath, newPath] };
case 'vim':
case 'neovim':
@@ -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.
+24 -9
View File
@@ -11,17 +11,22 @@ import type {
RetryInfo,
} from './googleErrors.js';
import { parseGoogleApiError } from './googleErrors.js';
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
/**
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
*/
export class TerminalQuotaError extends Error {
retryDelayMs?: number;
constructor(
message: string,
override readonly cause: GoogleApiError,
retryDelayMs?: number,
) {
super(message);
this.name = 'TerminalQuotaError';
this.retryDelayMs = retryDelayMs ? retryDelayMs * 1000 : undefined;
}
}
@@ -75,6 +80,14 @@ function parseDurationInSeconds(duration: string): number | null {
*/
export function classifyGoogleError(error: unknown): unknown {
const googleApiError = parseGoogleApiError(error);
const status = googleApiError?.code ?? getErrorStatus(error);
if (status === 404) {
const message =
googleApiError?.message ||
(error instanceof Error ? error.message : 'Model not found');
return new ModelNotFoundError(message, status);
}
if (!googleApiError || googleApiError.code !== 429) {
// Fallback: try to parse the error message for a retry delay
@@ -125,6 +138,14 @@ export function classifyGoogleError(error: unknown): unknown {
}
}
}
let delaySeconds;
if (retryInfo?.retryDelay) {
const parsedDelay = parseDurationInSeconds(retryInfo.retryDelay);
if (parsedDelay) {
delaySeconds = parsedDelay;
}
}
if (errorInfo) {
// New Cloud Code API quota handling
@@ -136,23 +157,17 @@ export function classifyGoogleError(error: unknown): unknown {
];
if (validDomains.includes(errorInfo.domain)) {
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
let delaySeconds = 10; // Default retry of 10s
if (retryInfo?.retryDelay) {
const parsedDelay = parseDurationInSeconds(retryInfo.retryDelay);
if (parsedDelay) {
delaySeconds = parsedDelay;
}
}
return new RetryableQuotaError(
`${googleApiError.message}`,
googleApiError,
delaySeconds,
delaySeconds ?? 10,
);
}
if (errorInfo.reason === 'QUOTA_EXHAUSTED') {
return new TerminalQuotaError(
`${googleApiError.message}`,
googleApiError,
delaySeconds,
);
}
}
@@ -170,12 +185,12 @@ export function classifyGoogleError(error: unknown): unknown {
// 2. Check for long delays in RetryInfo
if (retryInfo?.retryDelay) {
const delaySeconds = parseDurationInSeconds(retryInfo.retryDelay);
if (delaySeconds) {
if (delaySeconds > 120) {
return new TerminalQuotaError(
`${googleApiError.message}\nSuggested retry after ${retryInfo.retryDelay}.`,
googleApiError,
delaySeconds,
);
}
// This is a retryable error with a specific delay.
+45
View File
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface HttpError extends Error {
status?: number;
}
/**
* Extracts the HTTP status code from an error object.
* @param error The error object.
* @returns The HTTP status code, or undefined if not found.
*/
export function getErrorStatus(error: unknown): number | undefined {
if (typeof error === 'object' && error !== null) {
if ('status' in error && typeof error.status === 'number') {
return error.status;
}
// Check for error.response.status (common in axios errors)
if (
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: unknown }).response !== null
) {
const response = (
error as { response: { status?: unknown; headers?: unknown } }
).response;
if ('status' in response && typeof response.status === 'number') {
return response.status;
}
}
}
return undefined;
}
export class ModelNotFoundError extends Error {
code: number;
constructor(message: string, code?: number) {
super(message);
this.name = 'ModelNotFoundError';
this.code = code ? code : 404;
}
}
+66 -1
View File
@@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApiError } from '@google/genai';
import { AuthType } from '../core/contentGenerator.js';
import type { HttpError } from './retry.js';
import { type HttpError, ModelNotFoundError } from './httpErrors.js';
import { retryWithBackoff } from './retry.js';
import { setSimulate429 } from './testUtils.js';
import { debugLogger } from './debugLogger.js';
@@ -16,6 +16,7 @@ import {
TerminalQuotaError,
RetryableQuotaError,
} from './googleQuotaErrors.js';
import { PREVIEW_GEMINI_MODEL } from '../config/models.js';
// Helper to create a mock function that fails a certain number of times
const createFailingFunction = (
@@ -433,4 +434,68 @@ describe('retryWithBackoff', () => {
);
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should trigger fallback for OAuth personal users on persistent 500 errors', async () => {
const fallbackCallback = vi.fn().mockResolvedValue('gemini-2.5-flash');
let fallbackOccurred = false;
const mockFn = vi.fn().mockImplementation(async () => {
if (!fallbackOccurred) {
const error: HttpError = new Error('Internal Server Error');
error.status = 500;
throw error;
}
return 'success';
});
const promise = retryWithBackoff(mockFn, {
maxAttempts: 3,
initialDelayMs: 100,
onPersistent429: async (authType?: string, error?: unknown) => {
fallbackOccurred = true;
return await fallbackCallback(authType, error);
},
authType: AuthType.LOGIN_WITH_GOOGLE,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(fallbackCallback).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
expect.objectContaining({ status: 500 }),
);
// 3 attempts (initial + 2 retries) fail with 500, then fallback triggers, then 1 success
expect(mockFn).toHaveBeenCalledTimes(4);
});
it('should trigger fallback for OAuth personal users on ModelNotFoundError', async () => {
const fallbackCallback = vi.fn().mockResolvedValue(PREVIEW_GEMINI_MODEL);
let fallbackOccurred = false;
const mockFn = vi.fn().mockImplementation(async () => {
if (!fallbackOccurred) {
throw new ModelNotFoundError('Requested entity was not found.', 404);
}
return 'success';
});
const promise = retryWithBackoff(mockFn, {
maxAttempts: 3,
initialDelayMs: 100,
onPersistent429: async (authType?: string, error?: unknown) => {
fallbackOccurred = true;
return await fallbackCallback(authType, error);
},
authType: AuthType.LOGIN_WITH_GOOGLE,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(fallbackCallback).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
expect.any(ModelNotFoundError),
);
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
+26 -34
View File
@@ -14,14 +14,11 @@ import {
} from './googleQuotaErrors.js';
import { delay, createAbortError } from './delay.js';
import { debugLogger } from './debugLogger.js';
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
const FETCH_FAILED_MESSAGE =
'exception TypeError: fetch failed sending request';
export interface HttpError extends Error {
status?: number;
}
export interface RetryOptions {
maxAttempts: number;
initialDelayMs: number;
@@ -146,8 +143,12 @@ export async function retryWithBackoff<T>(
}
const classifiedError = classifyGoogleError(error);
const errorCode = getErrorStatus(error);
if (classifiedError instanceof TerminalQuotaError) {
if (
classifiedError instanceof TerminalQuotaError ||
classifiedError instanceof ModelNotFoundError
) {
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
try {
const fallbackModel = await onPersistent429(
@@ -166,7 +167,10 @@ export async function retryWithBackoff<T>(
throw classifiedError; // Throw if no fallback or fallback failed.
}
if (classifiedError instanceof RetryableQuotaError) {
const is500 =
errorCode !== undefined && errorCode >= 500 && errorCode < 600;
if (classifiedError instanceof RetryableQuotaError || is500) {
if (attempt >= maxAttempts) {
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
try {
@@ -183,13 +187,28 @@ export async function retryWithBackoff<T>(
console.warn('Model fallback failed:', fallbackError);
}
}
throw classifiedError;
throw classifiedError instanceof RetryableQuotaError
? classifiedError
: error;
}
if (classifiedError instanceof RetryableQuotaError) {
console.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
await delay(classifiedError.retryDelayMs, signal);
continue;
} else {
const errorStatus = getErrorStatus(error);
logRetryAttempt(attempt, error, errorStatus);
// Exponential backoff with jitter for non-quota errors
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
await delay(delayWithJitter, signal);
currentDelay = Math.min(maxDelayMs, currentDelay * 2);
continue;
}
}
// Generic retry logic for other errors
@@ -214,33 +233,6 @@ export async function retryWithBackoff<T>(
throw new Error('Retry attempts exhausted');
}
/**
* Extracts the HTTP status code from an error object.
* @param error The error object.
* @returns The HTTP status code, or undefined if not found.
*/
export function getErrorStatus(error: unknown): number | undefined {
if (typeof error === 'object' && error !== null) {
if ('status' in error && typeof error.status === 'number') {
return error.status;
}
// Check for error.response.status (common in axios errors)
if (
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: unknown }).response !== null
) {
const response = (
error as { response: { status?: unknown; headers?: unknown } }
).response;
if ('status' in response && typeof response.status === 'number') {
return response.status;
}
}
}
return undefined;
}
/**
* Logs a message for a retry attempt when using exponential backoff.
* @param attempt The current attempt number.
+7
View File
@@ -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.",