feat(proxy): make the in-memory management cache capacity configurable (#40725)

* feat(proxy): make the in-memory management cache capacity configurable

Add general_settings.user_api_key_cache_max_size (positive int, default 200) to resize the
in-memory tier of the shared user_api_key_cache at startup and on DB config reloads, expose it
in the Admin UI general settings, and cover it with behavioral tests. Prior art: #34726

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(caching): resize the in-memory tier from DualCache so any cache instance honours the cap

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(proxy): wrap the cache capacity field description to the 120 col limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-11 09:55:30 -07:00 committed by GitHub
parent de79310954
commit db3338b206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 195 additions and 3 deletions

View file

@ -22,7 +22,7 @@ from litellm._logging import print_verbose, verbose_logger
from litellm.constants import DEFAULT_MAX_REDIS_BATCH_CACHE_SIZE
from .base_cache import BaseCache
from .in_memory_cache import InMemoryCache
from .in_memory_cache import DEFAULT_MAX_SIZE_IN_MEMORY, InMemoryCache
from .redis_cache import RedisCache, RedisCircuitBreakerOpenError, log_redis_failure
if TYPE_CHECKING:
@ -83,6 +83,9 @@ class DualCache(BaseCache):
if default_redis_ttl is not None:
self.default_redis_ttl = default_redis_ttl
def update_in_memory_max_size(self, max_size: int | None) -> None:
self.in_memory_cache.max_size_in_memory = DEFAULT_MAX_SIZE_IN_MEMORY if max_size is None else max_size
def attach_redis_cache(
self,
redis_cache: RedisCache | None = None,

View file

@ -24,11 +24,13 @@ from litellm.constants import MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB
from .base_cache import BaseCache
DEFAULT_MAX_SIZE_IN_MEMORY: Final = 200
class InMemoryCache(BaseCache):
def __init__(
self,
max_size_in_memory: int | None = 200,
max_size_in_memory: int | None = DEFAULT_MAX_SIZE_IN_MEMORY,
default_ttl: int
| None = 600, # default ttl is 10 minutes. At maximum litellm rate limiting logic requires objects to be in memory for 1 minute
max_size_per_item: int | None = 1024, # 1MB = 1024KB
@ -37,7 +39,7 @@ class InMemoryCache(BaseCache):
max_size_in_memory [int]: Maximum number of items in cache. done to prevent memory leaks. Use 200 items as a default
"""
self.max_size_in_memory = (
max_size_in_memory if max_size_in_memory is not None else 200
max_size_in_memory if max_size_in_memory is not None else DEFAULT_MAX_SIZE_IN_MEMORY
) # set an upper bound of 200 items in-memory
self.default_ttl = default_ttl or 600
self.max_size_per_item = max_size_per_item or MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB # 1MB = 1024KB

View file

@ -2602,6 +2602,15 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
global_max_parallel_requests: int | None = Field(
None, description="global max parallel requests to allow for a proxy instance."
)
user_api_key_cache_max_size: int | None = Field(
None,
gt=0,
description=(
"max number of entries (virtual keys, teams, users, end users, memberships, ...) each worker keeps in "
"its in-memory auth cache. Defaults to 200. Raise this if you have more active keys than that or auth "
"lookups keep hitting the DB"
),
)
max_request_size_mb: int | None = Field(
None,
description="max request size in MB, if a request is larger than this size it will be rejected",

View file

@ -5808,6 +5808,16 @@ class ProxyConfig:
default_redis_ttl=ttl,
)
### USER API KEY CACHE MAX SIZE (in-memory tier shared by keys, teams, users, end users, ...) ###
if "user_api_key_cache_max_size" in general_settings:
user_api_key_cache.update_in_memory_max_size(
ConfigGeneralSettings.model_validate(
MappingProxyType(
{"user_api_key_cache_max_size": general_settings["user_api_key_cache_max_size"]}
)
).user_api_key_cache_max_size
)
### PKCE MULTI-INSTANCE PREREQUISITE CHECK ###
# PKCE verifiers are stored in redis_usage_cache when available so they can
# be read back by any instance (not just the one that started the auth flow).
@ -7058,6 +7068,23 @@ class ProxyConfig:
"enable_openai_websocket_passthrough"
)
if "user_api_key_cache_max_size" not in self._yaml_general_settings_keys:
db_cache_max_size: Final = _general_settings.get("user_api_key_cache_max_size")
try:
cache_max_size: Final = ConfigGeneralSettings.model_validate(
MappingProxyType({"user_api_key_cache_max_size": db_cache_max_size})
).user_api_key_cache_max_size
except ValidationError:
verbose_proxy_logger.warning(
"Ignoring invalid general_settings.user_api_key_cache_max_size=%r from the DB", db_cache_max_size
)
else:
if cache_max_size is None:
general_settings.pop("user_api_key_cache_max_size", None)
else:
general_settings["user_api_key_cache_max_size"] = cache_max_size
user_api_key_cache.update_in_memory_max_size(cache_max_size)
## STORE MODEL IN DB ##
if "store_model_in_db" in _general_settings:
value = _general_settings["store_model_in_db"]
@ -16970,6 +16997,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
"cancel_on_disconnect": "Boolean",
"disable_auto_add_proxy_admin_to_teams": "Boolean",
"apply_user_budget_to_team_keys": "Boolean",
"user_api_key_cache_max_size": "Integer",
}
)

View file

@ -7313,6 +7313,88 @@ async def test_update_general_settings_apply_user_budget_to_team_keys_yaml_wins(
assert ps.general_settings["apply_user_budget_to_team_keys"] is True
def _fill_user_api_key_cache(cache: DualCache, count: int) -> None:
for index in range(count):
cache.set_cache(key=f"key-{index}", value={"token": f"key-{index}"}, local_only=True)
@pytest.mark.asyncio
async def test_update_general_settings_user_api_key_cache_max_size_resizes_the_running_cache(monkeypatch):
"""The Admin UI writes the capacity to the DB config, so the running cache has
to pick it up on reload; otherwise the knob only works after a restart."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.proxy_server import ProxyConfig
cache = UserApiKeyCache()
monkeypatch.setattr(proxy_server_module, "general_settings", {})
monkeypatch.setattr(proxy_server_module, "user_api_key_cache", cache)
await ProxyConfig()._update_general_settings(db_general_settings={"user_api_key_cache_max_size": 300})
assert proxy_server_module.general_settings["user_api_key_cache_max_size"] == 300
_fill_user_api_key_cache(cache, 250)
assert cache.get_cache(key="key-0", local_only=True) == {"token": "key-0"}
@pytest.mark.asyncio
async def test_update_general_settings_clearing_user_api_key_cache_max_size_restores_the_default(monkeypatch):
"""Blanking the field in the dashboard deletes the key, so the cache must fall
back to the default capacity rather than keep the last configured size."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.proxy_server import ProxyConfig
cache = UserApiKeyCache()
cache.update_in_memory_max_size(5000)
monkeypatch.setattr(proxy_server_module, "general_settings", {"user_api_key_cache_max_size": 5000})
monkeypatch.setattr(proxy_server_module, "user_api_key_cache", cache)
await ProxyConfig()._update_general_settings(db_general_settings={"store_model_in_db": True})
assert "user_api_key_cache_max_size" not in proxy_server_module.general_settings
_fill_user_api_key_cache(cache, 201)
assert cache.get_cache(key="key-0", local_only=True) is None
@pytest.mark.asyncio
@pytest.mark.parametrize("db_value", [0, -5, "lots"])
async def test_update_general_settings_ignores_an_invalid_user_api_key_cache_max_size(db_value, monkeypatch):
"""A non-positive capacity would make the eviction loop pop an empty heap on the
next write, so a bad DB value must leave the running cache untouched."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.proxy_server import ProxyConfig
cache = UserApiKeyCache()
cache.update_in_memory_max_size(300)
monkeypatch.setattr(proxy_server_module, "general_settings", {})
monkeypatch.setattr(proxy_server_module, "user_api_key_cache", cache)
await ProxyConfig()._update_general_settings(db_general_settings={"user_api_key_cache_max_size": db_value})
assert "user_api_key_cache_max_size" not in proxy_server_module.general_settings
_fill_user_api_key_cache(cache, 250)
assert cache.get_cache(key="key-0", local_only=True) == {"token": "key-0"}
@pytest.mark.asyncio
async def test_update_general_settings_user_api_key_cache_max_size_yaml_wins(monkeypatch):
"""A DB value must not silently override an explicit YAML capacity on reload."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.proxy_server import ProxyConfig
proxy_config = ProxyConfig()
proxy_config._yaml_general_settings_keys = {"user_api_key_cache_max_size"}
cache = UserApiKeyCache()
cache.update_in_memory_max_size(300)
monkeypatch.setattr(proxy_server_module, "general_settings", {"user_api_key_cache_max_size": 300})
monkeypatch.setattr(proxy_server_module, "user_api_key_cache", cache)
await proxy_config._update_general_settings(db_general_settings={"user_api_key_cache_max_size": 10})
assert proxy_server_module.general_settings["user_api_key_cache_max_size"] == 300
_fill_user_api_key_cache(cache, 250)
assert cache.get_cache(key="key-0", local_only=True) == {"token": "key-0"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"db_value,expected",
@ -10344,6 +10426,27 @@ def test_get_config_list_includes_apply_user_budget_to_team_keys(monkeypatch):
app.dependency_overrides.clear()
def test_get_config_list_includes_user_api_key_cache_max_size(monkeypatch):
"""The Admin UI General Settings table renders whatever /config/list returns,
so the cache capacity has to be exposed there as an Integer to be editable."""
mock_prisma = MagicMock()
mock_config_table = MagicMock()
mock_config_table.find_first = AsyncMock(return_value=None)
mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
monkeypatch.setattr(proxy_server_module, "prisma_client", mock_prisma)
app.dependency_overrides[proxy_server_module.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
client = TestClient(app)
resp = client.get("/config/list", params={"config_type": "general_settings"})
assert resp.status_code == 200, resp.text
fields = {item["field_name"]: item for item in resp.json()}
assert fields["user_api_key_cache_max_size"]["field_type"] == "Integer"
finally:
app.dependency_overrides.clear()
def test_get_config_list_includes_budget_exceeded_throttle_percentage(monkeypatch):
"""The throttle fraction is a litellm_settings scalar surfaced on the General
Settings table as a Float field so it sits with the other global limits; it
@ -12946,6 +13049,48 @@ async def test_load_config_router_authorizes_fallback_targets_against_the_callin
assert router.fallback_access_check is router_fallback_access_check
@pytest.mark.asyncio
async def test_load_config_user_api_key_cache_max_size_keeps_more_than_200_entries(tmp_path, monkeypatch):
"""The auth cache used to be pinned at InMemoryCache's 200 entry default, so a
deployment with more keys than that evicted constantly and every request
fell through to the DB. The YAML knob has to raise the cap on the live cache."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.proxy_server import ProxyConfig
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump({"general_settings": {"user_api_key_cache_max_size": "1000"}}))
cache = UserApiKeyCache()
monkeypatch.setattr(proxy_server_module, "user_api_key_cache", cache)
await ProxyConfig().load_config(router=None, config_file_path=str(config_file))
_fill_user_api_key_cache(cache, 999)
assert cache.get_cache(key="key-0", local_only=True) == {"token": "key-0"}
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_value", [0, -1, "unbounded"])
async def test_load_config_rejects_a_non_positive_user_api_key_cache_max_size(tmp_path, bad_value, monkeypatch):
"""InMemoryCache treats 0 as 'cache nothing' and a negative cap makes eviction
pop an empty heap, so the proxy must refuse to boot with such a value instead
of silently disabling auth caching."""
from pydantic import ValidationError
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.proxy_server import ProxyConfig
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump({"general_settings": {"user_api_key_cache_max_size": bad_value}}))
cache = UserApiKeyCache()
monkeypatch.setattr(proxy_server_module, "user_api_key_cache", cache)
with pytest.raises(ValidationError):
await ProxyConfig().load_config(router=None, config_file_path=str(config_file))
_fill_user_api_key_cache(cache, 150)
assert cache.get_cache(key="key-0", local_only=True) == {"token": "key-0"}
def test_docs_redoc_openapi_are_reachable_by_default():
"""
LIT-6745: the interactive/machine-readable docs surfaces are on by

View file

@ -26007,6 +26007,11 @@ export interface components {
* @description If True and LiteLLM_SpendLogs has been converted to a range-partitioned table (db_scripts/partition_spend_logs.sql), retention cleanup drops expired partitions instead of deleting rows, and pre-creates upcoming partitions. Default is False.
*/
use_spend_logs_partitioning?: boolean | null;
/**
* User Api Key Cache Max Size
* @description max number of entries (virtual keys, teams, users, end users, memberships, ...) each worker keeps in its in-memory auth cache. Defaults to 200. Raise this if you have more active keys than that or auth lookups keep hitting the DB
*/
user_api_key_cache_max_size?: number | null;
/** User Header Mappings */
user_header_mappings?: components["schemas"]["UserHeaderMapping"][] | null;
/**