From d8cc9ed90a4bc9191485fdd062569e49e18bd215 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:37:29 -0700 Subject: [PATCH] fix(proxy): keep Windows on single-worker reload and pass unlisted uvicorn args to the reload pool --- litellm/proxy/dev_reload.py | 20 +++++++++++++++----- litellm/proxy/proxy_cli.py | 6 +++--- tests/unit/proxy/test_dev_reload.py | 8 ++++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/dev_reload.py b/litellm/proxy/dev_reload.py index cc8bf2beb7b..e8225134ccc 100644 --- a/litellm/proxy/dev_reload.py +++ b/litellm/proxy/dev_reload.py @@ -17,6 +17,7 @@ from uvicorn import config as uvicorn_config from uvicorn.supervisors import ChangeReload, Multiprocess +@with_config(ConfigDict(extra="allow")) class ServerOptions(TypedDict, total=False): host: ReadOnly[str] port: ReadOnly[int] @@ -34,7 +35,7 @@ class ServerOptions(TypedDict, total=False): loop: ReadOnly[Literal["none", "auto", "asyncio", "uvloop"]] -@with_config(ConfigDict(extra="forbid")) +@with_config(ConfigDict(extra="allow")) class ReloadOptions(ServerOptions): app: Required[ReadOnly[str]] reload: ReadOnly[bool] @@ -47,10 +48,17 @@ SERVER_OPTIONS: Final = TypeAdapter(ServerOptions) ASGI_APP: Final = TypeAdapter(ImportString[Callable[..., object]]) LOGGER: Final = logging.getLogger("uvicorn.error") RELOAD_WORKER_HEALTHCHECK_SECONDS: Final = 30 +RELOADER_ONLY_KEYS: Final = frozenset(ReloadOptions.__annotations__) - frozenset(ServerOptions.__annotations__) -def multi_worker_reload_supported() -> bool: - return hasattr(uvicorn_config, "STARTUP_FAILURE") +def multi_worker_reload_supported(platform: str = sys.platform) -> bool: + return platform != "win32" and hasattr(uvicorn_config, "STARTUP_FAILURE") + + +def server_options(options: ReloadOptions) -> ServerOptions: + return SERVER_OPTIONS.validate_python( + {key: value for key, value in options.items() if key not in RELOADER_ONLY_KEYS} + ) def load_app_or_exit(import_path: str) -> Callable[..., object]: @@ -68,7 +76,9 @@ def pool_config(options: ReloadOptions, workers: int) -> Config: partial(load_app_or_exit, options["app"]), factory=True, workers=workers, - **SERVER_OPTIONS.validate_python({"timeout_worker_healthcheck": RELOAD_WORKER_HEALTHCHECK_SECONDS, **options}), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int + **SERVER_OPTIONS.validate_python( + {"timeout_worker_healthcheck": RELOAD_WORKER_HEALTHCHECK_SECONDS, **server_options(options)} + ), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int ) @@ -109,7 +119,7 @@ def run_multi_worker_reload(options: ReloadOptions, workers: int) -> None: reload=options["reload"], reload_dirs=list(options["reload_dirs"]), reload_includes=list(options["reload_includes"]), - **SERVER_OPTIONS.validate_python(options), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int + **server_options(options), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int ) shared_socket: Final = worker_config.bind_socket() try: diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 821ce4857b0..40af4499cdc 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -1070,7 +1070,7 @@ class ProxyInitializationHelpers: "--reload", is_flag=True, default=False, - help="Enable uvicorn hot reload (dev only), including every --num_workers worker on uvicorn>=0.50.0. Also reloads when the --config YAML or .env changes. Incompatible with --run_gunicorn, --run_hypercorn, and --run_granian.", + help="Enable uvicorn hot reload (dev only), including every --num_workers worker on uvicorn>=0.50.0 outside Windows. Also reloads when the --config YAML or .env changes. Incompatible with --run_gunicorn, --run_hypercorn, and --run_granian.", ) @click.option( "--prometheus_metrics_port", @@ -1655,8 +1655,8 @@ def run_server( ) return print( - "\033[1;33mLiteLLM Proxy: --reload with --num_workers>1 requires uvicorn>=0.50.0, " - "so only a single worker reloads.\033[0m" + "\033[1;33mLiteLLM Proxy: --reload with --num_workers>1 requires uvicorn>=0.50.0 " + "and an OS other than Windows, so only a single worker reloads.\033[0m" ) uvicorn.run( **uvicorn_args, diff --git a/tests/unit/proxy/test_dev_reload.py b/tests/unit/proxy/test_dev_reload.py index 7498b249bee..36dae00cc82 100644 --- a/tests/unit/proxy/test_dev_reload.py +++ b/tests/unit/proxy/test_dev_reload.py @@ -171,6 +171,14 @@ def test_the_reload_pool_accepts_and_keeps_every_uvicorn_arg_the_proxy_cli_build assert {key: getattr(config, key) for key in server_args} == server_args +def test_the_reload_pool_passes_a_uvicorn_arg_it_does_not_list_through_to_the_workers() -> None: + config: Final = pool_config(RELOAD_OPTIONS.validate_python({**_options("app:app"), "backlog": 123}), workers=POOL_WORKERS) + assert (config.backlog, config.reload) == (123, False) + + +def test_windows_keeps_single_worker_reload_whatever_the_uvicorn_version() -> None: + assert not multi_worker_reload_supported("win32") + def test_the_pool_is_told_to_stop_once_the_reloader_process_exits() -> None: reloader: Final = multiprocessing.get_context("spawn").Process(target=time.sleep, args=(POOL_TIMEOUT_SECONDS,)) reloader.start()