Repository navigation
feat(cli): add 'gemini models list' with JSON output #29404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, it, expect, vi } from 'vitest'; | ||
| import { modelsCommand } from './models.js'; | ||
|
|
||
| vi.mock('./models/list.js', () => ({ listCommand: { command: 'list' } })); | ||
|
|
||
| vi.mock('../gemini.js', () => ({ | ||
| initializeOutputListenersAndFlush: vi.fn(), | ||
| })); | ||
|
|
||
| describe('modelsCommand', () => { | ||
| it('should have correct command and description', () => { | ||
| expect(modelsCommand.command).toBe('models <command>'); | ||
| expect(modelsCommand.describe).toBe('Inspect available models.'); | ||
| }); | ||
|
|
||
| it('should register the list subcommand in builder', () => { | ||
| const mockYargs = { | ||
| middleware: vi.fn().mockReturnThis(), | ||
| command: vi.fn().mockReturnThis(), | ||
| demandCommand: vi.fn().mockReturnThis(), | ||
| version: vi.fn().mockReturnThis(), | ||
| }; | ||
|
|
||
| // @ts-expect-error - Mocking yargs | ||
| modelsCommand.builder(mockYargs); | ||
|
|
||
| expect(mockYargs.middleware).toHaveBeenCalled(); | ||
| expect(mockYargs.command).toHaveBeenCalledWith( | ||
| expect.objectContaining({ command: 'list' }), | ||
| ); | ||
| expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); | ||
| expect(mockYargs.version).toHaveBeenCalledWith(false); | ||
| }); | ||
|
|
||
| it('should have a handler that does nothing', () => { | ||
| // @ts-expect-error - Handler doesn't take arguments in this case | ||
| expect(modelsCommand.handler()).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import type { CommandModule } from 'yargs'; | ||
| import { listCommand } from './models/list.js'; | ||
| import { initializeOutputListenersAndFlush } from '../gemini.js'; | ||
| import { defer } from '../deferred.js'; | ||
|
|
||
| export const modelsCommand: CommandModule = { | ||
| command: 'models <command>', | ||
| describe: 'Inspect available models.', | ||
| builder: (yargs) => | ||
| yargs | ||
| .middleware((argv) => { | ||
| initializeOutputListenersAndFlush(); | ||
| argv['isCommand'] = true; | ||
| }) | ||
| .command(defer(listCommand, 'models')) | ||
| .demandCommand(1, 'You need at least one command before continuing.') | ||
| .version(false), | ||
| handler: () => { | ||
| // This handler is not called when a subcommand is provided. | ||
| // Yargs will show the help menu. | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { | ||
| vi, | ||
| describe, | ||
| it, | ||
| expect, | ||
| beforeEach, | ||
| afterEach, | ||
| type MockInstance, | ||
| } from 'vitest'; | ||
| import { type Config } from '@google/gemini-cli-core'; | ||
| import { handleList, listCommand } from './list.js'; | ||
| import { loadSettings, type LoadedSettings } from '../../config/settings.js'; | ||
| import { loadCliConfig } from '../../config/config.js'; | ||
| import { buildAvailableModels } from '../../acp/acpUtils.js'; | ||
|
|
||
| vi.mock('../../config/settings.js'); | ||
| vi.mock('../../config/config.js'); | ||
| vi.mock('../../acp/acpUtils.js', () => ({ buildAvailableModels: vi.fn() })); | ||
| vi.mock('../utils.js', () => ({ | ||
| exitCli: vi.fn(), | ||
| })); | ||
|
|
||
| describe('models list command', () => { | ||
| const mockLoadSettings = vi.mocked(loadSettings); | ||
| const mockLoadCliConfig = vi.mocked(loadCliConfig); | ||
| const mockBuildAvailableModels = vi.mocked(buildAvailableModels); | ||
| let stdoutWriteSpy: MockInstance<typeof process.stdout.write>; | ||
|
|
||
| const written = () => | ||
| stdoutWriteSpy.mock.calls.map((call) => String(call[0])).join(''); | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| mockLoadSettings.mockReturnValue({ | ||
| merged: {}, | ||
| } as unknown as LoadedSettings); | ||
| mockLoadCliConfig.mockResolvedValue({ | ||
| initialize: vi.fn().mockResolvedValue(undefined), | ||
| } as unknown as Config); | ||
| mockBuildAvailableModels.mockReturnValue({ | ||
| currentModelId: 'gemini-2.5-pro', | ||
| availableModels: [ | ||
| { | ||
| modelId: 'auto', | ||
| name: 'Auto', | ||
| description: 'Let Gemini CLI decide', | ||
| }, | ||
| { modelId: 'gemini-2.5-pro', name: 'gemini-2.5-pro' }, | ||
| ], | ||
| }); | ||
| stdoutWriteSpy = vi | ||
| .spyOn(process.stdout, 'write') | ||
| .mockImplementation(() => true); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('handleList', () => { | ||
| it('prints machine-readable JSON with id, name, description and input limit', async () => { | ||
| await handleList({ outputFormat: 'json' }); | ||
|
|
||
| const parsed = JSON.parse(written()); | ||
| expect(parsed).toEqual({ | ||
| currentModel: 'gemini-2.5-pro', | ||
| models: [ | ||
| { | ||
| id: 'auto', | ||
| name: 'Auto', | ||
| description: 'Let Gemini CLI decide', | ||
| inputTokenLimit: 1_048_576, | ||
| }, | ||
| { | ||
| id: 'gemini-2.5-pro', | ||
| name: 'gemini-2.5-pro', | ||
| description: '', | ||
| inputTokenLimit: 1_048_576, | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it('emits only JSON on stdout when json is requested', async () => { | ||
| await handleList({ outputFormat: 'json' }); | ||
|
|
||
| expect(() => JSON.parse(written())).not.toThrow(); | ||
| }); | ||
|
|
||
| it('prints a human-readable list by default and marks the current model', async () => { | ||
| await handleList({}); | ||
|
|
||
| const output = written(); | ||
| expect(output).toContain('Available Models:'); | ||
| expect(output).toContain('auto'); | ||
| expect(output).toContain('Let Gemini CLI decide'); | ||
| expect(output).toContain('(current)'); | ||
| expect(output).toContain('1,048,576 tokens'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('listCommand', () => { | ||
| it('is registered as "list" and exposes an output-format option', () => { | ||
| expect(listCommand.command).toBe('list'); | ||
|
|
||
| const mockYargs = { option: vi.fn().mockReturnThis() }; | ||
| // @ts-expect-error - Mocking yargs | ||
| listCommand.builder(mockYargs); | ||
|
|
||
| expect(mockYargs.option).toHaveBeenCalledWith( | ||
| 'output-format', | ||
| expect.objectContaining({ | ||
| alias: 'o', | ||
| choices: ['text', 'json'], | ||
| default: 'text', | ||
| }), | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import type { CommandModule } from 'yargs'; | ||
| import { tokenLimit } from '@google/gemini-cli-core'; | ||
| import { loadSettings } from '../../config/settings.js'; | ||
| import { loadCliConfig, type CliArgs } from '../../config/config.js'; | ||
| import { buildAvailableModels } from '../../acp/acpUtils.js'; | ||
| import { exitCli } from '../utils.js'; | ||
| import chalk from 'chalk'; | ||
|
|
||
| export type ModelsListOutputFormat = 'text' | 'json'; | ||
|
|
||
| export interface ListedModel { | ||
| id: string; | ||
| name: string; | ||
| description: string; | ||
| inputTokenLimit: number; | ||
| } | ||
|
|
||
| export async function handleList(args: { | ||
| outputFormat?: ModelsListOutputFormat; | ||
| }) { | ||
| const workspaceDir = process.cwd(); | ||
| const settings = loadSettings(workspaceDir); | ||
|
|
||
| const config = await loadCliConfig( | ||
| settings.merged, | ||
| 'models-list-session', | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | ||
| { | ||
| debug: false, | ||
| } as Partial<CliArgs> as CliArgs, | ||
| { cwd: workspaceDir }, | ||
| ); | ||
|
|
||
| await config.initialize(); | ||
|
|
||
| const { availableModels, currentModelId } = buildAvailableModels( | ||
| config, | ||
| settings, | ||
| ); | ||
|
|
||
| const models: ListedModel[] = availableModels.map((m) => ({ | ||
| id: m.modelId, | ||
| name: m.name, | ||
| description: m.description ?? '', | ||
| inputTokenLimit: tokenLimit(m.modelId), | ||
| })); | ||
|
|
||
| if (args.outputFormat === 'json') { | ||
| process.stdout.write( | ||
| JSON.stringify({ currentModel: currentModelId, models }, null, 2) + '\n', | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| process.stdout.write(chalk.bold('Available Models:') + '\n\n'); | ||
| for (const model of models) { | ||
| const current = | ||
| model.id === currentModelId ? chalk.green(' (current)') : ''; | ||
| process.stdout.write(`${chalk.bold(model.id)}${current}\n`); | ||
| process.stdout.write(` Name: ${model.name}\n`); | ||
| if (model.description) { | ||
| process.stdout.write(` Description: ${model.description}\n`); | ||
| } | ||
| process.stdout.write( | ||
| ` Input limit: ${model.inputTokenLimit.toLocaleString('en-US')} tokens\n\n`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export const listCommand: CommandModule = { | ||
| command: 'list', | ||
| describe: | ||
| 'Lists the models available for use with the -m/--model flag, based on your current configuration.', | ||
| builder: (yargs) => | ||
| yargs.option('output-format', { | ||
| alias: 'o', | ||
| type: 'string', | ||
| choices: ['text', 'json'], | ||
| default: 'text', | ||
| description: 'The format of the output.', | ||
| }), | ||
| handler: async (argv) => { | ||
| await handleList({ | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | ||
| outputFormat: argv['outputFormat'] as ModelsListOutputFormat, | ||
| }); | ||
| await exitCli(); | ||
| }, | ||
|
Comment on lines
+88
to
+94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If handler: async (argv) => {
try {
await handleList({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
outputFormat: argv['outputFormat'] as ModelsListOutputFormat,
});
await exitCli();
} catch (error) {
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
await exitCli(1);
}
}, |
||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
tokenLimitfunction can returnundefinedif a model's limit is not defined or if the model is unrecognized. SinceinputTokenLimitin theListedModelinterface is strictly typed as anumber, assigning a potentiallyundefinedvalue will cause a TypeScript compilation error understrictNullChecks. Additionally,m.namemay be optional in the model interface and should have a fallback to prevent potential runtime issues.References
undefinedcase (e.g., by providing a default with??). Do not rely on the implementation details of the function that creates the object to always provide a value, as this can change. Code against the interface contract.