Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions litellm/proxy/health_endpoints/_health_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the router path
clientside_credential_keys,
)
from litellm.secret_managers.main import get_secret_bool

#### Health ENDPOINTS ####

Expand Down Expand Up @@ -1447,6 +1448,31 @@ def callback_name(callback):
return str(callback)


DISABLE_NO_REDIS_WARNING_ENV_VAR: Final = "LITELLM_DISABLE_NO_REDIS_WARNING"


def _show_no_redis_warning() -> bool:
"""
Whether the UI should warn that no Redis is configured.

Redis is what makes rate limits, budgets, router state, and cache
invalidation consistent across workers, so a proxy running without it is
only safe as a single worker. Both places a Redis can land count: the
coordination cache (from a Redis response cache, general_settings.
coordination_redis, or the REDIS_* env fallback) and the router's own
Redis (router_settings.redis_host), which backs cooldowns and usage-based
routing on its own. Operators who know they run one worker can silence the
warning with LITELLM_DISABLE_NO_REDIS_WARNING=true.
"""
from litellm.proxy.proxy_server import llm_router, redis_usage_cache

if redis_usage_cache is not None:
return False
if llm_router is not None and llm_router.cache.redis_cache is not None:
return False
return get_secret_bool(DISABLE_NO_REDIS_WARNING_ENV_VAR, False) is not True


async def _get_health_readiness_details(
response: Response | None = None,
) -> dict[str, Any]:
Expand Down Expand Up @@ -1487,6 +1513,7 @@ async def _get_health_readiness_details(
# check log level
log_level_name: Final = logging.getLevelName(verbose_logger.getEffectiveLevel())
is_detailed_debug: Final = verbose_logger.isEnabledFor(logging.DEBUG)
show_no_redis_warning: Final = _show_no_redis_warning()

# check DB
if prisma_client is not None: # if db passed in, check if it's connected
Expand All @@ -1506,6 +1533,7 @@ async def _get_health_readiness_details(
"use_aiohttp_transport": AsyncHTTPHandler._should_use_aiohttp_transport(),
"log_level": log_level_name,
"is_detailed_debug": is_detailed_debug,
"show_no_redis_warning": show_no_redis_warning,
}
else:
return {
Expand All @@ -1517,6 +1545,7 @@ async def _get_health_readiness_details(
"use_aiohttp_transport": AsyncHTTPHandler._should_use_aiohttp_transport(),
"log_level": log_level_name,
"is_detailed_debug": is_detailed_debug,
"show_no_redis_warning": show_no_redis_warning,
}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Service Unhealthy ({e})")
Expand Down
89 changes: 89 additions & 0 deletions tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.health_endpoints._health_endpoints import (
_db_health_readiness_check,
_show_no_redis_warning,
get_callback_identifier,
health_license_endpoint,
health_services_endpoint,
Expand Down Expand Up @@ -2457,3 +2458,91 @@ def test_stored_credential_reference_kept_when_request_sets_no_connection(self):
)
assert base["litellm_credential_name"] == "OpenAI-prod"
assert base["api_key"] == "sk-configured"


class TestNoRedisWarning:
"""`show_no_redis_warning` drives the Admin UI's default-on "no Redis" banner."""

@staticmethod
def _router(redis_cache):
return SimpleNamespace(cache=SimpleNamespace(redis_cache=redis_cache))

def test_warns_when_no_redis_is_configured(self, monkeypatch):
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
):
assert _show_no_redis_warning() is True

def test_warns_when_there_is_no_router_at_all(self, monkeypatch):
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", None),
):
assert _show_no_redis_warning() is True

def test_stays_quiet_when_a_coordination_redis_is_configured(self, monkeypatch):
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", MagicMock()),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
):
assert _show_no_redis_warning() is False

def test_stays_quiet_when_only_the_router_has_redis(self, monkeypatch):
"""router_settings.redis_host alone backs cooldowns and usage-based routing."""
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(MagicMock())),
):
assert _show_no_redis_warning() is False

@pytest.mark.parametrize("value", ["true", "True"])
def test_env_var_suppresses_the_warning(self, monkeypatch, value):
monkeypatch.setenv("LITELLM_DISABLE_NO_REDIS_WARNING", value)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
):
assert _show_no_redis_warning() is False

def test_env_var_set_false_keeps_the_warning(self, monkeypatch):
monkeypatch.setenv("LITELLM_DISABLE_NO_REDIS_WARNING", "false")
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
):
assert _show_no_redis_warning() is True

@pytest.mark.asyncio
@pytest.mark.parametrize("has_prisma_client", [True, False])
async def test_readiness_details_carries_the_flag(self, monkeypatch, has_prisma_client):
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
prisma_client = MagicMock() if has_prisma_client else None
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
patch.object(
_health_endpoints_module,
"_db_health_readiness_check",
AsyncMock(return_value={"status": "connected"}),
),
):
details = await _health_endpoints_module._get_health_readiness_details()
assert details["show_no_redis_warning"] is True

with (
patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
patch("litellm.proxy.proxy_server.redis_usage_cache", MagicMock()),
patch.object(
_health_endpoints_module,
"_db_health_readiness_check",
AsyncMock(return_value={"status": "connected"}),
),
):
details = await _health_endpoints_module._get_health_readiness_details()
assert details["show_no_redis_warning"] is False
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use_aiohttp_transport?: boolean;
log_level?: string;
is_detailed_debug?: boolean;
show_no_redis_warning?: boolean;
}

const fetchHealthReadinessDetails = async (accessToken: string): Promise<HealthReadinessDetailsResponse> => {
Expand Down Expand Up @@ -42,7 +43,7 @@
export const useHealthReadinessDetails = (
accessToken: string | null | undefined,
): UseQueryResult<HealthReadinessDetailsResponse> => {
return useQuery<HealthReadinessDetailsResponse>({

Check warning on line 46 in ui/litellm-dashboard/src/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 5 properties passed inline as an argument; assign it to a named variable first
queryKey: healthReadinessDetailsKeys.detail("readiness"),
queryFn: () => fetchHealthReadinessDetails(accessToken!),
enabled: Boolean(accessToken),
Expand Down
4 changes: 4 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ vi.mock("@/components/DebugWarningBanner", () => ({
DebugWarningBanner: () => null,
}));

vi.mock("@/components/NoRedisWarningBanner", () => ({
NoRedisWarningBanner: () => null,
}));

vi.mock("@/components/LicenseExpiryBanner", () => ({
LicenseExpiryBanner: () => null,
}));
Expand Down
3 changes: 3 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { UserBanner } from "@/components/UserBanner";
import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages";
Expand Down Expand Up @@ -55,7 +56,7 @@
// targetOrigin is the configured plugin URL — no other origin receives it.
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !auth || auth.plugin !== activePluginName || !agentPlatformUrl) return;

Check warning on line 59 in ui/litellm-dashboard/src/app/(dashboard)/layout.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 4 conditions; extract it into a named variable
const send = () => {
iframe.contentWindow?.postMessage({ type: "litellm-auth", session_claim: auth.claim }, agentPlatformUrl);
};
Expand Down Expand Up @@ -120,6 +121,7 @@
<div className="flex h-screen flex-col overflow-hidden bg-background">
<Navbar accessToken={accessToken} isPublicPage={false} />
<DebugWarningBanner accessToken={accessToken} />
<NoRedisWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<main className="flex min-h-0 flex-1 overflow-hidden">
Expand All @@ -143,6 +145,7 @@
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader page={page} />
<DebugWarningBanner accessToken={accessToken} />
<NoRedisWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { renderWithProviders, screen } from "../../tests/test-utils";
import { vi } from "vitest";
import { NoRedisWarningBanner } from "./NoRedisWarningBanner";
import type { HealthReadinessDetailsResponse } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import type { UseQueryResult } from "@tanstack/react-query";

vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
useHealthReadinessDetails: vi.fn(),
}));

import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";

const mockDetails = (data: Partial<HealthReadinessDetailsResponse> | undefined) => {
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data } as UseQueryResult<HealthReadinessDetailsResponse>);
};

describe("NoRedisWarningBanner", () => {
it("should warn that Redis is recommended when the proxy reports no Redis", () => {
mockDetails({ status: "healthy", show_no_redis_warning: true });
renderWithProviders(<NoRedisWarningBanner accessToken="token" />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/No Redis configured\. Redis is highly recommended/i)).toBeInTheDocument();
});

it("should link to the docs page listing what breaks without Redis", () => {
mockDetails({ status: "healthy", show_no_redis_warning: true });
renderWithProviders(<NoRedisWarningBanner accessToken="token" />);
expect(screen.getByRole("link", { name: /does not work without Redis/i })).toHaveAttribute(
"href",
"https://docs.litellm.ai/docs/proxy/redis_requirements",
);
});

it("should name the env var that suppresses it", () => {
mockDetails({ status: "healthy", show_no_redis_warning: true });
renderWithProviders(<NoRedisWarningBanner accessToken="token" />);
expect(screen.getByText("LITELLM_DISABLE_NO_REDIS_WARNING=true")).toBeInTheDocument();
});

it("should render nothing when the proxy reports the warning is not needed", () => {
mockDetails({ status: "healthy", show_no_redis_warning: false });
const { container } = renderWithProviders(<NoRedisWarningBanner accessToken="token" />);
expect(container).toBeEmptyDOMElement();
});

it("should render nothing when readiness details are unavailable", () => {
mockDetails(undefined);
const { container } = renderWithProviders(<NoRedisWarningBanner accessToken={null} />);
expect(container).toBeEmptyDOMElement();
});

it("should pass the access token to the readiness hook", () => {
mockDetails(undefined);
renderWithProviders(<NoRedisWarningBanner accessToken="my-token" />);
expect(useHealthReadinessDetails).toHaveBeenCalledWith("my-token");
});
});
40 changes: 40 additions & 0 deletions ui/litellm-dashboard/src/components/NoRedisWarningBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import React from "react";
import { TriangleAlert } from "lucide-react";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";

const REDIS_DOCS_URL = "https://docs.litellm.ai/docs/proxy/redis_requirements";

interface NoRedisWarningBannerProps {
accessToken: string | null;
}

export const NoRedisWarningBanner: React.FC<NoRedisWarningBannerProps> = ({ accessToken }) => {
const { data: healthData } = useHealthReadinessDetails(accessToken);

if (!healthData?.show_no_redis_warning) {
return null;
}

return (
<div
role="alert"
className="flex items-start gap-3 border-b border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<TriangleAlert className="mt-0.5 size-5 shrink-0" aria-hidden="true" />
<div>
<p className="font-semibold">No Redis configured. Redis is highly recommended</p>
<p>
Rate limits, budgets, router state, and cache invalidation are per worker without Redis, so limits are
enforced once per worker and spend can overshoot.{" "}
<a className="underline" href={REDIS_DOCS_URL} target="_blank" rel="noreferrer">
See everything that does not work without Redis
</a>
. If you run a single worker and this is intentional, set{" "}
<code className="font-mono">LITELLM_DISABLE_NO_REDIS_WARNING=true</code> to hide this banner.
</p>
</div>
</div>
);
};
Loading