use std::cell::Cell;
use std::collections::{HashMap, VecDeque};
use std::future::Future;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::pin::Pin;
use std::sync::{Arc, OnceLock, RwLock};
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use crate::observability::usage::CacheState;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use tower::{Layer, Service};
use super::types::{LlmRequest, LlmRequestKind, LlmResponse};
use crate::cache_key::{CacheKeyInput, CacheKeyStrategy, ExactHashStrategy};
use crate::client::BoxFuture;
use crate::embedding::EmbeddingProvider;
use crate::error::{LiterLlmError, Result};
use crate::tower::cache_policy::{CacheDecision, CachePolicy, CachePolicyContext, StandardCachePolicy};
use crate::types::{ChatCompletionResponse, EmbeddingResponse};
use crate::vectorstore::VectorStore;
tokio::task_local! {
pub static CACHE_STATE_CELL: Cell<CacheState>;
}
#[cfg_attr(alef, alef(skip))]
pub fn record_cache_state(state: CacheState) {
let _ = CACHE_STATE_CELL.try_with(|c| c.set(state));
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CacheBackend {
#[default]
Memory,
#[cfg(feature = "opendal-cache")]
OpenDal {
scheme: String,
config: std::collections::HashMap<String, String>,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CacheConfig {
pub max_entries: usize,
pub ttl: Duration,
pub backend: CacheBackend,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
max_entries: 256,
ttl: Duration::from_secs(300),
backend: CacheBackend::Memory,
}
}
}
#[derive(Clone, Debug)]
#[cfg_attr(alef, alef(skip))]
pub enum CachedResponse {
Chat(ChatCompletionResponse),
Embed(EmbeddingResponse),
Error {
error: Arc<LiterLlmError>,
expires_at: Instant,
},
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum CachedResponseRepr {
Chat(ChatCompletionResponse),
Embed(EmbeddingResponse),
}
impl Serialize for CachedResponse {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
match self {
Self::Chat(r) => CachedResponseRepr::Chat(r.clone()).serialize(serializer),
Self::Embed(r) => CachedResponseRepr::Embed(r.clone()).serialize(serializer),
Self::Error { .. } => Err(serde::ser::Error::custom(
"CachedResponse::Error is not serialisable; convert to a serialisable form before writing to an external store",
)),
}
}
}
impl<'de> Deserialize<'de> for CachedResponse {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
match CachedResponseRepr::deserialize(deserializer)? {
CachedResponseRepr::Chat(r) => Ok(Self::Chat(r)),
CachedResponseRepr::Embed(r) => Ok(Self::Embed(r)),
}
}
}
impl CachedResponse {
pub fn into_llm_response(self) -> Result<LlmResponse> {
match self {
Self::Chat(r) => Ok(LlmResponse::Chat(r)),
Self::Embed(r) => Ok(LlmResponse::Embed(r)),
Self::Error { error, .. } => {
Err(
Arc::try_unwrap(error).unwrap_or_else(|arc| LiterLlmError::InternalError {
message: arc.to_string(),
}),
)
}
}
}
#[must_use]
pub fn is_expired_error(&self) -> bool {
matches!(self, Self::Error { expires_at, .. } if Instant::now() >= *expires_at)
}
}
#[derive(Debug, Clone)]
pub struct CacheMetadata {
pub inserted_at: Instant,
pub ttl: Duration,
pub size_bytes: usize,
pub hit_count: u64,
}
#[cfg_attr(alef, alef(skip))]
pub trait CacheStore: Send + Sync + 'static {
fn get(&self, key: u64, request_body: &str) -> Pin<Box<dyn Future<Output = Option<CachedResponse>> + Send + '_>>;
fn put(
&self,
key: u64,
request_body: String,
response: CachedResponse,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
fn remove(&self, key: u64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
fn set_ttl(&self, _key: u64, _ttl: Duration) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(std::future::ready(()))
}
fn iter_keys(&self) -> Pin<Box<dyn Future<Output = Vec<u64>> + Send + '_>> {
Box::pin(std::future::ready(Vec::new()))
}
fn metadata(&self, _key: u64) -> Pin<Box<dyn Future<Output = Option<CacheMetadata>> + Send + '_>> {
Box::pin(std::future::ready(None))
}
}
#[derive(Clone)]
struct CacheEntry {
request_body: String,
response: CachedResponse,
inserted_at: Instant,
ttl_override: Option<Duration>,
hit_count: u64,
size_bytes: usize,
}
struct InnerCache {
map: HashMap<u64, CacheEntry>,
order: VecDeque<u64>,
max_entries: usize,
ttl: Duration,
}
impl InnerCache {
fn new(config: &CacheConfig) -> Self {
Self {
map: HashMap::new(),
order: VecDeque::new(),
max_entries: config.max_entries,
ttl: config.ttl,
}
}
fn effective_ttl(&self, entry: &CacheEntry) -> Duration {
entry.ttl_override.unwrap_or(self.ttl)
}
fn get_if_valid(&self, key: u64, request_body: &str) -> Option<CachedResponse> {
let entry = self.map.get(&key)?;
if entry.request_body != request_body {
return None;
}
let is_expired = match &entry.response {
CachedResponse::Error { expires_at, .. } => Instant::now() >= *expires_at,
_ => entry.inserted_at.elapsed() > self.effective_ttl(entry),
};
if is_expired {
return None;
}
Some(entry.response.clone())
}
fn remove_expired(&mut self, key: u64) {
let ttl = self.ttl;
let expired = self.map.get(&key).is_some_and(|e| {
let eff = e.ttl_override.unwrap_or(ttl);
match &e.response {
CachedResponse::Error { expires_at, .. } => Instant::now() >= *expires_at,
_ => e.inserted_at.elapsed() > eff,
}
});
if expired {
self.map.remove(&key);
}
}
fn insert(&mut self, key: u64, request_body: String, response: CachedResponse) {
if self.map.contains_key(&key) {
self.order.retain(|k| *k != key);
}
while self.map.len() >= self.max_entries {
if let Some(oldest_key) = self.order.pop_front() {
self.map.remove(&oldest_key);
} else {
break;
}
}
let size_bytes = serde_json::to_string(&response).map(|s| s.len()).unwrap_or(0);
self.map.insert(
key,
CacheEntry {
request_body,
response,
inserted_at: Instant::now(),
ttl_override: None,
hit_count: 0,
size_bytes,
},
);
self.order.push_back(key);
}
fn record_hit(&mut self, key: u64) {
if let Some(entry) = self.map.get_mut(&key) {
entry.hit_count = entry.hit_count.saturating_add(1);
}
}
}
#[cfg_attr(alef, alef(skip))]
pub struct InMemoryStore {
inner: RwLock<InnerCache>,
}
impl InMemoryStore {
#[must_use]
pub fn new(config: &CacheConfig) -> Self {
Self {
inner: RwLock::new(InnerCache::new(config)),
}
}
}
impl CacheStore for InMemoryStore {
fn get(&self, key: u64, request_body: &str) -> Pin<Box<dyn Future<Output = Option<CachedResponse>> + Send + '_>> {
let hit = self.inner.write().ok().and_then(|mut cache| {
let hit = cache.get_if_valid(key, request_body);
if hit.is_none() {
cache.remove_expired(key);
} else {
cache.record_hit(key);
}
hit
});
Box::pin(std::future::ready(hit))
}
fn put(
&self,
key: u64,
request_body: String,
response: CachedResponse,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
if let Ok(mut cache) = self.inner.write() {
cache.insert(key, request_body, response);
}
Box::pin(std::future::ready(()))
}
fn remove(&self, key: u64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
if let Ok(mut cache) = self.inner.write() {
cache.map.remove(&key);
}
Box::pin(std::future::ready(()))
}
fn set_ttl(&self, key: u64, ttl: Duration) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
if let Ok(mut cache) = self.inner.write()
&& let Some(entry) = cache.map.get_mut(&key)
{
entry.ttl_override = Some(ttl);
}
Box::pin(std::future::ready(()))
}
fn iter_keys(&self) -> Pin<Box<dyn Future<Output = Vec<u64>> + Send + '_>> {
let keys = self
.inner
.read()
.map(|cache| cache.map.keys().copied().collect())
.unwrap_or_default();
Box::pin(std::future::ready(keys))
}
fn metadata(&self, key: u64) -> Pin<Box<dyn Future<Output = Option<CacheMetadata>> + Send + '_>> {
let result = self.inner.read().ok().and_then(|cache| {
let entry = cache.map.get(&key)?;
Some(CacheMetadata {
inserted_at: entry.inserted_at,
ttl: cache.effective_ttl(entry),
size_bytes: entry.size_bytes,
hit_count: entry.hit_count,
})
});
Box::pin(std::future::ready(result))
}
}
#[cfg_attr(alef, alef(skip))]
pub struct CacheLayer {
store: Arc<dyn CacheStore>,
key_strategy: Arc<dyn CacheKeyStrategy>,
cache_policy: Arc<dyn CachePolicy>,
embedding_provider: Option<Arc<dyn EmbeddingProvider>>,
vector_store: Option<Arc<dyn VectorStore>>,
}
impl CacheLayer {
#[must_use]
pub fn new(config: CacheConfig) -> Self {
Self {
store: Arc::new(InMemoryStore::new(&config)),
key_strategy: Arc::new(ExactHashStrategy),
cache_policy: Arc::new(StandardCachePolicy::default()),
embedding_provider: None,
vector_store: None,
}
}
#[must_use]
pub fn with_store(store: Arc<dyn CacheStore>) -> Self {
Self {
store,
key_strategy: Arc::new(ExactHashStrategy),
cache_policy: Arc::new(StandardCachePolicy::default()),
embedding_provider: None,
vector_store: None,
}
}
#[must_use]
pub fn with_key_strategy(mut self, strategy: Arc<dyn CacheKeyStrategy>) -> Self {
self.key_strategy = strategy;
self
}
#[must_use]
pub fn with_policy(mut self, policy: Arc<dyn CachePolicy>) -> Self {
self.cache_policy = policy;
self
}
#[must_use]
pub fn with_semantic_cache(
mut self,
embedding_provider: Arc<dyn EmbeddingProvider>,
vector_store: Arc<dyn VectorStore>,
) -> Self {
self.embedding_provider = Some(embedding_provider);
self.vector_store = Some(vector_store);
self
}
}
impl<S> Layer<S> for CacheLayer {
type Service = CacheService<S>;
fn layer(&self, inner: S) -> Self::Service {
CacheService {
inner,
store: Arc::clone(&self.store),
key_strategy: Arc::clone(&self.key_strategy),
cache_policy: Arc::clone(&self.cache_policy),
embedding_provider: self.embedding_provider.clone(),
vector_store: self.vector_store.clone(),
}
}
}
#[cfg_attr(alef, alef(skip))]
pub struct CacheService<S> {
inner: S,
store: Arc<dyn CacheStore>,
key_strategy: Arc<dyn CacheKeyStrategy>,
cache_policy: Arc<dyn CachePolicy>,
embedding_provider: Option<Arc<dyn EmbeddingProvider>>,
vector_store: Option<Arc<dyn VectorStore>>,
}
impl<S: Clone> Clone for CacheService<S> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
store: Arc::clone(&self.store),
key_strategy: Arc::clone(&self.key_strategy),
cache_policy: Arc::clone(&self.cache_policy),
embedding_provider: self.embedding_provider.clone(),
vector_store: self.vector_store.clone(),
}
}
}
impl<S> CacheService<S> {
pub async fn warm<'a>(&self, requests: impl Iterator<Item = CacheKeyInput<'a>>) {
for input in requests {
let (key, body) = self.key_strategy.key_for(&input);
if self.store.get(key, &body).await.is_none() {
let _ = (key, body);
}
}
}
}
pub(crate) fn cache_key(req: &LlmRequest) -> Option<(u64, String)> {
let json = match &req.kind {
LlmRequestKind::Chat(r) => serde_json::to_string(r).ok()?,
LlmRequestKind::Embed(r) => serde_json::to_string(r).ok()?,
_ => return None,
};
let mut hasher = DefaultHasher::new();
json.hash(&mut hasher);
Some((hasher.finish(), json))
}
fn strategy_key(strategy: &dyn CacheKeyStrategy, req: &LlmRequest) -> Option<(u64, String)> {
let req_tenant = req.tenant_id().map(|t| t.as_ref().to_owned());
let (model, messages_json, params_json, tenant_id, system_prompt) = match &req.kind {
LlmRequestKind::Chat(r) => {
let msgs = serde_json::to_string(&r.messages).ok()?;
let params = serde_json::json!({
"temperature": r.temperature,
"top_p": r.top_p,
"max_tokens": r.max_tokens,
"n": r.n,
"stop": r.stop,
});
let tenant_id: Option<String> = req_tenant.or_else(|| {
r.user
.as_deref()
.and_then(|u| u.strip_prefix("tenant:"))
.map(str::to_owned)
});
let system_prompt: Option<String> = r.messages.iter().find_map(|m| {
if let crate::types::Message::System(s) = m {
s.content.as_text()
} else {
None
}
});
(
r.model.as_str().to_owned(),
msgs,
params.to_string(),
tenant_id,
system_prompt,
)
}
LlmRequestKind::Embed(r) => {
let input = serde_json::to_string(&r.input).ok()?;
let params = serde_json::json!({
"dimensions": r.dimensions,
"encoding_format": r.encoding_format,
});
(r.model.as_str().to_owned(), input, params.to_string(), req_tenant, None)
}
_ => return None,
};
let input = CacheKeyInput {
model: &model,
messages_json: &messages_json,
params_json: ¶ms_json,
tenant_id: tenant_id.as_deref(),
system_prompt: system_prompt.as_deref(),
};
Some(strategy.key_for(&input))
}
impl<S> Service<LlmRequest> for CacheService<S>
where
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Clone + Send + 'static,
S::Future: Send + 'static,
{
type Response = LlmResponse;
type Error = LiterLlmError;
type Future = BoxFuture<'static, Result<LlmResponse>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: LlmRequest) -> Self::Future {
static EMPTY_METADATA: OnceLock<HashMap<String, String>> = OnceLock::new();
let empty_meta = EMPTY_METADATA.get_or_init(HashMap::new);
let stream = matches!(req.kind, LlmRequestKind::ChatStream(_));
let model = req.model().unwrap_or("").to_owned();
let tenant_id_str: Option<String> = req.tenant_id().map(|t| t.as_ref().to_owned());
let ctx = CachePolicyContext {
model: &model,
tenant_id: tenant_id_str.as_deref(),
stream,
metadata: empty_meta,
};
let decision: CacheDecision = self.cache_policy.decide(&ctx);
let key_and_body = if decision.bypass {
None
} else {
strategy_key(self.key_strategy.as_ref(), &req)
};
let store = Arc::clone(&self.store);
let embedding_provider = self.embedding_provider.clone();
let vector_store = self.vector_store.clone();
let standby = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, standby);
let fut = inner.call(req);
Box::pin(async move {
if decision.use_exact
&& let Some((k, ref body)) = key_and_body
&& let Some(cached) = store.get(k, body).await
{
#[cfg(feature = "otel")]
crate::tower::metrics::record_cache_tier_hit("", &model, "exact");
record_cache_state(CacheState::ExactHit);
return cached.into_llm_response();
}
#[cfg(feature = "otel")]
if decision.use_exact && key_and_body.is_some() {
crate::tower::metrics::record_cache_tier_miss("", &model, "exact");
}
if decision.use_semantic
&& let (Some(ep), Some(vs)) = (&embedding_provider, &vector_store)
&& let Some((_, ref body)) = key_and_body
{
let maybe_cached = async {
let query_vec = ep.embed(body).await.ok()?;
let best = vs
.search(&query_vec, 1, decision.similarity_threshold)
.await
.into_iter()
.next()?;
store
.get(best.metadata.cache_key, &best.metadata.original_request_body)
.await
}
.await;
if let Some(cached) = maybe_cached {
#[cfg(feature = "otel")]
crate::tower::metrics::record_cache_tier_hit("", &model, "semantic");
record_cache_state(CacheState::SemanticHit);
return cached.into_llm_response();
}
#[cfg(feature = "otel")]
crate::tower::metrics::record_cache_tier_miss("", &model, "semantic");
}
record_cache_state(CacheState::Miss);
let resp = fut.await?;
if let Some((k, body)) = key_and_body {
let cached = match &resp {
LlmResponse::Chat(r) => Some(CachedResponse::Chat(r.clone())),
LlmResponse::Embed(r) => Some(CachedResponse::Embed(r.clone())),
_ => None,
};
if let Some(cached_resp) = cached {
store.put(k, body.clone(), cached_resp).await;
if let Some(ttl) = decision.ttl_override {
store.set_ttl(k, ttl).await;
}
if decision.use_semantic
&& let (Some(ep), Some(vs)) = (&embedding_provider, &vector_store)
&& let Ok(vec) = ep.embed(&body).await
{
let metadata = crate::vectorstore::VectorMetadata {
cache_key: k,
original_request_body: body.clone(),
tenant_id: None,
inserted_at: std::time::SystemTime::now(),
extra: HashMap::new(),
};
let _ = vs.upsert(format!("{k}"), vec, metadata).await;
}
}
}
Ok(resp)
})
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use tower::{Layer as _, Service as _};
use super::*;
use crate::tower::service::LlmService;
use crate::tower::tests_common::{MockClient, chat_req};
use crate::tower::types::LlmRequest;
#[tokio::test]
async fn cache_returns_cached_response_on_second_call() {
let config = CacheConfig {
backend: CacheBackend::default(),
max_entries: 10,
ttl: Duration::from_secs(60),
};
let layer = CacheLayer::new(config);
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
svc.call(LlmRequest::Chat(chat_req("gpt-4")))
.await
.expect("service call should not fail");
assert_eq!(call_count.load(Ordering::SeqCst), 1);
svc.call(LlmRequest::Chat(chat_req("gpt-4")))
.await
.expect("service call should not fail");
assert_eq!(call_count.load(Ordering::SeqCst), 1, "second call should hit cache");
}
#[tokio::test]
async fn cache_does_not_cache_streaming_requests() {
let config = CacheConfig {
backend: CacheBackend::default(),
max_entries: 10,
ttl: Duration::from_secs(60),
};
let layer = CacheLayer::new(config);
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
svc.call(LlmRequest::ChatStream(chat_req("gpt-4")))
.await
.expect("service call should not fail");
svc.call(LlmRequest::ChatStream(chat_req("gpt-4")))
.await
.expect("service call should not fail");
assert_eq!(call_count.load(Ordering::SeqCst), 2, "streaming should not be cached");
}
#[tokio::test]
async fn cache_evicts_oldest_when_full() {
let config = CacheConfig {
backend: CacheBackend::default(),
max_entries: 1,
ttl: Duration::from_secs(60),
};
let layer = CacheLayer::new(config);
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
svc.call(LlmRequest::Chat(chat_req("model-a")))
.await
.expect("service call should not fail");
assert_eq!(call_count.load(Ordering::SeqCst), 1);
svc.call(LlmRequest::Chat(chat_req("model-b")))
.await
.expect("service call should not fail");
assert_eq!(call_count.load(Ordering::SeqCst), 2);
svc.call(LlmRequest::Chat(chat_req("model-a")))
.await
.expect("service call should not fail");
assert_eq!(
call_count.load(Ordering::SeqCst),
3,
"evicted entry should be a cache miss"
);
}
#[tokio::test]
async fn cache_different_requests_have_different_keys() {
let config = CacheConfig {
backend: CacheBackend::default(),
max_entries: 10,
ttl: Duration::from_secs(60),
};
let layer = CacheLayer::new(config);
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
svc.call(LlmRequest::Chat(chat_req("gpt-4")))
.await
.expect("service call should not fail");
svc.call(LlmRequest::Chat(chat_req("gpt-3.5-turbo")))
.await
.expect("service call should not fail");
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"different models should be cache misses"
);
}
#[tokio::test]
async fn in_memory_store_set_ttl_overrides_default_ttl() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(3600),
backend: CacheBackend::default(),
};
let store = InMemoryStore::new(&config);
store
.put(
1,
"body".into(),
CachedResponse::Chat(crate::tower::tests_common::make_chat_response("gpt-4")),
)
.await;
store.set_ttl(1, Duration::from_nanos(1)).await;
tokio::time::sleep(Duration::from_millis(2)).await;
let result = store.get(1, "body").await;
assert!(result.is_none(), "entry with overridden near-zero TTL must be expired");
}
#[tokio::test]
async fn in_memory_store_iter_keys_lists_all_keys() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(3600),
backend: CacheBackend::default(),
};
let store = InMemoryStore::new(&config);
store
.put(
10,
"b1".into(),
CachedResponse::Chat(crate::tower::tests_common::make_chat_response("m")),
)
.await;
store
.put(
20,
"b2".into(),
CachedResponse::Chat(crate::tower::tests_common::make_chat_response("m")),
)
.await;
let mut keys = store.iter_keys().await;
keys.sort_unstable();
assert_eq!(keys, vec![10, 20]);
}
#[tokio::test]
async fn in_memory_store_metadata_tracks_hit_count() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(3600),
backend: CacheBackend::default(),
};
let store = InMemoryStore::new(&config);
store
.put(
42,
"req".into(),
CachedResponse::Chat(crate::tower::tests_common::make_chat_response("gpt-4")),
)
.await;
let _ = store.get(42, "req").await;
let _ = store.get(42, "req").await;
let meta = store.metadata(42).await.expect("metadata must be present");
assert_eq!(meta.hit_count, 2, "hit_count must reflect both cache hits");
assert!(meta.size_bytes > 0, "size_bytes must be non-zero");
}
#[tokio::test]
async fn three_tier_exact_hit_short_circuits_upstream() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let layer = CacheLayer::new(config);
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.unwrap();
assert_eq!(call_count.load(Ordering::SeqCst), 1);
svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"exact hit must short-circuit upstream"
);
}
#[tokio::test]
async fn semantic_cache_tier_returns_hit_when_vector_match_above_threshold() {
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
use crate::cache_key::ExactHashStrategy;
use crate::embedding::NoOpEmbeddingProvider;
use crate::tower::cache_policy::StandardCachePolicy;
use crate::vectorstore::{InMemoryVectorStore, VectorMetadata, VectorStore};
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let store: Arc<dyn CacheStore> = Arc::new(InMemoryStore::new(&config));
let cached = CachedResponse::Chat(crate::tower::tests_common::make_chat_response("gpt-4"));
let exact_key: u64 = 9999;
let sentinel_body = "sentinel-body";
store.put(exact_key, sentinel_body.into(), cached).await;
let vs: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new(1));
vs.upsert(
"sentinel".into(),
vec![0.0],
VectorMetadata {
cache_key: exact_key,
original_request_body: sentinel_body.into(),
tenant_id: None,
inserted_at: SystemTime::now(),
extra: HashMap::new(),
},
)
.await
.unwrap();
let ep: Arc<dyn crate::embedding::EmbeddingProvider> = Arc::new(NoOpEmbeddingProvider { dim: 1 });
let policy = Arc::new(StandardCachePolicy {
semantic_ttl: Some(Duration::from_secs(60)),
similarity_threshold: 0.0,
..Default::default()
});
let layer = CacheLayer::with_store(Arc::clone(&store))
.with_key_strategy(Arc::new(ExactHashStrategy))
.with_policy(policy)
.with_semantic_cache(ep, vs);
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
0,
"semantic hit must short-circuit upstream without calling it"
);
}
#[tokio::test]
async fn tenant_scoped_strategy_isolates_tenants_via_cache_service() {
use crate::cache_key::TenantScopedStrategy;
let config = CacheConfig {
max_entries: 20,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let layer = CacheLayer::new(config).with_key_strategy(Arc::new(TenantScopedStrategy));
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
let mut req_a = chat_req("gpt-4");
req_a.user = Some("tenant:acme".into());
let mut req_b = chat_req("gpt-4");
req_b.user = Some("tenant:globex".into());
svc.call(LlmRequest::Chat(req_a.clone())).await.unwrap();
assert_eq!(call_count.load(Ordering::SeqCst), 1, "first call must miss");
svc.call(LlmRequest::Chat(req_b)).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"tenant-b must not receive tenant-a cached response"
);
svc.call(LlmRequest::Chat(req_a)).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"tenant-a second call must hit cache"
);
}
#[tokio::test]
async fn system_prompt_aware_strategy_isolates_via_cache_service() {
use crate::cache_key::SystemPromptAwareStrategy;
use crate::types::{Message, SystemMessage, UserContent, UserMessage};
let config = CacheConfig {
max_entries: 20,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let layer = CacheLayer::new(config).with_key_strategy(Arc::new(SystemPromptAwareStrategy));
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
let mut req_a = chat_req("gpt-4");
req_a.messages = vec![
Message::System(SystemMessage {
content: "You are a helpful assistant.".into(),
name: None,
}),
Message::User(UserMessage {
content: UserContent::Text("Hello".into()),
name: None,
}),
];
let mut req_b = chat_req("gpt-4");
req_b.messages = vec![
Message::System(SystemMessage {
content: "You are a pirate.".into(),
name: None,
}),
Message::User(UserMessage {
content: UserContent::Text("Hello".into()),
name: None,
}),
];
svc.call(LlmRequest::Chat(req_a.clone())).await.unwrap();
assert_eq!(call_count.load(Ordering::SeqCst), 1, "first call must miss");
svc.call(LlmRequest::Chat(req_b)).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"different system prompt must produce a cache miss"
);
svc.call(LlmRequest::Chat(req_a)).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"same system prompt must hit cache"
);
}
#[tokio::test]
async fn in_memory_store_get_single_lock_acquisition() {
let config = CacheConfig {
max_entries: 1000,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let store = Arc::new(InMemoryStore::new(&config));
const TASKS: u64 = 100;
let handles: Vec<_> = (0..TASKS)
.map(|i| {
let store = Arc::clone(&store);
tokio::spawn(async move {
let key = i;
let body = format!("body-{i}");
let response = CachedResponse::Chat(crate::tower::tests_common::make_chat_response("m"));
store.put(key, body.clone(), response).await;
let result = store.get(key, &body).await;
assert!(
result.is_some(),
"key {key} written by task {i} must be immediately readable"
);
})
})
.collect();
for h in handles {
h.await.expect("task must not panic");
}
}
#[tokio::test]
async fn three_tier_full_miss_calls_upstream() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let layer = CacheLayer::new(config);
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
svc.call(LlmRequest::Chat(chat_req("new-model"))).await.unwrap();
assert_eq!(call_count.load(Ordering::SeqCst), 1, "full miss must call upstream");
}
#[tokio::test]
async fn warm_does_not_call_inner_service() {
use crate::cache_key::CacheKeyInput;
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let layer = CacheLayer::new(config);
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let svc = layer.layer(inner);
let inputs: Vec<CacheKeyInput<'_>> = vec![
CacheKeyInput {
model: "gpt-4",
messages_json: r#"[{"role":"user","content":"hi"}]"#,
params_json: "{}",
tenant_id: None,
system_prompt: None,
},
CacheKeyInput {
model: "gpt-4o",
messages_json: r#"[{"role":"user","content":"hi"}]"#,
params_json: "{}",
tenant_id: None,
system_prompt: None,
},
];
svc.warm(inputs.into_iter()).await;
assert_eq!(call_count.load(Ordering::SeqCst), 0, "warm must not call inner service");
}
#[tokio::test]
async fn cache_bypassed_when_policy_returns_bypass() {
use crate::tower::cache_policy::{CacheDecision, CachePolicy, CachePolicyContext};
struct AlwaysBypassPolicy;
impl CachePolicy for AlwaysBypassPolicy {
fn decide(&self, _ctx: &CachePolicyContext<'_>) -> CacheDecision {
CacheDecision {
bypass: true,
..Default::default()
}
}
}
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let layer = CacheLayer::new(config).with_policy(Arc::new(AlwaysBypassPolicy));
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.unwrap();
svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"bypassed calls must all hit upstream"
);
}
#[tokio::test]
async fn cache_call_propagates_tower_ready() {
use std::sync::atomic::AtomicUsize;
use std::task::Poll;
#[derive(Clone)]
struct CountingService {
poll_ready_count: Arc<AtomicUsize>,
call_count: Arc<AtomicUsize>,
}
impl Service<LlmRequest> for CountingService {
type Response = LlmResponse;
type Error = LiterLlmError;
type Future = crate::client::BoxFuture<'static, crate::error::Result<LlmResponse>>;
fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll<crate::error::Result<()>> {
self.poll_ready_count.fetch_add(1, Ordering::SeqCst);
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: LlmRequest) -> Self::Future {
self.call_count.fetch_add(1, Ordering::SeqCst);
Box::pin(async {
Ok(LlmResponse::Chat(crate::tower::tests_common::make_chat_response(
"gpt-4",
)))
})
}
}
let poll_ready_count = Arc::new(AtomicUsize::new(0));
let call_count = Arc::new(AtomicUsize::new(0));
let inner = CountingService {
poll_ready_count: Arc::clone(&poll_ready_count),
call_count: Arc::clone(&call_count),
};
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_nanos(1),
backend: CacheBackend::default(),
};
let mut svc = CacheLayer::new(config).layer(inner);
use tower::ServiceExt as _;
svc.ready()
.await
.expect("ready")
.call(LlmRequest::Chat(chat_req("gpt-4-v1")))
.await
.unwrap();
assert_eq!(call_count.load(Ordering::SeqCst), 1, "inner called once");
tokio::time::sleep(Duration::from_millis(1)).await;
svc.ready()
.await
.expect("ready second time")
.call(LlmRequest::Chat(chat_req("gpt-4-v2")))
.await
.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"inner called twice across two call cycles"
);
}
#[tokio::test]
async fn cache_policy_meta_no_unnecessary_allocation() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let layer = CacheLayer::new(config);
let client = MockClient::ok();
let inner = LlmService::new(client);
let mut svc = layer.layer(inner);
for _ in 0..1000 {
let _ = svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await;
}
}
}