diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx
index 409a6469f6..37823cf8a8 100644
--- a/packages/cli/src/ui/IdeIntegrationNudge.tsx
+++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx
@@ -6,8 +6,10 @@
import type { IdeInfo } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
-import type { RadioSelectItem } from './components/shared/RadioButtonSelect.js';
-import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
import { theme } from './semantic-colors.js';
diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx
index 4e523d6b11..c823f606c6 100644
--- a/packages/cli/src/ui/auth/AuthDialog.tsx
+++ b/packages/cli/src/ui/auth/AuthDialog.tsx
@@ -9,11 +9,11 @@ import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
-import type {
- LoadableSettingScope,
- LoadedSettings,
+import {
+ SettingScope,
+ type LoadableSettingScope,
+ type LoadedSettings,
} from '../../config/settings.js';
-import { SettingScope } from '../../config/settings.js';
import {
AuthType,
clearCachedCredentialFile,
diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx
index 819b32d7b0..3f5d348a45 100644
--- a/packages/cli/src/ui/components/AgentConfigDialog.tsx
+++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx
@@ -8,11 +8,11 @@ import type React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
-import type {
- LoadableSettingScope,
- LoadedSettings,
+import {
+ SettingScope,
+ type LoadableSettingScope,
+ type LoadedSettings,
} from '../../config/settings.js';
-import { SettingScope } from '../../config/settings.js';
import type { AgentDefinition, AgentOverride } from '@google/gemini-cli-core';
import { getCachedStringWidth } from '../utils/textUtils.js';
import {
diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx
index 4233616144..eec633b7de 100644
--- a/packages/cli/src/ui/components/AskUserDialog.tsx
+++ b/packages/cli/src/ui/components/AskUserDialog.tsx
@@ -15,13 +15,12 @@ import {
} from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
-import type { Question } from '@google/gemini-cli-core';
+import { checkExhaustive, type Question } from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
-import { checkExhaustive } from '@google/gemini-cli-core';
import { TextInput } from './shared/TextInput.js';
import { formatCommand } from '../key/keybindingUtils.js';
import {
diff --git a/packages/cli/src/ui/components/Checklist.tsx b/packages/cli/src/ui/components/Checklist.tsx
index cfbd4268fd..d9fb51278c 100644
--- a/packages/cli/src/ui/components/Checklist.tsx
+++ b/packages/cli/src/ui/components/Checklist.tsx
@@ -5,9 +5,9 @@
*/
import type React from 'react';
+import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
-import { useMemo } from 'react';
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
export interface ChecklistProps {
diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
index ff88afa888..13f3872e5d 100644
--- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
+++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useRef, useCallback } from 'react';
import type React from 'react';
+import { useRef, useCallback } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { ConsoleMessageItem } from '../types.js';
diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx
index 36832c1662..6ebe22d982 100644
--- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx
+++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx
@@ -7,8 +7,7 @@
import { render } from '../../test-utils/render.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { SettingScope } from '../../config/settings.js';
-import type { LoadedSettings } from '../../config/settings.js';
+import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { waitFor } from '../../test-utils/async.js';
diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx
index f75b1c27b8..7fa0d2a2cf 100644
--- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx
+++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx
@@ -13,18 +13,18 @@ import {
type EditorDisplay,
} from '../editors/editorSettingsManager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
-import type {
- LoadableSettingScope,
- LoadedSettings,
+import {
+ SettingScope,
+ type LoadableSettingScope,
+ type LoadedSettings,
} from '../../config/settings.js';
-import { SettingScope } from '../../config/settings.js';
import {
type EditorType,
isEditorAvailable,
EDITOR_DISPLAY_NAMES,
+ coreEvents,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
-import { coreEvents } from '@google/gemini-cli-core';
interface EditorDialogProps {
onSelect: (
diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx
index 5bb748b28f..6c1c0d9e8c 100644
--- a/packages/cli/src/ui/components/FolderTrustDialog.tsx
+++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx
@@ -9,8 +9,10 @@ import type React from 'react';
import { useEffect, useState, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import stripAnsi from 'strip-ansi';
-import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
-import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './shared/RadioButtonSelect.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { Scrollable } from './shared/Scrollable.js';
import { useKeypress } from '../hooks/useKeypress.js';
diff --git a/packages/cli/src/ui/components/GradientRegression.test.tsx b/packages/cli/src/ui/components/GradientRegression.test.tsx
index bc836a1102..ba118e6bcb 100644
--- a/packages/cli/src/ui/components/GradientRegression.test.tsx
+++ b/packages/cli/src/ui/components/GradientRegression.test.tsx
@@ -7,7 +7,7 @@
import { describe, it, expect, vi } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import * as SessionContext from '../contexts/SessionContext.js';
-import type { SessionStatsState } from '../contexts/SessionContext.js';
+import { type SessionStatsState } from '../contexts/SessionContext.js';
import { Banner } from './Banner.js';
import { Footer } from './Footer.js';
import { Header } from './Header.js';
diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx
index 666593f04f..dc86cb70dc 100644
--- a/packages/cli/src/ui/components/Help.test.tsx
+++ b/packages/cli/src/ui/components/Help.test.tsx
@@ -7,8 +7,7 @@
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { Help } from './Help.js';
-import type { SlashCommand } from '../commands/types.js';
-import { CommandKind } from '../commands/types.js';
+import { CommandKind, type SlashCommand } from '../commands/types.js';
const mockCommands: readonly SlashCommand[] = [
{
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
index a574a9f311..f049ffe15e 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -6,13 +6,12 @@
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
-import { type HistoryItem } from '../types.js';
-import { MessageType } from '../types.js';
+import { MessageType, type HistoryItem } from '../types.js';
import { SessionStatsProvider } from '../contexts/SessionContext.js';
import {
+ CoreToolCallStatus,
type Config,
type ToolExecuteConfirmationDetails,
- CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 260455c782..15f6e2f8c4 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -8,31 +8,46 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { act, useState } from 'react';
-import type { InputPromptProps } from './InputPrompt.js';
-import { InputPrompt, tryTogglePasteExpansion } from './InputPrompt.js';
-import type { TextBuffer } from './shared/text-buffer.js';
+import {
+ InputPrompt,
+ tryTogglePasteExpansion,
+ type InputPromptProps,
+} from './InputPrompt.js';
import {
calculateTransformationsForLine,
calculateTransformedLine,
+ type TextBuffer,
} from './shared/text-buffer.js';
-import type { Config } from '@google/gemini-cli-core';
-import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
+import {
+ ApprovalMode,
+ debugLogger,
+ type Config,
+} from '@google/gemini-cli-core';
import * as path from 'node:path';
-import type { CommandContext, SlashCommand } from '../commands/types.js';
-import { CommandKind } from '../commands/types.js';
+import {
+ CommandKind,
+ type CommandContext,
+ type SlashCommand,
+} from '../commands/types.js';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { Text } from 'ink';
-import type { UseShellHistoryReturn } from '../hooks/useShellHistory.js';
-import { useShellHistory } from '../hooks/useShellHistory.js';
-import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.js';
+import {
+ useShellHistory,
+ type UseShellHistoryReturn,
+} from '../hooks/useShellHistory.js';
import {
useCommandCompletion,
CompletionMode,
+ type UseCommandCompletionReturn,
} from '../hooks/useCommandCompletion.js';
-import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
-import { useInputHistory } from '../hooks/useInputHistory.js';
-import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearchCompletion.js';
-import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
+import {
+ useInputHistory,
+ type UseInputHistoryReturn,
+} from '../hooks/useInputHistory.js';
+import {
+ useReverseSearchCompletion,
+ type UseReverseSearchCompletionReturn,
+} from '../hooks/useReverseSearchCompletion.js';
import clipboardy from 'clipboardy';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 1cfa2d4215..94b1d2dc00 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -5,8 +5,8 @@
*/
import type React from 'react';
-import clipboardy from 'clipboardy';
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
+import clipboardy from 'clipboardy';
import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
@@ -34,13 +34,16 @@ import {
useCommandCompletion,
CompletionMode,
} from '../hooks/useCommandCompletion.js';
-import type { Key } from '../hooks/useKeypress.js';
-import { useKeypress } from '../hooks/useKeypress.js';
+import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { formatCommand } from '../key/keybindingUtils.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
-import type { Config } from '@google/gemini-cli-core';
-import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
+import {
+ ApprovalMode,
+ coreEvents,
+ debugLogger,
+ type Config,
+} from '@google/gemini-cli-core';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
diff --git a/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx b/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
index fbe4c30bd0..44726f9bc2 100644
--- a/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
+++ b/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
@@ -7,8 +7,10 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { theme } from '../semantic-colors.js';
-import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
-import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum LogoutChoice {
diff --git a/packages/cli/src/ui/components/LoopDetectionConfirmation.tsx b/packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
index 5d4690e51b..37523d5387 100644
--- a/packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
+++ b/packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
@@ -5,8 +5,10 @@
*/
import { Box, Text } from 'ink';
-import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
-import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
index 73c51fd0d1..5da3c3a6d2 100644
--- a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
+++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
@@ -9,8 +9,8 @@ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import * as SettingsContext from '../contexts/SettingsContext.js';
-import type { LoadedSettings } from '../../config/settings.js';
-import type { SessionMetrics } from '../contexts/SessionContext.js';
+import { type LoadedSettings } from '../../config/settings.js';
+import { type SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision, LlmRole } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
diff --git a/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx b/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
index 0c2c4e362d..deab70c0ce 100644
--- a/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
+++ b/packages/cli/src/ui/components/MultiFolderTrustDialog.tsx
@@ -8,14 +8,16 @@ import { Box, Text } from 'ink';
import type React from 'react';
import { useState } from 'react';
import { theme } from '../semantic-colors.js';
-import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
-import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import * as path from 'node:path';
import { MessageType, type HistoryItem } from '../types.js';
-import type { Config } from '@google/gemini-cli-core';
+import { type Config } from '@google/gemini-cli-core';
export enum MultiFolderTrustChoice {
YES,
diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx
index 9ffaa8797b..3047922e09 100644
--- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx
+++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx
@@ -4,8 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import type { Mock } from 'vitest';
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type Mock,
+} from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
index 6b24908560..c54f4ebf39 100644
--- a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
+++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
@@ -5,16 +5,18 @@
*/
import { Box, Text } from 'ink';
-import { useCallback, useRef } from 'react';
import type React from 'react';
+import { useCallback, useRef } from 'react';
import {
+ PolicyIntegrityManager,
type Config,
type PolicyUpdateConfirmationRequest,
- PolicyIntegrityManager,
} from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
-import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
-import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx
index a3a58db6f9..653d1e401a 100644
--- a/packages/cli/src/ui/components/RewindConfirmation.tsx
+++ b/packages/cli/src/ui/components/RewindConfirmation.tsx
@@ -8,8 +8,10 @@ import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import type React from 'react';
import { useMemo } from 'react';
import { theme } from '../semantic-colors.js';
-import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
-import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './shared/RadioButtonSelect.js';
import type { FileChangeStats } from '../utils/rewindFileOps.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatTimeAgo } from '../utils/formatters.js';
diff --git a/packages/cli/src/ui/components/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx
index e97ae310bd..83e3ae1aaa 100644
--- a/packages/cli/src/ui/components/SessionBrowser.test.tsx
+++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx
@@ -8,10 +8,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
-import type { Config } from '@google/gemini-cli-core';
-import { SessionBrowser } from './SessionBrowser.js';
-import type { SessionBrowserProps } from './SessionBrowser.js';
-import type { SessionInfo } from '../../utils/sessionUtils.js';
+import { type Config } from '@google/gemini-cli-core';
+import { SessionBrowser, type SessionBrowserProps } from './SessionBrowser.js';
+import { type SessionInfo } from '../../utils/sessionUtils.js';
// Collect key handlers registered via useKeypress so tests can
// simulate input without going through the full stdin pipeline.
diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
index 2ed71762b7..3be3cb09f5 100644
--- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
+++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
@@ -8,7 +8,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
-import type { SessionMetrics } from '../contexts/SessionContext.js';
+import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx
index b8136254f3..82965bda71 100644
--- a/packages/cli/src/ui/components/SettingsDialog.tsx
+++ b/packages/cli/src/ui/components/SettingsDialog.tsx
@@ -4,14 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
+import type React from 'react';
import { Text } from 'ink';
import { AsyncFzf } from 'fzf';
-import type { Key } from '../hooks/useKeypress.js';
+import { type Key } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
-import type { LoadableSettingScope, Settings } from '../../config/settings.js';
-import { SettingScope } from '../../config/settings.js';
+import {
+ SettingScope,
+ type LoadableSettingScope,
+ type Settings,
+} from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import {
getDialogSettingKeys,
diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx
index 8f5831c1ef..82366abdb0 100644
--- a/packages/cli/src/ui/components/ShellInputPrompt.tsx
+++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useCallback } from 'react';
import type React from 'react';
+import { useCallback } from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../key/keyToAnsi.js';
diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx
index 2b4422e69c..cb9aa55cc5 100644
--- a/packages/cli/src/ui/components/StatsDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx
@@ -8,7 +8,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
-import type { SessionMetrics } from '../contexts/SessionContext.js';
+import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
type RetrieveUserQuotaResponse,
diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx
index d369374d95..320203f3dc 100644
--- a/packages/cli/src/ui/components/StatsDisplay.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.tsx
@@ -9,8 +9,10 @@ import { Box, Text, useStdout } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import { formatDuration, formatResetTime } from '../utils/formatters.js';
-import type { ModelMetrics } from '../contexts/SessionContext.js';
-import { useSessionStats } from '../contexts/SessionContext.js';
+import {
+ useSessionStats,
+ type ModelMetrics,
+} from '../contexts/SessionContext.js';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
diff --git a/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx b/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
index 72a1f12cff..197c7d84d5 100644
--- a/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
+++ b/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
@@ -8,7 +8,7 @@ import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
-import type { SessionMetrics } from '../contexts/SessionContext.js';
+import { type SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
index 7a4b9bc42d..a4f5212289 100644
--- a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx
@@ -5,10 +5,12 @@
*/
import { renderWithProviders } from '../../../test-utils/render.js';
-import type { CompressionDisplayProps } from './CompressionMessage.js';
-import { CompressionMessage } from './CompressionMessage.js';
+import {
+ CompressionMessage,
+ type CompressionDisplayProps,
+} from './CompressionMessage.js';
import { CompressionStatus } from '@google/gemini-cli-core';
-import type { CompressionProps } from '../../types.js';
+import { type CompressionProps } from '../../types.js';
import { describe, it, expect } from 'vitest';
describe('', () => {
diff --git a/packages/cli/src/ui/components/messages/Todo.test.tsx b/packages/cli/src/ui/components/messages/Todo.test.tsx
index b6413e496a..17c4f623bf 100644
--- a/packages/cli/src/ui/components/messages/Todo.test.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.test.tsx
@@ -8,11 +8,9 @@ import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { Box } from 'ink';
import { TodoTray } from './Todo.js';
-import type { Todo } from '@google/gemini-cli-core';
-import type { UIState } from '../../contexts/UIStateContext.js';
-import { UIStateContext } from '../../contexts/UIStateContext.js';
-import type { HistoryItem } from '../../types.js';
-import { CoreToolCallStatus } from '@google/gemini-cli-core';
+import { CoreToolCallStatus, type Todo } from '@google/gemini-cli-core';
+import { UIStateContext, type UIState } from '../../contexts/UIStateContext.js';
+import { type HistoryItem } from '../../types.js';
const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
({
diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx
index cbc2405ac0..a7201b12fb 100644
--- a/packages/cli/src/ui/components/messages/Todo.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.tsx
@@ -5,9 +5,9 @@
*/
import type React from 'react';
+import { useMemo } from 'react';
import { type TodoList } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
-import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index 113852cb8d..8bc329f3df 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -18,9 +18,11 @@ import {
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
-import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
-import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import {
sanitizeForDisplay,
diff --git a/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx b/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx
index b81c951978..2375be7f0e 100644
--- a/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx
@@ -5,8 +5,7 @@
*/
import { describe, it, expect } from 'vitest';
-import type { ToolMessageProps } from './ToolMessage.js';
-import { ToolMessage } from './ToolMessage.js';
+import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
import { StreamingState } from '../../types.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { renderWithProviders } from '../../../test-utils/render.js';
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
index 7efb40b3ae..1090d4010d 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
@@ -8,9 +8,10 @@ import type React from 'react';
import { useEffect, useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
-import { useSelectionList } from '../../hooks/useSelectionList.js';
-
-import type { SelectionListItem } from '../../hooks/useSelectionList.js';
+import {
+ useSelectionList,
+ type SelectionListItem,
+} from '../../hooks/useSelectionList.js';
export interface RenderItemContext {
isSelected: boolean;
diff --git a/packages/cli/src/ui/components/shared/EnumSelector.tsx b/packages/cli/src/ui/components/shared/EnumSelector.tsx
index a86efd8ff1..5553e6ff0d 100644
--- a/packages/cli/src/ui/components/shared/EnumSelector.tsx
+++ b/packages/cli/src/ui/components/shared/EnumSelector.tsx
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useState, useEffect } from 'react';
import type React from 'react';
+import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
index 00607e522a..393dd63d44 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
@@ -6,9 +6,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
-import type { Text } from 'ink';
-import { Box } from 'ink';
import type React from 'react';
+import { Box, type Text } from 'ink';
import {
RadioButtonSelect,
type RadioSelectItem,
diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx
index 277d5e9723..479757d8f3 100644
--- a/packages/cli/src/ui/components/shared/TextInput.tsx
+++ b/packages/cli/src/ui/components/shared/TextInput.tsx
@@ -6,13 +6,11 @@
import type React from 'react';
import { useCallback } from 'react';
-import type { Key } from '../../hooks/useKeypress.js';
import { Text, Box } from 'ink';
-import { useKeypress } from '../../hooks/useKeypress.js';
+import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
-import type { TextBuffer } from './text-buffer.js';
-import { expandPastePlaceholders } from './text-buffer.js';
+import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
diff --git a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
index 73d0ae701f..d77bf45243 100644
--- a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
+++ b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
@@ -7,8 +7,12 @@
import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
-import type { Config } from '@google/gemini-cli-core';
-import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
+import {
+ debugLogger,
+ spawnAsync,
+ LlmRole,
+ type Config,
+} from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
diff --git a/packages/cli/src/ui/components/triage/TriageIssues.tsx b/packages/cli/src/ui/components/triage/TriageIssues.tsx
index 477be8a363..62c0f50e1c 100644
--- a/packages/cli/src/ui/components/triage/TriageIssues.tsx
+++ b/packages/cli/src/ui/components/triage/TriageIssues.tsx
@@ -7,8 +7,12 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
-import type { Config } from '@google/gemini-cli-core';
-import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
+import {
+ debugLogger,
+ spawnAsync,
+ LlmRole,
+ type Config,
+} from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx
index 3e9b8a3469..0539437fc3 100644
--- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx
+++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx
@@ -8,7 +8,6 @@ import type React from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
-
import {
SearchableList,
type GenericListItem,
diff --git a/packages/cli/src/ui/components/views/McpStatus.tsx b/packages/cli/src/ui/components/views/McpStatus.tsx
index c007d14635..1f14c0b5c5 100644
--- a/packages/cli/src/ui/components/views/McpStatus.tsx
+++ b/packages/cli/src/ui/components/views/McpStatus.tsx
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { MCPServerConfig } from '@google/gemini-cli-core';
-import { MCPServerStatus } from '@google/gemini-cli-core';
+import { MCPServerStatus, type MCPServerConfig } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import type React from 'react';
import { MAX_MCP_RESOURCES_TO_SHOW } from '../../constants.js';
diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts
index d86eb6f738..c5dcc6e22a 100644
--- a/packages/core/src/core/contentGenerator.test.ts
+++ b/packages/core/src/core/contentGenerator.test.ts
@@ -10,6 +10,7 @@ import {
AuthType,
createContentGeneratorConfig,
type ContentGenerator,
+ validateBaseUrl,
} from './contentGenerator.js';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { GoogleGenAI } from '@google/genai';
@@ -442,6 +443,116 @@ describe('createContentGenerator', () => {
);
});
+ it('should pass GOOGLE_GEMINI_BASE_URL as httpOptions.baseUrl for Gemini API', async () => {
+ const mockConfig = {
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
+ getProxy: vi.fn().mockReturnValue(undefined),
+ getUsageStatisticsEnabled: () => false,
+ } as unknown as Config;
+
+ const mockGenerator = {
+ models: {},
+ } as unknown as GoogleGenAI;
+ vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
+ vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://my-gemini-proxy.example.com');
+
+ await createContentGenerator(
+ {
+ apiKey: 'test-api-key',
+ authType: AuthType.USE_GEMINI,
+ },
+ mockConfig,
+ );
+
+ expect(GoogleGenAI).toHaveBeenCalledWith(
+ expect.objectContaining({
+ httpOptions: expect.objectContaining({
+ baseUrl: 'https://my-gemini-proxy.example.com',
+ }),
+ }),
+ );
+ });
+
+ it('should pass GOOGLE_VERTEX_BASE_URL as httpOptions.baseUrl for Vertex AI', async () => {
+ const mockConfig = {
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
+ getProxy: vi.fn().mockReturnValue(undefined),
+ getUsageStatisticsEnabled: () => false,
+ } as unknown as Config;
+
+ const mockGenerator = {
+ models: {},
+ } as unknown as GoogleGenAI;
+ vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
+ vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://my-vertex-proxy.example.com');
+
+ await createContentGenerator(
+ {
+ apiKey: 'test-api-key',
+ vertexai: true,
+ authType: AuthType.USE_VERTEX_AI,
+ },
+ mockConfig,
+ );
+
+ expect(GoogleGenAI).toHaveBeenCalledWith(
+ expect.objectContaining({
+ httpOptions: expect.objectContaining({
+ baseUrl: 'https://my-vertex-proxy.example.com',
+ }),
+ }),
+ );
+ });
+
+ it('should not include baseUrl in httpOptions when GOOGLE_GEMINI_BASE_URL is not set', async () => {
+ const mockConfig = {
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
+ getProxy: vi.fn().mockReturnValue(undefined),
+ getUsageStatisticsEnabled: () => false,
+ } as unknown as Config;
+
+ const mockGenerator = {
+ models: {},
+ } as unknown as GoogleGenAI;
+ vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
+
+ await createContentGenerator(
+ {
+ apiKey: 'test-api-key',
+ authType: AuthType.USE_GEMINI,
+ },
+ mockConfig,
+ );
+
+ expect(GoogleGenAI).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ httpOptions: expect.objectContaining({
+ baseUrl: expect.any(String),
+ }),
+ }),
+ );
+ });
+
+ it('should reject an insecure GOOGLE_GEMINI_BASE_URL for non-local hosts', async () => {
+ const mockConfig = {
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
+ getProxy: vi.fn().mockReturnValue(undefined),
+ getUsageStatisticsEnabled: () => false,
+ } as unknown as Config;
+
+ vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'http://evil-proxy.example.com');
+
+ await expect(
+ createContentGenerator(
+ {
+ apiKey: 'test-api-key',
+ authType: AuthType.USE_GEMINI,
+ },
+ mockConfig,
+ ),
+ ).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.');
+ });
+
it('should pass apiVersion for Vertex AI when GOOGLE_GENAI_API_VERSION is set', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
@@ -560,3 +671,33 @@ describe('createContentGeneratorConfig', () => {
expect(config.vertexai).toBeUndefined();
});
});
+
+describe('validateBaseUrl', () => {
+ it('should accept a valid HTTPS URL', () => {
+ expect(() => validateBaseUrl('https://my-proxy.example.com')).not.toThrow();
+ });
+
+ it('should accept HTTP for localhost', () => {
+ expect(() => validateBaseUrl('http://localhost:8080')).not.toThrow();
+ });
+
+ it('should accept HTTP for 127.0.0.1', () => {
+ expect(() => validateBaseUrl('http://127.0.0.1:3000')).not.toThrow();
+ });
+
+ it('should accept HTTP for ::1', () => {
+ expect(() => validateBaseUrl('http://[::1]:8080')).not.toThrow();
+ });
+
+ it('should reject HTTP for non-local hosts', () => {
+ expect(() => validateBaseUrl('http://my-proxy.example.com')).toThrow(
+ 'Custom base URL must use HTTPS unless it is localhost.',
+ );
+ });
+
+ it('should reject an invalid URL', () => {
+ expect(() => validateBaseUrl('not-a-url')).toThrow(
+ 'Invalid custom base URL: not-a-url',
+ );
+ });
+});
diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts
index 2ce5420335..d7da9fb064 100644
--- a/packages/core/src/core/contentGenerator.ts
+++ b/packages/core/src/core/contentGenerator.ts
@@ -225,13 +225,25 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
+ let baseUrl = config.baseUrl;
+ if (!baseUrl) {
+ const envBaseUrl = config.vertexai
+ ? process.env['GOOGLE_VERTEX_BASE_URL']
+ : process.env['GOOGLE_GEMINI_BASE_URL'];
+ if (envBaseUrl) {
+ validateBaseUrl(envBaseUrl);
+ baseUrl = envBaseUrl;
+ }
+ } else {
+ validateBaseUrl(baseUrl);
+ }
const httpOptions: {
baseUrl?: string;
headers: Record;
} = { headers };
- if (config.baseUrl) {
- httpOptions.baseUrl = config.baseUrl;
+ if (baseUrl) {
+ httpOptions.baseUrl = baseUrl;
}
const googleGenAI = new GoogleGenAI({
@@ -253,3 +265,17 @@ export async function createContentGenerator(
return generator;
}
+
+const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
+
+export function validateBaseUrl(baseUrl: string): void {
+ let url: URL;
+ try {
+ url = new URL(baseUrl);
+ } catch {
+ throw new Error(`Invalid custom base URL: ${baseUrl}`);
+ }
+ if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) {
+ throw new Error('Custom base URL must use HTTPS unless it is localhost.');
+ }
+}
diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts
index ec375e88be..275e02118a 100644
--- a/packages/core/src/core/geminiChat.test.ts
+++ b/packages/core/src/core/geminiChat.test.ts
@@ -1380,11 +1380,11 @@ describe('GeminiChat', () => {
}
}).rejects.toThrow(InvalidStreamError);
- // Should be called 2 times (initial + 1 retry)
+ // Should be called 4 times (initial + 3 retries)
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
- 2,
+ 4,
);
- expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
+ expect(mockLogContentRetry).toHaveBeenCalledTimes(3);
expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
// History should still contain the user message.
diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts
index 4dc586e156..c8f4897a38 100644
--- a/packages/core/src/core/geminiChat.ts
+++ b/packages/core/src/core/geminiChat.ts
@@ -79,17 +79,17 @@ export type StreamEvent =
| { type: StreamEventType.AGENT_EXECUTION_BLOCKED; reason: string };
/**
- * Options for retrying due to invalid content from the model.
+ * Options for retrying mid-stream errors (e.g. invalid content or API disconnects).
*/
-interface ContentRetryOptions {
+interface MidStreamRetryOptions {
/** Total number of attempts to make (1 initial + N retries). */
maxAttempts: number;
/** The base delay in milliseconds for linear backoff. */
initialDelayMs: number;
}
-const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
- maxAttempts: 2, // 1 initial call + 1 retry
+const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = {
+ maxAttempts: 4, // 1 initial call + 3 retries mid-stream
initialDelayMs: 500,
};
@@ -350,7 +350,7 @@ export class GeminiChat {
this: GeminiChat,
): AsyncGenerator {
try {
- const maxAttempts = INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
+ const maxAttempts = this.config.getMaxAttempts();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let isConnectionPhase = true;
@@ -402,21 +402,19 @@ export class GeminiChat {
return; // Stop the generator
}
+ if (isConnectionPhase) {
+ // Connection phase errors have already been retried by retryWithBackoff.
+ // If they bubble up here, they are exhausted or fatal.
+ throw error;
+ }
+
// Check if the error is retryable (e.g., transient SSL errors
- // like ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC)
+ // like ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC or ApiError)
const isRetryable = isRetryableError(
error,
this.config.getRetryFetchErrors(),
);
- // For connection phase errors, only retryable errors should continue
- if (isConnectionPhase) {
- if (!isRetryable || signal.aborted) {
- throw error;
- }
- // Fall through to retry logic for retryable connection errors
- }
-
const isContentError = error instanceof InvalidStreamError;
const errorType = isContentError
? error.type
@@ -426,9 +424,16 @@ export class GeminiChat {
(isContentError && isGemini2Model(model)) ||
(isRetryable && !signal.aborted)
) {
- // Check if we have more attempts left.
- if (attempt < maxAttempts - 1) {
- const delayMs = INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs;
+ // The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration.
+ // Regardless of the global maxAttempts (e.g. 10), we only want to retry these mid-stream API errors
+ // up to 3 times before finally throwing the error to the user.
+ const maxMidStreamAttempts = MID_STREAM_RETRY_OPTIONS.maxAttempts;
+
+ if (
+ attempt < maxAttempts - 1 &&
+ attempt < maxMidStreamAttempts - 1
+ ) {
+ const delayMs = MID_STREAM_RETRY_OPTIONS.initialDelayMs;
if (isContentError) {
logContentRetry(
@@ -449,7 +454,7 @@ export class GeminiChat {
}
coreEvents.emitRetryAttempt({
attempt: attempt + 1,
- maxAttempts,
+ maxAttempts: Math.min(maxAttempts, maxMidStreamAttempts),
delayMs: delayMs * (attempt + 1),
error: errorType,
model,
diff --git a/packages/core/src/core/geminiChat_network_retry.test.ts b/packages/core/src/core/geminiChat_network_retry.test.ts
index 2f7cf69dd8..2426cfd483 100644
--- a/packages/core/src/core/geminiChat_network_retry.test.ts
+++ b/packages/core/src/core/geminiChat_network_retry.test.ts
@@ -292,6 +292,14 @@ describe('GeminiChat Network Retries', () => {
(sslError as NodeJS.ErrnoException).code =
'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC';
+ // Instead of outer loop, connection retries are handled by retryWithBackoff.
+ // Simulate retryWithBackoff attempting it twice: first throws, second succeeds.
+ mockRetryWithBackoff.mockImplementation(
+ async (apiCall) =>
+ // Execute the apiCall to trigger mockContentGenerator
+ await apiCall(),
+ );
+
vi.mocked(mockContentGenerator.generateContentStream)
// First call: throw SSL error immediately (connection phase)
.mockRejectedValueOnce(sslError)
@@ -309,6 +317,15 @@ describe('GeminiChat Network Retries', () => {
})(),
);
+ // Because retryWithBackoff is mocked and we just want to test GeminiChat's integration,
+ // we need to actually execute the real retryWithBackoff logic for this test to see it work.
+ // So let's restore the real retryWithBackoff for this test.
+ const { retryWithBackoff } =
+ await vi.importActual(
+ '../utils/retry.js',
+ );
+ mockRetryWithBackoff.mockImplementation(retryWithBackoff);
+
const stream = await chat.sendMessageStream(
{ model: 'test-model' },
'test message',
@@ -322,10 +339,6 @@ describe('GeminiChat Network Retries', () => {
events.push(event);
}
- // Should have retried and succeeded
- const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
- expect(retryEvent).toBeDefined();
-
const successChunk = events.find(
(e) =>
e.type === StreamEventType.CHUNK &&
@@ -342,6 +355,12 @@ describe('GeminiChat Network Retries', () => {
const connectionError = new Error('read ECONNRESET');
(connectionError as NodeJS.ErrnoException).code = 'ECONNRESET';
+ const { retryWithBackoff } =
+ await vi.importActual(
+ '../utils/retry.js',
+ );
+ mockRetryWithBackoff.mockImplementation(retryWithBackoff);
+
vi.mocked(mockContentGenerator.generateContentStream)
.mockRejectedValueOnce(connectionError)
.mockImplementationOnce(async () =>
@@ -372,9 +391,6 @@ describe('GeminiChat Network Retries', () => {
events.push(event);
}
- const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
- expect(retryEvent).toBeDefined();
-
const successChunk = events.find(
(e) =>
e.type === StreamEventType.CHUNK &&
@@ -382,6 +398,7 @@ describe('GeminiChat Network Retries', () => {
'Success after connection retry',
);
expect(successChunk).toBeDefined();
+ expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(2);
});
it('should NOT retry on non-retryable error during connection phase', async () => {
diff --git a/scripts/lint.js b/scripts/lint.js
index af610b5832..06ba5610a5 100644
--- a/scripts/lint.js
+++ b/scripts/lint.js
@@ -116,6 +116,7 @@ export function runSensitiveKeywordLinter() {
console.log('\nRunning sensitive keyword linter...');
const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g;
const ALLOWED_KEYWORDS = new Set([
+ 'gemini-3.1',
'gemini-3',
'gemini-3.0',
'gemini-2.5',