mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
parent
d58f657fa2
commit
984287daaa
8 changed files with 331 additions and 23 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
litellm/proxy/common_utils/cache_pydantic_utils.py
Normal file
86
litellm/proxy/common_utils/cache_pydantic_utils.py
Normal 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
|
||||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
85
tests/test_litellm/proxy/common_utils/test_cache_codec.py
Normal file
85
tests/test_litellm/proxy/common_utils/test_cache_codec.py
Normal 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]}"
|
||||
Loading…
Add table
Reference in a new issue