From 3face4a867f14d3c496132b9ee07cbb720a5602e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 5 May 2026 23:23:03 -0700 Subject: [PATCH] fix: make it reload app when yaml changes --- litellm/proxy/proxy_cli.py | 72 +++++++++++++++++++++ tests/test_litellm/proxy/test_proxy_cli.py | 75 ++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 57ba71bf1b9..5bc300a1161 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -177,6 +177,12 @@ class ProxyInitializationHelpers: Uvicorn defaults to watching only `*.py` files in CWD; we extend the watch set to include the config file's directory and filename. + + Note: uvicorn's `reload_includes` is only honored by the + `WatchFilesReload` reloader (requires the optional `watchfiles` + package). Without `watchfiles`, uvicorn falls back to `StatReload` + which hard-codes `*.py` and silently ignores `reload_includes`. + :func:`_patch_statreload_for_config` handles that fallback. """ options: dict = {"reload": True} if not config_path: @@ -198,6 +204,66 @@ class ProxyInitializationHelpers: options["reload_includes"] = ["*.py", config_include] return options + @staticmethod + def _patch_statreload_for_config(config_path: str) -> bool: + """ + Make uvicorn's `StatReload` reloader also notice changes to the + YAML config file at ``config_path``. + + Background: + - Uvicorn picks `WatchFilesReload` if the optional `watchfiles` + package is installed, otherwise `StatReload`. + - `StatReload.iter_py_files()` only iterates `*.py` files in + `reload_dirs` (it ignores `reload_includes` and just logs a + warning saying so). That means setting `reload_includes` alone + is not enough to trigger a reload on YAML edits in environments + that lack `watchfiles` (e.g. plain `uvicorn`, not + `uvicorn[standard]`). + - `WatchFilesReload` honors `reload_includes` correctly, so this + patch is a no-op there. + + We monkey-patch `StatReload.iter_py_files` (idempotently) to also + yield the absolute path of the config file. The patch is applied + in the parent process before `uvicorn.run` spawns the reloader, + which means the same Python process becomes the reloader and + inherits the patched method. + + Returns True if the patch was applied (or was already applied), + False if uvicorn isn't importable (should never happen at runtime + but keeps this helper safe to call from tests). + """ + try: + from uvicorn.supervisors.statreload import StatReload + 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() + + # Track the set of patched config files on the class so multiple + # invocations (e.g. tests, repeat calls) compose correctly without + # wrapping the original method N times. + 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] + 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._litellm_patched_config_paths = patched_paths # type: ignore[attr-defined] + + patched_paths.add(config_abs) + return True + @staticmethod def _init_hypercorn_server( app: FastAPI, @@ -1060,6 +1126,12 @@ def run_server( # noqa: PLR0915 uvicorn_args.update( ProxyInitializationHelpers._get_reload_options(config) ) + # When `watchfiles` is not installed uvicorn falls back to + # `StatReload`, which hard-codes `*.py` and silently ignores + # `reload_includes`. Patch it so YAML edits also trigger a + # restart in that environment. + if config: + ProxyInitializationHelpers._patch_statreload_for_config(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 4d0df4dedcd..4c75ff29041 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -166,6 +166,81 @@ class TestProxyInitializationHelpers: # which raises NotImplementedError on absolute patterns. assert opts["reload_includes"] == ["*.py", "proxy.yaml"] + def test_patch_statreload_for_config_yields_yaml(self, tmp_path): + """ + When uvicorn falls back to StatReload (no watchfiles installed), + the patched iter_py_files() must yield the YAML config file alongside + the .py files so saving the config triggers a reload. + """ + from pathlib import Path + + from uvicorn.supervisors.statreload import StatReload + + # Reset any state from previous tests so we get a clean reading + # of the patched behavior. + if hasattr(StatReload, "_litellm_patched_config_paths"): + StatReload._litellm_patched_config_paths.clear() + + config_file = tmp_path / "config.yaml" + config_file.write_text("model_list: []\n") + py_file = tmp_path / "module.py" + py_file.write_text("x = 1\n") + + applied = ProxyInitializationHelpers._patch_statreload_for_config( + str(config_file) + ) + assert applied is True + + # Build a fake StatReload-like object whose `config.reload_dirs` + # points at tmp_path. We don't want to instantiate the real + # StatReload (which spawns processes); we just need to drive the + # patched iter_py_files() method directly. + fake_self = types.SimpleNamespace( + config=types.SimpleNamespace(reload_dirs=[tmp_path]) + ) + yielded = list(StatReload.iter_py_files(fake_self)) + yielded_paths = {Path(p).resolve() for p in yielded} + + assert config_file.resolve() in yielded_paths, ( + "Patched StatReload should yield the YAML config file so " + "edits trigger a reload" + ) + assert py_file.resolve() in yielded_paths, ( + "Patched StatReload must still yield .py files (we extend, " + "not replace, the original behavior)" + ) + + def test_patch_statreload_for_config_is_idempotent(self, tmp_path): + """ + Re-applying the patch with the same config path should not wrap the + original iter_py_files() multiple times (which would cause the .py + files to be yielded N times per poll cycle). + """ + from pathlib import Path + + from uvicorn.supervisors.statreload import StatReload + + if hasattr(StatReload, "_litellm_patched_config_paths"): + StatReload._litellm_patched_config_paths.clear() + + config_file = tmp_path / "config.yaml" + config_file.write_text("model_list: []\n") + py_file = tmp_path / "only.py" + py_file.write_text("x = 1\n") + + for _ in range(3): + ProxyInitializationHelpers._patch_statreload_for_config(str(config_file)) + + fake_self = types.SimpleNamespace( + config=types.SimpleNamespace(reload_dirs=[tmp_path]) + ) + yielded = list(StatReload.iter_py_files(fake_self)) + # Each path should appear at most once. + assert len(yielded) == len(set(map(str, yielded))) + yielded_paths = {Path(p).resolve() for p in yielded} + assert config_file.resolve() in yielded_paths + assert py_file.resolve() in yielded_paths + @patch("asyncio.run") @patch("builtins.print") def test_init_hypercorn_server(self, mock_print, mock_asyncio_run):