From b3297fc2ead41c1ef46f1034dbcd6d73bb473694 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:39:21 -0700 Subject: [PATCH] feat(proxy): hot-reload .env in dev when running with --reload (#29783) * feat(proxy): hot-reload .env in dev when running with --reload The --reload watcher already restarts the worker on *.py and --config YAML edits, but .env was unwatched, so changing a key there did nothing until a manual restart. Add .env to the uvicorn reload_includes (and to the StatReload monkeypatch, which ignores reload_includes) so an edit triggers a worker restart. A reloaded worker is a fresh process that inherits the reloader's environment, so load_dotenv(override=False) would keep serving the stale inherited value for any key already in the environment. The CLI now exports LITELLM_DEV_ENV_HOT_RELOAD when --reload is set, and litellm/__init__.py reads it to load .env with override=True only on that dev path, leaving normal startup precedence untouched. * feat(proxy): warn that --reload makes .env override shell env vars When --reload is active, worker processes re-read .env with override=True, so .env values win over shell-exported environment variables. Surface this dotenv precedence change with a startup warning so a developer who relies on a shell-exported override is not silently surprised. * fix(proxy): type reload helper paths as Optional[str] to satisfy mypy * fix(proxy): watch the cwd .env in both reload backends for parity WatchFiles only watches cwd (and the --config dir) for .env, while the StatReload fallback used find_dotenv(usecwd=True), which walks up to a parent-dir .env that WatchFiles never sees. Point StatReload at the same cwd .env so the two reload backends react to the same file. --- litellm/__init__.py | 11 ++- litellm/proxy/proxy_cli.py | 76 +++++++++------ tests/test_litellm/proxy/test_proxy_cli.py | 106 +++++++++++++++++++-- 3 files changed, 154 insertions(+), 39 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 8139cf8d6b..f22971dfa1 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -16,8 +16,17 @@ import os # Load .env before any other litellm imports so env vars (e.g. LITELLM_UI_SESSION_DURATION) are available import dotenv as _dotenv + +def _dev_env_hot_reload_enabled() -> bool: + """The proxy exports this flag when started with ``--reload``. A reloaded + worker is a fresh process that inherits the reloader's environment, so an + edited ``.env`` value stays masked by the stale inherited one unless we + let the file win; overriding makes the edit take effect on reload.""" + return os.getenv("LITELLM_DEV_ENV_HOT_RELOAD") == "True" + + if os.getenv("LITELLM_MODE", "DEV") == "DEV": - _dotenv.load_dotenv() + _dotenv.load_dotenv(override=_dev_env_hot_reload_enabled()) from typing import ( Callable, diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index c0246f234a..e4567b9f49 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -7,7 +7,7 @@ import subprocess import sys import urllib.parse as urlparse from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Iterable, Optional, Union import click import httpx @@ -201,32 +201,35 @@ class ProxyInitializationHelpers: @staticmethod def _get_reload_options(config_path: Optional[str]) -> dict: - """Build uvicorn reload kwargs so --reload also reacts to YAML edits.""" - options: dict = {"reload": True} - if not config_path: - return options - config_abs = os.path.abspath(config_path) - config_dir = os.path.dirname(config_abs) + """Build uvicorn reload kwargs so --reload also reacts to .env and YAML edits.""" cwd = os.path.abspath(os.getcwd()) reload_dirs = [cwd] - if config_dir and config_dir != cwd: - reload_dirs.append(config_dir) - options["reload_dirs"] = reload_dirs - # Must be a basename, not an absolute path: uvicorn's + # Must be basenames, not absolute paths: uvicorn's # resolve_reload_patterns() calls pathlib.Path.glob(), which raises # NotImplementedError on absolute patterns (uvicorn discussion #2156). - options["reload_includes"] = ["*.py", os.path.basename(config_abs)] - return options + reload_includes = ["*.py", ".env"] + if config_path: + config_abs = os.path.abspath(config_path) + config_dir = os.path.dirname(config_abs) + if config_dir and config_dir != cwd: + reload_dirs.append(config_dir) + reload_includes.append(os.path.basename(config_abs)) + return { + "reload": True, + "reload_dirs": reload_dirs, + "reload_includes": reload_includes, + } @staticmethod - def _patch_statreload_for_config(config_path: str) -> bool: - """Make uvicorn's StatReload reloader notice YAML config changes. + def _patch_statreload_extra_paths(paths: Iterable[Optional[str]]) -> bool: + """Make uvicorn's StatReload reloader notice non-Python dev files + (the --config YAML and .env). Uvicorn uses WatchFilesReload when the optional `watchfiles` package is installed, otherwise StatReload. StatReload hard-codes `*.py` in `iter_py_files()` and silently ignores `reload_includes`, so the - kwargs from `_get_reload_options` alone don't trigger reloads on YAML - edits. We monkey-patch `iter_py_files` to also yield the config path. + kwargs from `_get_reload_options` alone don't trigger reloads on those + files. We monkey-patch `iter_py_files` to also yield the given paths. Idempotent across calls and a no-op for the WatchFilesReload path. """ @@ -235,30 +238,49 @@ class ProxyInitializationHelpers: except ImportError: # pragma: no cover - uvicorn is a hard dep return False - if not config_path: - return False - from pathlib import Path - config_abs = Path(config_path).resolve() + resolved = {Path(p).resolve() for p in paths if p} + if not resolved: + return False patched_paths = getattr(StatReload, "_litellm_patched_config_paths", None) if patched_paths is None: original_iter = StatReload.iter_py_files patched_paths = set() - def _iter_with_config(self): # type: ignore[no-untyped-def] + def _iter_with_extra(self): # type: ignore[no-untyped-def] yield from original_iter(self) for path in StatReload._litellm_patched_config_paths: if path.exists(): yield path - StatReload.iter_py_files = _iter_with_config # type: ignore[assignment] + StatReload.iter_py_files = _iter_with_extra # type: ignore[assignment] StatReload._litellm_patched_config_paths = patched_paths # type: ignore[attr-defined] - patched_paths.add(config_abs) + patched_paths.update(resolved) return True + @staticmethod + def _configure_dev_reload(uvicorn_args: dict, config_path: Optional[str]) -> None: + """Wire up --reload (dev only): watch *.py, the --config YAML, and .env, + and signal reloaded workers to re-read .env with override so edits to + existing keys actually take effect rather than staying masked by the + value inherited from the reloader process.""" + from litellm._logging import verbose_proxy_logger + + uvicorn_args.update(ProxyInitializationHelpers._get_reload_options(config_path)) + os.environ["LITELLM_DEV_ENV_HOT_RELOAD"] = "True" + env_path = os.path.join(os.getcwd(), ".env") + ProxyInitializationHelpers._patch_statreload_extra_paths( + [config_path, env_path] + ) + verbose_proxy_logger.warning( + "LiteLLM --reload: worker processes re-read .env with override, so .env " + "values win over shell-exported environment variables. Unset a key in .env " + "to let a shell-exported value take precedence." + ) + @staticmethod def _init_hypercorn_server( app: FastAPI, @@ -1217,11 +1239,7 @@ def run_server( # noqa: PLR0915 uvicorn_args["loop"] = loop_type if reload: - uvicorn_args.update( - ProxyInitializationHelpers._get_reload_options(config) - ) - if config: - ProxyInitializationHelpers._patch_statreload_for_config(config) + ProxyInitializationHelpers._configure_dev_reload(uvicorn_args, config) uvicorn.run( **uvicorn_args, diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 580ed95062..4fb725b7ef 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -135,9 +135,11 @@ class TestProxyInitializationHelpers: ) assert args["timeout_worker_healthcheck"] == 15 - def test_get_reload_options_no_config(self): + def test_get_reload_options_no_config_still_watches_env(self): opts = ProxyInitializationHelpers._get_reload_options(None) - assert opts == {"reload": True} + assert opts["reload"] is True + assert opts["reload_dirs"] == [os.path.abspath(os.getcwd())] + assert opts["reload_includes"] == ["*.py", ".env"] def test_get_reload_options_with_config_in_cwd(self, tmp_path, monkeypatch): config_file = tmp_path / "config.yaml" @@ -148,7 +150,7 @@ class TestProxyInitializationHelpers: assert opts["reload"] is True assert opts["reload_dirs"] == [str(tmp_path)] - assert opts["reload_includes"] == ["*.py", "config.yaml"] + assert opts["reload_includes"] == ["*.py", ".env", "config.yaml"] def test_get_reload_options_with_config_outside_cwd(self, tmp_path, monkeypatch): cwd_dir = tmp_path / "work" @@ -163,9 +165,9 @@ class TestProxyInitializationHelpers: assert opts["reload"] is True assert opts["reload_dirs"] == [str(cwd_dir), str(elsewhere)] - assert opts["reload_includes"] == ["*.py", "proxy.yaml"] + assert opts["reload_includes"] == ["*.py", ".env", "proxy.yaml"] - def test_patch_statreload_for_config_yields_yaml(self, tmp_path): + def test_patch_statreload_extra_paths_yields_config_and_py(self, tmp_path): from pathlib import Path from uvicorn.supervisors.statreload import StatReload @@ -178,8 +180,8 @@ class TestProxyInitializationHelpers: py_file = tmp_path / "module.py" py_file.write_text("x = 1\n") - applied = ProxyInitializationHelpers._patch_statreload_for_config( - str(config_file) + applied = ProxyInitializationHelpers._patch_statreload_extra_paths( + [str(config_file)] ) assert applied is True @@ -191,7 +193,42 @@ class TestProxyInitializationHelpers: assert config_file.resolve() in yielded_paths assert py_file.resolve() in yielded_paths - def test_patch_statreload_for_config_is_idempotent(self, tmp_path): + def test_patch_statreload_extra_paths_yields_env(self, tmp_path): + from pathlib import Path + + from uvicorn.supervisors.statreload import StatReload + + if hasattr(StatReload, "_litellm_patched_config_paths"): + StatReload._litellm_patched_config_paths.clear() + + env_file = tmp_path / ".env" + env_file.write_text("FOO=bar\n") + + applied = ProxyInitializationHelpers._patch_statreload_extra_paths( + [str(env_file)] + ) + assert applied is True + + fake_self = types.SimpleNamespace( + config=types.SimpleNamespace(reload_dirs=[tmp_path]) + ) + yielded_paths = {Path(p).resolve() for p in StatReload.iter_py_files(fake_self)} + + assert env_file.resolve() in yielded_paths + + def test_patch_statreload_extra_paths_skips_falsy(self, tmp_path): + from uvicorn.supervisors.statreload import StatReload + + if hasattr(StatReload, "_litellm_patched_config_paths"): + StatReload._litellm_patched_config_paths.clear() + + assert ProxyInitializationHelpers._patch_statreload_extra_paths([]) is False + assert ( + ProxyInitializationHelpers._patch_statreload_extra_paths([None, ""]) + is False + ) + + def test_patch_statreload_extra_paths_is_idempotent(self, tmp_path): from pathlib import Path from uvicorn.supervisors.statreload import StatReload @@ -205,7 +242,7 @@ class TestProxyInitializationHelpers: py_file.write_text("x = 1\n") for _ in range(3): - ProxyInitializationHelpers._patch_statreload_for_config(str(config_file)) + ProxyInitializationHelpers._patch_statreload_extra_paths([str(config_file)]) fake_self = types.SimpleNamespace( config=types.SimpleNamespace(reload_dirs=[tmp_path]) @@ -216,6 +253,57 @@ class TestProxyInitializationHelpers: assert config_file.resolve() in yielded_paths assert py_file.resolve() in yielded_paths + def test_configure_dev_reload_watches_env_and_sets_override_flag( + self, tmp_path, monkeypatch + ): + from pathlib import Path + + from uvicorn.supervisors.statreload import StatReload + + if hasattr(StatReload, "_litellm_patched_config_paths"): + StatReload._litellm_patched_config_paths.clear() + monkeypatch.delenv("LITELLM_DEV_ENV_HOT_RELOAD", raising=False) + + config_file = tmp_path / "config.yaml" + config_file.write_text("model_list: []\n") + env_file = tmp_path / ".env" + env_file.write_text("FOO=bar\n") + monkeypatch.chdir(tmp_path) + + uvicorn_args: dict = {} + with patch("litellm._logging.verbose_proxy_logger.warning") as mock_warning: + ProxyInitializationHelpers._configure_dev_reload( + uvicorn_args, str(config_file) + ) + + assert os.environ["LITELLM_DEV_ENV_HOT_RELOAD"] == "True" + assert uvicorn_args["reload"] is True + assert ".env" in uvicorn_args["reload_includes"] + + mock_warning.assert_called_once() + warning_text = mock_warning.call_args.args[0].lower() + assert "override" in warning_text + assert ".env" in warning_text + + fake_self = types.SimpleNamespace( + config=types.SimpleNamespace(reload_dirs=[tmp_path]) + ) + yielded_paths = {Path(p).resolve() for p in StatReload.iter_py_files(fake_self)} + assert env_file.resolve() in yielded_paths + assert config_file.resolve() in yielded_paths + + def test_dev_env_hot_reload_enabled_reads_flag(self, monkeypatch): + import litellm + + monkeypatch.setenv("LITELLM_DEV_ENV_HOT_RELOAD", "True") + assert litellm._dev_env_hot_reload_enabled() is True + + monkeypatch.setenv("LITELLM_DEV_ENV_HOT_RELOAD", "false") + assert litellm._dev_env_hot_reload_enabled() is False + + monkeypatch.delenv("LITELLM_DEV_ENV_HOT_RELOAD", raising=False) + assert litellm._dev_env_hot_reload_enabled() is False + @patch("asyncio.run") @patch("builtins.print") def test_init_hypercorn_server(self, mock_print, mock_asyncio_run):