From d626e0a45453e55aa263fa3e04144a8a3724f0ee Mon Sep 17 00:00:00 2001 From: Daniele Salvador Date: Mon, 1 Jun 2026 00:15:17 +0200 Subject: [PATCH] fix(proxy): restructure UI before mounting; fall back to temp dir when read-only When the packaged UI path is not writable (e.g., site-packages in a pip install), the previous code logged a warning and skipped restructuring, leaving login.html unreachable at /ui/login (404). The fix copies the UI to a writable temp directory and restructures there before mounting StaticFiles, so /ui/login resolves to login/index.html in all environments. The restructuring and temp-dir fallback now happen before app.mount() so the mounted directory is always in the correct state when the first request arrives. Fixes #29340 --- litellm/proxy/proxy_server.py | 50 +++++++++---------- tests/test_litellm/proxy/test_proxy_server.py | 42 ++++++++++++++++ 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b296792cd09..046e68a9af8 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11,6 +11,7 @@ import secrets import shutil import subprocess import sys +import tempfile import threading import time import traceback @@ -1631,21 +1632,6 @@ try: # Skip binary files or files we can't write to continue - # # Mount the _next directory at the root level - app.mount( - "/_next", - StaticFiles(directory=os.path.join(ui_path, "_next")), - name="next_static", - ) - app.mount( - f"{litellm_asset_prefix}/_next", - StaticFiles(directory=os.path.join(ui_path, "_next")), - name="next_static", - ) - # print(f"mounted _next at {server_root_path}/ui/_next") - - app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui") - def _restructure_ui_html_files(ui_root: str) -> None: """Ensure each exported HTML route is available as /index.html.""" @@ -1672,10 +1658,6 @@ try: # Another process may have already moved this file. continue - # Handle HTML file restructuring - # Only restructure if: - # 1. UI is not already pre-restructured - # 2. Filesystem is writable try: is_pre_restructured = _is_ui_pre_restructured(ui_path) is_writable = os.access(ui_path, os.W_OK) @@ -1684,15 +1666,17 @@ try: verbose_proxy_logger.info( f"Skipping UI restructuring: {ui_path} is already pre-restructured" ) - elif not is_writable: - verbose_proxy_logger.warning( - f"Cannot restructure UI at {ui_path}: path is not writable. " - f"UI may not work correctly for extensionless routes. " - f"Pre-build and restructure UI in Dockerfile for read-only deployments." - ) - else: + elif is_writable: _restructure_ui_html_files(ui_path) verbose_proxy_logger.info(f"Restructured UI directory: {ui_path}") + else: + tmp_dir = tempfile.mkdtemp(prefix="litellm_ui_") + shutil.copytree(ui_path, tmp_dir, dirs_exist_ok=True) + _restructure_ui_html_files(tmp_dir) + ui_path = tmp_dir + verbose_proxy_logger.info( + f"Copied read-only UI to temp dir and restructured: {tmp_dir}" + ) except PermissionError as e: verbose_proxy_logger.exception( f"Permission error while restructuring UI directory {ui_path}: {e}" @@ -1702,6 +1686,20 @@ try: f"Error while restructuring UI directory {ui_path}: {e}" ) + # Mount _next and UI using the (possibly updated) ui_path + app.mount( + "/_next", + StaticFiles(directory=os.path.join(ui_path, "_next")), + name="next_static", + ) + app.mount( + f"{litellm_asset_prefix}/_next", + StaticFiles(directory=os.path.join(ui_path, "_next")), + name="next_static", + ) + + app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui") + except Exception: pass current_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index b912fec2479..5b2b07cfeea 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -604,6 +604,48 @@ def test_ui_extensionless_route_requires_restructure(tmp_path): assert "login" in response.text +def test_read_only_ui_dir_falls_back_to_temp_dir_for_extensionless_routes(tmp_path): + """ + Regression for pip-install 404: when the UI directory cannot be restructured + in-place (e.g., read-only site-packages), copying it to a writable temp dir + and restructuring there must make /ui/login return 200. + """ + import shutil + import tempfile + + from litellm.proxy import proxy_server + + ui_root = tmp_path / "ui" + ui_root.mkdir() + (ui_root / "index.html").write_text("index") + (ui_root / "login.html").write_text("login") + (ui_root / "_next").mkdir() + + # Confirm that without restructuring the extensionless route returns 404. + fastapi_before = FastAPI() + fastapi_before.mount( + "/ui", StaticFiles(directory=str(ui_root), html=True), name="ui" + ) + assert TestClient(fastapi_before).get("/ui/login").status_code == 404 + + # Apply the read-only fallback: copy to a temp dir and restructure. + tmp_dir = tempfile.mkdtemp(prefix="litellm_ui_test_") + try: + shutil.copytree(str(ui_root), tmp_dir, dirs_exist_ok=True) + proxy_server._restructure_ui_html_files(tmp_dir) + + assert (Path(tmp_dir) / "login" / "index.html").exists() + assert not (Path(tmp_dir) / "login.html").exists() + + fastapi_after = FastAPI() + fastapi_after.mount("/ui", StaticFiles(directory=tmp_dir, html=True), name="ui") + response = TestClient(fastapi_after).get("/ui/login") + assert response.status_code == 200 + assert "login" in response.text + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + def test_admin_ui_export_serves_nested_extensionless_routes(): out_dir = Path(litellm.__file__).parent / "proxy" / "_experimental" / "out" assert out_dir.is_dir(), f"missing UI export at {out_dir}"