mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 22:33:05 -07:00
639 lines
20 KiB
TypeScript
639 lines
20 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type { GenerateContentConfig } from '@google/genai';
|
|
import type { ModelPolicy } from '../availability/modelPolicy.js';
|
|
import {
|
|
getDisplayString,
|
|
PREVIEW_GEMINI_3_1_MODEL,
|
|
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
|
isProModel,
|
|
} from '../config/models.js';
|
|
|
|
// The primary key for the ModelConfig is the model string. However, we also
|
|
// support a secondary key to limit the override scope, typically an agent name.
|
|
export interface ModelConfigKey {
|
|
model: string;
|
|
|
|
// In many cases the model (or model config alias) is sufficient to fully
|
|
// scope an override. However, in some cases, we want additional scoping of
|
|
// an override. Consider the case of developing a new subagent, perhaps we
|
|
// want to override the temperature for all model calls made by this subagent.
|
|
// However, we most certainly do not want to change the temperature for other
|
|
// subagents, nor do we want to introduce a whole new set of aliases just for
|
|
// the new subagent. Using the `overrideScope` we can limit our overrides to
|
|
// model calls made by this specific subagent, and no others, while still
|
|
// ensuring model configs are fully orthogonal to the agents who use them.
|
|
overrideScope?: string;
|
|
|
|
// Indicates whether this configuration request is happening during a retry attempt.
|
|
// This allows overrides to specify different settings (e.g., higher temperature)
|
|
// specifically for retry scenarios.
|
|
isRetry?: boolean;
|
|
|
|
// Indicates whether this request originates from the primary interactive chat model.
|
|
// Enables the default fallback configuration to `chat-base` when unknown.
|
|
isChatModel?: boolean;
|
|
}
|
|
|
|
export interface ModelConfig {
|
|
model?: string;
|
|
generateContentConfig?: GenerateContentConfig;
|
|
}
|
|
|
|
export interface ModelConfigOverride {
|
|
match: {
|
|
model?: string; // Can be a model name or an alias
|
|
overrideScope?: string;
|
|
isRetry?: boolean;
|
|
};
|
|
modelConfig: ModelConfig;
|
|
}
|
|
|
|
export interface ModelConfigAlias {
|
|
extends?: string;
|
|
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 whether the model should be visible in the dialog.
|
|
isVisible?: boolean;
|
|
/** 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;
|
|
};
|
|
}
|
|
|
|
// A model resolution is a mapping from a model name to a list of conditions
|
|
// that can be used to resolve the model to a model ID.
|
|
export interface ModelResolution {
|
|
// The default model ID to use when no conditions are met.
|
|
default: string;
|
|
// A list of conditions that can be used to resolve the model.
|
|
contexts?: Array<{
|
|
// The condition to check for.
|
|
condition: ResolutionCondition;
|
|
// The model ID to use when the condition is met.
|
|
target: string;
|
|
}>;
|
|
}
|
|
|
|
/** The actual state of the current session. */
|
|
export interface ResolutionContext {
|
|
useGemini3_1?: boolean;
|
|
useGemini3_1FlashLite?: boolean;
|
|
useCustomTools?: boolean;
|
|
hasAccessToPreview?: boolean;
|
|
hasAccessToProModel?: boolean;
|
|
requestedModel?: string;
|
|
}
|
|
|
|
/** The requirements defined in the registry. */
|
|
export interface ResolutionCondition {
|
|
useGemini3_1?: boolean;
|
|
useGemini3_1FlashLite?: boolean;
|
|
useCustomTools?: boolean;
|
|
hasAccessToPreview?: boolean;
|
|
/** Matches if the current model is in this list. */
|
|
requestedModels?: string[];
|
|
}
|
|
|
|
export interface ModelConfigServiceConfig {
|
|
aliases?: Record<string, ModelConfigAlias>;
|
|
customAliases?: Record<string, ModelConfigAlias>;
|
|
overrides?: ModelConfigOverride[];
|
|
customOverrides?: ModelConfigOverride[];
|
|
modelDefinitions?: Record<string, ModelDefinition>;
|
|
modelIdResolutions?: Record<string, ModelResolution>;
|
|
classifierIdResolutions?: Record<string, ModelResolution>;
|
|
modelChains?: Record<string, ModelPolicy[]>;
|
|
}
|
|
|
|
const MAX_ALIAS_CHAIN_DEPTH = 100;
|
|
|
|
export type ResolvedModelConfig = _ResolvedModelConfig & {
|
|
readonly _brand: unique symbol;
|
|
};
|
|
|
|
export interface _ResolvedModelConfig {
|
|
model: string; // The actual, resolved model name
|
|
generateContentConfig: GenerateContentConfig;
|
|
}
|
|
|
|
export class ModelConfigService {
|
|
private readonly runtimeAliases: Record<string, ModelConfigAlias> = {};
|
|
private readonly runtimeOverrides: ModelConfigOverride[] = [];
|
|
|
|
// TODO(12597): Process config to build a typed alias hierarchy.
|
|
constructor(private readonly config: ModelConfigServiceConfig) {}
|
|
|
|
/**
|
|
* Returns a standardized list of available model options based on the resolution context.
|
|
* This logic is shared across the TUI and ACP mode.
|
|
*/
|
|
getAvailableModelOptions(context: ResolutionContext): Array<{
|
|
modelId: string;
|
|
name: string;
|
|
description: string;
|
|
tier: string;
|
|
}> {
|
|
const definitions = this.config.modelDefinitions ?? {};
|
|
const shouldShowPreviewModels = context.hasAccessToPreview ?? false;
|
|
const useGemini31 = context.useGemini3_1 ?? false;
|
|
const useGemini31FlashLite = context.useGemini3_1FlashLite ?? false;
|
|
|
|
const mainOptions = Object.entries(definitions)
|
|
.filter(([_, m]) => {
|
|
if (m.isVisible !== true) return false;
|
|
if (m.isPreview && !shouldShowPreviewModels) return false;
|
|
if (m.tier !== 'auto') return false;
|
|
return true;
|
|
})
|
|
.map(([id, m]) => ({
|
|
modelId: id,
|
|
name: m.displayName ?? getDisplayString(id),
|
|
description:
|
|
id === 'auto-gemini-3' && useGemini31
|
|
? (m.dialogDescription ?? '').replace(
|
|
'gemini-3-pro',
|
|
'gemini-3.1-pro',
|
|
)
|
|
: (m.dialogDescription ?? ''),
|
|
tier: m.tier ?? 'auto',
|
|
}));
|
|
|
|
const manualOptions = Object.entries(definitions)
|
|
.filter(([id, m]) => {
|
|
if (m.isVisible !== true) return false;
|
|
if (m.isPreview && !shouldShowPreviewModels) return false;
|
|
if (m.tier === 'auto') return false;
|
|
if (context.hasAccessToProModel === false && isProModel(id))
|
|
return false;
|
|
if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false;
|
|
if (id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && !useGemini31FlashLite)
|
|
return false;
|
|
return true;
|
|
})
|
|
.map(([id, m]) => {
|
|
const resolvedId = this.resolveModelId(id, context);
|
|
const titleId = this.resolveModelId(id, {
|
|
useGemini3_1: useGemini31,
|
|
useGemini3_1FlashLite: useGemini31FlashLite,
|
|
});
|
|
return {
|
|
modelId: resolvedId,
|
|
name: m.displayName ?? getDisplayString(titleId),
|
|
description: m.dialogDescription ?? '',
|
|
tier: m.tier ?? 'custom',
|
|
};
|
|
});
|
|
|
|
// Deduplicate manual options
|
|
const seen = new Set<string>();
|
|
const uniqueManualOptions = manualOptions.filter((option) => {
|
|
if (seen.has(option.modelId)) return false;
|
|
seen.add(option.modelId);
|
|
return true;
|
|
});
|
|
|
|
return [...mainOptions, ...uniqueManualOptions];
|
|
}
|
|
|
|
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 ?? {};
|
|
}
|
|
|
|
private matches(
|
|
condition: ResolutionCondition,
|
|
context: ResolutionContext,
|
|
): boolean {
|
|
return Object.entries(condition).every(([key, value]) => {
|
|
if (value === undefined) return true;
|
|
|
|
switch (key) {
|
|
case 'useGemini3_1':
|
|
return value === context.useGemini3_1;
|
|
case 'useGemini3_1FlashLite':
|
|
return value === context.useGemini3_1FlashLite;
|
|
case 'useCustomTools':
|
|
return value === context.useCustomTools;
|
|
case 'hasAccessToPreview':
|
|
return value === context.hasAccessToPreview;
|
|
case 'requestedModels':
|
|
return (
|
|
Array.isArray(value) &&
|
|
!!context.requestedModel &&
|
|
value.includes(context.requestedModel)
|
|
);
|
|
default:
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Resolves a model ID to a concrete model ID based on the provided context.
|
|
resolveModelId(
|
|
requestedName: string,
|
|
context: ResolutionContext = {},
|
|
): string {
|
|
const resolution = this.config.modelIdResolutions?.[requestedName];
|
|
if (!resolution) {
|
|
return requestedName;
|
|
}
|
|
|
|
for (const ctx of resolution.contexts ?? []) {
|
|
if (this.matches(ctx.condition, context)) {
|
|
return ctx.target;
|
|
}
|
|
}
|
|
|
|
return resolution.default;
|
|
}
|
|
|
|
// Resolves a classifier model ID to a concrete model ID based on the provided context.
|
|
resolveClassifierModelId(
|
|
tier: string,
|
|
requestedModel: string,
|
|
context: ResolutionContext = {},
|
|
): string {
|
|
const resolution = this.config.classifierIdResolutions?.[tier];
|
|
const fullContext: ResolutionContext = { ...context, requestedModel };
|
|
|
|
if (!resolution) {
|
|
// Fallback to regular model resolution if no classifier-specific rule exists
|
|
return this.resolveModelId(tier, fullContext);
|
|
}
|
|
|
|
for (const ctx of resolution.contexts ?? []) {
|
|
if (this.matches(ctx.condition, fullContext)) {
|
|
return ctx.target;
|
|
}
|
|
}
|
|
|
|
return resolution.default;
|
|
}
|
|
|
|
getModelChain(chainName: string): ModelPolicy[] | undefined {
|
|
return this.config.modelChains?.[chainName];
|
|
}
|
|
|
|
/**
|
|
* Fetches a chain template and resolves all model IDs within it
|
|
* based on the provided context.
|
|
*/
|
|
resolveChain(
|
|
chainName: string,
|
|
context: ResolutionContext = {},
|
|
): ModelPolicy[] | undefined {
|
|
const template = this.config.modelChains?.[chainName];
|
|
if (!template) {
|
|
return undefined;
|
|
}
|
|
// Map through the template and resolve each model ID
|
|
return template.map((policy) => ({
|
|
...policy,
|
|
model: this.resolveModelId(policy.model, context),
|
|
}));
|
|
}
|
|
|
|
registerRuntimeModelConfig(aliasName: string, alias: ModelConfigAlias): void {
|
|
this.runtimeAliases[aliasName] = alias;
|
|
}
|
|
|
|
registerRuntimeModelOverride(override: ModelConfigOverride): void {
|
|
this.runtimeOverrides.push(override);
|
|
}
|
|
|
|
/**
|
|
* Resolves a model configuration by merging settings from aliases and applying overrides.
|
|
*
|
|
* The resolution follows a linear application pipeline:
|
|
*
|
|
* 1. Alias Chain Resolution:
|
|
* Builds the inheritance chain from root to leaf. Configurations are merged starting from
|
|
* the root, so that children naturally override parents.
|
|
*
|
|
* 2. Override Level Assignment:
|
|
* Overrides are matched against the hierarchy and assigned a "Level" for application:
|
|
* - Level 0: Broad matches (Global or Resolved Model name).
|
|
* - Level 1..N: Hierarchy matches (from Root-most alias to Leaf-most alias).
|
|
*
|
|
* 3. Precedence & Application:
|
|
* Overrides are applied in order of their Level (ASC), then Specificity (ASC), then
|
|
* Configuration Order (ASC). This ensures that more targeted and "deeper" rules
|
|
* naturally layer on top of broader ones.
|
|
*
|
|
* 4. Orthogonality:
|
|
* All fields (including 'model') are treated equally. A more specific or deeper override
|
|
* can freely change any setting, including the target model name.
|
|
*/
|
|
private internalGetResolvedConfig(context: ModelConfigKey): {
|
|
model: string | undefined;
|
|
generateContentConfig: GenerateContentConfig;
|
|
} {
|
|
const {
|
|
aliases = {},
|
|
customAliases = {},
|
|
overrides = [],
|
|
customOverrides = [],
|
|
} = this.config || {};
|
|
const allAliases = {
|
|
...aliases,
|
|
...customAliases,
|
|
...this.runtimeAliases,
|
|
};
|
|
|
|
const { aliasChain, baseModel, resolvedConfig } = this.resolveAliasChain(
|
|
context.model,
|
|
allAliases,
|
|
context.isChatModel,
|
|
);
|
|
|
|
const modelToLevel = this.buildModelLevelMap(aliasChain, baseModel);
|
|
const allOverrides = [
|
|
...overrides,
|
|
...customOverrides,
|
|
...this.runtimeOverrides,
|
|
];
|
|
const matches = this.findMatchingOverrides(
|
|
allOverrides,
|
|
context,
|
|
modelToLevel,
|
|
);
|
|
|
|
this.sortOverrides(matches);
|
|
|
|
let currentConfig: ModelConfig = {
|
|
model: baseModel,
|
|
generateContentConfig: resolvedConfig,
|
|
};
|
|
|
|
for (const match of matches) {
|
|
currentConfig = ModelConfigService.merge(
|
|
currentConfig,
|
|
match.modelConfig,
|
|
);
|
|
}
|
|
|
|
return {
|
|
model: currentConfig.model,
|
|
generateContentConfig: currentConfig.generateContentConfig ?? {},
|
|
};
|
|
}
|
|
|
|
private resolveAliasChain(
|
|
requestedModel: string,
|
|
allAliases: Record<string, ModelConfigAlias>,
|
|
isChatModel?: boolean,
|
|
): {
|
|
aliasChain: string[];
|
|
baseModel: string | undefined;
|
|
resolvedConfig: GenerateContentConfig;
|
|
} {
|
|
const aliasChain: string[] = [];
|
|
|
|
if (allAliases[requestedModel]) {
|
|
let current: string | undefined = requestedModel;
|
|
const visited = new Set<string>();
|
|
while (current) {
|
|
const alias: ModelConfigAlias = allAliases[current];
|
|
if (!alias) {
|
|
throw new Error(`Alias "${current}" not found.`);
|
|
}
|
|
if (visited.size >= MAX_ALIAS_CHAIN_DEPTH) {
|
|
throw new Error(
|
|
`Alias inheritance chain exceeded maximum depth of ${MAX_ALIAS_CHAIN_DEPTH}.`,
|
|
);
|
|
}
|
|
if (visited.has(current)) {
|
|
throw new Error(
|
|
`Circular alias dependency: ${[...visited, current].join(' -> ')}`,
|
|
);
|
|
}
|
|
visited.add(current);
|
|
aliasChain.push(current);
|
|
current = alias.extends;
|
|
}
|
|
|
|
// Root-to-Leaf chain for merging and level assignment.
|
|
const reversedChain = [...aliasChain].reverse();
|
|
let resolvedConfig: ModelConfig = {};
|
|
for (const aliasName of reversedChain) {
|
|
const alias = allAliases[aliasName];
|
|
resolvedConfig = ModelConfigService.merge(
|
|
resolvedConfig,
|
|
alias.modelConfig,
|
|
);
|
|
}
|
|
return {
|
|
aliasChain: reversedChain,
|
|
baseModel: resolvedConfig.model,
|
|
resolvedConfig: resolvedConfig.generateContentConfig ?? {},
|
|
};
|
|
}
|
|
|
|
if (isChatModel) {
|
|
const fallbackAlias = 'chat-base';
|
|
if (allAliases[fallbackAlias]) {
|
|
const fallbackResolution = this.resolveAliasChain(
|
|
fallbackAlias,
|
|
allAliases,
|
|
);
|
|
return {
|
|
aliasChain: [...fallbackResolution.aliasChain, requestedModel],
|
|
baseModel: requestedModel,
|
|
resolvedConfig: fallbackResolution.resolvedConfig,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
aliasChain: [requestedModel],
|
|
baseModel: requestedModel,
|
|
resolvedConfig: {},
|
|
};
|
|
}
|
|
|
|
private buildModelLevelMap(
|
|
aliasChain: string[],
|
|
baseModel: string | undefined,
|
|
): Map<string, number> {
|
|
const modelToLevel = new Map<string, number>();
|
|
// Global and Model name are both level 0.
|
|
if (baseModel) {
|
|
modelToLevel.set(baseModel, 0);
|
|
}
|
|
// Alias chain starts at level 1.
|
|
aliasChain.forEach((name, i) => modelToLevel.set(name, i + 1));
|
|
return modelToLevel;
|
|
}
|
|
|
|
private findMatchingOverrides(
|
|
overrides: ModelConfigOverride[],
|
|
context: ModelConfigKey,
|
|
modelToLevel: Map<string, number>,
|
|
): Array<{
|
|
specificity: number;
|
|
level: number;
|
|
modelConfig: ModelConfig;
|
|
index: number;
|
|
}> {
|
|
return overrides
|
|
.map((override, index) => {
|
|
const matchEntries = Object.entries(override.match);
|
|
if (matchEntries.length === 0) return null;
|
|
|
|
let matchedLevel = 0; // Default to Global
|
|
const isMatch = matchEntries.every(([key, value]) => {
|
|
if (key === 'model') {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const level = modelToLevel.get(value as string);
|
|
if (level === undefined) return false;
|
|
matchedLevel = level;
|
|
return true;
|
|
}
|
|
if (key === 'overrideScope' && value === 'core') {
|
|
return context.overrideScope === 'core' || !context.overrideScope;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
return context[key as keyof ModelConfigKey] === value;
|
|
});
|
|
|
|
return isMatch
|
|
? {
|
|
specificity: matchEntries.length,
|
|
level: matchedLevel,
|
|
modelConfig: override.modelConfig,
|
|
index,
|
|
}
|
|
: null;
|
|
})
|
|
.filter((m): m is NonNullable<typeof m> => m !== null);
|
|
}
|
|
|
|
private sortOverrides(
|
|
matches: Array<{ specificity: number; level: number; index: number }>,
|
|
): void {
|
|
matches.sort((a, b) => {
|
|
if (a.level !== b.level) {
|
|
return a.level - b.level;
|
|
}
|
|
if (a.specificity !== b.specificity) {
|
|
return a.specificity - b.specificity;
|
|
}
|
|
return a.index - b.index;
|
|
});
|
|
}
|
|
|
|
getResolvedConfig(context: ModelConfigKey): ResolvedModelConfig {
|
|
const resolved = this.internalGetResolvedConfig(context);
|
|
|
|
if (!resolved.model) {
|
|
throw new Error(
|
|
`Could not resolve a model name for alias "${context.model}". Please ensure the alias chain or a matching override specifies a model.`,
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
return {
|
|
model: resolved.model,
|
|
generateContentConfig: resolved.generateContentConfig,
|
|
} as ResolvedModelConfig;
|
|
}
|
|
|
|
static isObject(item: unknown): item is Record<string, unknown> {
|
|
return !!item && typeof item === 'object' && !Array.isArray(item);
|
|
}
|
|
|
|
/**
|
|
* Merges an override `ModelConfig` into a base `ModelConfig`.
|
|
* The override's model name takes precedence if provided.
|
|
* The `generateContentConfig` properties are deeply merged.
|
|
*/
|
|
static merge(base: ModelConfig, override: ModelConfig): ModelConfig {
|
|
return {
|
|
model: override.model ?? base.model,
|
|
generateContentConfig: ModelConfigService.deepMerge(
|
|
base.generateContentConfig,
|
|
override.generateContentConfig,
|
|
),
|
|
};
|
|
}
|
|
|
|
static deepMerge(
|
|
config1: GenerateContentConfig | undefined,
|
|
config2: GenerateContentConfig | undefined,
|
|
): GenerateContentConfig {
|
|
return ModelConfigService.genericDeepMerge(
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
config1 as Record<string, unknown> | undefined,
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
config2 as Record<string, unknown> | undefined,
|
|
) as GenerateContentConfig;
|
|
}
|
|
|
|
private static genericDeepMerge(
|
|
...objects: Array<Record<string, unknown> | undefined>
|
|
): Record<string, unknown> {
|
|
return objects.reduce((acc: Record<string, unknown>, obj) => {
|
|
if (!obj) {
|
|
return acc;
|
|
}
|
|
|
|
Object.keys(obj).forEach((key) => {
|
|
const accValue = acc[key];
|
|
const objValue = obj[key];
|
|
|
|
// For now, we only deep merge objects, and not arrays. This is because
|
|
// If we deep merge arrays, there is no way for the user to completely
|
|
// override the base array.
|
|
// TODO(joshualitt): Consider knobs here, i.e. opt-in to deep merging
|
|
// arrays on a case-by-case basis.
|
|
if (
|
|
ModelConfigService.isObject(accValue) &&
|
|
ModelConfigService.isObject(objValue)
|
|
) {
|
|
acc[key] = ModelConfigService.genericDeepMerge(accValue, objValue);
|
|
} else {
|
|
acc[key] = objValue;
|
|
}
|
|
});
|
|
|
|
return acc;
|
|
}, {});
|
|
}
|
|
}
|