Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2496,7 +2496,7 @@ describe('BaseLlmClient Lifecycle', () => {
});
});

describe('Generation Config Merging (HACK)', () => {
describe('Generation Config Merging', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = createMockSandboxConfig({
command: 'docker',
Expand Down Expand Up @@ -2572,17 +2572,20 @@ describe('Generation Config Merging (HACK)', () => {
}
).config;

// Assert that the user's aliases are present
expect(serviceConfig.aliases).toEqual(userAliases);
// Assert that the default overrides are present
// The user's new alias is added on top of the defaults, which are
// preserved (previously, providing any alias obliterated the defaults).
expect(serviceConfig.aliases).toEqual({
...DEFAULT_MODEL_CONFIGS.aliases,
...userAliases,
});
// The user did not provide overrides, so the defaults remain.
expect(serviceConfig.overrides).toEqual(DEFAULT_MODEL_CONFIGS.overrides);
});

it('should use user-provided aliases if they exist', () => {
it('deep-merges a user alias that overrides a default, preserving the rest', () => {
const [defaultAliasKey] = Object.keys(DEFAULT_MODEL_CONFIGS.aliases ?? {});
const userAliases = {
'my-alias': {
modelConfig: { model: 'my-model' },
},
[defaultAliasKey]: { modelConfig: { model: 'my-model' } },
};

const params: ConfigParameters = {
Expand All @@ -2599,8 +2602,14 @@ describe('Generation Config Merging (HACK)', () => {
}
).config;

// Assert that the user's aliases are used, not the defaults
expect(serviceConfig.aliases).toEqual(userAliases);
// The overridden alias reflects the user's model...
expect(serviceConfig.aliases?.[defaultAliasKey]?.modelConfig?.model).toBe(
'my-model',
);
// ...while every other default alias is still present.
for (const key of Object.keys(DEFAULT_MODEL_CONFIGS.aliases ?? {})) {
expect(serviceConfig.aliases?.[key]).toBeDefined();
}
});

it('should use default generation config if none is provided', () => {
Expand Down
52 changes: 8 additions & 44 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1138,51 +1138,15 @@ export class Config implements McpContext, AgentLoopContext {
this.modelAvailabilityService = new ModelAvailabilityService();
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;

// 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) {
// Ensure user-defined model definitions augment, not replace, the defaults.
const mergedModelDefinitions = {
...DEFAULT_MODEL_CONFIGS.modelDefinitions,
...modelConfigServiceConfig.modelDefinitions,
};
const mergedModelIdResolutions = {
...DEFAULT_MODEL_CONFIGS.modelIdResolutions,
...modelConfigServiceConfig.modelIdResolutions,
};
const mergedClassifierIdResolutions = {
...DEFAULT_MODEL_CONFIGS.classifierIdResolutions,
...modelConfigServiceConfig.classifierIdResolutions,
};
const mergedModelChains = {
...DEFAULT_MODEL_CONFIGS.modelChains,
...modelConfigServiceConfig.modelChains,
};

modelConfigServiceConfig = {
// Preserve other user settings like customAliases
...modelConfigServiceConfig,
// Apply defaults for aliases and overrides if they are not provided
aliases:
modelConfigServiceConfig.aliases ?? DEFAULT_MODEL_CONFIGS.aliases,
overrides:
modelConfigServiceConfig.overrides ?? DEFAULT_MODEL_CONFIGS.overrides,
// Use the merged model definitions
modelDefinitions: mergedModelDefinitions,
modelIdResolutions: mergedModelIdResolutions,
classifierIdResolutions: mergedClassifierIdResolutions,
modelChains: mergedModelChains,
};
}

// Deep-merge the user's model config over the built-in defaults so that a
// partial user config (e.g. just a custom alias or only `overrides`)
// augments the defaults instead of replacing entire nested sections and
// silently dropping the default model aliases. See #28264.
this.modelConfigService = new ModelConfigService(
modelConfigServiceConfig ?? DEFAULT_MODEL_CONFIGS,
ModelConfigService.mergeConfigs(
DEFAULT_MODEL_CONFIGS,
params.modelConfigServiceConfig,
),
);

this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
Expand Down
118 changes: 118 additions & 0 deletions packages/core/src/services/modelConfigService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ModelConfigAlias,
type ModelConfigServiceConfig,
} from './modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js';

describe('ModelConfigService', () => {
it('should resolve a basic alias to its model and settings', () => {
Expand Down Expand Up @@ -1154,3 +1155,120 @@ describe('ModelConfigService', () => {
});
});
});

describe('ModelConfigService.mergeConfigs', () => {
const alias = (model: string): ModelConfigAlias => ({
modelConfig: {
model,
generateContentConfig: { temperature: 0, topP: 0.9 },
},
});

it('preserves default aliases when the user supplies a partial config', () => {
// Reproduces #28264: previously, providing any custom alias obliterated the
// built-in default aliases.
const merged = ModelConfigService.mergeConfigs(DEFAULT_MODEL_CONFIGS, {
aliases: { 'my-custom': alias('gemini-custom') },
});

// The user's custom alias is present...
expect(merged.aliases?.['my-custom']).toBeDefined();
// ...and every default alias is still there.
for (const key of Object.keys(DEFAULT_MODEL_CONFIGS.aliases ?? {})) {
expect(merged.aliases?.[key]).toBeDefined();
}
});

it('deep-merges nested objects so a partial override augments defaults', () => {
const base: ModelConfigServiceConfig = {
aliases: {
base: {
modelConfig: {
model: 'model-a',
generateContentConfig: { temperature: 0, topP: 0.9 },
},
},
},
};
const override: ModelConfigServiceConfig = {
aliases: {
// Only override one leaf; `model` and `topP` should be preserved.
base: { modelConfig: { generateContentConfig: { temperature: 1 } } },
},
};

const merged = ModelConfigService.mergeConfigs(base, override);

expect(merged.aliases?.['base']?.modelConfig?.model).toBe('model-a');
expect(
merged.aliases?.['base']?.modelConfig?.generateContentConfig,
).toEqual({ temperature: 1, topP: 0.9 });
});

it('adds override keys without dropping existing ones', () => {
const base: ModelConfigServiceConfig = {
aliases: { a: alias('model-a'), b: alias('model-b') },
};
const override: ModelConfigServiceConfig = {
aliases: { b: alias('model-b2'), c: alias('model-c') },
};

const merged = ModelConfigService.mergeConfigs(base, override);

expect(Object.keys(merged.aliases ?? {}).sort()).toEqual(['a', 'b', 'c']);
expect(merged.aliases?.['a']?.modelConfig?.model).toBe('model-a');
expect(merged.aliases?.['b']?.modelConfig?.model).toBe('model-b2');
expect(merged.aliases?.['c']?.modelConfig?.model).toBe('model-c');
});

it('replaces arrays wholesale rather than merging them', () => {
const base: ModelConfigServiceConfig = {
overrides: [{ match: { model: 'a' }, modelConfig: { model: 'a' } }],
};
const override: ModelConfigServiceConfig = {
overrides: [{ match: { model: 'b' }, modelConfig: { model: 'b' } }],
};

const merged = ModelConfigService.mergeConfigs(base, override);

expect(merged.overrides).toEqual([
{ match: { model: 'b' }, modelConfig: { model: 'b' } },
]);
});

it('keeps base arrays when the override omits them', () => {
const base: ModelConfigServiceConfig = {
overrides: [{ match: { model: 'a' }, modelConfig: { model: 'a' } }],
};
const override: ModelConfigServiceConfig = {
aliases: { c: alias('model-c') },
};

const merged = ModelConfigService.mergeConfigs(base, override);

expect(merged.overrides).toEqual(base.overrides);
expect(merged.aliases?.['c']).toBeDefined();
});

it('returns an equivalent config when the override is undefined', () => {
const base: ModelConfigServiceConfig = {
aliases: { a: alias('model-a') },
overrides: [],
};

expect(ModelConfigService.mergeConfigs(base, undefined)).toEqual(base);
});

it('does not mutate the base config', () => {
const base: ModelConfigServiceConfig = {
aliases: { a: alias('model-a') },
};
const snapshot = structuredClone(base);

ModelConfigService.mergeConfigs(base, {
aliases: { a: alias('model-a2'), b: alias('model-b') },
});

expect(base).toEqual(snapshot);
});
});
19 changes: 19 additions & 0 deletions packages/core/src/services/modelConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,25 @@ export class ModelConfigService {
) as GenerateContentConfig;
}

/**
* Deep-merges a partial `override` config over a `base` config so that user
* overrides augment—rather than obliterate—nested defaults. Object maps
* (`aliases`, `modelDefinitions`, etc.) are merged recursively, while arrays
* (e.g. `overrides`) are replaced wholesale so a user can fully override them.
* A missing `override` returns the base config unchanged.
*/
static mergeConfigs(
base: ModelConfigServiceConfig,
override: ModelConfigServiceConfig | undefined,
): ModelConfigServiceConfig {
return ModelConfigService.genericDeepMerge(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
base as Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
override as Record<string, unknown> | undefined,
) as ModelConfigServiceConfig;
}
Comment on lines +627 to +637

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Issue: Potential Shared Reference Mutation of Global Defaults

Because genericDeepMerge only recursively merges keys that are present in both objects, any nested properties in base (such as default aliases) that are not overridden by override are copied directly by reference.

Since base is passed as DEFAULT_MODEL_CONFIGS (which is a module-level global constant), any subsequent mutation of the merged configuration (e.g., by the Gemini SDK or runtime overrides) will propagate back and mutate the global DEFAULT_MODEL_CONFIGS object. This can lead to extremely subtle bugs, memory leaks, or flaky tests in concurrent environments.

Solution

Deep-clone the base configuration using structuredClone before merging to completely decouple the merged result from the global defaults.

Suggested change
static mergeConfigs(
base: ModelConfigServiceConfig,
override: ModelConfigServiceConfig | undefined,
): ModelConfigServiceConfig {
return ModelConfigService.genericDeepMerge(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
base as Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
override as Record<string, unknown> | undefined,
) as ModelConfigServiceConfig;
}
static mergeConfigs(
base: ModelConfigServiceConfig,
override: ModelConfigServiceConfig | undefined,
): ModelConfigServiceConfig {
return ModelConfigService.genericDeepMerge(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
structuredClone(base) as Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
override as Record<string, unknown> | undefined,
) as ModelConfigServiceConfig;
}


private static genericDeepMerge(
...objects: Array<Record<string, unknown> | undefined>
): Record<string, unknown> {
Expand Down
Loading