Update models menu dialog to support manual model selection and group… (#80)

* Update models menu dialog to support manual model selection and group by model family

* fix tests

* update codebase investigator model setting

* fix test

* fix test

* update generated golden file

* regenerate scheme and doc for settings

* use Preview Auto if previewFeatures is set to true
This commit is contained in:
Sehoon Shon
2025-12-11 09:57:27 -05:00
committed by Tommaso Sciortino
parent c3f6e7132b
commit 17bf02b901
26 changed files with 461 additions and 535 deletions
+1 -1
View File
@@ -827,7 +827,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.codebaseInvestigatorSettings.model`** (string): - **`experimental.codebaseInvestigatorSettings.model`** (string):
- **Description:** The model to use for the Codebase Investigator agent. - **Description:** The model to use for the Codebase Investigator agent.
- **Default:** `"pro"` - **Default:** `"gemini-2.5-pro"`
- **Requires restart:** Yes - **Requires restart:** Yes
#### `hooks` #### `hooks`
+1 -1
View File
@@ -1283,7 +1283,7 @@ describe('loadCliConfig model selection', () => {
argv, argv,
); );
expect(config.getModel()).toBe('auto'); expect(config.getModel()).toBe('auto-gemini-2.5');
}); });
it('always prefers model from argv', async () => { it('always prefers model from argv', async () => {
+4 -1
View File
@@ -31,6 +31,7 @@ import {
debugLogger, debugLogger,
loadServerHierarchicalMemory, loadServerHierarchicalMemory,
WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_NAME,
PREVIEW_GEMINI_MODEL_AUTO,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
@@ -569,7 +570,9 @@ export async function loadCliConfig(
extraExcludes.length > 0 ? extraExcludes : undefined, extraExcludes.length > 0 ? extraExcludes : undefined,
); );
const defaultModel = DEFAULT_GEMINI_MODEL_AUTO; const defaultModel = settings.general?.previewFeatures
? PREVIEW_GEMINI_MODEL_AUTO
: DEFAULT_GEMINI_MODEL_AUTO;
const resolvedModel: string = const resolvedModel: string =
argv.model || argv.model ||
process.env['GEMINI_MODEL'] || process.env['GEMINI_MODEL'] ||
+2 -2
View File
@@ -19,7 +19,7 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS, DEFAULT_MODEL_CONFIGS,
GEMINI_MODEL_ALIAS_PRO, DEFAULT_GEMINI_MODEL,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { CustomTheme } from '../ui/themes/theme.js'; import type { CustomTheme } from '../ui/themes/theme.js';
import type { SessionRetentionSettings } from './settings.js'; import type { SessionRetentionSettings } from './settings.js';
@@ -1384,7 +1384,7 @@ const SETTINGS_SCHEMA = {
label: 'Model', label: 'Model',
category: 'Experimental', category: 'Experimental',
requiresRestart: true, requiresRestart: true,
default: GEMINI_MODEL_ALIAS_PRO, default: DEFAULT_GEMINI_MODEL,
description: description:
'The model to use for the Codebase Investigator agent.', 'The model to use for the Codebase Investigator agent.',
showInDialog: false, showInDialog: false,
@@ -4,239 +4,186 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { render } from '../../test-utils/render.js'; import { render } from 'ink-testing-library';
import { cleanup } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
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'; import { ModelDialog } from './ModelDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core'; import { KeypressProvider } from '../contexts/KeypressContext.js';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
vi.mock('../hooks/useKeypress.js', () => ({ // Mock dependencies
useKeypress: vi.fn(), const mockGetDisplayString = vi.fn();
})); const mockLogModelSlashCommand = vi.fn();
const mockedUseKeypress = vi.mocked(useKeypress); const mockModelSlashCommandEvent = vi.fn();
vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
DescriptiveRadioButtonSelect: vi.fn(() => null),
}));
const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect);
const renderComponent = (
props: Partial<React.ComponentProps<typeof ModelDialog>> = {},
contextValue: Partial<Config> | undefined = undefined,
) => {
const defaultProps = {
onClose: vi.fn(),
};
const combinedProps = { ...defaultProps, ...props };
const mockConfig = contextValue
? ({
// --- 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),
getSessionId: vi.fn(() => 'mock-session-id'),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
getUseSmartEdit: vi.fn(() => false),
getProxy: vi.fn(() => undefined),
isInteractive: vi.fn(() => false),
getExperiments: () => {},
// --- Spread test-specific overrides ---
...contextValue,
} as Config)
: undefined;
const renderResult = render(
<ConfigContext.Provider value={mockConfig}>
<ModelDialog {...combinedProps} />
</ConfigContext.Provider>,
);
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return { return {
...renderResult, ...actual,
props: combinedProps, getDisplayString: (val: string) => mockGetDisplayString(val),
mockConfig, logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
mockLogModelSlashCommand(config, event),
ModelSlashCommandEvent: class {
constructor(model: string) {
mockModelSlashCommandEvent(model);
}
},
}; };
}; });
describe('<ModelDialog />', () => { describe('<ModelDialog />', () => {
const mockSetModel = vi.fn();
const mockGetModel = vi.fn();
const mockGetPreviewFeatures = vi.fn();
const mockOnClose = vi.fn();
interface MockConfig extends Partial<Config> {
setModel: (model: string) => void;
getModel: () => string;
getPreviewFeatures: () => boolean;
}
const mockConfig: MockConfig = {
setModel: mockSetModel,
getModel: mockGetModel,
getPreviewFeatures: mockGetPreviewFeatures,
};
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.resetAllMocks();
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetPreviewFeatures.mockReturnValue(false);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)';
if (val === 'auto-gemini-3') return 'Auto (Preview)';
return val;
});
}); });
afterEach(() => { const renderComponent = (contextValue = mockConfig as Config) =>
cleanup(); render(
}); <KeypressProvider>
<ConfigContext.Provider value={contextValue}>
<ModelDialog onClose={mockOnClose} />
</ConfigContext.Provider>
</KeypressProvider>,
);
it('renders the title and help text', () => { const waitForUpdate = () =>
const { lastFrame, unmount } = renderComponent(); new Promise((resolve) => setTimeout(resolve, 150));
it('renders the initial "main" view correctly', () => {
const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Select Model'); expect(lastFrame()).toContain('Select Model');
expect(lastFrame()).toContain('(Press Esc to close)'); expect(lastFrame()).toContain('Auto');
expect(lastFrame()).toContain( expect(lastFrame()).toContain('Manual');
'To use a specific Gemini model on startup, use the --model flag.',
);
unmount();
}); });
it('passes all model options to DescriptiveRadioButtonSelect', () => { it('renders "main" view with preview options when preview features are enabled', () => {
const { unmount } = renderComponent(); mockGetPreviewFeatures.mockReturnValue(true);
expect(mockedSelect).toHaveBeenCalledTimes(1); const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Auto (Preview)');
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(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', () => { it('switches to "manual" view when "Manual" is selected', async () => {
const mockGetModel = vi.fn(() => GEMINI_MODEL_ALIAS_FLASH); const { lastFrame, stdin } = renderComponent();
const { unmount } = renderComponent({}, { getModel: mockGetModel });
expect(mockGetModel).toHaveBeenCalled(); // Select "Manual" (index 1)
expect(mockedSelect).toHaveBeenCalledWith( // Press down arrow to move to "Manual"
expect.objectContaining({ stdin.write('\u001B[B'); // Arrow Down
initialIndex: 2, await waitForUpdate();
}),
undefined, // Press enter to select
); stdin.write('\r');
unmount(); await waitForUpdate();
// Should now show manual options
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
}); });
it('initializes with "auto" model if context is not provided', () => { it('renders "manual" view with preview options when preview features are enabled', async () => {
const { unmount } = renderComponent({}, undefined); mockGetPreviewFeatures.mockReturnValue(true);
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
const { lastFrame, stdin } = renderComponent();
expect(mockedSelect).toHaveBeenCalledWith( // Select "Manual" (index 2 because Preview Auto is first, then Auto (Gemini 2.5))
expect.objectContaining({ stdin.write('\u001B[B'); // Arrow Down (to Auto (Gemini 2.5))
initialIndex: 0, await waitForUpdate();
}), stdin.write('\u001B[B'); // Arrow Down (to Manual)
undefined, await waitForUpdate();
);
unmount(); // Press enter to select Manual
stdin.write('\r');
await waitForUpdate();
expect(lastFrame()).toContain(PREVIEW_GEMINI_MODEL);
}); });
it('initializes with "auto" model if getModel returns undefined', () => { it('sets model and closes when a model is selected in "main" view', async () => {
const mockGetModel = vi.fn(() => undefined); const { stdin } = renderComponent();
// @ts-expect-error This test validates component robustness when getModel
// returns an unexpected undefined value.
const { unmount } = renderComponent({}, { getModel: mockGetModel });
expect(mockGetModel).toHaveBeenCalled(); // Select "Auto" (index 0)
stdin.write('\r');
await waitForUpdate();
// When getModel returns undefined, preferredModel falls back to DEFAULT_GEMINI_MODEL_AUTO expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL_AUTO);
// which has index 0, so initialIndex should be 0 expect(mockOnClose).toHaveBeenCalled();
expect(mockedSelect).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 0,
}),
undefined,
);
expect(mockedSelect).toHaveBeenCalledTimes(1);
unmount();
}); });
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => { it('sets model and closes when a model is selected in "manual" view', async () => {
const { props, mockConfig, unmount } = renderComponent({}, {}); // Pass empty object for contextValue const { stdin } = renderComponent();
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; // Navigate to Manual (index 1) and select
expect(childOnSelect).toBeDefined(); stdin.write('\u001B[B');
await waitForUpdate();
stdin.write('\r');
await waitForUpdate();
childOnSelect(GEMINI_MODEL_ALIAS_PRO); // Now in manual view. Default selection is first item (DEFAULT_GEMINI_MODEL)
stdin.write('\r');
await waitForUpdate();
// Assert against the default mock provided by renderComponent expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
expect(mockConfig?.setModel).toHaveBeenCalledWith(GEMINI_MODEL_ALIAS_PRO); expect(mockOnClose).toHaveBeenCalled();
expect(props.onClose).toHaveBeenCalledTimes(1);
unmount();
}); });
it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => { it('closes dialog on escape in "main" view', async () => {
const { unmount } = renderComponent(); const { stdin } = renderComponent();
const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight; stdin.write('\u001B'); // Escape
expect(childOnHighlight).toBeUndefined(); await waitForUpdate();
unmount();
expect(mockOnClose).toHaveBeenCalled();
}); });
it('calls onClose prop when "escape" key is pressed', () => { it('goes back to "main" view on escape in "manual" view', async () => {
const { props, unmount } = renderComponent(); const { lastFrame, stdin } = renderComponent();
expect(mockedUseKeypress).toHaveBeenCalled(); // Go to manual view
stdin.write('\u001B[B');
await waitForUpdate();
stdin.write('\r');
await waitForUpdate();
const keyPressHandler = mockedUseKeypress.mock.calls[0][0]; expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
const options = mockedUseKeypress.mock.calls[0][1];
expect(options).toEqual({ isActive: true }); // Press Escape
stdin.write('\u001B');
await waitForUpdate();
keyPressHandler({ expect(mockOnClose).not.toHaveBeenCalled();
name: 'escape', // Should be back to main view (Manual option visible)
ctrl: false, expect(lastFrame()).toContain('Manual');
meta: false,
shift: false,
paste: false,
insertable: false,
sequence: '',
});
expect(props.onClose).toHaveBeenCalledTimes(1);
keyPressHandler({
name: 'a',
ctrl: false,
meta: false,
shift: false,
paste: false,
insertable: true,
sequence: '',
});
expect(props.onClose).toHaveBeenCalledTimes(1);
unmount();
});
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={oldMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
);
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_FLASH_LITE);
const newMockConfig = {
getModel: mockGetModel,
getPreviewFeatures: vi.fn(() => false),
} as unknown as Config;
rerender(
<ConfigContext.Provider value={newMockConfig}>
<ModelDialog onClose={vi.fn()} />
</ConfigContext.Provider>,
);
// Should be called at least twice: initial render + re-render after context change
expect(mockedSelect).toHaveBeenCalledTimes(2);
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(3);
unmount();
}); });
}); });
+97 -30
View File
@@ -5,19 +5,19 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useCallback, useContext, useMemo } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { import {
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
GEMINI_MODEL_ALIAS_PRO,
ModelSlashCommandEvent, ModelSlashCommandEvent,
logModelSlashCommand, logModelSlashCommand,
getDisplayString,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
@@ -31,61 +31,128 @@ interface ModelDialogProps {
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext); const config = useContext(ConfigContext);
const [view, setView] = useState<'main' | 'manual'>('main');
// Determine the Preferred Model (read once when the dialog opens). // Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const manualModelSelected = useMemo(() => {
const manualModels = [
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
];
if (manualModels.includes(preferredModel)) {
return preferredModel;
}
return '';
}, [preferredModel]);
useKeypress( useKeypress(
(key) => { (key) => {
if (key.name === 'escape') { if (key.name === 'escape') {
onClose(); if (view === 'manual') {
setView('main');
} else {
onClose();
}
} }
}, },
{ isActive: true }, { isActive: true },
); );
const options = useMemo( const mainOptions = useMemo(() => {
() => [ const list = [
{ {
value: DEFAULT_GEMINI_MODEL_AUTO, value: DEFAULT_GEMINI_MODEL_AUTO,
title: 'Auto', title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
description: 'Let the system choose the best model for your task.', description:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
key: DEFAULT_GEMINI_MODEL_AUTO, key: DEFAULT_GEMINI_MODEL_AUTO,
}, },
{ {
value: GEMINI_MODEL_ALIAS_PRO, value: 'Manual',
title: config?.getPreviewFeatures() title: manualModelSelected
? `Pro (${PREVIEW_GEMINI_MODEL}, ${DEFAULT_GEMINI_MODEL})` ? `Manual (${manualModelSelected})`
: `Pro (${DEFAULT_GEMINI_MODEL})`, : 'Manual',
description: 'Manually select a model',
key: 'Manual',
},
];
if (config?.getPreviewFeatures()) {
list.unshift({
value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description: description:
'For complex tasks that require deep reasoning and creativity', 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
key: GEMINI_MODEL_ALIAS_PRO, key: PREVIEW_GEMINI_MODEL_AUTO,
});
}
return list;
}, [config, manualModelSelected]);
const manualOptions = useMemo(() => {
const list = [
{
value: DEFAULT_GEMINI_MODEL,
title: DEFAULT_GEMINI_MODEL,
key: DEFAULT_GEMINI_MODEL,
}, },
{ {
value: GEMINI_MODEL_ALIAS_FLASH, value: DEFAULT_GEMINI_FLASH_MODEL,
title: `Flash (${DEFAULT_GEMINI_FLASH_MODEL})`, title: DEFAULT_GEMINI_FLASH_MODEL,
description: 'For tasks that need a balance of speed and reasoning', key: DEFAULT_GEMINI_FLASH_MODEL,
key: GEMINI_MODEL_ALIAS_FLASH,
}, },
{ {
value: GEMINI_MODEL_ALIAS_FLASH_LITE, value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: `Flash-Lite (${DEFAULT_GEMINI_FLASH_LITE_MODEL})`, title: DEFAULT_GEMINI_FLASH_LITE_MODEL,
description: 'For simple tasks that need to be done quickly', key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
key: GEMINI_MODEL_ALIAS_FLASH_LITE,
}, },
], ];
[config],
); if (config?.getPreviewFeatures()) {
list.unshift(
{
value: PREVIEW_GEMINI_MODEL,
title: PREVIEW_GEMINI_MODEL,
key: PREVIEW_GEMINI_MODEL,
},
{
value: PREVIEW_GEMINI_FLASH_MODEL,
title: PREVIEW_GEMINI_FLASH_MODEL,
key: PREVIEW_GEMINI_FLASH_MODEL,
},
);
}
return list;
}, [config]);
const options = view === 'main' ? mainOptions : manualOptions;
// Calculate the initial index based on the preferred model. // Calculate the initial index based on the preferred model.
const initialIndex = useMemo( const initialIndex = useMemo(() => {
() => options.findIndex((option) => option.value === preferredModel), const idx = options.findIndex((option) => option.value === preferredModel);
[preferredModel, options], if (idx !== -1) {
); return idx;
}
if (view === 'main') {
const manualIdx = options.findIndex((o) => o.value === 'Manual');
return manualIdx !== -1 ? manualIdx : 0;
}
return 0;
}, [preferredModel, options, view]);
// Handle selection internally (Autonomous Dialog). // Handle selection internally (Autonomous Dialog).
const handleSelect = useCallback( const handleSelect = useCallback(
(model: string) => { (model: string) => {
if (model === 'Manual') {
setView('manual');
return;
}
if (config) { if (config) {
config.setModel(model); config.setModel(model);
const event = new ModelSlashCommandEvent(model); const event = new ModelSlashCommandEvent(model);
@@ -12,7 +12,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> { export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
title: string; title: string;
description: string; description?: string;
} }
export interface DescriptiveRadioButtonSelectProps<T> { export interface DescriptiveRadioButtonSelectProps<T> {
@@ -62,7 +62,9 @@ export function DescriptiveRadioButtonSelect<T>({
renderItem={(item, { titleColor }) => ( renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}> <Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text> <Text color={titleColor}>{item.title}</Text>
<Text color={theme.text.secondary}>{item.description}</Text> {item.description && (
<Text color={theme.text.secondary}>{item.description}</Text>
)}
</Box> </Box>
)} )}
/> />
@@ -256,9 +256,8 @@ export class Session {
try { try {
const model = getEffectiveModel( const model = getEffectiveModel(
this.config.isInFallbackMode(),
this.config.getModel(), this.config.getModel(),
this.config.getPreviewFeatures(), this.config.isInFallbackMode(),
); );
const responseStream = await chat.sendMessageStream( const responseStream = await chat.sendMessageStream(
{ model }, { model },
-3
View File
@@ -12,9 +12,6 @@ export {
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_GEMINI_EMBEDDING_MODEL,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
} from './src/config/models.js'; } from './src/config/models.js';
export { export {
serializeTerminalToObject, serializeTerminalToObject,
@@ -12,7 +12,7 @@ import {
LS_TOOL_NAME, LS_TOOL_NAME,
READ_FILE_TOOL_NAME, READ_FILE_TOOL_NAME,
} from '../tools/tool-names.js'; } from '../tools/tool-names.js';
import { GEMINI_MODEL_ALIAS_PRO } from '../config/models.js'; import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
describe('CodebaseInvestigatorAgent', () => { describe('CodebaseInvestigatorAgent', () => {
it('should have the correct agent definition', () => { it('should have the correct agent definition', () => {
@@ -26,7 +26,7 @@ describe('CodebaseInvestigatorAgent', () => {
).toBe(true); ).toBe(true);
expect(CodebaseInvestigatorAgent.outputConfig?.outputName).toBe('report'); expect(CodebaseInvestigatorAgent.outputConfig?.outputName).toBe('report');
expect(CodebaseInvestigatorAgent.modelConfig?.model).toBe( expect(CodebaseInvestigatorAgent.modelConfig?.model).toBe(
GEMINI_MODEL_ALIAS_PRO, DEFAULT_GEMINI_MODEL,
); );
expect(CodebaseInvestigatorAgent.toolConfig?.tools).toEqual([ expect(CodebaseInvestigatorAgent.toolConfig?.tools).toEqual([
LS_TOOL_NAME, LS_TOOL_NAME,
@@ -11,7 +11,7 @@ import {
LS_TOOL_NAME, LS_TOOL_NAME,
READ_FILE_TOOL_NAME, READ_FILE_TOOL_NAME,
} from '../tools/tool-names.js'; } from '../tools/tool-names.js';
import { GEMINI_MODEL_ALIAS_PRO } from '../config/models.js'; import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
import { z } from 'zod'; import { z } from 'zod';
// Define a type that matches the outputConfig schema for type safety. // Define a type that matches the outputConfig schema for type safety.
@@ -69,7 +69,7 @@ export const CodebaseInvestigatorAgent: AgentDefinition<
processOutput: (output) => JSON.stringify(output, null, 2), processOutput: (output) => JSON.stringify(output, null, 2),
modelConfig: { modelConfig: {
model: GEMINI_MODEL_ALIAS_PRO, model: DEFAULT_GEMINI_MODEL,
temp: 0.1, temp: 0.1,
top_p: 0.95, top_p: 0.95,
thinkingBudget: -1, thinkingBudget: -1,
+2 -1
View File
@@ -10,6 +10,7 @@ import { makeFakeConfig } from '../test-utils/config.js';
import type { AgentDefinition } from './types.js'; import type { AgentDefinition } from './types.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
// A test-only subclass to expose the protected `registerAgent` method. // A test-only subclass to expose the protected `registerAgent` method.
class TestableAgentRegistry extends AgentRegistry { class TestableAgentRegistry extends AgentRegistry {
@@ -78,7 +79,7 @@ describe('AgentRegistry', () => {
model: 'gemini-3-pro-preview', model: 'gemini-3-pro-preview',
codebaseInvestigatorSettings: { codebaseInvestigatorSettings: {
enabled: true, enabled: true,
model: 'pro', model: DEFAULT_GEMINI_MODEL,
}, },
}); });
const previewRegistry = new TestableAgentRegistry(previewConfig); const previewRegistry = new TestableAgentRegistry(previewConfig);
+9 -4
View File
@@ -10,9 +10,10 @@ import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import { type z } from 'zod'; import { type z } from 'zod';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import { import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_PRO,
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
} from '../config/models.js'; } from '../config/models.js';
import type { ModelConfigAlias } from '../services/modelConfigService.js'; import type { ModelConfigAlias } from '../services/modelConfigService.js';
@@ -59,10 +60,14 @@ export class AgentRegistry {
// If the user is using the preview model for the main agent, force the sub-agent to use it too // If the user is using the preview model for the main agent, force the sub-agent to use it too
// if it's configured to use 'pro' or 'auto'. // if it's configured to use 'pro' or 'auto'.
if (this.config.getModel() === PREVIEW_GEMINI_MODEL) { if (
this.config.getModel() === PREVIEW_GEMINI_MODEL ||
this.config.getModel() === PREVIEW_GEMINI_MODEL_AUTO
) {
if ( if (
model === GEMINI_MODEL_ALIAS_PRO || model === PREVIEW_GEMINI_MODEL_AUTO ||
model === DEFAULT_GEMINI_MODEL_AUTO model === DEFAULT_GEMINI_MODEL_AUTO ||
model === DEFAULT_GEMINI_MODEL
) { ) {
model = PREVIEW_GEMINI_MODEL; model = PREVIEW_GEMINI_MODEL;
} }
@@ -33,11 +33,7 @@ export function resolvePolicyChain(
// Switch to getActiveModel() // Switch to getActiveModel()
const activeModel = const activeModel =
preferredModel ?? preferredModel ??
getEffectiveModel( getEffectiveModel(config.getModel(), config.isInFallbackMode());
config.isInFallbackMode(),
config.getModel(),
config.getPreviewFeatures(),
);
if (activeModel === 'auto') { if (activeModel === 'auto') {
return [...chain]; return [...chain];
+80 -184
View File
@@ -7,220 +7,116 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
getEffectiveModel, getEffectiveModel,
isGemini2Model,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL,
GEMINI_MODEL_ALIAS_PRO, PREVIEW_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_FLASH, DEFAULT_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_FLASH_LITE,
} from './models.js'; } from './models.js';
describe('getEffectiveModel', () => { describe('getEffectiveModel', () => {
describe('When NOT in fallback mode', () => { describe('When NOT in fallback mode', () => {
const isInFallbackMode = false; const useFallbackModel = false;
it('should return the Pro model when Pro is requested', () => { it('should return the Preview Pro model when auto-preview is requested', () => {
const model = getEffectiveModel( const model = getEffectiveModel(
isInFallbackMode, PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL, useFallbackModel,
false, );
expect(model).toBe(PREVIEW_GEMINI_MODEL);
});
it('should return the Default Pro model when auto-default is requested', () => {
const model = getEffectiveModel(
DEFAULT_GEMINI_MODEL_AUTO,
useFallbackModel,
); );
expect(model).toBe(DEFAULT_GEMINI_MODEL); expect(model).toBe(DEFAULT_GEMINI_MODEL);
}); });
it('should return the Flash model when Flash is requested', () => { it('should return the requested model as-is for explicit specific models', () => {
const model = getEffectiveModel( expect(getEffectiveModel(DEFAULT_GEMINI_MODEL, useFallbackModel)).toBe(
isInFallbackMode, DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
false,
); );
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL); expect(
}); getEffectiveModel(DEFAULT_GEMINI_FLASH_MODEL, useFallbackModel),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
it('should return the Lite model when Lite is requested', () => { expect(
const model = getEffectiveModel( getEffectiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, useFallbackModel),
isInFallbackMode, ).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
DEFAULT_GEMINI_FLASH_LITE_MODEL,
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
}); });
it('should return a custom model name when requested', () => { it('should return a custom model name when requested', () => {
const customModel = 'custom-model-v1'; const customModel = 'custom-model-v1';
const model = getEffectiveModel(isInFallbackMode, customModel, false); const model = getEffectiveModel(customModel, useFallbackModel);
expect(model).toBe(customModel); 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', () => { describe('When IN fallback mode', () => {
const isInFallbackMode = true; const useFallbackModel = true;
it('should downgrade the Pro model to the Flash model', () => { it('should return the Preview Flash model when auto-preview is requested', () => {
const model = getEffectiveModel( const model = getEffectiveModel(
isInFallbackMode, PREVIEW_GEMINI_MODEL_AUTO,
useFallbackModel,
);
expect(model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should return the Default Flash model when auto-default is requested', () => {
const model = getEffectiveModel(
DEFAULT_GEMINI_MODEL_AUTO,
useFallbackModel,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return the requested model as-is for explicit specific models', () => {
expect(getEffectiveModel(DEFAULT_GEMINI_MODEL, useFallbackModel)).toBe(
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
false,
); );
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL); expect(
getEffectiveModel(DEFAULT_GEMINI_FLASH_MODEL, useFallbackModel),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(
getEffectiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, useFallbackModel),
).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
}); });
it('should return the Flash model when Flash is requested', () => { it('should return custom model name as-is', () => {
const model = getEffectiveModel( const customModel = 'custom-model-v1';
isInFallbackMode, const model = getEffectiveModel(customModel, useFallbackModel);
DEFAULT_GEMINI_FLASH_MODEL, expect(model).toBe(customModel);
false,
);
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should HONOR the Lite model when Lite is requested', () => {
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, 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, 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);
});
}); });
}); });
}); });
describe('isGemini2Model', () => {
it('should return true for gemini-2.5-pro', () => {
expect(isGemini2Model('gemini-2.5-pro')).toBe(true);
});
it('should return true for gemini-2.5-flash', () => {
expect(isGemini2Model('gemini-2.5-flash')).toBe(true);
});
it('should return true for gemini-2.0-flash', () => {
expect(isGemini2Model('gemini-2.0-flash')).toBe(true);
});
it('should return false for gemini-1.5-pro', () => {
expect(isGemini2Model('gemini-1.5-pro')).toBe(false);
});
it('should return false for gemini-3-pro', () => {
expect(isGemini2Model('gemini-3-pro')).toBe(false);
});
it('should return false for arbitrary strings', () => {
expect(isGemini2Model('gpt-4')).toBe(false);
});
});
+39 -54
View File
@@ -10,83 +10,68 @@ export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; 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_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto'; export const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
]);
// Model aliases for user convenience. export const PREVIEW_GEMINI_MODEL_AUTO = 'auto-gemini-3';
export const GEMINI_MODEL_ALIAS_PRO = 'pro'; export const DEFAULT_GEMINI_MODEL_AUTO = 'auto-gemini-2.5';
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'; export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Cap the thinking at 8192 to prevent run-away thinking loops. // Cap the thinking at 8192 to prevent run-away thinking loops.
export const DEFAULT_THINKING_MODE = 8192; 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. * Determines the effective model to use, applying fallback logic if necessary.
* *
* When fallback mode is active, this function enforces the use of the standard * When fallback mode is active, this function enforces the use of the standard
* fallback model. However, it makes an exception for "lite" models (any model * fallback model.
* with "lite" in its name), allowing them to be used to preserve cost savings.
* This ensures that "pro" models are always downgraded, while "lite" model
* requests are honored.
* *
* @param isInFallbackMode Whether the application is in fallback mode.
* @param requestedModel The model that was originally requested. * @param requestedModel The model that was originally requested.
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled. * @param isInFallbackMode Whether the application is in fallback mode.
* @returns The effective model name. * @returns The effective model name.
*/ */
export function getEffectiveModel( export function getEffectiveModel(
isInFallbackMode: boolean,
requestedModel: string, requestedModel: string,
previewFeaturesEnabled: boolean | undefined, useFallbackModel: boolean,
): string { ): string {
const resolvedModel = resolveModel(requestedModel, previewFeaturesEnabled);
// If we are not in fallback mode, simply use the resolved model. // If we are not in fallback mode, simply use the resolved model.
if (!isInFallbackMode) { if (!useFallbackModel) {
return resolvedModel; switch (requestedModel) {
case PREVIEW_GEMINI_MODEL_AUTO:
return PREVIEW_GEMINI_MODEL;
case DEFAULT_GEMINI_MODEL_AUTO:
return DEFAULT_GEMINI_MODEL;
default:
return requestedModel;
}
} }
// If a "lite" model is requested, honor it. This allows for variations of // Fallback model for corresponding model family. We are doing fallback only
// lite models without needing to list them all as constants. // for Auto modes
if (resolvedModel.includes('lite')) { switch (requestedModel) {
return resolvedModel; case PREVIEW_GEMINI_MODEL_AUTO:
return PREVIEW_GEMINI_FLASH_MODEL;
case DEFAULT_GEMINI_MODEL_AUTO:
return DEFAULT_GEMINI_FLASH_MODEL;
default:
return requestedModel;
} }
}
// Default fallback for Gemini CLI. export function getDisplayString(model: string) {
return DEFAULT_GEMINI_FLASH_MODEL; switch (model) {
case PREVIEW_GEMINI_MODEL_AUTO:
return 'Auto (Gemini 3)';
case DEFAULT_GEMINI_MODEL_AUTO:
return 'Auto (Gemini 2.5)';
default:
return model;
}
} }
/** /**
+1 -5
View File
@@ -396,11 +396,7 @@ export class GeminiClient {
} }
const configModel = this.config.getModel(); const configModel = this.config.getModel();
return getEffectiveModel( return getEffectiveModel(configModel, this.config.isInFallbackMode());
this.config.isInFallbackMode(),
configModel,
this.config.getPreviewFeatures(),
);
} }
async *sendMessageStream( async *sendMessageStream(
+14 -11
View File
@@ -21,7 +21,9 @@ import {
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_THINKING_MODE, DEFAULT_THINKING_MODE,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
} from '../config/models.js'; } from '../config/models.js';
import { AuthType } from './contentGenerator.js'; import { AuthType } from './contentGenerator.js';
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js'; import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
@@ -155,7 +157,8 @@ describe('GeminiChat', () => {
getUserTier: vi.fn().mockReturnValue(undefined), getUserTier: vi.fn().mockReturnValue(undefined),
modelConfigService: { modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => { getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => {
const thinkingConfig = modelConfigKey.model.startsWith('gemini-3') const model = modelConfigKey.model ?? mockConfig.getModel();
const thinkingConfig = model.startsWith('gemini-3')
? { ? {
thinkingLevel: ThinkingLevel.HIGH, thinkingLevel: ThinkingLevel.HIGH,
} }
@@ -163,7 +166,7 @@ describe('GeminiChat', () => {
thinkingBudget: DEFAULT_THINKING_MODE, thinkingBudget: DEFAULT_THINKING_MODE,
}; };
return { return {
model: modelConfigKey.model, model,
generateContentConfig: { generateContentConfig: {
temperature: 0, temperature: 0,
thinkingConfig, thinkingConfig,
@@ -1859,7 +1862,7 @@ describe('GeminiChat', () => {
} as unknown as GenerateContentResponse; } as unknown as GenerateContentResponse;
it('should use the FLASH model when in fallback mode (sendMessageStream)', async () => { it('should use the FLASH model when in fallback mode (sendMessageStream)', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro'); vi.mocked(mockConfig.getModel).mockReturnValue('auto-gemini-2.5');
vi.mocked(mockConfig.isInFallbackMode).mockReturnValue(true); vi.mocked(mockConfig.isInFallbackMode).mockReturnValue(true);
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async () => async () =>
@@ -1869,7 +1872,7 @@ describe('GeminiChat', () => {
); );
const stream = await chat.sendMessageStream( const stream = await chat.sendMessageStream(
{ model: 'test-model' }, { model: 'auto-gemini-2.5' },
'test message', 'test message',
'prompt-id-res3', 'prompt-id-res3',
new AbortController().signal, new AbortController().signal,
@@ -1983,7 +1986,7 @@ describe('GeminiChat', () => {
expect(modelTurn.parts![0]!.text).toBe('Success on retry'); expect(modelTurn.parts![0]!.text).toBe('Success on retry');
}); });
it('should switch to DEFAULT_GEMINI_FLASH_MODEL and use thinkingBudget when falling back from a gemini-3 model', async () => { it('should switch to PREVIEW_GEMINI_FLASH_MODEL and use thinkingLevel when falling back from a gemini-3 model', async () => {
// ARRANGE // ARRANGE
const authType = AuthType.LOGIN_WITH_GOOGLE; const authType = AuthType.LOGIN_WITH_GOOGLE;
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
@@ -2020,7 +2023,7 @@ describe('GeminiChat', () => {
// ACT // ACT
const stream = await chat.sendMessageStream( const stream = await chat.sendMessageStream(
{ model: 'gemini-3-test-model' }, // Start with a gemini-3 model { model: PREVIEW_GEMINI_MODEL_AUTO }, // Start with a gemini-3 model
'test fallback thinking', 'test fallback thinking',
'prompt-id-fb3', 'prompt-id-fb3',
new AbortController().signal, new AbortController().signal,
@@ -2040,7 +2043,7 @@ describe('GeminiChat', () => {
).toHaveBeenNthCalledWith( ).toHaveBeenNthCalledWith(
1, 1,
expect.objectContaining({ expect.objectContaining({
model: 'gemini-3-test-model', model: PREVIEW_GEMINI_MODEL,
config: expect.objectContaining({ config: expect.objectContaining({
thinkingConfig: { thinkingConfig: {
thinkingBudget: undefined, thinkingBudget: undefined,
@@ -2051,17 +2054,17 @@ describe('GeminiChat', () => {
'prompt-id-fb3', 'prompt-id-fb3',
); );
// Second call: DEFAULT_GEMINI_FLASH_MODEL (due to fallback), thinkingBudget set (due to fix) // Second call: PREVIEW_GEMINI_FLASH_MODEL (due to fallback), thinkingLevel set
expect( expect(
mockContentGenerator.generateContentStream, mockContentGenerator.generateContentStream,
).toHaveBeenNthCalledWith( ).toHaveBeenNthCalledWith(
2, 2,
expect.objectContaining({ expect.objectContaining({
model: DEFAULT_GEMINI_FLASH_MODEL, model: PREVIEW_GEMINI_FLASH_MODEL,
config: expect.objectContaining({ config: expect.objectContaining({
thinkingConfig: { thinkingConfig: {
thinkingBudget: DEFAULT_THINKING_MODE, thinkingBudget: undefined,
thinkingLevel: undefined, thinkingLevel: ThinkingLevel.HIGH,
}, },
}), }),
}), }),
+1 -5
View File
@@ -460,11 +460,7 @@ export class GeminiChat {
} }
} }
} else { } else {
modelToUse = getEffectiveModel( modelToUse = getEffectiveModel(model, this.config.isInFallbackMode());
this.config.isInFallbackMode(),
model,
this.config.getPreviewFeatures(),
);
// Preview Model Bypass Logic: // Preview Model Bypass Logic:
// If we are in "Preview Model Bypass Mode" (transient failure), we force downgrade to 2.5 Pro // If we are in "Preview Model Bypass Mode" (transient failure), we force downgrade to 2.5 Pro
+1 -2
View File
@@ -106,9 +106,8 @@ export function getCoreSystemPrompt(
// TODO(joshualitt): Replace with system instructions on model configs. // TODO(joshualitt): Replace with system instructions on model configs.
const desiredModel = getEffectiveModel( const desiredModel = getEffectiveModel(
config.isInFallbackMode(),
config.getModel(), config.getModel(),
config.getPreviewFeatures(), config.isInFallbackMode(),
); );
const isGemini3 = desiredModel === PREVIEW_GEMINI_MODEL; const isGemini3 = desiredModel === PREVIEW_GEMINI_MODEL;
@@ -16,6 +16,7 @@ import {
import { import {
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
} from '../../config/models.js'; } from '../../config/models.js';
import { promptIdContext } from '../../utils/promptIdContext.js'; import { promptIdContext } from '../../utils/promptIdContext.js';
import type { Content } from '@google/genai'; import type { Content } from '@google/genai';
@@ -50,6 +51,7 @@ describe('ClassifierStrategy', () => {
modelConfigService: { modelConfigService: {
getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig), getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig),
}, },
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
getPreviewFeatures: () => false, getPreviewFeatures: () => false,
} as unknown as Config; } as unknown as Config;
mockBaseLlmClient = { mockBaseLlmClient = {
@@ -12,11 +12,7 @@ import type {
RoutingDecision, RoutingDecision,
RoutingStrategy, RoutingStrategy,
} from '../routingStrategy.js'; } from '../routingStrategy.js';
import { import { getEffectiveModel } from '../../config/models.js';
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_PRO,
resolveModel,
} from '../../config/models.js';
import { createUserContent, Type } from '@google/genai'; import { createUserContent, Type } from '@google/genai';
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { import {
@@ -174,10 +170,7 @@ export class ClassifierStrategy implements RoutingStrategy {
if (routerResponse.model_choice === FLASH_MODEL) { if (routerResponse.model_choice === FLASH_MODEL) {
return { return {
model: resolveModel( model: getEffectiveModel(config.getModel(), true),
GEMINI_MODEL_ALIAS_FLASH,
config.getPreviewFeatures(),
),
metadata: { metadata: {
source: 'Classifier', source: 'Classifier',
latencyMs, latencyMs,
@@ -186,10 +179,7 @@ export class ClassifierStrategy implements RoutingStrategy {
}; };
} else { } else {
return { return {
model: resolveModel( model: getEffectiveModel(config.getModel(), false),
GEMINI_MODEL_ALIAS_PRO,
config.getPreviewFeatures(),
),
metadata: { metadata: {
source: 'Classifier', source: 'Classifier',
reasoning, reasoning,
@@ -13,6 +13,9 @@ import {
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_FLASH_MODEL,
} from '../../config/models.js'; } from '../../config/models.js';
describe('FallbackStrategy', () => { describe('FallbackStrategy', () => {
@@ -32,11 +35,10 @@ describe('FallbackStrategy', () => {
}); });
describe('when in fallback mode', () => { describe('when in fallback mode', () => {
it('should downgrade a pro model to the flash model', async () => { it('should downgrade a default auto model to the flash model', async () => {
const mockConfig = { const mockConfig = {
isInFallbackMode: () => true, isInFallbackMode: () => true,
getModel: () => DEFAULT_GEMINI_MODEL, getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
getPreviewFeatures: () => false,
} as Config; } as Config;
const decision = await strategy.route( const decision = await strategy.route(
@@ -51,6 +53,42 @@ describe('FallbackStrategy', () => {
expect(decision?.metadata.reasoning).toContain('In fallback mode'); expect(decision?.metadata.reasoning).toContain('In fallback mode');
}); });
it('should downgrade a preview auto model to the preview flash model', async () => {
const mockConfig = {
isInFallbackMode: () => true,
getModel: () => PREVIEW_GEMINI_MODEL_AUTO,
} as Config;
const decision = await strategy.route(
mockContext,
mockConfig,
mockClient,
);
expect(decision).not.toBeNull();
expect(decision?.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(decision?.metadata.source).toBe('fallback');
expect(decision?.metadata.reasoning).toContain('In fallback mode');
});
it('should not downgrade a pro model to the flash model', async () => {
const mockConfig = {
isInFallbackMode: () => true,
getModel: () => DEFAULT_GEMINI_MODEL,
} as Config;
const decision = await strategy.route(
mockContext,
mockConfig,
mockClient,
);
expect(decision).not.toBeNull();
expect(decision?.model).toBe(DEFAULT_GEMINI_MODEL);
expect(decision?.metadata.source).toBe('fallback');
expect(decision?.metadata.reasoning).toContain('In fallback mode');
});
it('should honor a lite model request', async () => { it('should honor a lite model request', async () => {
const mockConfig = { const mockConfig = {
isInFallbackMode: () => true, isInFallbackMode: () => true,
@@ -28,9 +28,8 @@ export class FallbackStrategy implements RoutingStrategy {
} }
const effectiveModel = getEffectiveModel( const effectiveModel = getEffectiveModel(
isInFallbackMode,
config.getModel(), config.getModel(),
config.getPreviewFeatures(), isInFallbackMode,
); );
return { return {
model: effectiveModel, model: effectiveModel,
@@ -7,7 +7,8 @@
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { import {
DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO,
resolveModel, getEffectiveModel,
PREVIEW_GEMINI_MODEL_AUTO,
} from '../../config/models.js'; } from '../../config/models.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import type { import type {
@@ -30,11 +31,15 @@ export class OverrideStrategy implements RoutingStrategy {
const overrideModel = config.getModel(); const overrideModel = config.getModel();
// If the model is 'auto' we should pass to the next strategy. // If the model is 'auto' we should pass to the next strategy.
if (overrideModel === DEFAULT_GEMINI_MODEL_AUTO) return null; if (
overrideModel === DEFAULT_GEMINI_MODEL_AUTO ||
overrideModel === PREVIEW_GEMINI_MODEL_AUTO
)
return null;
// Return the overridden model name. // Return the overridden model name.
return { return {
model: resolveModel(overrideModel, config.getPreviewFeatures()), model: getEffectiveModel(overrideModel, false),
metadata: { metadata: {
source: this.name, source: this.name,
latencyMs: 0, latencyMs: 0,
+2 -2
View File
@@ -1353,8 +1353,8 @@
"model": { "model": {
"title": "Model", "title": "Model",
"description": "The model to use for the Codebase Investigator agent.", "description": "The model to use for the Codebase Investigator agent.",
"markdownDescription": "The model to use for the Codebase Investigator agent.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `pro`", "markdownDescription": "The model to use for the Codebase Investigator agent.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `gemini-2.5-pro`",
"default": "pro", "default": "gemini-2.5-pro",
"type": "string" "type": "string"
} }
}, },