From 708d4dc692598e4543ecb080af9a10e2d39067d4 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 9 Jul 2026 20:37:04 -0700 Subject: [PATCH] feat: serve /ui SPA fallback so /api-keys/[keyid] resolves; harden key lookup Add SPAStaticFiles for the /ui mount: an unmatched navigation request (a static-export client route like /ui/api-keys/) serves the app shell instead of 404, while missing assets still 404. This makes the dynamic key detail route resolve on click, refresh, and deep link on the single-container proxy; the microservices helm chart already does the same in ui/nginx.conf Addresses Greptile review: KeyDetailPage now reads the key id from window.location (the SPA-fallback shell bakes a placeholder param into useParams) and decodes it once instead of double-decoding, and useKeyInfo returns only an exact token match so substring matching on /key/list can no longer surface a different key --- litellm/proxy/proxy_server.py | 54 ++++++++++++++++++- .../api-keys/[keyid]/KeyDetailPage.tsx | 18 ++++--- .../src/app/(dashboard)/hooks/keys/useKeys.ts | 2 +- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4114bda47c9..83ae78afd1a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -604,6 +604,58 @@ from fastapi.routing import APIRouter from fastapi.security import OAuth2PasswordBearer from fastapi.security.api_key import APIKeyHeader from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.responses import Response as StarletteResponse +from starlette.types import Scope as StarletteScope + +_SPA_SHELL_PLACEHOLDER_SEGMENT = "placeholder" + + +class SPAStaticFiles(StaticFiles): + def _looks_like_navigation(self, path: str, scope: StarletteScope) -> bool: + headers = dict(scope.get("headers") or []) + accept = headers.get(b"accept", b"").decode("latin-1") + if "text/html" in accept: + return True + last_segment = path.rstrip("/").rsplit("/", 1)[-1] + return "." not in last_segment + + def _spa_shell(self, path: str) -> Optional[str]: + trimmed = path.strip("/") + segments = trimmed.split("/") if trimmed else [] + if segments: + parent = "/".join(segments[:-1]) + placeholder = ( + f"{parent}/{_SPA_SHELL_PLACEHOLDER_SEGMENT}/index.html" + if parent + else f"{_SPA_SHELL_PLACEHOLDER_SEGMENT}/index.html" + ) + if os.path.isfile(os.path.join(str(self.directory), placeholder)): + return placeholder + if os.path.isfile(os.path.join(str(self.directory), "index.html")): + return "index.html" + return None + + async def _try_original(self, path: str, scope: StarletteScope) -> Optional[StarletteResponse]: + try: + return await super().get_response(path, scope) + except StarletteHTTPException as exc: + if exc.status_code != 404: + raise + return None + + async def get_response(self, path: str, scope: StarletteScope) -> StarletteResponse: + original = await self._try_original(path, scope) + if original is not None and original.status_code != 404: + return original + if self._looks_like_navigation(path, scope): + shell = self._spa_shell(path) + if shell is not None: + return await super().get_response(shell, scope) + if original is not None: + return original + raise StarletteHTTPException(status_code=404) + from litellm.types.agents import AgentConfig @@ -1694,7 +1746,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", SPAStaticFiles(directory=ui_path, html=True), name="ui") def _restructure_ui_html_files(ui_root: str) -> None: """Ensure each exported HTML route is available as /index.html.""" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/KeyDetailPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/KeyDetailPage.tsx index 61a175f0b00..1c7b7d3001b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/KeyDetailPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/KeyDetailPage.tsx @@ -6,20 +6,26 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import KeyInfoView from "@/components/templates/key_info_view"; import { migratedHref } from "@/utils/migratedPages"; -import { useParams, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +function keyIdFromPathname(pathname: string): string { + const segments = pathname.replace(/\/+$/, "").split("/"); + return decodeURIComponent(segments[segments.length - 1] ?? ""); +} export default function KeyDetailPage() { const router = useRouter(); - const params = useParams(); const { isLoading: authLoading, isAuthorized } = useAuthorized(); - - const rawKeyId = params?.keyid; - const keyId = decodeURIComponent(Array.isArray(rawKeyId) ? rawKeyId[0] : rawKeyId ?? ""); + const [keyId] = useState(() => (typeof window === "undefined" ? "" : keyIdFromPathname(window.location.pathname))); const { data: keyData, isPending } = useKeyInfo(keyId); const { data: teams } = useAllTeams(); - if (authLoading || !isAuthorized || isPending) { + if (authLoading || !isAuthorized) { + return ; + } + if (!keyId || isPending) { return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index 534be82bd06..d10655377b3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -121,7 +121,7 @@ export const useKeyInfo = (keyId: string): UseQueryResult => queryFn: async () => { const response: KeysResponse = await keyListCall(accessToken!, 1, 1, { keyHash: keyId, expand: "user" }); const keys = response?.keys ?? []; - return keys.find((key) => key.token === keyId) ?? keys[0] ?? null; + return keys.find((key) => key.token === keyId) ?? null; }, enabled: Boolean(accessToken && keyId), staleTime: 30000, // 30 seconds