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
This commit is contained in:
Daniele Salvador 2026-06-01 00:15:17 +02:00
parent 28c0d8579b
commit d626e0a454
2 changed files with 66 additions and 26 deletions

View file

@ -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 <route>/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__))

View file

@ -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("<html>index</html>")
(ui_root / "login.html").write_text("<html>login</html>")
(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}"