This commit is contained in:
David Sheffer 2026-08-27 19:18:00 -05:00 committed by GitHub
commit e8a3b62828
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 139 additions and 1 deletions

View file

@ -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 ``<route>.html`` when ``<route>/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

View file

@ -377,6 +377,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,
end_user_cache_key,
@ -2026,7 +2027,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 <route>/index.html."""

View file

@ -0,0 +1,110 @@
"""
Unit tests for UiStaticFiles, the /ui static mount with <route>.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("<h1>root</h1>")
(tmp_path / "chat.html").write_text("<h1>chat page</h1>")
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("<h1>model hub page</h1>")
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("<h1>root</h1>")
chat_dir = tmp_path / "chat"
chat_dir.mkdir()
(chat_dir / "index.html").write_text("<h1>restructured chat</h1>")
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("<svg></svg>")
client = make_client(flat_export)
response = client.get("/ui/next.svg")
assert response.status_code == 200
assert response.text == "<svg></svg>"