fix(core): deep-merge user model config over defaults#28364
fix(core): deep-merge user model config over defaults#28364AriaZhao-coder wants to merge 1 commit into
Conversation
…28264) The Config constructor merged the user's `modelConfigServiceConfig` into DEFAULT_MODEL_CONFIGS with shallow spreads plus a `?? DEFAULT` fallback for `aliases`/`overrides`. Because DEFAULT_MODEL_CONFIGS is deeply nested (aliases -> modelConfig -> generateContentConfig -> ...), any partial user override obliterated the default aliases — a self-admitted HACK guarded by TODO(12593), whose tracking issue is now closed as stale. Replace the hack with a proper recursive merge by reusing the existing `genericDeepMerge` via a new typed `ModelConfigService.mergeConfigs`. Object maps (aliases, modelDefinitions, ...) merge recursively so partial user overrides augment the defaults; arrays (overrides) are replaced wholesale so users can still fully override them. Adds unit tests for mergeConfigs and updates the config hydration tests to assert the fixed (merge, not obliterate) behavior.
|
📊 PR Size: size/M
|
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses a long-standing issue where partial user model configurations would inadvertently overwrite default model aliases and definitions. By implementing a proper recursive merge strategy, the system now correctly augments default settings with user-provided overrides, improving the flexibility and reliability of the configuration hydration process. This change simplifies the core configuration logic and removes technical debt associated with previous workarounds. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request replaces a manual configuration merging hack with a robust ModelConfigService.mergeConfigs method that deep-merges user-provided model configurations over built-in defaults, preserving default aliases and nested structures. Comprehensive unit tests have been added to verify this behavior. The reviewer identified a critical issue where nested properties of the global DEFAULT_MODEL_CONFIGS could be mutated by reference because genericDeepMerge does not deep-clone the base object. It is recommended to use structuredClone(base) before merging to prevent accidental mutation of global defaults.
Note: Security Review did not run due to the size of the PR.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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; | |
| } | |
Problem
The
Configconstructor merges the user'smodelConfigServiceConfigintoDEFAULT_MODEL_CONFIGSusing shallow spreads plus a?? DEFAULTfallback foraliases/overrides. BecauseDEFAULT_MODEL_CONFIGSis deeply nested (aliases→modelConfig→generateContentConfig→ …), any partial user override obliterates the default model aliases.This was a self-admitted
// HACKinconfig.ts, guarded byTODO(12593)— whose tracking issue is now closed as stale.Fixes #28264.
Fix
Replace the hack with a proper recursive merge by reusing the module's existing
genericDeepMerge, exposed via a new typedModelConfigService.mergeConfigs(base, override):aliases,modelDefinitions,modelIdResolutions,classifierIdResolutions,modelChains) are merged recursively, so partial user overrides augment the defaults instead of dropping them.overrides,customOverrides) are replaced wholesale, so a user can still fully override them (matching the documentedgenericDeepMergebehavior).The
config.tshydration block shrinks from ~40 lines of manual re-stitching to a singlemergeConfigscall, and the staleTODO(12593)is removed.Testing
ModelConfigService.mergeConfigs(partial merge preserves defaults, nested deep-merge, key-add, array replace/keep, undefined override, no input mutation).tscclean for the changed files.