mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
fix: make it reload app when yaml changes
This commit is contained in:
parent
d9cc076d97
commit
3face4a867
2 changed files with 147 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue