From 956ab94452abb94c55f9a680c558662b82fe6d57 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Wed, 5 Nov 2025 17:18:42 -0800 Subject: [PATCH] feat(core): Add ModelConfigService. (#12556) --- docs/get-started/configuration.md | 14 + packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 33 ++ packages/core/src/config/config.test.ts | 87 +++ packages/core/src/config/config.ts | 24 + .../core/src/config/defaultModelConfigs.ts | 129 ++++ packages/core/src/index.ts | 1 + .../src/services/modelConfig.golden.test.ts | 63 ++ .../services/modelConfig.integration.test.ts | 234 ++++++++ .../src/services/modelConfigService.test.ts | 553 ++++++++++++++++++ .../core/src/services/modelConfigService.ts | 248 ++++++++ .../test-data/resolved-aliases.golden.json | 123 ++++ schemas/settings.schema.json | 264 +++++++++ 13 files changed, 1774 insertions(+) create mode 100644 packages/core/src/config/defaultModelConfigs.ts create mode 100644 packages/core/src/services/modelConfig.golden.test.ts create mode 100644 packages/core/src/services/modelConfig.integration.test.ts create mode 100644 packages/core/src/services/modelConfigService.test.ts create mode 100644 packages/core/src/services/modelConfigService.ts create mode 100644 packages/core/src/services/test-data/resolved-aliases.golden.json diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index d65ec509a9..8d17f10c78 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -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[]): diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 553a1ce760..a241a27d51 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 || {}, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ea6ca29fa2..8d4e459f58 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 23cea2d1b8..9f4650be07 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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(); @@ -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', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 676b4e32fa..5dcaf503e0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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, + ); } /** diff --git a/packages/core/src/config/defaultModelConfigs.ts b/packages/core/src/config/defaultModelConfigs.ts new file mode 100644 index 0000000000..3ee1730def --- /dev/null +++ b/packages/core/src/config/defaultModelConfigs.ts @@ -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: {} }], + }, + }, + }, + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 513fae847d..a867354c64 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/services/modelConfig.golden.test.ts b/packages/core/src/services/modelConfig.golden.test.ts new file mode 100644 index 0000000000..c11f763306 --- /dev/null +++ b/packages/core/src/services/modelConfig.golden.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/packages/core/src/services/modelConfig.integration.test.ts b/packages/core/src/services/modelConfig.integration.test.ts new file mode 100644 index 0000000000..fd47855766 --- /dev/null +++ b/packages/core/src/services/modelConfig.integration.test.ts @@ -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 + }); + }); +}); diff --git a/packages/core/src/services/modelConfigService.test.ts b/packages/core/src/services/modelConfigService.test.ts new file mode 100644 index 0000000000..998abe75b1 --- /dev/null +++ b/packages/core/src/services/modelConfigService.test.ts @@ -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', + ]); + }); + }); +}); diff --git a/packages/core/src/services/modelConfigService.ts b/packages/core/src/services/modelConfigService.ts new file mode 100644 index 0000000000..14b5e5bddb --- /dev/null +++ b/packages/core/src/services/modelConfigService.ts @@ -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; + 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, + visited = new Set(), + ): 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 => 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 { + return !!item && typeof item === 'object' && !Array.isArray(item); + } + + private deepMerge( + config1: GenerateContentConfig | undefined, + config2: GenerateContentConfig | undefined, + ): Record { + return this.genericDeepMerge( + config1 as Record | undefined, + config2 as Record | undefined, + ); + } + + private genericDeepMerge( + ...objects: Array | undefined> + ): Record { + return objects.reduce((acc: Record, 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, + objValue as Record, + ); + } else { + acc[key] = objValue; + } + }); + + return acc; + }, {}); + } +} diff --git a/packages/core/src/services/test-data/resolved-aliases.golden.json b/packages/core/src/services/test-data/resolved-aliases.golden.json new file mode 100644 index 0000000000..199c36ce3a --- /dev/null +++ b/packages/core/src/services/test-data/resolved-aliases.golden.json @@ -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": {} + } + ] + } + } +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 59992ce53f..055164cd4e 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -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.",