mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
280 lines
8.8 KiB
TypeScript
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;
|
|
}, {});
|
|
}
|
|
}
|