mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-21 08:26:34 +00:00
proxy: hot-reload config YAML when --reload is set (#27274)
* proxy: hot-reload config YAML when --reload is set Uvicorn's --reload only watches *.py by default, so editing the --config YAML did not restart the proxy. _get_reload_options() now extends reload_dirs/reload_includes with the config file's directory and basename when --config is provided. * proxy: qualify reload_includes with absolute config path Address Greptile review on PR #27274. When the --config file lives outside cwd, reload_includes previously stored only the basename, which meant uvicorn/watchfiles would also reload on edits to any same-named file inside cwd. Use the absolute config path as the include pattern in that case so only the actual proxy config triggers a restart. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * fix(proxy): use basename for reload_includes config pattern Uvicorn's resolve_reload_patterns() calls pathlib.Path.glob(), which raises NotImplementedError on absolute patterns (uvicorn discussion 2156). Passing config_abs (an absolute path) when the config file lived outside cwd crashed startup under --reload. The config_dir is already added to reload_dirs, so using just the basename as the include pattern is sufficient to match the specific config file. * fix: make it reload app when yaml changes * style: remove unneeded comments --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude
Cursor Agent
Mateo Wang
parent
bd1ea0252a
commit
b83d11351f
@@ -169,6 +169,66 @@ class ProxyInitializationHelpers:
|
||||
)
|
||||
return uvicorn_args
|
||||
|
||||
@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)
|
||||
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
|
||||
# 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
|
||||
|
||||
@staticmethod
|
||||
def _patch_statreload_for_config(config_path: str) -> bool:
|
||||
"""Make uvicorn's StatReload reloader notice YAML config changes.
|
||||
|
||||
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.
|
||||
|
||||
Idempotent across calls and a no-op for the WatchFilesReload path.
|
||||
"""
|
||||
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()
|
||||
|
||||
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,
|
||||
@@ -619,7 +679,7 @@ class ProxyInitializationHelpers:
|
||||
"--reload",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable uvicorn hot reload (dev only). Incompatible with --num_workers>1, --run_gunicorn, and --run_hypercorn.",
|
||||
help="Enable uvicorn hot reload (dev only). Also reloads when the --config YAML file changes. Incompatible with --num_workers>1, --run_gunicorn, and --run_hypercorn.",
|
||||
)
|
||||
def run_server( # noqa: PLR0915
|
||||
host,
|
||||
@@ -1028,7 +1088,11 @@ def run_server( # noqa: PLR0915
|
||||
uvicorn_args["loop"] = loop_type
|
||||
|
||||
if reload:
|
||||
uvicorn_args["reload"] = True
|
||||
uvicorn_args.update(
|
||||
ProxyInitializationHelpers._get_reload_options(config)
|
||||
)
|
||||
if config:
|
||||
ProxyInitializationHelpers._patch_statreload_for_config(config)
|
||||
|
||||
uvicorn.run(
|
||||
**uvicorn_args,
|
||||
|
||||
@@ -133,6 +133,87 @@ class TestProxyInitializationHelpers:
|
||||
)
|
||||
assert args["timeout_worker_healthcheck"] == 15
|
||||
|
||||
def test_get_reload_options_no_config(self):
|
||||
opts = ProxyInitializationHelpers._get_reload_options(None)
|
||||
assert opts == {"reload": True}
|
||||
|
||||
def test_get_reload_options_with_config_in_cwd(self, tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("model_list: []\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
opts = ProxyInitializationHelpers._get_reload_options("config.yaml")
|
||||
|
||||
assert opts["reload"] is True
|
||||
assert opts["reload_dirs"] == [str(tmp_path)]
|
||||
assert opts["reload_includes"] == ["*.py", "config.yaml"]
|
||||
|
||||
def test_get_reload_options_with_config_outside_cwd(self, tmp_path, monkeypatch):
|
||||
cwd_dir = tmp_path / "work"
|
||||
cwd_dir.mkdir()
|
||||
elsewhere = tmp_path / "configs"
|
||||
elsewhere.mkdir()
|
||||
config_file = elsewhere / "proxy.yaml"
|
||||
config_file.write_text("model_list: []\n")
|
||||
monkeypatch.chdir(cwd_dir)
|
||||
|
||||
opts = ProxyInitializationHelpers._get_reload_options(str(config_file))
|
||||
|
||||
assert opts["reload"] is True
|
||||
assert opts["reload_dirs"] == [str(cwd_dir), str(elsewhere)]
|
||||
assert opts["reload_includes"] == ["*.py", "proxy.yaml"]
|
||||
|
||||
def test_patch_statreload_for_config_yields_yaml(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()
|
||||
|
||||
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
|
||||
|
||||
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 config_file.resolve() in yielded_paths
|
||||
assert py_file.resolve() in yielded_paths
|
||||
|
||||
def test_patch_statreload_for_config_is_idempotent(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()
|
||||
|
||||
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))
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user