mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
refactor rust cache option extraction
This commit is contained in:
parent
177685dff7
commit
6e1e8d34a1
1 changed files with 351 additions and 0 deletions
351
litellm-rust/crates/python-bridge/src/cache.rs
Normal file
351
litellm-rust/crates/python-bridge/src/cache.rs
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyBool, PyDict};
|
||||
|
||||
use litellm_cache::{
|
||||
BaseCache, CacheControls, CacheEntry, CacheKeyContext, CacheKeyField, CacheKeyInput, cache_key,
|
||||
};
|
||||
use litellm_cache_memory::InMemoryCache;
|
||||
use litellm_core::call_lifecycle::cache::ResponseCachePlan;
|
||||
|
||||
#[pyclass]
|
||||
struct NativeResponseCache {
|
||||
cache: Arc<InMemoryCache<CacheEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CacheOptions {
|
||||
no_cache: bool,
|
||||
no_store: bool,
|
||||
use_cache: bool,
|
||||
ttl: Option<f64>,
|
||||
max_age: Option<f64>,
|
||||
}
|
||||
|
||||
impl FromPyObject<'_, '_> for CacheOptions {
|
||||
type Error = PyErr;
|
||||
|
||||
fn extract(obj: Borrowed<'_, '_, PyAny>) -> PyResult<Self> {
|
||||
let values = obj.cast::<PyDict>()?.to_owned();
|
||||
Ok(Self {
|
||||
no_cache: is_true(&values, "no-cache")?,
|
||||
no_store: is_true(&values, "no-store")?,
|
||||
use_cache: is_true(&values, "use-cache")?,
|
||||
ttl: values
|
||||
.get_item("ttl")?
|
||||
.filter(|value| !value.is_none())
|
||||
.map(|value| value.extract())
|
||||
.transpose()?,
|
||||
max_age: values
|
||||
.get_item("s-max-age")?
|
||||
.or(values.get_item("s-maxage")?)
|
||||
.filter(|value| !value.is_none())
|
||||
.map(|value| value.extract())
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(
|
||||
py: Python<'_>,
|
||||
call_type: &str,
|
||||
kwargs: &Bound<'_, PyDict>,
|
||||
) -> PyResult<CacheControls> {
|
||||
let configured = py.import("litellm")?.getattr("cache")?;
|
||||
let standard = configured
|
||||
.get_type()
|
||||
.is(&py.import("litellm.caching.caching")?.getattr("Cache")?);
|
||||
let options = cache_options(kwargs.get_item("cache")?)?;
|
||||
let native_backend = standard
|
||||
&& configured.getattr("cache")?.get_type().is(&py
|
||||
.import("litellm.caching.in_memory_cache")?
|
||||
.getattr("InMemoryCache")?);
|
||||
let supported_call_type = if standard {
|
||||
let supported = configured.getattr("supported_call_types")?;
|
||||
!supported.is_none()
|
||||
&& supported
|
||||
.extract::<Vec<String>>()?
|
||||
.iter()
|
||||
.any(|value| value == call_type)
|
||||
} else {
|
||||
!matches!(call_type, "ocr" | "aocr")
|
||||
};
|
||||
Ok(CacheControls {
|
||||
configured: !configured.is_none(),
|
||||
supported_call_type,
|
||||
native_backend,
|
||||
default_on: !standard || configured.getattr("mode")?.extract::<String>()? == "default_on",
|
||||
caching: kwargs
|
||||
.get_item("caching")?
|
||||
.filter(|value| !value.is_none())
|
||||
.map(|value| value.extract())
|
||||
.transpose()?,
|
||||
no_cache: options.no_cache,
|
||||
no_store: options.no_store,
|
||||
use_cache: options.use_cache,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn plan(
|
||||
py: Python<'_>,
|
||||
call_type: &str,
|
||||
kwargs: &Bound<'_, PyDict>,
|
||||
) -> PyResult<ResponseCachePlan> {
|
||||
let controls = snapshot(py, call_type, kwargs)?;
|
||||
ResponseCachePlan {
|
||||
controls,
|
||||
..Default::default()
|
||||
}
|
||||
.admit()
|
||||
.map_err(|error| PyValueError::new_err(error.to_string()))?;
|
||||
if !controls.reads() && !controls.writes() {
|
||||
return Ok(ResponseCachePlan {
|
||||
controls,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let configured = py.import("litellm")?.getattr("cache")?;
|
||||
let backend = Some(native_cache(&configured)? as Arc<dyn BaseCache<Value = CacheEntry>>);
|
||||
let options = cache_options(kwargs.get_item("cache")?)?;
|
||||
let duration = |seconds: Option<f64>| -> PyResult<Option<Duration>> {
|
||||
seconds
|
||||
.map(Duration::try_from_secs_f64)
|
||||
.transpose()
|
||||
.map_err(|_| PyValueError::new_err("cache duration must be finite and nonnegative"))
|
||||
};
|
||||
Ok(ResponseCachePlan {
|
||||
controls,
|
||||
backend,
|
||||
key: project_key(py, &configured, kwargs)?,
|
||||
ttl: duration(options.ttl.or(configured.getattr("ttl")?.extract()?))?,
|
||||
max_age: duration(options.max_age)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn native_cache(configured: &Bound<'_, PyAny>) -> PyResult<Arc<InMemoryCache<CacheEntry>>> {
|
||||
let attributes = configured.getattr("__dict__")?.cast_into::<PyDict>()?;
|
||||
let native = match attributes.get_item("_litellm_native_response_cache")? {
|
||||
Some(native) => native.cast_into::<NativeResponseCache>()?,
|
||||
None => {
|
||||
let native = Bound::new(
|
||||
configured.py(),
|
||||
NativeResponseCache {
|
||||
cache: Arc::new(InMemoryCache::response_cache(
|
||||
200,
|
||||
Duration::from_secs(600),
|
||||
1024 * 1024,
|
||||
)),
|
||||
},
|
||||
)?;
|
||||
attributes.set_item("_litellm_native_response_cache", &native)?;
|
||||
native
|
||||
}
|
||||
};
|
||||
Ok(native.borrow().cache.clone())
|
||||
}
|
||||
|
||||
fn dictionary<'py>(
|
||||
value: Option<Bound<'py, PyAny>>,
|
||||
py: Python<'py>,
|
||||
) -> PyResult<Bound<'py, PyDict>> {
|
||||
match value.filter(|value| !value.is_none()) {
|
||||
Some(value) => Ok(value.cast_into::<PyDict>()?),
|
||||
None => Ok(PyDict::new(py)),
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_options<'py>(value: Option<Bound<'py, PyAny>>) -> PyResult<CacheOptions> {
|
||||
value
|
||||
.filter(|value| !value.is_none())
|
||||
.map(|value| value.extract())
|
||||
.transpose()
|
||||
.map(|options| options.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn is_true(values: &Bound<'_, PyDict>, name: &str) -> PyResult<bool> {
|
||||
Ok(values
|
||||
.get_item(name)?
|
||||
.is_some_and(|value| value.is(&PyBool::new(values.py(), true))))
|
||||
}
|
||||
|
||||
fn optional_text(value: Option<Bound<'_, PyAny>>) -> PyResult<Option<String>> {
|
||||
value
|
||||
.filter(|value| !value.is_none())
|
||||
.map(|value| Ok(value.str()?.to_str()?.to_owned()))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn text_field(values: &Bound<'_, PyDict>, name: &str) -> PyResult<Option<String>> {
|
||||
Ok(optional_text(values.get_item(name)?)?.filter(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
fn project_key(
|
||||
py: Python<'_>,
|
||||
configured: &Bound<'_, PyAny>,
|
||||
kwargs: &Bound<'_, PyDict>,
|
||||
) -> PyResult<String> {
|
||||
let parameters = dictionary(kwargs.get_item("litellm_params")?, py)?;
|
||||
if let Some(preset) = optional_text(parameters.get_item("preset_cache_key")?)? {
|
||||
return Ok(cache_key(&CacheKeyInput {
|
||||
preset: Some(preset),
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
let api_params: std::collections::HashSet<String> = py
|
||||
.import("litellm.litellm_core_utils.model_param_helper")?
|
||||
.getattr("ModelParamHelper")?
|
||||
.call_method0("_get_all_llm_api_params")?
|
||||
.extract()?;
|
||||
let internal: Vec<String> = py
|
||||
.import("litellm.types.utils")?
|
||||
.getattr("all_litellm_params")?
|
||||
.extract()?;
|
||||
let include_provider_parameters: bool = py
|
||||
.import("litellm")?
|
||||
.getattr("enable_caching_on_provider_specific_optional_params")?
|
||||
.extract()?;
|
||||
let mut fields = Vec::new();
|
||||
for (name, value) in kwargs.iter() {
|
||||
let name: String = name.extract()?;
|
||||
let api_parameter = api_params.contains(&name);
|
||||
let internal_parameter = internal.contains(&name);
|
||||
if !api_parameter && (!include_provider_parameters || internal_parameter) {
|
||||
continue;
|
||||
}
|
||||
let value = if name == "file" {
|
||||
None
|
||||
} else {
|
||||
optional_text(Some(value))?
|
||||
};
|
||||
fields.push(CacheKeyField {
|
||||
name,
|
||||
value,
|
||||
api_parameter,
|
||||
internal_parameter,
|
||||
});
|
||||
}
|
||||
let metadata = dictionary(kwargs.get_item("metadata")?, py)?;
|
||||
let nested_metadata = dictionary(parameters.get_item("metadata")?, py)?;
|
||||
let dynamic = dictionary(kwargs.get_item("cache")?, py)?;
|
||||
let mut input = CacheKeyInput {
|
||||
fields,
|
||||
namespace: text_field(&dynamic, "namespace")?
|
||||
.or(text_field(&metadata, "redis_namespace")?)
|
||||
.or(optional_text(Some(configured.getattr("namespace")?))?),
|
||||
include_provider_parameters,
|
||||
..Default::default()
|
||||
};
|
||||
let mut context = CacheKeyContext {
|
||||
model_group: text_field(&metadata, "model_group")?
|
||||
.or(text_field(&nested_metadata, "model_group")?),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(groups) = metadata
|
||||
.get_item("caching_groups")?
|
||||
.filter(|value| !value.is_none())
|
||||
{
|
||||
for group in groups.try_iter()? {
|
||||
let group = group?;
|
||||
context
|
||||
.caching_groups
|
||||
.push((group.extract()?, group.str()?.to_str()?.to_owned()));
|
||||
}
|
||||
}
|
||||
if let Some(file) = kwargs.get_item("file")? {
|
||||
context.file_checksum = text_field(&metadata, "file_checksum")?;
|
||||
if context.file_checksum.is_none() {
|
||||
context.file_object_name = optional_text(
|
||||
file.getattr("name")
|
||||
.or_else(|error| {
|
||||
if error.is_instance_of::<pyo3::exceptions::PyAttributeError>(py) {
|
||||
Ok(py.None().into_bound(py))
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})
|
||||
.map(Some)?,
|
||||
)?;
|
||||
}
|
||||
context.metadata_file_name = text_field(&metadata, "file_name")?;
|
||||
context.parameters_file_name = text_field(¶meters, "file_name")?;
|
||||
}
|
||||
context.apply(&mut input);
|
||||
Ok(cache_key(&input))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cache_options_extracts_controls_and_max_age_alias() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let values = PyDict::new(py);
|
||||
values.set_item("no-cache", true).unwrap();
|
||||
values.set_item("use-cache", true).unwrap();
|
||||
values.set_item("ttl", 12.5).unwrap();
|
||||
values.set_item("s-maxage", 3.0).unwrap();
|
||||
|
||||
let options: CacheOptions = values.extract().unwrap();
|
||||
|
||||
assert!(options.no_cache);
|
||||
assert!(!options.no_store);
|
||||
assert!(options.use_cache);
|
||||
assert_eq!(options.ttl, Some(12.5));
|
||||
assert_eq!(options.max_age, Some(3.0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_storage_is_reused_without_replacing_python_storage() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let owner = py
|
||||
.import("types")
|
||||
.unwrap()
|
||||
.getattr("SimpleNamespace")
|
||||
.unwrap()
|
||||
.call0()
|
||||
.unwrap();
|
||||
let python_storage = PyDict::new(py);
|
||||
python_storage.set_item("python-only", "retained").unwrap();
|
||||
owner.setattr("cache", &python_storage).unwrap();
|
||||
let first = native_cache(&owner).unwrap();
|
||||
first
|
||||
.set_cache(
|
||||
"native-only",
|
||||
CacheEntry {
|
||||
timestamp: 1.0,
|
||||
response: serde_json::json!("native"),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let second = native_cache(&owner).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
assert!(second.get_cache("native-only").unwrap().is_some());
|
||||
assert!(owner.getattr("cache").unwrap().is(&python_storage));
|
||||
assert!(!python_storage.contains("native-only").unwrap());
|
||||
second.flush_cache().unwrap();
|
||||
assert_eq!(
|
||||
python_storage
|
||||
.get_item("python-only")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.extract::<String>()
|
||||
.unwrap(),
|
||||
"retained"
|
||||
);
|
||||
let weak = Arc::downgrade(&first);
|
||||
drop(first);
|
||||
drop(second);
|
||||
assert!(weak.upgrade().is_some());
|
||||
drop(owner);
|
||||
assert!(weak.upgrade().is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue