mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge 6257a6c6b5 into eb0e3f8c18
This commit is contained in:
commit
e8a3b62828
3 changed files with 139 additions and 1 deletions
27
litellm/proxy/common_utils/ui_static_files.py
Normal file
27
litellm/proxy/common_utils/ui_static_files.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
110
tests/test_litellm/proxy/common_utils/test_ui_static_files.py
Normal file
110
tests/test_litellm/proxy/common_utils/test_ui_static_files.py
Normal 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>"
|
||||
Loading…
Add table
Reference in a new issue