diff --git a/.agents/skills/database-access/references/python-fastapi.md b/.agents/skills/database-access/references/python-fastapi.md index b579495baed..de006615325 100644 --- a/.agents/skills/database-access/references/python-fastapi.md +++ b/.agents/skills/database-access/references/python-fastapi.md @@ -4,6 +4,7 @@ - `get_database` reads the lifespan-owned handle from request state - `get_repositories` builds the shared access point from that handle +- Choose each feature's replica routing once in `get_repositories`, never at call sites - A handler receives `Repositories | None`, then uses `repositories.banners`, never a Prisma client - `UserBannerRepository` keeps the existing Prisma table, serialization and configuration-sync behavior - The banner audit provider binds the legacy audit callable at request time, without performing audit I/O diff --git a/litellm/proxy/deps.py b/litellm/proxy/deps.py index ae5519f02a7..34c63687784 100644 --- a/litellm/proxy/deps.py +++ b/litellm/proxy/deps.py @@ -4,6 +4,7 @@ from fastapi import Depends, Request from litellm.repositories.container import Repositories from litellm.repositories.database import Database +from litellm.repositories.deployment_warning_ignore_repository import DeploymentWarningIgnoreRepository from litellm.repositories.user_banner_repository import UserBannerRepository _MISSING_DATABASE: Final = object() @@ -25,7 +26,12 @@ DatabaseDep: TypeAlias = Annotated[Database | None, Depends(get_database)] async def get_repositories(database: DatabaseDep) -> Repositories | None: - return None if database is None else Repositories(banners=UserBannerRepository(database.prisma)) + if database is None: + return None + return Repositories( + banners=UserBannerRepository(database.prisma), + deployment_warning_ignores=DeploymentWarningIgnoreRepository(database.prisma, use_writer=True), + ) RepositoriesDep: TypeAlias = Annotated[Repositories | None, Depends(get_repositories)] diff --git a/litellm/proxy/health_endpoints/deployment_warning_endpoints.py b/litellm/proxy/health_endpoints/deployment_warning_endpoints.py index 115068f79f8..06a9e3e468f 100644 --- a/litellm/proxy/health_endpoints/deployment_warning_endpoints.py +++ b/litellm/proxy/health_endpoints/deployment_warning_endpoints.py @@ -13,6 +13,7 @@ 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.deps import RepositoriesDep from litellm.proxy.health_endpoints._health_endpoints import ( DISABLE_NO_REDIS_WARNING_ENV_VAR, env_credential_login_enabled, @@ -22,7 +23,6 @@ from litellm.proxy.health_endpoints._health_endpoints import ( ) from litellm.repositories.deployment_warning_ignore_repository import ( DEPLOYMENT_WARNING_IGNORES_ROW_ID, - DeploymentWarningIgnoreRepository, ) from litellm.types.llms.base import LiteLLMBaseModel @@ -225,12 +225,8 @@ 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 deployment_warning_ignore_store(repositories: RepositoriesDep) -> DeploymentWarningIgnoreStore | None: + return None if repositories is None else repositories.deployment_warning_ignores def config_audit_logger() -> ConfigAuditLogger: diff --git a/litellm/repositories/container.py b/litellm/repositories/container.py index a25f065ec34..53d991bc7d1 100644 --- a/litellm/repositories/container.py +++ b/litellm/repositories/container.py @@ -1,8 +1,10 @@ from dataclasses import dataclass +from litellm.repositories.deployment_warning_ignore_repository import DeploymentWarningIgnoreRepository from litellm.repositories.user_banner_repository import UserBannerRepository @dataclass(frozen=True, slots=True) class Repositories: banners: UserBannerRepository + deployment_warning_ignores: DeploymentWarningIgnoreRepository diff --git a/tests/unit/proxy/health_endpoints/test_deployment_warning_endpoints.py b/tests/unit/proxy/health_endpoints/test_deployment_warning_endpoints.py index 4583d8fd978..d94b03406fa 100644 --- a/tests/unit/proxy/health_endpoints/test_deployment_warning_endpoints.py +++ b/tests/unit/proxy/health_endpoints/test_deployment_warning_endpoints.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from types import SimpleNamespace from typing import Final +from unittest.mock import MagicMock import pytest from fastapi import FastAPI @@ -15,6 +16,7 @@ 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.deps import get_repositories from litellm.proxy.health_endpoints.deployment_warning_endpoints import ( WarningCondition, config_audit_logger, @@ -28,6 +30,7 @@ from litellm.proxy.health_endpoints.deployment_warning_endpoints import ( from litellm.proxy.proxy_server import ( _dump_redacted_config, # pyright: ignore[reportPrivateUsage] # the exact redaction every config audit row goes through ) +from litellm.repositories.database import Database from litellm.repositories.deployment_warning_ignore_repository import DEPLOYMENT_WARNING_IGNORES_ROW_ID T0: Final = datetime(2026, 10, 14, 12, 0, tzinfo=timezone.utc) @@ -233,26 +236,24 @@ class TestCurrentWarningConditions: 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_no_database_means_no_store(self): + assert deployment_warning_ignore_store(await get_repositories(None)) is None @pytest.mark.asyncio - async def test_reads_ignores_from_the_writer_not_a_lagging_replica(self, monkeypatch): + async def test_reads_ignores_from_the_writer_not_a_lagging_replica(self): 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)) - ), + fresh: Final = {"detailed_debug": {"ignored_by": "a", "ignored_at": "2026-10-14T12:00:00Z"}} + prisma: Final = MagicMock( + db=ui_settings_table(None), writer_db=ui_settings_table(SimpleNamespace(ui_settings=fresh)) ) - store = deployment_warning_ignore_store() + repositories: Final = await get_repositories(Database(prisma=prisma)) + store: Final = deployment_warning_ignore_store(repositories) assert store is not None assert await store.get_raw_ignores() == fresh diff --git a/tests/unit/proxy/test_deps.py b/tests/unit/proxy/test_deps.py index da54d9eef5e..b575ceedada 100644 --- a/tests/unit/proxy/test_deps.py +++ b/tests/unit/proxy/test_deps.py @@ -24,6 +24,10 @@ def _app(database: database_runtime.Database | None) -> FastAPI: async def banner(repositories: RepositoriesDep) -> UserBanner: return UserBanner() if repositories is None else await repositories.banners.get_banner() + @app.get("/ignores") + async def ignores(repositories: RepositoriesDep) -> object: + return None if repositories is None else await repositories.deployment_warning_ignores.get_raw_ignores() + return app @@ -42,6 +46,20 @@ def test_lifespan_database_is_shared_until_shutdown(): ) +def test_both_repositories_use_the_lifespan_database(): + prisma: Final = MagicMock() + prisma.db.litellm_uisettings.find_unique = AsyncMock( + return_value=SimpleNamespace(ui_settings={"message": "stored banner"}) + ) + stored_ignores: Final = {"detailed_debug": {"ignored_by": "admin", "ignored_at": "2026-10-14T12:00:00Z"}} + prisma.writer_db.litellm_uisettings.find_unique = AsyncMock( + return_value=SimpleNamespace(ui_settings=stored_ignores) + ) + with TestClient(_app(database_runtime.Database(prisma=prisma))) as client: + assert client.get("/banner").json() == UserBanner(message="stored banner").model_dump() + assert client.get("/ignores").json() == stored_ignores + + def test_no_database_state_preserves_optional_repository(): with TestClient(_app(None)) as client: response: Final = client.get("/banner") diff --git a/tests/unit/proxy/ui_crud_endpoints/test_user_banner_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_user_banner_endpoints.py index 306b1b3eede..ac9c1e6fb62 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_user_banner_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_user_banner_endpoints.py @@ -14,6 +14,7 @@ from litellm.proxy.proxy_server import app from litellm.proxy.ui_crud_endpoints.user_banner_dependencies import get_user_banner_audit from litellm.repositories.container import Repositories from litellm.repositories.database import Database +from litellm.repositories.deployment_warning_ignore_repository import DeploymentWarningIgnoreRepository from litellm.repositories.user_banner_repository import UserBannerRepository from tests._master_key import MASTER_KEY @@ -169,7 +170,10 @@ class TestUpdateUserBanner: def test_persists_and_round_trips(self, admin_auth, monkeypatch, mock_audit_log, override_repositories: bool): mock_prisma: Final = _mock_prisma(monkeypatch, record=None) if override_repositories: - repositories: Final = Repositories(banners=UserBannerRepository(mock_prisma)) + repositories: Final = Repositories( + banners=UserBannerRepository(mock_prisma), + deployment_warning_ignores=DeploymentWarningIgnoreRepository(mock_prisma), + ) monkeypatch.setitem(app.dependency_overrides, get_database, lambda: None) monkeypatch.setitem(app.dependency_overrides, get_repositories, lambda: repositories)