diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 14295954dd..f077b0ef4b 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -27,6 +27,7 @@ import { type MessageBus, LlmRole, type GitService, + type ModelRouterService, processSingleFileContent, InvalidStreamError, } from '@google/gemini-cli-core'; @@ -102,17 +103,7 @@ vi.mock( ...actual, updatePolicy: vi.fn(), createPolicyUpdater: vi.fn(), - ReadManyFilesTool: vi.fn().mockImplementation(() => ({ - name: 'read_many_files', - kind: 'read', - build: vi.fn().mockReturnValue({ - getDescription: () => 'Read files', - toolLocations: () => [], - execute: vi.fn().mockResolvedValue({ - llmContent: ['--- file.txt ---\n\nFile content\n\n'], - }), - }), - })), + ReadManyFilesTool: vi.fn(), logToolCall: vi.fn(), LlmRole: { MAIN: 'main', @@ -421,6 +412,26 @@ describe('GeminiAgent', () => { ); }); + it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => { + mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true); + mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true); + mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true); + + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.models?.availableModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + modelId: 'gemini-3.1-flash-lite-preview', + name: 'gemini-3.1-flash-lite-preview', + }), + ]), + ); + }); + it('should return modes with plan mode when plan is enabled', async () => { mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ apiKey: 'test-key', @@ -646,6 +657,7 @@ describe('Session', () => { sendMessageStream: vi.fn(), addHistory: vi.fn(), recordCompletedToolCalls: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), } as unknown as Mocked; mockTool = { kind: 'read', @@ -667,6 +679,9 @@ describe('Session', () => { mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), + getModelRouterService: vi.fn().mockReturnValue({ + route: vi.fn().mockResolvedValue({ model: 'resolved-model' }), + }), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getMcpServers: vi.fn(), getFileService: vi.fn().mockReturnValue({ @@ -713,10 +728,22 @@ describe('Session', () => { }, errors: [], } as unknown as LoadedSettings); + + (ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({ + name: 'read_many_files', + kind: 'read', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Read files', + toolLocations: () => [], + execute: vi.fn().mockResolvedValue({ + llmContent: ['--- file.txt ---\n\nFile content\n\n'], + }), + }), + })); }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); it('should send available commands', async () => { @@ -786,6 +813,42 @@ describe('Session', () => { expect(result).toMatchObject({ stopReason: 'end_turn' }); }); + it('should use model router to determine model', async () => { + const mockRouter = { + route: vi.fn().mockResolvedValue({ model: 'routed-model' }), + } as unknown as ModelRouterService; + mockConfig.getModelRouterService.mockReturnValue(mockRouter); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Hello' }] } }], + }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(mockRouter.route).toHaveBeenCalledWith( + expect.objectContaining({ + requestedModel: 'gemini-pro', + request: [{ text: 'Hi' }], + }), + ); + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.objectContaining({ model: 'routed-model' }), + expect.any(Array), + expect.any(String), + expect.any(Object), + expect.any(String), + ); + }); + it('should handle prompt with empty response (InvalidStreamError)', async () => { mockChat.sendMessageStream.mockRejectedValue( new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'), diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 6b76ffdc7a..14761d7162 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -28,7 +28,7 @@ import { debugLogger, ReadManyFilesTool, REFERENCE_CONTENT_START, - resolveModel, + type RoutingContext, createWorkingStdio, startupProfiler, Kind, @@ -42,6 +42,7 @@ import { DEFAULT_GEMINI_FLASH_LITE_MODEL, PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL_AUTO, @@ -758,10 +759,15 @@ export class Session { const functionCalls: FunctionCall[] = []; try { - const model = resolveModel( - this.context.config.getModel(), - (await this.context.config.getGemini31Launched?.()) ?? false, - ); + const routingContext: RoutingContext = { + history: chat.getHistory(/*curated=*/ true), + request: nextMessage?.parts ?? [], + signal: pendingSend.signal, + requestedModel: this.context.config.getModel(), + }; + + const router = this.context.config.getModelRouterService(); + const { model } = await router.route(routingContext); const responseStream = await chat.sendMessageStream( { model }, nextMessage?.parts ?? [], @@ -2009,10 +2015,31 @@ function buildAvailableModels( const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const shouldShowPreviewModels = config.getHasAccessToPreviewModel(); const useGemini31 = config.getGemini31LaunchedSync?.() ?? false; + const useGemini31FlashLite = + config.getGemini31FlashLiteLaunchedSync?.() ?? false; const selectedAuthType = settings.merged.security.auth.selectedType; const useCustomToolModel = useGemini31 && selectedAuthType === AuthType.USE_GEMINI; + // --- DYNAMIC PATH --- + if ( + config.getExperimentalDynamicModelConfiguration?.() === true && + config.getModelConfigService + ) { + const options = config.getModelConfigService().getAvailableModelOptions({ + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + useCustomTools: useCustomToolModel, + hasAccessToPreview: shouldShowPreviewModels, + }); + + return { + availableModels: options, + currentModelId: preferredModel, + }; + } + + // --- LEGACY PATH --- const mainOptions = [ { value: DEFAULT_GEMINI_MODEL_AUTO, @@ -2056,7 +2083,7 @@ function buildAvailableModels( ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL : previewProModel; - manualOptions.unshift( + const previewOptions = [ { value: previewProValue, title: getDisplayString(previewProModel), @@ -2065,7 +2092,16 @@ function buildAvailableModels( value: PREVIEW_GEMINI_FLASH_MODEL, title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL), }, - ); + ]; + + if (useGemini31FlashLite) { + previewOptions.push({ + value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, + title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL), + }); + } + + manualOptions.unshift(...previewOptions); } const scaleOptions = ( diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 618bc353c1..8724799a94 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -71,9 +71,11 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const manualModelSelected = useMemo(() => { if ( config?.getExperimentalDynamicModelConfiguration?.() === true && - config.modelConfigService + config.getModelConfigService ) { - const def = config.modelConfigService.getModelDefinition(preferredModel); + const def = config + .getModelConfigService() + .getModelDefinition(preferredModel); // Only treat as manual selection if it's a visible, non-auto model. return def && def.tier !== 'auto' && def.isVisible === true ? preferredModel @@ -119,30 +121,25 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { // --- DYNAMIC PATH --- if ( config?.getExperimentalDynamicModelConfiguration?.() === true && - config.modelConfigService + config.getModelConfigService ) { - const list = Object.entries( - config.modelConfigService.getModelDefinitions?.() ?? {}, - ) - .filter(([_, m]) => { - // Basic visibility and Preview access - if (m.isVisible !== true) return false; - if (m.isPreview && !shouldShowPreviewModels) return false; - // Only auto models are shown on the main menu - if (m.tier !== 'auto') return false; - return true; - }) - .map(([id, m]) => ({ - value: id, - title: m.displayName ?? getDisplayString(id, config ?? undefined), - description: - id === 'auto-gemini-3' && useGemini31 - ? (m.dialogDescription ?? '').replace( - 'gemini-3-pro', - 'gemini-3.1-pro', - ) - : (m.dialogDescription ?? ''), - key: id, + const allOptions = config + .getModelConfigService() + .getAvailableModelOptions({ + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + useCustomTools: useCustomToolModel, + hasAccessToPreview: shouldShowPreviewModels, + hasAccessToProModel, + }); + + const list = allOptions + .filter((o) => o.tier === 'auto') + .map((o) => ({ + value: o.modelId, + title: o.name, + description: o.description, + key: o.modelId, })); list.push({ @@ -186,64 +183,39 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { }); } return list; - }, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]); + }, [ + config, + shouldShowPreviewModels, + manualModelSelected, + useGemini31, + useGemini31FlashLite, + useCustomToolModel, + hasAccessToProModel, + ]); const manualOptions = useMemo(() => { // --- DYNAMIC PATH --- if ( config?.getExperimentalDynamicModelConfiguration?.() === true && - config.modelConfigService + config.getModelConfigService ) { - const list = Object.entries( - config.modelConfigService.getModelDefinitions?.() ?? {}, - ) - .filter(([id, m]) => { - // Basic visibility and Preview access - if (m.isVisible !== true) return false; - if (m.isPreview && !shouldShowPreviewModels) return false; - // Auto models are for main menu only - if (m.tier === 'auto') return false; - // Pro models are shown for users with pro access - if (!hasAccessToProModel && m.tier === 'pro') return false; - - // Flag Guard: Versioned models only show if their flag is active. - if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false; - if ( - id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && - !useGemini31FlashLite - ) - return false; - - return true; - }) - .map(([id, m]) => { - const resolvedId = config.modelConfigService.resolveModelId(id, { - useGemini3_1: useGemini31, - useGemini3_1FlashLite: useGemini31FlashLite, - useCustomTools: useCustomToolModel, - }); - // Title ID is the resolved ID without custom tools flag - const titleId = config.modelConfigService.resolveModelId(id, { - useGemini3_1: useGemini31, - useGemini3_1FlashLite: useGemini31FlashLite, - }); - return { - value: resolvedId, - title: - m.displayName ?? getDisplayString(titleId, config ?? undefined), - key: id, - }; + const allOptions = config + .getModelConfigService() + .getAvailableModelOptions({ + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + useCustomTools: useCustomToolModel, + hasAccessToPreview: shouldShowPreviewModels, + hasAccessToProModel, }); - // Deduplicate: only show one entry per unique resolved model value. - // This is needed because 3 pro and 3.1 pro models can resolve to the same - // value, depending on the useGemini31 flag. - const seen = new Set(); - return list.filter((option) => { - if (seen.has(option.value)) return false; - seen.add(option.value); - return true; - }); + return allOptions + .filter((o) => o.tier !== 'auto') + .map((o) => ({ + value: o.modelId, + title: o.name, + key: o.modelId, + })); } // --- LEGACY PATH --- diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 00b5fa1010..f01b4bbd93 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2683,6 +2683,10 @@ export class Config implements McpContext, AgentLoopContext { return this.modelRouterService; } + getModelConfigService(): ModelConfigService { + return this.modelConfigService; + } + getModelAvailabilityService(): ModelAvailabilityService { return this.modelAvailabilityService; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 87feca53e7..5361397386 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,6 +49,10 @@ export * from './scheduler/tool-executor.js'; export * from './scheduler/policy.js'; export * from './core/recordingContentGenerator.js'; +// Export Routing +export * from './routing/routingStrategy.js'; +export * from './routing/modelRouterService.js'; + export * from './fallback/types.js'; export * from './fallback/handler.js'; @@ -132,6 +136,7 @@ export * from './services/FolderTrustDiscoveryService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sandboxedFileSystemService.js'; +export * from './services/modelConfigService.js'; export * from './sandbox/windows/WindowsSandboxManager.js'; export * from './services/sessionSummaryUtils.js'; export * from './context/contextManager.js'; diff --git a/packages/core/src/services/modelConfigService.test.ts b/packages/core/src/services/modelConfigService.test.ts index 2bc69bbfe2..70df1aa7b0 100644 --- a/packages/core/src/services/modelConfigService.test.ts +++ b/packages/core/src/services/modelConfigService.test.ts @@ -1018,4 +1018,41 @@ describe('ModelConfigService', () => { expect(retry.generateContentConfig.temperature).toBe(1.0); }); }); + + describe('getAvailableModelOptions', () => { + it('should filter out Pro models when hasAccessToProModel is false', () => { + const config: ModelConfigServiceConfig = { + modelDefinitions: { + 'gemini-3-pro': { isVisible: true, tier: 'pro' }, + 'gemini-3-flash': { isVisible: true, tier: 'flash' }, + }, + }; + const service = new ModelConfigService(config); + const options = service.getAvailableModelOptions({ + hasAccessToProModel: false, + }); + + expect(options.map((o) => o.modelId)).not.toContain('gemini-3-pro'); + expect(options.map((o) => o.modelId)).toContain('gemini-3-flash'); + }); + + it('should include Pro models when hasAccessToProModel is true or undefined', () => { + const config: ModelConfigServiceConfig = { + modelDefinitions: { + 'gemini-3-pro': { isVisible: true, tier: 'pro' }, + }, + }; + const service = new ModelConfigService(config); + + const optionsWithTrue = service.getAvailableModelOptions({ + hasAccessToProModel: true, + }); + expect(optionsWithTrue.map((o) => o.modelId)).toContain('gemini-3-pro'); + + const optionsWithUndefined = service.getAvailableModelOptions({}); + expect(optionsWithUndefined.map((o) => o.modelId)).toContain( + 'gemini-3-pro', + ); + }); + }); }); diff --git a/packages/core/src/services/modelConfigService.ts b/packages/core/src/services/modelConfigService.ts index d92532fd3a..a6d59365d7 100644 --- a/packages/core/src/services/modelConfigService.ts +++ b/packages/core/src/services/modelConfigService.ts @@ -6,6 +6,12 @@ import type { GenerateContentConfig } from '@google/genai'; import type { ModelPolicy } from '../availability/modelPolicy.js'; +import { + getDisplayString, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, + isProModel, +} from '../config/models.js'; // The primary key for the ModelConfig is the model string. However, we also // support a secondary key to limit the override scope, typically an agent name. @@ -93,6 +99,7 @@ export interface ResolutionContext { useGemini3_1FlashLite?: boolean; useCustomTools?: boolean; hasAccessToPreview?: boolean; + hasAccessToProModel?: boolean; requestedModel?: string; } @@ -135,6 +142,78 @@ export class ModelConfigService { // TODO(12597): Process config to build a typed alias hierarchy. constructor(private readonly config: ModelConfigServiceConfig) {} + /** + * Returns a standardized list of available model options based on the resolution context. + * This logic is shared across the TUI and ACP mode. + */ + getAvailableModelOptions(context: ResolutionContext): Array<{ + modelId: string; + name: string; + description: string; + tier: string; + }> { + const definitions = this.config.modelDefinitions ?? {}; + const shouldShowPreviewModels = context.hasAccessToPreview ?? false; + const useGemini31 = context.useGemini3_1 ?? false; + const useGemini31FlashLite = context.useGemini3_1FlashLite ?? false; + + const mainOptions = Object.entries(definitions) + .filter(([_, m]) => { + if (m.isVisible !== true) return false; + if (m.isPreview && !shouldShowPreviewModels) return false; + if (m.tier !== 'auto') return false; + return true; + }) + .map(([id, m]) => ({ + modelId: id, + name: m.displayName ?? getDisplayString(id), + description: + id === 'auto-gemini-3' && useGemini31 + ? (m.dialogDescription ?? '').replace( + 'gemini-3-pro', + 'gemini-3.1-pro', + ) + : (m.dialogDescription ?? ''), + tier: m.tier ?? 'auto', + })); + + const manualOptions = Object.entries(definitions) + .filter(([id, m]) => { + if (m.isVisible !== true) return false; + if (m.isPreview && !shouldShowPreviewModels) return false; + if (m.tier === 'auto') return false; + if (context.hasAccessToProModel === false && isProModel(id)) + return false; + if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false; + if (id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && !useGemini31FlashLite) + return false; + return true; + }) + .map(([id, m]) => { + const resolvedId = this.resolveModelId(id, context); + const titleId = this.resolveModelId(id, { + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + }); + return { + modelId: resolvedId, + name: m.displayName ?? getDisplayString(titleId), + description: m.dialogDescription ?? '', + tier: m.tier ?? 'custom', + }; + }); + + // Deduplicate manual options + const seen = new Set(); + const uniqueManualOptions = manualOptions.filter((option) => { + if (seen.has(option.modelId)) return false; + seen.add(option.modelId); + return true; + }); + + return [...mainOptions, ...uniqueManualOptions]; + } + getModelDefinition(modelId: string): ModelDefinition | undefined { const definition = this.config.modelDefinitions?.[modelId]; if (definition) {