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_core_utils::decline::Decline; 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::{ Error, 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 = Error; 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> { 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 { cache_with_limit(4096) } fn cache_with_limit(max_entry_bytes: usize) -> Arc { 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, options: Option, calls: &AtomicUsize, request: Value, ) -> Value { let output = execute_streaming::( 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, #[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) { 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, calls: &AtomicUsize, text: &str, fail: bool, ) -> OutputOf { execute_streaming::( 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::>(); let ending = fail.then_some(Err(Error::Declined(Decline::new("test transport failure")))); Ok(CallOutput::Stream { head: (), chunks: stream::iter(chunks.into_iter().chain(ending)).boxed(), }) }, ) .await .unwrap() } async fn consume(output: OutputOf) -> Result, Error> { 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, #[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, ) { 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) { let calls = AtomicUsize::new(0); let first = execute_streaming::( cache_request(json!({})), Some(cache.clone()), Some(CacheOptions::new(CacheScope::Shared)), &(), None, || async { calls.fetch_add(1, Ordering::SeqCst); Err(Error::Declined(Decline::new("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>, 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, 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 = 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 { Some(Duration::from_secs(60)) } fn get_cache( &self, _: &str, _: &Self::Context, ) -> Result, litellm_cache::Error> { Err(litellm_cache::Error::Unavailable(None)) } fn set_cache( &self, _: &str, _: Self::Value, _: &Self::Context, ) -> Result<(), litellm_cache::Error> { Err(litellm_cache::Error::Unavailable(None)) } } #[rstest] #[tokio::test] async fn backend_failures_do_not_fail_inference() { let cache: Arc = 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, reject: bool, } impl Interceptors for CacheHitAccounting { async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> { 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(Error::Declined(Decline::new("cache accounting rejected"))); } Ok(()) } async fn before_provider_request( &self, wire: WireRequest, _: RequestContext, ) -> Result { Ok(wire) } async fn after_provider_response(&self, _: RawResponse) -> Result<(), Error> { Ok(()) } } #[rstest] #[case::unary(false)] #[case::stream_replay(true)] #[tokio::test] async fn cache_hits_notify_accounting_once_and_propagate_its_failure( cache: Arc, #[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::( 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::( 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_eq!( result.unwrap_err().declined(), Some(&Decline::new("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 = Error; 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, options: Option, calls: &AtomicUsize, request: Value, ) -> Value { execute_unary::( 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, #[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 = Arc::new( ResponseCache::new(storage.clone()).with_config(ResponseCacheConfig { namespace: "first".into(), max_entry_bytes: 4096, }), ); let second_cache: Arc = 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); }