mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
* feat(rust): add python-compat crate for Python data formats
Add litellm-python-compat, a PyO3-free crate that reproduces the Python
data formats LiteLLM persists, so Rust readers and writers can interoperate
with state written by the Python proxy:
- literal::literal_eval: a linear recursive-descent port of
ast.literal_eval (prefixes, escapes, implicit concatenation, numeric
underscores and radixes, single unary sign, real +/- complex with 3.14
mixed-mode rules, set(), Python-equality key dedup)
- repr::{repr, to_str}: byte-exact repr()/str(), with a printable table
generated from CPython's str.isprintable (Unicode 16.0.0)
- json::{dumps, from_json, to_json}: json.dumps defaults and the
json.loads mapping
- pickle::{loads, dumps}: plain-data pickles via serde-pickle's serde
interface, which keeps dict insertion order
- truthy::truthy: bool() for plain data
Tests replay fixtures generated by CPython 3.14 (values across every
format and pickle protocol 0-5, plus 154 literal_eval source texts).
Accepted divergences are pinned in a KNOWN table that fails once one
starts matching. A criterion bench covers each format and literal_eval
cost by nesting depth, guarding the linear parse: the py_literal grammar
doubled per nested bracket (105 ms at 16 nested dicts; 19 us at 128 now).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(rust): split python-compat modules and harden the pickle verifier
- Disable class resolution in scripts/verify_rust_pickles.py, and truncate
the export file once instead of removing and appending to it, so the
verifier cannot be pointed at a pre-created file whose rows execute code
through pickle.loads
- Move Error to error.rs and Value to value.rs, leaving lib.rs as the crate
overview, module list and MAX_DEPTH
- Move the generator and verifier to scripts/, beside the Unicode table
generator, leaving tests/ to the Rust tests
- Group the bench by measured surface, give every case a Throughput so
criterion reports bytes per second, and document baseline comparison
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
122 lines
4.2 KiB
Rust
122 lines
4.2 KiB
Rust
use std::time::{Duration, Instant};
|
|
|
|
use litellm_python_compat::{Error, MAX_DEPTH, Value, json, literal::literal_eval, pickle};
|
|
use rstest::{fixture, rstest};
|
|
|
|
/// The bracket pair of one container shape, as `(open, close)`.
|
|
#[fixture]
|
|
fn shapes() -> [(&'static str, &'static str); 3] {
|
|
[("[", "]"), ("{'a': ", "}"), ("(", ",)")]
|
|
}
|
|
|
|
fn nested_text(open: &str, close: &str, depth: usize) -> String {
|
|
format!("{}1{}", open.repeat(depth), close.repeat(depth))
|
|
}
|
|
|
|
fn nested_list(depth: usize) -> Value {
|
|
(0..depth).fold(Value::from(1), |value, _| Value::List(vec![value]))
|
|
}
|
|
|
|
/// A protocol 3 pickle of `depth` nested lists around `1`: `EMPTY_LIST` per level, then
|
|
/// `BININT1 1`, then `APPEND` per level. Written by hand because `dumps` refuses the depth.
|
|
fn nested_list_pickle(depth: usize) -> Vec<u8> {
|
|
let mut data = vec![0x80, 3];
|
|
data.extend(std::iter::repeat_n(b']', depth));
|
|
data.extend([b'K', 1]);
|
|
data.extend(std::iter::repeat_n(b'a', depth));
|
|
data.push(b'.');
|
|
data
|
|
}
|
|
|
|
#[rstest]
|
|
fn literal_eval_accepts_the_limit_and_rejects_past_it(shapes: [(&'static str, &'static str); 3]) {
|
|
for (open, close) in shapes {
|
|
assert!(literal_eval(&nested_text(open, close, MAX_DEPTH)).is_ok());
|
|
assert!(matches!(
|
|
literal_eval(&nested_text(open, close, MAX_DEPTH + 1)),
|
|
Err(Error::TooDeep)
|
|
));
|
|
}
|
|
}
|
|
|
|
/// A backtracking parser (the `py_literal` grammar this replaced) doubles per nested level
|
|
/// and takes minutes here; the bound is loose enough to survive a slow debug build.
|
|
#[rstest]
|
|
fn literal_eval_stays_linear_in_depth(shapes: [(&'static str, &'static str); 3]) {
|
|
for (open, close) in shapes {
|
|
let text = nested_text(open, close, MAX_DEPTH);
|
|
let start = Instant::now();
|
|
assert!(literal_eval(&text).is_ok());
|
|
let elapsed = start.elapsed();
|
|
assert!(
|
|
elapsed < Duration::from_millis(50),
|
|
"{open} nested {MAX_DEPTH} deep took {elapsed:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[rstest]
|
|
fn literal_eval_ignores_brackets_inside_strings() {
|
|
let text = format!("'{}'", "[".repeat(MAX_DEPTH + 1));
|
|
assert!(matches!(literal_eval(&text), Ok(Value::Str(_))));
|
|
}
|
|
|
|
#[rstest]
|
|
fn pickle_nesting_is_bounded_in_both_directions() {
|
|
assert_eq!(
|
|
pickle::loads(&nested_list_pickle(MAX_DEPTH)).unwrap(),
|
|
nested_list(MAX_DEPTH)
|
|
);
|
|
assert!(matches!(
|
|
pickle::loads(&nested_list_pickle(MAX_DEPTH + 1)),
|
|
Err(Error::InvalidPickle(_))
|
|
));
|
|
assert!(pickle::dumps(&nested_list(MAX_DEPTH)).is_ok());
|
|
assert!(matches!(
|
|
pickle::dumps(&nested_list(MAX_DEPTH + 2)),
|
|
Err(Error::TooDeep)
|
|
));
|
|
}
|
|
|
|
#[rstest]
|
|
#[case("{1, 2}", "set")]
|
|
#[case("1+2j", "complex")]
|
|
fn pickle_dumps_refuses_types_it_would_change(#[case] source: &str, #[case] type_name: &str) {
|
|
let value = literal_eval(source).expect("source is a literal");
|
|
assert!(matches!(pickle::dumps(&value), Err(Error::NotPicklable(name)) if name == type_name));
|
|
}
|
|
|
|
#[rstest]
|
|
#[case(b"\x80\x02c__builtin__\ncomplex\nq\x00.".to_vec(), "a class reference")]
|
|
#[case({ let mut data = pickle::dumps(&Value::from(1)).unwrap(); data.push(b'.'); data }, "trailing data")]
|
|
fn pickle_loads_rejects(#[case] data: Vec<u8>, #[case] what: &str) {
|
|
assert!(
|
|
matches!(pickle::loads(&data), Err(Error::InvalidPickle(_))),
|
|
"{what} must not decode"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
#[case(Value::Bytes(b"x".to_vec()), "Object of type bytes is not JSON serializable")]
|
|
#[case(Value::Set(vec![Value::from(1)]), "Object of type set is not JSON serializable")]
|
|
#[case(Value::Complex { re: 1.0, im: 2.0 }, "Object of type complex is not JSON serializable")]
|
|
#[case(
|
|
Value::Float(f64::NAN),
|
|
"Out of range float values are not JSON compliant"
|
|
)]
|
|
fn to_json_reports_what_python_json_dumps_would_reject(
|
|
#[case] value: Value,
|
|
#[case] message: &str,
|
|
) {
|
|
let error = json::to_json(&value).expect_err("value has no serde_json form");
|
|
assert_eq!(error.to_string(), message);
|
|
}
|
|
|
|
/// `json.dumps` writes the non-finite floats that `to_json` cannot represent.
|
|
#[rstest]
|
|
#[case(f64::NAN, "NaN")]
|
|
#[case(f64::INFINITY, "Infinity")]
|
|
#[case(f64::NEG_INFINITY, "-Infinity")]
|
|
fn json_dumps_writes_non_finite_floats(#[case] value: f64, #[case] text: &str) {
|
|
assert_eq!(json::dumps(&Value::Float(value)).unwrap(), text);
|
|
}
|