From 5acfcfa4febefce2f9df7d6876915278bf91d229 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 21:00:48 +0000 Subject: [PATCH] feat(rust): native Azure Blob response cache backend Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm-rust/Cargo.lock | 82 +++ litellm-rust/Cargo.toml | 1 + litellm-rust/crates/auth-azure/src/types.rs | 15 + .../crates/cache-azure-blob/Cargo.toml | 22 + .../crates/cache-azure-blob/src/cache.rs | 246 +++++++ .../cache-azure-blob/src/cache/tests.rs | 692 ++++++++++++++++++ .../crates/cache-azure-blob/src/credential.rs | 84 +++ .../crates/cache-azure-blob/src/lib.rs | 5 + .../crates/cache-azure-blob/src/tests.rs | 0 litellm-rust/crates/python-bridge/Cargo.toml | 1 + .../crates/python-bridge/src/cache/config.rs | 49 +- .../crates/python-bridge/src/cache/facade.rs | 88 ++- .../crates/python-bridge/src/cache/handle.rs | 17 +- .../crates/python-bridge/src/cache/native.rs | 44 +- tests/test_litellm_rust/test_cache.py | 117 +++ 15 files changed, 1439 insertions(+), 24 deletions(-) create mode 100644 litellm-rust/crates/cache-azure-blob/Cargo.toml create mode 100644 litellm-rust/crates/cache-azure-blob/src/cache.rs create mode 100644 litellm-rust/crates/cache-azure-blob/src/cache/tests.rs create mode 100644 litellm-rust/crates/cache-azure-blob/src/credential.rs create mode 100644 litellm-rust/crates/cache-azure-blob/src/lib.rs create mode 100644 litellm-rust/crates/cache-azure-blob/src/tests.rs diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index ed4ae4e3353..5b112519add 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -115,6 +115,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.91" @@ -599,6 +621,37 @@ dependencies = [ "url", ] +[[package]] +name = "azure_storage_blob" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b10207ecf7d666df6940b50051f433b3cd5d2b9b1dd190613208d7a84e7eed" +dependencies = [ + "async-stream", + "async-trait", + "azure_core", + "azure_storage_common", + "bytes", + "futures", + "percent-encoding", + "pin-project", + "serde", + "serde_json", + "time", + "tokio", +] + +[[package]] +name = "azure_storage_common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0af2e6aeb8d76b17fc998f453c320913f73787b944e3cc29509d19411fa0321d" +dependencies = [ + "azure_core", + "serde", + "time", +] + [[package]] name = "base64" version = "0.13.1" @@ -2464,6 +2517,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "litellm-cache-azure-blob" +version = "0.1.0" +dependencies = [ + "async-trait", + "azure_core", + "azure_storage_blob", + "futures-util", + "litellm-auth-azure", + "litellm-auth-types", + "litellm-cache", + "litellm-cache-response", + "serde_json", + "tokio", + "url", +] + [[package]] name = "litellm-cache-memory" version = "0.1.0" @@ -2666,6 +2736,7 @@ dependencies = [ "litellm-auth", "litellm-auth-gcp", "litellm-cache", + "litellm-cache-azure-blob", "litellm-cache-memory", "litellm-cache-redis", "litellm-cache-response", @@ -3487,6 +3558,16 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.11" @@ -5030,6 +5111,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures", + "quick-xml", "serde", "serde_json", "url", diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 570d0dd3568..58fc1ba1d05 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -27,6 +27,7 @@ litellm-llms = { path = "crates/llms" } litellm-types = { path = "crates/types" } litellm-core-utils = { path = "crates/core-utils" } litellm-cache = { path = "crates/cache" } +litellm-cache-azure-blob = { path = "crates/cache-azure-blob" } litellm-cache-memory = { path = "crates/cache-memory" } litellm-cache-redis = { path = "crates/cache-redis" } litellm-cache-response = { path = "crates/cache-response" } diff --git a/litellm-rust/crates/auth-azure/src/types.rs b/litellm-rust/crates/auth-azure/src/types.rs index d5a00f09751..a3a898f000f 100644 --- a/litellm-rust/crates/auth-azure/src/types.rs +++ b/litellm-rust/crates/auth-azure/src/types.rs @@ -51,6 +51,21 @@ pub struct AzureAuthInputs { } impl AzureAuthInputs { + pub fn default_credential_for_scope(scope: &str) -> Self { + Self { + azure_scope: ConfigValue::Value(Sourced::new( + scope.to_string(), + InputSource::Deployment, + )), + azure_credential: ConfigValue::Value(Sourced::new( + "DefaultAzureCredential".to_string(), + InputSource::Deployment, + )), + enable_azure_ad_token_refresh: Sourced::new(true, InputSource::Deployment), + ..Self::default() + } + } + pub fn or_configured_token_refresh(self, enabled: bool) -> Self { if *self.enable_azure_ad_token_refresh.value() || !enabled { return self; diff --git a/litellm-rust/crates/cache-azure-blob/Cargo.toml b/litellm-rust/crates/cache-azure-blob/Cargo.toml new file mode 100644 index 00000000000..55abaff1975 --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "litellm-cache-azure-blob" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +litellm-auth-azure.workspace = true +litellm-auth-types.workspace = true +litellm-cache.workspace = true + +async-trait = "0.1" +azure_core = "1.1.0" +azure_storage_blob = "1.1.0" +futures-util.workspace = true +tokio.workspace = true +url.workspace = true + +[dev-dependencies] +litellm-cache-response.workspace = true +serde_json.workspace = true diff --git a/litellm-rust/crates/cache-azure-blob/src/cache.rs b/litellm-rust/crates/cache-azure-blob/src/cache.rs new file mode 100644 index 00000000000..f28b8c9a641 --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/src/cache.rs @@ -0,0 +1,246 @@ +use std::{sync::Arc, time::Duration}; + +use azure_core::{ + credentials::TokenCredential, + error::ErrorKind, + http::{ClientOptions, RequestContent}, +}; +use azure_storage_blob::{ + BlobContainerClient, BlobContainerClientOptions, + models::{BlobClientUploadOptions, StorageErrorCode}, +}; +use futures_util::{TryStreamExt, future::try_join_all}; +use litellm_cache::{ + BaseCache, BatchCache, CacheCodec, CacheConnectionResult, CacheConnectionStatus, Error, + ExactCacheContext, FlushCache, +}; +use tokio::runtime::Handle; +use url::Url; + +use crate::credential::AzureBlobCredential; + +/// Synchronous methods block on `runtime` and therefore must run outside of it +pub struct AzureBlobCache { + container: BlobContainerClient, + codec: C, + runtime: Handle, + account_url: String, + container_name: String, +} + +impl AzureBlobCache { + pub async fn connect( + account_url: &str, + container: &str, + codec: C, + runtime: Handle, + ) -> Result { + Self::connect_with_options( + account_url, + container, + Some(Arc::new(AzureBlobCredential::default())), + ClientOptions::default(), + codec, + runtime, + ) + .await + } + + pub async fn connect_with_options( + account_url: &str, + container: &str, + credential: Option>, + client_options: ClientOptions, + codec: C, + runtime: Handle, + ) -> Result { + let mut url = Url::parse(account_url).map_err(|_| Error::Unavailable)?; + let account_url = url.as_str().trim_end_matches('/').to_string(); + url.path_segments_mut() + .map_err(|()| Error::Unavailable)? + .pop_if_empty() + .push(container); + let client = BlobContainerClient::new( + url, + credential, + Some(BlobContainerClientOptions { + client_options, + ..BlobContainerClientOptions::default() + }), + ) + .map_err(|_| Error::Unavailable)?; + let cache = Self { + container: client, + codec, + runtime, + account_url, + container_name: container.to_string(), + }; + cache.create_container().await?; + Ok(cache) + } + + pub fn account_url(&self) -> &str { + &self.account_url + } + + pub fn container_name(&self) -> &str { + &self.container_name + } + + async fn create_container(&self) -> Result<(), Error> { + match self.container.create(None).await { + Ok(_) => Ok(()), + Err(error) if is_storage_error(&error, StorageErrorCode::ContainerAlreadyExists) => { + Ok(()) + } + Err(_) => Err(Error::Unavailable), + } + } + + async fn upload(&self, key: &str, value: &C::Value, overwrite: bool) -> Result<(), Error> { + let payload = self.codec.encode(value)?; + let options = (!overwrite).then(|| BlobClientUploadOptions::default().if_not_exists()); + match self + .container + .blob_client(key) + .upload(RequestContent::from(payload), options) + .await + { + Ok(_) => Ok(()), + Err(error) if is_storage_error(&error, StorageErrorCode::BlobAlreadyExists) => Ok(()), + Err(_) => Err(Error::Unavailable), + } + } + + async fn download(&self, key: &str) -> Result, Error> { + let response = match self.container.blob_client(key).download(None).await { + Ok(response) => response, + Err(error) if is_storage_error(&error, StorageErrorCode::BlobNotFound) => { + return Ok(None); + } + Err(_) => return Err(Error::Unavailable), + }; + let bytes = response + .body + .collect() + .await + .map_err(|_| Error::Unavailable)?; + self.codec.decode(&bytes).map(Some) + } + + async fn delete_all_blobs(&self) -> Result<(), Error> { + let mut pages = self + .container + .list_blobs(None) + .map_err(|_| Error::Unavailable)? + .into_pages(); + while let Some(page) = pages.try_next().await.map_err(|_| Error::Unavailable)? { + let page = page.into_model().map_err(|_| Error::Unavailable)?; + for name in page.blob_items.into_iter().filter_map(|item| item.name) { + self.container + .blob_client(&name) + .delete(None) + .await + .map_err(|_| Error::Unavailable)?; + } + } + Ok(()) + } + + fn block_on(&self, future: impl Future) -> T { + self.runtime.block_on(future) + } +} + +fn is_storage_error(error: &azure_core::Error, code: StorageErrorCode) -> bool { + matches!( + error.kind(), + ErrorKind::HttpResponse { + error_code: Some(error_code), + .. + } if error_code == code.as_ref() + ) +} + +impl BaseCache for AzureBlobCache { + type Value = C::Value; + type Context = ExactCacheContext; + + fn get_ttl(&self, _: &ExactCacheContext) -> Option { + None + } + + fn set_cache(&self, key: &str, value: C::Value, _: &ExactCacheContext) -> Result<(), Error> { + self.block_on(self.upload(key, &value, false)) + } + + fn get_cache(&self, key: &str, _: &ExactCacheContext) -> Result, Error> { + self.block_on(self.download(key)) + } + + async fn async_set_cache( + &self, + key: &str, + value: C::Value, + _: ExactCacheContext, + ) -> Result<(), Error> { + self.upload(key, &value, true).await + } + + async fn async_get_cache( + &self, + key: &str, + _: &ExactCacheContext, + ) -> Result, Error> { + self.download(key).await + } + + async fn async_set_cache_pipeline( + &self, + entries: Vec<(String, C::Value)>, + _: ExactCacheContext, + ) -> Result<(), Error> { + try_join_all( + entries + .iter() + .map(|(key, value)| self.upload(key, value, true)), + ) + .await + .map(drop) + } + + async fn disconnect(&self) -> Result<(), Error> { + Ok(()) + } + + async fn test_connection(&self) -> Result { + Ok(match self.container.get_properties(None).await { + Ok(_) => CacheConnectionResult { + status: CacheConnectionStatus::Success, + message: "Azure Blob cache connection test successful".into(), + error: None, + }, + Err(error) => CacheConnectionResult { + status: CacheConnectionStatus::Failed, + message: format!("Azure Blob connection failed: {error}"), + error: Some(error.to_string()), + }, + }) + } +} + +impl BatchCache for AzureBlobCache {} + +impl FlushCache for AzureBlobCache { + fn flush_cache(&self) -> Result<(), Error> { + self.block_on(self.delete_all_blobs()) + } + + async fn async_flush_cache(&self) -> Result<(), Error> { + self.delete_all_blobs().await + } +} + +#[cfg(test)] +mod tests; diff --git a/litellm-rust/crates/cache-azure-blob/src/cache/tests.rs b/litellm-rust/crates/cache-azure-blob/src/cache/tests.rs new file mode 100644 index 00000000000..fd116a0e28f --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/src/cache/tests.rs @@ -0,0 +1,692 @@ +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + time::Duration, +}; + +use azure_core::http::{ + AsyncRawResponse, Body, ClientOptions, HttpClient, Method, Request, StatusCode, Transport, + headers::{HeaderName, Headers}, +}; +use litellm_cache::{ + BaseCache, BatchCache, BatchEntry, CacheConnectionStatus, Error, ExactCacheContext, FlushCache, +}; +use litellm_cache_response::{ + CacheEntry, CacheKeyField, CacheKeyInput, ResponseCache, ResponseCacheCodec, + ResponseCacheRequest, cache_key, +}; +use serde_json::json; +use tokio::runtime::Runtime; + +use super::AzureBlobCache; + +const ACCOUNT_URL: &str = "https://example.blob.core.windows.net"; +const CONTAINER: &str = "litellm-cache"; +const IF_NONE_MATCH: HeaderName = HeaderName::from_static("if-none-match"); +const ERROR_CODE: HeaderName = HeaderName::from_static("x-ms-error-code"); + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RecordedRequest { + method: Method, + path: String, + query: String, + if_none_match: Option, +} + +#[derive(Default)] +struct FakeState { + container_exists: bool, + blobs: BTreeMap>, + requests: Vec, + failing: bool, +} + +#[derive(Clone, Default)] +struct FakeBlobService { + state: Arc>, +} + +impl std::fmt::Debug for FakeBlobService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("FakeBlobService") + } +} + +impl FakeBlobService { + fn with_existing_container() -> Self { + let service = Self::default(); + service.state.lock().unwrap().container_exists = true; + service + } + + fn blob(&self, name: &str) -> Option> { + self.state.lock().unwrap().blobs.get(name).cloned() + } + + fn blob_names(&self) -> Vec { + self.state.lock().unwrap().blobs.keys().cloned().collect() + } + + fn seed_blob(&self, name: &str, bytes: &[u8]) { + self.state + .lock() + .unwrap() + .blobs + .insert(name.to_string(), bytes.to_vec()); + } + + fn set_failing(&self, failing: bool) { + self.state.lock().unwrap().failing = failing; + } + + fn requests(&self) -> Vec { + self.state.lock().unwrap().requests.clone() + } + + fn container_exists(&self) -> bool { + self.state.lock().unwrap().container_exists + } + + fn respond(status: StatusCode, error_code: Option<&str>, body: Vec) -> AsyncRawResponse { + let mut headers = Headers::new(); + if let Some(code) = error_code { + headers.insert(ERROR_CODE, code.to_string()); + } + AsyncRawResponse::from_bytes(status, headers, body) + } + + fn list_body(state: &FakeState) -> Vec { + let mut xml = String::from( + r#""#, + ); + for name in state.blobs.keys() { + xml.push_str(&format!( + "{name}BlockBlob" + )); + } + xml.push_str(""); + xml.into_bytes() + } +} + +#[async_trait::async_trait] +impl HttpClient for FakeBlobService { + async fn execute_request(&self, request: &Request) -> azure_core::Result { + let mut state = self.state.lock().unwrap(); + let path = request.url().path().to_string(); + let query = request.url().query().unwrap_or_default().to_string(); + let if_none_match = request + .headers() + .get_optional_str(&IF_NONE_MATCH) + .map(str::to_owned); + state.requests.push(RecordedRequest { + method: request.method(), + path: path.clone(), + query: query.clone(), + if_none_match: if_none_match.clone(), + }); + if state.failing { + return Ok(Self::respond( + StatusCode::Forbidden, + Some("AuthorizationFailure"), + Vec::new(), + )); + } + let container_path = format!("/{CONTAINER}"); + let blob_name = path + .strip_prefix(&format!("{container_path}/")) + .map(str::to_owned); + let is_container = path == container_path && query.contains("restype=container"); + let response = match (request.method(), is_container, blob_name) { + (Method::Put, true, None) if state.container_exists => Self::respond( + StatusCode::Conflict, + Some("ContainerAlreadyExists"), + Vec::new(), + ), + (Method::Put, true, None) => { + state.container_exists = true; + Self::respond(StatusCode::Created, None, Vec::new()) + } + (Method::Get, true, None) if query.contains("comp=list") => { + Self::respond(StatusCode::Ok, None, Self::list_body(&state)) + } + (Method::Get, true, None) if state.container_exists => { + Self::respond(StatusCode::Ok, None, Vec::new()) + } + (Method::Get, true, None) => { + Self::respond(StatusCode::NotFound, Some("ContainerNotFound"), Vec::new()) + } + (Method::Put, false, Some(name)) => { + if if_none_match.as_deref() == Some("*") && state.blobs.contains_key(&name) { + Self::respond(StatusCode::Conflict, Some("BlobAlreadyExists"), Vec::new()) + } else { + let bytes = match request.body() { + Body::Bytes(bytes) => bytes.to_vec(), + Body::SeekableStream(_) => panic!("unexpected streaming upload"), + }; + state.blobs.insert(name, bytes); + Self::respond(StatusCode::Created, None, Vec::new()) + } + } + (Method::Get, false, Some(name)) => match state.blobs.get(&name) { + Some(bytes) => Self::respond(StatusCode::Ok, None, bytes.clone()), + None => Self::respond(StatusCode::NotFound, Some("BlobNotFound"), Vec::new()), + }, + (Method::Delete, false, Some(name)) => match state.blobs.remove(&name) { + Some(_) => Self::respond(StatusCode::Accepted, None, Vec::new()), + None => Self::respond(StatusCode::NotFound, Some("BlobNotFound"), Vec::new()), + }, + (method, _, _) => panic!("unexpected request {method:?} {path}?{query}"), + }; + Ok(response) + } +} + +struct Fixture { + runtime: Runtime, + service: FakeBlobService, + cache: Arc>, +} + +impl Fixture { + fn new(service: FakeBlobService) -> Self { + let runtime = Runtime::new().unwrap(); + let cache = runtime + .block_on(Self::connect(&service, runtime.handle().clone())) + .unwrap(); + Self { + runtime, + service, + cache: Arc::new(cache), + } + } + + async fn connect( + service: &FakeBlobService, + handle: tokio::runtime::Handle, + ) -> Result, Error> { + AzureBlobCache::connect_with_options( + ACCOUNT_URL, + CONTAINER, + None, + ClientOptions { + transport: Some(Transport::new(Arc::new(service.clone()))), + ..ClientOptions::default() + }, + ResponseCacheCodec, + handle, + ) + .await + } + + fn response_cache(&self) -> ResponseCache> { + ResponseCache::new(self.cache.clone()) + } + + fn stored_json(&self, key: &str) -> serde_json::Value { + serde_json::from_slice(&self.service.blob(key).expect("blob should exist")).unwrap() + } +} + +fn request(model: &str) -> ResponseCacheRequest { + ResponseCacheRequest::new(CacheKeyInput { + fields: vec![CacheKeyField { + name: "model".into(), + value: Some(model.into()), + api_parameter: true, + internal_parameter: false, + }], + preset: None, + namespace: None, + include_provider_parameters: false, + }) +} + +fn now() -> Duration { + Duration::from_secs(1_700_000_000) +} + +fn entry(value: serde_json::Value) -> CacheEntry { + CacheEntry { + timestamp: Some(1_700_000_000.5), + response: value, + } +} + +fn no_ttl() -> ExactCacheContext { + ExactCacheContext::default() +} + +fn with_ttl(seconds: u64) -> ExactCacheContext { + ExactCacheContext { + ttl: Some(Duration::from_secs(seconds)), + } +} + +#[test] +fn connect_creates_the_container_once() { + let fixture = Fixture::new(FakeBlobService::default()); + assert!(fixture.service.container_exists()); + assert_eq!( + fixture.service.requests(), + vec![RecordedRequest { + method: Method::Put, + path: format!("/{CONTAINER}"), + query: "restype=container".into(), + if_none_match: None, + }] + ); + assert_eq!(fixture.cache.account_url(), ACCOUNT_URL); + assert_eq!(fixture.cache.container_name(), CONTAINER); +} + +#[test] +fn connect_accepts_an_existing_container() { + let fixture = Fixture::new(FakeBlobService::with_existing_container()); + assert!(fixture.service.container_exists()); + assert_eq!(fixture.service.requests().len(), 1); +} + +#[test] +fn connect_accepts_account_urls_with_trailing_slash() { + let runtime = Runtime::new().unwrap(); + let service = FakeBlobService::default(); + let cache = runtime + .block_on(AzureBlobCache::connect_with_options( + "https://example.blob.core.windows.net/", + CONTAINER, + None, + ClientOptions { + transport: Some(Transport::new(Arc::new(service.clone()))), + ..ClientOptions::default() + }, + ResponseCacheCodec, + runtime.handle().clone(), + )) + .unwrap(); + assert_eq!(service.requests()[0].path, format!("/{CONTAINER}")); + assert_eq!(cache.account_url(), "https://example.blob.core.windows.net"); +} + +#[test] +fn connect_surfaces_service_failures() { + let runtime = Runtime::new().unwrap(); + let service = FakeBlobService::default(); + service.set_failing(true); + let result = runtime.block_on(Fixture::connect(&service, runtime.handle().clone())); + assert!(matches!(result, Err(Error::Unavailable))); +} + +#[test] +fn sync_set_and_get_round_trip_python_json_shape() { + let fixture = Fixture::new(FakeBlobService::default()); + let value = entry(json!({"choices": [{"message": {"content": "héllo 🌍"}}]})); + fixture + .cache + .set_cache("key-1", value.clone(), &no_ttl()) + .unwrap(); + + assert_eq!( + fixture.stored_json("key-1"), + json!({ + "timestamp": 1_700_000_000.5, + "response": {"choices": [{"message": {"content": "héllo 🌍"}}]} + }) + ); + assert_eq!( + fixture.cache.get_cache("key-1", &no_ttl()).unwrap(), + Some(value) + ); +} + +#[test] +fn sync_set_does_not_overwrite_an_existing_blob() { + let fixture = Fixture::new(FakeBlobService::default()); + fixture + .cache + .set_cache("key", entry(json!({"v": "first"})), &no_ttl()) + .unwrap(); + fixture + .cache + .set_cache("key", entry(json!({"v": "second"})), &no_ttl()) + .unwrap(); + + assert_eq!( + fixture.stored_json("key")["response"], + json!({"v": "first"}) + ); + let uploads: Vec<_> = fixture + .service + .requests() + .into_iter() + .filter(|request| request.method == Method::Put && request.path.ends_with("/key")) + .collect(); + assert_eq!(uploads.len(), 2); + assert!( + uploads + .iter() + .all(|request| request.if_none_match.as_deref() == Some("*")) + ); +} + +#[test] +fn async_set_overwrites_an_existing_blob() { + let fixture = Fixture::new(FakeBlobService::default()); + fixture.runtime.block_on(async { + fixture + .cache + .async_set_cache("key", entry(json!({"v": "first"})), no_ttl()) + .await + .unwrap(); + fixture + .cache + .async_set_cache("key", entry(json!({"v": "second"})), no_ttl()) + .await + .unwrap(); + assert_eq!( + fixture + .cache + .async_get_cache("key", &no_ttl()) + .await + .unwrap(), + Some(entry(json!({"v": "second"}))) + ); + }); + assert_eq!( + fixture.stored_json("key")["response"], + json!({"v": "second"}) + ); + assert!( + fixture + .service + .requests() + .iter() + .filter(|request| request.method == Method::Put && request.path.ends_with("/key")) + .all(|request| request.if_none_match.is_none()) + ); +} + +#[test] +fn missing_blobs_are_misses() { + let fixture = Fixture::new(FakeBlobService::default()); + assert_eq!(fixture.cache.get_cache("absent", &no_ttl()).unwrap(), None); + assert_eq!( + fixture + .runtime + .block_on(fixture.cache.async_get_cache("absent", &no_ttl())) + .unwrap(), + None + ); +} + +#[test] +fn ttl_is_ignored_and_entries_never_expire() { + let fixture = Fixture::new(FakeBlobService::default()); + assert_eq!(fixture.cache.get_ttl(&with_ttl(1)), None); + assert_eq!(fixture.cache.get_ttl(&no_ttl()), None); + + fixture + .cache + .set_cache("key", entry(json!("value")), &with_ttl(1)) + .unwrap(); + std::thread::sleep(Duration::from_millis(1100)); + assert_eq!( + fixture.cache.get_cache("key", &with_ttl(1)).unwrap(), + Some(entry(json!("value"))) + ); + assert!( + fixture + .service + .requests() + .iter() + .all(|request| !request.query.contains("expiry")) + ); +} + +#[test] +fn malformed_blobs_are_invalid_entries_and_response_cache_misses() { + let fixture = Fixture::new(FakeBlobService::default()); + fixture.service.seed_blob("broken-json", b"{not json"); + fixture + .service + .seed_blob("broken-utf8", &[0xff, 0xfe, 0x22]); + fixture + .service + .seed_blob("wrong-shape", br#"{"timestamp": "yesterday"}"#); + + for key in ["broken-json", "broken-utf8", "wrong-shape"] { + assert!(matches!( + fixture.cache.get_cache(key, &no_ttl()), + Err(Error::InvalidEntry) + )); + } + + let response_cache = fixture.response_cache(); + let broken = request("broken"); + fixture + .service + .seed_blob(&cache_key(&broken.key), b"{not json"); + assert_eq!(response_cache.lookup(&broken, now()).unwrap(), None); + assert_eq!( + fixture + .runtime + .block_on(response_cache.async_lookup(&broken, now())) + .unwrap(), + None + ); +} + +#[test] +fn batch_get_preserves_order_and_marks_misses_and_invalid_entries() { + let fixture = Fixture::new(FakeBlobService::default()); + fixture + .cache + .set_cache("a", entry(json!("A")), &no_ttl()) + .unwrap(); + fixture + .cache + .set_cache("c", entry(json!("C")), &no_ttl()) + .unwrap(); + fixture.service.seed_blob("bad", b"nope"); + let keys = ["c", "missing", "a", "bad"].map(String::from); + + let sync = fixture.cache.batch_get_cache(&keys, &no_ttl()).unwrap(); + assert_eq!( + sync, + vec![ + BatchEntry::Hit(entry(json!("C"))), + BatchEntry::Miss, + BatchEntry::Hit(entry(json!("A"))), + BatchEntry::Invalid, + ] + ); + + let asynchronous = fixture + .runtime + .block_on(fixture.cache.async_batch_get_cache(keys.to_vec(), no_ttl())) + .unwrap(); + assert_eq!(asynchronous, sync); + + let response_cache = fixture.response_cache(); + let requests = [request("hit"), request("missing"), request("bad")]; + response_cache + .store(&requests[0], json!("HIT"), now()) + .unwrap(); + fixture + .service + .seed_blob(&cache_key(&requests[2].key), b"nope"); + let hits = response_cache.lookup_batch(&requests, now()).unwrap(); + assert_eq!(hits.values, vec![Some(json!("HIT")), None, None]); + assert_eq!(hits.missing_indices, vec![1, 2]); + let async_hits = fixture + .runtime + .block_on(response_cache.async_lookup_batch(&requests, now())) + .unwrap(); + assert_eq!(async_hits.values, hits.values); +} + +#[test] +fn async_pipeline_writes_every_entry_with_overwrite() { + let fixture = Fixture::new(FakeBlobService::default()); + fixture.service.seed_blob("k2", b"stale"); + fixture + .runtime + .block_on(fixture.cache.async_set_cache_pipeline( + vec![ + ("k1".into(), entry(json!({"n": 1}))), + ("k2".into(), entry(json!({"n": 2}))), + ("k3".into(), entry(json!({"n": 3}))), + ], + with_ttl(30), + )) + .unwrap(); + assert_eq!(fixture.service.blob_names(), ["k1", "k2", "k3"]); + assert_eq!(fixture.stored_json("k2")["response"], json!({"n": 2})); +} + +#[test] +fn flush_deletes_every_blob_in_the_container() { + let fixture = Fixture::new(FakeBlobService::default()); + for key in ["x", "y", "z"] { + fixture + .cache + .set_cache(key, entry(json!(key)), &no_ttl()) + .unwrap(); + } + fixture.cache.flush_cache().unwrap(); + assert!(fixture.service.blob_names().is_empty()); + assert!(fixture.service.container_exists()); + + fixture + .cache + .set_cache("again", entry(json!(1)), &no_ttl()) + .unwrap(); + fixture + .runtime + .block_on(fixture.cache.async_flush_cache()) + .unwrap(); + assert!(fixture.service.blob_names().is_empty()); +} + +#[test] +fn service_failures_map_to_unavailable() { + let fixture = Fixture::new(FakeBlobService::default()); + fixture.service.set_failing(true); + assert!(matches!( + fixture.cache.get_cache("key", &no_ttl()), + Err(Error::Unavailable) + )); + assert!(matches!( + fixture.cache.set_cache("key", entry(json!(1)), &no_ttl()), + Err(Error::Unavailable) + )); + assert!(matches!( + fixture.cache.flush_cache(), + Err(Error::Unavailable) + )); + assert!(matches!( + fixture.runtime.block_on( + fixture + .cache + .async_set_cache_pipeline(vec![("k".into(), entry(json!(1)))], no_ttl()) + ), + Err(Error::Unavailable) + )); +} + +#[test] +fn test_connection_reports_container_reachability() { + let fixture = Fixture::new(FakeBlobService::default()); + let ok = fixture + .runtime + .block_on(fixture.cache.test_connection()) + .unwrap(); + assert_eq!(ok.status, CacheConnectionStatus::Success); + assert!(ok.error.is_none()); + + fixture.service.set_failing(true); + let failed = fixture + .runtime + .block_on(fixture.cache.test_connection()) + .unwrap(); + assert_eq!(failed.status, CacheConnectionStatus::Failed); + assert!(failed.error.is_some()); +} + +#[test] +fn disconnect_is_idempotent_and_keeps_data() { + let fixture = Fixture::new(FakeBlobService::default()); + fixture + .cache + .set_cache("key", entry(json!(1)), &no_ttl()) + .unwrap(); + fixture.runtime.block_on(async { + fixture.cache.disconnect().await.unwrap(); + fixture.cache.disconnect().await.unwrap(); + }); + assert_eq!( + fixture.cache.get_cache("key", &no_ttl()).unwrap(), + Some(entry(json!(1))) + ); +} + +#[test] +fn response_cache_stores_and_reads_through_the_backend() { + let fixture = Fixture::new(FakeBlobService::default()); + let response_cache = fixture.response_cache(); + let mut request = request("gpt"); + request.context = with_ttl(60); + let response = json!({"id": "chatcmpl-1"}); + response_cache + .store(&request, response.clone(), now()) + .unwrap(); + assert_eq!( + fixture.stored_json(&cache_key(&request.key)), + json!({"timestamp": 1_700_000_000.0, "response": {"id": "chatcmpl-1"}}) + ); + assert_eq!( + response_cache + .lookup(&request, now() + Duration::from_secs(3600)) + .unwrap(), + Some(response.clone()) + ); + assert_eq!( + fixture + .runtime + .block_on(response_cache.async_lookup(&request, now() + Duration::from_secs(3600))) + .unwrap(), + Some(response.clone()) + ); + fixture.runtime.block_on(async { + response_cache + .async_store(&request, json!("replaced"), now()) + .await + .unwrap(); + assert_eq!( + response_cache.async_lookup(&request, now()).await.unwrap(), + Some(json!("replaced")) + ); + response_cache.async_flush().await.unwrap(); + assert_eq!( + response_cache.async_lookup(&request, now()).await.unwrap(), + None + ); + }); +} + +#[test] +fn non_object_responses_are_written_serialized_like_python() { + let fixture = Fixture::new(FakeBlobService::default()); + fixture + .cache + .set_cache("s", entry(json!("plain")), &no_ttl()) + .unwrap(); + assert_eq!( + fixture.stored_json("s"), + json!({"timestamp": 1_700_000_000.5, "response": "\"plain\""}) + ); + assert_eq!( + fixture.cache.get_cache("s", &no_ttl()).unwrap(), + Some(entry(json!("plain"))) + ); +} diff --git a/litellm-rust/crates/cache-azure-blob/src/credential.rs b/litellm-rust/crates/cache-azure-blob/src/credential.rs new file mode 100644 index 00000000000..d1a3d0e44ec --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/src/credential.rs @@ -0,0 +1,84 @@ +use std::{ + fmt, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use azure_core::{ + credentials::{AccessToken, TokenCredential, TokenRequestOptions}, + error::ErrorKind, + time::OffsetDateTime, +}; +use litellm_auth_azure::{AzureAuthInputs, AzureAuthService}; +use litellm_auth_types::ResolvedCredential; + +const STATIC_TOKEN_LIFETIME: Duration = Duration::from_secs(300); +const LLM_TOKEN_ENV: &str = "AZURE_AD_TOKEN"; + +type EnvLookup = Arc Option + Send + Sync>; + +pub struct AzureBlobCredential { + service: AzureAuthService, + env_lookup: EnvLookup, +} + +impl fmt::Debug for AzureBlobCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AzureBlobCredential") + } +} + +impl Default for AzureBlobCredential { + fn default() -> Self { + Self::new( + AzureAuthService::default(), + Arc::new(|name| std::env::var(name).ok()), + ) + } +} + +impl AzureBlobCredential { + pub fn new(service: AzureAuthService, env_lookup: EnvLookup) -> Self { + Self { + service, + env_lookup, + } + } +} + +#[async_trait::async_trait] +impl TokenCredential for AzureBlobCredential { + async fn get_token( + &self, + scopes: &[&str], + _options: Option>, + ) -> azure_core::Result { + let env_lookup = &self.env_lookup; + let lookup = move |name: &str| (name != LLM_TOKEN_ENV).then(|| env_lookup(name)).flatten(); + let credential = self + .service + .get_azure_ad_token( + &AzureAuthInputs::default_credential_for_scope(&scopes.join(" ")), + &lookup, + ) + .await + .map_err(|error| { + azure_core::Error::with_message(ErrorKind::Credential, error.to_string()) + })? + .ok_or_else(|| { + azure_core::Error::with_message( + ErrorKind::Credential, + "no Azure credential is available for blob storage", + ) + })?; + let (token, expires_on) = match credential.into_value() { + ResolvedCredential::AccessToken { token, expires_on } => (token, expires_on), + ResolvedCredential::Static(token) => (token, None), + }; + let expires_on = expires_on.unwrap_or_else(|| SystemTime::now() + STATIC_TOKEN_LIFETIME); + Ok(AccessToken::new( + token.expose().to_string(), + OffsetDateTime::from(expires_on), + )) + } +} diff --git a/litellm-rust/crates/cache-azure-blob/src/lib.rs b/litellm-rust/crates/cache-azure-blob/src/lib.rs new file mode 100644 index 00000000000..5ae752c111d --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/src/lib.rs @@ -0,0 +1,5 @@ +mod cache; +mod credential; + +pub use cache::AzureBlobCache; +pub use credential::AzureBlobCredential; diff --git a/litellm-rust/crates/cache-azure-blob/src/tests.rs b/litellm-rust/crates/cache-azure-blob/src/tests.rs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm-rust/crates/python-bridge/Cargo.toml b/litellm-rust/crates/python-bridge/Cargo.toml index 1eb2ec28036..7e48874a30b 100644 --- a/litellm-rust/crates/python-bridge/Cargo.toml +++ b/litellm-rust/crates/python-bridge/Cargo.toml @@ -21,6 +21,7 @@ tiktoken = ["litellm-token-counter/tiktoken"] [dependencies] bytes.workspace = true litellm-cache.workspace = true +litellm-cache-azure-blob.workspace = true litellm-cache-memory.workspace = true litellm-cache-redis.workspace = true litellm-cache-response.workspace = true diff --git a/litellm-rust/crates/python-bridge/src/cache/config.rs b/litellm-rust/crates/python-bridge/src/cache/config.rs index 0e7d6aee11d..91f56157bdc 100644 --- a/litellm-rust/crates/python-bridge/src/cache/config.rs +++ b/litellm-rust/crates/python-bridge/src/cache/config.rs @@ -73,9 +73,15 @@ pub(super) struct RedisCacheConfig { pub(super) connection: RedisConnectionConfig, } +pub(super) struct AzureBlobCacheConfig { + pub(super) account_url: String, + pub(super) container: String, +} + pub(super) enum CacheBackendConfig { Memory(MemoryCacheConfig), Redis(Box), + AzureBlob(AzureBlobCacheConfig), } #[allow(dead_code, reason = "consumed by the cache activation follow-up")] @@ -142,13 +148,18 @@ impl NativeCacheConfig { }))), Err(reason) => Ok(CacheConfigProjection::Unsupported(reason)), }, + Some(CacheType::AzureBlob) => project_azure_blob(&backend).map(|backend| { + CacheConfigProjection::Native(Box::new(Self { + policy, + backend: CacheBackendConfig::AzureBlob(backend), + })) + }), Some( CacheType::RedisSemantic | CacheType::ValkeySemantic | CacheType::S3 | CacheType::Disk | CacheType::QdrantSemantic - | CacheType::AzureBlob | CacheType::Gcs, ) | None => Ok(CacheConfigProjection::Unsupported( @@ -158,12 +169,12 @@ impl NativeCacheConfig { } pub(super) fn service_mismatch(&self, service: &NativeResponseCache) -> Option<&'static str> { - if service.default_ttl() - != Some(match &self.backend { - CacheBackendConfig::Memory(config) => config.default_ttl, - CacheBackendConfig::Redis(config) => config.default_ttl, - }) - { + let default_ttl = match &self.backend { + CacheBackendConfig::Memory(config) => Some(config.default_ttl), + CacheBackendConfig::Redis(config) => Some(config.default_ttl), + CacheBackendConfig::AzureBlob(_) => None, + }; + if service.default_ttl() != default_ttl { return Some("facade and native backend default TTLs must match"); } match &self.backend { @@ -185,10 +196,34 @@ impl NativeCacheConfig { CacheBackendConfig::Redis(config) => (service.namespace() != config.namespace.as_deref()) .then_some("facade and native backend namespaces must match"), + CacheBackendConfig::AzureBlob(config) => match service.azure_blob_identity() { + None => Some("facade and native backend types must match"), + Some((account_url, container)) + if account_url != config.account_url || container != config.container => + { + Some("facade and native backend containers must match") + } + Some(_) => None, + }, } } } +#[inline(never)] +fn project_azure_blob(backend: &Bound<'_, PyAny>) -> PyResult { + let client = backend.getattr("container_client")?; + let container = client.getattr("container_name")?.extract::()?; + let url = client.getattr("url")?.extract::()?; + let account_url = url + .strip_suffix(container.as_str()) + .and_then(|url| url.strip_suffix('/')) + .ok_or_else(|| PyValueError::new_err("Azure Blob container URL is malformed"))?; + Ok(AzureBlobCacheConfig { + account_url: account_url.to_string(), + container, + }) +} + #[inline(never)] fn project_memory(backend: &Bound<'_, PyAny>) -> PyResult { let max_size_kib = backend.getattr("max_size_per_item")?.extract::()?; diff --git a/litellm-rust/crates/python-bridge/src/cache/facade.rs b/litellm-rust/crates/python-bridge/src/cache/facade.rs index f2f86c14b37..1df6312503f 100644 --- a/litellm-rust/crates/python-bridge/src/cache/facade.rs +++ b/litellm-rust/crates/python-bridge/src/cache/facade.rs @@ -32,10 +32,23 @@ struct RedisPoolGuard { max_connections: usize, } +struct AzureBlobClientGuard { + sync_client: Py, + async_client: Py, + url: String, + container_name: String, +} + +enum ConnectionGuard { + None, + RedisPool(RedisPoolGuard), + AzureBlob(AzureBlobClientGuard), +} + pub(super) struct FacadeGuard { outer: ObjectGuard, backend: ObjectGuard, - redis_pool: Option, + connection: ConnectionGuard, } impl ObjectGuard { @@ -176,6 +189,60 @@ impl RedisPoolGuard { } } +impl AzureBlobClientGuard { + fn capture(backend: &Bound<'_, PyAny>) -> PyResult { + let sync_client = backend.getattr("container_client")?; + Ok(Self { + url: sync_client.getattr("url")?.extract::()?, + container_name: sync_client.getattr("container_name")?.extract::()?, + sync_client: sync_client.unbind(), + async_client: backend.getattr("async_container_client")?.unbind(), + }) + } + + fn matches(&self, py: Python<'_>, backend: &Bound<'_, PyAny>) -> PyResult { + let sync_client = backend.getattr("container_client")?; + Ok(self.sync_client.bind(py).is(&sync_client) + && self + .async_client + .bind(py) + .is(&backend.getattr("async_container_client")?) + && self.url == sync_client.getattr("url")?.extract::()? + && self.container_name == sync_client.getattr("container_name")?.extract::()?) + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + visit.call(&self.sync_client)?; + visit.call(&self.async_client) + } +} + +impl ConnectionGuard { + fn capture(kind: &str, backend: &Bound<'_, PyAny>) -> PyResult { + Ok(match kind { + "redis" => Self::RedisPool(RedisPoolGuard::capture(backend)?), + "azure-blob" => Self::AzureBlob(AzureBlobClientGuard::capture(backend)?), + _ => Self::None, + }) + } + + fn matches(&self, py: Python<'_>, backend: &Bound<'_, PyAny>) -> PyResult { + match self { + Self::None => Ok(true), + Self::RedisPool(guard) => guard.matches(py, backend), + Self::AzureBlob(guard) => guard.matches(py, backend), + } + } + + fn traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> { + match self { + Self::None => Ok(()), + Self::RedisPool(guard) => guard.traverse(visit), + Self::AzureBlob(guard) => guard.traverse(visit), + } + } +} + impl FacadeGuard { pub(super) fn capture( py: Python<'_>, @@ -192,6 +259,11 @@ impl FacadeGuard { let (module, name, cache_kind) = match kind { "memory" => ("litellm.caching.in_memory_cache", "InMemoryCache", "local"), "redis" => ("litellm.caching.redis_cache", "RedisCache", "redis"), + "azure-blob" => ( + "litellm.caching.azure_blob_cache", + "AzureBlobCache", + "azure-blob", + ), _ => unreachable!(), }; let backend = facade.getattr("cache")?; @@ -237,9 +309,7 @@ impl FacadeGuard { "redis_flush_size", ], )?, - redis_pool: (kind == "redis") - .then(|| RedisPoolGuard::capture(&backend)) - .transpose()?, + connection: ConnectionGuard::capture(kind, &backend)?, }) } @@ -251,19 +321,13 @@ impl FacadeGuard { if !self.backend.matches(py, &backend)? { return Ok(false); } - match &self.redis_pool { - Some(guard) => guard.matches(py, &backend), - None => Ok(true), - } + self.connection.matches(py, &backend) } pub(super) fn traverse(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { self.outer.traverse(&visit)?; self.backend.traverse(&visit)?; - if let Some(guard) = &self.redis_pool { - guard.traverse(&visit)?; - } - Ok(()) + self.connection.traverse(&visit) } } diff --git a/litellm-rust/crates/python-bridge/src/cache/handle.rs b/litellm-rust/crates/python-bridge/src/cache/handle.rs index 8251b3df06c..69988980d64 100644 --- a/litellm-rust/crates/python-bridge/src/cache/handle.rs +++ b/litellm-rust/crates/python-bridge/src/cache/handle.rs @@ -1,4 +1,4 @@ -use litellm_host_python::release_gil; +use litellm_host_python::{release_gil, run_sync_value}; use pyo3::{PyTraverseError, PyVisit, exceptions::PyRuntimeError, prelude::*}; use super::{cache_error, facade::FacadeGuard, native::NativeResponseCache, request::duration}; @@ -51,6 +51,21 @@ impl CacheTestHandle { }) } + #[staticmethod] + #[pyo3(signature = (account_url, container))] + fn azure_blob(py: Python<'_>, account_url: String, container: String) -> PyResult { + let service = run_sync_value(py, async move { + NativeResponseCache::azure_blob(&account_url, &container) + .await + .map_err(cache_error) + })?; + Ok(Self { + service, + guard: None, + pid: std::process::id(), + }) + } + #[getter] fn backend(&self) -> &'static str { self.service.kind() diff --git a/litellm-rust/crates/python-bridge/src/cache/native.rs b/litellm-rust/crates/python-bridge/src/cache/native.rs index a9475429e45..08c65905e84 100644 --- a/litellm-rust/crates/python-bridge/src/cache/native.rs +++ b/litellm-rust/crates/python-bridge/src/cache/native.rs @@ -1,6 +1,7 @@ use std::{sync::Arc, time::Duration}; use litellm_cache::{CacheCodec, CacheConnectionResult, Error}; +use litellm_cache_azure_blob::AzureBlobCache; use litellm_cache_memory::InMemoryCache; use litellm_cache_redis::RedisCache; use litellm_cache_response::{ @@ -15,6 +16,7 @@ pub(super) enum NativeResponseCache { cache: Arc>>, buffer: Option>, }, + AzureBlob(Arc>>), } impl NativeResponseCache { @@ -43,6 +45,29 @@ impl NativeResponseCache { buffer: None, }) } + + pub async fn azure_blob(account_url: &str, container: &str) -> Result { + let backend = AzureBlobCache::connect( + account_url, + container, + ResponseCacheCodec, + tokio::runtime::Handle::current(), + ) + .await?; + Ok(Self::AzureBlob(Arc::new(ResponseCache::new(Arc::new( + backend, + ))))) + } + + pub fn azure_blob_identity(&self) -> Option<(&str, &str)> { + match self { + Self::AzureBlob(cache) => Some(( + cache.backend().account_url(), + cache.backend().container_name(), + )), + Self::Memory(_) | Self::Redis { .. } => None, + } + } } impl NativeResponseCache { @@ -50,6 +75,7 @@ impl NativeResponseCache { match self { Self::Memory(_) => "memory", Self::Redis { .. } => "redis", + Self::AzureBlob(_) => "azure-blob", } } @@ -57,12 +83,13 @@ impl NativeResponseCache { match self { Self::Memory(cache) => cache.default_ttl(), Self::Redis { cache, .. } => cache.default_ttl(), + Self::AzureBlob(cache) => cache.default_ttl(), } } pub fn namespace(&self) -> Option<&str> { match self { - Self::Memory(_) => None, + Self::Memory(_) | Self::AzureBlob(_) => None, Self::Redis { cache, .. } => cache.backend().namespace(), } } @@ -70,14 +97,14 @@ impl NativeResponseCache { pub fn capacity(&self) -> Option { match self { Self::Memory(cache) => Some(cache.backend().max_size_in_memory()), - Self::Redis { .. } => None, + Self::Redis { .. } | Self::AzureBlob(_) => None, } } pub fn max_entry_bytes(&self) -> Option { match self { Self::Memory(cache) => cache.backend().max_entry_bytes(), - Self::Redis { .. } => None, + Self::Redis { .. } | Self::AzureBlob(_) => None, } } @@ -87,7 +114,7 @@ impl NativeResponseCache { cache, buffer: flush_size.map(|flush_size| Arc::new(WriteBuffer::new(flush_size))), }, - memory => memory, + other => other, } } @@ -99,6 +126,7 @@ impl NativeResponseCache { match self { Self::Memory(cache) => cache.lookup(request, now), Self::Redis { cache, .. } => cache.lookup(request, now), + Self::AzureBlob(cache) => cache.lookup(request, now), } } @@ -111,6 +139,7 @@ impl NativeResponseCache { match self { Self::Memory(cache) => cache.store(request, response, now), Self::Redis { cache, .. } => cache.store(request, response, now), + Self::AzureBlob(cache) => cache.store(request, response, now), } } @@ -122,6 +151,7 @@ impl NativeResponseCache { match self { Self::Memory(cache) => cache.lookup_batch(requests, now), Self::Redis { cache, .. } => cache.lookup_batch(requests, now), + Self::AzureBlob(cache) => cache.lookup_batch(requests, now), } } @@ -133,6 +163,7 @@ impl NativeResponseCache { match self { Self::Memory(cache) => cache.async_lookup(request, now).await, Self::Redis { cache, .. } => cache.async_lookup(request, now).await, + Self::AzureBlob(cache) => cache.async_lookup(request, now).await, } } @@ -152,6 +183,7 @@ impl NativeResponseCache { cache, buffer: Some(buffer), } => buffer.async_store(cache, request, response, now).await, + Self::AzureBlob(cache) => cache.async_store(request, response, now).await, } } @@ -163,6 +195,7 @@ impl NativeResponseCache { match self { Self::Memory(cache) => cache.async_lookup_batch(requests, now).await, Self::Redis { cache, .. } => cache.async_lookup_batch(requests, now).await, + Self::AzureBlob(cache) => cache.async_lookup_batch(requests, now).await, } } @@ -174,6 +207,7 @@ impl NativeResponseCache { match self { Self::Memory(cache) => cache.async_store_batch(entries, now).await, Self::Redis { cache, .. } => cache.async_store_batch(entries, now).await, + Self::AzureBlob(cache) => cache.async_store_batch(entries, now).await, } } @@ -186,6 +220,7 @@ impl NativeResponseCache { } cache.async_flush().await } + Self::AzureBlob(cache) => cache.async_flush().await, } } @@ -193,6 +228,7 @@ impl NativeResponseCache { match self { Self::Memory(cache) => cache.test_connection().await, Self::Redis { cache, .. } => cache.test_connection().await, + Self::AzureBlob(cache) => cache.test_connection().await, } } } diff --git a/tests/test_litellm_rust/test_cache.py b/tests/test_litellm_rust/test_cache.py index c35cb1a20fb..99e371d8c2e 100644 --- a/tests/test_litellm_rust/test_cache.py +++ b/tests/test_litellm_rust/test_cache.py @@ -2,8 +2,10 @@ import asyncio import contextvars import gc import json +import os import threading import time +import uuid import weakref from collections.abc import Generator from types import SimpleNamespace @@ -13,8 +15,10 @@ from urllib.parse import urlparse import fakeredis import pytest import redis +from azure.storage.blob import ContainerClient import litellm +from litellm.caching.azure_blob_cache import AzureBlobCache from litellm.caching.caching import Cache, disable_cache, enable_cache, update_cache from litellm.caching.in_memory_cache import InMemoryCache from litellm.rust_bridge import _native @@ -45,6 +49,36 @@ def redis_url() -> Generator[str]: worker.join(timeout=5) +@pytest.fixture +def azure_blob_facade() -> Generator[Cache]: + account_url: Final = os.environ.get("AZURE_BLOB_CACHE_ACCOUNT_URL") + if account_url is None: + pytest.skip( + "live Azure Blob parity needs AZURE_BLOB_CACHE_ACCOUNT_URL plus DefaultAzureCredential inputs in the environment" + ) + facade: Final = Cache( + type=LiteLLMCacheType.AZURE_BLOB, + azure_account_url=account_url, + azure_blob_container=f"litellm-parity-{uuid.uuid4().hex[:12]}", + ) + backend: Final = facade.cache + assert isinstance(backend, AzureBlobCache) + try: + yield facade + finally: + backend.container_client.delete_container() + asyncio.run(backend.disconnect()) + + +def azure_blob_handle(facade: Cache) -> _native._CacheTestHandle: + backend: Final = facade.cache + assert isinstance(backend, AzureBlobCache) + return _native._CacheTestHandle.azure_blob( + backend.container_client.url.removesuffix(f"/{backend.container_client.container_name}"), + backend.container_client.container_name, + ) + + def test_existing_constructor_and_global_are_unchanged() -> None: facade: Final = Cache(type=LiteLLMCacheType.LOCAL) assert type(facade.cache) is InMemoryCache @@ -361,6 +395,89 @@ def test_facade_registration_rejects_mismatched_capacity() -> None: _native._CacheTestHandle.memory(capacity=7)._bind_facade(facade) +def test_azure_blob_facade_serves_natively_and_python_reads_the_same_blobs(azure_blob_facade: Cache) -> None: + backend: Final = azure_blob_facade.cache + assert isinstance(backend, AzureBlobCache) + handle: Final = azure_blob_handle(azure_blob_facade) + assert handle.backend == "azure-blob" + account_url: Final = backend.container_client.url.removesuffix(f"/{backend.container_client.container_name}") + with pytest.raises(TypeError, match="containers must match"): + _native._CacheTestHandle.azure_blob(account_url, f"{backend.container_client.container_name}-other")._bind_facade( + azure_blob_facade + ) + handle._bind_facade(azure_blob_facade) + resolver: Final = _native._CacheTestResolver(SimpleNamespace(cache=azure_blob_facade)) + native: Final = resolver.resolve() + assert native.kind == "native" + + response: Final = {"choices": [{"text": "caf\u00e9 \u2603"}], "usage": {"total_tokens": 3}, "flag": True, "empty": None} + native.store({**request("sync"), "ttl_seconds": 0.001}, response) + native.store(request("sync"), {"choices": [{"text": "second"}]}) + time.sleep(0.01) + stored: Final = json.loads(backend.container_client.download_blob("sync").readall()) + assert stored["response"] == response + assert isinstance(stored["timestamp"], float) + assert native.lookup(request("sync")) == response + assert cast(CacheLookup, azure_blob_facade).get_cache(cache_key="sync") == response + + backend.set_cache("python", {"timestamp": time.time(), "response": response}) + backend.set_cache("legacy", "bare legacy value") + backend.container_client.upload_blob("invalid", b"{not json", overwrite=True) + assert native.lookup(request("python")) == response + assert native.lookup(request("legacy")) == cast(CacheLookup, azure_blob_facade).get_cache(cache_key="legacy") + assert native.lookup_batch([request("python"), request("missing"), request("invalid"), request("sync")]) == { + "values": [response, None, None, response], + "missing_indices": [1, 2], + } + + with rebound(azure_blob_facade, "ttl", 12): + assert resolver.resolve().kind == "python_callback" + with rebound(backend, "container_client", ContainerClient.from_container_url(backend.container_client.url)): + assert resolver.resolve().kind == "python_callback" + + def custom_get(*_args: object, **_kwargs: object) -> None: + return None + + with rebound(backend, "get_cache", custom_get): + assert resolver.resolve().kind == "python_callback" + assert resolver.resolve().kind == "python_callback" + assert cast(CacheLookup, azure_blob_facade).get_cache(cache_key="sync") == response + + class CustomBlobCache(AzureBlobCache): + pass + + with rebound(azure_blob_facade, "cache", CustomBlobCache(account_url, backend.container_client.container_name)): + assert resolver.resolve().kind == "python_callback" + with pytest.raises(TypeError): + azure_blob_handle(azure_blob_facade)._bind_facade(azure_blob_facade) + + +async def test_azure_blob_native_async_writes_overwrite_batch_and_flush_like_python(azure_blob_facade: Cache) -> None: + backend: Final = azure_blob_facade.cache + assert isinstance(backend, AzureBlobCache) + azure_blob_handle(azure_blob_facade)._bind_facade(azure_blob_facade) + binding: Final = _native._CacheTestResolver(SimpleNamespace(cache=azure_blob_facade)).resolve() + assert binding.kind == "native" + ping: Final = cast(dict[str, object], await binding.ping()) + assert ping["status"] == "success", ping + + await binding.async_store(request("async"), {"value": 1}) + await binding.async_store({**request("async"), "ttl_seconds": 0.001}, {"value": 2}) + time.sleep(0.01) + assert await binding.async_lookup(request("async")) == {"value": 2} + assert await backend.async_get_cache("async") == json.loads(backend.container_client.download_blob("async").readall()) + assert cast(CacheLookup, azure_blob_facade).get_cache(cache_key="async") == {"value": 2} + + await binding.async_store_batch([request("first"), request("second")], [{"value": 3}, {"value": 4}]) + assert await binding.async_lookup_batch([request("second"), request("missing"), request("first")]) == { + "values": [{"value": 4}, None, {"value": 3}], + "missing_indices": [1], + } + await binding.async_flush() + assert [blob.name for blob in backend.container_client.list_blobs()] == [] + assert await binding.async_lookup(request("async")) is None + + async def test_redis_facade_buffers_native_async_writes(redis_url: str) -> None: parsed: Final = urlparse(redis_url) with rebound(litellm, "default_redis_ttl", 60):