From 1bea77e564ef4934f9a21face37235c87f09cdda Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 13 May 2026 20:33:23 -0700 Subject: [PATCH] fix(ui): fetch version + debug flag from /health/readiness/details (#27896) * fix(ui): fetch version + debug flag from /health/readiness/details The proxy moved `litellm_version`, `is_detailed_debug`, and other diagnostic fields off the public `/health/readiness` payload behind an auth-gated `/health/readiness/details` endpoint. The navbar version tag and the detailed-debug-mode banner stopped working because they were still reading those fields from the unauthed response, which no longer contains them. Replace `useHealthReadiness` with a `useHealthReadinessDetails` hook that takes an `accessToken` argument and sends a Bearer header to the auth-gated endpoint. The hook stays disabled while `accessToken` is falsy, so the navbar can keep rendering on the public model hub (where the token is null) without triggering an auth redirect or a 401-loop. * fix(ui): disable retries on readiness/details + cover token forwarding Two small follow-ups on the readiness/details migration: - Set `retry: false` on the query. The payload feeds a passive navbar tag and a debug banner; a 401 from an expired token shouldn't fan out into three retries against the proxy. - Add navbar specs that assert the `accessToken` prop is forwarded into the hook (matches the DebugWarningBanner spec). Without this, the navbar could silently regress to passing `undefined` and the existing tests wouldn't catch it. --- .../healthReadiness/useHealthReadiness.ts | 29 --------- .../useHealthReadinessDetails.ts | 61 +++++++++++++++++++ .../src/app/(dashboard)/layout.tsx | 2 +- .../components/DebugWarningBanner.test.tsx | 38 ++++++++---- .../src/components/DebugWarningBanner.tsx | 10 ++- .../src/components/navbar.test.tsx | 31 ++++++++-- .../src/components/navbar.tsx | 4 +- 7 files changed, 122 insertions(+), 53 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadiness.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadiness.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadiness.ts deleted file mode 100644 index 10d29d86ad..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadiness.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getProxyBaseUrl } from "@/components/networking"; -import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { createQueryKeys } from "../common/queryKeysFactory"; - -const healthReadinessKeys = createQueryKeys("healthReadiness"); - -interface HealthReadinessResponse { - litellm_version?: string; - log_level?: string; - is_detailed_debug?: boolean; - [key: string]: any; -} - -const fetchHealthReadiness = async (): Promise => { - const baseUrl = getProxyBaseUrl(); - const response = await fetch(`${baseUrl}/health/readiness`); - if (!response.ok) { - throw new Error(`Failed to fetch health readiness: ${response.statusText}`); - } - return response.json(); -}; - -export const useHealthReadiness = (): UseQueryResult => { - return useQuery({ - queryKey: healthReadinessKeys.detail("readiness"), - queryFn: fetchHealthReadiness, - staleTime: 5 * 60 * 1000, // 5 minutes - }); -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails.ts new file mode 100644 index 0000000000..5838dbd0ee --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails.ts @@ -0,0 +1,61 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { + getGlobalLitellmHeaderName, + getProxyBaseUrl, +} from "@/components/networking"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const healthReadinessDetailsKeys = createQueryKeys("healthReadinessDetails"); + +export interface HealthReadinessDetailsResponse { + status: string; + db?: string; + cache?: unknown; + litellm_version?: string; + success_callbacks?: string[]; + use_aiohttp_transport?: boolean; + log_level?: string; + is_detailed_debug?: boolean; +} + +const fetchHealthReadinessDetails = async ( + accessToken: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const response = await fetch(`${baseUrl}/health/readiness/details`, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch health readiness details: ${response.statusText}`, + ); + } + return response.json(); +}; + +/** + * Fetches the auth-gated detailed readiness payload. + * + * The caller passes its own `accessToken` so this hook stays usable in both + * authed and unauthed shells (e.g. the public model hub renders the navbar + * with a null token). When `accessToken` is falsy the query stays disabled + * and `data` is undefined — consumers should treat that as "details + * unavailable" rather than an error. + */ +export const useHealthReadinessDetails = ( + accessToken: string | null | undefined, +): UseQueryResult => { + return useQuery({ + queryKey: healthReadinessDetailsKeys.detail("readiness"), + queryFn: () => fetchHealthReadinessDetails(accessToken!), + enabled: Boolean(accessToken), + staleTime: 5 * 60 * 1000, + // The response feeds a passive navbar tag and a debug banner — a failed + // call (e.g. expired token → 401) shouldn't fan out into three retries. + retry: false, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 94dd6eb3cf..13e93798de 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -80,7 +80,7 @@ function LayoutContent({ children }: { children: React.ReactNode }) { isDarkMode={false} toggleDarkMode={() => { }} /> - +
({ - useHealthReadiness: vi.fn(), +vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({ + useHealthReadinessDetails: vi.fn(), })); -import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; describe("DebugWarningBanner", () => { it("should render", () => { - vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any); - renderWithProviders(); + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { is_detailed_debug: true } } as any); + renderWithProviders(); expect(screen.getByRole("alert")).toBeInTheDocument(); }); it("should show warning when detailed debug mode is active", () => { - vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any); - renderWithProviders(); + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { is_detailed_debug: true } } as any); + renderWithProviders(); expect(screen.getByText(/Performance Warning: Detailed Debug Mode Active/i)).toBeInTheDocument(); }); it("should mention LITELLM_LOG=DEBUG in the description", () => { - vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any); - renderWithProviders(); + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { is_detailed_debug: true } } as any); + renderWithProviders(); expect(screen.getByText("LITELLM_LOG=DEBUG")).toBeInTheDocument(); }); it("should render nothing when is_detailed_debug is false", () => { - vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: false } } as any); - const { container } = renderWithProviders(); + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { is_detailed_debug: false } } as any); + const { container } = renderWithProviders(); expect(container).toBeEmptyDOMElement(); }); it("should render nothing when health data is undefined", () => { - vi.mocked(useHealthReadiness).mockReturnValue({ data: undefined } as any); - const { container } = renderWithProviders(); + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: undefined } as any); + const { container } = renderWithProviders(); expect(container).toBeEmptyDOMElement(); }); + + it("should pass accessToken to the readiness hook", () => { + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: undefined } as any); + renderWithProviders(); + expect(useHealthReadinessDetails).toHaveBeenCalledWith("my-token"); + }); + + it("should pass a null accessToken through (disables the hook)", () => { + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: undefined } as any); + renderWithProviders(); + expect(useHealthReadinessDetails).toHaveBeenCalledWith(null); + }); }); diff --git a/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx b/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx index e4b2ab69a1..38fb485b58 100644 --- a/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx +++ b/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx @@ -2,10 +2,14 @@ import React from "react"; import { Alert } from "antd"; -import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; -export const DebugWarningBanner: React.FC = () => { - const { data: healthData } = useHealthReadiness(); +interface DebugWarningBannerProps { + accessToken: string | null; +} + +export const DebugWarningBanner: React.FC = ({ accessToken }) => { + const { data: healthData } = useHealthReadinessDetails(accessToken); // Only show banner if detailed debug mode is explicitly enabled if (!healthData?.is_detailed_debug) { diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index 9a3c781aaa..2e12216496 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -90,7 +90,7 @@ vi.mock("./Navbar/CommunityEngagementButtons/CommunityEngagementButtons", () => // Create mock functions that can be controlled in tests let mockUseThemeImpl = () => ({ logoUrl: null as string | null }); -let mockUseHealthReadinessImpl = () => ({ data: null as any }); +let mockUseHealthReadinessDetailsImpl = () => ({ data: null as any }); let mockGetLocalStorageItemImpl = (key: string) => null as string | null; let mockUseAuthorizedImpl = () => ({ userId: "test-user", @@ -99,12 +99,17 @@ let mockUseAuthorizedImpl = () => ({ premiumUser: false, }); +const useHealthReadinessDetailsSpy = vi.hoisted(() => vi.fn()); + vi.mock("@/contexts/ThemeContext", () => ({ useTheme: () => mockUseThemeImpl(), })); -vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness", () => ({ - useHealthReadiness: () => mockUseHealthReadinessImpl(), +vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({ + useHealthReadinessDetails: (accessToken: string | null | undefined) => { + useHealthReadinessDetailsSpy(accessToken); + return mockUseHealthReadinessDetailsImpl(); + }, })); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ @@ -204,14 +209,30 @@ describe("Navbar", () => { }); it("should show version badge when health data contains version", () => { - mockUseHealthReadinessImpl = () => ({ data: { litellm_version: "1.0.0" } }); + mockUseHealthReadinessDetailsImpl = () => ({ data: { litellm_version: "1.0.0" } }); renderWithProviders(); expect(screen.getByText("v1.0.0")).toBeInTheDocument(); // Reset mock - mockUseHealthReadinessImpl = () => ({ data: null }); + mockUseHealthReadinessDetailsImpl = () => ({ data: null }); + }); + + it("should forward accessToken to the readiness hook", () => { + useHealthReadinessDetailsSpy.mockClear(); + + renderWithProviders(); + + expect(useHealthReadinessDetailsSpy).toHaveBeenCalledWith("my-token"); + }); + + it("should forward a null accessToken to the readiness hook (disables the hook)", () => { + useHealthReadinessDetailsSpy.mockClear(); + + renderWithProviders(); + + expect(useHealthReadinessDetailsSpy).toHaveBeenCalledWith(null); }); it("should use custom logo from theme context", () => { diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 6e0a47a23f..055038b6d8 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,4 +1,4 @@ -import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; @@ -46,7 +46,7 @@ const Navbar: React.FC = ({ const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); const { logoUrl } = useTheme(); - const { data: healthData } = useHealthReadiness(); + const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; const disableBouncingIcon = useDisableBouncingIcon();