use std::{
collections::{HashMap, hash_map::DefaultHasher},
fmt,
hash::{Hash, Hasher},
pin::Pin,
sync::{Arc, RwLock, Weak},
task::{Context, Poll},
time::{Duration, Instant},
};
use async_trait::async_trait;
use futures::Stream;
use serde::Serialize;
use crate::{
LLMProvider, ToolCall,
chat::{
ChatMessage, ChatProvider, ChatResponse, ChatRole, MessageType, StreamChunk,
StreamResponse, StructuredOutputFormat, Tool,
},
completion::{CompletionProvider, CompletionRequest, CompletionResponse},
embedding::EmbeddingProvider,
error::LLMError,
models::{ModelListRequest, ModelListResponse, ModelsProvider},
pipeline::LLMLayer,
};
type Cache<T> = Arc<RwLock<HashMap<u64, CacheEntry<T>>>>;
type BoxStream<T> = Pin<Box<dyn Stream<Item = Result<T, LLMError>> + Send>>;
type InFlight = Arc<tokio::sync::Mutex<HashMap<u64, Weak<tokio::sync::Mutex<()>>>>>;
fn new_cache<T>() -> Cache<T> {
Arc::new(RwLock::new(HashMap::new()))
}
fn new_inflight() -> InFlight {
Arc::new(tokio::sync::Mutex::new(HashMap::new()))
}
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub chat_key_mode: ChatCacheKeyMode,
pub ttl: Option<Duration>,
pub max_size: Option<usize>,
pub cache_completions: bool,
pub cache_embeddings: bool,
pub cache_streaming: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatCacheKeyMode {
UserPromptOnly,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
chat_key_mode: ChatCacheKeyMode::UserPromptOnly,
ttl: Some(Duration::from_secs(3600)),
max_size: Some(1000),
cache_completions: true,
cache_embeddings: true,
cache_streaming: true,
}
}
}
pub struct CacheLayer {
config: CacheConfig,
}
impl CacheLayer {
pub fn new(config: CacheConfig) -> Self {
Self { config }
}
pub fn with_defaults() -> Self {
Self {
config: CacheConfig::default(),
}
}
}
impl LLMLayer for CacheLayer {
fn build(self: Box<Self>, next: Arc<dyn LLMProvider>) -> Arc<dyn LLMProvider> {
Arc::new(InMemoryCacheLayer::new(next, self.config))
}
}
struct CacheEntry<T> {
value: T,
created_at: Instant,
}
#[derive(Debug, Clone)]
struct CachedChatResponse {
text: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
thinking: Option<String>,
usage: Option<crate::chat::Usage>,
}
impl ChatResponse for CachedChatResponse {
fn text(&self) -> Option<String> {
self.text.clone()
}
fn tool_calls(&self) -> Option<Vec<ToolCall>> {
self.tool_calls.clone()
}
fn thinking(&self) -> Option<String> {
self.thinking.clone()
}
fn usage(&self) -> Option<crate::chat::Usage> {
self.usage.clone()
}
}
impl fmt::Display for CachedChatResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.text.as_deref().unwrap_or(""))
}
}
struct TeeStream<T> {
inner: Pin<Box<dyn Stream<Item = Result<T, LLMError>> + Send>>,
buffer: Vec<T>,
cache: Cache<Vec<T>>,
key: u64,
ttl: Option<Duration>,
max_size: Option<usize>,
done: bool,
}
impl<T: Clone + Send + Unpin> Stream for TeeStream<T> {
type Item = Result<T, LLMError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut(); if this.done {
return Poll::Ready(None);
}
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(item))) => {
this.buffer.push(item.clone());
Poll::Ready(Some(Ok(item)))
}
Poll::Ready(Some(Err(e))) => {
this.done = true;
Poll::Ready(Some(Err(e)))
}
Poll::Ready(None) => {
this.done = true;
let chunks = std::mem::take(&mut this.buffer);
match this.cache.write() {
Ok(mut cache) => {
evict_expired(&mut cache, this.ttl);
if let Some(max) = this.max_size {
evict_oldest(&mut cache, max);
}
cache.insert(
this.key,
CacheEntry {
value: chunks,
created_at: Instant::now(),
},
);
}
Err(e) => {
log::warn!("stream cache write lock poisoned: {e}");
}
}
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
struct InMemoryCacheLayer {
inner: Arc<dyn LLMProvider>,
chat_cache: Cache<CachedChatResponse>,
completion_cache: Cache<String>,
embedding_cache: Cache<Vec<Vec<f32>>>,
stream_chat_cache: Cache<Vec<String>>,
stream_tools_cache: Cache<Vec<StreamChunk>>,
stream_struct_cache: Cache<Vec<StreamResponse>>,
chat_inflight: InFlight,
completion_inflight: InFlight,
embedding_inflight: InFlight,
config: CacheConfig,
}
impl InMemoryCacheLayer {
fn new(inner: Arc<dyn LLMProvider>, config: CacheConfig) -> Self {
Self {
inner,
chat_cache: new_cache(),
completion_cache: new_cache(),
embedding_cache: new_cache(),
stream_chat_cache: new_cache(),
stream_tools_cache: new_cache(),
stream_struct_cache: new_cache(),
chat_inflight: new_inflight(),
completion_inflight: new_inflight(),
embedding_inflight: new_inflight(),
config,
}
}
}
fn hash_val<T: Serialize>(v: &T) -> u64 {
let s = serde_json::to_string(v).unwrap_or_default();
let mut h = DefaultHasher::default();
s.hash(&mut h);
h.finish()
}
fn hash_chat_user_prompt(
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<&StructuredOutputFormat>,
) -> u64 {
let tool_result_state: Vec<ToolCall> = messages
.iter()
.filter_map(|message| match &message.message_type {
MessageType::ToolResult(calls) => Some(calls.as_slice()),
_ => None,
})
.flatten()
.cloned()
.collect();
let prompt = messages
.iter()
.rev()
.find(|message| message.role == ChatRole::User)
.map(|message| message.content.as_str())
.unwrap_or_default();
hash_val(&(prompt, tool_result_state, tools, json_schema))
}
fn hash_chat_with_mode(
mode: ChatCacheKeyMode,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<&StructuredOutputFormat>,
) -> u64 {
match mode {
ChatCacheKeyMode::UserPromptOnly => hash_chat_user_prompt(messages, tools, json_schema),
}
}
fn hash_completion(req: &CompletionRequest, json_schema: Option<&StructuredOutputFormat>) -> u64 {
let mut h = DefaultHasher::default();
req.prompt.hash(&mut h);
req.max_tokens.hash(&mut h);
req.temperature.map(f32::to_bits).hash(&mut h);
hash_val(&json_schema).hash(&mut h);
h.finish()
}
fn is_expired(config: &CacheConfig, created_at: Instant) -> bool {
config.ttl.is_some_and(|ttl| created_at.elapsed() > ttl)
}
fn evict_oldest<V>(cache: &mut HashMap<u64, CacheEntry<V>>, max_size: usize) {
if cache.len() >= max_size
&& let Some(oldest_key) = cache
.iter()
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| *k)
{
cache.remove(&oldest_key);
}
}
fn evict_expired<V>(cache: &mut HashMap<u64, CacheEntry<V>>, ttl: Option<Duration>) {
if let Some(ttl) = ttl {
cache.retain(|_, entry| entry.created_at.elapsed() <= ttl);
}
}
async fn acquire_inflight_lock(inflight: &InFlight, key: u64) -> Arc<tokio::sync::Mutex<()>> {
let mut map = inflight.lock().await;
if let Some(lock) = map.get(&key).and_then(Weak::upgrade) {
return lock;
}
let lock = Arc::new(tokio::sync::Mutex::new(()));
map.insert(key, Arc::downgrade(&lock));
lock
}
fn lookup_stream<T: Clone + Send + 'static>(
cache: &Cache<Vec<T>>,
key: u64,
config: &CacheConfig,
) -> Option<BoxStream<T>> {
let mut stale = false;
match cache.read() {
Ok(guard) => {
if let Some(entry) = guard.get(&key)
&& !is_expired(config, entry.created_at)
{
let chunks = entry.value.clone();
return Some(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))));
} else if guard.get(&key).is_some() {
stale = true;
}
}
Err(e) => {
log::warn!("stream cache read lock poisoned: {e}");
return None;
}
}
if stale {
match cache.write() {
Ok(mut guard) => {
guard.remove(&key);
}
Err(e) => {
log::warn!("stream cache write lock poisoned while removing stale key: {e}");
}
}
}
None
}
#[async_trait]
impl ChatProvider for InMemoryCacheLayer {
async fn chat(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
let key = hash_chat_with_mode(
self.config.chat_key_mode,
messages,
None,
json_schema.as_ref(),
);
{
match self.chat_cache.read() {
Ok(cache) => {
if let Some(entry) = cache.get(&key)
&& !is_expired(&self.config, entry.created_at)
{
return Ok(Box::new(entry.value.clone()));
}
}
Err(e) => {
log::warn!("chat cache read lock poisoned: {e}");
}
}
}
let key_lock = acquire_inflight_lock(&self.chat_inflight, key).await;
let _guard = key_lock.lock().await;
{
match self.chat_cache.read() {
Ok(cache) => {
if let Some(entry) = cache.get(&key)
&& !is_expired(&self.config, entry.created_at)
{
return Ok(Box::new(entry.value.clone()));
}
}
Err(e) => {
log::warn!("chat cache read lock poisoned after single-flight wait: {e}");
}
}
}
let response = self.inner.chat(messages, json_schema).await?;
let cached = CachedChatResponse {
text: response.text(),
tool_calls: response.tool_calls(),
thinking: response.thinking(),
usage: response.usage(),
};
{
match self.chat_cache.write() {
Ok(mut cache) => {
evict_expired(&mut cache, self.config.ttl);
if let Some(max) = self.config.max_size {
evict_oldest(&mut cache, max);
}
cache.insert(
key,
CacheEntry {
value: cached.clone(),
created_at: Instant::now(),
},
);
}
Err(e) => {
log::warn!("chat cache write lock poisoned: {e}");
}
}
}
Ok(Box::new(cached))
}
async fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
let key = hash_chat_with_mode(
self.config.chat_key_mode,
messages,
tools,
json_schema.as_ref(),
);
{
match self.chat_cache.read() {
Ok(cache) => {
if let Some(entry) = cache.get(&key)
&& !is_expired(&self.config, entry.created_at)
{
return Ok(Box::new(entry.value.clone()));
}
}
Err(e) => {
log::warn!("chat cache read lock poisoned: {e}");
}
}
}
let key_lock = acquire_inflight_lock(&self.chat_inflight, key).await;
let _guard = key_lock.lock().await;
{
match self.chat_cache.read() {
Ok(cache) => {
if let Some(entry) = cache.get(&key)
&& !is_expired(&self.config, entry.created_at)
{
return Ok(Box::new(entry.value.clone()));
}
}
Err(e) => {
log::warn!("chat cache read lock poisoned after single-flight wait: {e}");
}
}
}
let response = self
.inner
.chat_with_tools(messages, tools, json_schema)
.await?;
let cached = CachedChatResponse {
text: response.text(),
tool_calls: response.tool_calls(),
thinking: response.thinking(),
usage: response.usage(),
};
{
match self.chat_cache.write() {
Ok(mut cache) => {
evict_expired(&mut cache, self.config.ttl);
if let Some(max) = self.config.max_size {
evict_oldest(&mut cache, max);
}
cache.insert(
key,
CacheEntry {
value: cached.clone(),
created_at: Instant::now(),
},
);
}
Err(e) => {
log::warn!("chat cache write lock poisoned: {e}");
}
}
}
Ok(Box::new(cached))
}
async fn chat_stream(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>, LLMError> {
if !self.config.cache_streaming {
return self.inner.chat_stream(messages, json_schema).await;
}
let key = hash_chat_with_mode(
self.config.chat_key_mode,
messages,
None,
json_schema.as_ref(),
);
if let Some(stream) = lookup_stream(&self.stream_chat_cache, key, &self.config) {
return Ok(stream);
}
let inner_stream = self.inner.chat_stream(messages, json_schema).await?;
Ok(Box::pin(TeeStream {
inner: inner_stream,
buffer: Vec::new(),
cache: self.stream_chat_cache.clone(),
key,
ttl: self.config.ttl,
max_size: self.config.max_size,
done: false,
}))
}
async fn chat_stream_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, LLMError>> + Send>>, LLMError> {
if !self.config.cache_streaming {
return self
.inner
.chat_stream_with_tools(messages, tools, json_schema)
.await;
}
let key = hash_chat_with_mode(
self.config.chat_key_mode,
messages,
tools,
json_schema.as_ref(),
);
if let Some(stream) = lookup_stream(&self.stream_tools_cache, key, &self.config) {
return Ok(stream);
}
let inner_stream = self
.inner
.chat_stream_with_tools(messages, tools, json_schema)
.await?;
Ok(Box::pin(TeeStream {
inner: inner_stream,
buffer: Vec::new(),
cache: self.stream_tools_cache.clone(),
key,
ttl: self.config.ttl,
max_size: self.config.max_size,
done: false,
}))
}
async fn chat_stream_struct(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamResponse, LLMError>> + Send>>, LLMError>
{
if !self.config.cache_streaming {
return self
.inner
.chat_stream_struct(messages, tools, json_schema)
.await;
}
let key = hash_chat_with_mode(
self.config.chat_key_mode,
messages,
tools,
json_schema.as_ref(),
);
if let Some(stream) = lookup_stream(&self.stream_struct_cache, key, &self.config) {
return Ok(stream);
}
let inner_stream = self
.inner
.chat_stream_struct(messages, tools, json_schema)
.await?;
Ok(Box::pin(TeeStream {
inner: inner_stream,
buffer: Vec::new(),
cache: self.stream_struct_cache.clone(),
key,
ttl: self.config.ttl,
max_size: self.config.max_size,
done: false,
}))
}
async fn chat_with_web_search(&self, input: String) -> Result<Box<dyn ChatResponse>, LLMError> {
self.inner.chat_with_web_search(input).await
}
fn model(&self) -> &str {
self.inner.model()
}
}
#[async_trait]
impl CompletionProvider for InMemoryCacheLayer {
async fn complete(
&self,
req: &CompletionRequest,
json_schema: Option<StructuredOutputFormat>,
) -> Result<CompletionResponse, LLMError> {
if !self.config.cache_completions {
return self.inner.complete(req, json_schema).await;
}
let key = hash_completion(req, json_schema.as_ref());
{
match self.completion_cache.read() {
Ok(cache) => {
if let Some(entry) = cache.get(&key)
&& !is_expired(&self.config, entry.created_at)
{
return Ok(CompletionResponse {
text: entry.value.clone(),
});
}
}
Err(e) => {
log::warn!("completion cache read lock poisoned: {e}");
}
}
}
let key_lock = acquire_inflight_lock(&self.completion_inflight, key).await;
let _guard = key_lock.lock().await;
{
match self.completion_cache.read() {
Ok(cache) => {
if let Some(entry) = cache.get(&key)
&& !is_expired(&self.config, entry.created_at)
{
return Ok(CompletionResponse {
text: entry.value.clone(),
});
}
}
Err(e) => {
log::warn!("completion cache read lock poisoned after single-flight wait: {e}");
}
}
}
let response = self.inner.complete(req, json_schema).await?;
{
match self.completion_cache.write() {
Ok(mut cache) => {
evict_expired(&mut cache, self.config.ttl);
if let Some(max) = self.config.max_size {
evict_oldest(&mut cache, max);
}
cache.insert(
key,
CacheEntry {
value: response.text.clone(),
created_at: Instant::now(),
},
);
}
Err(e) => {
log::warn!("completion cache write lock poisoned: {e}");
}
}
}
Ok(response)
}
}
#[async_trait]
impl EmbeddingProvider for InMemoryCacheLayer {
async fn embed(&self, input: Vec<String>) -> Result<Vec<Vec<f32>>, LLMError> {
if !self.config.cache_embeddings {
return self.inner.embed(input).await;
}
let key = hash_val(&input);
{
match self.embedding_cache.read() {
Ok(cache) => {
if let Some(entry) = cache.get(&key)
&& !is_expired(&self.config, entry.created_at)
{
return Ok(entry.value.clone());
}
}
Err(e) => {
log::warn!("embedding cache read lock poisoned: {e}");
}
}
}
let key_lock = acquire_inflight_lock(&self.embedding_inflight, key).await;
let _guard = key_lock.lock().await;
{
match self.embedding_cache.read() {
Ok(cache) => {
if let Some(entry) = cache.get(&key)
&& !is_expired(&self.config, entry.created_at)
{
return Ok(entry.value.clone());
}
}
Err(e) => {
log::warn!("embedding cache read lock poisoned after single-flight wait: {e}");
}
}
}
let result = self.inner.embed(input).await?;
{
match self.embedding_cache.write() {
Ok(mut cache) => {
evict_expired(&mut cache, self.config.ttl);
if let Some(max) = self.config.max_size {
evict_oldest(&mut cache, max);
}
cache.insert(
key,
CacheEntry {
value: result.clone(),
created_at: Instant::now(),
},
);
}
Err(e) => {
log::warn!("embedding cache write lock poisoned: {e}");
}
}
}
Ok(result)
}
}
#[async_trait]
impl ModelsProvider for InMemoryCacheLayer {
async fn list_models(
&self,
request: Option<&ModelListRequest>,
) -> Result<Box<dyn ModelListResponse>, LLMError> {
self.inner.list_models(request).await
}
}
impl LLMProvider for InMemoryCacheLayer {}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
HasConfig, LLMProvider, NoConfig,
chat::{
ChatMessage, ChatProvider, ChatResponse, ChatRole, MessageType, StreamChoice,
StreamChunk, StreamDelta, StreamResponse, StructuredOutputFormat, Tool,
},
completion::{CompletionProvider, CompletionRequest, CompletionResponse},
embedding::EmbeddingProvider,
error::LLMError,
models::ModelsProvider,
optim::CacheLayer,
pipeline::PipelineBuilder,
};
use async_trait::async_trait;
use futures::StreamExt;
use std::sync::{
Arc,
atomic::{AtomicU32, Ordering},
};
#[derive(Debug)]
struct MockChatResp(String);
impl ChatResponse for MockChatResp {
fn text(&self) -> Option<String> {
Some(self.0.clone())
}
fn tool_calls(&self) -> Option<Vec<crate::ToolCall>> {
None
}
}
impl fmt::Display for MockChatResp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
struct MockInner {
chat_calls: Arc<AtomicU32>,
chat_with_tools_calls: Arc<AtomicU32>,
completion_calls: Arc<AtomicU32>,
embedding_calls: Arc<AtomicU32>,
stream_chat_calls: Arc<AtomicU32>,
stream_tools_calls: Arc<AtomicU32>,
stream_struct_calls: Arc<AtomicU32>,
}
impl MockInner {
fn new() -> Arc<Self> {
Arc::new(Self {
chat_calls: Arc::new(AtomicU32::new(0)),
chat_with_tools_calls: Arc::new(AtomicU32::new(0)),
completion_calls: Arc::new(AtomicU32::new(0)),
embedding_calls: Arc::new(AtomicU32::new(0)),
stream_chat_calls: Arc::new(AtomicU32::new(0)),
stream_tools_calls: Arc::new(AtomicU32::new(0)),
stream_struct_calls: Arc::new(AtomicU32::new(0)),
})
}
}
#[async_trait]
impl ChatProvider for MockInner {
async fn chat(
&self,
_messages: &[ChatMessage],
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
self.chat_calls.fetch_add(1, Ordering::SeqCst);
Ok(Box::new(MockChatResp("chat-response".to_string())))
}
async fn chat_with_tools(
&self,
_messages: &[ChatMessage],
_tools: Option<&[Tool]>,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
self.chat_with_tools_calls.fetch_add(1, Ordering::SeqCst);
Ok(Box::new(MockChatResp("chat-response".to_string())))
}
async fn chat_stream(
&self,
_messages: &[ChatMessage],
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>, LLMError>
{
self.stream_chat_calls.fetch_add(1, Ordering::SeqCst);
let chunks: Vec<Result<String, LLMError>> = vec![
Ok("chunk1".into()),
Ok("chunk2".into()),
Ok("chunk3".into()),
];
Ok(Box::pin(futures::stream::iter(chunks)))
}
async fn chat_stream_with_tools(
&self,
_messages: &[ChatMessage],
_tools: Option<&[Tool]>,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, LLMError>> + Send>>, LLMError>
{
self.stream_tools_calls.fetch_add(1, Ordering::SeqCst);
let chunks: Vec<Result<StreamChunk, LLMError>> = vec![
Ok(StreamChunk::Text("hello".into())),
Ok(StreamChunk::Text(" world".into())),
Ok(StreamChunk::Done {
stop_reason: "end_turn".into(),
}),
];
Ok(Box::pin(futures::stream::iter(chunks)))
}
async fn chat_stream_struct(
&self,
_messages: &[ChatMessage],
_tools: Option<&[Tool]>,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamResponse, LLMError>> + Send>>, LLMError>
{
self.stream_struct_calls.fetch_add(1, Ordering::SeqCst);
let chunk = StreamResponse {
choices: vec![StreamChoice {
delta: StreamDelta {
content: Some("hi".into()),
reasoning_content: None,
tool_calls: None,
},
}],
usage: None,
};
Ok(Box::pin(futures::stream::iter(vec![Ok(chunk)])))
}
}
#[async_trait]
impl CompletionProvider for MockInner {
async fn complete(
&self,
_req: &CompletionRequest,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<CompletionResponse, LLMError> {
self.completion_calls.fetch_add(1, Ordering::SeqCst);
Ok(CompletionResponse {
text: "completion-response".to_string(),
})
}
}
#[async_trait]
impl EmbeddingProvider for MockInner {
async fn embed(&self, _input: Vec<String>) -> Result<Vec<Vec<f32>>, LLMError> {
self.embedding_calls.fetch_add(1, Ordering::SeqCst);
Ok(vec![vec![1.0, 2.0, 3.0]])
}
}
#[async_trait]
impl ModelsProvider for MockInner {}
impl LLMProvider for MockInner {}
impl HasConfig for MockInner {
type Config = NoConfig;
}
fn make_pipeline(config: CacheConfig) -> (Arc<MockInner>, Arc<dyn LLMProvider>) {
let inner = MockInner::new();
let provider = PipelineBuilder::new(inner.clone() as Arc<dyn LLMProvider>)
.add_layer(CacheLayer::new(config))
.build();
(inner, provider)
}
async fn collect_stream<T: Clone>(
stream: Pin<Box<dyn Stream<Item = Result<T, LLMError>> + Send>>,
) -> Vec<T> {
let mut out = Vec::new();
let mut s = stream;
while let Some(item) = s.next().await {
out.push(item.unwrap());
}
out
}
#[tokio::test]
async fn test_chat_cache_hit_does_not_call_inner() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("hello").build()];
let r1 = provider
.chat_with_tools(&messages, None, None)
.await
.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 1);
assert_eq!(r1.text(), Some("chat-response".to_string()));
let r2 = provider
.chat_with_tools(&messages, None, None)
.await
.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 1);
assert_eq!(r2.text(), Some("chat-response".to_string()));
}
#[tokio::test]
async fn test_chat_preserves_chat_method_shape() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("hello").build()];
let response = provider.chat(&messages, None).await.unwrap();
assert_eq!(response.text(), Some("chat-response".to_string()));
assert_eq!(inner.chat_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_chat_cache_different_messages_are_separate_entries() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let msg_a = vec![ChatMessage::user().content("question A").build()];
let msg_b = vec![ChatMessage::user().content("question B").build()];
provider.chat_with_tools(&msg_a, None, None).await.unwrap();
provider.chat_with_tools(&msg_b, None, None).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 2);
provider.chat_with_tools(&msg_a, None, None).await.unwrap();
provider.chat_with_tools(&msg_b, None, None).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_chat_cache_reuses_same_prompt_across_different_history() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let msg_a = vec![ChatMessage::user().content("repeat prompt").build()];
let msg_b = vec![
ChatMessage::assistant().content("older context").build(),
ChatMessage::user().content("repeat prompt").build(),
];
provider.chat_with_tools(&msg_a, None, None).await.unwrap();
provider.chat_with_tools(&msg_b, None, None).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_chat_cache_does_not_reuse_user_prompt_key_across_tool_result_state() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let msg_a = vec![ChatMessage::user().content("repeat prompt").build()];
let msg_b = vec![
ChatMessage::user().content("repeat prompt").build(),
ChatMessage {
role: ChatRole::Tool,
message_type: MessageType::ToolResult(vec![crate::ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: crate::FunctionCall {
name: "add".to_string(),
arguments: "42".to_string(),
},
}]),
content: String::new(),
},
];
provider.chat_with_tools(&msg_a, None, None).await.unwrap();
provider.chat_with_tools(&msg_b, None, None).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_chat_cache_ttl_expiry() {
let (inner, provider) = make_pipeline(CacheConfig {
ttl: Some(Duration::from_millis(5)),
..CacheConfig::default()
});
let messages = vec![ChatMessage::user().content("ttl test").build()];
provider
.chat_with_tools(&messages, None, None)
.await
.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 1);
provider
.chat_with_tools(&messages, None, None)
.await
.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 1);
tokio::time::sleep(Duration::from_millis(15)).await;
provider
.chat_with_tools(&messages, None, None)
.await
.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_chat_cache_max_size_evicts_oldest() {
let (inner, provider) = make_pipeline(CacheConfig {
max_size: Some(2),
..CacheConfig::default()
});
let msg_a = vec![ChatMessage::user().content("A").build()];
let msg_b = vec![ChatMessage::user().content("B").build()];
let msg_c = vec![ChatMessage::user().content("C").build()];
provider.chat_with_tools(&msg_a, None, None).await.unwrap();
provider.chat_with_tools(&msg_b, None, None).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 2);
provider.chat_with_tools(&msg_c, None, None).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 3);
provider.chat_with_tools(&msg_b, None, None).await.unwrap();
provider.chat_with_tools(&msg_c, None, None).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 3);
provider.chat_with_tools(&msg_a, None, None).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn test_chat_stream_first_call_hits_inner() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("stream test").build()];
let stream = provider.chat_stream(&messages, None).await.unwrap();
let chunks = collect_stream(stream).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 1);
assert_eq!(chunks, vec!["chunk1", "chunk2", "chunk3"]);
}
#[tokio::test]
async fn test_chat_stream_cache_hit_does_not_call_inner() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("stream test").build()];
let chunks1 = collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 1);
let chunks2 = collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 1);
assert_eq!(chunks1, chunks2);
}
#[tokio::test]
async fn test_chat_stream_cache_disabled_always_calls_inner() {
let (inner, provider) = make_pipeline(CacheConfig {
cache_streaming: false,
..CacheConfig::default()
});
let messages = vec![ChatMessage::user().content("no cache").build()];
collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_chat_stream_ttl_expiry() {
let (inner, provider) = make_pipeline(CacheConfig {
ttl: Some(Duration::from_millis(5)),
..CacheConfig::default()
});
let messages = vec![ChatMessage::user().content("ttl stream").build()];
collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 1);
collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 1);
tokio::time::sleep(Duration::from_millis(15)).await;
collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_chat_stream_max_size_evicts_oldest() {
let (inner, provider) = make_pipeline(CacheConfig {
max_size: Some(2),
..CacheConfig::default()
});
let msg_a = vec![ChatMessage::user().content("SA").build()];
let msg_b = vec![ChatMessage::user().content("SB").build()];
let msg_c = vec![ChatMessage::user().content("SC").build()];
collect_stream(provider.chat_stream(&msg_a, None).await.unwrap()).await;
collect_stream(provider.chat_stream(&msg_b, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 2);
collect_stream(provider.chat_stream(&msg_c, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 3);
collect_stream(provider.chat_stream(&msg_b, None).await.unwrap()).await;
collect_stream(provider.chat_stream(&msg_c, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 3);
collect_stream(provider.chat_stream(&msg_a, None).await.unwrap()).await;
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn test_chat_stream_with_tools_first_call_hits_inner() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("tool stream").build()];
let chunks = collect_stream(
provider
.chat_stream_with_tools(&messages, None, None)
.await
.unwrap(),
)
.await;
assert_eq!(inner.stream_tools_calls.load(Ordering::SeqCst), 1);
assert_eq!(chunks.len(), 3); }
#[tokio::test]
async fn test_chat_stream_with_tools_cache_hit() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("tool stream cached").build()];
let c1 = collect_stream(
provider
.chat_stream_with_tools(&messages, None, None)
.await
.unwrap(),
)
.await;
let c2 = collect_stream(
provider
.chat_stream_with_tools(&messages, None, None)
.await
.unwrap(),
)
.await;
assert_eq!(inner.stream_tools_calls.load(Ordering::SeqCst), 1);
assert_eq!(c1.len(), c2.len());
}
#[tokio::test]
async fn test_chat_stream_with_tools_disabled() {
let (inner, provider) = make_pipeline(CacheConfig {
cache_streaming: false,
..CacheConfig::default()
});
let messages = vec![ChatMessage::user().content("no cache tools").build()];
collect_stream(
provider
.chat_stream_with_tools(&messages, None, None)
.await
.unwrap(),
)
.await;
collect_stream(
provider
.chat_stream_with_tools(&messages, None, None)
.await
.unwrap(),
)
.await;
assert_eq!(inner.stream_tools_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_chat_stream_struct_first_call_hits_inner() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("struct stream").build()];
let chunks = collect_stream(
provider
.chat_stream_struct(&messages, None, None)
.await
.unwrap(),
)
.await;
assert_eq!(inner.stream_struct_calls.load(Ordering::SeqCst), 1);
assert_eq!(chunks.len(), 1);
}
#[tokio::test]
async fn test_chat_stream_struct_cache_hit() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("struct cached").build()];
let c1 = collect_stream(
provider
.chat_stream_struct(&messages, None, None)
.await
.unwrap(),
)
.await;
let c2 = collect_stream(
provider
.chat_stream_struct(&messages, None, None)
.await
.unwrap(),
)
.await;
assert_eq!(inner.stream_struct_calls.load(Ordering::SeqCst), 1);
assert_eq!(c1.len(), c2.len());
assert_eq!(
c1[0].choices[0].delta.content,
c2[0].choices[0].delta.content
);
}
#[tokio::test]
async fn test_chat_stream_struct_disabled() {
let (inner, provider) = make_pipeline(CacheConfig {
cache_streaming: false,
..CacheConfig::default()
});
let messages = vec![ChatMessage::user().content("no cache struct").build()];
collect_stream(
provider
.chat_stream_struct(&messages, None, None)
.await
.unwrap(),
)
.await;
collect_stream(
provider
.chat_stream_struct(&messages, None, None)
.await
.unwrap(),
)
.await;
assert_eq!(inner.stream_struct_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_chat_stream_error_not_cached() {
use std::sync::atomic::AtomicBool;
struct ErrorOnFirst {
failed: Arc<AtomicBool>,
calls: Arc<AtomicU32>,
}
#[async_trait]
impl ChatProvider for ErrorOnFirst {
async fn chat_with_tools(
&self,
_: &[ChatMessage],
_: Option<&[Tool]>,
_: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
Err(LLMError::Generic("not impl".into()))
}
async fn chat_stream(
&self,
_: &[ChatMessage],
_: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>, LLMError>
{
self.calls.fetch_add(1, Ordering::SeqCst);
if !self.failed.swap(true, Ordering::SeqCst) {
let s: Vec<Result<String, LLMError>> = vec![
Ok("ok".into()),
Err(LLMError::Generic("mid-stream error".into())),
];
Ok(Box::pin(futures::stream::iter(s)))
} else {
let s: Vec<Result<String, LLMError>> =
vec![Ok("clean1".into()), Ok("clean2".into())];
Ok(Box::pin(futures::stream::iter(s)))
}
}
}
#[async_trait]
impl CompletionProvider for ErrorOnFirst {
async fn complete(
&self,
_: &CompletionRequest,
_: Option<StructuredOutputFormat>,
) -> Result<CompletionResponse, LLMError> {
Err(LLMError::Generic("not impl".into()))
}
}
#[async_trait]
impl EmbeddingProvider for ErrorOnFirst {
async fn embed(&self, _: Vec<String>) -> Result<Vec<Vec<f32>>, LLMError> {
Err(LLMError::Generic("not impl".into()))
}
}
#[async_trait]
impl ModelsProvider for ErrorOnFirst {}
impl LLMProvider for ErrorOnFirst {}
let calls = Arc::new(AtomicU32::new(0));
let inner = Arc::new(ErrorOnFirst {
failed: Arc::new(AtomicBool::new(false)),
calls: calls.clone(),
});
let provider = PipelineBuilder::new(inner as Arc<dyn LLMProvider>)
.add_layer(CacheLayer::with_defaults())
.build();
let messages = vec![ChatMessage::user().content("error test").build()];
let mut stream1 = provider.chat_stream(&messages, None).await.unwrap();
let mut saw_error = false;
while let Some(item) = stream1.next().await {
if item.is_err() {
saw_error = true;
}
}
assert!(saw_error, "expected an error chunk");
assert_eq!(calls.load(Ordering::SeqCst), 1);
let chunks2 = collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(chunks2, vec!["clean1", "clean2"]);
let chunks3 = collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
assert_eq!(calls.load(Ordering::SeqCst), 2); assert_eq!(chunks3, vec!["clean1", "clean2"]);
}
#[tokio::test]
async fn test_completion_cache_hit() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let req = CompletionRequest::new("prompt");
provider.complete(&req, None).await.unwrap();
assert_eq!(inner.completion_calls.load(Ordering::SeqCst), 1);
provider.complete(&req, None).await.unwrap();
assert_eq!(inner.completion_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_completion_cache_key_includes_json_schema() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let req = CompletionRequest::new("prompt");
let schema = StructuredOutputFormat {
name: "Answer".into(),
description: Some("Structured answer".into()),
schema: Some(serde_json::json!({
"type": "object",
"properties": { "answer": { "type": "string" } },
"required": ["answer"]
})),
strict: Some(true),
};
provider.complete(&req, Some(schema.clone())).await.unwrap();
provider.complete(&req, None).await.unwrap();
provider.complete(&req, Some(schema)).await.unwrap();
assert_eq!(inner.completion_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_completion_cache_disabled() {
let (inner, provider) = make_pipeline(CacheConfig {
cache_completions: false,
..CacheConfig::default()
});
let req = CompletionRequest::new("prompt");
provider.complete(&req, None).await.unwrap();
provider.complete(&req, None).await.unwrap();
assert_eq!(inner.completion_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_completion_cache_ttl_expiry() {
let (inner, provider) = make_pipeline(CacheConfig {
ttl: Some(Duration::from_millis(5)),
..CacheConfig::default()
});
let req = CompletionRequest::new("ttl-prompt");
provider.complete(&req, None).await.unwrap();
provider.complete(&req, None).await.unwrap();
assert_eq!(inner.completion_calls.load(Ordering::SeqCst), 1);
tokio::time::sleep(Duration::from_millis(15)).await;
provider.complete(&req, None).await.unwrap();
assert_eq!(inner.completion_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_embedding_cache_hit() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let input = vec!["hello world".to_string()];
let r1 = provider.embed(input.clone()).await.unwrap();
assert_eq!(inner.embedding_calls.load(Ordering::SeqCst), 1);
assert_eq!(r1, vec![vec![1.0, 2.0, 3.0]]);
let r2 = provider.embed(input).await.unwrap();
assert_eq!(inner.embedding_calls.load(Ordering::SeqCst), 1);
assert_eq!(r2, vec![vec![1.0, 2.0, 3.0]]);
}
#[tokio::test]
async fn test_embedding_cache_disabled() {
let (inner, provider) = make_pipeline(CacheConfig {
cache_embeddings: false,
..CacheConfig::default()
});
let input = vec!["hello".to_string()];
provider.embed(input.clone()).await.unwrap();
provider.embed(input).await.unwrap();
assert_eq!(inner.embedding_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_all_caches_are_independent() {
let (inner, provider) = make_pipeline(CacheConfig::default());
let messages = vec![ChatMessage::user().content("x").build()];
let req = CompletionRequest::new("x");
let emb_input = vec!["x".to_string()];
provider
.chat_with_tools(&messages, None, None)
.await
.unwrap();
collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
collect_stream(
provider
.chat_stream_with_tools(&messages, None, None)
.await
.unwrap(),
)
.await;
collect_stream(
provider
.chat_stream_struct(&messages, None, None)
.await
.unwrap(),
)
.await;
provider.complete(&req, None).await.unwrap();
provider.embed(emb_input.clone()).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.stream_tools_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.stream_struct_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.completion_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.embedding_calls.load(Ordering::SeqCst), 1);
provider
.chat_with_tools(&messages, None, None)
.await
.unwrap();
collect_stream(provider.chat_stream(&messages, None).await.unwrap()).await;
collect_stream(
provider
.chat_stream_with_tools(&messages, None, None)
.await
.unwrap(),
)
.await;
collect_stream(
provider
.chat_stream_struct(&messages, None, None)
.await
.unwrap(),
)
.await;
provider.complete(&req, None).await.unwrap();
provider.embed(emb_input).await.unwrap();
assert_eq!(inner.chat_with_tools_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.stream_chat_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.stream_tools_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.stream_struct_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.completion_calls.load(Ordering::SeqCst), 1);
assert_eq!(inner.embedding_calls.load(Ordering::SeqCst), 1);
}
}