mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
* refactor(ocr): extract call completion boundary * fix(ocr): release completion state after dispatch * test(ocr): prove wrapper completion handoff * test(ocr): narrow mapped failure assertion * fix(ocr): preserve wrapper invocation kwargs * fix(ocr): retain completion through finalization * fix(ocr): make completion ownership explicit * refactor(ocr): resolve logging executor explicitly * fix(callbacks): preserve completion lifecycle behavior * refactor(ocr): move public OCR into native lifecycle * refactor(ocr): remove unused rust bridge capability * wip * wip * refactor * wip * fix(ocr): preserve reducto native compatibility * wip * fix(ocr): document native callable casts * perf(ocr): bound responses and reduce native scheduling overhead * refactor(python-bridge): organize placeholder routes * refactor test * fix(ocr): normalize DeepSeek document content * perf(ocr): skip unused callback work and benchmark callback overhead * fix(ocr): align conversion contracts * test(ocr): cover official provider response shapes * fix(ocr): restore Python fallback and honor Rust opt-out * fixes and refactor * fix(ocr): preserve Azure Document Intelligence authentication * fix(rust): enforce OCR response limits and lint contracts * test(rust): align native OCR contract coverage * test(ocr): isolate Azure auth precedence coverage
363 lines
11 KiB
Rust
363 lines
11 KiB
Rust
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_core::auth::InputSource;
|
|
use litellm_python_interop::from_py_preserving_errors as from_py;
|
|
|
|
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) 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) extra_headers: Option<Value>,
|
|
pub(crate) timeout_seconds: Option<f64>,
|
|
}
|
|
|
|
impl RouteOptions {
|
|
pub(crate) fn from_python(inputs: RouteOptionsInputs) -> PyResult<Self> {
|
|
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<Vec<Value>> {
|
|
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<Map<String, Value>> {
|
|
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<Value>,
|
|
) -> PyResult<Map<String, Value>> {
|
|
match value {
|
|
Some(value) => required_object(name, value),
|
|
None => Ok(Map::new()),
|
|
}
|
|
}
|
|
|
|
fn optional_object(
|
|
name: &'static str,
|
|
value: Option<Value>,
|
|
) -> PyResult<Option<Map<String, Value>>> {
|
|
value.map(|value| required_object(name, value)).transpose()
|
|
}
|
|
|
|
pub(crate) fn optional_timeout(timeout_seconds: Option<f64>) -> Option<Duration> {
|
|
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<PyAny>) -> PyResult<Option<f64>> {
|
|
py.import("litellm.rust_bridge.timeouts")?
|
|
.getattr("timeout_to_seconds")?
|
|
.call1((timeout,))?
|
|
.extract()
|
|
}
|
|
|
|
pub(crate) fn project_optional_fields(
|
|
kwargs: &Bound<'_, PyDict>,
|
|
names: &[&str],
|
|
) -> PyResult<Map<String, Value>> {
|
|
names
|
|
.iter()
|
|
.filter_map(|name| match kwargs.get_item(name) {
|
|
Ok(Some(value)) => Some(from_py(&value).map(|value| ((*name).to_string(), value))),
|
|
Ok(None) => None,
|
|
Err(error) => Some(Err(error)),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
struct RequestFieldSources<'py> {
|
|
body: Option<Bound<'py, PyAny>>,
|
|
credentials: Option<Bound<'py, PyAny>>,
|
|
}
|
|
|
|
impl<'py> RequestFieldSources<'py> {
|
|
fn extract(proxy_request: &Bound<'py, PyAny>) -> PyResult<Self> {
|
|
let proxy_request = proxy_request.cast::<PyDict>()?;
|
|
|
|
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<Item = &'a str>,
|
|
) -> PyResult<BTreeMap<String, InputSource>> {
|
|
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<Value>) -> PyResult<HashMap<String, String>> {
|
|
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<BTreeMap<String, InputSource>> {
|
|
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::<PyTypeError>(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)
|
|
);
|
|
});
|
|
}
|
|
}
|