feat(core): Add ModelConfigService. (#12556)

This commit is contained in:
joshualitt
2025-11-05 17:18:42 -08:00
committed by GitHub
parent 0f5dd2229c
commit 956ab94452
13 changed files with 1774 additions and 0 deletions

View File

@@ -296,6 +296,20 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Skip the next speaker check.
- **Default:** `true`
#### `modelConfigs`
- **`modelConfigs.aliases`** (object):
- **Description:** Named presets for model configs. Can be used in place of a
model name and can inherit from other aliases using an `extends` property.
- **Default:**
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":-1}}}},"gemini-2.5-pro":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash-lite"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search-tool":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash","generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch-tool":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash","generateContentConfig":{"tools":[{"urlContext":{}}]}}}}`
- **`modelConfigs.overrides`** (array):
- **Description:** Apply specific configuration overrides based on matches,
with a primary key of model (or alias). The most specific match will be
used.
- **Default:** `[]`
#### `context`
- **`context.fileName`** (string | string[]):

View File

@@ -669,6 +669,7 @@ export async function loadCliConfig(
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors ?? false,
ptyInfo: ptyInfo?.name,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: settings.tools?.enableHooks ?? false,
hooks: settings.hooks || {},

View File

@@ -21,6 +21,7 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_GEMINI_MODEL,
DEFAULT_MODEL_CONFIGS,
} from '@google/gemini-cli-core';
import type { CustomTheme } from '../ui/themes/theme.js';
import type { SessionRetentionSettings } from './settings.js';
@@ -680,6 +681,38 @@ const SETTINGS_SCHEMA = {
},
},
modelConfigs: {
type: 'object',
label: 'Model Configs',
category: 'Model',
requiresRestart: false,
default: DEFAULT_MODEL_CONFIGS,
description: 'Model configurations.',
showInDialog: false,
properties: {
aliases: {
type: 'object',
label: 'Model Config Aliases',
category: 'Model',
requiresRestart: false,
default: DEFAULT_MODEL_CONFIGS.aliases,
description:
'Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.',
showInDialog: false,
},
overrides: {
type: 'array',
label: 'Model Config Overrides',
category: 'Model',
requiresRestart: false,
default: [],
description:
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
showInDialog: false,
},
},
},
context: {
type: 'object',
label: 'Context',

View File

@@ -31,6 +31,7 @@ import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js';
import { logRipgrepFallback } from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
@@ -1249,6 +1250,92 @@ describe('BaseLlmClient Lifecycle', () => {
});
});
describe('Generation Config Merging (HACK)', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = {
command: 'docker',
image: 'gemini-cli-sandbox',
};
const TARGET_DIR = '/path/to/target';
const DEBUG_MODE = false;
const QUESTION = 'test question';
const USER_MEMORY = 'Test User Memory';
const TELEMETRY_SETTINGS = { enabled: false };
const EMBEDDING_MODEL = 'gemini-embedding';
const SESSION_ID = 'test-session-id';
const baseParams: ConfigParameters = {
cwd: '/tmp',
embeddingModel: EMBEDDING_MODEL,
sandbox: SANDBOX,
targetDir: TARGET_DIR,
debugMode: DEBUG_MODE,
question: QUESTION,
userMemory: USER_MEMORY,
telemetry: TELEMETRY_SETTINGS,
sessionId: SESSION_ID,
model: MODEL,
usageStatisticsEnabled: false,
};
it('should merge default aliases when user provides only overrides', () => {
const userOverrides = [
{
match: { model: 'test-model' },
modelConfig: { generateContentConfig: { temperature: 0.1 } },
},
];
const params: ConfigParameters = {
...baseParams,
modelConfigServiceConfig: {
overrides: userOverrides,
},
};
const config = new Config(params);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serviceConfig = (config.modelConfigService as any).config;
// Assert that the default aliases are present
expect(serviceConfig.aliases).toEqual(DEFAULT_MODEL_CONFIGS.aliases);
// Assert that the user's overrides are present
expect(serviceConfig.overrides).toEqual(userOverrides);
});
it('should use user-provided aliases if they exist', () => {
const userAliases = {
'my-alias': {
modelConfig: { model: 'my-model' },
},
};
const params: ConfigParameters = {
...baseParams,
modelConfigServiceConfig: {
aliases: userAliases,
},
};
const config = new Config(params);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serviceConfig = (config.modelConfigService as any).config;
// Assert that the user's aliases are used, not the defaults
expect(serviceConfig.aliases).toEqual(userAliases);
});
it('should use default generation config if none is provided', () => {
const params: ConfigParameters = { ...baseParams };
const config = new Config(params);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serviceConfig = (config.modelConfigService as any).config;
// Assert that the full default config is used
expect(serviceConfig).toEqual(DEFAULT_MODEL_CONFIGS);
});
});
describe('Config getHooks', () => {
const baseParams: ConfigParameters = {
cwd: '/tmp',

View File

@@ -62,6 +62,9 @@ import { RipgrepFallbackEvent } from '../telemetry/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
import { OutputFormat } from '../output/types.js';
import type { ModelConfigServiceConfig } from '../services/modelConfigService.js';
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
// Re-export OAuth config type
export type { MCPOAuthConfig, AnyToolInvocation };
@@ -291,6 +294,7 @@ export interface ConfigParameters {
recordResponses?: string;
ptyInfo?: string;
disableYoloMode?: boolean;
modelConfigServiceConfig?: ModelConfigServiceConfig;
enableHooks?: boolean;
experiments?: Experiments;
hooks?: {
@@ -309,6 +313,7 @@ export class Config {
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
readonly modelConfigService: ModelConfigService;
private readonly embeddingModel: string;
private readonly sandbox: SandboxConfig | undefined;
private readonly targetDir: string;
@@ -560,6 +565,25 @@ export class Config {
}
this.geminiClient = new GeminiClient(this);
this.modelRouterService = new ModelRouterService(this);
// HACK: The settings loading logic doesn't currently merge the default
// generation config with the user's settings. This means if a user provides
// any `generation` settings (e.g., just `overrides`), the default `aliases`
// are lost. This hack manually merges the default aliases back in if they
// are missing from the user's config.
// TODO(12593): Fix the settings loading logic to properly merge defaults and
// remove this hack.
let modelConfigServiceConfig = params.modelConfigServiceConfig;
if (modelConfigServiceConfig && !modelConfigServiceConfig.aliases) {
modelConfigServiceConfig = {
...modelConfigServiceConfig,
aliases: DEFAULT_MODEL_CONFIGS.aliases,
};
}
this.modelConfigService = new ModelConfigService(
modelConfigServiceConfig ?? DEFAULT_MODEL_CONFIGS,
);
}
/**

View File

@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ModelConfigServiceConfig } from '../services/modelConfigService.js';
// The default model configs. We use `base` as the parent for all of our model
// configs, while `chat-base`, a child of `base`, is the parent of the models
// we use in the "chat" experience.
export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
aliases: {
base: {
modelConfig: {
generateContentConfig: {
temperature: 0,
topP: 1,
},
},
},
'chat-base': {
extends: 'base',
modelConfig: {
generateContentConfig: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: -1,
},
},
},
},
// Because `gemini-2.5-pro` and related model configs are "user-facing"
// today, i.e. they could be passed via `--model`, we have to be careful to
// ensure these model configs can be used interactively.
// TODO(joshualitt): Introduce internal base configs for the various models,
// note: we will have to think carefully about names.
'gemini-2.5-pro': {
extends: 'chat-base',
modelConfig: {
model: 'gemini-2.5-pro',
},
},
'gemini-2.5-flash': {
extends: 'chat-base',
modelConfig: {
model: 'gemini-2.5-flash',
},
},
'gemini-2.5-flash-lite': {
extends: 'chat-base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
},
},
classifier: {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
generateContentConfig: {
maxOutputTokens: 1024,
thinkingConfig: {
thinkingBudget: 512,
},
},
},
},
'prompt-completion': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
generateContentConfig: {
temperature: 0.3,
maxOutputTokens: 16000,
thinkingConfig: {
thinkingBudget: 0,
},
},
},
},
'edit-corrector': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
generateContentConfig: {
thinkingConfig: {
thinkingBudget: 0,
},
},
},
},
'summarizer-default': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
generateContentConfig: {
maxOutputTokens: 2000,
},
},
},
'summarizer-shell': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
generateContentConfig: {
maxOutputTokens: 2000,
},
},
},
'web-search-tool': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash',
generateContentConfig: {
tools: [{ googleSearch: {} }],
},
},
},
'web-fetch-tool': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash',
generateContentConfig: {
tools: [{ urlContext: {} }],
},
},
},
},
};

View File

@@ -6,6 +6,7 @@
// Export config
export * from './config/config.js';
export * from './config/defaultModelConfigs.js';
export * from './output/types.js';
export * from './output/json-formatter.js';
export * from './output/stream-json-formatter.js';

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { ModelConfigService } from './modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js';
const GOLDEN_FILE_PATH = path.resolve(
process.cwd(),
'src',
'services',
'test-data',
'resolved-aliases.golden.json',
);
describe('ModelConfigService Golden Test', () => {
it('should match the golden file for resolved default aliases', async () => {
const service = new ModelConfigService(DEFAULT_MODEL_CONFIGS);
const aliases = Object.keys(DEFAULT_MODEL_CONFIGS.aliases ?? {});
const resolvedAliases: Record<string, unknown> = {};
for (const alias of aliases) {
resolvedAliases[alias] =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).internalGetResolvedConfig({ model: alias });
}
if (process.env['UPDATE_GOLDENS']) {
await fs.mkdir(path.dirname(GOLDEN_FILE_PATH), { recursive: true });
await fs.writeFile(
GOLDEN_FILE_PATH,
JSON.stringify(resolvedAliases, null, 2),
'utf-8',
);
// In update mode, we pass the test after writing the file.
return;
}
let goldenContent: string;
try {
goldenContent = await fs.readFile(GOLDEN_FILE_PATH, 'utf-8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(
'Golden file not found. Run with `UPDATE_GOLDENS=true` to create it.',
);
}
throw e;
}
const goldenData = JSON.parse(goldenContent);
expect(
resolvedAliases,
'Golden file mismatch. If the new resolved aliases are correct, run the test with `UPDATE_GOLDENS=true` to regenerate the golden file.',
).toEqual(goldenData);
});
});

View File

@@ -0,0 +1,234 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ModelConfigService } from './modelConfigService.js';
import type { ModelConfigServiceConfig } from './modelConfigService.js';
// This test suite is designed to validate the end-to-end logic of the
// ModelConfigService with a complex, realistic configuration.
// It tests the interplay of global settings, alias inheritance, and overrides
// of varying specificities.
describe('ModelConfigService Integration', () => {
const complexConfig: ModelConfigServiceConfig = {
aliases: {
// Abstract base with no model
base: {
modelConfig: {
generateContentConfig: {
topP: 0.95,
topK: 64,
},
},
},
'default-text-model': {
extends: 'base',
modelConfig: {
model: 'gemini-1.5-pro-latest',
generateContentConfig: {
topK: 40, // Override base
},
},
},
'creative-writer': {
extends: 'default-text-model',
modelConfig: {
generateContentConfig: {
temperature: 0.9, // Override global
topK: 50, // Override parent
},
},
},
'fast-classifier': {
extends: 'base',
modelConfig: {
model: 'gemini-1.5-flash-latest',
generateContentConfig: {
temperature: 0.1,
candidateCount: 4,
},
},
},
},
overrides: [
// Broad override for all flash models
{
match: { model: 'gemini-1.5-flash-latest' },
modelConfig: {
generateContentConfig: {
maxOutputTokens: 2048,
},
},
},
// Specific override for the 'core' agent
{
match: { overrideScope: 'core' },
modelConfig: {
generateContentConfig: {
temperature: 0.5,
stopSequences: ['AGENT_STOP'],
},
},
},
// Highly specific override for the 'fast-classifier' when used by the 'core' agent
{
match: { model: 'fast-classifier', overrideScope: 'core' },
modelConfig: {
generateContentConfig: {
temperature: 0.0,
maxOutputTokens: 4096,
},
},
},
// Override to provide a model for the abstract alias
{
match: { model: 'base', overrideScope: 'core' },
modelConfig: {
model: 'gemini-1.5-pro-latest',
},
},
],
};
const service = new ModelConfigService(complexConfig);
it('should resolve a simple model, applying core agent defaults', () => {
const resolved = service.getResolvedConfig({
model: 'gemini-test-model',
});
expect(resolved.model).toBe('gemini-test-model');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.5, // from agent override
stopSequences: ['AGENT_STOP'], // from agent override
});
});
it('should correctly apply a simple inherited alias and merge with global defaults', () => {
const resolved = service.getResolvedConfig({
model: 'default-text-model',
});
expect(resolved.model).toBe('gemini-1.5-pro-latest'); // from alias
expect(resolved.generateContentConfig).toEqual({
temperature: 0.5, // from agent override
topP: 0.95, // from base
topK: 40, // from alias
stopSequences: ['AGENT_STOP'], // from agent override
});
});
it('should resolve a multi-level inherited alias', () => {
const resolved = service.getResolvedConfig({
model: 'creative-writer',
});
expect(resolved.model).toBe('gemini-1.5-pro-latest'); // from default-text-model
expect(resolved.generateContentConfig).toEqual({
temperature: 0.5, // from agent override
topP: 0.95, // from base
topK: 50, // from alias
stopSequences: ['AGENT_STOP'], // from agent override
});
});
it('should apply an inherited alias and a broad model-based override', () => {
const resolved = service.getResolvedConfig({
model: 'fast-classifier',
// No agent specified, so it should match core agent-specific rules
});
expect(resolved.model).toBe('gemini-1.5-flash-latest'); // from alias
expect(resolved.generateContentConfig).toEqual({
topP: 0.95, // from base
topK: 64, // from base
candidateCount: 4, // from alias
stopSequences: ['AGENT_STOP'], // from agent override
maxOutputTokens: 4096, // from most specific override
temperature: 0.0, // from most specific override
});
});
it('should apply settings for an unknown model but a known agent', () => {
const resolved = service.getResolvedConfig({
model: 'gemini-test-model',
overrideScope: 'core',
});
expect(resolved.model).toBe('gemini-test-model');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.5, // from agent override
stopSequences: ['AGENT_STOP'], // from agent override
});
});
it('should apply the most specific override for a known inherited alias and agent', () => {
const resolved = service.getResolvedConfig({
model: 'fast-classifier',
overrideScope: 'core',
});
expect(resolved.model).toBe('gemini-1.5-flash-latest');
expect(resolved.generateContentConfig).toEqual({
// Inherited from 'base'
topP: 0.95,
topK: 64,
// From 'fast-classifier' alias
candidateCount: 4,
// From 'core' agent override
stopSequences: ['AGENT_STOP'],
// From most specific override (model+agent)
temperature: 0.0,
maxOutputTokens: 4096,
});
});
it('should correctly apply agent override on top of a multi-level inherited alias', () => {
const resolved = service.getResolvedConfig({
model: 'creative-writer',
overrideScope: 'core',
});
expect(resolved.model).toBe('gemini-1.5-pro-latest'); // from default-text-model
expect(resolved.generateContentConfig).toEqual({
temperature: 0.5, // from agent override (wins over alias)
topP: 0.95, // from base
topK: 50, // from creative-writer alias
stopSequences: ['AGENT_STOP'], // from agent override
});
});
it('should resolve an abstract alias if a specific override provides the model', () => {
const resolved = service.getResolvedConfig({
model: 'base',
overrideScope: 'core',
});
expect(resolved.model).toBe('gemini-1.5-pro-latest'); // from override
expect(resolved.generateContentConfig).toEqual({
temperature: 0.5, // from agent override
topP: 0.95, // from base alias
topK: 64, // from base alias
stopSequences: ['AGENT_STOP'], // from agent override
});
});
it('should not apply core agent overrides when a different agent is specified', () => {
const resolved = service.getResolvedConfig({
model: 'fast-classifier',
overrideScope: 'non-core-agent',
});
expect(resolved.model).toBe('gemini-1.5-flash-latest');
expect(resolved.generateContentConfig).toEqual({
candidateCount: 4, // from alias
maxOutputTokens: 2048, // from override of model
temperature: 0.1, // from alias
topK: 64, // from base
topP: 0.95, // from base
});
});
});

View File

@@ -0,0 +1,553 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import type { ModelConfigServiceConfig } from './modelConfigService.js';
import { ModelConfigService } from './modelConfigService.js';
describe('ModelConfigService', () => {
it('should resolve a basic alias to its model and settings', () => {
const config: ModelConfigServiceConfig = {
aliases: {
classifier: {
modelConfig: {
model: 'gemini-1.5-flash-latest',
generateContentConfig: {
temperature: 0,
topP: 0.9,
},
},
},
},
overrides: [],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'classifier' });
expect(resolved.model).toBe('gemini-1.5-flash-latest');
expect(resolved.generateContentConfig).toEqual({
temperature: 0,
topP: 0.9,
});
});
it('should apply a simple override on top of an alias', () => {
const config: ModelConfigServiceConfig = {
aliases: {
classifier: {
modelConfig: {
model: 'gemini-1.5-flash-latest',
generateContentConfig: {
temperature: 0,
topP: 0.9,
},
},
},
},
overrides: [
{
match: { model: 'classifier' },
modelConfig: {
generateContentConfig: {
temperature: 0.5,
maxOutputTokens: 1000,
},
},
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'classifier' });
expect(resolved.model).toBe('gemini-1.5-flash-latest');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.5,
topP: 0.9,
maxOutputTokens: 1000,
});
});
it('should apply the most specific override rule', () => {
const config: ModelConfigServiceConfig = {
aliases: {},
overrides: [
{
match: { model: 'gemini-pro' },
modelConfig: { generateContentConfig: { temperature: 0.5 } },
},
{
match: { model: 'gemini-pro', overrideScope: 'my-agent' },
modelConfig: { generateContentConfig: { temperature: 0.1 } },
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({
model: 'gemini-pro',
overrideScope: 'my-agent',
});
expect(resolved.model).toBe('gemini-pro');
expect(resolved.generateContentConfig).toEqual({ temperature: 0.1 });
});
it('should use the last override in case of a tie in specificity', () => {
const config: ModelConfigServiceConfig = {
aliases: {},
overrides: [
{
match: { model: 'gemini-pro' },
modelConfig: {
generateContentConfig: { temperature: 0.5, topP: 0.8 },
},
},
{
match: { model: 'gemini-pro' },
modelConfig: { generateContentConfig: { temperature: 0.1 } },
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'gemini-pro' });
expect(resolved.model).toBe('gemini-pro');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.1,
topP: 0.8,
});
});
it('should correctly pass through generation config from an alias', () => {
const config: ModelConfigServiceConfig = {
aliases: {
'thinking-alias': {
modelConfig: {
model: 'gemini-pro',
generateContentConfig: {
candidateCount: 500,
},
},
},
},
overrides: [],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'thinking-alias' });
expect(resolved.generateContentConfig).toEqual({ candidateCount: 500 });
});
it('should let an override generation config win over an alias config', () => {
const config: ModelConfigServiceConfig = {
aliases: {
'thinking-alias': {
modelConfig: {
model: 'gemini-pro',
generateContentConfig: {
candidateCount: 500,
},
},
},
},
overrides: [
{
match: { model: 'thinking-alias' },
modelConfig: {
generateContentConfig: {
candidateCount: 1000,
},
},
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'thinking-alias' });
expect(resolved.generateContentConfig).toEqual({
candidateCount: 1000,
});
});
it('should merge settings from global, alias, and multiple matching overrides', () => {
const config: ModelConfigServiceConfig = {
aliases: {
'test-alias': {
modelConfig: {
model: 'gemini-test-model',
generateContentConfig: {
topP: 0.9,
topK: 50,
},
},
},
},
overrides: [
{
match: { model: 'gemini-test-model' },
modelConfig: {
generateContentConfig: {
topK: 40,
maxOutputTokens: 2048,
},
},
},
{
match: { overrideScope: 'test-agent' },
modelConfig: {
generateContentConfig: {
maxOutputTokens: 4096,
},
},
},
{
match: { model: 'gemini-test-model', overrideScope: 'test-agent' },
modelConfig: {
generateContentConfig: {
temperature: 0.2,
},
},
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({
model: 'test-alias',
overrideScope: 'test-agent',
});
expect(resolved.model).toBe('gemini-test-model');
expect(resolved.generateContentConfig).toEqual({
// From global, overridden by most specific override
temperature: 0.2,
// From alias, not overridden
topP: 0.9,
// From alias, overridden by less specific override
topK: 40,
// From first matching override, overridden by second matching override
maxOutputTokens: 4096,
});
});
it('should match an agent:core override when agent is undefined', () => {
const config: ModelConfigServiceConfig = {
aliases: {},
overrides: [
{
match: { overrideScope: 'core' },
modelConfig: {
generateContentConfig: {
temperature: 0.1,
},
},
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({
model: 'gemini-pro',
overrideScope: undefined, // Explicitly undefined
});
expect(resolved.model).toBe('gemini-pro');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.1,
});
});
describe('alias inheritance', () => {
it('should resolve a simple "extends" chain', () => {
const config: ModelConfigServiceConfig = {
aliases: {
base: {
modelConfig: {
model: 'gemini-1.5-pro-latest',
generateContentConfig: {
temperature: 0.7,
topP: 0.9,
},
},
},
'flash-variant': {
extends: 'base',
modelConfig: {
model: 'gemini-1.5-flash-latest',
},
},
},
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'flash-variant' });
expect(resolved.model).toBe('gemini-1.5-flash-latest');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.7,
topP: 0.9,
});
});
it('should override parent properties from child alias', () => {
const config: ModelConfigServiceConfig = {
aliases: {
base: {
modelConfig: {
model: 'gemini-1.5-pro-latest',
generateContentConfig: {
temperature: 0.7,
topP: 0.9,
},
},
},
'flash-variant': {
extends: 'base',
modelConfig: {
model: 'gemini-1.5-flash-latest',
generateContentConfig: {
temperature: 0.2,
},
},
},
},
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'flash-variant' });
expect(resolved.model).toBe('gemini-1.5-flash-latest');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.2,
topP: 0.9,
});
});
it('should resolve a multi-level "extends" chain', () => {
const config: ModelConfigServiceConfig = {
aliases: {
base: {
modelConfig: {
model: 'gemini-1.5-pro-latest',
generateContentConfig: {
temperature: 0.7,
topP: 0.9,
},
},
},
'base-flash': {
extends: 'base',
modelConfig: {
model: 'gemini-1.5-flash-latest',
},
},
'classifier-flash': {
extends: 'base-flash',
modelConfig: {
generateContentConfig: {
temperature: 0,
},
},
},
},
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({
model: 'classifier-flash',
});
expect(resolved.model).toBe('gemini-1.5-flash-latest');
expect(resolved.generateContentConfig).toEqual({
temperature: 0,
topP: 0.9,
});
});
it('should throw an error for circular dependencies', () => {
const config: ModelConfigServiceConfig = {
aliases: {
a: { extends: 'b', modelConfig: {} },
b: { extends: 'a', modelConfig: {} },
},
};
const service = new ModelConfigService(config);
expect(() => service.getResolvedConfig({ model: 'a' })).toThrow(
'Circular alias dependency: a -> b -> a',
);
});
describe('abstract aliases', () => {
it('should allow an alias to extend an abstract alias without a model', () => {
const config: ModelConfigServiceConfig = {
aliases: {
'abstract-base': {
modelConfig: {
generateContentConfig: {
temperature: 0.1,
},
},
},
'concrete-child': {
extends: 'abstract-base',
modelConfig: {
model: 'gemini-1.5-pro-latest',
generateContentConfig: {
topP: 0.9,
},
},
},
},
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'concrete-child' });
expect(resolved.model).toBe('gemini-1.5-pro-latest');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.1,
topP: 0.9,
});
});
it('should throw an error if a resolved alias chain has no model', () => {
const config: ModelConfigServiceConfig = {
aliases: {
'abstract-base': {
modelConfig: {
generateContentConfig: { temperature: 0.7 },
},
},
},
};
const service = new ModelConfigService(config);
expect(() =>
service.getResolvedConfig({ model: 'abstract-base' }),
).toThrow(
'Could not resolve a model name for alias "abstract-base". Please ensure the alias chain or a matching override specifies a model.',
);
});
it('should resolve an abstract alias if an override provides the model', () => {
const config: ModelConfigServiceConfig = {
aliases: {
'abstract-base': {
modelConfig: {
generateContentConfig: {
temperature: 0.1,
},
},
},
},
overrides: [
{
match: { model: 'abstract-base' },
modelConfig: {
model: 'gemini-1.5-flash-latest',
},
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'abstract-base' });
expect(resolved.model).toBe('gemini-1.5-flash-latest');
expect(resolved.generateContentConfig).toEqual({
temperature: 0.1,
});
});
});
it('should throw an error if an extended alias does not exist', () => {
const config: ModelConfigServiceConfig = {
aliases: {
'bad-alias': {
extends: 'non-existent',
modelConfig: {},
},
},
};
const service = new ModelConfigService(config);
expect(() => service.getResolvedConfig({ model: 'bad-alias' })).toThrow(
'Alias "non-existent" not found.',
);
});
});
describe('deep merging', () => {
it('should deep merge nested config objects from aliases and overrides', () => {
const config: ModelConfigServiceConfig = {
aliases: {
'base-safe': {
modelConfig: {
model: 'gemini-pro',
generateContentConfig: {
safetySettings: {
HARM_CATEGORY_HARASSMENT: 'BLOCK_ONLY_HIGH',
HARM_CATEGORY_HATE_SPEECH: 'BLOCK_ONLY_HIGH',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
},
},
},
overrides: [
{
match: { model: 'base-safe' },
modelConfig: {
generateContentConfig: {
safetySettings: {
HARM_CATEGORY_HATE_SPEECH: 'BLOCK_NONE',
HARM_CATEGORY_SEXUALLY_EXPLICIT: 'BLOCK_MEDIUM_AND_ABOVE',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
},
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'base-safe' });
expect(resolved.model).toBe('gemini-pro');
expect(resolved.generateContentConfig.safetySettings).toEqual({
// From alias
HARM_CATEGORY_HARASSMENT: 'BLOCK_ONLY_HIGH',
// From alias, overridden by override
HARM_CATEGORY_HATE_SPEECH: 'BLOCK_NONE',
// From override
HARM_CATEGORY_SEXUALLY_EXPLICIT: 'BLOCK_MEDIUM_AND_ABOVE',
});
});
it('should not deeply merge merge arrays from aliases and overrides', () => {
const config: ModelConfigServiceConfig = {
aliases: {
base: {
modelConfig: {
model: 'gemini-pro',
generateContentConfig: {
stopSequences: ['foo'],
},
},
},
},
overrides: [
{
match: { model: 'base' },
modelConfig: {
generateContentConfig: {
stopSequences: ['overrideFoo'],
},
},
},
],
};
const service = new ModelConfigService(config);
const resolved = service.getResolvedConfig({ model: 'base' });
expect(resolved.model).toBe('gemini-pro');
expect(resolved.generateContentConfig.stopSequences).toEqual([
'overrideFoo',
]);
});
});
});

View File

@@ -0,0 +1,248 @@
/**
* @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;
}
export interface ModelConfig {
model?: string;
generateContentConfig?: GenerateContentConfig;
}
export interface ModelConfigOverride {
match: {
model?: string; // Can be a model name or an alias
overrideScope?: string;
};
modelConfig: ModelConfig;
}
export interface ModelConfigAlias {
extends?: string;
modelConfig: ModelConfig;
}
export interface ModelConfigServiceConfig {
aliases?: Record<string, ModelConfigAlias>;
overrides?: ModelConfigOverride[];
}
export type ResolvedModelConfig = _ResolvedModelConfig & {
readonly _brand: unique symbol;
};
export interface _ResolvedModelConfig {
model: string; // The actual, resolved model name
generateContentConfig: GenerateContentConfig;
}
export class ModelConfigService {
// TODO(12597): Process config to build a typed alias hierarchy.
constructor(private readonly config: ModelConfigServiceConfig) {}
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 = {}, overrides = [] } = config;
let baseModel: string | undefined = context.model;
let resolvedConfig: GenerateContentConfig = {};
// Step 1: Alias Resolution
if (aliases[context.model]) {
const resolvedAlias = this.resolveAlias(context.model, aliases);
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 = overrides
.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 as Record<string, unknown>,
objValue as Record<string, unknown>,
);
} else {
acc[key] = objValue;
}
});
return acc;
}, {});
}
}

View File

@@ -0,0 +1,123 @@
{
"base": {
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"chat-base": {
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"thinkingConfig": {
"includeThoughts": true,
"thinkingBudget": -1
}
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"thinkingConfig": {
"includeThoughts": true,
"thinkingBudget": -1
}
}
},
"gemini-2.5-flash": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"thinkingConfig": {
"includeThoughts": true,
"thinkingBudget": -1
}
}
},
"gemini-2.5-flash-lite": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"thinkingConfig": {
"includeThoughts": true,
"thinkingBudget": -1
}
}
},
"classifier": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"maxOutputTokens": 1024,
"thinkingConfig": {
"thinkingBudget": 512
}
}
},
"prompt-completion": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0.3,
"topP": 1,
"maxOutputTokens": 16000,
"thinkingConfig": {
"thinkingBudget": 0
}
}
},
"edit-corrector": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"thinkingConfig": {
"thinkingBudget": 0
}
}
},
"summarizer-default": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"maxOutputTokens": 2000
}
},
"summarizer-shell": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"maxOutputTokens": 2000
}
},
"web-search-tool": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"tools": [
{
"googleSearch": {}
}
]
}
},
"web-fetch-tool": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
"temperature": 0,
"topP": 1,
"tools": [
{
"urlContext": {}
}
]
}
}
}

View File

@@ -412,6 +412,270 @@
},
"additionalProperties": false
},
"modelConfigs": {
"title": "Model Configs",
"description": "Model configurations.",
"markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\"aliases\":{\"base\":{\"modelConfig\":{\"generateContentConfig\":{\"temperature\":0,\"topP\":1}}},\"chat-base\":{\"extends\":\"base\",\"modelConfig\":{\"generateContentConfig\":{\"thinkingConfig\":{\"includeThoughts\":true,\"thinkingBudget\":-1}}}},\"gemini-2.5-pro\":{\"extends\":\"chat-base\",\"modelConfig\":{\"model\":\"gemini-2.5-pro\"}},\"gemini-2.5-flash\":{\"extends\":\"chat-base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\"}},\"gemini-2.5-flash-lite\":{\"extends\":\"chat-base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\"}},\"classifier\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":1024,\"thinkingConfig\":{\"thinkingBudget\":512}}}},\"prompt-completion\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"temperature\":0.3,\"maxOutputTokens\":16000,\"thinkingConfig\":{\"thinkingBudget\":0}}}},\"edit-corrector\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}},\"summarizer-default\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":2000}}},\"summarizer-shell\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":2000}}},\"web-search-tool\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\",\"generateContentConfig\":{\"tools\":[{\"googleSearch\":{}}]}}},\"web-fetch-tool\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\",\"generateContentConfig\":{\"tools\":[{\"urlContext\":{}}]}}}}}`",
"default": {
"aliases": {
"base": {
"modelConfig": {
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
}
},
"chat-base": {
"extends": "base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"includeThoughts": true,
"thinkingBudget": -1
}
}
}
},
"gemini-2.5-pro": {
"extends": "chat-base",
"modelConfig": {
"model": "gemini-2.5-pro"
}
},
"gemini-2.5-flash": {
"extends": "chat-base",
"modelConfig": {
"model": "gemini-2.5-flash"
}
},
"gemini-2.5-flash-lite": {
"extends": "chat-base",
"modelConfig": {
"model": "gemini-2.5-flash-lite"
}
},
"classifier": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 1024,
"thinkingConfig": {
"thinkingBudget": 512
}
}
}
},
"prompt-completion": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0.3,
"maxOutputTokens": 16000,
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"edit-corrector": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"summarizer-default": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
}
},
"summarizer-shell": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
}
},
"web-search-tool": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
"tools": [
{
"googleSearch": {}
}
]
}
}
},
"web-fetch-tool": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
"tools": [
{
"urlContext": {}
}
]
}
}
}
}
},
"type": "object",
"properties": {
"aliases": {
"title": "Model Config Aliases",
"description": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.",
"markdownDescription": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\"base\":{\"modelConfig\":{\"generateContentConfig\":{\"temperature\":0,\"topP\":1}}},\"chat-base\":{\"extends\":\"base\",\"modelConfig\":{\"generateContentConfig\":{\"thinkingConfig\":{\"includeThoughts\":true,\"thinkingBudget\":-1}}}},\"gemini-2.5-pro\":{\"extends\":\"chat-base\",\"modelConfig\":{\"model\":\"gemini-2.5-pro\"}},\"gemini-2.5-flash\":{\"extends\":\"chat-base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\"}},\"gemini-2.5-flash-lite\":{\"extends\":\"chat-base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\"}},\"classifier\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":1024,\"thinkingConfig\":{\"thinkingBudget\":512}}}},\"prompt-completion\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"temperature\":0.3,\"maxOutputTokens\":16000,\"thinkingConfig\":{\"thinkingBudget\":0}}}},\"edit-corrector\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}},\"summarizer-default\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":2000}}},\"summarizer-shell\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":2000}}},\"web-search-tool\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\",\"generateContentConfig\":{\"tools\":[{\"googleSearch\":{}}]}}},\"web-fetch-tool\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\",\"generateContentConfig\":{\"tools\":[{\"urlContext\":{}}]}}}}`",
"default": {
"base": {
"modelConfig": {
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
}
},
"chat-base": {
"extends": "base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"includeThoughts": true,
"thinkingBudget": -1
}
}
}
},
"gemini-2.5-pro": {
"extends": "chat-base",
"modelConfig": {
"model": "gemini-2.5-pro"
}
},
"gemini-2.5-flash": {
"extends": "chat-base",
"modelConfig": {
"model": "gemini-2.5-flash"
}
},
"gemini-2.5-flash-lite": {
"extends": "chat-base",
"modelConfig": {
"model": "gemini-2.5-flash-lite"
}
},
"classifier": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 1024,
"thinkingConfig": {
"thinkingBudget": 512
}
}
}
},
"prompt-completion": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0.3,
"maxOutputTokens": 16000,
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"edit-corrector": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"summarizer-default": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
}
},
"summarizer-shell": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
}
},
"web-search-tool": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
"tools": [
{
"googleSearch": {}
}
]
}
}
},
"web-fetch-tool": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
"tools": [
{
"urlContext": {}
}
]
}
}
}
},
"type": "object",
"additionalProperties": true
},
"overrides": {
"title": "Model Config Overrides",
"description": "Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.",
"markdownDescription": "Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `[]`",
"default": [],
"type": "array",
"items": {}
}
},
"additionalProperties": false
},
"context": {
"title": "Context",
"description": "Settings for managing context provided to the model.",