Compare commits

...

6 Commits

Author SHA1 Message Date
mkorwel a0f32ff7d6 chore: fix circular dependency and complete dynamic model configuration migration 2026-03-13 17:43:18 +00:00
Kevin Wang 1693afe027 pull and merge latest changes 2026-03-13 09:29:33 +00:00
kevinjwang1 d179e3673b Merge branch 'main' into splitModelConfigurability 2026-03-13 02:02:55 -07:00
Kevin Wang e4d6a5cc7c Make ensure default definitions are merged with user defined definitions 2026-03-13 08:53:20 +00:00
Kevin Wang fc4fdc3cbe regenerate docs 2026-03-13 08:38:05 +00:00
Kevin Wang bdbb996a2e Add ModelDefinitions to ModelConfigService 2026-03-13 08:25:03 +00:00
18 changed files with 944 additions and 50 deletions
+141
View File
@@ -677,6 +677,141 @@ their corresponding top-level category object in your `settings.json` file.
used. used.
- **Default:** `[]` - **Default:** `[]`
- **`modelConfigs.modelDefinitions`** (object):
- **Description:** Registry of model metadata, including tier, family, and
features.
- **Default:**
```json
{
"gemini-3.1-pro-preview": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3.1-pro-preview-customtools": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3-pro-preview": {
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": true,
"multimodalToolUse": true
}
},
"gemini-3-flash-preview": {
"tier": "flash",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": true
}
},
"gemini-2.5-pro": {
"tier": "pro",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"gemini-2.5-flash": {
"tier": "flash",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"gemini-2.5-flash-lite": {
"tier": "flash-lite",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"auto": {
"tier": "auto",
"isPreview": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"pro": {
"tier": "pro",
"isPreview": false,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"flash": {
"tier": "flash",
"isPreview": false,
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"flash-lite": {
"tier": "flash-lite",
"isPreview": false,
"features": {
"thinking": false,
"multimodalToolUse": false
}
},
"auto-gemini-3": {
"displayName": "Auto (Gemini 3)",
"tier": "auto",
"isPreview": true,
"dialogLocation": "main",
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash",
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"auto-gemini-2.5": {
"displayName": "Auto (Gemini 2.5)",
"tier": "auto",
"isPreview": false,
"dialogLocation": "main",
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash",
"features": {
"thinking": false,
"multimodalToolUse": false
}
}
}
```
- **Requires restart:** Yes
#### `agents` #### `agents`
- **`agents.overrides`** (object): - **`agents.overrides`** (object):
@@ -1068,6 +1203,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false` - **Default:** `false`
- **Requires restart:** Yes - **Requires restart:** Yes
- **`experimental.dynamicModelConfiguration`** (boolean):
- **Description:** Enable dynamic model configuration (definitions,
resolutions, and chains) via settings.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.enabled`** (boolean): - **`experimental.gemmaModelRouter.enabled`** (boolean):
- **Description:** Enable the Gemma Model Router (experimental). Requires a - **Description:** Enable the Gemma Model Router (experimental). Requires a
local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.
+1
View File
@@ -857,6 +857,7 @@ export async function loadCliConfig(
disableLLMCorrection: settings.tools?.disableLLMCorrection, disableLLMCorrection: settings.tools?.disableLLMCorrection,
rawOutput: argv.rawOutput, rawOutput: argv.rawOutput,
acceptRawOutputRisk: argv.acceptRawOutputRisk, acceptRawOutputRisk: argv.acceptRawOutputRisk,
dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration,
modelConfigServiceConfig: settings.modelConfigs, modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust // TODO: loading of hooks based on workspace trust
enableHooks: settings.hooksConfig.enabled, enableHooks: settings.hooksConfig.enabled,
+43
View File
@@ -1039,6 +1039,20 @@ const SETTINGS_SCHEMA = {
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.', 'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
showInDialog: false, showInDialog: false,
}, },
modelDefinitions: {
type: 'object',
label: 'Model Definitions',
category: 'Model',
requiresRestart: true,
default: DEFAULT_MODEL_CONFIGS.modelDefinitions,
description:
'Registry of model metadata, including tier, family, and features.',
showInDialog: false,
additionalProperties: {
type: 'object',
ref: 'ModelDefinition',
},
},
}, },
}, },
@@ -1910,6 +1924,16 @@ const SETTINGS_SCHEMA = {
'Enable web fetch behavior that bypasses LLM summarization.', 'Enable web fetch behavior that bypasses LLM summarization.',
showInDialog: true, showInDialog: true,
}, },
dynamicModelConfiguration: {
type: 'boolean',
label: 'Dynamic Model Configuration',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
showInDialog: false,
},
gemmaModelRouter: { gemmaModelRouter: {
type: 'object', type: 'object',
label: 'Gemma Model Router', label: 'Gemma Model Router',
@@ -2726,6 +2750,25 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
}, },
}, },
}, },
ModelDefinition: {
type: 'object',
description: 'Model metadata registry entry.',
properties: {
displayName: { type: 'string' },
tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] },
family: { type: 'string' },
isPreview: { type: 'boolean' },
dialogLocation: { enum: ['main', 'manual'] },
dialogDescription: { type: 'string' },
features: {
type: 'object',
properties: {
thinking: { type: 'boolean' },
multimodalToolUse: { type: 'boolean' },
},
},
},
},
}; };
export function getSettingsSchema(): SettingsSchemaType { export function getSettingsSchema(): SettingsSchemaType {
@@ -27,6 +27,7 @@ import {
} from '../utils/displayUtils.js'; } from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js'; import { computeSessionStats } from '../utils/computeStats.js';
import { import {
type Config,
type RetrieveUserQuotaResponse, type RetrieveUserQuotaResponse,
isActiveModel, isActiveModel,
getDisplayString, getDisplayString,
@@ -88,13 +89,16 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
// Logic for building the unified list of table rows // Logic for building the unified list of table rows
const buildModelRows = ( const buildModelRows = (
models: Record<string, ModelMetrics>, models: Record<string, ModelMetrics>,
config: Config,
quotas?: RetrieveUserQuotaResponse, quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false, useGemini3_1 = false,
useCustomToolModel = false, useCustomToolModel = false,
) => { ) => {
const getBaseModelName = (name: string) => name.replace('-001', ''); const getBaseModelName = (name: string) => name.replace('-001', '');
const usedModelNames = new Set( const usedModelNames = new Set(
Object.keys(models).map(getBaseModelName).map(getDisplayString), Object.keys(models)
.map(getBaseModelName)
.map((name) => getDisplayString(name, config)),
); );
// 1. Models with active usage // 1. Models with active usage
@@ -104,7 +108,7 @@ const buildModelRows = (
const inputTokens = metrics.tokens.input; const inputTokens = metrics.tokens.input;
return { return {
key: name, key: name,
modelName: getDisplayString(modelName), modelName: getDisplayString(modelName, config),
requests: metrics.api.totalRequests, requests: metrics.api.totalRequests,
cachedTokens: cachedTokens.toLocaleString(), cachedTokens: cachedTokens.toLocaleString(),
inputTokens: inputTokens.toLocaleString(), inputTokens: inputTokens.toLocaleString(),
@@ -121,11 +125,11 @@ const buildModelRows = (
(b) => (b) =>
b.modelId && b.modelId &&
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) && isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
!usedModelNames.has(getDisplayString(b.modelId)), !usedModelNames.has(getDisplayString(b.modelId, config)),
) )
.map((bucket) => ({ .map((bucket) => ({
key: bucket.modelId!, key: bucket.modelId!,
modelName: getDisplayString(bucket.modelId!), modelName: getDisplayString(bucket.modelId!, config),
requests: '-', requests: '-',
cachedTokens: '-', cachedTokens: '-',
inputTokens: '-', inputTokens: '-',
@@ -139,6 +143,7 @@ const buildModelRows = (
const ModelUsageTable: React.FC<{ const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>; models: Record<string, ModelMetrics>;
config: Config;
quotas?: RetrieveUserQuotaResponse; quotas?: RetrieveUserQuotaResponse;
cacheEfficiency: number; cacheEfficiency: number;
totalCachedTokens: number; totalCachedTokens: number;
@@ -150,6 +155,7 @@ const ModelUsageTable: React.FC<{
useCustomToolModel?: boolean; useCustomToolModel?: boolean;
}> = ({ }> = ({
models, models,
config,
quotas, quotas,
cacheEfficiency, cacheEfficiency,
totalCachedTokens, totalCachedTokens,
@@ -162,7 +168,13 @@ const ModelUsageTable: React.FC<{
}) => { }) => {
const { stdout } = useStdout(); const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 84; const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel); const rows = buildModelRows(
models,
config,
quotas,
useGemini3_1,
useCustomToolModel,
);
if (rows.length === 0) { if (rows.length === 0) {
return null; return null;
@@ -676,6 +688,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
</Section> </Section>
<ModelUsageTable <ModelUsageTable
models={models} models={models}
config={config}
quotas={quotas} quotas={quotas}
cacheEfficiency={computed.cacheEfficiency} cacheEfficiency={computed.cacheEfficiency}
totalCachedTokens={computed.totalCachedTokens} totalCachedTokens={computed.totalCachedTokens}
@@ -109,7 +109,7 @@ export const BrowserAgentDefinition = (
): LocalAgentDefinition<typeof BrowserTaskResultSchema> => { ): LocalAgentDefinition<typeof BrowserTaskResultSchema> => {
// Use Preview Flash model if the main model is any of the preview models. // Use Preview Flash model if the main model is any of the preview models.
// If the main model is not a preview model, use the default flash model. // If the main model is not a preview model, use the default flash model.
const model = isPreviewModel(config.getModel()) const model = isPreviewModel(config.getModel(), config)
? PREVIEW_GEMINI_FLASH_MODEL ? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL; : DEFAULT_GEMINI_FLASH_MODEL;
@@ -54,19 +54,21 @@ export function resolvePolicyChain(
useCustomToolModel, useCustomToolModel,
hasAccessToPreview, hasAccessToPreview,
); );
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false; const isAutoPreferred = preferredModel
const isAutoConfigured = isAutoModel(configuredModel); ? isAutoModel(preferredModel, config)
: false;
const isAutoConfigured = isAutoModel(configuredModel, config);
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) { if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain(); chain = getFlashLitePolicyChain();
} else if ( } else if (
isGemini3Model(resolvedModel) || isGemini3Model(resolvedModel, config) ||
isAutoPreferred || isAutoPreferred ||
isAutoConfigured isAutoConfigured
) { ) {
if (hasAccessToPreview) { if (hasAccessToPreview) {
const previewEnabled = const previewEnabled =
isGemini3Model(resolvedModel) || isGemini3Model(resolvedModel, config) ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO || preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO; configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({ chain = getModelPolicyChain({
+33 -17
View File
@@ -601,6 +601,7 @@ export interface ConfigParameters {
disableYoloMode?: boolean; disableYoloMode?: boolean;
rawOutput?: boolean; rawOutput?: boolean;
acceptRawOutputRisk?: boolean; acceptRawOutputRisk?: boolean;
dynamicModelConfiguration?: boolean;
modelConfigServiceConfig?: ModelConfigServiceConfig; modelConfigServiceConfig?: ModelConfigServiceConfig;
enableHooks?: boolean; enableHooks?: boolean;
enableHooksUI?: boolean; enableHooksUI?: boolean;
@@ -799,6 +800,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly disableYoloMode: boolean; private readonly disableYoloMode: boolean;
private readonly rawOutput: boolean; private readonly rawOutput: boolean;
private readonly acceptRawOutputRisk: boolean; private readonly acceptRawOutputRisk: boolean;
private readonly dynamicModelConfiguration: boolean;
private pendingIncludeDirectories: string[]; private pendingIncludeDirectories: string[];
private readonly enableHooks: boolean; private readonly enableHooks: boolean;
private readonly enableHooksUI: boolean; private readonly enableHooksUI: boolean;
@@ -987,7 +989,7 @@ export class Config implements McpContext, AgentLoopContext {
this.truncateToolOutputThreshold = this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ?? params.truncateToolOutputThreshold ??
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD; DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
this.useWriteTodos = isPreviewModel(this.model) this.useWriteTodos = isPreviewModel(this.model, this)
? false ? false
: (params.useWriteTodos ?? true); : (params.useWriteTodos ?? true);
this.workspacePoliciesDir = params.workspacePoliciesDir; this.workspacePoliciesDir = params.workspacePoliciesDir;
@@ -1062,6 +1064,7 @@ export class Config implements McpContext, AgentLoopContext {
this.disableYoloMode = params.disableYoloMode ?? false; this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false; this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false; this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;
if (params.hooks) { if (params.hooks) {
this.hooks = params.hooks; this.hooks = params.hooks;
@@ -1111,18 +1114,23 @@ export class Config implements McpContext, AgentLoopContext {
// remove this hack. // remove this hack.
let modelConfigServiceConfig = params.modelConfigServiceConfig; let modelConfigServiceConfig = params.modelConfigServiceConfig;
if (modelConfigServiceConfig) { if (modelConfigServiceConfig) {
if (!modelConfigServiceConfig.aliases) { // Ensure user-defined model definitions augment, not replace, the defaults.
modelConfigServiceConfig = { const mergedModelDefinitions = {
...modelConfigServiceConfig, ...DEFAULT_MODEL_CONFIGS.modelDefinitions,
aliases: DEFAULT_MODEL_CONFIGS.aliases, ...modelConfigServiceConfig.modelDefinitions,
}; };
}
if (!modelConfigServiceConfig.overrides) { modelConfigServiceConfig = {
modelConfigServiceConfig = { // Preserve other user settings like customAliases
...modelConfigServiceConfig, ...modelConfigServiceConfig,
overrides: DEFAULT_MODEL_CONFIGS.overrides, // Apply defaults for aliases and overrides if they are not provided
}; aliases:
} modelConfigServiceConfig.aliases ?? DEFAULT_MODEL_CONFIGS.aliases,
overrides:
modelConfigServiceConfig.overrides ?? DEFAULT_MODEL_CONFIGS.overrides,
// Use the merged model definitions
modelDefinitions: mergedModelDefinitions,
};
} }
this.modelConfigService = new ModelConfigService( this.modelConfigService = new ModelConfigService(
@@ -1325,7 +1333,10 @@ export class Config implements McpContext, AgentLoopContext {
// Only reset when we have explicit "no access" (hasAccessToPreviewModel === false). // Only reset when we have explicit "no access" (hasAccessToPreviewModel === false).
// When null (quota not fetched) or true, we preserve the saved model. // When null (quota not fetched) or true, we preserve the saved model.
if (isPreviewModel(this.model) && this.hasAccessToPreviewModel === false) { if (
isPreviewModel(this.model, this) &&
this.hasAccessToPreviewModel === false
) {
this.setModel(DEFAULT_GEMINI_MODEL_AUTO); this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
} }
@@ -1593,7 +1604,7 @@ export class Config implements McpContext, AgentLoopContext {
const isPreview = const isPreview =
model === PREVIEW_GEMINI_MODEL_AUTO || model === PREVIEW_GEMINI_MODEL_AUTO ||
isPreviewModel(this.getActiveModel()); isPreviewModel(this.getActiveModel(), this);
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL; const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
const flashModel = isPreview const flashModel = isPreview
? PREVIEW_GEMINI_FLASH_MODEL ? PREVIEW_GEMINI_FLASH_MODEL
@@ -1791,8 +1802,9 @@ export class Config implements McpContext, AgentLoopContext {
} }
const hasAccess = const hasAccess =
quota.buckets?.some((b) => b.modelId && isPreviewModel(b.modelId)) ?? quota.buckets?.some(
false; (b) => b.modelId && isPreviewModel(b.modelId, this),
) ?? false;
this.setHasAccessToPreviewModel(hasAccess); this.setHasAccessToPreviewModel(hasAccess);
return quota; return quota;
} catch (e) { } catch (e) {
@@ -2184,6 +2196,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.acceptRawOutputRisk; return this.acceptRawOutputRisk;
} }
getExperimentalDynamicModelConfiguration(): boolean {
return this.dynamicModelConfiguration;
}
getPendingIncludeDirectories(): string[] { getPendingIncludeDirectories(): string[] {
return this.pendingIncludeDirectories; return this.pendingIncludeDirectories;
} }
@@ -249,4 +249,94 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
}, },
}, },
], ],
modelDefinitions: {
// Concrete Models
'gemini-3.1-pro-preview': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3.1-pro-preview-customtools': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3-pro-preview': {
tier: 'pro',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: true, multimodalToolUse: true },
},
'gemini-3-flash-preview': {
tier: 'flash',
family: 'gemini-3',
isPreview: true,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: true },
},
'gemini-2.5-pro': {
tier: 'pro',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
'gemini-2.5-flash': {
tier: 'flash',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
'gemini-2.5-flash-lite': {
tier: 'flash-lite',
family: 'gemini-2.5',
isPreview: false,
dialogLocation: 'manual',
features: { thinking: false, multimodalToolUse: false },
},
// Aliases
auto: {
tier: 'auto',
isPreview: true,
features: { thinking: true, multimodalToolUse: false },
},
pro: {
tier: 'pro',
isPreview: false,
features: { thinking: true, multimodalToolUse: false },
},
flash: {
tier: 'flash',
isPreview: false,
features: { thinking: false, multimodalToolUse: false },
},
'flash-lite': {
tier: 'flash-lite',
isPreview: false,
features: { thinking: false, multimodalToolUse: false },
},
'auto-gemini-3': {
displayName: 'Auto (Gemini 3)',
tier: 'auto',
isPreview: true,
dialogLocation: 'main',
dialogDescription:
'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash',
features: { thinking: true, multimodalToolUse: false },
},
'auto-gemini-2.5': {
displayName: 'Auto (Gemini 2.5)',
tier: 'auto',
isPreview: false,
dialogLocation: 'main',
dialogDescription:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
features: { thinking: false, multimodalToolUse: false },
},
},
}; };
+115
View File
@@ -36,6 +36,121 @@ import {
VALID_GEMINI_MODELS, VALID_GEMINI_MODELS,
VALID_ALIASES, VALID_ALIASES,
} from './models.js'; } from './models.js';
import type { Config } from './config.js';
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
const modelConfigService = new ModelConfigService(DEFAULT_MODEL_CONFIGS);
const dynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
const legacyConfig = {
getExperimentalDynamicModelConfiguration: () => false,
modelConfigService,
} as unknown as Config;
describe('Dynamic Configuration Parity', () => {
const modelsToTest = [
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
'custom-model',
];
it('getDisplayString should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = getDisplayString(model, legacyConfig);
const dynamic = getDisplayString(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isPreviewModel should match legacy behavior', () => {
const allModels = [
...modelsToTest,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
];
for (const model of allModels) {
const legacy = isPreviewModel(model, legacyConfig);
const dynamic = isPreviewModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isProModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isProModel(model, legacyConfig);
const dynamic = isProModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isGemini3Model should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isGemini3Model(model, legacyConfig);
const dynamic = isGemini3Model(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isGemini2Model should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isGemini2Model(model, legacyConfig);
const dynamic = isGemini2Model(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isCustomModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isCustomModel(model, legacyConfig);
const dynamic = isCustomModel(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('supportsModernFeatures should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = supportsModernFeatures(model, legacyConfig);
const dynamic = supportsModernFeatures(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isActiveModel should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isActiveModel(model, false, false, legacyConfig);
const dynamic = isActiveModel(model, false, false, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('isValidModelOrAlias should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = isValidModelOrAlias(model, legacyConfig);
const dynamic = isValidModelOrAlias(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
it('supportsMultimodalFunctionResponse should match legacy behavior', () => {
for (const model of modelsToTest) {
const legacy = supportsMultimodalFunctionResponse(model, legacyConfig);
const dynamic = supportsMultimodalFunctionResponse(model, dynamicConfig);
expect(dynamic).toBe(legacy);
}
});
});
describe('isPreviewModel', () => { describe('isPreviewModel', () => {
it('should return true for preview models', () => { it('should return true for preview models', () => {
+137 -13
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { ModelConfigService } from '../services/modelConfigService.js';
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview'; export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview'; export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL = export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
@@ -46,6 +48,15 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Cap the thinking at 8192 to prevent run-away thinking loops. // Cap the thinking at 8192 to prevent run-away thinking loops.
export const DEFAULT_THINKING_MODE = 8192; export const DEFAULT_THINKING_MODE = 8192;
/**
* Represents the minimal configuration needed to resolve models dynamically.
* This breaks circular dependencies by avoiding an import of the full Config class.
*/
export interface ModelResolutionContext {
getExperimentalDynamicModelConfiguration?: () => boolean;
modelConfigService: ModelConfigService;
}
/** /**
* Resolves the requested model alias (e.g., 'auto-gemini-3', 'pro', 'flash', 'flash-lite') * Resolves the requested model alias (e.g., 'auto-gemini-3', 'pro', 'flash', 'flash-lite')
* to a concrete model name. * to a concrete model name.
@@ -148,7 +159,17 @@ export function resolveClassifierModel(
} }
return resolveModel(requestedModel, useGemini3_1, useCustomToolModel); return resolveModel(requestedModel, useGemini3_1, useCustomToolModel);
} }
export function getDisplayString(model: string) { export function getDisplayString(
model: string,
config?: ModelResolutionContext,
) {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const definition = config.modelConfigService.getModelDefinition(model);
if (definition?.displayName) {
return definition.displayName;
}
}
switch (model) { switch (model) {
case PREVIEW_GEMINI_MODEL_AUTO: case PREVIEW_GEMINI_MODEL_AUTO:
return 'Auto (Gemini 3)'; return 'Auto (Gemini 3)';
@@ -169,9 +190,19 @@ export function getDisplayString(model: string) {
* Checks if the model is a preview model. * Checks if the model is a preview model.
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a preview model. * @returns True if the model is a preview model.
*/ */
export function isPreviewModel(model: string): boolean { export function isPreviewModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return (
config.modelConfigService.getModelDefinition(model)?.isPreview === true
);
}
return ( return (
model === PREVIEW_GEMINI_MODEL || model === PREVIEW_GEMINI_MODEL ||
model === PREVIEW_GEMINI_3_1_MODEL || model === PREVIEW_GEMINI_3_1_MODEL ||
@@ -186,9 +217,16 @@ export function isPreviewModel(model: string): boolean {
* Checks if the model is a Pro model. * Checks if the model is a Pro model.
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Pro model. * @returns True if the model is a Pro model.
*/ */
export function isProModel(model: string): boolean { export function isProModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return config.modelConfigService.getModelDefinition(model)?.tier === 'pro';
}
return model.toLowerCase().includes('pro'); return model.toLowerCase().includes('pro');
} }
@@ -196,9 +234,22 @@ export function isProModel(model: string): boolean {
* Checks if the model is a Gemini 3 model. * Checks if the model is a Gemini 3 model.
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Gemini 3 model. * @returns True if the model is a Gemini 3 model.
*/ */
export function isGemini3Model(model: string): boolean { export function isGemini3Model(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
// Legacy behavior resolves the model first.
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.family ===
'gemini-3'
);
}
const resolved = resolveModel(model); const resolved = resolveModel(model);
return /^gemini-3(\.|-|$)/.test(resolved); return /^gemini-3(\.|-|$)/.test(resolved);
} }
@@ -207,9 +258,20 @@ export function isGemini3Model(model: string): boolean {
* Checks if the model is a Gemini 2.x model. * Checks if the model is a Gemini 2.x model.
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is a Gemini-2.x model. * @returns True if the model is a Gemini-2.x model.
*/ */
export function isGemini2Model(model: string): boolean { export function isGemini2Model(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
// Legacy behavior does NOT resolve the model first for Gemini 2 check.
return (
config.modelConfigService.getModelDefinition(model)?.family ===
'gemini-2.5'
);
}
return /^gemini-2(\.|$)/.test(model); return /^gemini-2(\.|$)/.test(model);
} }
@@ -217,9 +279,20 @@ export function isGemini2Model(model: string): boolean {
* Checks if the model is a "custom" model (not Gemini branded). * Checks if the model is a "custom" model (not Gemini branded).
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is not a Gemini branded model. * @returns True if the model is not a Gemini branded model.
*/ */
export function isCustomModel(model: string): boolean { export function isCustomModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.tier ===
'custom' || !resolved.startsWith('gemini-')
);
}
const resolved = resolveModel(model); const resolved = resolveModel(model);
return !resolved.startsWith('gemini-'); return !resolved.startsWith('gemini-');
} }
@@ -229,20 +302,31 @@ export function isCustomModel(model: string): boolean {
* This includes Gemini 3 models and any custom models. * This includes Gemini 3 models and any custom models.
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model supports modern features like thoughts. * @returns True if the model supports modern features like thoughts.
*/ */
export function supportsModernFeatures(model: string): boolean { export function supportsModernFeatures(
if (isGemini3Model(model)) return true; model: string,
return isCustomModel(model); config?: ModelResolutionContext,
): boolean {
if (isGemini3Model(model, config)) return true;
return isCustomModel(model, config);
} }
/** /**
* Checks if the model is an auto model. * Checks if the model is an auto model.
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is an auto model. * @returns True if the model is an auto model.
*/ */
export function isAutoModel(model: string): boolean { export function isAutoModel(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return config.modelConfigService.getModelDefinition(model)?.tier === 'auto';
}
return ( return (
model === GEMINI_MODEL_ALIAS_AUTO || model === GEMINI_MODEL_ALIAS_AUTO ||
model === PREVIEW_GEMINI_MODEL_AUTO || model === PREVIEW_GEMINI_MODEL_AUTO ||
@@ -255,9 +339,23 @@ export function isAutoModel(model: string): boolean {
* This is supported in Gemini 3. * This is supported in Gemini 3.
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model supports multimodal function responses. * @returns True if the model supports multimodal function responses.
*/ */
export function supportsMultimodalFunctionResponse(model: string): boolean { export function supportsMultimodalFunctionResponse(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (VALID_ALIASES.has(model)) {
return false;
}
const resolved = resolveModel(model);
return (
config.modelConfigService.getModelDefinition(resolved)?.features
?.multimodalToolUse === true
);
}
return model.startsWith('gemini-3-'); return model.startsWith('gemini-3-');
} }
@@ -266,16 +364,32 @@ export function supportsMultimodalFunctionResponse(model: string): boolean {
* *
* @param model The model name to check. * @param model The model name to check.
* @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled. * @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is active. * @returns True if the model is active.
*/ */
export function isActiveModel( export function isActiveModel(
model: string, model: string,
useGemini3_1: boolean = false, useGemini3_1: boolean = false,
useCustomToolModel: boolean = false, useCustomToolModel: boolean = false,
config?: ModelResolutionContext,
): boolean { ): boolean {
if (!VALID_GEMINI_MODELS.has(model)) { if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (VALID_ALIASES.has(model)) {
return false;
}
const definition = config.modelConfigService.getModelDefinition(model);
if (!definition) {
return false;
}
// Legacy logic only considers explicit Gemini models as "active".
if (definition.tier === 'custom' && !model.startsWith('gemini-')) {
return false;
}
} else if (!VALID_GEMINI_MODELS.has(model)) {
return false; return false;
} }
if (useGemini3_1) { if (useGemini3_1) {
if (model === PREVIEW_GEMINI_MODEL) { if (model === PREVIEW_GEMINI_MODEL) {
return false; return false;
@@ -297,9 +411,19 @@ export function isActiveModel(
* Checks if the model name is valid (either a valid model or a valid alias). * Checks if the model name is valid (either a valid model or a valid alias).
* *
* @param model The model name to check. * @param model The model name to check.
* @param config Optional config object for dynamic model configuration.
* @returns True if the model is valid. * @returns True if the model is valid.
*/ */
export function isValidModelOrAlias(model: string): boolean { export function isValidModelOrAlias(
model: string,
config?: ModelResolutionContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
if (config.modelConfigService.getModelDefinition(model)) {
return true;
}
}
// Check if it's a valid alias // Check if it's a valid alias
if (VALID_ALIASES.has(model)) { if (VALID_ALIASES.has(model)) {
return true; return true;
+5 -2
View File
@@ -421,7 +421,7 @@ export class GeminiChat {
: getRetryErrorType(error); : getRetryErrorType(error);
if ( if (
(isContentError && isGemini2Model(model)) || (isContentError && isGemini2Model(model, this.context.config)) ||
(isRetryable && !signal.aborted) (isRetryable && !signal.aborted)
) { ) {
// The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration. // The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration.
@@ -551,7 +551,10 @@ export class GeminiChat {
abortSignal, abortSignal,
}; };
let contentsToUse: Content[] = supportsModernFeatures(modelToUse) let contentsToUse: Content[] = supportsModernFeatures(
modelToUse,
this.context.config,
)
? [...contentsForPreviewModel] ? [...contentsForPreviewModel]
: [...requestContents]; : [...requestContents];
+2 -2
View File
@@ -63,7 +63,7 @@ export class PromptProvider {
context.config.getActiveModel(), context.config.getActiveModel(),
context.config.getGemini31LaunchedSync?.() ?? false, context.config.getGemini31LaunchedSync?.() ?? false,
); );
const isModernModel = supportsModernFeatures(desiredModel); const isModernModel = supportsModernFeatures(desiredModel, context.config);
const activeSnippets = isModernModel ? snippets : legacySnippets; const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames(); const contextFilenames = getAllGeminiMdFilenames();
@@ -235,7 +235,7 @@ export class PromptProvider {
context.config.getActiveModel(), context.config.getActiveModel(),
context.config.getGemini31LaunchedSync?.() ?? false, context.config.getGemini31LaunchedSync?.() ?? false,
); );
const isModernModel = supportsModernFeatures(desiredModel); const isModernModel = supportsModernFeatures(desiredModel, context.config);
const activeSnippets = isModernModel ? snippets : legacySnippets; const activeSnippets = isModernModel ? snippets : legacySnippets;
return activeSnippets.getCompressionPrompt( return activeSnippets.getCompressionPrompt(
context.config.getApprovedPlanPath(), context.config.getApprovedPlanPath(),
@@ -36,7 +36,7 @@ export class ApprovalModeStrategy implements RoutingStrategy {
const model = context.requestedModel ?? config.getModel(); const model = context.requestedModel ?? config.getModel();
// This strategy only applies to "auto" models. // This strategy only applies to "auto" models.
if (!isAutoModel(model)) { if (!isAutoModel(model, config)) {
return null; return null;
} }
@@ -139,7 +139,7 @@ export class ClassifierStrategy implements RoutingStrategy {
const model = context.requestedModel ?? config.getModel(); const model = context.requestedModel ?? config.getModel();
if ( if (
(await config.getNumericalRoutingEnabled()) && (await config.getNumericalRoutingEnabled()) &&
isGemini3Model(model) isGemini3Model(model, config)
) { ) {
return null; return null;
} }
@@ -109,7 +109,7 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
return null; return null;
} }
if (!isGemini3Model(model)) { if (!isGemini3Model(model, config)) {
return null; return null;
} }
@@ -29,7 +29,7 @@ export class OverrideStrategy implements RoutingStrategy {
const overrideModel = context.requestedModel ?? config.getModel(); const overrideModel = context.requestedModel ?? config.getModel();
// If the model is 'auto' we should pass to the next strategy. // If the model is 'auto' we should pass to the next strategy.
if (isAutoModel(overrideModel)) { if (isAutoModel(overrideModel, config)) {
return null; return null;
} }
@@ -51,11 +51,34 @@ export interface ModelConfigAlias {
modelConfig: ModelConfig; modelConfig: ModelConfig;
} }
// A model definition is a mapping from a model name to a list of features
// that the model supports. Model names can be either direct model IDs
// (gemini-2.5-pro) or aliases (auto).
export interface ModelDefinition {
displayName?: string;
tier?: string; // 'pro' | 'flash' | 'flash-lite' | 'custom' | 'auto'
family?: string; // The gemini family, e.g. 'gemini-3' | 'gemini-2'
isPreview?: boolean;
// Specifies which view the model should appear in. If unset, the model will
// not appear in the dialog.
dialogLocation?: 'main' | 'manual';
/** A short description of the model for the dialog. */
dialogDescription?: string;
features?: {
// Whether the model supports thinking.
thinking?: boolean;
// Whether the model supports mutlimodal function responses. This is
// supported in Gemini 3.
multimodalToolUse?: boolean;
};
}
export interface ModelConfigServiceConfig { export interface ModelConfigServiceConfig {
aliases?: Record<string, ModelConfigAlias>; aliases?: Record<string, ModelConfigAlias>;
customAliases?: Record<string, ModelConfigAlias>; customAliases?: Record<string, ModelConfigAlias>;
overrides?: ModelConfigOverride[]; overrides?: ModelConfigOverride[];
customOverrides?: ModelConfigOverride[]; customOverrides?: ModelConfigOverride[];
modelDefinitions?: Record<string, ModelDefinition>;
} }
const MAX_ALIAS_CHAIN_DEPTH = 100; const MAX_ALIAS_CHAIN_DEPTH = 100;
@@ -76,6 +99,28 @@ export class ModelConfigService {
// TODO(12597): Process config to build a typed alias hierarchy. // TODO(12597): Process config to build a typed alias hierarchy.
constructor(private readonly config: ModelConfigServiceConfig) {} constructor(private readonly config: ModelConfigServiceConfig) {}
getModelDefinition(modelId: string): ModelDefinition | undefined {
const definition = this.config.modelDefinitions?.[modelId];
if (definition) {
return definition;
}
// For unknown models, return an implicit custom definition to match legacy behavior.
if (!modelId.startsWith('gemini-')) {
return {
tier: 'custom',
family: 'custom',
features: {},
};
}
return undefined;
}
getModelDefinitions(): Record<string, ModelDefinition> {
return this.config.modelDefinitions ?? {};
}
registerRuntimeModelConfig(aliasName: string, alias: ModelConfigAlias): void { registerRuntimeModelConfig(aliasName: string, alias: ModelConfigAlias): void {
this.runtimeAliases[aliasName] = alias; this.runtimeAliases[aliasName] = alias;
} }
File diff suppressed because one or more lines are too long