diff --git a/tests/test_litellm/proxy/test_custom_proxy.py b/examples/custom_root_path_server.py
similarity index 100%
rename from tests/test_litellm/proxy/test_custom_proxy.py
rename to examples/custom_root_path_server.py
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 172eba9e43c..bd309abcc8f 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -1710,6 +1710,58 @@ def _get_cors_config(
origins, allow_cors_credentials = _get_cors_config()
+_UI_NON_TEXT_ASSET_SUFFIXES: Final = (
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".ico",
+ ".woff",
+ ".woff2",
+ ".ttf",
+ ".eot",
+)
+
+
+def _apply_server_root_path_to_ui_assets(ui_dir: str, server_root_path: str, asset_prefix: str) -> None:
+ """Rewrite ``asset_prefix`` to ``server_root_path`` in the exported UI under ``ui_dir``.
+
+ Rewrites in place, so ``ui_dir`` must be a runtime-owned copy of the bundle and never the
+ packaged one when that is a read-only or version-controlled checkout.
+ """
+ if not server_root_path or server_root_path == "/":
+ return
+
+ if not os.access(ui_dir, os.W_OK):
+ verbose_proxy_logger.warning(
+ "Cannot apply server_root_path replacements to UI at %s: path is not writable. Ensure server_root_path is '/' or pre-process UI files in Dockerfile with custom server_root_path.",
+ ui_dir,
+ )
+ return
+
+ for current_root, _, files in os.walk(ui_dir):
+ for filename in files:
+ if filename.endswith(_UI_NON_TEXT_ASSET_SUFFIXES):
+ continue
+
+ file_path = os.path.join(current_root, filename)
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ modified_content = content.replace(asset_prefix, server_root_path).replace(
+ "/litellm/.well-known/litellm-ui-config",
+ f"{server_root_path}/.well-known/litellm-ui-config",
+ )
+ if modified_content == content:
+ continue
+
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write(modified_content)
+ except (UnicodeDecodeError, PermissionError, OSError):
+ continue
+
+
# get current directory
try:
current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -1888,57 +1940,7 @@ try:
if not _validate_ui_directory(ui_path):
verbose_proxy_logger.error("Selected UI path %s is invalid or incomplete. UI may not work correctly.", ui_path)
- # Only modify files if a custom server root path is set AND filesystem is writable
- if server_root_path and server_root_path != "/":
- # Check if UI path is writable
- is_writable = os.access(ui_path, os.W_OK)
-
- if not is_writable:
- verbose_proxy_logger.warning(
- "Cannot apply server_root_path replacements to UI at %s: path is not writable. Ensure server_root_path is '/' or pre-process UI files in Dockerfile with custom server_root_path.",
- ui_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 asset prefix with the server root path
- modified_content = content.replace(
- f"{litellm_asset_prefix}",
- f"{server_root_path}",
- )
-
- # 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
+ _apply_server_root_path_to_ui_assets(ui_path, server_root_path, litellm_asset_prefix)
# # Mount the _next directory at the root level
app.mount(
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/conftest.py b/tests/test_litellm/proxy/_experimental/mcp_server/conftest.py
index b477bf3f406..fb0adbf8a3f 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/conftest.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/conftest.py
@@ -5,14 +5,13 @@ import pytest
@pytest.fixture(autouse=True)
def _hermetic_server_root_path():
- """Isolate MCP discovery tests from a leaked ``SERVER_ROOT_PATH``.
+ """Isolate MCP discovery tests from an ambient ``SERVER_ROOT_PATH``.
- ``tests/test_litellm/proxy/test_custom_proxy.py`` sets ``SERVER_ROOT_PATH`` at import time
- (its app mounts under a custom path) and never restores it, so in a shared shard the value
- leaks into this process. The discovery routes and the 401 challenges read it, so a leaked
- value would silently rewrite every ``resource_metadata`` URL and make these tests depend on
- shard ordering. Clearing it here pins the default (root-mounted) deployment; a test that
- exercises a sub-path deployment sets the value explicitly within its own body.
+ The discovery routes and the 401 challenges read it, so a value inherited from the
+ environment would silently rewrite every ``resource_metadata`` URL and make these tests
+ depend on how the shard was invoked. Clearing it here pins the default (root-mounted)
+ deployment; a test that exercises a sub-path deployment sets the value explicitly within
+ its own body.
"""
saved = os.environ.pop("SERVER_ROOT_PATH", None)
try:
diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py
index 3acb9fcafd3..127ac8dc4aa 100644
--- a/tests/test_litellm/proxy/test_proxy_server.py
+++ b/tests/test_litellm/proxy/test_proxy_server.py
@@ -1,4 +1,6 @@
+import ast
import asyncio
+import hashlib
import importlib
import json
import os
@@ -10798,3 +10800,92 @@ async def test_ptu_rollup_job_not_registered_without_opt_in(monkeypatch):
assert scheduler.get_job(PTU_ROLLUP_JOB_ID) is None
assert len(scheduler.get_jobs()) > 0
+
+
+def _hash_tree(root: Path) -> dict:
+ return {
+ str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest()
+ for p in sorted(root.rglob("*"))
+ if p.is_file()
+ }
+
+
+def _write_fake_ui_bundle(root: Path) -> None:
+ (root / "_next" / "static").mkdir(parents=True)
+ (root / "index.html").write_text(
+ ''
+ '',
+ encoding="utf-8",
+ )
+ (root / "_next" / "static" / "app.js").write_text(
+ 'fetch("/litellm-asset-prefix/_next/static/chunk.js")', encoding="utf-8"
+ )
+ (root / "favicon.ico").write_bytes(b"\x00\x01/litellm-asset-prefix\xff")
+
+
+def test_apply_server_root_path_rewrites_copy_and_leaves_packaged_bundle_untouched(tmp_path):
+ """The rewrite must land on the runtime copy only.
+
+ Regression: an example script under tests/ set SERVER_ROOT_PATH at import time, so importing
+ proxy_server rewrote the committed bundle in litellm/proxy/_experimental/out/ in place and
+ left ~475 tracked files dirty.
+ """
+ packaged_bundle = Path(proxy_server_module.packaged_ui_path)
+ assert packaged_bundle.is_dir(), "packaged UI bundle missing; this guard would be inert"
+ packaged_before = _hash_tree(packaged_bundle)
+ assert packaged_before, "packaged UI bundle is empty; this guard would be inert"
+
+ runtime_copy = tmp_path / "out"
+ _write_fake_ui_bundle(runtime_copy)
+
+ proxy_server_module._apply_server_root_path_to_ui_assets(
+ str(runtime_copy), "/my-custom-path", "/litellm-asset-prefix"
+ )
+
+ index_html = (runtime_copy / "index.html").read_text(encoding="utf-8")
+ assert '' in index_html
+ assert '' in index_html
+ assert "litellm-asset-prefix" not in index_html
+ assert "litellm-asset-prefix" not in (runtime_copy / "_next" / "static" / "app.js").read_text(encoding="utf-8")
+ assert (runtime_copy / "favicon.ico").read_bytes() == b"\x00\x01/litellm-asset-prefix\xff"
+
+ assert _hash_tree(packaged_bundle) == packaged_before
+
+
+@pytest.mark.parametrize("root_path", ["/", ""])
+def test_apply_server_root_path_is_a_noop_for_root_deployments(tmp_path, root_path):
+ """A root-mounted deployment must not touch the bundle at all."""
+ runtime_copy = tmp_path / "out"
+ _write_fake_ui_bundle(runtime_copy)
+ before = _hash_tree(runtime_copy)
+
+ proxy_server_module._apply_server_root_path_to_ui_assets(
+ str(runtime_copy), root_path, "/litellm-asset-prefix"
+ )
+
+ assert _hash_tree(runtime_copy) == before
+
+
+def test_no_proxy_test_module_sets_server_root_path_at_import_time():
+ """Import-time SERVER_ROOT_PATH in a collected module rewrites the committed UI bundle.
+
+ proxy_server applies the asset-prefix rewrite at module scope, so any test module that sets
+ the variable before pytest imports proxy_server corrupts litellm/proxy/_experimental/out/ for
+ the whole run and leaks the value into every sibling test.
+ """
+ proxy_tests_root = Path(__file__).parent
+ module_scope_writes = (ast.Assign, ast.AugAssign, ast.AnnAssign, ast.Expr)
+ offenders = sorted(
+ str(module_path.relative_to(proxy_tests_root))
+ for module_path in proxy_tests_root.rglob("*.py")
+ if any(
+ "SERVER_ROOT_PATH" in ast.dump(node)
+ for node in ast.parse(module_path.read_text(encoding="utf-8")).body
+ if isinstance(node, module_scope_writes)
+ )
+ )
+
+ assert offenders == [], (
+ f"module-scope SERVER_ROOT_PATH assignment in {offenders}; set it inside a test with "
+ "monkeypatch.setenv so it cannot rewrite the committed UI bundle at import time"
+ )