mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
fix(proxy): make admin UI root-path rewrite idempotent
This commit is contained in:
parent
e1d16f51d1
commit
99c3105ef6
3 changed files with 192 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"(?<![a-zA-Z0-9_/-])/(?:[a-zA-Z0-9_.-]+/)*\.well-known/litellm-ui-config"
|
||||
)
|
||||
UI_ASSET_PREFIX_PATTERN: Final = re.compile(r"(?<![a-zA-Z0-9_/-])/(?:[a-zA-Z0-9_.-]+/)*_next/")
|
||||
|
||||
|
||||
def rewrite_ui_content(
|
||||
content: str,
|
||||
server_root_path: str,
|
||||
litellm_asset_prefix: str = "/litellm-asset-prefix",
|
||||
) -> 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"(?<![a-zA-Z0-9_/-]){re.escape(litellm_asset_prefix)}(?!/)", clean_root_path, content_with_assets)
|
||||
if litellm_asset_prefix and litellm_asset_prefix != clean_root_path
|
||||
else content_with_assets
|
||||
)
|
||||
|
||||
target_endpoint: Final = f"{clean_root_path}/.well-known/litellm-ui-config"
|
||||
return UI_CONFIG_ENDPOINT_PATTERN.sub(target_endpoint, content_with_prefix)
|
||||
|
||||
|
||||
def get_prisma_client_or_throw(message: str):
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
from unittest import mock
|
||||
|
||||
from litellm.proxy import utils
|
||||
|
||||
|
||||
|
|
@ -14,9 +15,7 @@ def test_get_server_root_path_unset():
|
|||
del os.environ["SERVER_ROOT_PATH"]
|
||||
|
||||
root_path = utils.get_server_root_path()
|
||||
assert (
|
||||
root_path == ""
|
||||
), "Should return empty string when unset to allow X-Forwarded-Prefix"
|
||||
assert root_path == "", "Should return empty string when unset to allow X-Forwarded-Prefix"
|
||||
|
||||
|
||||
def test_get_server_root_path_set():
|
||||
|
|
@ -34,9 +33,7 @@ def test_get_server_root_path_empty_string():
|
|||
"""
|
||||
with mock.patch.dict(os.environ, {"SERVER_ROOT_PATH": ""}, clear=True):
|
||||
root_path = utils.get_server_root_path()
|
||||
assert (
|
||||
root_path == ""
|
||||
), "Should return empty string when explicitly set to empty"
|
||||
assert root_path == "", "Should return empty string when explicitly set to empty"
|
||||
|
||||
|
||||
# Integration test simulation for FastAPI app initialization
|
||||
|
|
@ -62,3 +59,148 @@ def test_fastapi_app_initialization_mock():
|
|||
server_root_path = utils.get_server_root_path()
|
||||
app = FastAPI(root_path=server_root_path)
|
||||
assert app.root_path == "/custom-root"
|
||||
|
||||
|
||||
SAMPLE_PRISTINE_UI_CONTENT = (
|
||||
"let t=h?`${h}/litellm/.well-known/litellm-ui-config`:"
|
||||
'"/litellm/.well-known/litellm-ui-config";'
|
||||
'<link rel="preload" href="/litellm-asset-prefix/_next/static/css/app.css" />'
|
||||
)
|
||||
|
||||
|
||||
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 = '<link rel="stylesheet" href="/litellm-asset-prefix/_next/static/css/app.css" />'
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue