fix(proxy/ui): detect stale restructured dirs as needing rebuild

`_is_ui_pre_restructured` returned True as soon as it found any
`<route>/index.html` subdirectory, even when a fresh `<route>.html`
source file was sitting alongside it. That mixed state happens when
the Next.js build output is updated (new chunk hashes) while leftover
restructured directories from a previous proxy run remain on disk —
e.g. after `git checkout` brings back the new `<route>.html`. The
detection short-circuited, the restructure step was skipped, and the
proxy served the stale `<route>/index.html` referencing chunks that
no longer exist (404 on `/_next/static/chunks/<hash>.js`).

Walk the tree in the fallback signal: only return True when at least
one restructured subdirectory exists AND no unrestructured `*.html`
source files remain (outside `_next/` and `litellm-asset-prefix/`).
Mixed state now correctly triggers a re-restructure, which
atomically overwrites the stale `index.html` files via `os.replace`.

Adds regression tests covering the four states: marker file, clean
pattern, fully unrestructured, and mixed.
This commit is contained in:
mateo-berri 2026-05-16 15:14:55 -07:00
parent e58a561caa
commit 2cc5177401
2 changed files with 89 additions and 11 deletions

View file

@ -1313,6 +1313,12 @@ try:
Returns True if:
1. Marker file .litellm_ui_ready exists (created by Dockerfile), OR
2. Restructuring pattern detected (subdirectories with index.html inside)
AND no unrestructured source *.html files remain alongside them.
Mixed state — a fresh source `<route>.html` next to a stale
`<route>/index.html` from a previous run — must NOT return True. The
stale directory's HTML may reference asset chunks that no longer exist
in a newer build, producing 404s on `/_next/static/chunks/...`.
This allows skipping copy/restructure operations on read-only filesystems.
"""
@ -1332,26 +1338,44 @@ try:
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)
# Walk the tree to confirm both:
# (a) at least one restructured subdirectory exists, and
# (b) no unrestructured *.html source files remain anywhere.
# If we find any non-index *.html (outside Next.js asset dirs) we
# treat the tree as needing restructure — even if some routes look
# already converted, those converted directories may be stale.
has_restructured_dir = False
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
for current_root, dirs, files in os.walk(ui_dir):
if current_root == ui_dir:
# Prune Next.js asset directories from the walk; their
# internal *.html files (if any) are not route sources.
dirs[:] = [
d for d in dirs if d not in {"_next", "litellm-asset-prefix"}
]
if current_root != ui_dir and "index.html" in files:
has_restructured_dir = True
for filename in files:
if filename.endswith(".html") and filename != "index.html":
verbose_proxy_logger.debug(
f"Detected restructured UI via pattern: found {entry.name}/index.html"
f"Found unrestructured HTML file at "
f"{os.path.join(current_root, filename)} — "
f"UI is not pre-restructured"
)
return True
return False
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
if has_restructured_dir:
verbose_proxy_logger.debug(
f"Detected restructured UI via pattern in {ui_dir}"
)
return has_restructured_dir
def _try_populate_ui_directory(
source_path: str, target_path: str

View file

@ -604,6 +604,60 @@ def test_ui_extensionless_route_requires_restructure(tmp_path):
assert "login" in response.text
def test_is_ui_pre_restructured_marker_file(tmp_path):
from litellm.proxy import proxy_server
ui_root = tmp_path / "ui"
ui_root.mkdir()
(ui_root / ".litellm_ui_ready").write_text("")
assert proxy_server._is_ui_pre_restructured(str(ui_root)) is True
def test_is_ui_pre_restructured_clean_pattern(tmp_path):
from litellm.proxy import proxy_server
ui_root = tmp_path / "ui"
ui_root.mkdir()
(ui_root / "index.html").write_text("root")
(ui_root / "login").mkdir()
(ui_root / "login" / "index.html").write_text("login")
(ui_root / "_next").mkdir()
(ui_root / "_next" / "skip.html").write_text("asset")
assert proxy_server._is_ui_pre_restructured(str(ui_root)) is True
def test_is_ui_pre_restructured_unrestructured(tmp_path):
from litellm.proxy import proxy_server
ui_root = tmp_path / "ui"
ui_root.mkdir()
(ui_root / "index.html").write_text("root")
(ui_root / "login.html").write_text("login")
assert proxy_server._is_ui_pre_restructured(str(ui_root)) is False
def test_is_ui_pre_restructured_mixed_state_returns_false(tmp_path):
"""
Regression: a fresh `<route>.html` checked out next to a stale
`<route>/index.html` from a previous restructure run must return False,
otherwise the stale subdirectory is served and points at chunk hashes
that no longer exist in the new build.
"""
from litellm.proxy import proxy_server
ui_root = tmp_path / "ui"
ui_root.mkdir()
(ui_root / "index.html").write_text("root")
(ui_root / "login.html").write_text("fresh login")
(ui_root / "login").mkdir()
(ui_root / "login" / "index.html").write_text("stale login")
assert proxy_server._is_ui_pre_restructured(str(ui_root)) is False
def test_restructure_always_happens(monkeypatch):
"""
Test that restructuring logic always executes regardless of LITELLM_NON_ROOT setting.