fix(proxy): read blocked_user_list per call and make cache eviction best-effort

This commit is contained in:
devin-ai-integration[bot] 2026-08-02 03:17:28 +00:00 committed by GitHub
parent a4aeec11c4
commit b521aee43e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 99 additions and 10 deletions

View file

@ -7,7 +7,7 @@
## This accepts a list of user id's for whom calls will be rejected
from typing import Optional, Literal
from typing import List, Literal, Optional
import litellm
from litellm.proxy.utils import PrismaClient
from litellm.caching.caching import DualCache
@ -23,18 +23,13 @@ class _ENTERPRISE_BlockedUserList(CustomLogger):
self.prisma_client = prisma_client
blocked_user_list = litellm.blocked_user_list
if blocked_user_list is None:
self.blocked_user_list = None
return
if isinstance(blocked_user_list, list):
self.blocked_user_list = blocked_user_list
self.file_blocked_user_list: Optional[List[str]] = None
if isinstance(blocked_user_list, str): # assume it's a filepath
try:
with open(blocked_user_list, "r") as file:
data = file.read()
self.blocked_user_list = data.split("\n")
self.file_blocked_user_list = data.split("\n")
except FileNotFoundError:
raise Exception(
f"File not found. blocked_user_list={blocked_user_list}"
@ -44,6 +39,15 @@ class _ENTERPRISE_BlockedUserList(CustomLogger):
f"An error occurred: {str(e)}, blocked_user_list={blocked_user_list}"
)
@property
def blocked_user_list(self) -> Optional[List[str]]:
"""Read the configured list on every call, so /customer/unblock takes effect immediately"""
if self.file_blocked_user_list is not None:
return self.file_blocked_user_list
if isinstance(litellm.blocked_user_list, list):
return litellm.blocked_user_list
return None
def print_verbose(self, print_statement, level: Literal["INFO", "DEBUG"] = "DEBUG"):
if level == "INFO":
verbose_proxy_logger.info(print_statement)

View file

@ -54,8 +54,11 @@ async def _invalidate_end_user_cache(user_ids: Sequence[str]) -> None:
from litellm.proxy.proxy_server import user_api_key_cache
for user_id in user_ids:
await user_api_key_cache.async_delete_cache(key=f"end_user_id:{user_id}")
await user_api_key_cache.async_delete_cache(key=f"litellm:end_user_id:{user_id}")
try:
await user_api_key_cache.async_delete_cache(key=f"end_user_id:{user_id}")
await user_api_key_cache.async_delete_cache(key=f"litellm:end_user_id:{user_id}")
except Exception as e: # noqa: BLE001 # the row is already written; a cached view expires on its own
verbose_proxy_logger.error(f"Failed to evict cached end-user {user_id} - {e!s}")
def _to_customer_response(record: BaseModel) -> CustomerResponse:

View file

@ -0,0 +1,63 @@
import os
import sys
import pytest
from fastapi import HTTPException
sys.path.insert(0, os.path.abspath("../../../.."))
import litellm
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
@pytest.fixture
def hook():
from enterprise.enterprise_hooks.blocked_user_list import (
_ENTERPRISE_BlockedUserList,
)
return _ENTERPRISE_BlockedUserList(prisma_client=None)
async def _call(hook, user_id: str):
return await hook.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(),
cache=DualCache(),
data={"user": user_id},
call_type="completion",
)
@pytest.mark.asyncio
async def test_hook_reads_blocked_user_list_on_every_call(monkeypatch, hook):
"""Regression: the hook used to keep the list object it read at init, so /customer/unblock
rebinding litellm.blocked_user_list left the hook rejecting an already unblocked customer.
"""
monkeypatch.setattr(litellm, "blocked_user_list", ["blocked-1"])
with pytest.raises(HTTPException) as exc_info:
await _call(hook, "blocked-1")
assert "blocked-1" in str(exc_info.value.detail)
monkeypatch.setattr(litellm, "blocked_user_list", [])
assert await _call(hook, "blocked-1") is None
@pytest.mark.asyncio
async def test_hook_keeps_filepath_backed_list(monkeypatch, tmp_path):
from enterprise.enterprise_hooks.blocked_user_list import (
_ENTERPRISE_BlockedUserList,
)
blocked_users_file = tmp_path / "blocked_users.txt"
blocked_users_file.write_text("blocked-1")
monkeypatch.setattr(litellm, "blocked_user_list", str(blocked_users_file))
file_backed_hook = _ENTERPRISE_BlockedUserList(prisma_client=None)
monkeypatch.setattr(litellm, "blocked_user_list", [])
with pytest.raises(HTTPException):
await _call(file_backed_hook, "blocked-1")

View file

@ -870,6 +870,25 @@ def test_unblock_customer_without_enterprise_callback_still_persists(
mock_prisma_client.db.litellm_endusertable.update_many.assert_awaited_once()
def test_unblock_customer_survives_cache_eviction_failure(
mock_prisma_client, mock_user_api_key_auth, mock_end_user_cache, monkeypatch
):
"""An unreachable cache backend must not turn an already committed unblock into a 500."""
monkeypatch.setattr(litellm, "blocked_user_list", ["blocked-1"])
mock_prisma_client.db.litellm_endusertable.update_many = AsyncMock(return_value=1)
mock_end_user_cache.async_delete_cache = AsyncMock(side_effect=Exception("redis down"))
response = client.post(
"/customer/unblock",
json={"user_ids": ["blocked-1"]},
headers={"Authorization": "Bearer test-key"},
)
assert response.status_code == 200
assert response.json() == {"blocked_users": []}
mock_prisma_client.db.litellm_endusertable.update_many.assert_awaited_once()
def test_unblock_customer_rejects_filepath_blocked_user_list(
mock_prisma_client, mock_user_api_key_auth, mock_end_user_cache, monkeypatch
):