use std::collections::{BTreeMap, HashMap}; use std::time::Duration; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyDict; use serde_json::{Map, Value}; use litellm_auth::InputSource; use litellm_core::call_arguments::ArgumentSpec; use litellm_python_interop::from_py_preserving_errors as from_py; use crate::auth::{AZURE_AD_TOKEN_PROVIDER, PythonTokenProvider}; use crate::lifecycle::BoundArguments; pub(crate) struct Projection { pub native: Native, pub retained: Retained, } /// Fields every lifecycle route reads from its bound `*args, **kwargs` before /// asking core to build the typed request. Route-specific inputs (for example /// the OCR `document`) are read separately by the route. pub(crate) struct BoundRouteInputs { pub(crate) model: String, pub(crate) custom_llm_provider: Option, pub(crate) api_key: Option, pub(crate) api_base: Option, pub(crate) extra_headers: Map, pub(crate) timeout: Option, /// Only the optional params core reports as consumed plus opaque unknown /// values; bound/control fields are never serialized. pub(crate) optional_params: Map, /// Which projected fields were supplied via the proxy request body. pub(crate) input_sources: BTreeMap, pub(crate) azure_ad_token_provider: Option, /// Consumed params whose values must be redacted from logging. pub(crate) secret_fields: Vec<&'static str>, } const CONNECTION_FIELDS: [&str; 3] = ["api_key", "api_base", "extra_headers"]; impl BoundRouteInputs { /// `consumed` is the route's provider-selected optional param list for the /// already-extracted `model`/`custom_llm_provider`; `bound_fields` are the /// route's positional parameter names that must not be projected. pub(crate) fn extract( py: Python<'_>, arguments: &BoundArguments<'_>, consumed: Vec, bound_fields: &[&str], ) -> PyResult { let kwargs = arguments.kwargs(); let optional_params = project_optional_fields(kwargs, &consumed, bound_fields)?; let input_sources = request_input_sources( kwargs, optional_params .keys() .map(String::as_str) .chain(CONNECTION_FIELDS), )?; Ok(Self { model: arguments.extract("model")?, custom_llm_provider: arguments.optional("custom_llm_provider")?, api_key: arguments.optional("api_key")?, api_base: arguments.optional("api_base")?, extra_headers: arguments .optional::>("extra_headers")? .map(|value| { from_py(&value).and_then(|value| required_object("extra_headers", value)) }) .transpose()? .unwrap_or_default(), timeout: bound_timeout(py, arguments)?, optional_params, input_sources, azure_ad_token_provider: kwargs.get_item("azure_ad_token_provider")?.and_then( |provider| PythonTokenProvider::select(provider, AZURE_AD_TOKEN_PROVIDER), ), secret_fields: consumed .into_iter() .filter(|spec| spec.secret) .map(|spec| spec.name) .collect(), }) } } /// Reads `timeout` through `litellm.rust_bridge.timeouts.timeout_to_seconds` /// so Python `httpx.Timeout` objects keep their existing semantics. Unlike the /// value-route [`optional_timeout`], non-finite or negative values are /// rejected rather than silently dropped. fn bound_timeout(py: Python<'_>, arguments: &BoundArguments<'_>) -> PyResult> { arguments .optional::>("timeout")? .map(|value| python_timeout_seconds(py, value.unbind())) .transpose()? .flatten() .map(|seconds| { Duration::try_from_secs_f64(seconds) .map_err(|_| PyValueError::new_err("timeout must be a non-negative finite number")) }) .transpose() } pub(crate) struct RouteOptions { pub(crate) model: String, pub(crate) api_key: Option, pub(crate) api_base: Option, pub(crate) custom_llm_provider: Option, pub(crate) extra_headers: Option>, pub(crate) timeout: Option, } pub(crate) struct RouteOptionsInputs { pub(crate) model: String, pub(crate) api_key: Option, pub(crate) api_base: Option, pub(crate) custom_llm_provider: Option, pub(crate) extra_headers: Option, pub(crate) timeout_seconds: Option, } impl RouteOptions { pub(crate) fn from_python(inputs: RouteOptionsInputs) -> PyResult { Ok(Self { model: inputs.model, api_key: inputs.api_key, api_base: inputs.api_base, custom_llm_provider: inputs.custom_llm_provider, extra_headers: optional_object("extra_headers", inputs.extra_headers)?, timeout: optional_timeout(inputs.timeout_seconds), }) } } pub(crate) fn required_array(name: &'static str, value: Value) -> PyResult> { match value { Value::Array(values) => Ok(values), _ => Err(PyValueError::new_err(format!("{name} must be a list"))), } } pub(crate) fn required_object(name: &'static str, value: Value) -> PyResult> { match value { Value::Object(values) => Ok(values), _ => Err(PyValueError::new_err(format!("{name} must be a dict"))), } } pub(crate) fn object_or_empty( name: &'static str, value: Option, ) -> PyResult> { match value { Some(value) => required_object(name, value), None => Ok(Map::new()), } } fn optional_object( name: &'static str, value: Option, ) -> PyResult>> { value.map(|value| required_object(name, value)).transpose() } pub(crate) fn optional_timeout(timeout_seconds: Option) -> Option { timeout_seconds.and_then(|secs| { if secs.is_finite() && secs > 0.0 { Some(Duration::from_secs_f64(secs)) } else { None } }) } pub(crate) fn python_timeout_seconds(py: Python<'_>, timeout: Py) -> PyResult> { py.import("litellm.rust_bridge.timeouts")? .getattr("timeout_to_seconds")? .call1((timeout,))? .extract() } pub(crate) fn project_optional_fields( kwargs: &Bound<'_, PyDict>, fields: &[litellm_core::call_arguments::ArgumentSpec], bound_fields: &[&str], ) -> PyResult> { kwargs .iter() .map(|(name, value)| Ok((name.extract::()?, value))) .filter_map(|entry: PyResult<_>| match entry { Ok((name, value)) if litellm_core::call_arguments::should_project(&name, fields, bound_fields) => { Some(from_py(&value).map(|value| (name, value))) } Ok(_) => None, Err(error) => Some(Err(error)), }) .collect() } struct RequestFieldSources<'py> { body: Option>, credentials: Option>, } impl<'py> RequestFieldSources<'py> { fn extract(proxy_request: &Bound<'py, PyAny>) -> PyResult { let proxy_request = proxy_request.cast::()?; let body = proxy_request .get_item("body_fields")? .or(proxy_request.get_item("body")?); let credentials = proxy_request.get_item("credential_fields")?; Ok(Self { body, credentials }) } fn contains(&self, name: &str) -> bool { self.body .as_ref() .is_some_and(|fields| fields.contains(name).unwrap_or(false)) || self .credentials .as_ref() .is_some_and(|fields| fields.contains(name).unwrap_or(false)) } } pub(crate) fn request_input_sources<'a>( kwargs: &Bound<'_, PyDict>, names: impl Iterator, ) -> PyResult> { let Some(proxy_request) = kwargs.get_item("proxy_server_request")? else { return Ok(BTreeMap::new()); }; let sources = RequestFieldSources::extract(&proxy_request)?; Ok(names .filter(|name| sources.contains(name)) .map(|name| (name.to_string(), InputSource::Request)) .collect()) } pub(crate) fn marshal_headers(headers: Option) -> PyResult> { let value = match headers { Some(headers) => headers, None => Value::Object(Map::new()), }; let Value::Object(headers) = value else { return Err(PyValueError::new_err("headers must be a dict")); }; headers .into_iter() .map(|(name, value)| { value .as_str() .map(|value| (name, value.to_string())) .ok_or_else(|| PyValueError::new_err("header values must be strings")) }) .collect() } #[cfg(test)] mod tests { use super::*; use pyo3::exceptions::PyTypeError; use serde_json::json; fn eval<'py>(py: Python<'py>, source: &std::ffi::CStr) -> Bound<'py, PyDict> { let locals = PyDict::new(py); py.run(source, Some(&locals), Some(&locals)).unwrap(); locals } fn sources( py: Python<'_>, proxy: &Bound<'_, PyAny>, names: &[&str], ) -> PyResult> { let kwargs = PyDict::new(py); kwargs.set_item("proxy_server_request", proxy)?; request_input_sources(&kwargs, names.iter().copied()) } #[test] fn required_shapes_preserve_nested_values_and_existing_errors() { let nested = json!([{"role": "user", "content": [{"type": "text", "text": "hi"}]}]); assert_eq!( Value::Array(required_array("messages", nested.clone()).unwrap()), nested ); let body = json!({"model": "claude", "metadata": {"user": "1"}}); assert_eq!( Value::Object(required_object("body", body.clone()).unwrap()), body ); assert_eq!( required_array("messages", json!({"role": "user"})) .unwrap_err() .to_string(), "ValueError: messages must be a list" ); assert_eq!( required_object("body", json!([])).unwrap_err().to_string(), "ValueError: body must be a dict" ); } #[test] fn optional_parameters_treat_missing_as_empty() { assert_eq!( object_or_empty("optional_params", None).unwrap(), Map::new() ); assert_eq!( object_or_empty("optional_params", Some(json!({"temperature": 0.2}))).unwrap(), required_object("optional_params", json!({"temperature": 0.2})).unwrap() ); } #[test] fn missing_none_and_empty_proxy_metadata_are_distinct() { Python::initialize(); Python::attach(|py| { let kwargs = PyDict::new(py); assert!( request_input_sources(&kwargs, ["api_key"].into_iter()) .unwrap() .is_empty() ); kwargs.set_item("proxy_server_request", py.None()).unwrap(); assert!( request_input_sources(&kwargs, ["api_key"].into_iter()) .unwrap_err() .is_instance_of::(py) ); kwargs .set_item("proxy_server_request", PyDict::new(py)) .unwrap(); assert!( request_input_sources(&kwargs, ["api_key"].into_iter()) .unwrap() .is_empty() ); }); } #[test] fn body_fields_win_over_body_and_explicit_none_does_not_fall_back() { Python::initialize(); Python::attach(|py| { let locals = eval( py, c" proxy = {'body_fields': ['api_key'], 'body': ['api_base']} none_fields = {'body_fields': None, 'body': ['api_key']} body_only = {'body': ['api_base']} ", ); let named = sources( py, &locals.get_item("proxy").unwrap().unwrap(), &["api_key", "api_base"], ) .unwrap(); assert_eq!(named.get("api_key").copied(), Some(InputSource::Request)); assert!(!named.contains_key("api_base")); assert!( sources( py, &locals.get_item("none_fields").unwrap().unwrap(), &["api_key"], ) .unwrap() .is_empty() ); let body_only = sources( py, &locals.get_item("body_only").unwrap().unwrap(), &["api_base"], ) .unwrap(); assert_eq!( body_only.get("api_base").copied(), Some(InputSource::Request) ); }); } #[test] fn body_and_credential_membership_can_mark_request_fields() { Python::initialize(); Python::attach(|py| { let locals = eval( py, c" class Raising: def __contains__(self, item): raise RuntimeError('credential membership') proxy = { 'body_fields': ['api_key'], 'credential_fields': Raising(), } credentials_only = {'credential_fields': ['extra_headers']} erroring = {'body_fields': Raising()} extra = {'body_fields': ['api_key', 'unused']} ", ); let skipped = sources( py, &locals.get_item("proxy").unwrap().unwrap(), &["api_key"], ) .unwrap(); assert_eq!(skipped.get("api_key").copied(), Some(InputSource::Request)); let credentials = sources( py, &locals.get_item("credentials_only").unwrap().unwrap(), &["extra_headers"], ) .unwrap(); assert_eq!( credentials.get("extra_headers").copied(), Some(InputSource::Request) ); assert!( sources( py, &locals.get_item("erroring").unwrap().unwrap(), &["api_key"], ) .unwrap() .is_empty() ); let requested = sources( py, &locals.get_item("extra").unwrap().unwrap(), &["api_key"], ) .unwrap(); assert_eq!(requested.len(), 1); assert_eq!( requested.get("api_key").copied(), Some(InputSource::Request) ); }); } }