mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 10:24:03 +00:00
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.
This commit is contained in:
@@ -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<HealthReadinessResponse> => {
|
||||
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<HealthReadinessResponse> => {
|
||||
return useQuery<HealthReadinessResponse>({
|
||||
queryKey: healthReadinessKeys.detail("readiness"),
|
||||
queryFn: fetchHealthReadiness,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
+61
@@ -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<HealthReadinessDetailsResponse> => {
|
||||
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<HealthReadinessDetailsResponse> => {
|
||||
return useQuery<HealthReadinessDetailsResponse>({
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -80,7 +80,7 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
isDarkMode={false}
|
||||
toggleDarkMode={() => { }}
|
||||
/>
|
||||
<DebugWarningBanner />
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<div className="mt-2">
|
||||
<SidebarProvider
|
||||
|
||||
@@ -2,40 +2,52 @@ import { renderWithProviders, screen } from "../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
import { DebugWarningBanner } from "./DebugWarningBanner";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness", () => ({
|
||||
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(<DebugWarningBanner />);
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { is_detailed_debug: true } } as any);
|
||||
renderWithProviders(<DebugWarningBanner accessToken="token" />);
|
||||
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(<DebugWarningBanner />);
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { is_detailed_debug: true } } as any);
|
||||
renderWithProviders(<DebugWarningBanner accessToken="token" />);
|
||||
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(<DebugWarningBanner />);
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { is_detailed_debug: true } } as any);
|
||||
renderWithProviders(<DebugWarningBanner accessToken="token" />);
|
||||
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(<DebugWarningBanner />);
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { is_detailed_debug: false } } as any);
|
||||
const { container } = renderWithProviders(<DebugWarningBanner accessToken="token" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("should render nothing when health data is undefined", () => {
|
||||
vi.mocked(useHealthReadiness).mockReturnValue({ data: undefined } as any);
|
||||
const { container } = renderWithProviders(<DebugWarningBanner />);
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: undefined } as any);
|
||||
const { container } = renderWithProviders(<DebugWarningBanner accessToken="token" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("should pass accessToken to the readiness hook", () => {
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: undefined } as any);
|
||||
renderWithProviders(<DebugWarningBanner accessToken="my-token" />);
|
||||
expect(useHealthReadinessDetails).toHaveBeenCalledWith("my-token");
|
||||
});
|
||||
|
||||
it("should pass a null accessToken through (disables the hook)", () => {
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: undefined } as any);
|
||||
renderWithProviders(<DebugWarningBanner accessToken={null} />);
|
||||
expect(useHealthReadinessDetails).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DebugWarningBannerProps> = ({ accessToken }) => {
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
|
||||
// Only show banner if detailed debug mode is explicitly enabled
|
||||
if (!healthData?.is_detailed_debug) {
|
||||
|
||||
@@ -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(<Navbar {...defaultProps} />);
|
||||
|
||||
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(<Navbar {...defaultProps} accessToken="my-token" />);
|
||||
|
||||
expect(useHealthReadinessDetailsSpy).toHaveBeenCalledWith("my-token");
|
||||
});
|
||||
|
||||
it("should forward a null accessToken to the readiness hook (disables the hook)", () => {
|
||||
useHealthReadinessDetailsSpy.mockClear();
|
||||
|
||||
renderWithProviders(<Navbar {...defaultProps} accessToken={null} />);
|
||||
|
||||
expect(useHealthReadinessDetailsSpy).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("should use custom logo from theme context", () => {
|
||||
|
||||
@@ -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<NavbarProps> = ({
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user