feat: launch Gemini 3 in Gemini CLI 🚀🚀🚀 (in main) (#13287)

Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Aishanee Shah <aishaneeshah@gmail.com>
Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: joshualitt <joshualitt@google.com>
Co-authored-by: Jenna Inouye <jinouye@google.com>
This commit is contained in:
Shreya Keshive
2025-11-18 12:01:16 -05:00
committed by GitHub
parent 78075c8a37
commit 86828bb561
79 changed files with 3148 additions and 605 deletions

View File

@@ -72,6 +72,11 @@ describe('editor utils', () => {
{ editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{ editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] },
{
editor: 'antigravity',
commands: ['agy'],
win32Commands: ['agy.cmd'],
},
];
for (const { editor, commands, win32Commands } of testCases) {
@@ -171,6 +176,11 @@ describe('editor utils', () => {
},
{ editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{
editor: 'antigravity',
commands: ['agy'],
win32Commands: ['agy.cmd'],
},
];
for (const { editor, commands, win32Commands } of guiEditors) {
@@ -430,6 +440,7 @@ describe('editor utils', () => {
'windsurf',
'cursor',
'zed',
'antigravity',
];
for (const editor of guiEditors) {
it(`should not call onEditorClose for ${editor}`, async () => {

View File

@@ -15,7 +15,24 @@ export type EditorType =
| 'vim'
| 'neovim'
| 'zed'
| 'emacs';
| 'emacs'
| 'antigravity';
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
vscode: 'VS Code',
vscodium: 'VSCodium',
windsurf: 'Windsurf',
cursor: 'Cursor',
vim: 'Vim',
neovim: 'Neovim',
zed: 'Zed',
emacs: 'Emacs',
antigravity: 'Antigravity',
};
export function getEditorDisplayName(editor: EditorType): string {
return EDITOR_DISPLAY_NAMES[editor] || editor;
}
function isValidEditorType(editor: string): editor is EditorType {
return [
@@ -27,6 +44,7 @@ function isValidEditorType(editor: string): editor is EditorType {
'neovim',
'zed',
'emacs',
'antigravity',
].includes(editor);
}
@@ -63,6 +81,7 @@ const editorCommands: Record<
neovim: { win32: ['nvim'], default: ['nvim'] },
zed: { win32: ['zed'], default: ['zed', 'zeditor'] },
emacs: { win32: ['emacs.exe'], default: ['emacs'] },
antigravity: { win32: ['agy.cmd'], default: ['agy'] },
};
export function checkHasEditorType(editor: EditorType): boolean {
@@ -74,7 +93,11 @@ export function checkHasEditorType(editor: EditorType): boolean {
export function allowEditorTypeInSandbox(editor: EditorType): boolean {
const notUsingSandbox = !process.env['SANDBOX'];
if (['vscode', 'vscodium', 'windsurf', 'cursor', 'zed'].includes(editor)) {
if (
['vscode', 'vscodium', 'windsurf', 'cursor', 'zed', 'antigravity'].includes(
editor,
)
) {
return notUsingSandbox;
}
// For terminal-based editors like vim and emacs, allow in sandbox.
@@ -116,6 +139,7 @@ export function getDiffCommand(
case 'windsurf':
case 'cursor':
case 'zed':
case 'antigravity':
return { command, args: ['--wait', '--diff', oldPath, newPath] };
case 'vim':
case 'neovim':

View File

@@ -54,7 +54,7 @@ describe('Retry Utility Fallback Integration', () => {
// This test validates the Config's ability to store and execute the handler contract.
it('should execute the injected FallbackHandler contract correctly', async () => {
// Set up a minimal handler for testing, ensuring it matches the new type.
const fallbackHandler: FallbackModelHandler = async () => 'retry';
const fallbackHandler: FallbackModelHandler = async () => 'retry_always';
// Use the generalized setter
config.setFallbackModelHandler(fallbackHandler);
@@ -67,7 +67,7 @@ describe('Retry Utility Fallback Integration', () => {
);
// Verify it returns the correct intent
expect(result).toBe('retry');
expect(result).toBe('retry_always');
});
// This test validates the retry utility's logic for triggering the callback.

View File

@@ -11,17 +11,22 @@ import type {
RetryInfo,
} from './googleErrors.js';
import { parseGoogleApiError } from './googleErrors.js';
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
/**
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
*/
export class TerminalQuotaError extends Error {
retryDelayMs?: number;
constructor(
message: string,
override readonly cause: GoogleApiError,
retryDelayMs?: number,
) {
super(message);
this.name = 'TerminalQuotaError';
this.retryDelayMs = retryDelayMs ? retryDelayMs * 1000 : undefined;
}
}
@@ -75,6 +80,14 @@ function parseDurationInSeconds(duration: string): number | null {
*/
export function classifyGoogleError(error: unknown): unknown {
const googleApiError = parseGoogleApiError(error);
const status = googleApiError?.code ?? getErrorStatus(error);
if (status === 404) {
const message =
googleApiError?.message ||
(error instanceof Error ? error.message : 'Model not found');
return new ModelNotFoundError(message, status);
}
if (!googleApiError || googleApiError.code !== 429) {
// Fallback: try to parse the error message for a retry delay
@@ -125,6 +138,14 @@ export function classifyGoogleError(error: unknown): unknown {
}
}
}
let delaySeconds;
if (retryInfo?.retryDelay) {
const parsedDelay = parseDurationInSeconds(retryInfo.retryDelay);
if (parsedDelay) {
delaySeconds = parsedDelay;
}
}
if (errorInfo) {
// New Cloud Code API quota handling
@@ -136,23 +157,17 @@ export function classifyGoogleError(error: unknown): unknown {
];
if (validDomains.includes(errorInfo.domain)) {
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
let delaySeconds = 10; // Default retry of 10s
if (retryInfo?.retryDelay) {
const parsedDelay = parseDurationInSeconds(retryInfo.retryDelay);
if (parsedDelay) {
delaySeconds = parsedDelay;
}
}
return new RetryableQuotaError(
`${googleApiError.message}`,
googleApiError,
delaySeconds,
delaySeconds ?? 10,
);
}
if (errorInfo.reason === 'QUOTA_EXHAUSTED') {
return new TerminalQuotaError(
`${googleApiError.message}`,
googleApiError,
delaySeconds,
);
}
}
@@ -170,12 +185,12 @@ export function classifyGoogleError(error: unknown): unknown {
// 2. Check for long delays in RetryInfo
if (retryInfo?.retryDelay) {
const delaySeconds = parseDurationInSeconds(retryInfo.retryDelay);
if (delaySeconds) {
if (delaySeconds > 120) {
return new TerminalQuotaError(
`${googleApiError.message}\nSuggested retry after ${retryInfo.retryDelay}.`,
googleApiError,
delaySeconds,
);
}
// This is a retryable error with a specific delay.

View File

@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface HttpError extends Error {
status?: number;
}
/**
* Extracts the HTTP status code from an error object.
* @param error The error object.
* @returns The HTTP status code, or undefined if not found.
*/
export function getErrorStatus(error: unknown): number | undefined {
if (typeof error === 'object' && error !== null) {
if ('status' in error && typeof error.status === 'number') {
return error.status;
}
// Check for error.response.status (common in axios errors)
if (
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: unknown }).response !== null
) {
const response = (
error as { response: { status?: unknown; headers?: unknown } }
).response;
if ('status' in response && typeof response.status === 'number') {
return response.status;
}
}
}
return undefined;
}
export class ModelNotFoundError extends Error {
code: number;
constructor(message: string, code?: number) {
super(message);
this.name = 'ModelNotFoundError';
this.code = code ? code : 404;
}
}

View File

@@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApiError } from '@google/genai';
import { AuthType } from '../core/contentGenerator.js';
import type { HttpError } from './retry.js';
import { type HttpError, ModelNotFoundError } from './httpErrors.js';
import { retryWithBackoff } from './retry.js';
import { setSimulate429 } from './testUtils.js';
import { debugLogger } from './debugLogger.js';
@@ -16,6 +16,7 @@ import {
TerminalQuotaError,
RetryableQuotaError,
} from './googleQuotaErrors.js';
import { PREVIEW_GEMINI_MODEL } from '../config/models.js';
// Helper to create a mock function that fails a certain number of times
const createFailingFunction = (
@@ -433,4 +434,68 @@ describe('retryWithBackoff', () => {
);
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should trigger fallback for OAuth personal users on persistent 500 errors', async () => {
const fallbackCallback = vi.fn().mockResolvedValue('gemini-2.5-flash');
let fallbackOccurred = false;
const mockFn = vi.fn().mockImplementation(async () => {
if (!fallbackOccurred) {
const error: HttpError = new Error('Internal Server Error');
error.status = 500;
throw error;
}
return 'success';
});
const promise = retryWithBackoff(mockFn, {
maxAttempts: 3,
initialDelayMs: 100,
onPersistent429: async (authType?: string, error?: unknown) => {
fallbackOccurred = true;
return await fallbackCallback(authType, error);
},
authType: AuthType.LOGIN_WITH_GOOGLE,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(fallbackCallback).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
expect.objectContaining({ status: 500 }),
);
// 3 attempts (initial + 2 retries) fail with 500, then fallback triggers, then 1 success
expect(mockFn).toHaveBeenCalledTimes(4);
});
it('should trigger fallback for OAuth personal users on ModelNotFoundError', async () => {
const fallbackCallback = vi.fn().mockResolvedValue(PREVIEW_GEMINI_MODEL);
let fallbackOccurred = false;
const mockFn = vi.fn().mockImplementation(async () => {
if (!fallbackOccurred) {
throw new ModelNotFoundError('Requested entity was not found.', 404);
}
return 'success';
});
const promise = retryWithBackoff(mockFn, {
maxAttempts: 3,
initialDelayMs: 100,
onPersistent429: async (authType?: string, error?: unknown) => {
fallbackOccurred = true;
return await fallbackCallback(authType, error);
},
authType: AuthType.LOGIN_WITH_GOOGLE,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(fallbackCallback).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
expect.any(ModelNotFoundError),
);
expect(mockFn).toHaveBeenCalledTimes(2);
});
});

View File

@@ -14,14 +14,11 @@ import {
} from './googleQuotaErrors.js';
import { delay, createAbortError } from './delay.js';
import { debugLogger } from './debugLogger.js';
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
const FETCH_FAILED_MESSAGE =
'exception TypeError: fetch failed sending request';
export interface HttpError extends Error {
status?: number;
}
export interface RetryOptions {
maxAttempts: number;
initialDelayMs: number;
@@ -146,8 +143,12 @@ export async function retryWithBackoff<T>(
}
const classifiedError = classifyGoogleError(error);
const errorCode = getErrorStatus(error);
if (classifiedError instanceof TerminalQuotaError) {
if (
classifiedError instanceof TerminalQuotaError ||
classifiedError instanceof ModelNotFoundError
) {
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
try {
const fallbackModel = await onPersistent429(
@@ -166,7 +167,10 @@ export async function retryWithBackoff<T>(
throw classifiedError; // Throw if no fallback or fallback failed.
}
if (classifiedError instanceof RetryableQuotaError) {
const is500 =
errorCode !== undefined && errorCode >= 500 && errorCode < 600;
if (classifiedError instanceof RetryableQuotaError || is500) {
if (attempt >= maxAttempts) {
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
try {
@@ -183,13 +187,28 @@ export async function retryWithBackoff<T>(
console.warn('Model fallback failed:', fallbackError);
}
}
throw classifiedError;
throw classifiedError instanceof RetryableQuotaError
? classifiedError
: error;
}
if (classifiedError instanceof RetryableQuotaError) {
console.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
await delay(classifiedError.retryDelayMs, signal);
continue;
} else {
const errorStatus = getErrorStatus(error);
logRetryAttempt(attempt, error, errorStatus);
// Exponential backoff with jitter for non-quota errors
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
await delay(delayWithJitter, signal);
currentDelay = Math.min(maxDelayMs, currentDelay * 2);
continue;
}
console.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
await delay(classifiedError.retryDelayMs, signal);
continue;
}
// Generic retry logic for other errors
@@ -214,33 +233,6 @@ export async function retryWithBackoff<T>(
throw new Error('Retry attempts exhausted');
}
/**
* Extracts the HTTP status code from an error object.
* @param error The error object.
* @returns The HTTP status code, or undefined if not found.
*/
export function getErrorStatus(error: unknown): number | undefined {
if (typeof error === 'object' && error !== null) {
if ('status' in error && typeof error.status === 'number') {
return error.status;
}
// Check for error.response.status (common in axios errors)
if (
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: unknown }).response !== null
) {
const response = (
error as { response: { status?: unknown; headers?: unknown } }
).response;
if ('status' in response && typeof response.status === 'number') {
return response.status;
}
}
}
return undefined;
}
/**
* Logs a message for a retry attempt when using exponential backoff.
* @param attempt The current attempt number.