diff --git a/litellm/constants.py b/litellm/constants.py index ed89474600e..75867781bd4 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1645,6 +1645,8 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [ "cost_margin_config", "block_requests_for_models_without_pricing", "budget_exceeded_throttle_percentage", + # Proxy-wide budget stored via POST /config/update; applied to litellm.max_budget on reload. + "max_budget", # Every field editable from the Admin UI (proxy_server._GENERAL_SETTINGS_UI_LITELLM_FIELDS) # must be listed here so a DB write from one worker overrides the live litellm attribute on # the others when config reloads; otherwise peer workers stay on their startup value. diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index af26a9f669e..55518b460e0 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3835,6 +3835,27 @@ def _scrub_guardrail_inner(inner: dict[str, JsonValue]) -> None: inner["guardrail"] = None +def _apply_safe_db_litellm_setting(key: str, value: object) -> None: + """Apply a DB-overlaid ``litellm_settings`` key to the live ``litellm`` module. + + ``max_budget`` is coerced to float to match startup config loading + (``ProxyConfig.load_config``). A ``None`` DB value leaves the current + runtime attribute unchanged; a non-numeric value is ignored (with a + warning) rather than aborting the reload of an already-running proxy. + """ + if key == "max_budget": + if value is None: + return + try: + budget = float(value) + except (TypeError, ValueError): + verbose_proxy_logger.warning("Ignoring non-numeric DB litellm_settings.max_budget override: %r", value) + return + litellm.max_budget = budget + return + setattr(litellm, key, value) + + def _scrub_db_overlay_remote_module_loads(section: str, db_value: JsonValue) -> JsonValue: """Strip ``s3://`` / ``gcs://`` entries from the DB-overlay value for fields whose contents reach ``get_instance_fn``. The same scheme is @@ -6656,7 +6677,7 @@ class ProxyConfig: elif param_name == "litellm_settings" and isinstance(db_param_value, dict): for key, value in db_param_value.items(): if key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES: # params that are safe to override with db values - setattr(litellm, key, value) + _apply_safe_db_litellm_setting(key, value) # If param doesn't exist in config, add it if param_name not in current_config: diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 2c8ae9f0370..dd8d98029bc 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -9838,6 +9838,67 @@ def test_prompt_caching_settings_propagate_on_config_reload(monkeypatch, field_n assert getattr(litellm, field_name) == db_value +@pytest.mark.parametrize( + "db_value, expected", + [ + (10, 10.0), + ("10", 10.0), + (0.5, 0.5), + ], +) +def test_max_budget_propagates_on_config_reload(monkeypatch, db_value, expected): + """Regression for #36533: a DB-stored litellm_settings.max_budget must overlay the live + litellm.max_budget when config reloads (STORE_MODEL_IN_DB=True). It was absent from + LITELLM_SETTINGS_SAFE_DB_OVERRIDES, so peer workers kept enforcing the stale config.yaml value.""" + import litellm.proxy.proxy_server as ps + + # peer worker booted with the stale config.yaml value + monkeypatch.setattr(litellm, "max_budget", 0.00001) + + pc = ps.ProxyConfig() + pc._update_config_fields( + current_config={"litellm_settings": {}}, + param_name="litellm_settings", + db_param_value={"max_budget": db_value}, + ) + + assert litellm.max_budget == expected + assert isinstance(litellm.max_budget, float) + + +def test_max_budget_none_db_value_leaves_runtime_unchanged(monkeypatch): + """A null DB max_budget must not clobber the running litellm.max_budget.""" + import litellm.proxy.proxy_server as ps + + monkeypatch.setattr(litellm, "max_budget", 7.5) + + pc = ps.ProxyConfig() + pc._update_config_fields( + current_config={"litellm_settings": {}}, + param_name="litellm_settings", + db_param_value={"max_budget": None}, + ) + + assert litellm.max_budget == 7.5 + + +def test_max_budget_invalid_db_value_is_ignored(monkeypatch): + """A non-numeric DB max_budget must be skipped (logged), not abort the reload of a + running proxy or clobber the live litellm.max_budget.""" + import litellm.proxy.proxy_server as ps + + monkeypatch.setattr(litellm, "max_budget", 7.5) + + pc = ps.ProxyConfig() + pc._update_config_fields( + current_config={"litellm_settings": {}}, + param_name="litellm_settings", + db_param_value={"max_budget": "not-a-number"}, + ) + + assert litellm.max_budget == 7.5 + + def test_get_config_list_marks_untouched_prompt_caching_flag_as_not_set(monkeypatch): """The flag defaults to False rather than None, so a plain 'is not None' check would report the default as 'In Config' and imply an admin had set it."""