From 6257a6c6b56bb73a19445a194f06d2260dd2f46b Mon Sep 17 00:00:00 2001 From: davids Date: Tue, 4 Aug 2026 09:41:39 +0300 Subject: [PATCH] fix(proxy): serve dashboard routes from flat Next.js exports without restructuring Next.js exports built without trailingSlash emit chat.html plus an index-less chat/ data directory. StaticFiles(html=True) matches the directory first, looks for chat/index.html, and 404s direct navigation to /ui/chat. The startup restructure that compensates for this needs a writable filesystem, so read-only deployments keep returning 404. Resolve the fallback at lookup time instead: when /index.html is missing, serve the sibling .html. Both export layouts now work as-is with zero writes to disk at startup. Fixes #24037 --- litellm/proxy/common_utils/ui_static_files.py | 27 +++++ litellm/proxy/proxy_server.py | 3 +- .../common_utils/test_ui_static_files.py | 110 ++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 litellm/proxy/common_utils/ui_static_files.py create mode 100644 tests/test_litellm/proxy/common_utils/test_ui_static_files.py diff --git a/litellm/proxy/common_utils/ui_static_files.py b/litellm/proxy/common_utils/ui_static_files.py new file mode 100644 index 00000000000..ed21c4aa151 --- /dev/null +++ b/litellm/proxy/common_utils/ui_static_files.py @@ -0,0 +1,27 @@ +"""Static file serving for the Next.js dashboard export.""" + +import os + +from starlette.staticfiles import StaticFiles + + +class UiStaticFiles(StaticFiles): + """StaticFiles that falls back to ``.html`` when ``/index.html`` is missing. + + Next.js exports built without ``trailingSlash: true`` emit ``chat.html`` plus an + index-less ``chat/`` data directory; Starlette matches the directory first, finds no + ``index.html``, and returns 404 (https://github.com/BerriAI/litellm/issues/24037). + Resolving the fallback at lookup time serves both export layouts as-is, so the UI + works on read-only filesystems without restructuring files on disk at startup. + """ + + def lookup_path(self, path: str) -> "tuple[str, os.stat_result | None]": + full_path, stat_result = super().lookup_path(path) + if stat_result is not None: + return full_path, stat_result + route = path.replace(os.sep, "/").rstrip("/") + if route.endswith("/index.html"): + return super().lookup_path(f"{route.removesuffix('/index.html')}.html") + if route and not route.endswith(".html"): + return super().lookup_path(f"{route}.html") + return full_path, stat_result diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e8299be8f07..018287ecdc1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -325,6 +325,7 @@ from litellm.proxy.common_utils.timezone_utils import ( get_budget_reset_settings, get_budget_reset_time, ) +from litellm.proxy.common_utils.ui_static_files import UiStaticFiles from litellm.proxy.common_utils.user_api_key_cache import ( UserApiKeyCache, get_management_object_ttl, @@ -1791,7 +1792,7 @@ try: ) # print(f"mounted _next at {server_root_path}/ui/_next") - app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui") + app.mount("/ui", UiStaticFiles(directory=ui_path, html=True), name="ui") def _restructure_ui_html_files(ui_root: str) -> None: """Ensure each exported HTML route is available as /index.html.""" diff --git a/tests/test_litellm/proxy/common_utils/test_ui_static_files.py b/tests/test_litellm/proxy/common_utils/test_ui_static_files.py new file mode 100644 index 00000000000..a8342412dde --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_ui_static_files.py @@ -0,0 +1,110 @@ +""" +Unit tests for UiStaticFiles, the /ui static mount with .html fallback. + +Regression tests for https://github.com/BerriAI/litellm/issues/24037: a Next.js +export without ``trailingSlash: true`` ships ``chat.html`` plus an index-less +``chat/`` data directory, which vanilla StaticFiles(html=True) turns into a 404 +on direct navigation to /ui/chat. The fallback must serve that layout without +writing anything to disk, so read-only deployments work. +""" + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from litellm.proxy.common_utils.ui_static_files import UiStaticFiles + + +def make_client(ui_dir) -> TestClient: + app = Starlette() + app.mount("/ui", UiStaticFiles(directory=str(ui_dir), html=True), name="ui") + return TestClient(app) + + +@pytest.fixture +def flat_export(tmp_path): + (tmp_path / "index.html").write_text("

root

") + (tmp_path / "chat.html").write_text("

chat page

") + chat_dir = tmp_path / "chat" + chat_dir.mkdir() + (chat_dir / "__next.chat.__PAGE__.txt").write_text("rsc payload") + (tmp_path / "model_hub.html").write_text("

model hub page

") + return tmp_path + + +def test_route_shadowed_by_indexless_directory_serves_html(flat_export): + client = make_client(flat_export) + + response = client.get("/ui/chat") + + assert response.status_code == 200 + assert "chat page" in response.text + + +def test_route_with_trailing_slash_serves_html(flat_export): + client = make_client(flat_export) + + response = client.get("/ui/chat/") + + assert response.status_code == 200 + assert "chat page" in response.text + + +def test_route_without_directory_serves_html(flat_export): + client = make_client(flat_export) + + response = client.get("/ui/model_hub") + + assert response.status_code == 200 + assert "model hub page" in response.text + + +def test_serving_flat_export_writes_nothing_to_disk(flat_export): + client = make_client(flat_export) + before = sorted(p.relative_to(flat_export) for p in flat_export.rglob("*")) + + client.get("/ui/chat") + client.get("/ui/model_hub") + + after = sorted(p.relative_to(flat_export) for p in flat_export.rglob("*")) + assert after == before + + +def test_restructured_export_still_served(tmp_path): + (tmp_path / "index.html").write_text("

root

") + chat_dir = tmp_path / "chat" + chat_dir.mkdir() + (chat_dir / "index.html").write_text("

restructured chat

") + client = make_client(tmp_path) + + response = client.get("/ui/chat") + + assert response.status_code == 200 + assert "restructured chat" in response.text + + +def test_root_serves_index(flat_export): + client = make_client(flat_export) + + response = client.get("/ui/") + + assert response.status_code == 200 + assert "root" in response.text + + +def test_unknown_route_returns_404(flat_export): + client = make_client(flat_export) + + response = client.get("/ui/does-not-exist") + + assert response.status_code == 404 + + +def test_asset_files_still_served_directly(flat_export): + (flat_export / "next.svg").write_text("") + client = make_client(flat_export) + + response = client.get("/ui/next.svg") + + assert response.status_code == 200 + assert response.text == ""