Implement CacheCodec for DualCache serialization and deserialization. Add attach_redis_cache method to DualCache for lazy Redis integration. Update RedisCache to handle None keys and improve logging. Enhance user_api_key_auth caching logic and introduce tests for CacheCodec functionality.

This commit is contained in:
harish-berri 2026-04-21 22:53:54 +00:00
parent d58f657fa2
commit 984287daaa
8 changed files with 331 additions and 23 deletions

View file

@ -92,6 +92,25 @@ class DualCache(BaseCache):
if default_redis_ttl is not None:
self.default_redis_ttl = default_redis_ttl
def attach_redis_cache(
self,
redis_cache: Optional[RedisCache] = None,
*,
default_redis_ttl: Optional[float] = None,
) -> None:
"""
Attach a Redis backend if this DualCache does not already have one.
No-op when ``redis_cache`` is None or when Redis was already set (constructor
or a prior attach). Use this for lazy wiring after a shared Redis client exists.
Does not backfill in-memory-only keys to Redis.
"""
if redis_cache is None or self.redis_cache is not None:
return
self.redis_cache = redis_cache
if default_redis_ttl is not None:
self.default_redis_ttl = default_redis_ttl
def set_cache(self, key, value, local_only: bool = False, **kwargs):
# Update both Redis and in-memory cache
try:

View file

@ -551,6 +551,13 @@ class RedisCache(BaseCache):
async def async_set_cache(self, key, value, **kwargs):
from redis.asyncio import Redis
if key is None:
verbose_logger.debug(
"LiteLLM Redis Caching: async set() skipped — key is None, value=%r",
value,
)
return None
start_time = time.time()
try:
_redis_client: Redis = self.init_async_client() # type: ignore
@ -569,8 +576,9 @@ class RedisCache(BaseCache):
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async set() - Got exception from REDIS %s, Writing value=%s",
"LiteLLM Redis Caching: async set() - Got exception from REDIS %s, key=%r, value=%r",
str(e),
key,
value,
)
raise e

View file

@ -12,7 +12,7 @@ Run checks for:
import asyncio
import re
import time
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cast
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Type, Union, cast
from fastapi import HTTPException, Request, status
from pydantic import BaseModel
@ -65,6 +65,7 @@ from litellm.proxy.guardrails.tool_name_extraction import (
TOOL_CAPABLE_CALL_TYPES,
extract_request_tool_names,
)
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
from litellm.proxy.route_llm_request import route_request
from litellm.proxy.utils import PrismaClient, ProxyLogging, log_db_metrics
from litellm.router import Router
@ -1435,13 +1436,23 @@ async def get_user_object(
async def _cache_management_object(
key: str,
value: BaseModel,
value: Union[BaseModel, Dict[str, Any]],
user_api_key_cache: DualCache,
proxy_logging_obj: Optional[ProxyLogging],
*,
model_type: Type[BaseModel],
):
"""
Persist management objects to DualCache (in-memory + optional Redis).
Values must be JSON-serializable for the Redis path (``json.dumps``). Payloads are
encoded with ``CacheCodec.serialize(..., model_type=...)`` so writes match reads
via ``CacheCodec.deserialize(..., model_type)``.
"""
cache_payload = CacheCodec.serialize(value, model_type=model_type)
await user_api_key_cache.async_set_cache(
key=key,
value=value,
value=cache_payload,
ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL,
)
@ -1462,6 +1473,7 @@ async def _cache_team_object(
value=team_table,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
model_type=LiteLLM_TeamTableCachedObj,
)
@ -1481,6 +1493,7 @@ async def _cache_key_object(
value=user_api_key_obj,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
model_type=UserAPIKeyAuth,
)
@ -2196,15 +2209,13 @@ async def get_key_object(
# check if in cache
key = hashed_token
cached_key_obj: Optional[UserAPIKeyAuth] = await user_api_key_cache.async_get_cache(
key=key
)
# Same flow as before: use cache only when we have a hit we can turn into UserAPIKeyAuth
# (dict from Redis / model_dump, or UserAPIKeyAuth from in-memory). Otherwise fall through to DB.
cached_key_obj = await user_api_key_cache.async_get_cache(key=key)
if cached_key_obj is not None:
if isinstance(cached_key_obj, dict):
return UserAPIKeyAuth(**cached_key_obj)
elif isinstance(cached_key_obj, UserAPIKeyAuth):
return cached_key_obj
user_api_key_auth = CacheCodec.deserialize(cached_key_obj, UserAPIKeyAuth)
if user_api_key_auth is not None:
return user_api_key_auth
if check_cache_only:
raise Exception(
@ -3465,6 +3476,7 @@ async def get_project_object(
value=project_obj,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
model_type=LiteLLM_ProjectTableCachedObj,
)
return project_obj

View file

@ -1452,9 +1452,11 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
else:
_team_obj = None
await user_api_key_cache.async_set_cache(
key=valid_token.team_id, value=_team_obj
) # save team table in cache - used for tpm/rpm limiting - tpm_rpm_limiter.py
# Only cache when the key is a real team_id (non-team keys must not use key=None).
if valid_token.team_id is not None and _team_obj is not None:
await user_api_key_cache.async_set_cache(
key=valid_token.team_id, value=_team_obj
) # save team table in cache - used for tpm/rpm limiting - tpm_rpm_limiter.py
# Fetch project object if key belongs to a project
_project_obj = None

View file

@ -0,0 +1,86 @@
"""
DualCache presents a single API for reads and writes, but the two backends behave
differently: the in-memory layer can store arbitrary Python objects (including live
``BaseModel`` instances), while Redis persists strings and therefore needs JSON-safe
payloads (``json.dumps`` on the Redis side).
Call sites therefore see cache ``value`` / ``cached`` as effectively ``Any``: the same
key may deserialize to a model on one process (memory hit) or to a ``dict`` after a
Redis round-trip. ``CacheCodec`` centralizes encode/decode at that boundary:
``CacheCodec.serialize`` before ``set``, ``CacheCodec.deserialize`` after ``get``
when you need a typed ``BaseModel``.
``dataclasses`` are not supported: only ``dict`` and Pydantic ``BaseModel`` inputs
are encoded; pass a Pydantic model or convert with e.g. ``dataclasses.asdict`` first.
"""
from __future__ import annotations
from typing import Any, Optional, Type, TypeVar
from pydantic import BaseModel, ValidationError
from litellm._logging import verbose_proxy_logger
T = TypeVar("T", bound=BaseModel)
class CacheCodec:
"""
Encode/decode Pydantic models for DualCache (memory vs Redis safe payloads).
Dataclasses are not supported yet (only ``dict`` and ``BaseModel``).
Use ``serialize`` with ``model_type`` when writing so the same schema is used
as on read (``deserialize``). Pass ``model_type`` whenever you know it
(validates ``dict`` payloads and normalizes ``BaseModel`` instances).
"""
@staticmethod
def serialize(value: Any, model_type: Optional[Type[T]] = None) -> Any:
"""
Encode a value for DualCache / Redis (``json.dumps``-safe).
If ``model_type`` is set, the payload is validated with that model, then
``model_dump(mode="json", exclude_none=True)`` symmetric with ``deserialize``.
If ``model_type`` is omitted, any ``BaseModel`` is dumped as above; other
values (e.g. plain ``dict``) are returned unchanged.
"""
if model_type is not None:
if isinstance(value, (dict, BaseModel)):
return model_type.model_validate(value).model_dump(
mode="json", exclude_none=True
)
return value
if isinstance(value, BaseModel):
return value.model_dump(mode="json", exclude_none=True)
return value
@staticmethod
def deserialize(cached: Any, model_type: Type[T]) -> Optional[T]:
"""
Decode a cache entry to ``model_type``.
- ``None`` ``None``
- Already an instance of ``model_type`` (including subclasses) returned as-is
- ``dict`` ``model_type.model_validate(...)``; on ``ValidationError``,
logs a warning and returns ``None`` (treat as cache miss; avoids serving
malformed or schema-drifted entries)
- Any other type ``None`` (caller should treat as cache miss or log)
"""
if cached is None:
return None
if isinstance(cached, model_type):
return cached
if isinstance(cached, dict):
try:
return model_type.model_validate(cached)
except ValidationError as e:
verbose_proxy_logger.warning(
"CacheCodec.deserialize: validation failed for %s (%s)",
model_type.__name__,
e,
)
return None
return None

View file

@ -93,6 +93,7 @@ from litellm.proxy._types import (
TransformRequestBody,
UserAPIKeyAuth,
)
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
from litellm.proxy.common_utils.callback_utils import (
normalize_callback_names,
process_callback,
@ -1943,14 +1944,22 @@ async def update_cache( # noqa: PLR0915
else:
hashed_token = token
verbose_proxy_logger.debug("_update_key_cache: hashed_token=%s", hashed_token)
existing_spend_obj: LiteLLM_VerificationTokenView = await user_api_key_cache.async_get_cache(key=hashed_token) # type: ignore
cached_key = await user_api_key_cache.async_get_cache(key=hashed_token)
verbose_proxy_logger.debug(
f"_update_key_cache: existing_spend_obj={existing_spend_obj}"
f"_update_key_cache: existing_spend_obj={cached_key}"
)
if existing_spend_obj is None:
if cached_key is None:
return
else:
existing_spend = existing_spend_obj.spend
existing_spend_obj = CacheCodec.deserialize(cached_key, UserAPIKeyAuth)
if existing_spend_obj is None:
verbose_proxy_logger.warning(
"_update_key_cache: unexpected cached key type %s for hashed_token=%s; skipping spend update",
type(cached_key).__name__,
hashed_token,
)
return
existing_spend = existing_spend_obj.spend or 0.0
# Calculate the new cost by adding the existing cost and response_cost
new_spend = existing_spend + response_cost
@ -2008,9 +2017,14 @@ async def update_cache( # noqa: PLR0915
existing_team_member_spend + response_cost
)
# Update the cost column for the given token
# Update the cost column for the given token (dict for Redis pipeline json.dumps)
existing_spend_obj.spend = new_spend
values_to_update_in_cache.append((hashed_token, existing_spend_obj))
values_to_update_in_cache.append(
(
hashed_token,
CacheCodec.serialize(existing_spend_obj, model_type=UserAPIKeyAuth),
)
)
### UPDATE USER SPEND ###
async def _update_user_cache():
@ -2851,9 +2865,22 @@ class ProxyConfig:
):
## INIT PROXY REDIS USAGE CLIENT ##
redis_usage_cache = litellm.cache.cache
spend_counter_cache.redis_cache = redis_usage_cache
spend_counter_cache.attach_redis_cache(
redis_usage_cache,
default_redis_ttl=litellm.default_redis_ttl,
)
# Note: PKCE verifier storage uses redis_usage_cache directly (not
# user_api_key_cache) to avoid routing all API-key lookups through Redis.
# Share the same Redis client for virtual-key lookups (same DualCache as
# model_max_budget_limiter). attach_redis_cache is a no-op if Redis is
# already set (e.g. config reload).
user_api_key_cache.attach_redis_cache(
redis_usage_cache,
default_redis_ttl=litellm.default_redis_ttl,
)
verbose_proxy_logger.debug(
"Attached redis_usage_cache Redis client to user_api_key_cache"
)
def switch_on_llm_response_caching(self):
"""

View file

@ -245,3 +245,72 @@ async def test_dual_cache_delete(is_async):
result = dual_cache.get_cache(test_key)
assert result is None
def test_dual_cache_late_attach_redis_wires_writes_and_ttl_sync():
"""
Typical lazy startup (sync): DualCache runs with in-memory only, then Redis
becomes available and is attached. New writes must reach Redis; keys written
before attach are not backfilled. Optional default_redis_ttl is applied on attach.
"""
in_memory = InMemoryCache()
dual_cache = DualCache(in_memory_cache=in_memory, redis_cache=None)
mock_redis = MagicMock()
mock_redis.set_cache = MagicMock()
mock_redis.async_set_cache = AsyncMock()
key_before = f"before_attach_{uuid.uuid4()}"
val_before = {"phase": "memory_only"}
dual_cache.set_cache(key_before, val_before)
assert in_memory.get_cache(key_before) == val_before
dual_cache.attach_redis_cache(mock_redis, default_redis_ttl=99.0)
assert dual_cache.redis_cache is mock_redis
assert dual_cache.default_redis_ttl == 99.0
mock_redis.set_cache.assert_not_called()
key_after = f"after_attach_{uuid.uuid4()}"
val_after = {"phase": "memory_and_redis"}
dual_cache.set_cache(key_after, val_after)
mock_redis.set_cache.assert_called_once()
assert mock_redis.set_cache.call_args[0][:2] == (key_after, val_after)
assert in_memory.get_cache(key_after) == val_after
@pytest.mark.asyncio
async def test_dual_cache_late_attach_redis_wires_writes_and_ttl_async():
"""
Typical lazy startup (async): DualCache runs with in-memory only, then Redis
becomes available and is attached. New writes must reach Redis; keys written
before attach are not backfilled. Optional default_redis_ttl is applied on attach.
"""
in_memory = InMemoryCache()
dual_cache = DualCache(in_memory_cache=in_memory, redis_cache=None)
mock_redis = MagicMock()
mock_redis.set_cache = MagicMock()
mock_redis.async_set_cache = AsyncMock()
key_before = f"before_attach_{uuid.uuid4()}"
val_before = {"phase": "memory_only"}
await dual_cache.async_set_cache(key_before, val_before)
assert in_memory.get_cache(key_before) == val_before
dual_cache.attach_redis_cache(mock_redis, default_redis_ttl=99.0)
assert dual_cache.redis_cache is mock_redis
assert dual_cache.default_redis_ttl == 99.0
mock_redis.async_set_cache.assert_not_called()
key_after = f"after_attach_{uuid.uuid4()}"
val_after = {"phase": "memory_and_redis"}
await dual_cache.async_set_cache(key_after, val_after)
mock_redis.async_set_cache.assert_called_once()
assert mock_redis.async_set_cache.call_args[0][:2] == (key_after, val_after)
assert in_memory.get_cache(key_after) == val_after

View file

@ -0,0 +1,85 @@
import logging
from typing import Optional
import pytest
from pydantic import BaseModel, ValidationError
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
class _SampleModel(BaseModel):
name: str
count: Optional[int] = None
class _SampleSubModel(_SampleModel):
pass
class TestCacheCodecSerialize:
def test_without_model_type_base_model_dumped_json_safe(self):
m = _SampleModel(name="a", count=1)
out = CacheCodec.serialize(m)
assert out == {"name": "a", "count": 1}
def test_without_model_type_dict_unchanged(self):
d = {"name": "x"}
assert CacheCodec.serialize(d) is d
def test_without_model_type_primitive_unchanged(self):
assert CacheCodec.serialize(42) == 42
def test_with_model_type_dict_validated_and_dumped(self):
out = CacheCodec.serialize({"name": "b", "count": 2}, model_type=_SampleModel)
assert out == {"name": "b", "count": 2}
def test_with_model_type_base_model_validated_and_dumped(self):
m = _SampleModel(name="c", count=None)
out = CacheCodec.serialize(m, model_type=_SampleModel)
assert out == {"name": "c"}
def test_with_model_type_exclude_none_on_dump(self):
out = CacheCodec.serialize({"name": "d"}, model_type=_SampleModel)
assert out == {"name": "d"}
assert "count" not in out
def test_with_model_type_non_dict_non_model_passthrough(self):
assert CacheCodec.serialize("raw", model_type=_SampleModel) == "raw"
def test_with_model_type_invalid_dict_raises(self):
with pytest.raises(ValidationError):
CacheCodec.serialize({"count": 1}, model_type=_SampleModel)
class TestCacheCodecDeserialize:
def test_none_returns_none(self):
assert CacheCodec.deserialize(None, _SampleModel) is None
def test_dict_validates_to_model(self):
m = CacheCodec.deserialize({"name": "e", "count": 3}, _SampleModel)
assert isinstance(m, _SampleModel)
assert m.name == "e"
assert m.count == 3
def test_instance_same_type_returned_as_is(self):
original = _SampleModel(name="f")
m = CacheCodec.deserialize(original, _SampleModel)
assert m is original
def test_subclass_instance_accepted(self):
sub = _SampleSubModel(name="g")
m = CacheCodec.deserialize(sub, _SampleModel)
assert m is sub
def test_wrong_type_returns_none(self):
assert CacheCodec.deserialize("not-a-dict", _SampleModel) is None
def test_invalid_dict_returns_none_and_logs_warning(self, caplog):
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
out = CacheCodec.deserialize({"count": 1}, _SampleModel)
assert out is None
assert any(
"CacheCodec.deserialize" in r.message and "_SampleModel" in r.message
for r in caplog.records
if r.levelno >= logging.WARNING
), f"Expected deserialize validation warning. Records: {[r.message for r in caplog.records]}"