From 2cc51774018009791761363469bcc8cd231d3774 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 16 May 2026 15:14:55 -0700 Subject: [PATCH] fix(proxy/ui): detect stale restructured dirs as needing rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_is_ui_pre_restructured` returned True as soon as it found any `/index.html` subdirectory, even when a fresh `.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 `.html`. The detection short-circuited, the restructure step was skipped, and the proxy served the stale `/index.html` referencing chunks that no longer exist (404 on `/_next/static/chunks/.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. --- litellm/proxy/proxy_server.py | 46 ++++++++++++---- tests/test_litellm/proxy/test_proxy_server.py | 54 +++++++++++++++++++ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0114774cc2d..c500588b532 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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 `.html` next to a stale + `/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 diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index e66dbcc3495..8383e7d72a9 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -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 `.html` checked out next to a stale + `/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.