Files
gemini-cli/packages/core/src/services/modelConfigService.ts

280 lines
8.8 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentConfig } from '@google/genai';
// 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;
}
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;
}
export interface ModelConfigServiceConfig {
aliases?: Record<string, ModelConfigAlias>;
customAliases?: Record<string, ModelConfigAlias>;
overrides?: ModelConfigOverride[];
customOverrides?: ModelConfigOverride[];
}
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) {}
registerRuntimeModelConfig(aliasName: string, alias: ModelConfigAlias): void {
this.runtimeAliases[aliasName] = alias;
}
registerRuntimeModelOverride(override: ModelConfigOverride): void {
this.runtimeOverrides.push(override);
}
private resolveAlias(
aliasName: string,
aliases: Record<string, ModelConfigAlias>,
visited = new Set<string>(),
): ModelConfigAlias {
if (visited.has(aliasName)) {
throw new Error(
`Circular alias dependency: ${[...visited, aliasName].join(' -> ')}`,
);
}
visited.add(aliasName);
const alias = aliases[aliasName];
if (!alias) {
throw new Error(`Alias "${aliasName}" not found.`);
}
if (!alias.extends) {
return alias;
}
const baseAlias = this.resolveAlias(alias.extends, aliases, visited);
return {
modelConfig: {
model: alias.modelConfig.model ?? baseAlias.modelConfig.model,
generateContentConfig: this.deepMerge(
baseAlias.modelConfig.generateContentConfig,
alias.modelConfig.generateContentConfig,
),
},
};
}
private internalGetResolvedConfig(context: ModelConfigKey): {
model: string | undefined;
generateContentConfig: GenerateContentConfig;
} {
const config = this.config || {};
const {
aliases = {},
customAliases = {},
overrides = [],
customOverrides = [],
} = config;
const allAliases = {
...aliases,
...customAliases,
...this.runtimeAliases,
};
const allOverrides = [
...overrides,
...customOverrides,
...this.runtimeOverrides,
];
let baseModel: string | undefined = context.model;
let resolvedConfig: GenerateContentConfig = {};
// Step 1: Alias Resolution
if (allAliases[context.model]) {
const resolvedAlias = this.resolveAlias(context.model, allAliases);
baseModel = resolvedAlias.modelConfig.model; // This can now be undefined
resolvedConfig = this.deepMerge(
resolvedConfig,
resolvedAlias.modelConfig.generateContentConfig,
);
}
// If an alias was used but didn't resolve to a model, `baseModel` is undefined.
// We still need a model for matching overrides. We'll use the original alias name
// for matching if no model is resolved yet.
const modelForMatching = baseModel ?? context.model;
const finalContext = {
...context,
model: modelForMatching,
};
// Step 2: Override Application
const matches = allOverrides
.map((override, index) => {
const matchEntries = Object.entries(override.match);
if (matchEntries.length === 0) {
return null;
}
const isMatch = matchEntries.every(([key, value]) => {
if (key === 'model') {
return value === context.model || value === finalContext.model;
}
if (key === 'overrideScope' && value === 'core') {
// The 'core' overrideScope is special. It should match if the
// overrideScope is explicitly 'core' or if the overrideScope
// is not specified.
return context.overrideScope === 'core' || !context.overrideScope;
}
return finalContext[key as keyof ModelConfigKey] === value;
});
if (isMatch) {
return {
specificity: matchEntries.length,
modelConfig: override.modelConfig,
index,
};
}
return null;
})
.filter((match): match is NonNullable<typeof match> => match !== null);
// The override application logic is designed to be both simple and powerful.
// By first sorting all matching overrides by specificity (and then by their
// original order as a tie-breaker), we ensure that as we merge the `config`
// objects, the settings from the most specific rules are applied last,
// correctly overwriting any values from broader, less-specific rules.
// This achieves a per-property override effect without complex per-property logic.
matches.sort((a, b) => {
if (a.specificity !== b.specificity) {
return a.specificity - b.specificity;
}
return a.index - b.index;
});
// Apply matching overrides
for (const match of matches) {
if (match.modelConfig.model) {
baseModel = match.modelConfig.model;
}
if (match.modelConfig.generateContentConfig) {
resolvedConfig = this.deepMerge(
resolvedConfig,
match.modelConfig.generateContentConfig,
);
}
}
return {
model: baseModel,
generateContentConfig: resolvedConfig,
};
}
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.`,
);
}
return {
model: resolved.model,
generateContentConfig: resolved.generateContentConfig,
} as ResolvedModelConfig;
}
private isObject(item: unknown): item is Record<string, unknown> {
return !!item && typeof item === 'object' && !Array.isArray(item);
}
private deepMerge(
config1: GenerateContentConfig | undefined,
config2: GenerateContentConfig | undefined,
): Record<string, unknown> {
return this.genericDeepMerge(
config1 as Record<string, unknown> | undefined,
config2 as Record<string, unknown> | undefined,
);
}
private 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 (this.isObject(accValue) && this.isObject(objValue)) {
acc[key] = this.deepMerge(accValue, objValue);
} else {
acc[key] = objValue;
}
});
return acc;
}, {});
}
}