From 99c3105ef64559659c77e7ee4e3cac62e7c738e6 Mon Sep 17 00:00:00 2001 From: Rashmi Date: Mon, 5 Oct 2026 01:23:01 +0530 Subject: [PATCH] fix(proxy): make admin UI root-path rewrite idempotent --- litellm/proxy/proxy_server.py | 20 ++- litellm/proxy/utils.py | 36 +++++ tests/unit/proxy/test_server_root_path.py | 154 +++++++++++++++++++++- 3 files changed, 192 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f40e541dd86..15eec1f0408 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -844,6 +844,7 @@ from litellm.proxy.utils import ( migrate_passwords_to_scrypt_async, model_dump_with_preserved_fields, prefetch_config_params, + rewrite_ui_content, update_spend, ) from litellm.proxy.video_endpoints.endpoints import router as video_router @@ -2352,20 +2353,15 @@ 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}", + modified_content = rewrite_ui_content( + content=content, + server_root_path=server_root_path, + litellm_asset_prefix=litellm_asset_prefix, ) - # 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) + if modified_content != content: + 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 diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 1c738aefb97..b05f5cd3367 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -6,6 +6,7 @@ import inspect import json import math import os +import re import smtplib import ssl import sys @@ -8412,6 +8413,41 @@ def normalize_route_for_root_path(route: str) -> str | None: return route +UI_CONFIG_ENDPOINT_PATTERN: Final = re.compile( + r"(? str: + """ + Rewrite packaged admin UI asset references and configuration endpoint paths + for a configured SERVER_ROOT_PATH. + + This transformation is idempotent: repeatedly applying it for the same + server_root_path (e.g. across container or proxy restarts) preserves the + configured path instead of nesting it (e.g. /foo/foo/litellm/...). + """ + if not server_root_path or server_root_path == "/": + return content + + clean_root_path: Final = "/" + server_root_path.strip("/") + + content_with_assets: Final = UI_ASSET_PREFIX_PATTERN.sub(f"{clean_root_path}/_next/", content) + content_with_prefix: Final = ( + re.sub(rf"(?' +) + + +def test_rewrite_ui_content_first_rewrite(): + """Test A: First rewrite from pristine path with /foo/litellm.""" + result = utils.rewrite_ui_content( + content=SAMPLE_PRISTINE_UI_CONTENT, + server_root_path="/foo/litellm", + ) + assert "`${h}/foo/litellm/.well-known/litellm-ui-config`" in result + assert ':"/foo/litellm/.well-known/litellm-ui-config"' in result + assert 'href="/foo/litellm/_next/static/css/app.css"' in result + + +def test_rewrite_ui_content_repeated_rewrite(): + """ + Test B: Repeated rewrite must be idempotent. + Applying rewrite twice with /foo/litellm must NOT produce /foo/foo/litellm. + """ + first = utils.rewrite_ui_content( + content=SAMPLE_PRISTINE_UI_CONTENT, + server_root_path="/foo/litellm", + ) + second = utils.rewrite_ui_content( + content=first, + server_root_path="/foo/litellm", + ) + assert second == first + assert "/foo/foo/litellm" not in second + assert "`${h}/foo/litellm/.well-known/litellm-ui-config`" in second + assert ':"/foo/litellm/.well-known/litellm-ui-config"' in second + + +def test_rewrite_ui_content_default_path(): + """Test C: SERVER_ROOT_PATH=/litellm behavior must remain unchanged.""" + first = utils.rewrite_ui_content( + content=SAMPLE_PRISTINE_UI_CONTENT, + server_root_path="/litellm", + ) + second = utils.rewrite_ui_content( + content=first, + server_root_path="/litellm", + ) + assert "`${h}/litellm/.well-known/litellm-ui-config`" in first + assert ':"/litellm/.well-known/litellm-ui-config"' in first + assert second == first + + +def test_rewrite_ui_content_nested_and_edge_paths(): + """Test other supported root paths: /api/litellm, nested, custom, and slash normalization.""" + for root_path in [ + "/api/litellm", + "/app/gateway/litellm", + "/custom-proxy", + "/nested/litellm/", + "prefix/litellm", + ]: + first = utils.rewrite_ui_content( + content=SAMPLE_PRISTINE_UI_CONTENT, + server_root_path=root_path, + ) + second = utils.rewrite_ui_content( + content=first, + server_root_path=root_path, + ) + assert second == first, f"Failed idempotency for {root_path}" + + clean_path = "/" + root_path.strip("/") + expected_endpoint = f"{clean_path}/.well-known/litellm-ui-config" + assert f':"{expected_endpoint}"' in first, f"Expected endpoint {expected_endpoint} not in output" + + +def test_rewrite_ui_content_healing_and_reconfiguration(): + """ + Test healing previously corrupted paths (e.g. from buggy restarts) + and smoothly switching between different configured root paths. + """ + corrupted = ( + 'let t=h?`${h}/foo/foo/litellm/.well-known/litellm-ui-config`:"/foo/foo/litellm/.well-known/litellm-ui-config";' + ) + healed = utils.rewrite_ui_content( + content=corrupted, + server_root_path="/foo/litellm", + ) + assert "`${h}/foo/litellm/.well-known/litellm-ui-config`" in healed + assert ':"/foo/litellm/.well-known/litellm-ui-config"' in healed + + reconfigured = utils.rewrite_ui_content( + content=healed, + server_root_path="/bar/litellm", + ) + assert "`${h}/bar/litellm/.well-known/litellm-ui-config`" in reconfigured + assert ':"/bar/litellm/.well-known/litellm-ui-config"' in reconfigured + assert utils.rewrite_ui_content(reconfigured, "/bar/litellm") == reconfigured + + +def test_rewrite_ui_content_actual_packaged_ui_chunk(): + """Verify idempotency on the actual packaged UI chunk file if available.""" + from pathlib import Path + + current_dir = Path(__file__).resolve().parent + repo_root = current_dir.parents[2] + packaged_ui_path = repo_root / "litellm" / "proxy" / "_experimental" / "out" + + if not packaged_ui_path.exists(): + return + + for chunk in packaged_ui_path.rglob("*.js"): + content = chunk.read_text(encoding="utf-8", errors="ignore") + if ".well-known/litellm-ui-config" in content: + once = utils.rewrite_ui_content(content, "/foo/litellm") + twice = utils.rewrite_ui_content(once, "/foo/litellm") + assert once == twice + assert "/foo/foo/litellm" not in twice + assert "/foo/litellm/.well-known/litellm-ui-config" in twice + + +def test_rewrite_ui_content_asset_reconfiguration_and_prefix_edge_cases(): + """Verify asset URL rewriting handles root path changes and root paths containing asset prefix.""" + sample_html = '' + + first = utils.rewrite_ui_content(sample_html, "/foo/litellm") + assert 'href="/foo/litellm/_next/static/css/app.css"' in first + second = utils.rewrite_ui_content(first, "/foo/litellm") + assert second == first + + reconfigured = utils.rewrite_ui_content(first, "/bar/litellm") + assert 'href="/bar/litellm/_next/static/css/app.css"' in reconfigured + assert utils.rewrite_ui_content(reconfigured, "/bar/litellm") == reconfigured + + root_with_prefix = "/litellm-asset-prefix/custom" + with_prefix = utils.rewrite_ui_content(sample_html, root_with_prefix) + assert f'href="{root_with_prefix}/_next/static/css/app.css"' in with_prefix + assert utils.rewrite_ui_content(with_prefix, root_with_prefix) == with_prefix + + +def test_rewrite_ui_content_empty_or_root_path(): + assert utils.rewrite_ui_content(SAMPLE_PRISTINE_UI_CONTENT, "") == SAMPLE_PRISTINE_UI_CONTENT + assert utils.rewrite_ui_content(SAMPLE_PRISTINE_UI_CONTENT, "/") == SAMPLE_PRISTINE_UI_CONTENT