Merge pull request #17989 from Jetemple/non-root-ui-path-fix

fix: update UI path handling for non-root Docker and restructure HTML…
This commit is contained in:
yuneng-jiang
2025-12-16 21:57:31 -08:00
committed by GitHub
2 changed files with 94 additions and 20 deletions
+67 -20
View File
@@ -5,6 +5,7 @@ import io
import os
import random
import secrets
import shutil
import subprocess
import sys
import time
@@ -939,31 +940,68 @@ origins = ["*"]
# get current directory
try:
current_dir = os.path.dirname(os.path.abspath(__file__))
ui_path = os.path.join(current_dir, "_experimental", "out")
packaged_ui_path = os.path.join(current_dir, "_experimental", "out")
ui_path = packaged_ui_path
litellm_asset_prefix = "/litellm-asset-prefix"
# For non-root Docker, use the pre-built UI from /tmp/litellm_ui
# Support both "true" and "True" for case-insensitive comparison
if os.getenv("LITELLM_NON_ROOT", "").lower() == "true":
non_root_ui_path = "/tmp/litellm_ui"
def _dir_has_content(path: str) -> bool:
try:
return os.path.isdir(path) and any(os.scandir(path))
except FileNotFoundError:
return False
# Check if the UI was built and exists at the expected location
if os.path.exists(non_root_ui_path) and os.listdir(non_root_ui_path):
# Use a writable runtime UI directory whenever possible.
# This prevents mutating the packaged UI directory (e.g. site-packages or the repo checkout)
# and ensures extensionless routes like /ui/login work via <route>/index.html.
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
runtime_ui_path = "/tmp/litellm_ui"
if _dir_has_content(runtime_ui_path):
if is_non_root:
verbose_proxy_logger.info(
f"Using pre-built UI for non-root Docker: {non_root_ui_path}"
f"Using pre-built UI for non-root Docker: {runtime_ui_path}"
)
verbose_proxy_logger.info(
f"UI files found: {len(os.listdir(non_root_ui_path))} items"
)
ui_path = non_root_ui_path
else:
verbose_proxy_logger.info(
f"Using cached runtime UI directory: {runtime_ui_path}"
)
ui_path = runtime_ui_path
else:
if is_non_root:
verbose_proxy_logger.error(
f"UI not found at {non_root_ui_path}. UI will not be available."
f"UI not found at {runtime_ui_path}. Attempting to populate it from packaged UI."
)
verbose_proxy_logger.error(
f"Path exists: {os.path.exists(non_root_ui_path)}, Has content: {os.path.exists(non_root_ui_path) and bool(os.listdir(non_root_ui_path))}"
f"Path exists: {os.path.exists(runtime_ui_path)}, Has content: {_dir_has_content(runtime_ui_path)}"
)
try:
os.makedirs(runtime_ui_path, exist_ok=True)
if not _dir_has_content(runtime_ui_path) and _dir_has_content(
packaged_ui_path
):
shutil.copytree(
packaged_ui_path,
runtime_ui_path,
dirs_exist_ok=True,
)
except Exception as e:
if is_non_root:
verbose_proxy_logger.exception(
f"Failed to populate runtime UI directory {runtime_ui_path} from {packaged_ui_path}: {e}"
)
else:
if _dir_has_content(runtime_ui_path):
if is_non_root:
verbose_proxy_logger.info(
f"Using populated UI for non-root Docker: {runtime_ui_path}"
)
else:
verbose_proxy_logger.info(
f"Using populated runtime UI directory: {runtime_ui_path}"
)
ui_path = runtime_ui_path
# Only modify files if a custom server root path is set
if server_root_path and server_root_path != "/":
# Iterate through files in the UI directory
@@ -1042,16 +1080,25 @@ try:
target_path = os.path.join(target_dir, "index.html")
os.makedirs(target_dir, exist_ok=True)
os.replace(file_path, target_path)
try:
os.replace(file_path, target_path)
except FileNotFoundError:
# Another process may have already moved this file.
continue
# Handle HTML file restructuring
# Skip this for non-root Docker since it's done at build time
# Support both "true" and "True" for case-insensitive comparison
if os.getenv("LITELLM_NON_ROOT", "").lower() != "true":
_restructure_ui_html_files(ui_path)
# Always restructure the directory we actually serve, but avoid mutating the packaged UI.
# This is critical for extensionless routes like /ui/login (expects login/index.html).
if ui_path != packaged_ui_path:
try:
_restructure_ui_html_files(ui_path)
except PermissionError as e:
verbose_proxy_logger.exception(
f"Permission error while restructuring UI directory {ui_path}: {e}"
)
else:
verbose_proxy_logger.info(
"Skipping runtime HTML restructuring for non-root Docker (already done at build time)"
f"Skipping runtime HTML restructuring for packaged UI directory: {ui_path}"
)
except Exception:
@@ -15,6 +15,7 @@ import httpx
import pytest
import yaml
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
sys.path.insert(
@@ -196,6 +197,32 @@ def test_restructure_ui_html_files_handles_nested_routes(tmp_path):
)
def test_ui_extensionless_route_requires_restructure(tmp_path):
"""Regression for non-root fallback: /ui/login expects login/index.html."""
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")
fastapi_app = FastAPI()
fastapi_app.mount(
"/ui", StaticFiles(directory=str(ui_root), html=True), name="ui"
)
client = TestClient(fastapi_app)
assert client.get("/ui/login.html").status_code == 200
assert client.get("/ui/login").status_code == 404
proxy_server._restructure_ui_html_files(str(ui_root))
response = client.get("/ui/login")
assert response.status_code == 200
assert "login" in response.text
@pytest.mark.asyncio
async def test_initialize_scheduled_jobs_credentials(monkeypatch):
"""