fix(admin-ui): emit nested routes as <dir>/index.html (#28106)
Some checks failed
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / schema-migration (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled

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 <dir>/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.
This commit is contained in:
Mateo Wang 2026-05-20 11:24:45 -07:00 • committed by GitHub
parent 8757b86a09
commit 7e0dced0bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 45 additions and 13 deletions

View file

@ -54,22 +54,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 \

View file

@ -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 "<html" in landed.text.lower()
def test_restructure_always_happens(monkeypatch):
"""
Test that restructuring logic always executes regardless of LITELLM_NON_ROOT setting.

View file

@ -14,6 +14,7 @@ const nextConfig = {
},
basePath: "",
assetPrefix: "/litellm-asset-prefix",
trailingSlash: true,
turbopack: {
// Must be absolute; "." is no longer allowed
root: __dirname,