mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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/<hash>) 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
This commit is contained in:
parent
b715d69558
commit
708d4dc692
3 changed files with 66 additions and 8 deletions
|
|
@ -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 <route>/index.html."""
|
||||
|
|
|
|||
|
|
@ -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 <LoadingScreen />;
|
||||
}
|
||||
if (!keyId || isPending) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export const useKeyInfo = (keyId: string): UseQueryResult<KeyResponse | null> =>
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue