feat(proxy): add deployment warning endpoints with instance-wide ignore and snooze (#46347)

* feat(proxy): add deployment warning endpoints with instance-wide ignore and snooze

GET /health/deployment_warnings returns the active deployment warnings
(env credential login, license expiry, no Redis on multiple workers,
detailed debug logging) with a severity and ignore state, for proxy admins
and proxy admin viewers. PATCH /health/deployment_warnings/{warning_id}
lets a proxy admin ignore a warning forever or snooze it for up to 30 days,
or restore it. Ignores live in one LiteLLM_UISettings row, apply to every
admin, and are written to the config audit log. Expired snoozes drop out
on read, and LITELLM_DISABLE_NO_REDIS_WARNING is reported as an env var
ignore. /health/readiness/details is unchanged

* fix(proxy): keep readiness Redis short-circuit ahead of the env lookup and harden ignore storage

Readiness details again answers from Redis before reading
LITELLM_DISABLE_NO_REDIS_WARNING, so proxies with Redis and a secret
manager do not fetch it on every poll. Stored ignores require timezone
aware datetimes and are read from the writer before a rewrite

* fix(proxy): keep deployment warning ignores readable in the config audit log

The audit payload keyed ignores by warning id, so the secret matcher
redacted the env_credential_login entry and the audit row lost who
snoozed it and until when. Log a list of id and ignore entries instead
This commit is contained in:
yuneng-jiang 2026-10-14 10:53:38 -07:00 • committed by GitHub
parent d0f3a2dfdb
commit a0ac9b4f1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1081 additions and 13 deletions

View file

@ -1464,7 +1464,7 @@ async def shared_health_check_status_endpoint(
)
def _read_license_data() -> EnterpriseLicenseData | None:
def read_license_data() -> EnterpriseLicenseData | None:
from litellm.proxy.proxy_server import _license_check, premium_user_data
license_data: EnterpriseLicenseData | None = premium_user_data or _license_check.airgapped_license_data
@ -1509,7 +1509,7 @@ async def health_license_endpoint(
"""Return metadata about the configured LiteLLM license without exposing the key."""
from litellm.proxy.proxy_server import _license_check, premium_user
license_data: Final = _read_license_data()
license_data: Final = read_license_data()
has_license: Final = bool(getattr(_license_check, "license_str", None))
license_type: Final = "enterprise" if premium_user else "community"
@ -1703,9 +1703,9 @@ def callback_name(callback):
DISABLE_NO_REDIS_WARNING_ENV_VAR: Final = "LITELLM_DISABLE_NO_REDIS_WARNING"
async def _show_no_redis_warning() -> bool:
async def no_redis_on_multiple_workers() -> bool:
"""
Whether the UI should warn that no Redis is configured.
Whether this proxy runs without Redis on more than one worker.
Redis is what makes rate limits, budgets, router state, and cache
invalidation consistent across workers, so a proxy running without it is
@ -1716,22 +1716,34 @@ async def _show_no_redis_warning() -> bool:
routing on its own. A deployment whose worker-heartbeat census proves it
is exactly one worker needs no cross-worker coordination, so it never
warns; when the census is unavailable or shows more than one worker, the
warning stands unless LITELLM_DISABLE_NO_REDIS_WARNING=true silences it.
warning stands.
"""
from litellm.proxy.proxy_server import llm_router, prisma_client, redis_usage_cache
from litellm.proxy.proxy_server import prisma_client
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
if get_secret_bool(DISABLE_NO_REDIS_WARNING_ENV_VAR, False) is True:
if _redis_configured():
return False
if prisma_client is None:
return True
return await count_live_proxy_workers(prisma_client) != 1
def _show_env_credential_login_warning() -> bool:
def _redis_configured() -> bool:
from litellm.proxy.proxy_server import llm_router, redis_usage_cache
return redis_usage_cache is not None or (llm_router is not None and llm_router.cache.redis_cache is not None)
def no_redis_warning_disabled_by_env() -> bool:
return get_secret_bool(DISABLE_NO_REDIS_WARNING_ENV_VAR, False) is True
async def _show_no_redis_warning() -> bool:
if _redis_configured() or no_redis_warning_disabled_by_env():
return False
return await no_redis_on_multiple_workers()
def env_credential_login_enabled() -> bool:
from litellm.proxy.auth.login_utils import is_env_credential_login_enabled
from litellm.proxy.proxy_server import general_settings
@ -1779,7 +1791,7 @@ async def _get_health_readiness_details(
log_level_name: Final = logging.getLevelName(verbose_logger.getEffectiveLevel())
is_detailed_debug: Final = verbose_logger.isEnabledFor(logging.DEBUG)
show_no_redis_warning: Final = await _show_no_redis_warning()
show_env_credential_login_warning: Final = _show_env_credential_login_warning()
show_env_credential_login_warning: Final = env_credential_login_enabled()
# check DB
if prisma_client is not None: # if db passed in, check if it's connected

View file

@ -0,0 +1,350 @@
import json
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from types import MappingProxyType
from typing import Annotated, Final, Literal, Protocol, TypeAlias
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import AwareDatetime, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError, model_validator
from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.proxy._types import AUDIT_ACTIONS, LitellmTableNames, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.health_endpoints._health_endpoints import (
DISABLE_NO_REDIS_WARNING_ENV_VAR,
env_credential_login_enabled,
no_redis_on_multiple_workers,
no_redis_warning_disabled_by_env,
read_license_data,
)
from litellm.repositories.deployment_warning_ignore_repository import (
DEPLOYMENT_WARNING_IGNORES_ROW_ID,
DeploymentWarningIgnoreRepository,
)
from litellm.types.llms.base import LiteLLMBaseModel
router: Final = APIRouter()
ENV_CREDENTIAL_LOGIN_WARNING_ID: Final = "env_credential_login"
NO_REDIS_WARNING_ID: Final = "no_redis"
DETAILED_DEBUG_WARNING_ID: Final = "detailed_debug"
LICENSE_EXPIRY_WARNING_ID_PREFIX: Final = "license_expiry:"
LICENSE_EXPIRY_WARNING_DAYS: Final = 30
LICENSE_EXPIRY_CRITICAL_DAYS: Final = 7
MAX_SNOOZE_SECONDS: Final = 30 * 24 * 60 * 60
_STATIC_WARNING_IDS: Final = frozenset(
{ENV_CREDENTIAL_LOGIN_WARNING_ID, NO_REDIS_WARNING_ID, DETAILED_DEBUG_WARNING_ID}
)
WarningSeverity: TypeAlias = Literal["critical", "warning"]
@dataclass(frozen=True, slots=True)
class WarningCondition:
id: str
severity: WarningSeverity
env_var_ignore: str | None = None
class AdminIgnore(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
source: Literal["admin"] = "admin"
ignored_by: str
ignored_at: AwareDatetime
ignored_until: AwareDatetime | None = Field(
default=None,
description="When the snooze ends. Null means the warning is ignored until an admin restores it.",
)
class EnvVarIgnore(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
source: Literal["env_var"] = "env_var"
env_var: str
class DeploymentWarning(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
id: str
severity: WarningSeverity
ignore: Annotated[AdminIgnore | EnvVarIgnore, Field(discriminator="source")] | None = None
class DeploymentWarningsResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
warnings: tuple[DeploymentWarning, ...]
ignore_available: bool = Field(description="False when no database is connected, so warnings cannot be ignored.")
class DeploymentWarningIgnoreUpdate(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
ignored: bool = Field(description="True ignores the warning for every admin, false restores it.")
snooze_seconds: int | None = Field(
default=None,
gt=0,
le=MAX_SNOOZE_SECONDS,
description="How long to ignore the warning, up to 30 days. Leave it out to ignore it forever.",
)
@model_validator(mode="after")
def _snooze_only_when_ignoring(self) -> "DeploymentWarningIgnoreUpdate":
if self.snooze_seconds is not None and not self.ignored:
raise ValueError("snooze_seconds is only valid when ignored is true")
return self
class DeploymentWarningIgnoreResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
id: str
ignore: AdminIgnore | None
class DeploymentWarningIgnoreStore(Protocol):
async def get_raw_ignores(self) -> object: ...
async def upsert_ignores(self, payload: str) -> None: ...
class ConfigAuditLogger(Protocol):
async def __call__(
self,
param_name: str,
action: AUDIT_ACTIONS,
before_value: JsonValue | None,
after_value: JsonValue | None,
user_api_key_dict: UserAPIKeyAuth,
table_name: LitellmTableNames = ...,
) -> None: ...
_IGNORES_ADAPTER: Final[TypeAdapter[Mapping[str, AdminIgnore]]] = TypeAdapter(Mapping[str, AdminIgnore])
_JSON_VALUE_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_AUDIT_ENTRIES_ADAPTER: Final[TypeAdapter[tuple[DeploymentWarningIgnoreResponse, ...]]] = TypeAdapter(
tuple[DeploymentWarningIgnoreResponse, ...]
)
def license_expiry_condition(expiration_date: str | None, now: datetime) -> WarningCondition | None:
if not expiration_date:
return None
try:
expires_on: Final = date.fromisoformat(expiration_date)
except ValueError:
return None
days_left: Final = (expires_on - now.date()).days
if days_left > LICENSE_EXPIRY_WARNING_DAYS:
return None
return WarningCondition(
id=f"{LICENSE_EXPIRY_WARNING_ID_PREFIX}{expires_on.isoformat()}",
severity="critical" if days_left <= LICENSE_EXPIRY_CRITICAL_DAYS else "warning",
)
def deployment_warning_conditions(
*,
env_credential_login_enabled: bool,
license_expiration_date: str | None,
no_redis_on_multiple_workers: bool,
no_redis_warning_disabled_by_env: bool,
detailed_debug: bool,
now: datetime,
) -> tuple[WarningCondition, ...]:
no_redis_env_var: Final = DISABLE_NO_REDIS_WARNING_ENV_VAR if no_redis_warning_disabled_by_env else None
candidates: Final = (
WarningCondition(id=ENV_CREDENTIAL_LOGIN_WARNING_ID, severity="critical")
if env_credential_login_enabled
else None,
license_expiry_condition(license_expiration_date, now),
WarningCondition(id=NO_REDIS_WARNING_ID, severity="warning", env_var_ignore=no_redis_env_var)
if no_redis_on_multiple_workers
else None,
WarningCondition(id=DETAILED_DEBUG_WARNING_ID, severity="warning") if detailed_debug else None,
)
return tuple(condition for condition in candidates if condition is not None)
def is_known_warning_id(warning_id: str) -> bool:
if warning_id in _STATIC_WARNING_IDS:
return True
if not warning_id.startswith(LICENSE_EXPIRY_WARNING_ID_PREFIX):
return False
expiration_date: Final = warning_id.removeprefix(LICENSE_EXPIRY_WARNING_ID_PREFIX)
try:
return date.fromisoformat(expiration_date).isoformat() == expiration_date
except ValueError:
return False
def _parse_stored_ignores(raw: object) -> Mapping[str, AdminIgnore]:
if raw is None:
return MappingProxyType({})
try:
return MappingProxyType(_IGNORES_ADAPTER.validate_python(json.loads(raw) if isinstance(raw, str) else raw))
except (json.JSONDecodeError, ValidationError):
verbose_proxy_logger.warning("Ignoring unreadable %s row", DEPLOYMENT_WARNING_IGNORES_ROW_ID)
return MappingProxyType({})
async def _read_unexpired_ignores(store: DeploymentWarningIgnoreStore, now: datetime) -> Mapping[str, AdminIgnore]:
stored: Final = _parse_stored_ignores(await store.get_raw_ignores())
return MappingProxyType(
{
warning_id: ignore
for warning_id, ignore in stored.items()
if ignore.ignored_until is None or ignore.ignored_until > now
}
)
def _resolve_ignore(
condition: WarningCondition, ignores: Mapping[str, AdminIgnore]
) -> AdminIgnore | EnvVarIgnore | None:
if condition.env_var_ignore is not None:
return EnvVarIgnore(env_var=condition.env_var_ignore)
return ignores.get(condition.id)
def _ignores_as_audit_entries(ignores: Mapping[str, AdminIgnore]) -> JsonValue:
entries: Final = tuple(
DeploymentWarningIgnoreResponse(id=warning_id, ignore=ignores[warning_id]) for warning_id in sorted(ignores)
)
return _JSON_VALUE_ADAPTER.validate_json(_AUDIT_ENTRIES_ADAPTER.dump_json(entries))
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def deployment_warning_ignore_store() -> DeploymentWarningIgnoreStore | None:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
return None
return DeploymentWarningIgnoreRepository(prisma_client, use_writer=True)
def config_audit_logger() -> ConfigAuditLogger:
from litellm.proxy.proxy_server import create_config_audit_log
return create_config_audit_log
async def _deployment_warning_reader(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> UserAPIKeyAuth:
if user_api_key_dict.user_role not in (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only proxy admins and proxy admin viewers can read deployment warnings.",
)
return user_api_key_dict
async def _deployment_warning_admin(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> UserAPIKeyAuth:
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only proxy admins can ignore deployment warnings.",
)
return user_api_key_dict
async def current_warning_conditions(now: Annotated[datetime, Depends(utc_now)]) -> tuple[WarningCondition, ...]:
license_data: Final = read_license_data()
return deployment_warning_conditions(
env_credential_login_enabled=env_credential_login_enabled(),
license_expiration_date=None if license_data is None else license_data.get("expiration_date"),
no_redis_on_multiple_workers=await no_redis_on_multiple_workers(),
no_redis_warning_disabled_by_env=no_redis_warning_disabled_by_env(),
detailed_debug=verbose_logger.isEnabledFor(logging.DEBUG),
now=now,
)
@router.get(
"/health/deployment_warnings",
tags=["health"],
response_model=DeploymentWarningsResponse,
)
async def get_deployment_warnings(
_: Annotated[UserAPIKeyAuth, Depends(_deployment_warning_reader)],
conditions: Annotated[tuple[WarningCondition, ...], Depends(current_warning_conditions)],
store: Annotated[DeploymentWarningIgnoreStore | None, Depends(deployment_warning_ignore_store)],
now: Annotated[datetime, Depends(utc_now)],
) -> DeploymentWarningsResponse:
"""
List the active deployment warnings with their severity and whether an admin ignored or snoozed them.
Readable by proxy admins and proxy admin viewers.
"""
ignores: Final[Mapping[str, AdminIgnore]] = (
MappingProxyType({}) if store is None else await _read_unexpired_ignores(store, now)
)
return DeploymentWarningsResponse(
warnings=tuple(
DeploymentWarning(id=condition.id, severity=condition.severity, ignore=_resolve_ignore(condition, ignores))
for condition in conditions
),
ignore_available=store is not None,
)
@router.patch(
"/health/deployment_warnings/{warning_id}",
tags=["health"],
response_model=DeploymentWarningIgnoreResponse,
)
async def update_deployment_warning_ignore(
warning_id: str,
update: DeploymentWarningIgnoreUpdate,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(_deployment_warning_admin)],
store: Annotated[DeploymentWarningIgnoreStore | None, Depends(deployment_warning_ignore_store)],
audit_logger: Annotated[ConfigAuditLogger, Depends(config_audit_logger)],
now: Annotated[datetime, Depends(utc_now)],
) -> DeploymentWarningIgnoreResponse:
"""
Ignore, snooze, or restore one deployment warning for every admin of this proxy.
Only proxy admins can call this, and it needs a database.
"""
if not is_known_warning_id(warning_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Unknown deployment warning: {warning_id}")
if store is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Ignoring deployment warnings needs a database. Connect one to enable it.",
)
before: Final = await _read_unexpired_ignores(store, now)
ignore: Final = (
AdminIgnore(
ignored_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
ignored_at=now,
ignored_until=None if update.snooze_seconds is None else now + timedelta(seconds=update.snooze_seconds),
)
if update.ignored
else None
)
others: Final = {other_id: other for other_id, other in before.items() if other_id != warning_id}
after: Final = MappingProxyType(others if ignore is None else {**others, warning_id: ignore})
await store.upsert_ignores(_IGNORES_ADAPTER.dump_json(dict(after)).decode())
await audit_logger(
param_name=DEPLOYMENT_WARNING_IGNORES_ROW_ID,
action="updated",
before_value=_ignores_as_audit_entries(before),
after_value=_ignores_as_audit_entries(after),
user_api_key_dict=user_api_key_dict,
table_name=LitellmTableNames.UI_SETTINGS_TABLE_NAME,
)
return DeploymentWarningIgnoreResponse(id=warning_id, ignore=ignore)

View file

@ -590,6 +590,7 @@ from litellm.proxy.health_check import (
perform_health_check,
)
from litellm.proxy.health_endpoints._health_endpoints import router as health_router
from litellm.proxy.health_endpoints.deployment_warning_endpoints import router as deployment_warning_router
from litellm.proxy.hooks.model_max_budget_limiter import ( # noqa: F401, RUF100 # legacy module exports
PROXY_VirtualKeyModelMaxBudgetLimiter,
_PROXY_VirtualKeyModelMaxBudgetLimiter, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
@ -20402,6 +20403,7 @@ app.include_router(openai_files_router)
reserve_lazy_slot(app, "llm_passthrough")
app.include_router(pass_through_router)
app.include_router(health_router)
app.include_router(deployment_warning_router)
app.include_router(key_management_router)
app.include_router(internal_user_router)
app.include_router(daily_activity_router)

View file

@ -0,0 +1,23 @@
from typing import TYPE_CHECKING, Final
from litellm.repositories.table_repositories import PrismaTableRepository
if TYPE_CHECKING:
from prisma import models as prisma_models # noqa: F401 # resolved only from the quoted base-class subscript below
DEPLOYMENT_WARNING_IGNORES_ROW_ID: Final = "deployment_warning_ignores"
class DeploymentWarningIgnoreRepository(PrismaTableRepository["prisma_models.LiteLLM_UISettings"]):
table_name = "litellm_uisettings"
async def get_raw_ignores(self) -> object:
db_record: Final = await self.table.find_unique(where={"id": DEPLOYMENT_WARNING_IGNORES_ROW_ID})
return db_record.ui_settings if db_record is not None else None
async def upsert_ignores(self, payload: str) -> None:
row: Final = {"id": DEPLOYMENT_WARNING_IGNORES_ROW_ID, "ui_settings": payload}
await self.table.upsert(
where={"id": DEPLOYMENT_WARNING_IGNORES_ROW_ID},
data={"create": row, "update": {"ui_settings": payload}},
)

View file

@ -0,0 +1,475 @@
import json
import logging
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Final
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import JsonValue
from litellm._logging import verbose_logger
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.proxy._types import AUDIT_ACTIONS, LitellmTableNames, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.health_endpoints.deployment_warning_endpoints import (
WarningCondition,
config_audit_logger,
current_warning_conditions,
deployment_warning_conditions,
deployment_warning_ignore_store,
license_expiry_condition,
router,
utc_now,
)
from litellm.proxy.proxy_server import (
_dump_redacted_config, # pyright: ignore[reportPrivateUsage] # the exact redaction every config audit row goes through
)
from litellm.repositories.deployment_warning_ignore_repository import DEPLOYMENT_WARNING_IGNORES_ROW_ID
T0: Final = datetime(2026, 10, 14, 12, 0, tzinfo=timezone.utc)
THIRTY_DAYS: Final = 30 * 24 * 60 * 60
@dataclass
class InMemoryIgnoreStore:
raw: object = None
async def get_raw_ignores(self) -> object:
return self.raw
async def upsert_ignores(self, payload: str) -> None:
self.raw = json.loads(payload)
@dataclass
class RecordingAuditLogger:
calls: list[dict[str, object]] = field(default_factory=list)
async def __call__(
self,
param_name: str,
action: AUDIT_ACTIONS,
before_value: JsonValue | None,
after_value: JsonValue | None,
user_api_key_dict: UserAPIKeyAuth,
table_name: LitellmTableNames = LitellmTableNames.CONFIG_TABLE_NAME,
) -> None:
self.calls.append(
{
"param_name": param_name,
"action": action,
"before_value": before_value,
"after_value": after_value,
"table_name": table_name,
}
)
@dataclass
class Harness:
app: FastAPI
client: TestClient
store: InMemoryIgnoreStore
audit: RecordingAuditLogger
clock: list[datetime]
def as_role(self, role: LitellmUserRoles, user_id: str | None = "admin-user") -> None:
async def auth() -> UserAPIKeyAuth:
return UserAPIKeyAuth(api_key="sk-test", user_id=user_id, user_role=role)
self.app.dependency_overrides[user_api_key_auth] = auth
def with_conditions(self, *conditions: WarningCondition) -> None:
self.app.dependency_overrides[current_warning_conditions] = lambda: conditions
def without_database(self) -> None:
self.app.dependency_overrides[deployment_warning_ignore_store] = lambda: None
def warnings(self) -> dict[str, dict[str, object]]:
response = self.client.get("/health/deployment_warnings")
assert response.status_code == 200, response.text
return {warning["id"]: warning for warning in response.json()["warnings"]}
@pytest.fixture
def harness() -> Iterator[Harness]:
app = FastAPI()
app.include_router(router)
store = InMemoryIgnoreStore()
audit = RecordingAuditLogger()
clock = [T0]
app.dependency_overrides[deployment_warning_ignore_store] = lambda: store
app.dependency_overrides[config_audit_logger] = lambda: audit
app.dependency_overrides[utc_now] = lambda: clock[0]
h = Harness(app=app, client=TestClient(app), store=store, audit=audit, clock=clock)
h.as_role(LitellmUserRoles.PROXY_ADMIN)
h.with_conditions(
WarningCondition(id="env_credential_login", severity="critical"),
WarningCondition(id="detailed_debug", severity="warning"),
)
yield h
class TestLicenseExpiryCondition:
@pytest.mark.parametrize(
("days_left", "severity"),
[(31, None), (30, "warning"), (8, "warning"), (7, "critical"), (0, "critical"), (-1, "critical")],
)
def test_severity_follows_days_left(self, days_left, severity):
expires_on = (T0 + timedelta(days=days_left)).date().isoformat()
condition = license_expiry_condition(expires_on, T0)
if severity is None:
assert condition is None
return
assert condition == WarningCondition(id=f"license_expiry:{expires_on}", severity=severity)
def test_days_left_counts_utc_calendar_days(self):
just_before_midnight = datetime(2026, 10, 14, 23, 59, tzinfo=timezone.utc)
assert license_expiry_condition("2026-10-21", just_before_midnight).severity == "critical"
assert license_expiry_condition("2026-10-22", just_before_midnight).severity == "warning"
def test_renewed_license_gets_a_new_id(self):
first = license_expiry_condition("2026-10-20", T0)
renewed = license_expiry_condition("2026-11-10", T0)
assert first is not None and renewed is not None
assert first.id != renewed.id
@pytest.mark.parametrize("expiration_date", [None, "", "not-a-date"])
def test_missing_or_invalid_date_has_no_warning(self, expiration_date):
assert license_expiry_condition(expiration_date, T0) is None
class TestDeploymentWarningConditions:
@staticmethod
def _conditions(**overrides: object) -> dict[str, WarningCondition]:
signals: dict[str, object] = {
"env_credential_login_enabled": False,
"license_expiration_date": None,
"no_redis_on_multiple_workers": False,
"no_redis_warning_disabled_by_env": False,
"detailed_debug": False,
"now": T0,
**overrides,
}
return {c.id: c for c in deployment_warning_conditions(**signals)}
def test_healthy_deployment_has_no_warnings(self):
assert self._conditions() == {}
def test_every_signal_maps_to_its_warning_and_severity(self):
conditions = self._conditions(
env_credential_login_enabled=True,
license_expiration_date="2026-11-01",
no_redis_on_multiple_workers=True,
detailed_debug=True,
)
assert {cid: c.severity for cid, c in conditions.items()} == {
"env_credential_login": "critical",
"license_expiry:2026-11-01": "warning",
"no_redis": "warning",
"detailed_debug": "warning",
}
assert all(c.env_var_ignore is None for c in conditions.values())
def test_no_redis_env_var_keeps_the_warning_listed_as_env_ignored(self):
conditions = self._conditions(no_redis_on_multiple_workers=True, no_redis_warning_disabled_by_env=True)
assert conditions["no_redis"].env_var_ignore == "LITELLM_DISABLE_NO_REDIS_WARNING"
def test_env_var_alone_does_not_raise_the_no_redis_warning(self):
assert self._conditions(no_redis_warning_disabled_by_env=True) == {}
@pytest.fixture
def set_logger_level() -> Iterator[Callable[[int], None]]:
original = verbose_logger.level
yield verbose_logger.setLevel
verbose_logger.setLevel(original)
class TestCurrentWarningConditions:
@staticmethod
def _proxy_state(monkeypatch, *, redis_cache: object, general_settings: dict[str, object], license: object) -> None:
monkeypatch.setattr("litellm.proxy.proxy_server.redis_usage_cache", redis_cache)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user_data", license)
monkeypatch.setattr(
"litellm.proxy.proxy_server._license_check",
SimpleNamespace(airgapped_license_data=None, license_str=None, public_key=None),
)
@pytest.mark.asyncio
async def test_reads_every_signal_from_the_running_proxy(self, monkeypatch, set_logger_level):
self._proxy_state(monkeypatch, redis_cache=None, general_settings={}, license={"expiration_date": "2026-10-20"})
monkeypatch.setenv("LITELLM_DISABLE_NO_REDIS_WARNING", "true")
set_logger_level(logging.DEBUG)
conditions = await current_warning_conditions(T0)
assert conditions == (
WarningCondition(id="env_credential_login", severity="critical"),
WarningCondition(id="license_expiry:2026-10-20", severity="critical"),
WarningCondition(id="no_redis", severity="warning", env_var_ignore="LITELLM_DISABLE_NO_REDIS_WARNING"),
WarningCondition(id="detailed_debug", severity="warning"),
)
@pytest.mark.asyncio
async def test_healthy_proxy_has_no_conditions(self, monkeypatch, set_logger_level):
self._proxy_state(
monkeypatch,
redis_cache=SimpleNamespace(),
general_settings={"disable_env_credential_login": True},
license=None,
)
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
set_logger_level(logging.INFO)
assert await current_warning_conditions(T0) == ()
class TestDeploymentWarningIgnoreStore:
def test_no_database_means_no_store(self, monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
assert deployment_warning_ignore_store() is None
@pytest.mark.asyncio
async def test_reads_ignores_from_the_writer_not_a_lagging_replica(self, monkeypatch):
def ui_settings_table(row: object) -> SimpleNamespace:
async def find_unique(where: dict[str, str]) -> object:
return row
return SimpleNamespace(litellm_uisettings=SimpleNamespace(find_unique=find_unique))
fresh = {"detailed_debug": {"ignored_by": "a", "ignored_at": "2026-10-14T12:00:00Z"}}
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
SimpleNamespace(
db=ui_settings_table(None), writer_db=ui_settings_table(SimpleNamespace(ui_settings=fresh))
),
)
store = deployment_warning_ignore_store()
assert store is not None
assert await store.get_raw_ignores() == fresh
class TestGetDeploymentWarnings:
@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
def test_admins_and_viewers_read_warnings(self, harness, role):
harness.as_role(role)
response = harness.client.get("/health/deployment_warnings")
assert response.status_code == 200
assert response.json() == {
"warnings": [
{"id": "env_credential_login", "severity": "critical", "ignore": None},
{"id": "detailed_debug", "severity": "warning", "ignore": None},
],
"ignore_available": True,
}
@pytest.mark.parametrize(
"role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, LitellmUserRoles.TEAM]
)
def test_other_roles_are_rejected(self, harness, role):
harness.as_role(role)
assert harness.client.get("/health/deployment_warnings").status_code == 403
def test_env_var_ignore_is_reported(self, harness):
harness.with_conditions(
WarningCondition(id="no_redis", severity="warning", env_var_ignore="LITELLM_DISABLE_NO_REDIS_WARNING")
)
assert harness.warnings()["no_redis"]["ignore"] == {
"source": "env_var",
"env_var": "LITELLM_DISABLE_NO_REDIS_WARNING",
}
def test_env_var_ignore_wins_over_a_stored_admin_ignore(self, harness):
harness.with_conditions(WarningCondition(id="no_redis", severity="warning"))
harness.client.patch("/health/deployment_warnings/no_redis", json={"ignored": True})
harness.with_conditions(
WarningCondition(id="no_redis", severity="warning", env_var_ignore="LITELLM_DISABLE_NO_REDIS_WARNING")
)
assert harness.warnings()["no_redis"]["ignore"]["source"] == "env_var"
def test_stored_ignore_without_timezone_reads_as_unreadable(self, harness):
harness.store.raw = {
"detailed_debug": {"ignored_by": "a", "ignored_at": "2026-10-14T12:00:00", "ignored_until": None}
}
warnings = harness.warnings()
assert set(warnings) == {"env_credential_login", "detailed_debug"}
assert warnings["detailed_debug"]["ignore"] is None
def test_corrupt_stored_ignores_read_as_none(self, harness):
harness.store.raw = "{not json"
warnings = harness.warnings()
assert set(warnings) == {"env_credential_login", "detailed_debug"}
assert all(w["ignore"] is None for w in warnings.values())
def test_without_database_ignoring_is_reported_unavailable(self, harness):
harness.without_database()
response = harness.client.get("/health/deployment_warnings")
assert response.status_code == 200
assert response.json()["ignore_available"] is False
assert [w["id"] for w in response.json()["warnings"]] == ["env_credential_login", "detailed_debug"]
class TestUpdateDeploymentWarningIgnore:
def test_snooze_computes_end_time_and_applies_to_every_admin(self, harness):
harness.as_role(LitellmUserRoles.PROXY_ADMIN, user_id="alice")
response = harness.client.patch(
"/health/deployment_warnings/env_credential_login", json={"ignored": True, "snooze_seconds": 86400}
)
assert response.status_code == 200
expected_ignore = {
"source": "admin",
"ignored_by": "alice",
"ignored_at": "2026-10-14T12:00:00Z",
"ignored_until": "2026-10-15T12:00:00Z",
}
assert response.json() == {"id": "env_credential_login", "ignore": expected_ignore}
harness.as_role(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, user_id="bob")
warnings = harness.warnings()
assert warnings["env_credential_login"]["ignore"] == expected_ignore
assert warnings["detailed_debug"]["ignore"] is None
def test_snooze_ends_exactly_at_its_end_time(self, harness):
harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": True, "snooze_seconds": 60})
harness.clock[0] = T0 + timedelta(seconds=59)
assert harness.warnings()["detailed_debug"]["ignore"] is not None
harness.clock[0] = T0 + timedelta(seconds=60)
assert harness.warnings()["detailed_debug"]["ignore"] is None
def test_omitting_snooze_ignores_forever(self, harness):
response = harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": True})
assert response.status_code == 200
assert response.json()["ignore"]["ignored_until"] is None
harness.clock[0] = T0 + timedelta(days=3650)
assert harness.warnings()["detailed_debug"]["ignore"]["ignored_at"] == "2026-10-14T12:00:00Z"
def test_restore_clears_only_that_warning(self, harness):
harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": True})
harness.client.patch("/health/deployment_warnings/env_credential_login", json={"ignored": True})
response = harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": False})
assert response.status_code == 200
assert response.json() == {"id": "detailed_debug", "ignore": None}
warnings = harness.warnings()
assert warnings["detailed_debug"]["ignore"] is None
assert warnings["env_credential_login"]["ignore"]["source"] == "admin"
def test_any_severity_can_be_ignored(self, harness):
harness.with_conditions(WarningCondition(id="license_expiry:2026-10-16", severity="critical"))
response = harness.client.patch("/health/deployment_warnings/license_expiry:2026-10-16", json={"ignored": True})
assert response.status_code == 200
assert harness.warnings()["license_expiry:2026-10-16"]["ignore"]["ignored_by"] == "admin-user"
def test_ignoring_an_old_license_date_does_not_hide_the_renewed_one(self, harness):
harness.client.patch("/health/deployment_warnings/license_expiry:2026-10-16", json={"ignored": True})
harness.with_conditions(WarningCondition(id="license_expiry:2026-11-01", severity="warning"))
assert harness.warnings()["license_expiry:2026-11-01"]["ignore"] is None
def test_change_is_written_to_the_config_audit_log(self, harness):
harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": True})
harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": False})
assert len(harness.audit.calls) == 2
ignore_call, restore_call = harness.audit.calls
assert ignore_call["table_name"] == LitellmTableNames.UI_SETTINGS_TABLE_NAME
assert ignore_call["before_value"] == []
assert ignore_call["after_value"] == [
{
"id": "detailed_debug",
"ignore": {
"source": "admin",
"ignored_by": "admin-user",
"ignored_at": "2026-10-14T12:00:00Z",
"ignored_until": None,
},
}
]
assert restore_call["before_value"] == ignore_call["after_value"]
assert restore_call["after_value"] == []
assert {call["param_name"] for call in harness.audit.calls} == {DEPLOYMENT_WARNING_IGNORES_ROW_ID}
assert {call["action"] for call in harness.audit.calls} == {"updated"}
def test_key_without_a_user_is_recorded_as_the_proxy_admin(self, harness):
harness.as_role(LitellmUserRoles.PROXY_ADMIN, user_id=None)
response = harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": True})
assert response.json()["ignore"]["ignored_by"] == LITELLM_PROXY_ADMIN_NAME
def test_expired_snoozes_are_not_carried_into_the_next_write(self, harness):
harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": True, "snooze_seconds": 60})
harness.clock[0] = T0 + timedelta(minutes=5)
harness.client.patch("/health/deployment_warnings/env_credential_login", json={"ignored": True})
assert [entry["id"] for entry in harness.audit.calls[-1]["after_value"]] == ["env_credential_login"]
def test_audit_entry_survives_secret_redaction(self, harness):
harness.client.patch(
"/health/deployment_warnings/env_credential_login", json={"ignored": True, "snooze_seconds": 3600}
)
stored = json.loads(_dump_redacted_config(harness.audit.calls[-1]["after_value"]))
assert stored == [
{
"id": "env_credential_login",
"ignore": {
"source": "admin",
"ignored_by": "admin-user",
"ignored_at": "2026-10-14T12:00:00Z",
"ignored_until": "2026-10-14T13:00:00Z",
},
}
]
@pytest.mark.parametrize("snooze_seconds", [0, -1, THIRTY_DAYS + 1])
def test_snooze_outside_thirty_days_is_rejected(self, harness, snooze_seconds):
response = harness.client.patch(
"/health/deployment_warnings/detailed_debug", json={"ignored": True, "snooze_seconds": snooze_seconds}
)
assert response.status_code == 422
assert harness.store.raw is None
def test_thirty_day_snooze_is_allowed(self, harness):
response = harness.client.patch(
"/health/deployment_warnings/detailed_debug", json={"ignored": True, "snooze_seconds": THIRTY_DAYS}
)
assert response.status_code == 200
assert response.json()["ignore"]["ignored_until"] == "2026-11-13T12:00:00Z"
def test_snooze_without_ignoring_is_rejected(self, harness):
response = harness.client.patch(
"/health/deployment_warnings/detailed_debug", json={"ignored": False, "snooze_seconds": 60}
)
assert response.status_code == 422
@pytest.mark.parametrize("warning_id", ["made_up", "license_expiry:soon", "license_expiry:20261016"])
def test_unknown_warning_is_rejected(self, harness, warning_id):
response = harness.client.patch(f"/health/deployment_warnings/{warning_id}", json={"ignored": True})
assert response.status_code == 404
assert harness.store.raw is None
@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.INTERNAL_USER])
def test_only_proxy_admins_can_ignore(self, harness, role):
harness.as_role(role)
response = harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": True})
assert response.status_code == 403
assert harness.store.raw is None
assert harness.audit.calls == []
def test_without_database_ignoring_is_unavailable(self, harness):
harness.without_database()
response = harness.client.patch("/health/deployment_warnings/detailed_debug", json={"ignored": True})
assert response.status_code == 503
assert "database" in response.json()["detail"].lower()
assert harness.audit.calls == []

View file

@ -4840,6 +4840,21 @@ class TestNoRedisWarning:
assert await _show_no_redis_warning() is False
prisma.db.query_raw.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("coordination_redis, router_redis", [(MagicMock(), None), (None, MagicMock())])
async def test_redis_answers_before_the_env_var_is_looked_up(self, monkeypatch, coordination_redis, router_redis):
monkeypatch.setattr(
_health_endpoints_module,
"get_secret_bool",
MagicMock(side_effect=AssertionError("env var looked up although Redis is configured")),
)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", coordination_redis),
patch("litellm.proxy.proxy_server.llm_router", self._router(router_redis)),
patch("litellm.proxy.proxy_server.prisma_client", self._prisma_with_workers(5)),
):
assert await _show_no_redis_warning() is False
@pytest.mark.asyncio
async def test_stays_quiet_when_only_the_router_has_redis(self, monkeypatch):
"""router_settings.redis_host alone backs cooldowns and usage-based routing."""

View file

@ -23,6 +23,7 @@ from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.chunked_in import IN_LIST_CHUNK_SIZE
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.credentials_repository import CredentialsRepository
from litellm.repositories.deployment_warning_ignore_repository import DeploymentWarningIgnoreRepository
from litellm.repositories.model_repository import ModelRepository
from litellm.repositories.object_permission_repository import (
ObjectPermissionRepository,
@ -1599,6 +1600,28 @@ class TestConfigRepository:
assert len(params) == 2
class TestDeploymentWarningIgnoreRepository:
@pytest.mark.asyncio
async def test_round_trips_on_the_writer_without_touching_other_ui_settings_rows(self):
client = MockPrismaClient()
writer_table = MockTable()
client.writer_db = SimpleNamespace(litellm_uisettings=writer_table)
other_rows = {
"ui_settings": {"id": "ui_settings", "ui_settings": '{"disable_model_add_for_internal_users": true}'},
"user_banner": {"id": "user_banner", "ui_settings": '{"enabled": false}'},
}
writer_table._records.update({row_id: dict(row) for row_id, row in other_rows.items()})
repo = DeploymentWarningIgnoreRepository(client, use_writer=True)
assert await repo.get_raw_ignores() is None
await repo.upsert_ignores('{"no_redis": {}}')
await repo.upsert_ignores('{"detailed_debug": {}}')
assert await repo.get_raw_ignores() == '{"detailed_debug": {}}'
assert {row_id: writer_table._records[row_id] for row_id in other_rows} == other_rows
assert len(writer_table._records) == len(other_rows) + 1
class TestVerificationTokenRepositoryExtended:
@pytest.fixture
def repo(self):

View file

@ -7502,6 +7502,48 @@ export interface paths {
patch?: never;
trace?: never;
};
"/health/deployment_warnings": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Deployment Warnings
* @description List the active deployment warnings with their severity and whether an admin ignored or snoozed them.
* Readable by proxy admins and proxy admin viewers.
*/
get: operations["get_deployment_warnings_health_deployment_warnings_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/health/deployment_warnings/{warning_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Update Deployment Warning Ignore
* @description Ignore, snooze, or restore one deployment warning for every admin of this proxy.
* Only proxy admins can call this, and it needs a database.
*/
patch: operations["update_deployment_warning_ignore_health_deployment_warnings__warning_id__patch"];
trace?: never;
};
"/health/drain": {
parameters: {
query?: never;
@ -25449,6 +25491,26 @@ export interface components {
[key: string]: string;
};
};
/** AdminIgnore */
AdminIgnore: {
/**
* Ignored At
* Format: date-time
*/
ignored_at: string;
/** Ignored By */
ignored_by: string;
/**
* Ignored Until
* @description When the snooze ends. Null means the warning is ignored until an admin restores it.
*/
ignored_until?: string | null;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
source: "admin";
};
/**
* AgentCapabilities
* @description Defines optional capabilities supported by an agent.
@ -31496,6 +31558,47 @@ export interface components {
} & {
[key: string]: unknown;
};
/** DeploymentWarning */
DeploymentWarning: {
/** Id */
id: string;
/** Ignore */
ignore?: (components["schemas"]["AdminIgnore"] | components["schemas"]["EnvVarIgnore"]) | null;
/**
* Severity
* @enum {string}
*/
severity: "critical" | "warning";
};
/** DeploymentWarningIgnoreResponse */
DeploymentWarningIgnoreResponse: {
/** Id */
id: string;
ignore: components["schemas"]["AdminIgnore"] | null;
};
/** DeploymentWarningIgnoreUpdate */
DeploymentWarningIgnoreUpdate: {
/**
* Ignored
* @description True ignores the warning for every admin, false restores it.
*/
ignored: boolean;
/**
* Snooze Seconds
* @description How long to ignore the warning, up to 30 days. Leave it out to ignore it forever.
*/
snooze_seconds?: number | null;
};
/** DeploymentWarningsResponse */
DeploymentWarningsResponse: {
/**
* Ignore Available
* @description False when no database is connected, so warnings cannot be ignored.
*/
ignore_available: boolean;
/** Warnings */
warnings: components["schemas"]["DeploymentWarning"][];
};
/** DiscoverAgentRequest */
DiscoverAgentRequest: {
/**
@ -31834,6 +31937,16 @@ export interface components {
/** Tenant Id */
tenant_id: string;
};
/** EnvVarIgnore */
EnvVarIgnore: {
/** Env Var */
env_var: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
source: "env_var";
};
/** EnvironmentReport */
EnvironmentReport: {
/** Config Lines */
@ -61046,6 +61159,61 @@ export interface operations {
};
};
};
get_deployment_warnings_health_deployment_warnings_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeploymentWarningsResponse"];
};
};
};
};
update_deployment_warning_ignore_health_deployment_warnings__warning_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
warning_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DeploymentWarningIgnoreUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeploymentWarningIgnoreResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
health_drain_health_drain_get: {
parameters: {
query?: never;