fix: rewrite absolute /ui/assets/ paths when SERVER_ROOT_PATH is set

Some JS chunks in the packaged Next.js UI output use absolute paths like
"/ui/assets/logos/" instead of the litellm-asset-prefix token. When the
proxy is served under a sub-path (SERVER_ROOT_PATH=/web-llmgateway/v1),
those absolute paths are not rewritten, so browsers request
/{SERVER_ROOT_PATH}/assets/logos/... (without /ui) and receive 404s.

Add a targeted string replacement alongside the existing litellm-asset-prefix
substitution so that '"/ui/assets/' becomes '"{server_root_path}/ui/assets/'
in all text files under the UI directory.

Fixes https://github.com/BerriAI/litellm/issues/25283

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Smeet Agrawal 2026-04-09 10:34:23 +05:30
parent 62757ff48f
commit 921b06f7cd
2 changed files with 128 additions and 0 deletions

View file

@ -1373,6 +1373,15 @@ try:
f"{server_root_path}",
)
# Replace absolute /ui/assets/ paths with server_root_path-prefixed
# equivalents so logo/asset requests include the root path prefix.
# Some JS bundles emit absolute paths like "/ui/assets/logos/" which
# resolve incorrectly when the app is served under a sub-path.
modified_content = modified_content.replace(
'"/ui/assets/',
f'"{server_root_path}/ui/assets/',
)
# Replace the /.well-known/litellm-ui-config with the server root path
modified_content = modified_content.replace(
"/litellm/.well-known/litellm-ui-config",

View file

@ -0,0 +1,119 @@
"""
Tests for SERVER_ROOT_PATH rewriting of absolute /ui/assets/ paths in JS bundles.
Regression test for https://github.com/BerriAI/litellm/issues/25283
When LiteLLM is served under a sub-path (SERVER_ROOT_PATH=/web-llmgateway/v1),
some JS chunks emit absolute paths like "/ui/assets/logos/" that the browser
resolves without the sub-path prefix, causing 404s for logo assets.
The startup path-rewriting logic must patch these absolute paths.
"""
import os
def _apply_server_root_path_replacements(
ui_path: str,
server_root_path: str,
litellm_asset_prefix: str = "/litellm-asset-prefix",
) -> None:
"""Replicate the replacement logic from proxy_server.py for testing."""
skip_extensions = (".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot")
for root, _, files in os.walk(ui_path):
for filename in files:
if filename.endswith(skip_extensions):
continue
file_path = os.path.join(root, filename)
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
modified = content.replace(litellm_asset_prefix, server_root_path)
modified = modified.replace('"/ui/assets/', f'"{server_root_path}/ui/assets/')
modified = modified.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)
except (UnicodeDecodeError, PermissionError, OSError):
continue
def test_absolute_ui_asset_paths_rewritten_with_server_root_path(tmp_path):
"""
JS bundles that use absolute "/ui/assets/logos/" paths should have those
paths rewritten to include SERVER_ROOT_PATH when it is set.
Reproduces: browser requests /{SERVER_ROOT_PATH}/assets/logos/... (404)
instead of /{SERVER_ROOT_PATH}/ui/assets/logos/... (200).
"""
ui_path = tmp_path / "ui"
ui_path.mkdir()
# Simulate a JS chunk that uses an absolute /ui/assets/ path.
# This mirrors the pattern seen in the packaged UI output:
# _next/static/chunks/0d219667baa010f5.js
js_file = ui_path / "_next" / "static" / "chunks" / "logos.js"
js_file.parent.mkdir(parents=True)
js_file.write_text('let eq="/ui/assets/logos/",r={github:`${eq}github.svg`}')
server_root_path = "/web-llmgateway/v1"
_apply_server_root_path_replacements(str(ui_path), server_root_path)
result = js_file.read_text()
assert f'"{server_root_path}/ui/assets/logos/"' in result, (
f"Absolute /ui/assets/ path not rewritten to include SERVER_ROOT_PATH. Got: {result}"
)
assert '"/ui/assets/logos/"' not in result, (
"Old absolute /ui/assets/ path should have been replaced"
)
def test_litellm_asset_prefix_still_rewritten(tmp_path):
"""Existing litellm-asset-prefix replacement must still work alongside the new fix."""
ui_path = tmp_path / "ui"
ui_path.mkdir()
js_file = ui_path / "bundle.js"
js_file.write_text(
'let t="/litellm-asset-prefix/_next/",eq="/ui/assets/logos/"'
)
server_root_path = "/myapp"
_apply_server_root_path_replacements(str(ui_path), server_root_path)
result = js_file.read_text()
assert f'"{server_root_path}/_next/"' in result
assert f'"{server_root_path}/ui/assets/logos/"' in result
assert '"/litellm-asset-prefix/_next/"' not in result
assert '"/ui/assets/logos/"' not in result
def test_no_replacement_when_no_server_root_path(tmp_path):
"""When server_root_path is empty, paths should remain unchanged."""
ui_path = tmp_path / "ui"
ui_path.mkdir()
original = 'let eq="/ui/assets/logos/",r={}'
js_file = ui_path / "bundle.js"
js_file.write_text(original)
# Simulate the guard: only replace when server_root_path is non-empty
server_root_path = ""
if server_root_path and server_root_path != "/":
_apply_server_root_path_replacements(str(ui_path), server_root_path)
assert js_file.read_text() == original
def test_binary_files_are_skipped(tmp_path):
"""Image and font files must not be touched by the replacement logic."""
ui_path = tmp_path / "ui"
ui_path.mkdir()
png_file = ui_path / "logo.png"
png_file.write_bytes(b"\x89PNG\r\n\x1a\n")
_apply_server_root_path_replacements(str(ui_path), "/root")
assert png_file.read_bytes() == b"\x89PNG\r\n\x1a\n"