mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-22 12:01:39 -07:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
45
packages/core/src/utils/httpErrors.ts
Normal file
45
packages/core/src/utils/httpErrors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user