fix: Add LITELLM_UI_PATH and LITELLM_ASSETS_PATH for read-only filesystem support (#20492)

Fixes #19578

---

When deploying the LiteLLM proxy with `readOnlyRootFilesystem: true` in Kubernetes, UI routes returned `404` because:

- Hardcoded paths:
  - `/var/lib/litellm/ui`
  - `/var/lib/litellm/assets`
- Runtime copy/restructure operations failed on read-only filesystems
- No detection mechanism for pre-restructured UI

---

Add configurable environment variables with intelligent detection, graceful fallbacks, and code quality improvements.

---

- **`LITELLM_UI_PATH`** — Custom UI directory location
  - Default: `/var/lib/litellm/ui` (when `LITELLM_NON_ROOT=true`)
  - Default: packaged UI path (otherwise)
  - Example: `/app/var/litellm/ui` for `emptyDir` volumes

- **`LITELLM_ASSETS_PATH`** — Custom assets directory location
  - Default: `/var/lib/litellm/assets` (when `LITELLM_NON_ROOT=true`)
  - Default: current working directory (otherwise)
  - Example: `/app/var/litellm/assets`

---

UI is detected as **pre-restructured and ready** if any of the following apply:

1. **Primary**: `.litellm_ui_ready` marker file exists (created by Dockerfile)
2. **Fallback**: Pattern-based detection — finds *any* subdirectory containing `index.html`
   (resilient to UI structure changes; no hardcoded route names)
3. **Safety**: Filesystem writability check before operations

---

**`litellm/proxy/proxy_server.py`**

- `_validate_ui_directory()` — Verifies UI has required structure (`index.html`, `_next/`)
- `_is_ui_pre_restructured()` — Pattern-based detection (not hardcoded routes)
- `_try_populate_ui_directory()` — Helper for clean error handling
- Refactored UI path decision tree with numbered cases (1, 2, 3, 4a, 4b)
- Updated UI path logic to use `LITELLM_UI_PATH`
- Added writability checks before copy/restructure operations
- Graceful fallback to packaged UI if operations fail
- Updated `server_root_path` replacement with read-only check
- Simplified assets directory creation (try/except instead of complex parent checks)
- Updated `get_image()` endpoint to use `LITELLM_ASSETS_PATH`
- Added validation for packaged and final UI paths

**`docker/Dockerfile.non_root`**

- Added `touch .litellm_ui_ready` marker after UI restructuring
- Enables automatic detection of pre-built UI in Docker images

**`tests/proxy_unit_tests/test_ui_path_detection.py`**

- Added comprehensive unit tests for new functionality
- Tests env var handling, detection logic, and writability checks

---

**`docs/my-website/docs/proxy/config_settings.md`**

- Added `LITELLM_UI_PATH` and `LITELLM_ASSETS_PATH` to env vars table
- Documented defaults and use cases

**`docs/my-website/docs/proxy/prod.md`**

- Added comprehensive "Read-Only Root Filesystem" section
- Quick fixes for permission errors
- Full Kubernetes setup with `initContainer` + `emptyDir` volumes
- API-only deployment option
- Environment variables reference table
- Notes on migrations, caching, and `server_root_path`

**`docker/README.md`**

- Updated hardened setup notes to mention pre-built UI
- Added details about UI serving from read-only paths

---

- No breaking changes
- Existing deployments continue working without modifications
- New env vars are optional with sensible defaults
- Detection logic supports both old and new builds
- Graceful fallbacks throughout

---

```yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      initContainers:
        - name: setup-ui
          image: ghcr.io/berriai/litellm:main-stable
          command: ["sh", "-c", "cp -r /var/lib/litellm/ui/* /app/var/litellm/ui/"]
          volumeMounts:
            - name: ui-volume
              mountPath: /app/var/litellm/ui
      containers:
        - name: litellm
          env:
            - name: LITELLM_UI_PATH
              value: "/app/var/litellm/ui"
            - name: LITELLM_ASSETS_PATH
              value: "/app/var/litellm/assets"
          securityContext:
            readOnlyRootFilesystem: true
          volumeMounts:
            - name: ui-volume
              mountPath: /app/var/litellm/ui
      volumes:
        - name: ui-volume
          emptyDir:
            sizeLimit: 100Mi
This commit is contained in:
Achilleas Athanasiou Fragkoulis 2026-02-06 14:59:36 +00:00 committed by Sameer Kankute
parent 3d70a84eed
commit cb95b1cf92
5 changed files with 534 additions and 89 deletions

View File

@ -59,7 +59,8 @@ RUN mkdir -p /var/lib/litellm/ui && \
mkdir -p "$folder_name" && \
mv "$html_file" "$folder_name/index.html"; \
fi; \
done ) && \
done && \
touch .litellm_ui_ready ) && \
cd /app/ui/litellm-dashboard && rm -rf ./out
# Build litellm wheel and place it in wheels dir (replace any PyPI wheels)

View File

@ -70,9 +70,12 @@ docker compose -f docker-compose.yml -f docker-compose.hardened.yml up -d
This setup:
- Builds from `docker/Dockerfile.non_root` with Prisma engines and Node toolchain baked into the image.
- Runs the proxy as a non-root user with a read-only rootfs and only two writable tmpfs mounts:
- Runs the proxy as a non-root user with a read-only rootfs and only writable tmpfs mounts:
- `/app/cache` (Prisma/NPM cache; backing `PRISMA_BINARY_CACHE_DIR`, `NPM_CONFIG_CACHE`, `XDG_CACHE_HOME`)
- `/app/migrations` (Prisma migration workspace; backing `LITELLM_MIGRATION_DIR`)
- Pre-builds and serves the admin UI from read-only paths:
- `/var/lib/litellm/ui` (pre-restructured Next.js UI with `.litellm_ui_ready` marker)
- `/var/lib/litellm/assets` (UI logos and assets)
- Routes all outbound traffic through a local Squid proxy that denies egress, so Prisma migrations must use the cached CLI and engines.
You should also verify offline Prisma behaviour with:

View File

@ -250,11 +250,133 @@ The migrate deploy command:
### Read-only File System
If you see a `Permission denied` error, it means the LiteLLM pod is running with a read-only file system.
Running LiteLLM with `readOnlyRootFilesystem: true` is a Kubernetes security best practice that prevents container processes from writing to the root filesystem. LiteLLM fully supports this configuration.
To fix this, just set `LITELLM_MIGRATION_DIR="/path/to/writeable/directory"` in your environment.
#### Quick Fix for Permission Errors
LiteLLM will use this directory to write migration files.
If you see a `Permission denied` error, it means the LiteLLM pod is running with a read-only file system. LiteLLM needs writable directories for:
- **Database migrations**: Set `LITELLM_MIGRATION_DIR="/path/to/writable/directory"`
- **Admin UI**: Set `LITELLM_UI_PATH="/path/to/writable/directory"`
- **UI assets/logos**: Set `LITELLM_ASSETS_PATH="/path/to/writable/directory"`
#### Complete Read-Only Filesystem Setup (Kubernetes)
For production deployments with enhanced security, use this configuration:
**Option 1: Using EmptyDir Volumes with InitContainer (Recommended)**
This approach copies the pre-built UI from the Docker image to writable emptyDir volumes at pod startup.
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: litellm-proxy
spec:
template:
spec:
initContainers:
- name: setup-ui
image: ghcr.io/berriai/litellm:main-stable
command:
- sh
- -c
- |
cp -r /var/lib/litellm/ui/* /app/var/litellm/ui/ && \
cp -r /var/lib/litellm/assets/* /app/var/litellm/assets/
volumeMounts:
- name: ui-volume
mountPath: /app/var/litellm/ui
- name: assets-volume
mountPath: /app/var/litellm/assets
containers:
- name: litellm
image: ghcr.io/berriai/litellm:main-stable
env:
- name: LITELLM_NON_ROOT
value: "true"
- name: LITELLM_UI_PATH
value: "/app/var/litellm/ui"
- name: LITELLM_ASSETS_PATH
value: "/app/var/litellm/assets"
- name: LITELLM_MIGRATION_DIR
value: "/app/migrations"
- name: PRISMA_BINARY_CACHE_DIR
value: "/app/cache/prisma-python/binaries"
- name: XDG_CACHE_HOME
value: "/app/cache"
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 101
capabilities:
drop:
- ALL
volumeMounts:
- name: config
mountPath: /app/config.yaml
subPath: config.yaml
readOnly: true
- name: ui-volume
mountPath: /app/var/litellm/ui
- name: assets-volume
mountPath: /app/var/litellm/assets
- name: cache
mountPath: /app/cache
- name: migrations
mountPath: /app/migrations
volumes:
- name: config
configMap:
name: litellm-config
- name: ui-volume
emptyDir:
sizeLimit: 100Mi
- name: assets-volume
emptyDir:
sizeLimit: 10Mi
- name: cache
emptyDir:
sizeLimit: 500Mi
- name: migrations
emptyDir:
sizeLimit: 64Mi
```
**Option 2: Without UI (API-only deployment)**
If you don't need the admin UI, you can run with minimal configuration:
```yaml
env:
- name: LITELLM_NON_ROOT
value: "true"
- name: LITELLM_MIGRATION_DIR
value: "/app/migrations"
securityContext:
readOnlyRootFilesystem: true
```
The proxy will log a warning about the UI but API endpoints will work normally.
#### Environment Variables for Read-Only Filesystems
| Variable | Purpose | Default |
|----------|---------|---------|
| `LITELLM_UI_PATH` | Admin UI directory | `/var/lib/litellm/ui` (Docker) |
| `LITELLM_ASSETS_PATH` | UI assets/logos | `/var/lib/litellm/assets` (Docker) |
| `LITELLM_MIGRATION_DIR` | Database migrations | Package directory |
| `PRISMA_BINARY_CACHE_DIR` | Prisma binary cache | System default |
| `XDG_CACHE_HOME` | General cache directory | System default |
#### Important Notes
1. **Migrations**: Always set `LITELLM_MIGRATION_DIR` to a writable emptyDir path
2. **Prisma Cache**: Set `PRISMA_BINARY_CACHE_DIR` and `XDG_CACHE_HOME` to writable paths
3. **Server Root Path**: If using a custom `server_root_path`, you must pre-process UI files in your Dockerfile as the proxy cannot modify files at runtime with read-only filesystem
4. **Automatic Detection**: The UI is automatically detected as pre-restructured if it contains a `.litellm_ui_ready` marker file (created by the official Docker images)
## 10. Use a Separate Health Check App
:::info

View File

@ -1051,98 +1051,236 @@ try:
except FileNotFoundError:
return False
def _validate_ui_directory(ui_path: str) -> bool:
"""
Verify UI directory has minimum required structure.
Checks for:
- Directory exists
- Has index.html (main entry point)
- Has _next directory (Next.js assets)
Returns True if UI directory appears valid and servable.
"""
if not os.path.isdir(ui_path):
return False
# Must have main index.html
if not os.path.exists(os.path.join(ui_path, "index.html")):
return False
# Must have _next directory with Next.js assets
next_dir = os.path.join(ui_path, "_next")
if not os.path.isdir(next_dir):
return False
return True
def _is_ui_pre_restructured(ui_dir: str) -> bool:
"""
Detect if UI directory is already pre-restructured and ready to serve.
Returns True if:
1. Marker file .litellm_ui_ready exists (created by Dockerfile), OR
2. Restructuring pattern detected (subdirectories with index.html inside)
This allows skipping copy/restructure operations on read-only filesystems.
"""
if not os.path.isdir(ui_dir):
return False
# Primary signal: marker file created by Dockerfile
marker_file = os.path.join(ui_dir, ".litellm_ui_ready")
if os.path.exists(marker_file):
verbose_proxy_logger.debug(f"Found UI ready marker: {marker_file}")
return True
# Fallback signal: Detect restructuring pattern
# After restructuring, routes exist as directories with index.html inside
# (e.g., login/index.html instead of login.html)
# Check for main index.html first (basic UI structure requirement)
if not os.path.exists(os.path.join(ui_dir, "index.html")):
return False
# Look for ANY subdirectory with index.html (proves restructuring happened)
# Ignore directories starting with _ (Next.js internals like _next)
try:
for entry in os.scandir(ui_dir):
if entry.is_dir() and not entry.name.startswith("_"):
index_path = os.path.join(entry.path, "index.html")
if os.path.exists(index_path):
# Found at least one restructured route - this proves the pattern
verbose_proxy_logger.debug(
f"Detected restructured UI via pattern: found {entry.name}/index.html"
)
return True
except (PermissionError, OSError) as e:
verbose_proxy_logger.debug(
f"Could not scan {ui_dir} for restructuring detection: {e}"
)
return False
# No restructured routes found
return False
def _try_populate_ui_directory(
source_path: str, target_path: str
) -> tuple[bool, str]:
"""
Attempt to populate target UI directory from source.
Returns: (success: bool, error_message: str)
"""
try:
os.makedirs(target_path, exist_ok=True)
if not _dir_has_content(target_path) and _dir_has_content(source_path):
shutil.copytree(
source_path,
target_path,
dirs_exist_ok=True,
)
verbose_proxy_logger.info(f"Successfully populated UI at {target_path}")
return True, ""
else:
return False, "Source or target directory state invalid"
except (PermissionError, OSError) as e:
return False, str(e)
# 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"
# Only use runtime UI path in Docker/non-root environments
# In local development, use the packaged UI directly
# Determine runtime UI path
# Priority: LITELLM_UI_PATH env var > default path based on is_non_root
if is_non_root:
# Use /var/lib/litellm/ui for Docker (more secure than /tmp)
runtime_ui_path = "/var/lib/litellm/ui"
default_runtime_ui_path = "/var/lib/litellm/ui"
else:
default_runtime_ui_path = packaged_ui_path
if _dir_has_content(runtime_ui_path):
runtime_ui_path = os.getenv("LITELLM_UI_PATH", default_runtime_ui_path)
# Validate packaged UI before proceeding
if not _validate_ui_directory(packaged_ui_path):
verbose_proxy_logger.error(
f"Packaged UI at {packaged_ui_path} is invalid or incomplete. "
f"UI may not function correctly."
)
# Decision tree for UI path selection:
# 1. If runtime path == packaged path: use packaged UI directly
# 2. If runtime UI exists and is pre-restructured: use it
# 3. If runtime UI exists but not restructured: use it (will restructure later)
# 4. If runtime UI missing: try to populate from packaged UI
# 4a. If population succeeds: use runtime UI
# 4b. If population fails: fall back to packaged UI
should_use_runtime_path = runtime_ui_path != packaged_ui_path
if should_use_runtime_path:
is_pre_restructured = _is_ui_pre_restructured(runtime_ui_path)
has_content = _dir_has_content(runtime_ui_path)
# Case 2: Runtime UI exists and is ready
if has_content and is_pre_restructured:
verbose_proxy_logger.info(
f"Using pre-built UI for non-root Docker: {runtime_ui_path}"
f"Using pre-restructured UI at {runtime_ui_path}"
)
ui_path = runtime_ui_path
# Case 3: Runtime UI exists but needs restructuring
elif has_content and not is_pre_restructured:
verbose_proxy_logger.warning(
f"UI at {runtime_ui_path} has content but is not properly restructured. "
f"Will attempt to restructure in place."
)
ui_path = runtime_ui_path
# Case 4: Runtime UI missing - try to populate
else:
verbose_proxy_logger.error(
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(runtime_ui_path)}, Has content: {_dir_has_content(runtime_ui_path)}"
verbose_proxy_logger.info(
f"UI not found at {runtime_ui_path}. Attempting to populate from packaged UI."
)
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:
verbose_proxy_logger.exception(
f"Failed to populate runtime UI directory {runtime_ui_path} from {packaged_ui_path}: {e}"
)
success, error = _try_populate_ui_directory(
packaged_ui_path, runtime_ui_path
)
if success:
# Case 4a: Population succeeded
ui_path = runtime_ui_path
else:
if _dir_has_content(runtime_ui_path):
verbose_proxy_logger.info(
f"Using populated UI for non-root Docker: {runtime_ui_path}"
)
ui_path = runtime_ui_path
# Case 4b: Population failed - fall back to packaged UI
verbose_proxy_logger.warning(
f"Failed to populate UI at {runtime_ui_path}: {error}. "
f"Falling back to packaged UI at {packaged_ui_path}. "
f"For read-only deployments, pre-build UI in Dockerfile "
f"or set LITELLM_UI_PATH to a writable emptyDir volume."
)
ui_path = packaged_ui_path
else:
# Local development: use packaged UI directly, no runtime copy needed
verbose_proxy_logger.info(
f"Using packaged UI directory for local development: {packaged_ui_path}"
)
# Case 1: Using packaged UI directly (local development)
verbose_proxy_logger.info(f"Using packaged UI directory: {packaged_ui_path}")
ui_path = packaged_ui_path
# Only modify files if a custom server root path is set
# Validate final UI path
if not _validate_ui_directory(ui_path):
verbose_proxy_logger.error(
f"Selected UI path {ui_path} is invalid or incomplete. UI may not work correctly."
)
# Only modify files if a custom server root path is set AND filesystem is writable
if server_root_path and server_root_path != "/":
# Iterate through files in the UI directory
for root, dirs, files in os.walk(ui_path):
for filename in files:
file_path = os.path.join(root, filename)
# Skip binary files and files that don't need path replacement
if filename.endswith(
(
".png",
".jpg",
".jpeg",
".gif",
".ico",
".woff",
".woff2",
".ttf",
".eot",
)
):
continue
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Check if UI path is writable
is_writable = os.access(ui_path, os.W_OK)
# Replace the asset prefix with the server root path
modified_content = content.replace(
f"{litellm_asset_prefix}",
f"{server_root_path}",
)
if not is_writable:
verbose_proxy_logger.warning(
f"Cannot apply server_root_path replacements to UI at {ui_path}: "
f"path is not writable. Ensure server_root_path is '/' or pre-process "
f"UI files in Dockerfile with custom server_root_path."
)
else:
# Iterate through files in the UI directory
for root, dirs, files in os.walk(ui_path):
for filename in files:
file_path = os.path.join(root, filename)
# Skip binary files and files that don't need path replacement
if filename.endswith(
(
".png",
".jpg",
".jpeg",
".gif",
".ico",
".woff",
".woff2",
".ttf",
".eot",
)
):
continue
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Replace the /.well-known/litellm-ui-config with the server root path
modified_content = modified_content.replace(
"/litellm/.well-known/litellm-ui-config",
f"{server_root_path}/.well-known/litellm-ui-config",
)
# Replace the asset prefix with the server root path
modified_content = content.replace(
f"{litellm_asset_prefix}",
f"{server_root_path}",
)
with open(file_path, "w", encoding="utf-8") as f:
f.write(modified_content)
except UnicodeDecodeError:
# Skip binary files that can't be decoded
continue
# Replace the /.well-known/litellm-ui-config with the server root path
modified_content = modified_content.replace(
"/litellm/.well-known/litellm-ui-config",
f"{server_root_path}/.well-known/litellm-ui-config",
)
with open(file_path, "w", encoding="utf-8") as f:
f.write(modified_content)
except (UnicodeDecodeError, PermissionError, OSError):
# Skip binary files or files we can't write to
continue
# # Mount the _next directory at the root level
app.mount(
@ -1186,14 +1324,22 @@ try:
continue
# Handle HTML file restructuring
# Always restructure the directory we actually serve.
# This is critical for extensionless routes like /ui/login (expects login/index.html).
# In development, we restructure directly in _experimental/out.
# In non-root Docker, we restructure in /var/lib/litellm/ui.
# Only restructure if:
# 1. UI is not already pre-restructured
# 2. Filesystem is writable
try:
if is_non_root and ui_path == "/var/lib/litellm/ui":
is_pre_restructured = _is_ui_pre_restructured(ui_path)
is_writable = os.access(ui_path, os.W_OK)
if is_pre_restructured:
verbose_proxy_logger.info(
f"Skipping runtime UI restructuring for non-root Docker. UI at {ui_path} is pre-restructured."
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:
_restructure_ui_html_files(ui_path)
@ -10294,18 +10440,34 @@ async def get_image():
default_site_logo = os.path.join(current_dir, "logo.jpg")
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
assets_dir = "/var/lib/litellm/assets" if is_non_root else current_dir
if is_non_root:
os.makedirs(assets_dir, exist_ok=True)
# Determine assets directory
# Priority: LITELLM_ASSETS_PATH env var > default based on is_non_root
default_assets_dir = "/var/lib/litellm/assets" if is_non_root else current_dir
assets_dir = os.getenv("LITELLM_ASSETS_PATH", default_assets_dir)
# Try to create assets_dir if it doesn't exist (simple try/except approach)
if not os.path.exists(assets_dir):
try:
os.makedirs(assets_dir, exist_ok=True)
verbose_proxy_logger.debug(f"Created assets directory at {assets_dir}")
except (PermissionError, OSError) as e:
verbose_proxy_logger.warning(
f"Cannot create assets directory at {assets_dir}: {e}. "
f"Logo caching may not work. Using current directory for assets."
)
assets_dir = current_dir
# Determine default logo path
default_logo = (
os.path.join(assets_dir, "logo.jpg") if is_non_root else default_site_logo
os.path.join(assets_dir, "logo.jpg")
if assets_dir != current_dir
else default_site_logo
)
if is_non_root and not os.path.exists(default_logo):
if assets_dir != current_dir and not os.path.exists(default_logo):
default_logo = default_site_logo
cache_dir = assets_dir if is_non_root else current_dir
cache_dir = assets_dir if os.access(assets_dir, os.W_OK) else current_dir
cache_path = os.path.join(cache_dir, "cached_logo.jpg")
# [OPTIMIZATION] Check if the cached image exists first

View File

@ -0,0 +1,157 @@
"""
Unit tests for UI path detection and configuration.
Tests the new LITELLM_UI_PATH and LITELLM_ASSETS_PATH functionality
for read-only filesystem support.
Note: Tests involving proxy_server imports are intentionally minimal
to avoid long module load times during testing.
"""
import os
import tempfile
from pathlib import Path
from unittest import mock
import pytest
class TestUIPathEnvironmentVariable:
"""Test LITELLM_UI_PATH environment variable handling."""
def test_custom_ui_path_env_var(self):
"""Test that LITELLM_UI_PATH overrides default."""
custom_path = "/custom/ui/path"
with mock.patch.dict(
os.environ, {"LITELLM_UI_PATH": custom_path, "LITELLM_NON_ROOT": "true"}
):
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
default_runtime_ui_path = (
"/var/lib/litellm/ui" if is_non_root else "/default/packaged/path"
)
runtime_ui_path = os.getenv("LITELLM_UI_PATH", default_runtime_ui_path)
assert runtime_ui_path == custom_path
def test_default_ui_path_non_root(self):
"""Test default UI path in non-root mode."""
with mock.patch.dict(
os.environ, {"LITELLM_NON_ROOT": "true"}, clear=False
):
# Clear LITELLM_UI_PATH if it exists
env_copy = os.environ.copy()
if "LITELLM_UI_PATH" in env_copy:
del env_copy["LITELLM_UI_PATH"]
with mock.patch.dict(os.environ, env_copy, clear=True):
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
default_runtime_ui_path = (
"/var/lib/litellm/ui"
if is_non_root
else "/default/packaged/path"
)
runtime_ui_path = os.getenv(
"LITELLM_UI_PATH", default_runtime_ui_path
)
assert runtime_ui_path == "/var/lib/litellm/ui"
class TestAssetsPathEnvironmentVariable:
"""Test LITELLM_ASSETS_PATH environment variable handling."""
def test_custom_assets_path_env_var(self):
"""Test that LITELLM_ASSETS_PATH overrides default."""
custom_path = "/custom/assets/path"
with mock.patch.dict(
os.environ,
{"LITELLM_ASSETS_PATH": custom_path, "LITELLM_NON_ROOT": "true"},
):
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
default_assets_dir = (
"/var/lib/litellm/assets" if is_non_root else "/default/current/dir"
)
assets_dir = os.getenv("LITELLM_ASSETS_PATH", default_assets_dir)
assert assets_dir == custom_path
def test_default_assets_path_non_root(self):
"""Test default assets path in non-root mode."""
env_copy = os.environ.copy()
env_copy["LITELLM_NON_ROOT"] = "true"
if "LITELLM_ASSETS_PATH" in env_copy:
del env_copy["LITELLM_ASSETS_PATH"]
with mock.patch.dict(os.environ, env_copy, clear=True):
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
default_assets_dir = (
"/var/lib/litellm/assets" if is_non_root else "/default/current/dir"
)
assets_dir = os.getenv("LITELLM_ASSETS_PATH", default_assets_dir)
assert assets_dir == "/var/lib/litellm/assets"
class TestUIDetectionLogic:
"""Test UI pre-restructured detection logic without importing proxy_server."""
def setup_method(self):
"""Create temporary directory for testing."""
self.temp_dir = tempfile.mkdtemp()
def teardown_method(self):
"""Clean up temporary directory."""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_marker_file_exists(self):
"""Test marker file detection logic."""
marker_path = os.path.join(self.temp_dir, ".litellm_ui_ready")
Path(marker_path).touch()
# Verify marker file exists
assert os.path.exists(marker_path)
def test_structural_routes_exist(self):
"""Test structural detection logic."""
routes = ["login", "guardrails", "logs"]
for route in routes:
route_dir = os.path.join(self.temp_dir, route)
os.makedirs(route_dir, exist_ok=True)
index_html = os.path.join(route_dir, "index.html")
Path(index_html).touch()
# Verify routes exist
found_routes = 0
expected_routes = ["login", "guardrails", "logs", "api-reference"]
for route in expected_routes:
route_index = os.path.join(self.temp_dir, route, "index.html")
if os.path.exists(route_index):
found_routes += 1
assert found_routes >= 3
def test_writability_check(self):
"""Test that os.access() correctly detects writable directories."""
# Should be writable
assert os.access(self.temp_dir, os.W_OK) is True
# Create a directory we can't write to (platform-dependent)
if os.name != "nt": # Skip on Windows
readonly_dir = os.path.join(self.temp_dir, "readonly")
os.makedirs(readonly_dir)
os.chmod(readonly_dir, 0o444) # Read-only
# Should not be writable
assert os.access(readonly_dir, os.W_OK) is False
# Restore permissions for cleanup
os.chmod(readonly_dir, 0o755)
if __name__ == "__main__":
pytest.main([__file__, "-v"])