mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
* 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>
191 lines
7.5 KiB
Python
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)}
|