litellm/litellm-rust/crates/inference/tests/caching.rs
devin-ai-integration[bot] 6a55e0a8aa
refactor(rust): extract inference-chat crate (#44827)
Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-06 07:17:26 -07:00

731 lines
21 KiB
Rust

use std::{
convert::Infallible,
num::NonZeroUsize,
sync::{
Arc, OnceLock,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use bytes::Bytes;
use futures_util::{StreamExt, TryStreamExt, stream};
use litellm_cache_memory::InMemoryCache;
use litellm_cache_response::{
CacheOptions, CachePolicy, CacheScope, ResponseCache, ResponseCacheConfig, ResponseCacheService,
};
use litellm_host::{
call::{CallOutput, OutputOf},
interceptors::{
ExecutionFacts, Interceptors, ProviderIdentity, RawResponse, RequestContext, ResultSource,
WireRequest,
},
lifecycle::{CallEvent, ExecutionEvent},
observation::observation_channel,
protocol::Protocol,
};
use litellm_inference::{
RouteError,
caching::{Cachable, CacheRequest, StreamCachable, execute_streaming, execute_unary},
};
use rstest::{fixture, rstest};
use serde_json::{Value, json};
struct TestRoute;
impl Protocol for TestRoute {
type Request = Value;
type Response = Value;
type Error = RouteError;
type HostCall = Infallible;
type Chunk = Bytes;
type StreamHead = ();
}
impl Cachable for TestRoute {
const SURFACE: &'static str = "test";
}
impl StreamCachable for TestRoute {
const TERMINAL_EVENT: &'static str = "message_stop";
fn replay(data: Bytes) -> Option<OutputOf<Self>> {
Some(CallOutput::Stream {
head: (),
chunks: stream::iter([Ok(data)]).boxed(),
})
}
fn bytes(chunk: &Bytes) -> &[u8] {
chunk
}
}
fn cache_request(input: Value) -> CacheRequest {
CacheRequest {
identity: ProviderIdentity {
model: "test-model".into(),
provider: "test-provider".into(),
},
input,
}
}
#[fixture]
fn cache() -> Arc<dyn ResponseCacheService> {
cache_with_limit(4096)
}
fn cache_with_limit(max_entry_bytes: usize) -> Arc<dyn ResponseCacheService> {
Arc::new(
ResponseCache::new(Arc::new(InMemoryCache::new(
Some(100),
Some(Duration::from_secs(60)),
)))
.with_config(ResponseCacheConfig {
namespace: "test".into(),
max_entry_bytes,
}),
)
}
async fn call(
cache: &Arc<dyn ResponseCacheService>,
options: Option<CacheOptions>,
calls: &AtomicUsize,
request: Value,
) -> Value {
let output = execute_streaming::<TestRoute, _, _>(
cache_request(request),
Some(cache.clone()),
options,
&(),
None,
|| async {
Ok(CallOutput::Complete(
json!({"call": calls.fetch_add(1, Ordering::SeqCst)}),
))
},
)
.await
.unwrap();
let CallOutput::Complete(response) = output else {
panic!("expected a response");
};
response
}
#[rstest]
#[case::normal(CacheOptions::new(CacheScope::Shared), true, true)]
#[case::no_cache(CacheOptions { policy: CachePolicy { no_cache: true, ..CachePolicy::default() }, ..CacheOptions::new(CacheScope::Shared) }, false, true)]
#[case::no_store(CacheOptions { policy: CachePolicy { no_store: true, ..CachePolicy::default() }, ..CacheOptions::new(CacheScope::Shared) }, true, false)]
#[case::disabled(CacheOptions { policy: CachePolicy { caching: Some(false), ..CachePolicy::default() }, ..CacheOptions::new(CacheScope::Shared) }, false, false)]
#[tokio::test]
async fn cache_controls_apply_to_both_reads_and_writes(
cache: Arc<dyn ResponseCacheService>,
#[case] options: CacheOptions,
#[case] reads: bool,
#[case] writes: bool,
) {
let calls = AtomicUsize::new(0);
let options = Some(options);
let first = call(&cache, options.clone(), &calls, json!({"model":"test"})).await;
let second = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"model":"test"}),
)
.await;
assert_eq!(first == second, writes);
let third = call(&cache, options, &calls, json!({"model":"test"})).await;
assert_eq!(second == third, reads);
assert_eq!(
calls.load(Ordering::SeqCst),
1 + usize::from(!writes) + usize::from(!reads)
);
}
#[rstest]
#[tokio::test]
async fn request_identity_is_canonical_and_scoped(cache: Arc<dyn ResponseCacheService>) {
let calls = AtomicUsize::new(0);
let first = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"model":"m", "input":{"a":1,"b":2}}),
)
.await;
let second = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":{"b":2,"a":1}, "model":"m"}),
)
.await;
assert_eq!(first, second);
let other = call(
&cache,
Some(CacheOptions {
scope: CacheScope::Isolated("other-tenant".into()),
..CacheOptions::new(CacheScope::Shared)
}),
&calls,
json!({"model":"m", "input":{"a":1,"b":2}}),
)
.await;
assert_ne!(first, other);
let changed = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"model":"m", "input":{"a":2,"b":2}}),
)
.await;
assert_ne!(first, changed);
}
async fn streamed(
cache: &Arc<dyn ResponseCacheService>,
calls: &AtomicUsize,
text: &str,
fail: bool,
) -> OutputOf<TestRoute> {
execute_streaming::<TestRoute, _, _>(
cache_request(json!({"stream":true})),
Some(cache.clone()),
Some(CacheOptions::new(CacheScope::Shared)),
&(),
None,
|| async {
calls.fetch_add(1, Ordering::SeqCst);
let chunks = text
.as_bytes()
.chunks(3)
.map(|bytes| Ok(Bytes::copy_from_slice(bytes)))
.collect::<Vec<_>>();
let ending = fail.then_some(Err(RouteError::Unsupported("test transport failure")));
Ok(CallOutput::Stream {
head: (),
chunks: stream::iter(chunks.into_iter().chain(ending)).boxed(),
})
},
)
.await
.unwrap()
}
async fn consume(output: OutputOf<TestRoute>) -> Result<Vec<u8>, RouteError> {
let CallOutput::Stream { chunks, .. } = output else {
panic!("expected a stream");
};
chunks
.try_fold(Vec::new(), |mut bytes, chunk| async move {
bytes.extend_from_slice(&chunk);
Ok(bytes)
})
.await
}
#[rstest]
#[case::complete("data: {\"type\":\"message_stop\"}\n\n", false, true)]
#[case::truncated("data: {\"type\":\"content_block_delta\"}\n\n", false, false)]
#[case::error_then_stop(
"data: {\"type\":\"error\"}\n\ndata: {\"type\":\"message_stop\"}\n\n",
false,
false
)]
#[case::trailing_incomplete("data: {\"type\":\"message_stop\"}\n\ndata: {", false, false)]
#[case::transport_failure("data: {\"type\":\"message_stop\"}\n\n", true, false)]
#[tokio::test]
async fn stream_replay_requires_successful_exhaustion(
cache: Arc<dyn ResponseCacheService>,
#[case] text: &str,
#[case] fail: bool,
#[case] cached: bool,
) {
let calls = AtomicUsize::new(0);
let first = consume(streamed(&cache, &calls, text, fail).await).await;
assert_eq!(first.is_err(), fail);
let second = consume(streamed(&cache, &calls, text, fail).await).await;
assert_eq!(second.is_err(), fail);
if !fail {
assert_eq!(first.unwrap(), second.unwrap());
}
assert_eq!(calls.load(Ordering::SeqCst), if cached { 1 } else { 2 });
}
#[rstest]
#[tokio::test]
async fn abandoning_a_partially_consumed_stream_does_not_store(
cache: Arc<dyn ResponseCacheService>,
) {
let calls = AtomicUsize::new(0);
let text = "data: {\"type\":\"message_stop\"}\n\n";
let CallOutput::Stream { mut chunks, .. } = streamed(&cache, &calls, text, false).await else {
panic!();
};
assert!(chunks.next().await.unwrap().is_ok());
drop(chunks);
assert_eq!(
consume(streamed(&cache, &calls, text, false).await)
.await
.unwrap(),
text.as_bytes()
);
assert_eq!(
consume(streamed(&cache, &calls, text, false).await)
.await
.unwrap(),
text.as_bytes()
);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[rstest]
#[tokio::test]
async fn oversized_streams_are_delivered_without_being_stored() {
let cache = cache_with_limit(8);
let calls = AtomicUsize::new(0);
let text = "data: {\"type\":\"message_stop\"}\n\n";
assert_eq!(
consume(streamed(&cache, &calls, text, false).await)
.await
.unwrap(),
text.as_bytes()
);
assert_eq!(
consume(streamed(&cache, &calls, text, false).await)
.await
.unwrap(),
text.as_bytes()
);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[rstest]
#[tokio::test]
async fn a_provider_failure_never_populates_the_cache(cache: Arc<dyn ResponseCacheService>) {
let calls = AtomicUsize::new(0);
let first = execute_streaming::<TestRoute, _, _>(
cache_request(json!({})),
Some(cache.clone()),
Some(CacheOptions::new(CacheScope::Shared)),
&(),
None,
|| async {
calls.fetch_add(1, Ordering::SeqCst);
Err(RouteError::Unsupported("test provider failure"))
},
)
.await;
assert!(first.is_err());
let successful = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({}),
)
.await;
let replayed = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({}),
)
.await;
assert_eq!(successful, replayed);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
struct InvalidEntryCache(
ResponseCache<InMemoryCache<litellm_cache_response::CacheEntry>>,
Value,
);
impl ResponseCacheService for InvalidEntryCache {
fn config(&self) -> &ResponseCacheConfig {
self.0.config()
}
fn lookup<'a>(
&'a self,
request: &'a litellm_cache_response::ResponseCacheRequest,
now: Duration,
) -> futures_util::future::BoxFuture<'a, Result<Option<Value>, litellm_cache::Error>> {
Box::pin(async move {
Ok(self
.0
.async_lookup(request, now)
.await?
.or_else(|| Some(self.1.clone())))
})
}
fn store<'a>(
&'a self,
request: &'a litellm_cache_response::ResponseCacheRequest,
response: Value,
now: Duration,
) -> futures_util::future::BoxFuture<'a, Result<(), litellm_cache::Error>> {
Box::pin(self.0.async_store(request, response, now))
}
}
#[rstest]
#[case::legacy(json!({"unexpected":"old-format"}))]
#[case::wrong_version(json!({"version":2,"surface":"test","output":{"kind":"Response","value":{"call":100}}}))]
#[case::wrong_surface(json!({"version":1,"surface":"other","output":{"kind":"Response","value":{"call":100}}}))]
#[tokio::test]
async fn an_invalid_cached_envelope_is_replaced_by_a_provider_result(#[case] poisoned: Value) {
let cache: Arc<dyn ResponseCacheService> = Arc::new(InvalidEntryCache(
ResponseCache::new(Arc::new(InMemoryCache::default())),
poisoned,
));
let request = json!({"input":"hello"});
let calls = AtomicUsize::new(0);
let first = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
request.clone(),
)
.await;
let second = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
request,
)
.await;
assert_eq!(first, second);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
struct UnavailableCache;
impl litellm_cache::BaseCache for UnavailableCache {
type Value = litellm_cache_response::CacheEntry;
type Context = litellm_cache::ExactCacheContext;
fn get_ttl(&self, _: &Self::Context) -> Option<Duration> {
Some(Duration::from_secs(60))
}
fn get_cache(
&self,
_: &str,
_: &Self::Context,
) -> Result<Option<Self::Value>, litellm_cache::Error> {
Err(litellm_cache::Error::Unavailable)
}
fn set_cache(
&self,
_: &str,
_: Self::Value,
_: &Self::Context,
) -> Result<(), litellm_cache::Error> {
Err(litellm_cache::Error::Unavailable)
}
}
#[rstest]
#[tokio::test]
async fn backend_failures_do_not_fail_inference() {
let cache: Arc<dyn ResponseCacheService> = Arc::new(
ResponseCache::new(Arc::new(UnavailableCache)).with_config(ResponseCacheConfig {
namespace: "test".into(),
max_entry_bytes: 4096,
}),
);
let calls = AtomicUsize::new(0);
let first = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({}),
)
.await;
let second = call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({}),
)
.await;
assert_ne!(first, second);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
struct UnaryTestRoute;
#[derive(Default)]
struct CacheHitAccounting {
calls: AtomicUsize,
key: OnceLock<String>,
reject: bool,
}
impl Interceptors<RouteError> for CacheHitAccounting {
async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), RouteError> {
self.calls.fetch_add(1, Ordering::SeqCst);
let ResultSource::Cache { key } = facts.source else {
panic!("expected cache source")
};
assert_eq!(
facts.provider,
ProviderIdentity {
model: "test-model".into(),
provider: "test-provider".into()
}
);
self.key.set(key).unwrap();
if self.reject {
return Err(RouteError::Unsupported("cache accounting rejected"));
}
Ok(())
}
async fn before_provider_request(
&self,
wire: WireRequest,
_: RequestContext,
) -> Result<WireRequest, RouteError> {
Ok(wire)
}
async fn after_provider_response(&self, _: RawResponse) -> Result<(), RouteError> {
Ok(())
}
}
#[rstest]
#[case::unary(false)]
#[case::stream_replay(true)]
#[tokio::test]
async fn cache_hits_notify_accounting_once_and_propagate_its_failure(
cache: Arc<dyn ResponseCacheService>,
#[case] streaming_route: bool,
#[values(false, true)] reject: bool,
) {
let provider_calls = AtomicUsize::new(0);
let accounting = CacheHitAccounting {
reject,
..Default::default()
};
let (observer, mut events) = observation_channel(NonZeroUsize::new(4).unwrap());
let request = if streaming_route {
json!({"stream":true})
} else {
json!({"input":"hello"})
};
let expected = if streaming_route {
json!(
consume(
streamed(
&cache,
&provider_calls,
"data: {\"type\":\"message_stop\"}\n\n",
false,
)
.await
)
.await
.unwrap()
)
} else {
unary_call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&provider_calls,
request.clone(),
)
.await
};
let result = if streaming_route {
match execute_streaming::<TestRoute, _, _>(
cache_request(request),
Some(cache),
Some(CacheOptions::new(CacheScope::Shared)),
&accounting,
Some(&observer),
|| async { panic!("a cache hit must not call the provider") },
)
.await
{
Ok(output) => consume(output).await.map(|bytes| json!(bytes)),
Err(error) => Err(error),
}
} else {
execute_unary::<UnaryTestRoute, _, _>(
cache_request(request),
Some(cache),
Some(CacheOptions::new(CacheScope::Shared)),
&accounting,
Some(&observer),
|| async { panic!("a cache hit must not call the provider") },
)
.await
};
if reject {
assert!(matches!(
result,
Err(RouteError::Unsupported("cache accounting rejected"))
));
} else {
assert_eq!(result.unwrap(), expected);
}
assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
assert_eq!(accounting.calls.load(Ordering::SeqCst), 1);
let key = accounting.key.get().unwrap();
assert!(!key.is_empty());
assert!(matches!(
events.try_recv().unwrap(),
CallEvent::Execution(ExecutionEvent::ResultReady { facts }) if facts.source == ResultSource::Cache { key: key.clone() }
));
assert!(events.try_recv().is_err());
}
impl Protocol for UnaryTestRoute {
type Request = Value;
type Response = Value;
type Error = RouteError;
type HostCall = Infallible;
type Chunk = Infallible;
type StreamHead = Infallible;
}
impl Cachable for UnaryTestRoute {
const SURFACE: &'static str = "unary-test";
}
async fn unary_call(
cache: &Arc<dyn ResponseCacheService>,
options: Option<CacheOptions>,
calls: &AtomicUsize,
request: Value,
) -> Value {
execute_unary::<UnaryTestRoute, _, _>(
cache_request(request),
Some(cache.clone()),
options,
&(),
None,
|| async { Ok(json!({"call":calls.fetch_add(1, Ordering::SeqCst)})) },
)
.await
.unwrap()
}
#[rstest]
#[case::normal(CacheOptions::new(CacheScope::Shared), true, true)]
#[case::no_cache(CacheOptions { policy: CachePolicy { no_cache: true, ..CachePolicy::default() }, ..CacheOptions::new(CacheScope::Shared) }, false, true)]
#[case::no_store(CacheOptions { policy: CachePolicy { no_store: true, ..CachePolicy::default() }, ..CacheOptions::new(CacheScope::Shared) }, true, false)]
#[case::disabled(CacheOptions { policy: CachePolicy { caching: Some(false), ..CachePolicy::default() }, ..CacheOptions::new(CacheScope::Shared) }, false, false)]
#[tokio::test]
async fn unary_cache_controls_do_not_change_the_shared_service(
cache: Arc<dyn ResponseCacheService>,
#[case] options: CacheOptions,
#[case] reads: bool,
#[case] writes: bool,
) {
let calls = AtomicUsize::new(0);
let options = Some(options);
let first = unary_call(&cache, options.clone(), &calls, json!({"input":"hello"})).await;
let second = unary_call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":"hello"}),
)
.await;
assert_eq!(first == second, writes);
let third = unary_call(&cache, options, &calls, json!({"input":"hello"})).await;
assert_eq!(second == third, reads);
let fourth = unary_call(
&cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":"hello"}),
)
.await;
assert_eq!(fourth, if !reads && writes { third } else { second });
assert_eq!(
calls.load(Ordering::SeqCst),
1 + usize::from(!writes) + usize::from(!reads)
);
}
#[rstest]
#[tokio::test]
async fn namespaces_and_surfaces_isolate_entries_on_shared_storage() {
let storage = Arc::new(InMemoryCache::default());
let first_cache: Arc<dyn ResponseCacheService> = Arc::new(
ResponseCache::new(storage.clone()).with_config(ResponseCacheConfig {
namespace: "first".into(),
max_entry_bytes: 4096,
}),
);
let second_cache: Arc<dyn ResponseCacheService> = Arc::new(
ResponseCache::new(storage).with_config(ResponseCacheConfig {
namespace: "second".into(),
max_entry_bytes: 4096,
}),
);
let calls = AtomicUsize::new(0);
let first = call(
&first_cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":"hello"}),
)
.await;
let different_namespace = call(
&second_cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":"hello"}),
)
.await;
let different_surface = unary_call(
&first_cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":"hello"}),
)
.await;
assert_ne!(first, different_namespace);
assert_ne!(first, different_surface);
assert_eq!(
call(
&first_cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":"hello"})
)
.await,
first
);
assert_eq!(
call(
&second_cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":"hello"})
)
.await,
different_namespace
);
assert_eq!(
unary_call(
&first_cache,
Some(CacheOptions::new(CacheScope::Shared)),
&calls,
json!({"input":"hello"})
)
.await,
different_surface
);
assert_eq!(calls.load(Ordering::SeqCst), 3);
}