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 "