From 25e7d72e3963250d88d12770b0e2418206e4b3e7 Mon Sep 17 00:00:00 2001 From: Bellam vedha kousik Date: Wed, 15 Jul 2026 19:30:32 +0530 Subject: [PATCH] fix(availability): apply modelIdResolutions to tool sub-agent model configs web-search, web-fetch, loop-detection and other utility tools extend gemini-3-flash-base which hardcodes gemini-3-flash-preview. This model ID was never run through modelIdResolutions, so API-key users without preview access hit INVALID_MODEL on every web tool call. applyModelSelection now calls resolveModelId on the alias-resolved model for non-chat-model requests, mirroring the fallback logic that already exists for the main chat model router. Users without preview access are transparently downgraded to gemini-2.5-flash (or gemini-3.5-flash if Gemini 3.5 GA access is available). Fixes: google-gemini/gemini-cli#28390 --- .../availability/fallbackIntegration.test.ts | 2 + .../src/availability/policyHelpers.test.ts | 70 +++++++++++++++++++ .../core/src/availability/policyHelpers.ts | 30 +++++++- packages/core/src/core/baseLlmClient.test.ts | 4 ++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/core/src/availability/fallbackIntegration.test.ts b/packages/core/src/availability/fallbackIntegration.test.ts index 1ee53da37e1..df56312fd0a 100644 --- a/packages/core/src/availability/fallbackIntegration.test.ts +++ b/packages/core/src/availability/fallbackIntegration.test.ts @@ -29,6 +29,8 @@ describe('Fallback Integration', () => { setActiveModel: vi.fn(), getUserTier: () => undefined, getHasAccessToPreviewModel: () => true, + getGemini31LaunchedSync: () => false, + hasGemini35FlashGAAccess: () => false, getModelAvailabilityService: () => availabilityService, modelConfigService: undefined as unknown as ModelConfigService, } as unknown as Config; diff --git a/packages/core/src/availability/policyHelpers.test.ts b/packages/core/src/availability/policyHelpers.test.ts index 0d4a25e8c49..197bf7d5014 100644 --- a/packages/core/src/availability/policyHelpers.test.ts +++ b/packages/core/src/availability/policyHelpers.test.ts @@ -37,6 +37,7 @@ const createMockConfig = (overrides: Partial = {}): Config => { }, getContentGeneratorConfig: () => ({ authType: undefined }), getHasAccessToPreviewModel: () => true, + hasGemini35FlashGAAccess: () => false, getMaxAttemptsPerTurn: () => 3, getExperimentalDynamicModelConfiguration: () => false, getReleaseChannel: () => 'preview', @@ -283,6 +284,9 @@ describe('policyHelpers', () => { describe('applyModelSelection', () => { const mockModelConfigService = { getResolvedConfig: vi.fn(), + // resolveModelId is called for non-chat-model calls to apply modelIdResolutions + // (preview-gated model fallback for API-key users). + resolveModelId: vi.fn((model: string) => model), }; const mockAvailabilityService = { @@ -446,6 +450,72 @@ describe('policyHelpers', () => { expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro'); expect(result.maxAttempts).toBe(1); }); + + it('applies modelIdResolutions for non-chat tool configs (web-search/web-fetch preview fallback)', () => { + // Reproduces the INVALID_MODEL bug: API-key users have hasAccessToPreview=false, + // but web-search/web-fetch alias chains resolve to gemini-3-flash-preview which + // requires preview access. resolveModelId must map it to gemini-2.5-flash. + const config = createExtendedMockConfig({ + getHasAccessToPreviewModel: () => false, + getGemini31LaunchedSync: () => false, + hasGemini35FlashGAAccess: () => false, + }); + + // Alias chain resolves web-search → gemini-3-flash-base → gemini-3-flash-preview + mockModelConfigService.getResolvedConfig + .mockReturnValueOnce({ + model: 'gemini-3-flash-preview', + generateContentConfig: { tools: [{ googleSearch: {} }] }, + }) + // Second call after resolveModelId returns the fallback model config + .mockReturnValueOnce({ + model: 'gemini-2.5-flash', + generateContentConfig: { tools: [{ googleSearch: {} }] }, + }); + + // resolveModelId maps gemini-3-flash-preview → gemini-2.5-flash for non-preview users + mockModelConfigService.resolveModelId.mockImplementation( + (model: string) => + model === 'gemini-3-flash-preview' ? 'gemini-2.5-flash' : model, + ); + + mockAvailabilityService.selectFirstAvailable.mockReturnValue({ + selectedModel: 'gemini-2.5-flash', + }); + + const result = applyModelSelection(config, { + model: 'web-search', + isChatModel: false, + }); + + expect(result.model).toBe('gemini-2.5-flash'); + expect(mockModelConfigService.resolveModelId).toHaveBeenCalledWith( + 'gemini-3-flash-preview', + expect.objectContaining({ hasAccessToPreview: false }), + ); + expect(config.setActiveModel).not.toHaveBeenCalled(); + }); + + it('does not apply modelIdResolutions for chat model calls (already routed)', () => { + const config = createExtendedMockConfig({ + getHasAccessToPreviewModel: () => false, + }); + mockModelConfigService.getResolvedConfig.mockReturnValue({ + model: 'gemini-2.5-pro', + generateContentConfig: {}, + }); + mockAvailabilityService.selectFirstAvailable.mockReturnValue({ + selectedModel: 'gemini-2.5-pro', + }); + + applyModelSelection(config, { + model: 'gemini-2.5-pro', + isChatModel: true, + }); + + // resolveModelId must NOT be called for chat model — router already handled it + expect(mockModelConfigService.resolveModelId).not.toHaveBeenCalled(); + }); }); describe('applyAvailabilityTransition', () => { diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts index 71b8240120d..b3fa73540c7 100644 --- a/packages/core/src/availability/policyHelpers.ts +++ b/packages/core/src/availability/policyHelpers.ts @@ -286,15 +286,39 @@ export function applyModelSelection( options: { consumeAttempt?: boolean } = {}, ): { model: string; config: GenerateContentConfig; maxAttempts?: number } { const resolved = config.modelConfigService.getResolvedConfig(modelConfigKey); - const model = resolved.model; + + // Tool sub-agent configs (web-search, web-fetch, loop-detection, etc.) resolve + // their alias chain to a concrete model ID (e.g. gemini-3-flash-preview via + // gemini-3-flash-base) but never go through modelIdResolutions. This means + // API-key users without preview access hit INVALID_MODEL. For non-chat-model + // calls, apply modelIdResolutions now so preview-gated sub-agent models fall + // back to the appropriate GA model (e.g. gemini-2.5-flash). + let model = resolved.model; + let resolvedGenerateContentConfig = resolved.generateContentConfig; + if (!modelConfigKey.isChatModel) { + const resolvedModelId = config.modelConfigService.resolveModelId(model, { + hasAccessToPreview: config.getHasAccessToPreviewModel?.() ?? false, + useGemini3_1: config.getGemini31LaunchedSync?.() ?? false, + useGemini3_5Flash: config.hasGemini35FlashGAAccess?.() ?? false, + }); + if (resolvedModelId !== model) { + model = resolvedModelId; + const refetchedResolved = config.modelConfigService.getResolvedConfig({ + ...modelConfigKey, + model, + }); + resolvedGenerateContentConfig = refetchedResolved.generateContentConfig; + } + } + const selection = selectModelForAvailability(config, model); if (!selection) { - return { model, config: resolved.generateContentConfig }; + return { model, config: resolvedGenerateContentConfig }; } const finalModel = selection.selectedModel ?? model; - let generateContentConfig = resolved.generateContentConfig; + let generateContentConfig = resolvedGenerateContentConfig; if (finalModel !== model) { const fallbackResolved = config.modelConfigService.getResolvedConfig({ diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts index 82196ffa84a..e568a52e7fa 100644 --- a/packages/core/src/core/baseLlmClient.test.ts +++ b/packages/core/src/core/baseLlmClient.test.ts @@ -118,6 +118,7 @@ describe('BaseLlmClient', () => { getResolvedConfig: vi .fn() .mockImplementation(({ model }) => makeResolvedModelConfig(model)), + resolveModelId: vi.fn((model: string) => model), } as unknown as ModelConfigService, getModelAvailabilityService: vi .fn() @@ -128,6 +129,9 @@ describe('BaseLlmClient', () => { getMaxAttempts: vi.fn().mockReturnValue(3), getModel: vi.fn().mockReturnValue('test-model'), getActiveModel: vi.fn().mockReturnValue('test-model'), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), + getGemini31LaunchedSync: vi.fn().mockReturnValue(false), + hasGemini35FlashGAAccess: vi.fn().mockReturnValue(false), } as unknown as Mocked; client = new BaseLlmClient(mockContentGenerator, mockConfig);