Compare commits

...

4 Commits

Author SHA1 Message Date
gemini-cli[bot] 45d35ff440 fix(core): fall back to default model for invalid Gemini model IDs
This PR implements a validation check in `resolveModel` to ensure that Gemini model IDs are valid before use.

### Problem
Outdated or non-existent Gemini model IDs (like `gemini-pro-latest`) can get persisted in a user's `settings.json` if they were ever used or resolved in previous CLI versions. On subsequent startups, the CLI picks this invalid model from settings, leading to "model not supported" API errors.

### Solution
Modified `resolveModel` in `packages/core/src/config/models.ts` to verify if a Gemini model ID (starting with `gemini-`) is in the `VALID_GEMINI_MODELS` set. If it's not, the function now falls back to `DEFAULT_GEMINI_MODEL`.

Custom (non-Gemini) model IDs are still allowed and returned as-is to maintain flexibility for proxies and other providers.

Fixes: #26971

cc @kevinjwang1
2026-05-15 20:51:44 +00:00
David Pierce 77e65c0db5 fix(core): use hasAccessToPreview for auto model resolution and fix disappearing models (#27112) 2026-05-15 17:26:59 +00:00
Anish Sabharwal b36788eb2a fix(core): add aliases and thinking config for gemini-3.1 models (#27007) 2026-05-15 16:29:15 +00:00
PROTHAM d32c9b77df Fix/web fetch ctrl c abort (#24320)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-15 16:26:03 +00:00
11 changed files with 250 additions and 17 deletions
+18
View File
@@ -550,6 +550,24 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"gemini-3.1-pro-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview"
}
},
"gemini-3.1-pro-preview-customtools": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview-customtools"
}
},
"gemini-3.1-flash-lite-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
-1
View File
@@ -2019,7 +2019,6 @@ describe('Server Config (config.ts)', () => {
expect(configInternal.lastEmittedQuotaRemaining).toBeUndefined();
expect(configInternal.lastEmittedQuotaLimit).toBeUndefined();
expect(configInternal.lastQuotaFetchTime).toBe(0);
expect(configInternal.hasAccessToPreviewModel).toBeNull();
// Event emission
expect(emitQuotaSpy).toHaveBeenCalledWith(undefined, undefined, undefined);
-1
View File
@@ -1833,7 +1833,6 @@ export class Config implements McpContext, AgentLoopContext {
this.modelQuotas.clear();
this.lastRetrievedQuota = undefined;
this.lastQuotaFetchTime = 0;
this.hasAccessToPreviewModel = null;
// Force an event emission to clear the UI display
coreEvents.emitQuotaChanged(undefined, undefined, undefined);
@@ -71,6 +71,24 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-flash-preview',
},
},
'gemini-3.1-pro-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview',
},
},
'gemini-3.1-pro-preview-customtools': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview-customtools',
},
},
'gemini-3.1-flash-lite-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-flash-lite-preview',
},
},
'gemini-2.5-pro': {
extends: 'chat-base-2.5',
modelConfig: {
+41
View File
@@ -439,6 +439,15 @@ describe('resolveModel', () => {
expect(model).toBe(customModel);
});
it('should fallback for known legacy models like gemini-pro-latest', () => {
expect(resolveModel('gemini-pro-latest')).toBe(DEFAULT_GEMINI_MODEL);
});
it('should NOT fallback for unknown gemini models that might be future versions', () => {
const futureModel = 'gemini-4-pro';
expect(resolveModel(futureModel)).toBe(futureModel);
});
it('should handle non-string inputs gracefully', () => {
// @ts-expect-error - testing invalid runtime input
expect(resolveModel(['a', 'b'])).toBe('b');
@@ -672,3 +681,35 @@ describe('isActiveModel', () => {
).toBe(false);
});
});
describe('Gemini 3.1 Config Resolution', () => {
it('PREVIEW_GEMINI_3_1_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
});
+7 -1
View File
@@ -164,7 +164,7 @@ export function resolveModel(
switch (normalizedModel) {
case GEMINI_MODEL_ALIAS_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
if (currentReleaseChannel === 'stable') {
if (!hasAccessToPreview) {
resolved = DEFAULT_GEMINI_MODEL;
break;
}
@@ -201,6 +201,12 @@ export function resolveModel(
}
}
// Fallback for known legacy Gemini model aliases that are no longer supported
// by the API but may still be present in user settings.
if (resolved === 'gemini-pro-latest') {
return DEFAULT_GEMINI_MODEL;
}
if (!hasAccessToPreview && isPreviewModel(resolved)) {
// Downgrade to stable models if user lacks preview access.
switch (resolved) {
@@ -61,6 +61,42 @@
"topK": 64
}
},
"gemini-3.1-pro-preview": {
"model": "gemini-3.1-pro-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-pro-preview-customtools": {
"model": "gemini-3.1-pro-preview-customtools",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-flash-lite-preview": {
"model": "gemini-3.1-flash-lite-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
@@ -61,6 +61,42 @@
"topK": 64
}
},
"gemini-3.1-pro-preview": {
"model": "gemini-3.1-pro-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-pro-preview-customtools": {
"model": "gemini-3.1-pro-preview-customtools",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-flash-lite-preview": {
"model": "gemini-3.1-flash-lite-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
+46 -10
View File
@@ -5,7 +5,7 @@
*/
import { updateGlobalFetchTimeouts } from './fetch.js';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress, LookupAllOptions } from 'node:dns';
import ipaddr from 'ipaddr.js';
@@ -34,18 +34,14 @@ const {
fetchWithTimeout,
setGlobalProxy,
} = await import('./fetch.js');
// Mock global fetch
const originalFetch = global.fetch;
global.fetch = vi.fn();
interface ErrorWithCode extends Error {
code?: string;
}
describe('fetch utils', () => {
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(global, 'fetch').mockImplementation(vi.fn() as any);
// Default DNS lookup to return a public IP, or the IP itself if valid
vi.mocked(
dnsPromises.lookup as (
@@ -60,8 +56,8 @@ describe('fetch utils', () => {
});
});
afterAll(() => {
global.fetch = originalFetch;
afterEach(() => {
vi.restoreAllMocks();
});
describe('isAddressPrivate', () => {
@@ -177,7 +173,7 @@ describe('fetch utils', () => {
});
describe('fetchWithTimeout', () => {
it('should handle timeouts', async () => {
it('should throw FetchError with ETIMEDOUT on an internal timeout', async () => {
vi.mocked(global.fetch).mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
@@ -198,6 +194,46 @@ describe('fetch utils', () => {
'Request timed out after 50ms',
);
});
it('should throw an AbortError (not ETIMEDOUT) when the caller signal is aborted', async () => {
vi.mocked(global.fetch).mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
const rejectWithAbortError = () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
// @ts-expect-error - for mocking purposes
error.code = 'ABORT_ERR';
reject(error);
};
// Handle the case where the signal is already aborted before
// fetch is called (e.g. controller.abort() called synchronously).
if (init?.signal?.aborted) {
rejectWithAbortError();
return;
}
if (init?.signal) {
init.signal.addEventListener('abort', rejectWithAbortError, {
once: true,
});
}
}),
);
const controller = new AbortController();
// Abort the external signal before the request even starts
controller.abort();
const rejection = fetchWithTimeout('http://example.com', 10_000, {
signal: controller.signal,
});
await expect(rejection).rejects.toMatchObject({ name: 'AbortError' });
// Must NOT be classified as a timeout
await expect(rejection).rejects.not.toThrow('timed out');
});
});
describe('setGlobalProxy', () => {
+10 -2
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getErrorMessage, isNodeError } from './errors.js';
import { getErrorMessage, isAbortError } from './errors.js';
import { URL } from 'node:url';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
import ipaddr from 'ipaddr.js';
@@ -202,7 +202,15 @@ export async function fetchWithTimeout(
});
return response;
} catch (error) {
if (isNodeError(error) && error.code === 'ABORT_ERR') {
if (isAbortError(error)) {
// If the caller's own signal was already aborted, this is a user-initiated
// cancellation (e.g. Ctrl+C), not an internal timeout. Re-throw as a plain
// AbortError so the retry layer does NOT treat it as a retryable ETIMEDOUT.
if (options?.signal?.aborted) {
// Rethrow the original abort reason or the caught error to preserve
// the stack trace and any custom abort reason (e.g. from Ctrl+C).
throw options.signal.reason ?? error;
}
throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT');
}
throw new FetchError(getErrorMessage(error), undefined, { cause: error });
File diff suppressed because one or more lines are too long