mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-27 01:22:18 +00:00
* refactor(rust): align the cache crates with Python and activate every backend The cache port had drifted: lifecycle and Redis-only operations sat on `BaseCache`, counters were pinned to `f64`, each semantic backend defined its own embedder and prompt handling, and only the in-memory backend could be selected natively. - Split `disconnect` and `test_connection` out of `BaseCache` into optional capabilities, implemented only where the Python class defines them, and give every Redis-only operation its own capability trait. - Decouple counters from the stored value type, so one backend can serve both responses and counters as Python's `RedisCache` does. - Share one `Embedder` and prompt contract in `litellm_cache::semantic`, and make the Redis and Valkey semantic backends generic over their codec. - Port the Python operations that were missing: `async_refresh_ttl`, `async_rpush_and_trim`, `async_set_cache_pipeline_with_ttls`, the DualCache pipeline, sadd, bulk delete and TTL reads, and the semantic-similarity write-back. - Take the HTTP client from the host pool in the GCS, S3 and Azure backends. - Activate all nine backends through the Rust catalog, whose rules all stay `PYTHON_ONLY`, and route the `Cache` facade's storage calls to the native runtime when one is selected. - Give every crate the same layout, move all tests to `tests/` on rstest, and add the shared `litellm-cache-testing` contract suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: freeze native cache request kwargs and batch entries for type discipline Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: declare semantic lookup methods in the native stub Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(rust): align the cache crates with Python and activate every backend The cache port had drifted: lifecycle and Redis-only operations sat on `BaseCache`, counters were pinned to `f64`, each semantic backend defined its own embedder and prompt handling, and only the in-memory backend could be selected natively. - Split `disconnect` and `test_connection` out of `BaseCache` into optional capabilities, implemented only where the Python class defines them, and give every Redis-only operation its own capability trait. - Decouple counters from the stored value type, so one backend can serve both responses and counters as Python's `RedisCache` does. - Share one `Embedder` and prompt contract in `litellm_cache::semantic`, and make the Redis and Valkey semantic backends generic over their codec. - Port the Python operations that were missing: `async_refresh_ttl`, `async_rpush_and_trim`, `async_set_cache_pipeline_with_ttls`, the DualCache pipeline, sadd, bulk delete and TTL reads, and the semantic-similarity write-back. - Take the HTTP client from the host pool in the GCS, S3 and Azure backends. - Activate all nine backends through the Rust catalog, whose rules all stay `PYTHON_ONLY`, and route the `Cache` facade's storage calls to the native runtime when one is selected. - Give every crate the same layout, move all tests to `tests/` on rstest, and add the shared `litellm-cache-testing` contract suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: freeze native cache request kwargs and batch entries for type discipline Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: declare semantic lookup methods in the native stub Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(rust): opt the native Messages and tokenizer suites into Rust explicitly #42517 made the Messages, token counter and tokenizer routes Python-only, so tests/test_litellm_rust silently exercised the Python path or failed outright. Each suite now prepends a RUST_OPT_IN rule for its route, keeping native coverage without changing the shipped default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(rust): pop one at a time in the Redis 6 lpop pipeline and drop explanatory comments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
110 lines
4 KiB
Rust
110 lines
4 KiB
Rust
use litellm_cache::{CacheCodec, Error};
|
|
use litellm_cache_response::{CacheEntry, ResponseCacheCodec};
|
|
use rstest::rstest;
|
|
use serde_json::{Value, json};
|
|
|
|
fn entry(response: Value) -> CacheEntry {
|
|
CacheEntry {
|
|
timestamp: Some(100.0),
|
|
response,
|
|
}
|
|
}
|
|
|
|
#[rstest]
|
|
#[case::python_literal_object(
|
|
br#"{'timestamp': 100.0, 'response': {'text': 'hello \\ world', 'flag': True, 'empty': None, 'list': [1, 2.5]}}"#.as_slice(),
|
|
json!({"text": "hello \\ world", "flag": true, "empty": null, "list": [1, 2.5]}),
|
|
)]
|
|
#[case::python_sync_string_response(
|
|
br#"{'timestamp': 100.0, 'response': '{"ok": true, "text": "cached"}'}"#.as_slice(),
|
|
json!({"ok": true, "text": "cached"}),
|
|
)]
|
|
#[case::python_literal_string_response(
|
|
br#"{'timestamp': 100.0, 'response': "{'ok': True, 'items': (1, 2)}"}"#.as_slice(),
|
|
json!({"ok": true, "items": [1, 2]}),
|
|
)]
|
|
#[case::json_object_response(
|
|
br#"{"timestamp":100.0,"response":{"ok":true,"text":"cached"}}"#.as_slice(),
|
|
json!({"ok": true, "text": "cached"}),
|
|
)]
|
|
#[case::json_string_response(
|
|
br#"{"timestamp": 100.0, "response": "[1,2]"}"#.as_slice(),
|
|
json!([1, 2]),
|
|
)]
|
|
fn decode_reads_every_python_envelope(#[case] bytes: &[u8], #[case] response: Value) {
|
|
assert_eq!(ResponseCacheCodec.decode(bytes).unwrap(), entry(response));
|
|
}
|
|
|
|
#[rstest]
|
|
#[case::code_is_not_executed(b"__import__('os').system('false')".to_vec())]
|
|
#[case::non_numeric_timestamp(b"{'timestamp': 'invalid', 'response': {}}".to_vec())]
|
|
#[case::infinite_timestamp(b"{'timestamp': 1e9999, 'response': {}}".to_vec())]
|
|
#[case::missing_response(br#"{"timestamp": 100.0}"#.to_vec())]
|
|
#[case::unserialized_string_response(br#"{"timestamp": 100.0, "response": "not serialized"}"#.to_vec())]
|
|
#[case::non_utf8(vec![0xff, 0xfe])]
|
|
#[case::deep_nesting(format!("{}None{}", "[".repeat(1000), "]".repeat(1000)).into_bytes())]
|
|
fn decode_rejects_invalid_entries(#[case] bytes: Vec<u8>) {
|
|
assert_eq!(
|
|
ResponseCacheCodec.decode(&bytes).unwrap_err(),
|
|
Error::InvalidEntry
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
#[case::nan(f64::NAN)]
|
|
#[case::infinity(f64::INFINITY)]
|
|
fn encode_rejects_non_finite_timestamps(#[case] timestamp: f64) {
|
|
assert_eq!(
|
|
ResponseCacheCodec
|
|
.encode(&CacheEntry {
|
|
timestamp: Some(timestamp),
|
|
response: json!({}),
|
|
})
|
|
.unwrap_err(),
|
|
Error::InvalidEntry
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
#[case::object(json!({"choices": [{"text": "cached"}]}), json!({"choices": [{"text": "cached"}]}))]
|
|
#[case::array(json!([1, 2]), json!("[1,2]"))]
|
|
#[case::number(json!(7), json!("7"))]
|
|
#[case::null(json!(null), json!("null"))]
|
|
#[case::string(json!("hello world"), json!("\"hello world\""))]
|
|
#[case::numeric_string(json!("123"), json!("\"123\""))]
|
|
#[case::null_string(json!("null"), json!("\"null\""))]
|
|
fn encode_writes_python_readable_envelopes_that_round_trip(
|
|
#[case] response: Value,
|
|
#[case] wire_response: Value,
|
|
) {
|
|
let wire = ResponseCacheCodec.encode(&entry(response.clone())).unwrap();
|
|
assert_eq!(
|
|
serde_json::from_slice::<Value>(&wire).unwrap(),
|
|
json!({"timestamp": 100.0, "response": wire_response})
|
|
);
|
|
assert_eq!(ResponseCacheCodec.decode(&wire).unwrap(), entry(response));
|
|
}
|
|
|
|
#[rstest]
|
|
fn object_entries_preserve_the_existing_json_representation() {
|
|
let entry = CacheEntry {
|
|
timestamp: Some(123.0),
|
|
response: json!({"choices": [{"text": "cached"}]}),
|
|
};
|
|
let bytes = ResponseCacheCodec.encode(&entry).unwrap();
|
|
assert_eq!(bytes, serde_json::to_vec(&entry).unwrap());
|
|
assert_eq!(ResponseCacheCodec.decode(&bytes).unwrap(), entry);
|
|
}
|
|
|
|
#[rstest]
|
|
#[case::json(br#"{"choices": [{"text": "legacy"}]}"#.as_slice())]
|
|
#[case::python_literal(br#"{'choices': [{'text': 'legacy'}]}"#.as_slice())]
|
|
fn values_without_timestamps_decode_as_bare_responses(#[case] bytes: &[u8]) {
|
|
assert_eq!(
|
|
ResponseCacheCodec.decode(bytes).unwrap(),
|
|
CacheEntry {
|
|
timestamp: None,
|
|
response: json!({"choices": [{"text": "legacy"}]}),
|
|
}
|
|
);
|
|
}
|