| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| import { BaseLlmClient } from '../core/baseLlmClient.js'; |
| import { FakeContentGenerator } from '../core/fakeContentGenerator.js'; |
| import { Config } from '../config/config.js'; |
| import { RetryableQuotaError } from '../utils/googleQuotaErrors.js'; |
| import { |
| PREVIEW_GEMINI_MODEL, |
| PREVIEW_GEMINI_FLASH_MODEL, |
| PREVIEW_GEMINI_MODEL_AUTO, |
| } from '../config/models.js'; |
| import fs from 'node:fs'; |
| import { AuthType } from '../core/contentGenerator.js'; |
| import type { FallbackIntent } from '../fallback/types.js'; |
| import { LlmRole } from '../telemetry/types.js'; |
| import type { GenerateContentResponse } from '@google/genai'; |
|
|
| vi.mock('node:fs'); |
|
|
| describe('Auto Routing Fallback Integration', () => { |
| let config: Config; |
| let fakeGenerator: FakeContentGenerator; |
| let client: BaseLlmClient; |
|
|
| beforeEach(() => { |
| vi.useFakeTimers(); |
| vi.spyOn(Config.prototype, 'getHasAccessToPreviewModel').mockReturnValue( |
| true, |
| ); |
|
|
| |
| vi.mocked(fs.existsSync).mockReturnValue(true); |
| vi.mocked(fs.statSync).mockReturnValue({ |
| isDirectory: () => true, |
| } as fs.Stats); |
|
|
| |
| vi.mocked(fs.readFileSync).mockImplementation((path) => { |
| if (typeof path === 'string' && path.endsWith('.toml')) { |
| return ` |
| [modes.plan] |
| network = false |
| readonly = true |
| approvedTools = [] |
| |
| [modes.default] |
| network = false |
| readonly = false |
| approvedTools = [] |
| |
| [modes.accepting_edits] |
| network = false |
| readonly = false |
| approvedTools = [] |
| `; |
| } |
| return ''; |
| }); |
|
|
| fakeGenerator = new FakeContentGenerator([]); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| it('should fallback to Flash after 3 tries and try 10 times for Flash in auto mode', async () => { |
| |
| config = new Config({ |
| sessionId: 'test-session', |
| targetDir: '/test', |
| debugMode: false, |
| cwd: '/test', |
| model: PREVIEW_GEMINI_MODEL_AUTO, |
| }); |
|
|
| |
| vi.spyOn(config, 'isInteractive').mockReturnValue(true); |
|
|
| client = new BaseLlmClient( |
| fakeGenerator, |
| config, |
| AuthType.LOGIN_WITH_GOOGLE, |
| ); |
|
|
| let attemptsPro = 0; |
| let attemptsFlash = 0; |
|
|
| const mockGoogleApiError = { |
| code: 429, |
| message: 'Quota exceeded', |
| details: [], |
| }; |
|
|
| |
| vi.spyOn(fakeGenerator, 'generateContent').mockImplementation( |
| async (params) => { |
| if (params.model === PREVIEW_GEMINI_MODEL) { |
| attemptsPro++; |
| throw new RetryableQuotaError( |
| 'Quota exceeded for Pro', |
| mockGoogleApiError, |
| 0, |
| ); |
| } else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) { |
| attemptsFlash++; |
| throw new RetryableQuotaError( |
| 'Quota exceeded for Flash', |
| mockGoogleApiError, |
| 0, |
| ); |
| } |
| throw new Error(`Unexpected model: ${params.model}`); |
| }, |
| ); |
|
|
| |
| config.setFallbackModelHandler( |
| async (failed, _fallback, _error): Promise<FallbackIntent | null> => { |
| if (failed === PREVIEW_GEMINI_FLASH_MODEL) { |
| return 'stop'; |
| } |
| return 'retry_always'; |
| }, |
| ); |
|
|
| |
| const promise = client.generateContent({ |
| modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true }, |
| contents: [{ role: 'user', parts: [{ text: 'hi' }] }], |
| abortSignal: new AbortController().signal, |
| promptId: 'test-prompt', |
| role: LlmRole.UTILITY_TOOL, |
| }); |
|
|
| await Promise.all([ |
| expect(promise).rejects.toThrow('Quota exceeded for Flash'), |
| vi.runAllTimersAsync(), |
| ]); |
|
|
| |
| expect(attemptsPro).toBe(3); |
| expect(attemptsFlash).toBe(10); |
| }); |
|
|
| it('should try 10 times and prompt user in non-auto mode', async () => { |
| |
| const configNonAuto = new Config({ |
| sessionId: 'test-session', |
| targetDir: '/test', |
| debugMode: false, |
| cwd: '/test', |
| model: PREVIEW_GEMINI_MODEL, |
| }); |
|
|
| |
| vi.spyOn(configNonAuto, 'isInteractive').mockReturnValue(true); |
|
|
| const clientNonAuto = new BaseLlmClient( |
| fakeGenerator, |
| configNonAuto, |
| AuthType.LOGIN_WITH_GOOGLE, |
| ); |
|
|
| let attemptsPro = 0; |
|
|
| const mockGoogleApiError = { |
| code: 429, |
| message: 'Quota exceeded', |
| details: [], |
| }; |
|
|
| |
| vi.spyOn(fakeGenerator, 'generateContent').mockImplementation( |
| async (params) => { |
| if (params.model === PREVIEW_GEMINI_MODEL) { |
| attemptsPro++; |
| throw new RetryableQuotaError( |
| 'Quota exceeded for Pro', |
| mockGoogleApiError, |
| 0, |
| ); |
| } |
| throw new Error(`Unexpected model: ${params.model}`); |
| }, |
| ); |
|
|
| |
| const handler = vi.fn( |
| async (_failed, _fallback, _error): Promise<FallbackIntent | null> => |
| 'stop', |
| ); |
| configNonAuto.setFallbackModelHandler(handler); |
|
|
| const promise = clientNonAuto.generateContent({ |
| modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true }, |
| contents: [{ role: 'user', parts: [{ text: 'hi' }] }], |
| abortSignal: new AbortController().signal, |
| promptId: 'test-prompt', |
| role: LlmRole.UTILITY_TOOL, |
| maxAttempts: 10, |
| }); |
|
|
| await Promise.all([ |
| expect(promise).rejects.toThrow('Quota exceeded for Pro'), |
| vi.runAllTimersAsync(), |
| ]); |
|
|
| |
| expect(attemptsPro).toBe(10); |
|
|
| |
| expect(handler).toHaveBeenCalledTimes(1); |
| expect(handler).toHaveBeenCalledWith( |
| PREVIEW_GEMINI_MODEL, |
| PREVIEW_GEMINI_FLASH_MODEL, |
| expect.any(RetryableQuotaError), |
| ); |
| }); |
|
|
| it('should fallback to Flash after 3 tries in experimental dynamic mode', async () => { |
| |
| const configDynamic = new Config({ |
| sessionId: 'test-session', |
| targetDir: '/test', |
| debugMode: false, |
| cwd: '/test', |
| model: PREVIEW_GEMINI_MODEL_AUTO, |
| }); |
|
|
| |
| vi.spyOn(configDynamic, 'isInteractive').mockReturnValue(true); |
|
|
| |
| vi.spyOn( |
| configDynamic, |
| 'getExperimentalDynamicModelConfiguration', |
| ).mockReturnValue(true); |
|
|
| const clientDynamic = new BaseLlmClient( |
| fakeGenerator, |
| configDynamic, |
| AuthType.LOGIN_WITH_GOOGLE, |
| ); |
|
|
| let attemptsPro = 0; |
| let attemptsFlash = 0; |
|
|
| const mockGoogleApiError = { |
| code: 429, |
| message: 'Quota exceeded', |
| details: [], |
| }; |
|
|
| |
| vi.spyOn(fakeGenerator, 'generateContent').mockImplementation( |
| async (params) => { |
| if (params.model === PREVIEW_GEMINI_MODEL) { |
| attemptsPro++; |
| throw new RetryableQuotaError( |
| 'Quota exceeded for Pro', |
| mockGoogleApiError, |
| 0, |
| ); |
| } else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) { |
| attemptsFlash++; |
| throw new RetryableQuotaError( |
| 'Quota exceeded for Flash', |
| mockGoogleApiError, |
| 0, |
| ); |
| } |
| throw new Error(`Unexpected model: ${params.model}`); |
| }, |
| ); |
|
|
| |
| configDynamic.setFallbackModelHandler( |
| async (failed, _fallback, _error): Promise<FallbackIntent | null> => { |
| if (failed === PREVIEW_GEMINI_FLASH_MODEL) { |
| return 'stop'; |
| } |
| return 'retry_always'; |
| }, |
| ); |
|
|
| const promise = clientDynamic.generateContent({ |
| modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true }, |
| contents: [{ role: 'user', parts: [{ text: 'hi' }] }], |
| abortSignal: new AbortController().signal, |
| promptId: 'test-prompt', |
| role: LlmRole.UTILITY_TOOL, |
| }); |
|
|
| await Promise.all([ |
| expect(promise).rejects.toThrow('Quota exceeded for Flash'), |
| vi.runAllTimersAsync(), |
| ]); |
|
|
| |
| expect(attemptsPro).toBe(3); |
| expect(attemptsFlash).toBe(10); |
| }); |
|
|
| it('should retry Pro on next turn after successful fallback to Flash', async () => { |
| |
| config = new Config({ |
| sessionId: 'test-session', |
| targetDir: '/test', |
| debugMode: false, |
| cwd: '/test', |
| model: PREVIEW_GEMINI_MODEL_AUTO, |
| }); |
|
|
| |
| vi.spyOn(config, 'isInteractive').mockReturnValue(true); |
|
|
| client = new BaseLlmClient( |
| fakeGenerator, |
| config, |
| AuthType.LOGIN_WITH_GOOGLE, |
| ); |
|
|
| let attemptsPro = 0; |
| let attemptsFlash = 0; |
|
|
| const mockGoogleApiError = { |
| code: 429, |
| message: 'Quota exceeded', |
| details: [], |
| }; |
|
|
| |
| vi.spyOn(fakeGenerator, 'generateContent').mockImplementation( |
| async (params) => { |
| if (params.model === PREVIEW_GEMINI_MODEL) { |
| attemptsPro++; |
| throw new RetryableQuotaError( |
| 'Quota exceeded for Pro', |
| mockGoogleApiError, |
| 0, |
| ); |
| } else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) { |
| attemptsFlash++; |
| return { |
| candidates: [ |
| { |
| content: { role: 'model', parts: [{ text: 'Flash success' }] }, |
| }, |
| ], |
| } as unknown as GenerateContentResponse; |
| } |
| throw new Error(`Unexpected model: ${params.model}`); |
| }, |
| ); |
|
|
| config.setFallbackModelHandler( |
| async (_failed, _fallback, _error): Promise<FallbackIntent | null> => |
| 'retry_always', |
| ); |
|
|
| |
| const promise1 = client.generateContent({ |
| modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true }, |
| contents: [{ role: 'user', parts: [{ text: 'hi' }] }], |
| abortSignal: new AbortController().signal, |
| promptId: 'test-prompt-1', |
| role: LlmRole.UTILITY_TOOL, |
| }); |
|
|
| await vi.runAllTimersAsync(); |
| const result1 = await promise1; |
|
|
| expect(result1.candidates?.[0]?.content?.parts?.[0]?.text).toBe( |
| 'Flash success', |
| ); |
| expect(attemptsPro).toBe(3); |
| expect(attemptsFlash).toBe(1); |
|
|
| |
| config.getModelAvailabilityService().resetTurn(); |
|
|
| |
| |
| vi.spyOn(fakeGenerator, 'generateContent').mockImplementation( |
| async (params) => { |
| if (params.model === PREVIEW_GEMINI_MODEL) { |
| return { |
| candidates: [ |
| { content: { role: 'model', parts: [{ text: 'Pro success' }] } }, |
| ], |
| } as unknown as GenerateContentResponse; |
| } |
| throw new Error(`Unexpected model: ${params.model}`); |
| }, |
| ); |
|
|
| const promise2 = client.generateContent({ |
| modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true }, |
| contents: [{ role: 'user', parts: [{ text: 'hello again' }] }], |
| abortSignal: new AbortController().signal, |
| promptId: 'test-prompt-2', |
| role: LlmRole.UTILITY_TOOL, |
| }); |
|
|
| const result2 = await promise2; |
| expect(result2.candidates?.[0]?.content?.parts?.[0]?.text).toBe( |
| 'Pro success', |
| ); |
| }); |
|
|
| it('should rotate session ID on fallback and retry successfully with the Flash model', async () => { |
| const originalSessionId = 'test-session-rotate-id'; |
| config = new Config({ |
| sessionId: originalSessionId, |
| targetDir: '/test', |
| debugMode: false, |
| cwd: '/test', |
| model: PREVIEW_GEMINI_MODEL_AUTO, |
| }); |
|
|
| vi.spyOn(config, 'isInteractive').mockReturnValue(true); |
|
|
| client = new BaseLlmClient( |
| fakeGenerator, |
| config, |
| AuthType.LOGIN_WITH_GOOGLE, |
| ); |
|
|
| let attemptsPro = 0; |
| let attemptsFlash = 0; |
|
|
| const mockGoogleApiError = { |
| code: 429, |
| message: |
| 'Automatically switching from gemini-2.5-pro to gemini-2.5-flash for faster responses for the remainder of this session. Possible reasons for this are...', |
| details: [], |
| }; |
|
|
| vi.spyOn(fakeGenerator, 'generateContent').mockImplementation( |
| async (params) => { |
| if (params.model === PREVIEW_GEMINI_MODEL) { |
| attemptsPro++; |
| throw new RetryableQuotaError( |
| 'Quota exceeded for Pro', |
| mockGoogleApiError, |
| 0, |
| ); |
| } else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) { |
| attemptsFlash++; |
| return { |
| candidates: [ |
| { |
| content: { |
| role: 'model', |
| parts: [{ text: 'Flash success after rotation' }], |
| }, |
| }, |
| ], |
| } as unknown as GenerateContentResponse; |
| } |
| throw new Error(`Unexpected model: ${params.model}`); |
| }, |
| ); |
|
|
| config.setFallbackModelHandler( |
| async (_failed, _fallback, _error): Promise<FallbackIntent | null> => |
| 'retry_always', |
| ); |
|
|
| const promise = client.generateContent({ |
| modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true }, |
| contents: [{ role: 'user', parts: [{ text: 'test query' }] }], |
| abortSignal: new AbortController().signal, |
| promptId: 'test-prompt', |
| role: LlmRole.UTILITY_TOOL, |
| }); |
|
|
| await vi.runAllTimersAsync(); |
| const result = await promise; |
|
|
| |
| expect(result.candidates?.[0]?.content?.parts?.[0]?.text).toBe( |
| 'Flash success after rotation', |
| ); |
| expect(attemptsPro).toBe(3); |
| expect(attemptsFlash).toBe(1); |
|
|
| |
| expect(config.getSessionId()).not.toBe(originalSessionId); |
| expect(config.getSessionId()).toBeDefined(); |
| }); |
| }); |
|
|