perf(rust-bridge): interned keys and fast paths at the Python boundary

This commit is contained in:
Yujong Lee 2026-09-03 14:19:40 -07:00
parent 77e85554a7
commit dbccac852d
15 changed files with 513 additions and 76 deletions

View file

@ -1490,6 +1490,7 @@ dependencies = [
"pyo3",
"pythonize",
"rstest",
"rustc-hash",
"serde",
"serde_json",
]

View file

@ -15,6 +15,7 @@ repository = "https://github.com/BerriAI/litellm"
[workspace.dependencies]
bytes = "1.10"
rustc-hash = "2.1.3"
bytestring = "1.5.1"
h2 = "0.4"
tracing = "0.1"

View file

@ -1,8 +1,8 @@
use std::hint::black_box;
use std::time::Duration;
use criterion::{BenchmarkId, Criterion, criterion_group};
use litellm_python_interop::{from_py, to_py};
use criterion::{BenchmarkId, Criterion};
use litellm_python_interop::{from_py, to_py, value_to_py};
use pyo3::prelude::*;
use pyo3::types::PyDict;
use serde_json::{Value, json};
@ -15,6 +15,57 @@ const PAYLOAD_SIZES: &[(&str, usize)] = &[
("16_MiB", 16 * 1024 * 1024),
];
const TOOL_CALLS: usize = 18;
fn chat_completion_response() -> Value {
json!({
"id": "chatcmpl-9f2c8f0e6b1d4a7fa3c5e2b8d1f0a6c4",
"object": "chat.completion",
"created": 1725409200,
"model": "gpt-5.2",
"system_fingerprint": "fp_b7c1a9d3e5",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Here is the summary you asked for. The rollout plan covers three phases. First, the canary fleet moves to the new router. Second, we hold for error-rate parity over two hours. Third, we ramp to full traffic while watching p99 latency. If any guard trips, the router falls back to the previous rule set within one minute, so the blast radius stays bounded to a single shard.",
"tool_calls": (0..TOOL_CALLS)
.map(|index| {
json!({
"id": format!("call_0123456789abcdef{index:02}"),
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"San Francisco\", \"unit\": \"celsius\"}",
},
})
})
.collect::<Vec<_>>(),
},
"logprobs": null,
"finish_reason": "tool_calls",
},
{
"index": 1,
"message": {
"role": "assistant",
"content": null,
},
"logprobs": null,
"finish_reason": "stop",
},
],
"usage": {
"prompt_tokens": 1024,
"completion_tokens": 8192,
"total_tokens": 9216,
"prompt_tokens_details": {"cached_tokens": 512},
"completion_tokens_details": {"reasoning_tokens": 2048},
},
})
}
fn former_json_roundtrip_from_py(py: Python<'_>, value: &Bound<'_, PyAny>) -> Value {
let json = py.import("json").expect("Python json module should import");
let encoded: String = json
@ -41,9 +92,36 @@ fn pythonize_to_py(py: Python<'_>, value: &Value) -> Py<PyAny> {
to_py(py, value).expect("response should pythonize")
}
fn interned_to_py(py: Python<'_>, value: &Value) -> Py<PyAny> {
value_to_py(py, value).expect("response should convert")
}
fn bridge_serialization(c: &mut Criterion) {
Python::initialize();
Python::attach(|py| {
let chat_response = chat_completion_response();
let chat_size = serde_json::to_string(&chat_response)
.expect("chat response should serialize")
.len();
assert!(
(3500..=4500).contains(&chat_size),
"chat benchmark payload should stay near 4 KiB, was {chat_size} bytes"
);
c.bench_with_input(
BenchmarkId::new(
"rust_to_python_pythonize_chat",
format!("{chat_size}_bytes"),
),
&chat_response,
|b, response| b.iter(|| pythonize_to_py(py, black_box(response))),
);
c.bench_with_input(
BenchmarkId::new("rust_to_python_interned_chat", format!("{chat_size}_bytes")),
&chat_response,
|b, response| b.iter(|| interned_to_py(py, black_box(response))),
);
for &(label, payload_bytes) in PAYLOAD_SIZES {
let data_uri = format!("data:image/png;base64,{}", "A".repeat(payload_bytes));
let document = PyDict::new(py);
@ -87,25 +165,27 @@ fn bridge_serialization(c: &mut Criterion) {
&response,
|b, response| b.iter(|| pythonize_to_py(py, black_box(response))),
);
c.bench_with_input(
BenchmarkId::new("rust_to_python_interned", label),
&response,
|b, response| b.iter(|| interned_to_py(py, black_box(response))),
);
}
});
}
criterion_group! {
name = benches;
config = Criterion::default()
.sample_size(20)
.warm_up_time(Duration::from_secs(1))
.measurement_time(Duration::from_secs(4));
targets = bridge_serialization
}
mod media;
fn main() {
if std::env::args().nth(1).as_deref() == Some("--media") {
media::run();
} else {
benches();
Criterion::default().configure_from_args().final_summary();
return;
}
let mut criterion = Criterion::default()
.sample_size(20)
.warm_up_time(Duration::from_secs(1))
.measurement_time(Duration::from_secs(4))
.configure_from_args();
bridge_serialization(&mut criterion);
criterion.final_summary();
}

View file

@ -4,39 +4,99 @@ use std::time::Duration;
use futures_util::FutureExt;
use litellm_core::error::Error;
use litellm_python_interop::{Pythonized, panic_to_pyerr, release_gil};
use litellm_python_interop::{PythonValue, Pythonized, panic_to_pyerr, release_gil};
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use serde::Serialize;
use serde_json::Value;
use tokio::runtime::{Handle, Runtime};
use tokio::time::{self, MissedTickBehavior};
pub(crate) fn run_sync<T, F>(
use crate::function_trace::TraceResponse;
pub(crate) trait ResponseMarshal<T>: 'static {
type Output: for<'py> IntoPyObject<'py, Error = PyErr, Output = Bound<'py, PyAny>>
+ Send
+ 'static;
fn wrap(value: T) -> Self::Output;
}
pub(crate) struct GenericMarshal;
impl<T> ResponseMarshal<T> for GenericMarshal
where
T: Serialize + Send + 'static,
{
type Output = Pythonized<T>;
fn wrap(value: T) -> Self::Output {
Pythonized(value)
}
}
pub(crate) struct ValueMarshal;
impl ResponseMarshal<TraceResponse<Value>> for ValueMarshal {
type Output = ValueTraceResponse;
fn wrap(value: TraceResponse<Value>) -> Self::Output {
ValueTraceResponse(value)
}
}
pub(crate) struct ValueTraceResponse(TraceResponse<Value>);
impl<'py> IntoPyObject<'py> for ValueTraceResponse {
type Target = PyAny;
type Output = Bound<'py, PyAny>;
type Error = PyErr;
fn into_pyobject(self, py: Python<'py>) -> PyResult<Self::Output> {
match self.0 {
TraceResponse::Plain(value) => PythonValue(value).into_pyobject(py),
TraceResponse::Traced { response, trace } => {
let traced = PyDict::new(py);
traced.set_item("response", PythonValue(response))?;
traced.set_item("trace", Pythonized(trace))?;
Ok(traced.into_any())
}
}
}
}
pub(crate) fn run_sync<T, F, M>(
py: Python<'_>,
future: F,
map_error: fn(Error) -> PyErr,
_marshal: M,
) -> PyResult<Py<PyAny>>
where
T: Serialize + Send + 'static,
T: Send + 'static,
F: Future<Output = Result<T, Error>> + Send + 'static,
M: ResponseMarshal<T>,
{
run_sync_on(
py,
pyo3_async_runtimes::tokio::get_runtime(),
future,
map_error,
_marshal,
)
}
fn run_sync_on<T, F>(
fn run_sync_on<T, F, M>(
py: Python<'_>,
runtime: &Runtime,
future: F,
map_error: fn(Error) -> PyErr,
_marshal: M,
) -> PyResult<Py<PyAny>>
where
T: Serialize + Send + 'static,
T: Send + 'static,
F: Future<Output = Result<T, Error>> + Send + 'static,
M: ResponseMarshal<T>,
{
if Handle::try_current().is_ok() {
return Err(PyRuntimeError::new_err(
@ -46,22 +106,24 @@ where
let result = release_gil(py, move || runtime.block_on(wait_for_sync_result(future)))?;
let result = map_core_result(result, map_error)?;
Pythonized(result).into_pyobject(py).map(Bound::unbind)
M::wrap(result).into_pyobject(py).map(Bound::unbind)
}
pub(crate) fn run_async<T, F>(
pub(crate) fn run_async<T, F, M>(
py: Python<'_>,
future: F,
map_error: fn(Error) -> PyErr,
_marshal: M,
) -> PyResult<Bound<'_, PyAny>>
where
T: Serialize + Send + 'static,
T: Send + 'static,
F: Future<Output = Result<T, Error>> + Send + 'static,
M: ResponseMarshal<T>,
{
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let result = catch_future_panic(future).await?;
let result = map_core_result(result, map_error)?;
Ok(Pythonized(result))
Ok(M::wrap(result))
})
}
@ -120,6 +182,8 @@ mod tests {
use tokio::runtime::Builder;
use super::*;
use crate::function_trace::FunctionTraceEvent;
use litellm_python_interop::to_py;
fn runtime_error(error: Error) -> PyErr {
PyRuntimeError::new_err(error.to_string())
@ -144,7 +208,25 @@ mod tests {
#[pyfunction]
fn async_serialization_panic(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
run_async(py, async { Ok(PanickingOutput) }, runtime_error)
run_async(
py,
async { Ok(PanickingOutput) },
runtime_error,
GenericMarshal,
)
}
#[pyfunction]
fn async_value_probe(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
run_async(
py,
crate::function_trace::trace_call(
async { Ok(serde_json::json!({"content": [{"type": "text", "text": "héllo"}]})) },
false,
),
runtime_error,
ValueMarshal,
)
}
#[pyfunction]
@ -156,6 +238,7 @@ mod tests {
Ok(true)
},
runtime_error,
GenericMarshal,
)
}
@ -191,6 +274,73 @@ mod tests {
.expect("result should convert")
}
#[test]
fn value_marshal_matches_pythonized_conversion() {
Python::initialize();
Python::attach(|py| {
let payload = serde_json::json!({
"id": "msg_01",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "héllo 🌍", "index": 0},
{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "sf"}},
],
"stop_reason": null,
"usage": {"input_tokens": 10, "output_tokens": 20},
});
let plain = TraceResponse::Plain(payload.clone());
let traced = TraceResponse::Traced {
response: payload,
trace: vec![FunctionTraceEvent {
function: "transform",
depth: 1,
}],
};
for response in [plain, traced] {
let reference = to_py(py, &response).expect("pythonize should convert");
let fast = ValueMarshal::wrap(response)
.into_pyobject(py)
.expect("value marshal should convert");
let equal: bool = fast
.eq(reference.bind(py))
.expect("converted values should compare equal in Python");
assert!(equal, "value marshal diverged from pythonize");
}
});
}
#[test]
fn async_value_marshal_returns_the_response_dict() {
Python::initialize();
Python::attach(|py| {
let module = PyModule::new(py, "runtime").expect("module should be created");
module
.add_function(
wrap_pyfunction!(async_value_probe, &module).expect("function should wrap"),
)
.expect("function should register");
let locals = PyDict::new(py);
locals
.set_item("runtime", &module)
.expect("module should enter Python locals");
let code = CString::new(
r#"
import asyncio
async def exercise():
result = await runtime.async_value_probe()
assert result == {"content": [{"type": "text", "text": "héllo"}]}
asyncio.run(exercise())
"#,
)
.expect("Python source should not contain null bytes");
py.run(&code, Some(&locals), Some(&locals))
.expect("async value route should deliver its response");
});
}
#[test]
fn sync_runner_polls_future_on_the_caller_thread() {
Python::initialize();
@ -200,6 +350,7 @@ mod tests {
py,
async move { Ok(std::thread::current().id() == caller_thread) },
runtime_error,
GenericMarshal,
);
assert!(extract_bool(py, result));
@ -221,6 +372,7 @@ mod tests {
Ok(matches!(gil_acquired, Ok(Ok(true))))
},
runtime_error,
GenericMarshal,
);
assert!(extract_bool(py, result));
@ -237,7 +389,7 @@ mod tests {
let error = runtime.block_on(async {
Python::attach(|py| {
run_sync::<bool, _>(py, async { Ok(true) }, runtime_error)
run_sync::<bool, _, _>(py, async { Ok(true) }, runtime_error, GenericMarshal)
.expect_err("sync route should reject a nested Tokio runtime")
})
});
@ -264,6 +416,7 @@ mod tests {
Ok(true)
},
runtime_error,
GenericMarshal,
);
assert!(extract_bool(py, result));
});
@ -273,10 +426,11 @@ mod tests {
fn sync_runner_maps_a_panicked_future() {
Python::initialize();
Python::attach(|py| {
let error = run_sync::<bool, _>(
let error = run_sync::<bool, _, _>(
py,
poll_fn(|_| -> Poll<Result<bool, Error>> { panic!("route future panicked") }),
runtime_error,
GenericMarshal,
)
.expect_err("panicked route should become a Python exception");
@ -289,10 +443,11 @@ mod tests {
fn sync_runner_maps_a_panicked_error_mapper() {
Python::initialize();
Python::attach(|py| {
let error = run_sync::<bool, _>(
let error = run_sync::<bool, _, _>(
py,
async { Err(Error::InvalidRequest("invalid".to_string())) },
panicking_error_mapper,
GenericMarshal,
)
.expect_err("panicked mapper should become a Python exception");
@ -305,8 +460,13 @@ mod tests {
fn sync_runner_surfaces_serializer_panics() {
Python::initialize();
Python::attach(|py| {
let error = run_sync(py, async { Ok(PanickingOutput) }, runtime_error)
.expect_err("serializer panic should become a Python exception");
let error = run_sync(
py,
async { Ok(PanickingOutput) },
runtime_error,
GenericMarshal,
)
.expect_err("serializer panic should become a Python exception");
assert!(error.is_instance_of::<PanicException>(py));
assert_eq!(error.to_string(), "PanicException: serializer panicked");
@ -332,6 +492,7 @@ mod tests {
.is_ok())
},
runtime_error,
GenericMarshal,
),
)
})

View file

@ -3,22 +3,23 @@ use std::time::Duration;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::pybacked::PyBackedStr;
use serde_json::{Map, Value};
pub(crate) struct RouteOptions {
pub(crate) model: String,
pub(crate) api_key: Option<String>,
pub(crate) api_base: Option<String>,
pub(crate) custom_llm_provider: Option<String>,
pub(crate) model: PyBackedStr,
pub(crate) api_key: Option<PyBackedStr>,
pub(crate) api_base: Option<PyBackedStr>,
pub(crate) custom_llm_provider: Option<PyBackedStr>,
pub(crate) extra_headers: Option<Map<String, Value>>,
pub(crate) timeout: Option<Duration>,
}
pub(crate) struct RouteOptionsInputs {
pub(crate) model: String,
pub(crate) api_key: Option<String>,
pub(crate) api_base: Option<String>,
pub(crate) custom_llm_provider: Option<String>,
pub(crate) model: PyBackedStr,
pub(crate) api_key: Option<PyBackedStr>,
pub(crate) api_base: Option<PyBackedStr>,
pub(crate) custom_llm_provider: Option<PyBackedStr>,
pub(crate) extra_headers: Option<Value>,
pub(crate) timeout_seconds: Option<f64>,
}
@ -36,20 +37,6 @@ impl RouteOptions {
}
}
pub(crate) fn required_value(
name: &'static str,
value: Value,
expected: fn(&Value) -> bool,
expected_name: &'static str,
) -> PyResult<Value> {
if expected(&value) {
return Ok(value);
}
Err(PyValueError::new_err(format!(
"{name} must be a {expected_name}"
)))
}
pub(crate) fn object_or_empty(
name: &'static str,
value: Option<Value>,

View file

@ -1,5 +1,6 @@
use litellm_core::Error;
use litellm_core::http_utils::body::JsonPayload;
use pyo3::pybacked::PyBackedStr;
use std::future::Future;
use litellm_core::audio_transcription::{
@ -53,14 +54,14 @@ bridge_route! {
asynchronous = atranscription,
inputs = AudioTranscriptionInputs,
required = {
model: String,
model: PyBackedStr,
#[pyo3(from_py_with = crate::payload::audio_payload_from_py)]
audio: JsonPayload,
},
optional = {
api_key: Option<String>,
api_base: Option<String>,
custom_llm_provider: Option<String>,
api_key: Option<PyBackedStr>,
api_base: Option<PyBackedStr>,
custom_llm_provider: Option<PyBackedStr>,
#[pyo3(from_py_with = litellm_python_interop::from_py)]
extra_headers: Option<Value>,
#[pyo3(from_py_with = litellm_python_interop::from_py)]
@ -69,4 +70,5 @@ bridge_route! {
},
prepare = prepare_transcription,
errors = core_error_to_pyerr,
marshal = crate::execution::ValueMarshal,
}

View file

@ -6,15 +6,20 @@ use litellm_core::chat_completions::{
chat_completions as run_chat_completions, chat_completions_decline_reason,
};
use pyo3::prelude::*;
use pyo3::pybacked::PyBackedStr;
use serde_json::Value;
use crate::errors::chat_completions_error_to_pyerr;
use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty, required_value};
use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty};
fn messages_from_py(value: &Bound<'_, PyAny>) -> PyResult<Value> {
litellm_python_interop::array_from_py("messages", value)
}
fn prepare_chat_completions(
inputs: ChatCompletionsInputs,
) -> PyResult<impl Future<Output = Result<ChatCompletionsResponse, Error>> + Send + 'static> {
let messages = required_value("messages", inputs.messages, Value::is_array, "list")?;
let messages = inputs.messages;
let optional_params = object_or_empty("optional_params", inputs.optional_params)?;
let options = RouteOptions::from_python(RouteOptionsInputs {
model: inputs.model,
@ -51,10 +56,10 @@ fn prepare_chat_completions(
#[pyfunction]
#[pyo3(signature = (model, messages, optional_params=None, custom_llm_provider=None))]
fn chat_completions_decline(
model: String,
model: PyBackedStr,
#[pyo3(from_py_with = litellm_python_interop::from_py)] messages: Value,
#[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option<Value>,
custom_llm_provider: Option<String>,
custom_llm_provider: Option<PyBackedStr>,
) -> PyResult<Option<String>> {
let optional_params = object_or_empty("optional_params", optional_params)?;
Ok(chat_completions_decline_reason(
@ -71,21 +76,22 @@ bridge_route! {
asynchronous = achat_completions,
inputs = ChatCompletionsInputs,
required = {
model: String,
#[pyo3(from_py_with = litellm_python_interop::from_py)]
model: PyBackedStr,
#[pyo3(from_py_with = messages_from_py)]
messages: Value,
},
optional = {
#[pyo3(from_py_with = litellm_python_interop::from_py)]
optional_params: Option<Value>,
api_key: Option<String>,
api_base: Option<String>,
custom_llm_provider: Option<String>,
api_key: Option<PyBackedStr>,
api_base: Option<PyBackedStr>,
custom_llm_provider: Option<PyBackedStr>,
#[pyo3(from_py_with = litellm_python_interop::from_py)]
extra_headers: Option<Value>,
timeout_seconds: Option<f64>,
},
prepare = prepare_chat_completions,
errors = chat_completions_error_to_pyerr,
marshal = crate::execution::GenericMarshal,
extra = [chat_completions_decline],
}

View file

@ -10,7 +10,8 @@ macro_rules! bridge_route {
required = { $($(#[$required_attr:meta])* $required_name:ident: $required_type:ty),+ $(,)? },
optional = { $($(#[$optional_attr:meta])* $optional_name:ident: $optional_type:ty),* $(,)? },
prepare = $prepare:path,
errors = $map_error:path
errors = $map_error:path,
marshal = $marshal:path
$(, extra = [$($extra:ident),* $(,)?])?
$(,)?
) => {
@ -36,6 +37,7 @@ macro_rules! bridge_route {
py,
$crate::function_trace::trace_call(future, trace),
$map_error,
$marshal,
)
}
@ -56,6 +58,7 @@ macro_rules! bridge_route {
py,
$crate::function_trace::trace_call(future, trace),
$map_error,
$marshal,
)
}
@ -122,6 +125,7 @@ mod tests {
optional = {},
prepare = prepare_echo,
errors = map_error,
marshal = crate::execution::GenericMarshal,
extra = [future_dropped],
}

View file

@ -4,6 +4,7 @@ use litellm_core::messages::types::{
AnthropicMessagesRequest, AnthropicMessagesResponse, MessagesRequest,
};
use pyo3::prelude::*;
use pyo3::pybacked::PyBackedStr;
use serde_json::Value;
use std::future::Future;
@ -50,18 +51,19 @@ bridge_route! {
asynchronous = amessages,
inputs = MessagesInputs,
required = {
model: String,
model: PyBackedStr,
#[pyo3(from_py_with = crate::messages::messages_from_py)]
body: AnthropicMessagesRequest,
},
optional = {
api_key: Option<String>,
api_base: Option<String>,
custom_llm_provider: Option<String>,
api_key: Option<PyBackedStr>,
api_base: Option<PyBackedStr>,
custom_llm_provider: Option<PyBackedStr>,
#[pyo3(from_py_with = litellm_python_interop::from_py)]
extra_headers: Option<Value>,
timeout_seconds: Option<f64>,
},
prepare = prepare_messages,
errors = core_error_to_pyerr,
marshal = crate::execution::GenericMarshal,
}

View file

@ -1,5 +1,6 @@
use litellm_core::Error;
use litellm_core::http_utils::body::JsonPayload;
use pyo3::pybacked::PyBackedStr;
use std::future::Future;
use litellm_ai_gateway::io::ocr::{OcrRequest, ocr as run_ocr};
@ -55,14 +56,14 @@ bridge_route! {
asynchronous = aocr,
inputs = OcrInputs,
required = {
model: String,
model: PyBackedStr,
#[pyo3(from_py_with = crate::payload::payload_from_py)]
document: JsonPayload,
},
optional = {
api_key: Option<String>,
api_base: Option<String>,
custom_llm_provider: Option<String>,
api_key: Option<PyBackedStr>,
api_base: Option<PyBackedStr>,
custom_llm_provider: Option<PyBackedStr>,
#[pyo3(from_py_with = litellm_python_interop::from_py)]
extra_headers: Option<Value>,
#[pyo3(from_py_with = litellm_python_interop::from_py)]
@ -71,4 +72,5 @@ bridge_route! {
},
prepare = prepare_ocr,
errors = core_error_to_pyerr,
marshal = crate::execution::ValueMarshal,
}

View file

@ -9,8 +9,9 @@ repository.workspace = true
bytes.workspace = true
pyo3.workspace = true
pythonize.workspace = true
rustc-hash.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
rstest.workspace = true
serde_json.workspace = true

View file

@ -2,7 +2,10 @@ mod gil;
mod marshal;
pub use gil::{release_count, release_gil};
pub use marshal::{Pythonized, from_py, panic_to_pyerr, to_py};
pub use marshal::{Pythonized, array_from_py, from_py, panic_to_pyerr, to_py};
mod value;
pub use value::{PythonValue, value_to_py};
mod bytes;
pub use bytes::{bytes_from_py, text_bytes_from_py};

View file

@ -4,8 +4,10 @@ use std::panic::{AssertUnwindSafe, catch_unwind};
use pyo3::exceptions::PyValueError;
use pyo3::panic::PanicException;
use pyo3::prelude::*;
use pyo3::types::{PyFrozenSet, PyList, PySequence, PySet, PyString, PyTuple};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
pub fn from_py<T>(value: &Bound<'_, PyAny>) -> PyResult<T>
where
@ -23,6 +25,24 @@ where
.map_err(|error| PyValueError::new_err(error.to_string()))
}
pub fn array_from_py(name: &'static str, value: &Bound<'_, PyAny>) -> PyResult<Value> {
if !is_array_input(value) {
return Err(PyValueError::new_err(format!("{name} must be a list")));
}
from_py(value)
}
fn is_array_input(value: &Bound<'_, PyAny>) -> bool {
if value.is_instance_of::<PyString>() {
return false;
}
value.is_instance_of::<PyList>()
|| value.is_instance_of::<PyTuple>()
|| value.is_instance_of::<PySet>()
|| value.is_instance_of::<PyFrozenSet>()
|| value.cast::<PySequence>().is_ok()
}
pub struct Pythonized<T>(pub T);
impl<'py, T> IntoPyObject<'py> for Pythonized<T>

View file

@ -0,0 +1,72 @@
use std::panic::{AssertUnwindSafe, catch_unwind};
use rustc_hash::FxHashMap;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList, PyString};
use serde_json::Value;
use crate::marshal::panic_to_pyerr;
pub struct PythonValue(pub Value);
impl<'py> IntoPyObject<'py> for PythonValue {
type Target = PyAny;
type Output = Bound<'py, PyAny>;
type Error = PyErr;
fn into_pyobject(self, py: Python<'py>) -> PyResult<Self::Output> {
let mut keys = FxHashMap::default();
catch_unwind(AssertUnwindSafe(|| convert(py, &self.0, &mut keys)))
.map_err(panic_to_pyerr)?
}
}
pub fn value_to_py(py: Python<'_>, value: &Value) -> PyResult<Py<PyAny>> {
let mut keys = FxHashMap::default();
convert(py, value, &mut keys).map(Bound::unbind)
}
fn convert<'py, 'value>(
py: Python<'py>,
value: &'value Value,
keys: &mut FxHashMap<&'value str, Py<PyString>>,
) -> PyResult<Bound<'py, PyAny>> {
let converted = match value {
Value::Null => py.None().into_bound(py),
Value::Bool(boolean) => (*boolean).into_pyobject(py)?.to_owned().into_any(),
Value::Number(number) => {
if let Some(signed) = number.as_i64() {
signed.into_pyobject(py)?.into_any()
} else if let Some(unsigned) = number.as_u64() {
unsigned.into_pyobject(py)?.into_any()
} else {
let float = number.as_f64().ok_or_else(|| {
PyValueError::new_err(format!("unsupported number: {number}"))
})?;
float.into_pyobject(py)?.into_any()
}
}
Value::String(text) => PyString::new(py, text).into_any(),
Value::Array(items) => {
let mut elements = Vec::with_capacity(items.len());
for item in items {
elements.push(convert(py, item, keys)?);
}
PyList::new(py, elements)?.into_any()
}
Value::Object(entries) => {
let dict = PyDict::new(py);
for (name, item) in entries {
let converted = convert(py, item, keys)?;
let key = keys
.entry(name.as_str())
.or_insert_with(|| PyString::new(py, name).unbind());
dict.set_item(key.bind(py), converted)?;
}
dict.into_any()
}
};
Ok(converted)
}

View file

@ -1,8 +1,11 @@
use pyo3::Python;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyDictMethods, PyList, PyString, PyTuple};
use rstest::{fixture, rstest};
use serde_json::{Value, json};
use litellm_python_interop::{from_py, release_count, release_gil, to_py};
use litellm_python_interop::{
array_from_py, from_py, release_count, release_gil, to_py, value_to_py,
};
struct InitializedPython;
@ -34,6 +37,98 @@ fn serde_values_round_trip_through_python(#[from(initialized_python)] python: &I
});
}
#[rstest]
fn value_converter_matches_pythonize_and_round_trips(
#[from(initialized_python)] python: &InitializedPython,
) {
python.attach(|py| {
let payload = json!({
"null": null,
"bools": [true, false],
"numbers": [0, -9223372036854775808i64, 9223372036854775807i64, 18446744073709551615u64],
"floats": [0.0, -2.5, 1024.75],
"unicode": "héllo 🌍 中文",
"empty_list": [],
"empty_dict": {},
"nested": {"a": [{"b": [null, {"c": "deep", "d": [1, 2, 3]}]}]},
});
let converted = value_to_py(py, &payload).expect("value should convert to Python");
let reference = to_py(py, &payload).expect("pythonize should convert the same value");
let equal: bool = converted
.bind(py)
.eq(reference.bind(py))
.expect("converted values should compare in Python");
assert!(equal, "value converter diverged from pythonize");
let round_tripped: Value = from_py(converted.bind(py)).expect("Python value should convert back");
assert_eq!(round_tripped, payload);
});
}
#[rstest]
fn value_converter_shares_repeated_keys_within_one_call(
#[from(initialized_python)] python: &InitializedPython,
) {
python.attach(|py| {
let payload = json!({
"type": "message",
"content": [
{"type": "text", "text": "one", "index": 0},
{"type": "text", "text": "two", "index": 1},
{"type": "text", "text": "three", "index": 2},
],
});
let first = value_to_py(py, &payload).expect("first payload should convert");
let second = value_to_py(py, &payload).expect("second payload should convert");
let globals = PyDict::new(py);
globals
.set_item("first", &first)
.expect("first payload should enter Python locals");
globals
.set_item("second", &second)
.expect("second payload should enter Python locals");
py.run(
c"
first_keys = [k for block in first['content'] for k in block]
second_keys = [k for block in second['content'] for k in block]
shared = [k for k in first_keys if k == 'type']
assert all(k is shared[0] for k in shared)
assert all(k is not second_keys[0] for k in first_keys)
",
Some(&globals),
None,
)
.expect("repeated keys should resolve to one object per conversion");
});
}
#[rstest]
fn array_from_py_validates_shape_before_depythonizing(
#[from(initialized_python)] python: &InitializedPython,
) {
python.attach(|py| {
for rejected in [
PyDict::new(py).into_any(),
PyString::new(py, "text").into_any(),
py.None().into_bound(py),
] {
let error = array_from_py("messages", &rejected)
.expect_err("non-array input should be rejected before depythonization");
assert_eq!(error.to_string(), "ValueError: messages must be a list");
}
let list = PyList::new(py, [1, 2]).expect("list should be created");
let converted = array_from_py("messages", &list)
.expect("list input should depythonize after the shape check");
assert_eq!(converted, json!([1, 2]));
let tuple = PyTuple::new(py, ["a"]).expect("tuple should be created");
let converted = array_from_py("messages", &tuple)
.expect("tuple input should depythonize after the shape check");
assert_eq!(converted, json!(["a"]));
});
}
#[rstest]
fn release_gil_runs_work_and_records_it(#[from(initialized_python)] python: &InitializedPython) {
let before = release_count();