diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 5fc8c44b2d8..40ce102b8d5 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -398,9 +398,29 @@ class ProxyInitializationHelpers: @staticmethod def _get_loop_type(): - """Helper function to determine the event loop type based on platform""" + """Helper function to determine the event loop type based on platform. + + Returns ``None`` (let uvicorn pick the default asyncio loop) when: + + - the platform doesn't ship uvloop (Windows + cygwin); OR + - uvloop is installed but can't be imported on the current + Python — see GH #20933, where the pinned ``uvloop==0.21.0`` + fails on Python 3.14.2 with ``ImportError: cannot import + name 'BaseDefaultEventLoopPolicy' from 'asyncio.events'``. + + Probing via ``import uvloop`` here is cheap (one-time at proxy + startup) and lets the proxy boot under any Python version even + when the lock-file-pinned uvloop happens to be incompatible. + Users wanting uvloop's perf back on a previously-broken Python + install ``uvloop>=0.22.1`` themselves; the probe picks the new + wheel up automatically. + """ if sys.platform in ("win32", "cygwin", "cli"): - return None # Let uvicorn choose the default loop on Windows + return None + try: + import uvloop # noqa: F401 + except ImportError: + return None return "uvloop" @staticmethod diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 327200a6a95..ff649019a4b 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -8,7 +8,6 @@ sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system-path -import builtins import types from litellm.proxy.proxy_cli import ProxyInitializationHelpers @@ -82,6 +81,44 @@ class TestProxyInitializationHelpers: ) mock_client.chat.completions.create.assert_called() + @patch("sys.platform", "linux") + def test_get_loop_type_returns_uvloop_when_available(self): + """On Linux/macOS with a working uvloop install, return "uvloop".""" + # uvloop is in the proxy extras and importable in this test env. + assert ProxyInitializationHelpers._get_loop_type() == "uvloop" + + @patch("sys.platform", "win32") + def test_get_loop_type_returns_none_on_windows(self): + """Windows / cygwin don't ship uvloop — let uvicorn pick the default.""" + assert ProxyInitializationHelpers._get_loop_type() is None + + @patch("sys.platform", "linux") + def test_get_loop_type_returns_none_when_uvloop_unimportable(self): + """GH #20933: uvloop 0.21.0 fails to import on Python 3.14.2 with + ``ImportError: cannot import name 'BaseDefaultEventLoopPolicy' + from 'asyncio.events'``. Before the fix this propagated up + through ``uvicorn.run`` and crashed proxy startup. The probe in + ``_get_loop_type`` now catches the ImportError and falls back + to ``None`` so uvicorn picks the default asyncio loop. + + Implementation note: setting ``sys.modules["uvloop"] = None`` + is the documented Python idiom for forcing the next + ``import uvloop`` statement to raise ImportError (see CPython + importlib source). This routes through the real ``try / except`` + block in ``_get_loop_type`` rather than mocking it away, so + coverage tools see the fallback branch executed. + """ + # Drop any cached real-uvloop module so the patched None takes effect. + original = sys.modules.get("uvloop", _MISSING := object()) + sys.modules["uvloop"] = None # type: ignore[assignment] + try: + assert ProxyInitializationHelpers._get_loop_type() is None + finally: + if original is _MISSING: + sys.modules.pop("uvloop", None) + else: + sys.modules["uvloop"] = original + def test_get_default_unvicorn_init_args(self): # Test without log_config args = ProxyInitializationHelpers._get_default_unvicorn_init_args(