use std::cell::Cell;
use std::collections::{HashMap, VecDeque};
use std::future::Future;
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(error.to_singleflight_error()),
}
}
#[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);
self.order.retain(|k| *k != key);
}
}
fn insert(&mut self, key: u64, request_body: String, response: CachedResponse) {
let is_new = !self.map.contains_key(&key);
if !is_new {
self.order.retain(|k| *k != key);
}
if is_new {
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 = match self.inner.write() {
Ok(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
}
Err(_) => {
warn_lock_poisoned("get");
None
}
};
Box::pin(std::future::ready(hit))
}
fn put(
&self,
key: u64,
request_body: String,
response: CachedResponse,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
match self.inner.write() {
Ok(mut cache) => cache.insert(key, request_body, response),
Err(_) => warn_lock_poisoned("put"),
}
Box::pin(std::future::ready(()))
}
fn remove(&self, key: u64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
match self.inner.write() {
Ok(mut cache) => {
cache.map.remove(&key);
cache.order.retain(|k| *k != key);
}
Err(_) => warn_lock_poisoned("remove"),
}
Box::pin(std::future::ready(()))
}
fn set_ttl(&self, key: u64, ttl: Duration) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
match self.inner.write() {
Ok(mut cache) => {
if let Some(entry) = cache.map.get_mut(&key) {
entry.ttl_override = Some(ttl);
}
}
Err(_) => warn_lock_poisoned("set_ttl"),
}
Box::pin(std::future::ready(()))
}
fn iter_keys(&self) -> Pin<Box<dyn Future<Output = Vec<u64>> + Send + '_>> {
let keys = match self.inner.read() {
Ok(cache) => cache.map.keys().copied().collect(),
Err(_) => {
warn_lock_poisoned("iter_keys");
Vec::new()
}
};
Box::pin(std::future::ready(keys))
}
fn metadata(&self, key: u64) -> Pin<Box<dyn Future<Output = Option<CacheMetadata>> + Send + '_>> {
let result = match self.inner.read() {
Ok(cache) => cache.map.get(&key).map(|entry| CacheMetadata {
inserted_at: entry.inserted_at,
ttl: cache.effective_ttl(entry),
size_bytes: entry.size_bytes,
hit_count: entry.hit_count,
}),
Err(_) => {
warn_lock_poisoned("metadata");
None
}
};
Box::pin(std::future::ready(result))
}
}
fn warn_lock_poisoned(op: &'static str) {
tracing::warn!(operation = op, "in-memory cache lock poisoned; treating as no-op/miss");
}
#[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 {
let cache_policy = StandardCachePolicy {
exact_ttl: config.ttl,
..StandardCachePolicy::default()
};
Self {
store: Arc::new(InMemoryStore::new(&config)),
key_strategy: Arc::new(ExactHashStrategy),
cache_policy: Arc::new(cache_policy),
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_store_and_config(store: Arc<dyn CacheStore>, config: &CacheConfig) -> Self {
Self {
store,
key_strategy: Arc::new(ExactHashStrategy),
cache_policy: Arc::new(StandardCachePolicy {
exact_ttl: config.ttl,
..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 key_strategy(&self) -> Arc<dyn CacheKeyStrategy> {
Arc::clone(&self.key_strategy)
}
#[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 placeholder = CachedResponse::Error {
error: Arc::new(LiterLlmError::InternalError {
message: "cache slot pre-warmed by CacheService::warm; not yet populated".into(),
}),
expires_at: Instant::now(),
};
self.store.put(key, body, placeholder).await;
}
}
}
}
pub(crate) fn strategy_key(strategy: &dyn CacheKeyStrategy, req: &LlmRequest) -> Option<(u64, String, Option<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,
"presence_penalty": r.presence_penalty,
"frequency_penalty": r.frequency_penalty,
"logit_bias": r.logit_bias,
"tools": r.tools,
"tool_choice": r.tool_choice,
"parallel_tool_calls": r.parallel_tool_calls,
"response_format": r.response_format,
"seed": r.seed,
"reasoning_effort": r.reasoning_effort,
"modalities": r.modalities,
"extra_body": r.extra_body,
});
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(),
};
let (key, body) = strategy.key_for(&input);
Some((key, body, tenant_id))
}
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, ref tenant_id)) = key_and_body
{
let maybe_cached = async {
let input = crate::types::EmbeddingInput::Single(body.clone());
let query_vec = ep.embed(&input).await.ok()?;
let best = vs
.search(&query_vec, 1, decision.similarity_threshold, tenant_id.as_deref())
.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(if decision.bypass {
CacheState::Bypass
} else {
CacheState::Miss
});
let resp = fut.await?;
if let Some((k, body, tenant_id)) = 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(&crate::types::EmbeddingInput::Single(body.clone())).await
{
let metadata = crate::vectorstore::VectorMetadata {
cache_key: k,
original_request_body: body.clone(),
image_url: None,
tenant_id,
inserted_at: std::time::SystemTime::now(),
extra: HashMap::new(),
};
if let Err(error) = vs.upsert(format!("{k}"), vec, metadata).await {
tracing::warn!(
cache_key = k,
%error,
"semantic cache: vector store upsert failed; entry will not be searchable"
);
}
}
}
}
Ok(resp)
})
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
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 remove_expired_also_removes_from_order_index() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_millis(20),
backend: CacheBackend::default(),
};
let store = InMemoryStore::new(&config);
for _ in 0..5 {
store
.put(
1,
"body".into(),
CachedResponse::Chat(crate::tower::tests_common::make_chat_response("m")),
)
.await;
tokio::time::sleep(Duration::from_millis(30)).await;
assert!(store.get(1, "body").await.is_none(), "entry must have expired by now");
}
let order_len = store.inner.read().unwrap().order.len();
assert_eq!(
order_len, 0,
"order index must not accumulate a stale duplicate per expiry cycle; got {order_len} stale entries"
);
}
#[tokio::test]
async fn reinsert_existing_key_does_not_evict_unrelated_entry_at_capacity() {
let config = CacheConfig {
max_entries: 2,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let store = InMemoryStore::new(&config);
store
.put(
1,
"a".into(),
CachedResponse::Chat(crate::tower::tests_common::make_chat_response("m")),
)
.await;
store
.put(
2,
"b".into(),
CachedResponse::Chat(crate::tower::tests_common::make_chat_response("m")),
)
.await;
store
.put(
1,
"a".into(),
CachedResponse::Chat(crate::tower::tests_common::make_chat_response("m2")),
)
.await;
assert!(
store.get(2, "b").await.is_some(),
"re-inserting an existing key must not evict an unrelated live entry when at capacity"
);
}
#[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(),
image_url: None,
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 semantic_cache_tier_does_not_leak_across_tenants() {
use crate::cache_key::TenantScopedStrategy;
use crate::embedding::NoOpEmbeddingProvider;
use crate::tower::cache_policy::StandardCachePolicy;
use crate::vectorstore::InMemoryVectorStore;
let config = CacheConfig {
max_entries: 20,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let vs: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new(1));
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::new(config)
.with_key_strategy(Arc::new(TenantScopedStrategy))
.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);
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)).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"tenant A's first call must miss and populate both cache tiers"
);
svc.call(LlmRequest::Chat(req_b)).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"tenant B must not receive tenant A's semantically-matched cached response"
);
}
#[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 configured_ttl_expires_the_entry() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_millis(60),
backend: CacheBackend::default(),
};
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let mut svc = CacheLayer::new(config).layer(LlmService::new(client));
svc.call(LlmRequest::Chat(chat_req("ttl-model"))).await.unwrap();
svc.call(LlmRequest::Chat(chat_req("ttl-model"))).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"second call within the TTL must be served from cache"
);
tokio::time::sleep(Duration::from_millis(150)).await;
svc.call(LlmRequest::Chat(chat_req("ttl-model"))).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"entry must expire after the CONFIGURED 60ms, not the 300s policy default"
);
}
#[tokio::test]
async fn configured_ttl_expires_the_entry_on_the_custom_store_path() {
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_millis(60),
backend: CacheBackend::default(),
};
let store: Arc<dyn CacheStore> = Arc::new(InMemoryStore::new(&config));
let client = MockClient::ok();
let call_count = Arc::clone(&client.call_count);
let mut svc = CacheLayer::with_store_and_config(store, &config).layer(LlmService::new(client));
svc.call(LlmRequest::Chat(chat_req("ttl-store-model"))).await.unwrap();
svc.call(LlmRequest::Chat(chat_req("ttl-store-model"))).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"second call within the TTL must be served from cache"
);
tokio::time::sleep(Duration::from_millis(150)).await;
svc.call(LlmRequest::Chat(chat_req("ttl-store-model"))).await.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"entry must expire after the CONFIGURED 60ms, not the 300s policy default"
);
}
#[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 warm_allocates_a_cache_slot_for_each_key() {
use crate::cache_key::CacheKeyInput;
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let store = Arc::new(InMemoryStore::new(&config));
let layer = CacheLayer::with_store(Arc::clone(&store) as Arc<dyn CacheStore>);
let client = MockClient::ok();
let inner = LlmService::new(client);
let svc = layer.layer(inner);
let input = CacheKeyInput {
model: "gpt-4",
messages_json: r#"[{"role":"user","content":"hi"}]"#,
params_json: "{}",
tenant_id: None,
system_prompt: None,
};
let (key, _body) = ExactHashStrategy.key_for(&input);
svc.warm(std::iter::once(input)).await;
assert!(
store.metadata(key).await.is_some(),
"warm must allocate a cache slot for each probed key, per its own doc contract — \
this fails if warm() reverts to discarding (key, body) instead of writing a placeholder"
);
}
#[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 bypassed_request_records_bypass_not_miss() {
use std::cell::Cell;
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 inner = LlmService::new(client);
let mut svc = layer.layer(inner);
let (result, state) = CACHE_STATE_CELL
.scope(Cell::new(CacheState::Miss), async {
let result = svc.call(LlmRequest::Chat(chat_req("gpt-4"))).await;
let state = CACHE_STATE_CELL.with(|c| c.get());
(result, state)
})
.await;
result.expect("bypassed call should still succeed");
assert_eq!(
state,
CacheState::Bypass,
"a policy-bypassed request must record CacheState::Bypass, not Miss"
);
}
#[tokio::test]
async fn cache_call_swaps_a_fresh_standby_into_inner() {
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
use std::task::Poll;
struct IdentityService {
id: usize,
next_id: Arc<AtomicUsize>,
call_ids: Arc<Mutex<Vec<usize>>>,
}
impl Clone for IdentityService {
fn clone(&self) -> Self {
Self {
id: self.next_id.fetch_add(1, Ordering::SeqCst),
next_id: Arc::clone(&self.next_id),
call_ids: Arc::clone(&self.call_ids),
}
}
}
impl Service<LlmRequest> for IdentityService {
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<()>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: LlmRequest) -> Self::Future {
self.call_ids.lock().unwrap().push(self.id);
Box::pin(async {
Ok(LlmResponse::Chat(crate::tower::tests_common::make_chat_response(
"gpt-4",
)))
})
}
}
let next_id = Arc::new(AtomicUsize::new(1));
let call_ids = Arc::new(Mutex::new(Vec::new()));
let inner = IdentityService {
id: 0,
next_id: Arc::clone(&next_id),
call_ids: Arc::clone(&call_ids),
};
let config = CacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
backend: CacheBackend::default(),
};
let mut svc = CacheLayer::new(config).layer(inner);
let id_before_call = svc.inner.id;
svc.call(LlmRequest::Chat(chat_req("gpt-4-v1"))).await.unwrap();
let id_after_call = svc.inner.id;
assert_eq!(
*call_ids.lock().unwrap(),
vec![id_before_call],
"call() must run on the instance whose readiness was current before the swap"
);
assert_ne!(
id_after_call, id_before_call,
"self.inner must be left holding a freshly cloned standby after call(), not the \
consumed instance — this fails if the mem::replace swap in CacheService::call is removed"
);
let id_before_second_call = svc.inner.id;
assert_eq!(
id_before_second_call, id_after_call,
"sanity check: no swap happens between calls, only during one"
);
svc.call(LlmRequest::Chat(chat_req("gpt-4-v2"))).await.unwrap();
assert_eq!(
*call_ids.lock().unwrap(),
vec![id_before_call, id_before_second_call],
"second call() must run on the standby swapped in after the first call, not a \
further, un-polled clone of it"
);
assert_ne!(
svc.inner.id, id_before_second_call,
"self.inner must again be left holding a fresh standby after the second call"
);
}
#[tokio::test]
async fn cache_policy_meta_stable_across_many_repeated_calls() {
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);
for _ in 0..1000 {
svc.call(LlmRequest::Chat(chat_req("gpt-4")))
.await
.expect("cached call should not fail");
}
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"1000 identical calls must all be served from the cache after the first upstream call"
);
}
#[tokio::test]
async fn cache_key_differs_when_tools_response_format_or_seed_differ() {
use crate::types::{ChatCompletionTool, FunctionDefinition, ResponseFormat, ToolType};
let strategy = ExactHashStrategy;
let base = chat_req("gpt-4");
let mut with_tools = base.clone();
with_tools.tools = Some(vec![ChatCompletionTool {
tool_type: ToolType::Function,
function: FunctionDefinition {
name: "get_weather".into(),
description: None,
parameters: None,
strict: None,
},
}]);
let mut with_response_format = base.clone();
with_response_format.response_format = Some(ResponseFormat::JsonObject);
let mut with_seed = base.clone();
with_seed.seed = Some(42);
let base_key = strategy_key(&strategy, &LlmRequest::Chat(base)).expect("base request is cacheable");
let tools_key = strategy_key(&strategy, &LlmRequest::Chat(with_tools)).expect("tools request is cacheable");
let format_key = strategy_key(&strategy, &LlmRequest::Chat(with_response_format))
.expect("response_format request is cacheable");
let seed_key = strategy_key(&strategy, &LlmRequest::Chat(with_seed)).expect("seed request is cacheable");
assert_ne!(base_key.0, tools_key.0, "adding `tools` must change the cache key");
assert_ne!(
base_key.0, format_key.0,
"changing `response_format` must change the cache key"
);
assert_ne!(base_key.0, seed_key.0, "changing `seed` must change the cache key");
}
}