Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 112 additions & 2 deletions packages/cli/src/ui/components/ModelDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
AuthType,
UserTierId,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';

Expand All @@ -28,8 +30,9 @@
const mockLogModelSlashCommand = vi.fn();
const mockModelSlashCommandEvent = vi.fn();

vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getDisplayString: (val: string) => mockGetDisplayString(val),
Expand All @@ -40,6 +43,7 @@
mockModelSlashCommandEvent(model);
}
},
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: 'gemini-3.1-flash-lite-preview',

Check warning on line 46 in packages/cli/src/ui/components/ModelDialog.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.1". Please make sure this change is appropriate to submit.
};
});

Expand All @@ -49,13 +53,19 @@
const mockOnClose = vi.fn();
const mockGetHasAccessToPreviewModel = vi.fn();
const mockGetGemini31LaunchedSync = vi.fn();
const mockGetProModelNoAccess = vi.fn();
const mockGetProModelNoAccessSync = vi.fn();
const mockGetUserTier = vi.fn();

interface MockConfig extends Partial<Config> {
setModel: (model: string, isTemporary?: boolean) => void;
getModel: () => string;
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
}

const mockConfig: MockConfig = {
Expand All @@ -64,13 +74,19 @@
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
};

beforeEach(() => {
vi.resetAllMocks();
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetGemini31LaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);

// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
Expand Down Expand Up @@ -109,6 +125,55 @@
unmount();
});

it('renders the "manual" view initially for users with no pro access and filters Pro models with correct order', async () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
mockGetDisplayString.mockImplementation((val: string) => val);

const { lastFrame, unmount } = await renderComponent();

const output = lastFrame();
expect(output).toContain('Select Model');
expect(output).not.toContain(DEFAULT_GEMINI_MODEL);
expect(output).not.toContain(PREVIEW_GEMINI_MODEL);

// Verify order: Flash Preview -> Flash Lite Preview -> Flash -> Flash Lite
const flashPreviewIdx = output.indexOf(PREVIEW_GEMINI_FLASH_MODEL);
const flashLitePreviewIdx = output.indexOf(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
);
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
const flashLiteIdx = output.indexOf(DEFAULT_GEMINI_FLASH_LITE_MODEL);

expect(flashPreviewIdx).toBeLessThan(flashLitePreviewIdx);
expect(flashLitePreviewIdx).toBeLessThan(flashIdx);
expect(flashIdx).toBeLessThan(flashLiteIdx);

expect(output).not.toContain('Auto');
unmount();
});

it('closes dialog on escape in "manual" view for users with no pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
const { stdin, waitUntilReady, unmount } = await renderComponent();

// Already in manual view
await act(async () => {
stdin.write('\u001B'); // Escape
});
await act(async () => {
await waitUntilReady();
});

await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});

it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => {
mockGetDisplayString.mockImplementation((val: string) => {
if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model';
Expand Down Expand Up @@ -369,5 +434,50 @@
});
unmount();
});

it('hides Flash Lite Preview model for users with pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();

// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();

const output = lastFrame();
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});

it('shows Flash Lite Preview model for free tier users', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();

// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();

const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
});
});
55 changes: 49 additions & 6 deletions packages/cli/src/ui/components/ModelDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
*/

import type React from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { useCallback, useContext, useMemo, useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
Expand All @@ -21,6 +22,8 @@
getDisplayString,
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
UserTierId,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
Expand All @@ -35,9 +38,26 @@
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const settings = useSettings();
const [view, setView] = useState<'main' | 'manual'>('main');
const [hasAccessToProModel, setHasAccessToProModel] = useState<boolean>(
() => !(config?.getProModelNoAccessSync() ?? false),
);
const [view, setView] = useState<'main' | 'manual'>(() =>
config?.getProModelNoAccessSync() ? 'manual' : 'main',
);
const [persistMode, setPersistMode] = useState(false);

useEffect(() => {
async function checkAccess() {
if (!config) return;
const noAccess = await config.getProModelNoAccess();
setHasAccessToProModel(!noAccess);
if (noAccess) {
setView('manual');
}
}
void checkAccess();
}, [config]);

// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;

Expand Down Expand Up @@ -66,7 +86,7 @@
useKeypress(
(key) => {
if (key.name === 'escape') {
if (view === 'manual') {
if (view === 'manual' && hasAccessToProModel) {
setView('main');
} else {
onClose();
Expand Down Expand Up @@ -106,7 +126,7 @@
value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description: useGemini31
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'

Check warning on line 129 in packages/cli/src/ui/components/ModelDialog.tsx

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.1". Please make sure this change is appropriate to submit.
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
key: PREVIEW_GEMINI_MODEL_AUTO,
});
Expand All @@ -115,6 +135,7 @@
}, [shouldShowPreviewModels, manualModelSelected, useGemini31]);

const manualOptions = useMemo(() => {
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
const list = [
{
value: DEFAULT_GEMINI_MODEL,
Expand Down Expand Up @@ -142,7 +163,7 @@
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;

list.unshift(
const previewOptions = [
{
value: previewProValue,
title: getDisplayString(previewProModel),
Expand All @@ -153,10 +174,32 @@
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
key: PREVIEW_GEMINI_FLASH_MODEL,
},
);
];

if (isFreeTier) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
key: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
});
}

list.unshift(...previewOptions);
}

if (!hasAccessToProModel) {
// Filter out all Pro models for free tier
return list.filter((option) => !isProModel(option.value));
}

return list;
}, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
}, [
shouldShowPreviewModels,
useGemini31,
useCustomToolModel,
hasAccessToProModel,
config,
]);

const options = view === 'main' ? mainOptions : manualOptions;

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/code_assist/experiments/flagNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const ExperimentFlags = {
MASKING_PRUNABLE_THRESHOLD: 45758818,
MASKING_PROTECT_LATEST_TURN: 45758819,
GEMINI_3_1_PRO_LAUNCHED: 45760185,
PRO_MODEL_NO_ACCESS: 45768879,
} as const;

export type ExperimentFlagName =
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_FLASH_MODEL,
} from './models.js';
import { Storage } from './storage.js';

Expand Down Expand Up @@ -593,6 +595,46 @@
config.getGeminiClient().stripThoughtsFromHistory,
).not.toHaveBeenCalledWith();
});

it('should switch to flash model if user has no Pro access and model is auto', async () => {
vi.mocked(getExperiments).mockResolvedValue({
experimentIds: [],
flags: {
[ExperimentFlags.PRO_MODEL_NO_ACCESS]: {
boolValue: true,
},
},
});

const config = new Config({
...baseParams,
model: PREVIEW_GEMINI_MODEL_AUTO,
});

await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);

expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});

it('should NOT switch to flash model if user has Pro access and model is auto', async () => {
vi.mocked(getExperiments).mockResolvedValue({
experimentIds: [],
flags: {
[ExperimentFlags.PRO_MODEL_NO_ACCESS]: {
boolValue: false,
},
},
});

const config = new Config({
...baseParams,
model: PREVIEW_GEMINI_MODEL_AUTO,
});

await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);

expect(config.getModel()).toBe(PREVIEW_GEMINI_MODEL_AUTO);
});
});

it('Config constructor should store userMemory correctly', () => {
Expand Down Expand Up @@ -2577,7 +2619,7 @@
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3.1-pro-preview',

Check warning on line 2622 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.1". Please make sure this change is appropriate to submit.
remainingAmount: '100',
remainingFraction: 1.0,
},
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,10 @@ export class Config implements McpContext, AgentLoopContext {
},
);
this.setRemoteAdminSettings(adminControls);

if ((await this.getProModelNoAccess()) && isAutoModel(this.model)) {
this.setModel(PREVIEW_GEMINI_FLASH_MODEL);
}
}

async getExperimentsAsync(): Promise<Experiments | undefined> {
Expand Down Expand Up @@ -2534,6 +2538,30 @@ export class Config implements McpContext, AgentLoopContext {
);
}

/**
* Returns whether the user has access to Pro models.
* This is determined by the PRO_MODEL_NO_ACCESS experiment flag.
*/
async getProModelNoAccess(): Promise<boolean> {
await this.ensureExperimentsLoaded();
return this.getProModelNoAccessSync();
}

/**
* Returns whether the user has access to Pro models synchronously.
*
* Note: This method should only be called after startup, once experiments have been loaded.
*/
getProModelNoAccessSync(): boolean {
if (this.contentGeneratorConfig?.authType !== AuthType.LOGIN_WITH_GOOGLE) {
return false;
}
return (
this.experiments?.flags[ExperimentFlags.PRO_MODEL_NO_ACCESS]?.boolValue ??
false
);
}

/**
* Returns whether Gemini 3.1 has been launched.
* This method is async and ensures that experiments are loaded before returning the result.
Expand Down
Loading
Loading