mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-01 12:22:11 +00:00
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.
This commit is contained in:
+10
-1
@@ -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,
|
||||
|
||||
+47
-29
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user