From 48d7e15b831da6321e7d6d5301c8e4ed8118253c Mon Sep 17 00:00:00 2001
From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
Date: Mon, 25 May 2026 21:06:50 -0700
Subject: [PATCH] chore(admin-ui): regenerate static export with trailingSlash:
true (#28112)
* chore(admin-ui): regenerate static export with trailingSlash: true
Rebuilds litellm/proxy/_experimental/out/ from ui/litellm-dashboard with
`trailingSlash: true` enabled in next.config.mjs. Next.js now emits every
route as
/index.html (e.g. mcp/oauth/callback/index.html) instead of
.html with a sibling metadata-only directory, which fixes the 404 on
extensionless URLs served through FastAPI's StaticFiles(html=True) mount.
This is the build artifact half of the fix; the config change, Dockerfile
cleanup, and regression test live in the follow-up source PR that stacks
on top of this branch.
* fix(admin-ui): emit nested routes as /index.html (#28106)
Linear and other OAuth providers redirect the user back to
/ui/mcp/oauth/callback?code=...&state=... after the consent step. The
packaged Next.js static export only produced /ui/mcp/oauth/callback.html,
so FastAPI's StaticFiles served a 404 on the extensionless URL and the
OAuth handshake never completed.
The Dockerfile.non_root build step tried to paper over this at image-build
time with `for html_file in *.html; do ...`, but that shell glob does not
recurse, so nested routes like mcp/oauth/callback.html were left stranded
next to an empty mcp/oauth/callback/ directory containing only Next.js
metadata. The runtime restructure step in proxy_server.py was then skipped
because the .litellm_ui_ready marker had already been dropped.
Set trailingSlash: true in the dashboard's Next.js config so the export
emits every nested route as /index.html natively. The Dockerfile loop
is now a no-op for the bundled UI and has been removed; the
.litellm_ui_ready marker is still written so the proxy keeps skipping the
redundant Python restructure step at startup. Stacks on top of the static
export regeneration in the parent branch.
* chore: restore origin/litellm_internal_staging out files
---
docker/Dockerfile.non_root | 14 +-----
tests/test_litellm/proxy/test_proxy_server.py | 43 +++++++++++++++++++
ui/litellm-dashboard/next.config.mjs | 1 +
3 files changed, 45 insertions(+), 13 deletions(-)
diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root
index 2729babb6d6..8717e5b3fcd 100644
--- a/docker/Dockerfile.non_root
+++ b/docker/Dockerfile.non_root
@@ -55,22 +55,10 @@ COPY . .
# Set non-root flag for build time consistency
ENV LITELLM_NON_ROOT=true
-# Stage the pre-built Admin UI from the checked-in Next.js static export.
-# _experimental/out/ is regenerated as part of the release runbook.
-# Restructure extensionless routes (foo.html -> foo/index.html) to match the layout
-# proxy_server.py expects, and drop a readiness marker.
RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \
cp -r /app/litellm/proxy/_experimental/out/. /var/lib/litellm/ui/ && \
cp /app/litellm/proxy/logo.jpg /var/lib/litellm/assets/logo.jpg && \
- ( cd /var/lib/litellm/ui && \
- for html_file in *.html; do \
- if [ "$html_file" != "index.html" ] && [ -f "$html_file" ]; then \
- folder_name="${html_file%.html}" && \
- mkdir -p "$folder_name" && \
- mv "$html_file" "$folder_name/index.html"; \
- fi; \
- done && \
- touch .litellm_ui_ready )
+ touch /var/lib/litellm/ui/.litellm_ui_ready
RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
if [ "$PROXY_EXTRAS_SOURCE" = "published" ]; then \
diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py
index ae0996d16d5..ba9c3b75bae 100644
--- a/tests/test_litellm/proxy/test_proxy_server.py
+++ b/tests/test_litellm/proxy/test_proxy_server.py
@@ -604,6 +604,49 @@ def test_ui_extensionless_route_requires_restructure(tmp_path):
assert "login" in response.text
+def test_admin_ui_export_serves_nested_extensionless_routes():
+ out_dir = (
+ Path(litellm.__file__).parent / "proxy" / "_experimental" / "out"
+ )
+ assert out_dir.is_dir(), f"missing UI export at {out_dir}"
+
+ nested_html_offenders = [
+ path.relative_to(out_dir).as_posix()
+ for path in out_dir.rglob("*.html")
+ if path.parent != out_dir
+ and path.name != "index.html"
+ and "_next" not in path.parts
+ and "litellm-asset-prefix" not in path.parts
+ ]
+ assert not nested_html_offenders, (
+ "Nested routes must be named index.html. Offenders: "
+ f"{nested_html_offenders}"
+ )
+
+ callback_index = out_dir / "mcp" / "oauth" / "callback" / "index.html"
+ assert callback_index.is_file(), (
+ f"MCP OAuth callback page must exist at {callback_index}; "
+ "without it /ui/mcp/oauth/callback 404s after Linear redirects back."
+ )
+
+ fastapi_app = FastAPI()
+ fastapi_app.mount(
+ "/ui", StaticFiles(directory=str(out_dir), html=True), name="ui"
+ )
+ client = TestClient(fastapi_app)
+
+ redirect = client.get(
+ "/ui/mcp/oauth/callback?code=abc&state=xyz",
+ follow_redirects=False,
+ )
+ assert redirect.status_code == 307
+ assert redirect.headers["location"].endswith("/ui/mcp/oauth/callback/?code=abc&state=xyz")
+
+ landed = client.get("/ui/mcp/oauth/callback?code=abc&state=xyz")
+ assert landed.status_code == 200
+ assert "