From a0ac9b4f1fbf16f90f43942cca92ed2f3528ffa3 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 14 Oct 2026 10:53:38 -0700 Subject: [PATCH] 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 --- .../health_endpoints/_health_endpoints.py | 38 +- .../deployment_warning_endpoints.py | 350 +++++++++++++ litellm/proxy/proxy_server.py | 2 + .../deployment_warning_ignore_repository.py | 23 + .../test_deployment_warning_endpoints.py | 475 ++++++++++++++++++ .../health_endpoints/test_health_endpoints.py | 15 + tests/unit/repositories/test_repositories.py | 23 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 168 +++++++ 8 files changed, 1081 insertions(+), 13 deletions(-) create mode 100644 litellm/proxy/health_endpoints/deployment_warning_endpoints.py create mode 100644 litellm/repositories/deployment_warning_ignore_repository.py create mode 100644 tests/unit/proxy/health_endpoints/test_deployment_warning_endpoints.py diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index fa58fda8f47..e16a391dc12 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -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 diff --git a/litellm/proxy/health_endpoints/deployment_warning_endpoints.py b/litellm/proxy/health_endpoints/deployment_warning_endpoints.py new file mode 100644 index 00000000000..115068f79f8 --- /dev/null +++ b/litellm/proxy/health_endpoints/deployment_warning_endpoints.py @@ -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) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 306cb6bd95c..b51df22d07a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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) diff --git a/litellm/repositories/deployment_warning_ignore_repository.py b/litellm/repositories/deployment_warning_ignore_repository.py new file mode 100644 index 00000000000..da02d424f80 --- /dev/null +++ b/litellm/repositories/deployment_warning_ignore_repository.py @@ -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}}, + ) diff --git a/tests/unit/proxy/health_endpoints/test_deployment_warning_endpoints.py b/tests/unit/proxy/health_endpoints/test_deployment_warning_endpoints.py new file mode 100644 index 00000000000..4583d8fd978 --- /dev/null +++ b/tests/unit/proxy/health_endpoints/test_deployment_warning_endpoints.py @@ -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 == [] diff --git a/tests/unit/proxy/health_endpoints/test_health_endpoints.py b/tests/unit/proxy/health_endpoints/test_health_endpoints.py index 2c6e013d075..f215da5cf3e 100644 --- a/tests/unit/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/unit/proxy/health_endpoints/test_health_endpoints.py @@ -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.""" diff --git a/tests/unit/repositories/test_repositories.py b/tests/unit/repositories/test_repositories.py index b7b663c2873..cbcd0b1cd28 100644 --- a/tests/unit/repositories/test_repositories.py +++ b/tests/unit/repositories/test_repositories.py @@ -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): diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6da601efeec..29d659ec1d9 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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;