From ce8a520a86b0baf83bb779094ebccbc229363041 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Wed, 8 Oct 2025 14:40:01 -0700 Subject: [PATCH] new deep merge behavior when processing config --- litellm/proxy/proxy_server.py | 23 ++++++-- tests/proxy_unit_tests/test_proxy_utils.py | 68 ++++++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7a1e8f1e73..7feb4b825b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2885,6 +2885,20 @@ class ProxyConfig: dict: Updated configuration dictionary """ + def _deep_merge_dicts(dst: dict, src: dict) -> None: + """ + Deep-merge src into dst, skipping None values from src. + On conflicts, src (DB) wins. + """ + for k, v in src.items(): + if v is None: + # Preserve existing config when DB value is None (matches prior behavior) + continue + if isinstance(v, dict) and isinstance(dst.get(k), dict): + _deep_merge_dicts(dst[k], v) + else: + dst[k] = v + if param_name == "environment_variables": decrypted_env_vars = self._decrypt_and_set_db_env_variables(db_param_value) current_config.setdefault("environment_variables", {}).update( @@ -2905,13 +2919,10 @@ class ProxyConfig: return current_config # For dictionary values, update only non-none values - if isinstance(current_config[param_name], dict): - # Only keep non None values from db_param_value - non_none_values = {k: v for k, v in db_param_value.items() if v is not None} - - # Update the config with non-none values - current_config[param_name].update(non_none_values) + if isinstance(current_config[param_name], dict) and isinstance(db_param_value, dict): + _deep_merge_dicts(current_config[param_name], db_param_value) else: + # Non-dict or mismatched types: DB value replaces config (unchanged behavior) current_config[param_name] = db_param_value return current_config diff --git a/tests/proxy_unit_tests/test_proxy_utils.py b/tests/proxy_unit_tests/test_proxy_utils.py index 34a8a9daf8..9b01f21c58 100644 --- a/tests/proxy_unit_tests/test_proxy_utils.py +++ b/tests/proxy_unit_tests/test_proxy_utils.py @@ -1029,6 +1029,74 @@ def test_update_config_fields(): assert team_config["langfuse_secret"] == "my-fake-secret" +def test_update_config_fields_deep_merge_db_wins(): + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + + current_config = { + "router_settings": { + "routing_mode": "cost_optimized", + "model_group_alias": { + # Existing alias with older model + different hidden flag + "claude-sonnet-4": { + "model": "claude-sonnet-4-20240219", + "hidden": True, + }, + # An extra alias that should remain untouched unless DB overrides it + "legacy-sonnet": { + "model": "claude-2.1", + "hidden": True, + }, + }, + } + } + + db_param_value = { + "model_group_alias": { + # Conflict: DB should win (both 'model' and 'hidden') + "claude-sonnet-4": { + "model": "claude-sonnet-4-20250514", + "hidden": False, + }, + # New alias to be added by the merge + "claude-sonnet-latest": { + "model": "claude-sonnet-4-20250514", + "hidden": True, + }, + # Demonstrate that None values from DB are skipped (preserve existing) + "legacy-sonnet": { + "hidden": None # should not clobber current True + }, + } + } + + updated = proxy_config._update_config_fields( + current_config=current_config, + param_name="router_settings", + db_param_value=db_param_value, + ) + + rs = updated["router_settings"] + aliases = rs["model_group_alias"] + + # DB wins on conflicts (deep) for existing alias + assert aliases["claude-sonnet-4"]["model"] == "claude-sonnet-4-20250514" + assert aliases["claude-sonnet-4"]["hidden"] is False + + # New alias introduced by DB is present with its values + assert "claude-sonnet-latest" in aliases + assert aliases["claude-sonnet-latest"]["model"] == "claude-sonnet-4-20250514" + assert aliases["claude-sonnet-latest"]["hidden"] is True + + # None in DB does not overwrite existing values + assert aliases["legacy-sonnet"]["model"] == "claude-2.1" + assert aliases["legacy-sonnet"]["hidden"] is True + + # Unrelated router_settings keys are preserved + assert rs["routing_mode"] == "cost_optimized" + + def test_update_config_fields_default_internal_user_params(monkeypatch): from litellm.proxy.proxy_server import ProxyConfig