diff --git a/litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs b/litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs index 3edc6ddda7a..91b6ac71a20 100644 --- a/litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs +++ b/litellm-rust/crates/python-bridge/src/routes/audio_transcription/value.rs @@ -8,8 +8,20 @@ use pyo3::prelude::*; use serde_json::Value; use crate::errors::core_error_to_pyerr; +use crate::execution::{run_async, run_sync}; use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty}; +struct AudioTranscriptionInputs { + model: String, + audio: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + extra_headers: Option, + optional_params: Option, + timeout_seconds: Option, +} + fn prepare_transcription( inputs: AudioTranscriptionInputs, ) -> PyResult> + Send + 'static> { @@ -47,25 +59,146 @@ fn prepare_transcription( }) } -bridge_route! { - sync = transcription, - asynchronous = atranscription, - inputs = AudioTranscriptionInputs, - required = { +#[pyfunction] +#[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn transcription( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] audio: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + timeout_seconds: Option, +) -> PyResult> { + run_sync( + py, + prepare_transcription(AudioTranscriptionInputs { + model, + audio, + api_key, + api_base, + custom_llm_provider, + extra_headers, + optional_params, + timeout_seconds, + })?, + core_error_to_pyerr, + ) +} + +#[pyfunction] +#[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn atranscription( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] audio: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + timeout_seconds: Option, +) -> PyResult> { + run_async( + py, + prepare_transcription(AudioTranscriptionInputs { + model, + audio, + api_key, + api_base, + custom_llm_provider, + extra_headers, + optional_params, + timeout_seconds, + })?, + core_error_to_pyerr, + ) +} + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::super::add_function(module, wrap_pyfunction!(transcription, module)?)?; + super::super::add_function(module, wrap_pyfunction!(atranscription, module)?) +} + +#[cfg(feature = "trace-parity")] +mod trace { + use super::{ + AudioTranscriptionInputs, Value, core_error_to_pyerr, prepare_transcription, run_async, + run_sync, + }; + use pyo3::prelude::*; + + #[pyfunction] + #[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn transcription( + py: Python<'_>, model: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - audio: serde_json::Value, - }, - optional = { + #[pyo3(from_py_with = litellm_python_interop::from_py)] audio: Value, api_key: Option, api_base: Option, custom_llm_provider: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, timeout_seconds: Option, - }, - prepare = prepare_transcription, - errors = core_error_to_pyerr, + ) -> PyResult> { + run_sync( + py, + crate::function_trace::capture(prepare_transcription(AudioTranscriptionInputs { + model, + audio, + api_key, + api_base, + custom_llm_provider, + extra_headers, + optional_params, + timeout_seconds, + })?), + core_error_to_pyerr, + ) + } + + #[pyfunction] + #[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn atranscription( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] audio: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + timeout_seconds: Option, + ) -> PyResult> { + run_async( + py, + crate::function_trace::capture(prepare_transcription(AudioTranscriptionInputs { + model, + audio, + api_key, + api_base, + custom_llm_provider, + extra_headers, + optional_params, + timeout_seconds, + })?), + core_error_to_pyerr, + ) + } + + pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::super::super::add_function(module, wrap_pyfunction!(transcription, module)?)?; + super::super::super::add_function(module, wrap_pyfunction!(atranscription, module)?) + } +} + +#[cfg(feature = "trace-parity")] +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + trace::register(module) } diff --git a/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs b/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs index deaffa5258d..add9b708f32 100644 --- a/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs +++ b/litellm-rust/crates/python-bridge/src/routes/chat_completions/value.rs @@ -9,8 +9,20 @@ use pyo3::prelude::*; use serde_json::Value; use crate::errors::chat_completions_error_to_pyerr; +use crate::execution::{run_async, run_sync}; use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty, required_array}; +struct ChatCompletionsInputs { + model: String, + messages: Value, + optional_params: Option, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + extra_headers: Option, + timeout_seconds: Option, +} + fn prepare_chat_completions( inputs: ChatCompletionsInputs, ) -> PyResult> + Send + 'static> { @@ -66,26 +78,147 @@ fn chat_completions_decline( .map(str::to_string)) } -bridge_route! { - sync = chat_completions, - asynchronous = achat_completions, - inputs = ChatCompletionsInputs, - required = { +#[pyfunction] +#[pyo3(signature = (model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn chat_completions( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] messages: Value, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + timeout_seconds: Option, +) -> PyResult> { + run_sync( + py, + prepare_chat_completions(ChatCompletionsInputs { + model, + messages, + optional_params, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout_seconds, + })?, + chat_completions_error_to_pyerr, + ) +} + +#[pyfunction] +#[pyo3(signature = (model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn achat_completions( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] messages: Value, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + timeout_seconds: Option, +) -> PyResult> { + run_async( + py, + prepare_chat_completions(ChatCompletionsInputs { + model, + messages, + optional_params, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout_seconds, + })?, + chat_completions_error_to_pyerr, + ) +} + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::super::add_function(module, wrap_pyfunction!(chat_completions_decline, module)?)?; + super::super::add_function(module, wrap_pyfunction!(chat_completions, module)?)?; + super::super::add_function(module, wrap_pyfunction!(achat_completions, module)?) +} + +#[cfg(feature = "trace-parity")] +mod trace { + use super::{ + ChatCompletionsInputs, Value, chat_completions_error_to_pyerr, prepare_chat_completions, + run_async, run_sync, + }; + use pyo3::prelude::*; + + #[pyfunction] + #[pyo3(signature = (model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn chat_completions( + py: Python<'_>, model: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - messages: serde_json::Value, - }, - optional = { - #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] messages: Value, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, api_key: Option, api_base: Option, custom_llm_provider: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, timeout_seconds: Option, - }, - prepare = prepare_chat_completions, - errors = chat_completions_error_to_pyerr, - extra = [chat_completions_decline], + ) -> PyResult> { + run_sync( + py, + crate::function_trace::capture(prepare_chat_completions(ChatCompletionsInputs { + model, + messages, + optional_params, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout_seconds, + })?), + chat_completions_error_to_pyerr, + ) + } + + #[pyfunction] + #[pyo3(signature = (model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn achat_completions( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] messages: Value, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + timeout_seconds: Option, + ) -> PyResult> { + run_async( + py, + crate::function_trace::capture(prepare_chat_completions(ChatCompletionsInputs { + model, + messages, + optional_params, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout_seconds, + })?), + chat_completions_error_to_pyerr, + ) + } + + pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::super::super::add_function(module, wrap_pyfunction!(chat_completions, module)?)?; + super::super::super::add_function(module, wrap_pyfunction!(achat_completions, module)?) + } +} + +#[cfg(feature = "trace-parity")] +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + trace::register(module) } diff --git a/litellm-rust/crates/python-bridge/src/routes/definition.rs b/litellm-rust/crates/python-bridge/src/routes/definition.rs deleted file mode 100644 index 4c8d98ebe62..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/definition.rs +++ /dev/null @@ -1,501 +0,0 @@ -use pyo3::exceptions::PyRuntimeError; -use pyo3::prelude::*; -use pyo3::types::PyCFunction; - -macro_rules! bridge_route { - ( - sync = $sync_name:ident, - asynchronous = $async_name:ident, - inputs = $inputs:ident, - 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 - $(, extra = [$($extra:ident),* $(,)?])? - $(,)? - ) => { - struct $inputs { - $($required_name: $required_type,)* - $($optional_name: $optional_type),* - } - - #[pyfunction] - #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] - #[allow(clippy::too_many_arguments)] - fn $sync_name( - py: pyo3::Python<'_>, - $($(#[$required_attr])* $required_name: $required_type,)* - $($(#[$optional_attr])* $optional_name: $optional_type,)* - ) -> pyo3::PyResult> { - let future = $prepare($inputs { - $($required_name,)* - $($optional_name),* - })?; - $crate::execution::run_sync(py, future, $map_error) - } - - #[pyfunction] - #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] - #[allow(clippy::too_many_arguments)] - fn $async_name( - py: pyo3::Python<'_>, - $($(#[$required_attr])* $required_name: $required_type,)* - $($(#[$optional_attr])* $optional_name: $optional_type,)* - ) -> pyo3::PyResult> { - let future = $prepare($inputs { - $($required_name,)* - $($optional_name),* - })?; - $crate::execution::run_async(py, future, $map_error) - } - - pub(super) fn register( - module: &pyo3::Bound<'_, pyo3::types::PyModule>, - ) -> pyo3::PyResult<()> { - $($($crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($extra, module)?)?;)*)? - $crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($sync_name, module)?)?; - $crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($async_name, module)?)?; - Ok(()) - } - - }; -} - -pub(super) fn add_function( - module: &Bound<'_, PyModule>, - function: Bound<'_, PyCFunction>, -) -> PyResult<()> { - let name: String = function.getattr("__name__")?.extract()?; - if module.hasattr(&name)? { - return Err(PyRuntimeError::new_err(format!( - "duplicate native route: {name}" - ))); - } - module.add_function(function) -} - -#[cfg(test)] -mod tests { - use std::ffi::CString; - use std::sync::atomic::{AtomicBool, Ordering}; - - use litellm_core::messages::Error; - use pyo3::exceptions::PyLookupError; - use pyo3::types::{PyDict, PyList}; - - use super::*; - - mod synthetic { - use std::future::{Future, pending}; - - use super::*; - - static FUTURE_DROPPED: AtomicBool = AtomicBool::new(false); - - struct DropGuard; - - impl Drop for DropGuard { - fn drop(&mut self) { - FUTURE_DROPPED.store(true, Ordering::SeqCst); - } - } - - #[pyfunction] - fn future_dropped() -> bool { - FUTURE_DROPPED.load(Ordering::SeqCst) - } - - bridge_route! { - sync = echo, - asynchronous = aecho, - inputs = EchoInputs, - required = { value: String }, - optional = {}, - prepare = prepare_echo, - errors = map_error, - extra = [future_dropped], - } - - fn prepare_echo( - inputs: EchoInputs, - ) -> PyResult> + Send + 'static> { - FUTURE_DROPPED.store(false, Ordering::SeqCst); - let drop_guard = (inputs.value == "pending").then_some(DropGuard); - Ok(execute_echo(inputs, drop_guard)) - } - - async fn execute_echo( - inputs: EchoInputs, - drop_guard: Option, - ) -> Result { - let _drop_guard = drop_guard; - tokio::task::yield_now().await; - match inputs.value.as_str() { - "error" => Err(Error::InvalidRequest("synthetic error".to_string())), - "map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())), - "panic" => panic!("synthetic panic"), - "pending" => { - pending::<()>().await; - unreachable!() - } - _ => Ok(inputs.value), - } - } - - fn map_error(error: Error) -> PyErr { - if matches!(&error, Error::InvalidRequest(message) if message == "panic in mapper") { - panic!("synthetic mapper panic") - } - PyLookupError::new_err(error.to_string()) - } - } - - #[test] - fn sync_and_async_route_signatures_match_the_python_contract() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - let routes = [ - ( - "ocr", - "aocr", - "(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, input_sources=None, timeout_seconds=None)", - ), - ( - "transcription", - "atranscription", - "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", - ), - ( - "messages", - "amessages", - "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", - ), - ( - "chat_completions", - "achat_completions", - "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", - ), - ]; - - for (sync_name, async_name, expected) in routes { - let sync_signature: String = module - .getattr(sync_name) - .and_then(|function| function.getattr("__text_signature__")) - .and_then(|signature| signature.extract()) - .expect("sync signature should be available"); - let async_signature: String = module - .getattr(async_name) - .and_then(|function| function.getattr("__text_signature__")) - .and_then(|signature| signature.extract()) - .expect("async signature should be available"); - - assert_eq!(sync_signature, expected); - assert_eq!(async_signature, expected); - } - }); - } - - #[test] - fn sync_and_async_routes_apply_the_same_input_validation() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - - let invalid_messages = PyDict::new(py); - let sync_chat_error = module - .getattr("chat_completions") - .and_then(|function| function.call1(("model", &invalid_messages))) - .expect_err("sync chat should reject a non-list messages value"); - let async_chat_error = module - .getattr("achat_completions") - .and_then(|function| function.call1(("model", &invalid_messages))) - .expect_err("async chat should reject a non-list messages value"); - - assert_eq!( - sync_chat_error.to_string(), - "ValueError: messages must be a list" - ); - assert_eq!(async_chat_error.to_string(), sync_chat_error.to_string()); - - let invalid_body = PyList::empty(py); - let sync_messages_error = module - .getattr("messages") - .and_then(|function| function.call1(("model", &invalid_body))) - .expect_err("sync Messages should reject a non-dict body"); - let async_messages_error = module - .getattr("amessages") - .and_then(|function| function.call1(("model", &invalid_body))) - .expect_err("async Messages should reject a non-dict body"); - - assert_eq!( - sync_messages_error.to_string(), - "ValueError: body must be a dict" - ); - assert_eq!( - async_messages_error.to_string(), - sync_messages_error.to_string() - ); - - let invalid_headers = PyList::empty(py); - let kwargs = PyDict::new(py); - kwargs - .set_item("extra_headers", &invalid_headers) - .expect("kwargs should accept extra_headers"); - let document = PyDict::new(py); - - for (sync_name, async_name) in [("ocr", "aocr"), ("transcription", "atranscription")] { - let sync_error = module - .getattr(sync_name) - .and_then(|function| function.call(("model", &document), Some(&kwargs))) - .expect_err("sync route should reject non-dict extra_headers"); - let async_error = module - .getattr(async_name) - .and_then(|function| function.call(("model", &document), Some(&kwargs))) - .expect_err("async route should reject non-dict extra_headers"); - - assert_eq!( - sync_error.to_string(), - "ValueError: extra_headers must be a dict" - ); - assert_eq!(async_error.to_string(), sync_error.to_string()); - } - }); - } - - #[test] - fn route_input_validation_preserves_left_to_right_order() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - let invalid = PyList::empty(py); - - let chat_kwargs = PyDict::new(py); - chat_kwargs - .set_item("optional_params", &invalid) - .expect("kwargs should accept optional_params"); - chat_kwargs - .set_item("extra_headers", &invalid) - .expect("kwargs should accept extra_headers"); - let invalid_messages = PyDict::new(py); - let error = module - .getattr("chat_completions") - .and_then(|function| { - function.call(("model", &invalid_messages), Some(&chat_kwargs)) - }) - .expect_err("messages should be validated first"); - assert_eq!(error.to_string(), "ValueError: messages must be a list"); - - let valid_messages = PyList::empty(py); - let error = module - .getattr("chat_completions") - .and_then(|function| function.call(("model", &valid_messages), Some(&chat_kwargs))) - .expect_err("optional_params should be validated before headers"); - assert_eq!( - error.to_string(), - "ValueError: optional_params must be a dict" - ); - - let headers_kwargs = PyDict::new(py); - headers_kwargs - .set_item("extra_headers", &invalid) - .expect("kwargs should accept extra_headers"); - let invalid_body = PyList::empty(py); - let error = module - .getattr("messages") - .and_then(|function| function.call(("model", &invalid_body), Some(&headers_kwargs))) - .expect_err("body should be validated before headers"); - assert_eq!(error.to_string(), "ValueError: body must be a dict"); - - let invalid_payload = - PyModule::new(py, "invalid_payload").expect("invalid payload should be created"); - for name in ["ocr", "transcription"] { - let error = module - .getattr(name) - .and_then(|function| { - function.call(("model", &invalid_payload), Some(&headers_kwargs)) - }) - .expect_err("payload should be validated before headers"); - assert!(!error.to_string().contains("extra_headers")); - } - }); - } - - #[test] - fn missing_and_explicit_none_optional_params_share_the_next_error() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - let messages = PyList::empty(py); - let headers = PyList::empty(py); - let omitted = PyDict::new(py); - omitted - .set_item("extra_headers", &headers) - .expect("kwargs should accept extra_headers"); - let explicit = PyDict::new(py); - explicit - .set_item("optional_params", py.None()) - .expect("kwargs should accept optional_params"); - explicit - .set_item("extra_headers", &headers) - .expect("kwargs should accept extra_headers"); - - let omitted_error = module - .getattr("chat_completions") - .and_then(|function| function.call(("model", &messages), Some(&omitted))) - .expect_err("omitted optional_params should reach header validation"); - let explicit_error = module - .getattr("chat_completions") - .and_then(|function| function.call(("model", &messages), Some(&explicit))) - .expect_err("None optional_params should reach header validation"); - assert_eq!( - omitted_error.to_string(), - "ValueError: extra_headers must be a dict" - ); - assert_eq!(explicit_error.to_string(), omitted_error.to_string()); - }); - } - - #[test] - fn chat_completions_decline_keeps_existing_reasons() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "routes").expect("module should be created"); - crate::routes::register(&module).expect("routes should register"); - let decline = module - .getattr("chat_completions_decline") - .expect("decline helper should be registered"); - let empty = PyList::empty(py); - let unreadable = py - .eval(c"'nope'", None, None) - .expect("string messages should convert"); - - let unknown: Option = decline - .call1(("unknown-model", &empty)) - .and_then(|value| value.extract()) - .expect("unknown providers should decline"); - assert_eq!( - unknown.as_deref(), - Some("provider is not on the rust chat completions path") - ); - - let empty_reason: Option = decline - .call1(("anthropic/claude-sonnet-4-5", &empty)) - .and_then(|value| value.extract()) - .expect("empty lists should decline"); - assert_eq!(empty_reason.as_deref(), Some("empty message list")); - - let unreadable_reason: Option = decline - .call1(("anthropic/claude-sonnet-4-5", unreadable)) - .and_then(|value| value.extract()) - .expect("non-list messages should decline"); - assert_eq!( - unreadable_reason.as_deref(), - Some("unreadable message list") - ); - }); - } - - #[test] - fn generated_routes_execute_sync_and_async_contracts() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "synthetic").expect("module should be created"); - synthetic::register(&module).expect("routes should register"); - - let sync_value: String = module - .getattr("echo") - .and_then(|function| function.call1(("sync",))) - .and_then(|value| value.extract()) - .expect("sync route should return its value"); - assert_eq!(sync_value, "sync"); - - let sync_error = module - .getattr("echo") - .and_then(|function| function.call1(("error",))) - .expect_err("sync route should map its error"); - assert!(sync_error.is_instance_of::(py)); - assert_eq!( - sync_error.to_string(), - "LookupError: invalid request: synthetic error" - ); - - let locals = PyDict::new(py); - locals - .set_item("routes", &module) - .expect("module should enter Python locals"); - let code = CString::new( - r#" -import asyncio - -async def exercise(): - assert await routes.aecho("async") == "async" - - try: - await routes.aecho("error") - except LookupError as error: - assert str(error) == "invalid request: synthetic error" - else: - raise AssertionError("mapped error was not raised") - - try: - await routes.aecho("panic") - except BaseException as error: - assert type(error).__name__ == "PanicException" - assert str(error) == "synthetic panic" - else: - raise AssertionError("panic was not raised") - - try: - await routes.aecho("map_panic") - except BaseException as error: - assert type(error).__name__ == "PanicException" - assert str(error) == "synthetic mapper panic" - else: - raise AssertionError("mapper panic was not raised") - - task = asyncio.ensure_future(routes.aecho("pending")) - await asyncio.sleep(0) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - else: - raise AssertionError("cancelled route completed") - - for _ in range(100): - if routes.future_dropped(): - break - await asyncio.sleep(0.001) - assert routes.future_dropped() - -asyncio.run(exercise()) -"#, - ) - .expect("Python source should not contain null bytes"); - py.run(&code, Some(&locals), Some(&locals)) - .expect("async route contract should hold"); - }); - } - - #[test] - fn route_registration_rejects_duplicate_python_names() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "synthetic").expect("module should be created"); - synthetic::register(&module).expect("first registration should succeed"); - let error = synthetic::register(&module) - .expect_err("duplicate registration should be rejected"); - - assert_eq!( - error.to_string(), - "RuntimeError: duplicate native route: future_dropped" - ); - }); - } -} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages/value.rs b/litellm-rust/crates/python-bridge/src/routes/messages/value.rs index ed869105dd0..e4f4af5c87a 100644 --- a/litellm-rust/crates/python-bridge/src/routes/messages/value.rs +++ b/litellm-rust/crates/python-bridge/src/routes/messages/value.rs @@ -6,8 +6,19 @@ use serde_json::Value; use std::future::Future; use crate::errors::core_error_to_pyerr; +use crate::execution::{run_async, run_sync}; use crate::marshal::{RouteOptions, RouteOptionsInputs, required_object}; +struct MessagesInputs { + model: String, + body: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + extra_headers: Option, + timeout_seconds: Option, +} + fn prepare_messages( inputs: MessagesInputs, ) -> PyResult> + Send + 'static> { @@ -43,23 +54,137 @@ fn prepare_messages( }) } -bridge_route! { - sync = messages, - asynchronous = amessages, - inputs = MessagesInputs, - required = { +#[pyfunction] +#[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn messages( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] body: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + timeout_seconds: Option, +) -> PyResult> { + run_sync( + py, + prepare_messages(MessagesInputs { + model, + body, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout_seconds, + })?, + core_error_to_pyerr, + ) +} + +#[pyfunction] +#[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn amessages( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] body: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + timeout_seconds: Option, +) -> PyResult> { + run_async( + py, + prepare_messages(MessagesInputs { + model, + body, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout_seconds, + })?, + core_error_to_pyerr, + ) +} + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::super::add_function(module, wrap_pyfunction!(messages, module)?)?; + super::super::add_function(module, wrap_pyfunction!(amessages, module)?) +} + +#[cfg(feature = "trace-parity")] +mod trace { + use super::{ + MessagesInputs, Value, core_error_to_pyerr, prepare_messages, run_async, run_sync, + }; + use pyo3::prelude::*; + + #[pyfunction] + #[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn messages( + py: Python<'_>, model: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - body: serde_json::Value, - }, - optional = { + #[pyo3(from_py_with = litellm_python_interop::from_py)] body: Value, api_key: Option, api_base: Option, custom_llm_provider: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, timeout_seconds: Option, - }, - prepare = prepare_messages, - errors = core_error_to_pyerr, + ) -> PyResult> { + run_sync( + py, + crate::function_trace::capture(prepare_messages(MessagesInputs { + model, + body, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout_seconds, + })?), + core_error_to_pyerr, + ) + } + + #[pyfunction] + #[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn amessages( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] body: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + timeout_seconds: Option, + ) -> PyResult> { + run_async( + py, + crate::function_trace::capture(prepare_messages(MessagesInputs { + model, + body, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout_seconds, + })?), + core_error_to_pyerr, + ) + } + + pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::super::super::add_function(module, wrap_pyfunction!(messages, module)?)?; + super::super::super::add_function(module, wrap_pyfunction!(amessages, module)?) + } +} + +#[cfg(feature = "trace-parity")] +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + trace::register(module) } diff --git a/litellm-rust/crates/python-bridge/src/routes/mod.rs b/litellm-rust/crates/python-bridge/src/routes/mod.rs index 4e2530a94f8..16ead03a7af 100644 --- a/litellm-rust/crates/python-bridge/src/routes/mod.rs +++ b/litellm-rust/crates/python-bridge/src/routes/mod.rs @@ -1,13 +1,16 @@ use pyo3::prelude::*; +use pyo3::types::PyCFunction; -#[macro_use] -mod definition; +use pyo3::exceptions::PyRuntimeError; mod audio_transcription; mod chat_completions; mod messages; mod ocr; +#[cfg(test)] +mod tests; + pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { ocr::register(module)?; audio_transcription::register(module)?; @@ -15,3 +18,16 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { chat_completions::register(module)?; Ok(()) } + +pub(super) fn add_function( + module: &Bound<'_, PyModule>, + function: Bound<'_, PyCFunction>, +) -> PyResult<()> { + let name: String = function.getattr("__name__")?.extract()?; + if module.hasattr(&name)? { + return Err(PyRuntimeError::new_err(format!( + "duplicate native route: {name}" + ))); + } + module.add_function(function) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/value.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/value.rs index f2a068ead27..bdbbc40073d 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr/value.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/value.rs @@ -7,8 +7,21 @@ use serde_json::Value; use super::errors::to_pyerr as ocr_error_to_pyerr; use super::request::BridgeOcrRequest; +use crate::execution::{run_async, run_sync}; use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty}; +struct OcrInputs { + model: String, + document: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + extra_headers: Option, + optional_params: Option, + input_sources: Option, + timeout_seconds: Option, +} + fn prepare_ocr( inputs: OcrInputs, ) -> PyResult> + Send + 'static> { @@ -55,27 +68,151 @@ fn prepare_ocr( }) } -bridge_route! { - sync = ocr, - asynchronous = aocr, - inputs = OcrInputs, - required = { +#[pyfunction] +#[pyo3(signature = (model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, input_sources=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn ocr( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] document: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] input_sources: Option, + timeout_seconds: Option, +) -> PyResult> { + run_sync( + py, + prepare_ocr(OcrInputs { + model, + document, + api_key, + api_base, + custom_llm_provider, + extra_headers, + optional_params, + input_sources, + timeout_seconds, + })?, + ocr_error_to_pyerr, + ) +} + +#[pyfunction] +#[pyo3(signature = (model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, input_sources=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn aocr( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] document: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] input_sources: Option, + timeout_seconds: Option, +) -> PyResult> { + run_async( + py, + prepare_ocr(OcrInputs { + model, + document, + api_key, + api_base, + custom_llm_provider, + extra_headers, + optional_params, + input_sources, + timeout_seconds, + })?, + ocr_error_to_pyerr, + ) +} + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::super::add_function(module, wrap_pyfunction!(ocr, module)?)?; + super::super::add_function(module, wrap_pyfunction!(aocr, module)?) +} + +#[cfg(feature = "trace-parity")] +mod trace { + use super::{OcrInputs, Value, ocr_error_to_pyerr, prepare_ocr, run_async, run_sync}; + use pyo3::prelude::*; + + #[pyfunction] + #[pyo3(signature = (model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, input_sources=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn ocr( + py: Python<'_>, model: String, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - document: serde_json::Value, - }, - optional = { + #[pyo3(from_py_with = litellm_python_interop::from_py)] document: Value, api_key: Option, api_base: Option, custom_llm_provider: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, - #[pyo3(from_py_with = litellm_python_interop::from_py)] - input_sources: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] input_sources: Option, timeout_seconds: Option, - }, - prepare = prepare_ocr, - errors = ocr_error_to_pyerr, + ) -> PyResult> { + run_sync( + py, + crate::function_trace::capture(prepare_ocr(OcrInputs { + model, + document, + api_key, + api_base, + custom_llm_provider, + extra_headers, + optional_params, + input_sources, + timeout_seconds, + })?), + ocr_error_to_pyerr, + ) + } + + #[pyfunction] + #[pyo3(signature = (model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, input_sources=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn aocr( + py: Python<'_>, + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] document: Value, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] input_sources: Option, + timeout_seconds: Option, + ) -> PyResult> { + run_async( + py, + crate::function_trace::capture(prepare_ocr(OcrInputs { + model, + document, + api_key, + api_base, + custom_llm_provider, + extra_headers, + optional_params, + input_sources, + timeout_seconds, + })?), + ocr_error_to_pyerr, + ) + } + + pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::super::super::add_function(module, wrap_pyfunction!(ocr, module)?)?; + super::super::super::add_function(module, wrap_pyfunction!(aocr, module)?) + } +} + +#[cfg(feature = "trace-parity")] +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + trace::register(module) } diff --git a/litellm-rust/crates/python-bridge/src/routes/tests.rs b/litellm-rust/crates/python-bridge/src/routes/tests.rs new file mode 100644 index 00000000000..151a3cd8f34 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/tests.rs @@ -0,0 +1,274 @@ +use pyo3::types::{PyDict, PyList}; + +use super::*; + +#[test] +fn sync_and_async_route_signatures_match_the_python_contract() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + register(&module).expect("routes should register"); + let routes = [ + ( + "ocr", + "aocr", + "(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, input_sources=None, timeout_seconds=None)", + ), + ( + "transcription", + "atranscription", + "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", + ), + ( + "messages", + "amessages", + "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", + ), + ( + "chat_completions", + "achat_completions", + "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", + ), + ]; + + for (sync_name, async_name, expected) in routes { + let sync_signature: String = module + .getattr(sync_name) + .and_then(|function| function.getattr("__text_signature__")) + .and_then(|signature| signature.extract()) + .expect("sync signature should be available"); + let async_signature: String = module + .getattr(async_name) + .and_then(|function| function.getattr("__text_signature__")) + .and_then(|signature| signature.extract()) + .expect("async signature should be available"); + + assert_eq!(sync_signature, expected); + assert_eq!(async_signature, expected); + } + }); +} + +#[test] +fn sync_and_async_routes_apply_the_same_input_validation() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + register(&module).expect("routes should register"); + + let invalid_messages = PyDict::new(py); + let sync_chat_error = module + .getattr("chat_completions") + .and_then(|function| function.call1(("model", &invalid_messages))) + .expect_err("sync chat should reject a non-list messages value"); + let async_chat_error = module + .getattr("achat_completions") + .and_then(|function| function.call1(("model", &invalid_messages))) + .expect_err("async chat should reject a non-list messages value"); + + assert_eq!( + sync_chat_error.to_string(), + "ValueError: messages must be a list" + ); + assert_eq!(async_chat_error.to_string(), sync_chat_error.to_string()); + + let invalid_body = PyList::empty(py); + let sync_messages_error = module + .getattr("messages") + .and_then(|function| function.call1(("model", &invalid_body))) + .expect_err("sync Messages should reject a non-dict body"); + let async_messages_error = module + .getattr("amessages") + .and_then(|function| function.call1(("model", &invalid_body))) + .expect_err("async Messages should reject a non-dict body"); + + assert_eq!( + sync_messages_error.to_string(), + "ValueError: body must be a dict" + ); + assert_eq!( + async_messages_error.to_string(), + sync_messages_error.to_string() + ); + + let invalid_headers = PyList::empty(py); + let kwargs = PyDict::new(py); + kwargs + .set_item("extra_headers", &invalid_headers) + .expect("kwargs should accept extra_headers"); + let document = PyDict::new(py); + + for (sync_name, async_name) in [("ocr", "aocr"), ("transcription", "atranscription")] { + let sync_error = module + .getattr(sync_name) + .and_then(|function| function.call(("model", &document), Some(&kwargs))) + .expect_err("sync route should reject non-dict extra_headers"); + let async_error = module + .getattr(async_name) + .and_then(|function| function.call(("model", &document), Some(&kwargs))) + .expect_err("async route should reject non-dict extra_headers"); + + assert_eq!( + sync_error.to_string(), + "ValueError: extra_headers must be a dict" + ); + assert_eq!(async_error.to_string(), sync_error.to_string()); + } + }); +} + +#[test] +fn route_input_validation_preserves_left_to_right_order() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + register(&module).expect("routes should register"); + let invalid = PyList::empty(py); + + let chat_kwargs = PyDict::new(py); + chat_kwargs + .set_item("optional_params", &invalid) + .expect("kwargs should accept optional_params"); + chat_kwargs + .set_item("extra_headers", &invalid) + .expect("kwargs should accept extra_headers"); + let invalid_messages = PyDict::new(py); + let error = module + .getattr("chat_completions") + .and_then(|function| function.call(("model", &invalid_messages), Some(&chat_kwargs))) + .expect_err("messages should be validated first"); + assert_eq!(error.to_string(), "ValueError: messages must be a list"); + + let valid_messages = PyList::empty(py); + let error = module + .getattr("chat_completions") + .and_then(|function| function.call(("model", &valid_messages), Some(&chat_kwargs))) + .expect_err("optional_params should be validated before headers"); + assert_eq!( + error.to_string(), + "ValueError: optional_params must be a dict" + ); + + let headers_kwargs = PyDict::new(py); + headers_kwargs + .set_item("extra_headers", &invalid) + .expect("kwargs should accept extra_headers"); + let invalid_body = PyList::empty(py); + let error = module + .getattr("messages") + .and_then(|function| function.call(("model", &invalid_body), Some(&headers_kwargs))) + .expect_err("body should be validated before headers"); + assert_eq!(error.to_string(), "ValueError: body must be a dict"); + + let invalid_payload = + PyModule::new(py, "invalid_payload").expect("invalid payload should be created"); + for name in ["ocr", "transcription"] { + let error = module + .getattr(name) + .and_then(|function| { + function.call(("model", &invalid_payload), Some(&headers_kwargs)) + }) + .expect_err("payload should be validated before headers"); + assert!(!error.to_string().contains("extra_headers")); + } + }); +} + +#[test] +fn missing_and_explicit_none_optional_params_share_the_next_error() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + register(&module).expect("routes should register"); + let messages = PyList::empty(py); + let headers = PyList::empty(py); + let omitted = PyDict::new(py); + omitted + .set_item("extra_headers", &headers) + .expect("kwargs should accept extra_headers"); + let explicit = PyDict::new(py); + explicit + .set_item("optional_params", py.None()) + .expect("kwargs should accept optional_params"); + explicit + .set_item("extra_headers", &headers) + .expect("kwargs should accept extra_headers"); + + let omitted_error = module + .getattr("chat_completions") + .and_then(|function| function.call(("model", &messages), Some(&omitted))) + .expect_err("omitted optional_params should reach header validation"); + let explicit_error = module + .getattr("chat_completions") + .and_then(|function| function.call(("model", &messages), Some(&explicit))) + .expect_err("None optional_params should reach header validation"); + assert_eq!( + omitted_error.to_string(), + "ValueError: extra_headers must be a dict" + ); + assert_eq!(explicit_error.to_string(), omitted_error.to_string()); + }); +} + +#[test] +fn chat_completions_decline_keeps_existing_reasons() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + register(&module).expect("routes should register"); + let decline = module + .getattr("chat_completions_decline") + .expect("decline helper should be registered"); + let empty = PyList::empty(py); + let unreadable = py + .eval(c"'nope'", None, None) + .expect("string messages should convert"); + + let unknown: Option = decline + .call1(("unknown-model", &empty)) + .and_then(|value| value.extract()) + .expect("unknown providers should decline"); + assert_eq!( + unknown.as_deref(), + Some("provider is not on the rust chat completions path") + ); + + let empty_reason: Option = decline + .call1(("anthropic/claude-sonnet-4-5", &empty)) + .and_then(|value| value.extract()) + .expect("empty lists should decline"); + assert_eq!(empty_reason.as_deref(), Some("empty message list")); + + let unreadable_reason: Option = decline + .call1(("anthropic/claude-sonnet-4-5", unreadable)) + .and_then(|value| value.extract()) + .expect("non-list messages should decline"); + assert_eq!( + unreadable_reason.as_deref(), + Some("unreadable message list") + ); + }); +} + +#[cfg(feature = "trace-parity")] +#[test] +fn trace_routes_preserve_the_direct_route_signatures() { + Python::initialize(); + Python::attach(|py| { + let parent = PyModule::new(py, "routes").expect("module should be created"); + let module = PyModule::new(py, "_trace").expect("trace module should be created"); + ocr::register_trace(&module).expect("trace OCR route should register"); + parent + .add_submodule(&module) + .expect("trace module should be attached"); + let signature: String = module + .getattr("ocr") + .and_then(|function| function.getattr("__text_signature__")) + .and_then(|signature| signature.extract()) + .expect("trace signature should be available"); + assert_eq!( + signature, + "(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, input_sources=None, timeout_seconds=None)" + ); + }); +}