litellm/litellm/rust_bridge/response_cache.py
devin-ai-integration[bot] 4677f1028e
refactor(rust): align the cache crates with Python and wire every native backend (#42530)
* refactor(rust): align the cache crates with Python and activate every backend

The cache port had drifted: lifecycle and Redis-only operations sat on
`BaseCache`, counters were pinned to `f64`, each semantic backend defined its
own embedder and prompt handling, and only the in-memory backend could be
selected natively.

- Split `disconnect` and `test_connection` out of `BaseCache` into optional
  capabilities, implemented only where the Python class defines them, and give
  every Redis-only operation its own capability trait.
- Decouple counters from the stored value type, so one backend can serve both
  responses and counters as Python's `RedisCache` does.
- Share one `Embedder` and prompt contract in `litellm_cache::semantic`, and
  make the Redis and Valkey semantic backends generic over their codec.
- Port the Python operations that were missing: `async_refresh_ttl`,
  `async_rpush_and_trim`, `async_set_cache_pipeline_with_ttls`, the DualCache
  pipeline, sadd, bulk delete and TTL reads, and the semantic-similarity
  write-back.
- Take the HTTP client from the host pool in the GCS, S3 and Azure backends.
- Activate all nine backends through the Rust catalog, whose rules all stay
  `PYTHON_ONLY`, and route the `Cache` facade's storage calls to the native
  runtime when one is selected.
- Give every crate the same layout, move all tests to `tests/` on rstest, and
  add the shared `litellm-cache-testing` contract suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: freeze native cache request kwargs and batch entries for type discipline

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

* fix: declare semantic lookup methods in the native stub

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

* refactor(rust): align the cache crates with Python and activate every backend

The cache port had drifted: lifecycle and Redis-only operations sat on
`BaseCache`, counters were pinned to `f64`, each semantic backend defined its
own embedder and prompt handling, and only the in-memory backend could be
selected natively.

- Split `disconnect` and `test_connection` out of `BaseCache` into optional
  capabilities, implemented only where the Python class defines them, and give
  every Redis-only operation its own capability trait.
- Decouple counters from the stored value type, so one backend can serve both
  responses and counters as Python's `RedisCache` does.
- Share one `Embedder` and prompt contract in `litellm_cache::semantic`, and
  make the Redis and Valkey semantic backends generic over their codec.
- Port the Python operations that were missing: `async_refresh_ttl`,
  `async_rpush_and_trim`, `async_set_cache_pipeline_with_ttls`, the DualCache
  pipeline, sadd, bulk delete and TTL reads, and the semantic-similarity
  write-back.
- Take the HTTP client from the host pool in the GCS, S3 and Azure backends.
- Activate all nine backends through the Rust catalog, whose rules all stay
  `PYTHON_ONLY`, and route the `Cache` facade's storage calls to the native
  runtime when one is selected.
- Give every crate the same layout, move all tests to `tests/` on rstest, and
  add the shared `litellm-cache-testing` contract suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: freeze native cache request kwargs and batch entries for type discipline

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

* fix: declare semantic lookup methods in the native stub

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

* test(rust): opt the native Messages and tokenizer suites into Rust explicitly

#42517 made the Messages, token counter and tokenizer routes Python-only, so
tests/test_litellm_rust silently exercised the Python path or failed outright.
Each suite now prepends a RUST_OPT_IN rule for its route, keeping native
coverage without changing the shipped default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rust): pop one at a time in the Redis 6 lpop pipeline and drop explanatory comments

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

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 13:13:02 -07:00

191 lines
7.5 KiB
Python

from __future__ import annotations
import math
from collections.abc import Awaitable, Mapping, Sequence
from dataclasses import dataclass
from typing import Final, Protocol, cast
from typing_extensions import ReadOnly, Required, TypedDict, assert_never
from litellm.rust_bridge.bindings import NativeBinding, native_exception_types
from litellm.rust_bridge.catalog import CacheContext, Rules, decision
from litellm.rust_bridge.configuration import Decision
class CacheFacade(Protocol):
@property
def type(self) -> object: ...
@property
def ttl(self) -> float | None: ...
@property
def semantic_cache_scope(self) -> str: ...
def get_cache_key(self, **kwargs: object) -> str: ... # kwargs-ok: mirrors the legacy cache facade contract
class NativeCacheKey(TypedDict):
preset: ReadOnly[str]
class NativeCacheRequest(TypedDict, total=False):
key: Required[ReadOnly[NativeCacheKey]]
ttl_seconds: ReadOnly[float | None]
max_age_seconds: ReadOnly[float | None]
messages: ReadOnly[object | None]
input: ReadOnly[object | None]
metadata: ReadOnly[object | None]
litellm_metadata: ReadOnly[object | None]
litellm_params: ReadOnly[object | None]
scope: ReadOnly[str]
class NativeResponseCacheRuntime(Protocol):
@property
def kind(self) -> str: ...
def lookup(self, request: NativeCacheRequest) -> object: ...
def lookup_semantic(self, request: NativeCacheRequest) -> tuple[object, float | None]: ...
def store(self, request: NativeCacheRequest, response: object) -> None: ...
def lookup_batch(self, requests: Sequence[NativeCacheRequest]) -> object: ...
def async_lookup(self, request: NativeCacheRequest) -> Awaitable[object]: ...
def async_lookup_semantic(self, request: NativeCacheRequest) -> Awaitable[tuple[object, float | None]]: ...
def async_store(self, request: NativeCacheRequest, response: object) -> Awaitable[None]: ...
def async_lookup_batch(self, requests: Sequence[NativeCacheRequest]) -> Awaitable[object]: ...
def async_store_batch(
self,
requests: Sequence[NativeCacheRequest],
responses: Sequence[object],
) -> Awaitable[object]: ...
def async_flush(self) -> Awaitable[None]: ...
def ping(self) -> Awaitable[object]: ...
class NativeResponseCacheRuntimeFactory(Protocol):
@staticmethod
def from_cache(cache: CacheFacade) -> NativeResponseCacheRuntime: ...
def _runtime_factory(value: object) -> NativeResponseCacheRuntimeFactory | None:
return cast(NativeResponseCacheRuntimeFactory, value) if callable(getattr(value, "from_cache", None)) else None
_RUNTIME: Final = NativeBinding("_ResponseCacheRuntime", validate=_runtime_factory)
@dataclass(frozen=True, slots=True)
class ResponseCacheRuntime:
native: NativeResponseCacheRuntime
@property
def kind(self) -> str:
return self.native.kind
def request(self, cache: CacheFacade, kwargs: Mapping[str, object]) -> NativeCacheRequest | None:
key_value: Final = kwargs.get("cache_key")
key: Final = key_value if isinstance(key_value, str) else cache.get_cache_key(**dict(kwargs))
if not key:
return None
control_value: Final = kwargs.get("cache")
control: Final = _string_mapping(control_value)
configured_ttl: Final = cache.ttl if cache.ttl is not None else _duration(kwargs.get("ttl"))
control_ttl: Final = _duration(control.get("ttl"))
current_max_age: Final = _duration(control.get("s-max-age"))
legacy_max_age: Final = _duration(control.get("s-maxage"))
ttl: Final = configured_ttl if control_ttl is None else control_ttl
max_age: Final = legacy_max_age if current_max_age is None else current_max_age
return NativeCacheRequest(
key=NativeCacheKey(preset=key),
ttl_seconds=ttl,
max_age_seconds=max_age,
messages=kwargs.get("messages"),
input=kwargs.get("input"),
metadata=kwargs.get("metadata"),
litellm_metadata=kwargs.get("litellm_metadata"),
litellm_params=kwargs.get("litellm_params"),
scope=cache.semantic_cache_scope,
)
def lookup(self, request: NativeCacheRequest) -> object:
return self.native.lookup(request)
def lookup_semantic(self, request: NativeCacheRequest) -> tuple[object, float | None]:
"""The cached response and the similarity a semantic backend reports, if any."""
response, similarity = self.native.lookup_semantic(request)
return response, similarity
def store(self, request: NativeCacheRequest, response: object) -> None:
self.native.store(request, response)
def lookup_batch(self, requests: Sequence[NativeCacheRequest]) -> object:
return self.native.lookup_batch(requests)
async def async_lookup(self, request: NativeCacheRequest) -> object:
return await self.native.async_lookup(request)
async def async_lookup_semantic(self, request: NativeCacheRequest) -> tuple[object, float | None]:
response, similarity = await self.native.async_lookup_semantic(request)
return response, similarity
async def async_store(self, request: NativeCacheRequest, response: object) -> None:
await self.native.async_store(request, response)
async def async_lookup_batch(self, requests: Sequence[NativeCacheRequest]) -> object:
return await self.native.async_lookup_batch(requests)
async def async_store_batch(
self,
requests: Sequence[NativeCacheRequest],
responses: Sequence[object],
) -> object:
return await self.native.async_store_batch(requests, responses)
async def ping(self) -> object:
return await self.native.ping()
async def async_flush(self) -> None:
await self.native.async_flush()
def resolve_response_cache(
cache: CacheFacade,
rules: Rules | None = None,
) -> ResponseCacheRuntime | None:
backend_value: Final = cache.type
backend: Final = str.__str__(backend_value) if isinstance(backend_value, str) else str(backend_value)
selected: Final = decision(CacheContext(backend=backend), rules)
match selected:
case Decision.PYTHON:
return None
case Decision.RUST_WITH_FALLBACK | Decision.RUST_REQUIRED:
factory: Final = _RUNTIME.load()
if factory is None:
if selected is Decision.RUST_REQUIRED:
raise RuntimeError("Rust response cache runtime is unavailable")
return None
try:
return ResponseCacheRuntime(factory.from_cache(cache))
except Exception as error:
exceptions: Final = native_exception_types()
if exceptions is None or not isinstance(error, exceptions[0]):
raise
if selected is Decision.RUST_REQUIRED:
raise RuntimeError(f"Rust response cache runtime declined the cache: {error}") from error
return None
case _:
assert_never(selected)
def _duration(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, int | float):
return None
duration: Final = float(value)
return duration if math.isfinite(duration) and duration >= 0 else None
def _string_mapping(value: object) -> Mapping[str, object]:
if not isinstance(value, Mapping):
return {}
source: Final = cast(Mapping[object, object], value)
return {key: item for key, item in source.items() if isinstance(key, str)}