feat(rust): add litellm-http client pool and inject it into the OCR route

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yujong Lee 2026-09-18 23:28:11 +00:00
parent 8e93031c19
commit abb9618971
19 changed files with 1037 additions and 62 deletions

View file

@ -2058,6 +2058,7 @@ dependencies = [
"litellm-auth-aws",
"litellm-callbacks",
"litellm-core-utils",
"litellm-http",
"litellm-llms",
"litellm-types",
"mime_guess",
@ -2125,6 +2126,16 @@ dependencies = [
"tokio",
]
[[package]]
name = "litellm-http"
version = "0.1.0"
dependencies = [
"reqwest 0.12.28",
"rstest",
"thiserror 2.0.19",
"tokio",
]
[[package]]
name = "litellm-llms"
version = "0.1.0"
@ -2142,6 +2153,7 @@ dependencies = [
"litellm-callbacks",
"litellm-core-utils",
"litellm-framing",
"litellm-http",
"litellm-types",
"reqwest 0.12.28",
"rstest",
@ -2166,6 +2178,7 @@ dependencies = [
"litellm-callbacks-legacy",
"litellm-core",
"litellm-host-python",
"litellm-http",
"litellm-llms",
"litellm-token-counter",
"litellm-types",

View file

@ -17,6 +17,7 @@ litellm-auth = { path = "crates/auth" }
litellm-auth-aws = { path = "crates/auth-aws" }
litellm-auth-azure = { path = "crates/auth-azure" }
litellm-auth-gcp = { path = "crates/auth-gcp" }
litellm-http = { path = "crates/http" }
litellm-llms = { path = "crates/llms" }
litellm-types = { path = "crates/types" }
litellm-core-utils = { path = "crates/core-utils" }

View file

@ -15,6 +15,7 @@ futures-util.workspace = true
base64.workspace = true
litellm-auth.workspace = true
litellm-auth-aws.workspace = true
litellm-http.workspace = true
litellm-llms.workspace = true
moka.workspace = true
mime_guess = "2.0.5"

View file

@ -1,3 +1,4 @@
use litellm_http::{HttpClientConfig, HttpClientPool};
use litellm_llms::{
base_llm::ocr::{error::Error, transformation::LiteLLMOcrResponse},
custom_httpx::llm_http_handler::OcrClient,
@ -15,6 +16,10 @@ pub async fn perform(
litellm_callbacks::run::run(ocr_machine(client.clone()), &LocalOcrHost::new(request)).await
}
pub async fn ocr(request: LiteLLMOcrRequest) -> Result<LiteLLMOcrResponse, Error> {
perform(&OcrClient::shared()?, request).await
pub async fn ocr(
pool: &HttpClientPool,
config: &HttpClientConfig,
request: LiteLLMOcrRequest,
) -> Result<LiteLLMOcrResponse, Error> {
perform(&OcrClient::new(pool, config)?, request).await
}

View file

@ -5,6 +5,7 @@ use litellm_callbacks::{
host::{Host, HostOp, HostResult},
machine::{HostFailure, Machine, MachineStep},
};
use litellm_http::{HttpClientConfig, HttpClientPool, HttpSettings, Verify};
use litellm_llms::{
base_llm::ocr::{
error::Error as OcrError,
@ -171,25 +172,44 @@ async fn facade_retains_native_response_when_requested() {
}
#[tokio::test]
async fn facade_uses_the_injected_http_client() {
async fn facade_uses_the_injected_http_pool_configuration() {
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
"x-transport-owner",
reqwest::header::HeaderValue::from_static("host"),
);
let provider_http = reqwest::Client::builder()
.default_headers(default_headers)
.build()
.unwrap();
crate::ocr::client::perform(
&OcrClient::new(provider_http).unwrap(),
let settings = HttpSettings {
user_agent: Some("host-owned/1".into()),
..HttpSettings::default()
};
let config = HttpClientConfig::resolve(&settings, None).unwrap();
crate::ocr::client::ocr(
&HttpClientPool::new(),
&config,
wire_request("mistral/model", &base, json!({})),
)
.await
.unwrap();
server.await.unwrap();
assert!(seen.lock().unwrap()[0].contains("x-transport-owner: host"));
assert!(seen.lock().unwrap()[0].contains("user-agent: host-owned/1"));
}
#[tokio::test]
async fn unbuildable_http_configuration_fails_before_dispatch() {
let (base, _seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
let config = HttpClientConfig {
verify: Verify::CaBundle(std::env::temp_dir().join("litellm-ocr-missing-bundle.pem")),
..HttpClientConfig::resolve(&HttpSettings::default(), None).unwrap()
};
let error = crate::ocr::client::ocr(
&HttpClientPool::new(),
&config,
wire_request("mistral/model", &base, json!({})),
)
.await
.unwrap_err();
server.abort();
assert!(matches!(
error,
OcrError::Transport(litellm_llms::custom_httpx::transport::Error::Connect(_))
));
assert!(error.to_string().contains("litellm-ocr-missing-bundle.pem"));
}
fn event_name(event: &CallEvent) -> &'static str {

View file

@ -0,0 +1,14 @@
[package]
name = "litellm-http"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
reqwest.workspace = true
thiserror.workspace = true
[dev-dependencies]
rstest.workspace = true
tokio.workspace = true

View file

@ -0,0 +1,327 @@
use std::{
net::{IpAddr, Ipv4Addr},
path::{Path, PathBuf},
time::Duration,
};
use crate::settings::{HttpSettings, SslVerify};
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {
#[error("{setting} cannot be expressed with rustls: {reason}")]
Unsupported {
setting: &'static str,
reason: String,
},
#[error("could not read {}: {message}", path.display())]
Read { path: PathBuf, message: String },
#[error("{} is not a PEM file: {message}", path.display())]
InvalidPem { path: PathBuf, message: String },
#[error("could not build the HTTP client: {0}")]
Client(String),
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
Self::Client(error.without_url().to_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Verify {
Disabled,
CaBundle(PathBuf),
BuiltInRoots,
}
/// One fully resolved client configuration. Every field is a plain value so the pool can
/// key cached clients on it.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct HttpClientConfig {
pub verify: Verify,
pub client_certificate: Option<PathBuf>,
pub force_ipv4: bool,
pub http2: bool,
pub user_agent: Option<String>,
pub trust_proxy_env: bool,
pub connect_timeout: Duration,
pub request_timeout: Option<Duration>,
}
impl HttpClientConfig {
/// Port of `get_ssl_verify` + `get_ssl_configuration`: the per-call value wins, then the
/// configured (environment-overlaid) `ssl_verify`, then `SSL_CERT_FILE`, then the built-in
/// roots. Settings rustls has no equivalent for are an error instead of a silent no-op.
pub fn resolve(
settings: &HttpSettings,
per_call_ssl_verify: Option<&SslVerify>,
) -> Result<Self, Error> {
if let Some(level) = &settings.ssl_security_level {
return Err(Error::Unsupported {
setting: "ssl_security_level",
reason: format!("OpenSSL cipher string {level:?} has no rustls equivalent"),
});
}
if let Some(curve) = &settings.ssl_ecdh_curve {
return Err(Error::Unsupported {
setting: "ssl_ecdh_curve",
reason: format!("key exchange group {curve:?} is fixed by the rustls provider"),
});
}
let verify = match per_call_ssl_verify.or(settings.ssl_verify.as_ref()) {
Some(SslVerify::Disabled) => Verify::Disabled,
Some(SslVerify::CaBundle(path)) => Verify::CaBundle(path.clone()),
Some(SslVerify::Enabled) | None => settings
.ssl_cert_file
.clone()
.map_or(Verify::BuiltInRoots, Verify::CaBundle),
};
Ok(Self {
verify,
client_certificate: settings.ssl_certificate.clone(),
force_ipv4: settings.force_ipv4,
http2: settings.http2,
user_agent: settings.user_agent.clone(),
trust_proxy_env: settings.trust_proxy_env,
connect_timeout: settings.connect_timeout,
request_timeout: settings.request_timeout,
})
}
/// A builder carrying every shared setting; variants add their own policy on top.
pub fn client_builder(&self) -> Result<reqwest::ClientBuilder, Error> {
let base = reqwest::Client::builder().connect_timeout(self.connect_timeout);
let with_roots = match &self.verify {
Verify::Disabled => base.danger_accept_invalid_certs(true),
Verify::BuiltInRoots => base,
Verify::CaBundle(path) => {
let pem = read(path)?;
let certificates =
reqwest::Certificate::from_pem_bundle(&pem).map_err(|error| {
Error::InvalidPem {
path: path.clone(),
message: error.without_url().to_string(),
}
})?;
if certificates.is_empty() {
return Err(Error::InvalidPem {
path: path.clone(),
message: "no certificates found".into(),
});
}
certificates.into_iter().fold(
base.tls_built_in_root_certs(false),
|builder, certificate| builder.add_root_certificate(certificate),
)
}
};
let with_identity = match &self.client_certificate {
None => with_roots,
Some(path) => {
let identity = reqwest::Identity::from_pem(&read(path)?).map_err(|error| {
Error::InvalidPem {
path: path.clone(),
message: error.without_url().to_string(),
}
})?;
with_roots.identity(identity)
}
};
let with_address = if self.force_ipv4 {
with_identity.local_address(IpAddr::V4(Ipv4Addr::UNSPECIFIED))
} else {
with_identity
};
let with_protocol = if self.http2 {
with_address
} else {
with_address.http1_only()
};
let with_agent = match &self.user_agent {
Some(agent) => with_protocol.user_agent(agent),
None => with_protocol,
};
let with_proxy = if self.trust_proxy_env {
with_agent
} else {
with_agent.no_proxy()
};
Ok(match self.request_timeout {
Some(timeout) => with_proxy.timeout(timeout),
None => with_proxy,
})
}
}
fn read(path: &Path) -> Result<Vec<u8>, Error> {
std::fs::read(path).map_err(|error| Error::Read {
path: path.to_path_buf(),
message: error.to_string(),
})
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
fn settings(ssl_verify: Option<SslVerify>, ssl_cert_file: Option<&str>) -> HttpSettings {
HttpSettings {
ssl_verify,
ssl_cert_file: ssl_cert_file.map(PathBuf::from),
..HttpSettings::default()
}
.with_environment(&no_env)
}
#[rstest]
#[case::default(settings(None, None), None, Verify::BuiltInRoots)]
#[case::setting_disables(
settings(Some(SslVerify::Disabled), Some("/env/roots.pem")),
None,
Verify::Disabled
)]
#[case::setting_bundle(
settings(Some(SslVerify::CaBundle("/configured.pem".into())), Some("/env/roots.pem")),
None,
Verify::CaBundle("/configured.pem".into())
)]
#[case::enabled_uses_cert_file(
settings(Some(SslVerify::Enabled), Some("/env/roots.pem")),
None,
Verify::CaBundle("/env/roots.pem".into())
)]
#[case::unset_uses_cert_file(settings(None, Some("/env/roots.pem")), None, Verify::CaBundle("/env/roots.pem".into()))]
#[case::per_call_beats_setting(
settings(Some(SslVerify::Disabled), None),
Some(SslVerify::Enabled),
Verify::BuiltInRoots
)]
#[case::per_call_disables(
settings(Some(SslVerify::CaBundle("/configured.pem".into())), Some("/env/roots.pem")),
Some(SslVerify::Disabled),
Verify::Disabled
)]
#[case::per_call_bundle(
settings(None, Some("/env/roots.pem")),
Some(SslVerify::CaBundle("/call.pem".into())),
Verify::CaBundle("/call.pem".into())
)]
#[case::per_call_enabled_still_honours_cert_file(
settings(Some(SslVerify::Disabled), Some("/env/roots.pem")),
Some(SslVerify::Enabled),
Verify::CaBundle("/env/roots.pem".into())
)]
fn verify_follows_per_call_then_setting_then_cert_file(
#[case] settings: HttpSettings,
#[case] per_call: Option<SslVerify>,
#[case] expected: Verify,
) {
let config = HttpClientConfig::resolve(&settings, per_call.as_ref()).unwrap();
assert_eq!(config.verify, expected);
}
#[test]
fn ssl_verify_environment_variable_beats_the_configured_setting() {
let settings = HttpSettings {
ssl_verify: Some(SslVerify::Disabled),
..HttpSettings::default()
}
.with_environment(&|name: &str| (name == "SSL_VERIFY").then(|| "true".to_string()));
let config = HttpClientConfig::resolve(&settings, None).unwrap();
assert_eq!(config.verify, Verify::BuiltInRoots);
}
#[test]
fn cipher_strings_are_rejected_rather_than_ignored() {
let settings = HttpSettings {
ssl_security_level: Some("DEFAULT@SECLEVEL=1".into()),
..HttpSettings::default()
};
assert!(matches!(
HttpClientConfig::resolve(&settings, None),
Err(Error::Unsupported {
setting: "ssl_security_level",
..
})
));
}
#[test]
fn ecdh_curves_are_rejected_rather_than_ignored() {
let settings = HttpSettings {
ssl_ecdh_curve: Some("X25519".into()),
..HttpSettings::default()
};
assert!(matches!(
HttpClientConfig::resolve(&settings, None),
Err(Error::Unsupported {
setting: "ssl_ecdh_curve",
..
})
));
}
#[test]
fn connection_settings_carry_over_unchanged() {
let settings = HttpSettings {
ssl_certificate: Some("/client.pem".into()),
force_ipv4: true,
http2: true,
user_agent: Some("litellm/1.0".into()),
trust_proxy_env: true,
connect_timeout: Duration::from_secs(7),
request_timeout: Some(Duration::from_secs(70)),
..HttpSettings::default()
};
let config = HttpClientConfig::resolve(&settings, None).unwrap();
assert_eq!(
config,
HttpClientConfig {
verify: Verify::BuiltInRoots,
client_certificate: Some("/client.pem".into()),
force_ipv4: true,
http2: true,
user_agent: Some("litellm/1.0".into()),
trust_proxy_env: true,
connect_timeout: Duration::from_secs(7),
request_timeout: Some(Duration::from_secs(70)),
}
);
}
#[test]
fn missing_ca_bundle_is_a_read_error() {
let path = std::env::temp_dir().join("litellm-http-missing-bundle.pem");
let config = HttpClientConfig {
verify: Verify::CaBundle(path.clone()),
..HttpClientConfig::resolve(&HttpSettings::default(), None).unwrap()
};
assert!(matches!(
config.client_builder(),
Err(Error::Read { path: reported, .. }) if reported == path
));
}
#[test]
fn non_pem_ca_bundle_is_an_invalid_pem_error() {
let path =
std::env::temp_dir().join(format!("litellm-http-not-pem-{}.pem", std::process::id()));
std::fs::write(&path, b"not a certificate").unwrap();
let config = HttpClientConfig {
verify: Verify::CaBundle(path.clone()),
..HttpClientConfig::resolve(&HttpSettings::default(), None).unwrap()
};
let result = config.client_builder().map(drop);
std::fs::remove_file(&path).unwrap();
assert!(matches!(
result,
Err(Error::InvalidPem { path: reported, .. }) if reported == path
));
}
}

View file

@ -0,0 +1,11 @@
//! Rust counterpart of `litellm/llms/custom_httpx/http_handler.py`: the plain HTTP settings
//! LiteLLM exposes, their resolution into one typed client configuration, and a pool that
//! caches `reqwest::Client`s per resolved configuration.
mod config;
mod pool;
mod settings;
pub use config::{Error, HttpClientConfig, Verify};
pub use pool::{ClientVariant, HttpClientPool};
pub use settings::{HttpSettings, SslVerify};

View file

@ -0,0 +1,172 @@
use std::{
collections::HashMap,
sync::{Mutex, PoisonError},
};
use crate::config::{Error, HttpClientConfig};
/// The client shapes routes need; each is the shared base plus one policy.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ClientVariant {
Provider,
NoRedirect,
/// Media downloads: no redirects (the fetcher validates each hop) and never a proxy.
Media,
}
impl ClientVariant {
fn apply(self, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
match self {
Self::Provider => builder,
Self::NoRedirect => builder.redirect(reqwest::redirect::Policy::none()),
Self::Media => builder
.redirect(reqwest::redirect::Policy::none())
.no_proxy(),
}
}
}
/// Counterpart of `get_async_httpx_client`: one `reqwest::Client` per resolved configuration
/// and variant, built on first use and shared afterwards.
#[derive(Default)]
pub struct HttpClientPool {
clients: Mutex<HashMap<(HttpClientConfig, ClientVariant), reqwest::Client>>,
}
impl HttpClientPool {
pub fn new() -> Self {
Self::default()
}
pub fn client(
&self,
config: &HttpClientConfig,
variant: ClientVariant,
) -> Result<reqwest::Client, Error> {
self.client_with(config, variant, |builder| builder)
}
/// Like [`Self::client`], with a caller hook for builder options that are not plain values
/// (a DNS resolver, for example). The hook only runs when the client is first built.
pub fn client_with(
&self,
config: &HttpClientConfig,
variant: ClientVariant,
customize: impl FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder,
) -> Result<reqwest::Client, Error> {
let key = (config.clone(), variant);
let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(client) = clients.get(&key) {
return Ok(client.clone());
}
let client = customize(variant.apply(config.client_builder()?)).build()?;
clients.insert(key, client.clone());
Ok(client)
}
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, time::Duration};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::*;
use crate::{HttpSettings, Verify};
fn config(user_agent: &str) -> HttpClientConfig {
HttpClientConfig {
user_agent: Some(user_agent.into()),
..HttpClientConfig::resolve(&HttpSettings::default(), None).unwrap()
}
}
#[test]
fn clients_are_built_once_per_config_and_variant() {
let pool = HttpClientPool::new();
let builds = Cell::new(0);
let build = |config: &HttpClientConfig, variant| {
pool.client_with(config, variant, |builder| {
builds.set(builds.get() + 1);
builder
})
.unwrap()
};
build(&config("a"), ClientVariant::Provider);
build(&config("a"), ClientVariant::Provider);
assert_eq!(builds.get(), 1);
build(&config("a"), ClientVariant::NoRedirect);
assert_eq!(builds.get(), 2);
build(&config("b"), ClientVariant::Provider);
assert_eq!(builds.get(), 3);
build(&config("b"), ClientVariant::Provider);
build(&config("a"), ClientVariant::NoRedirect);
assert_eq!(builds.get(), 3);
}
#[test]
fn build_failures_are_not_cached() {
let pool = HttpClientPool::new();
let missing = HttpClientConfig {
verify: Verify::CaBundle(std::env::temp_dir().join("litellm-http-absent.pem")),
..config("a")
};
assert!(pool.client(&missing, ClientVariant::Provider).is_err());
assert!(pool.client(&missing, ClientVariant::Provider).is_err());
assert!(pool.client(&config("a"), ClientVariant::Provider).is_ok());
}
async fn serve_once(status_line: &'static str) -> (String, tokio::task::JoinHandle<String>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = vec![0u8; 4096];
let read = socket.read(&mut request).await.unwrap();
socket
.write_all(
format!("{status_line}\r\nLocation: /elsewhere\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
String::from_utf8_lossy(&request[..read]).into_owned()
});
(base, server)
}
#[tokio::test]
async fn provider_client_sends_the_configured_user_agent_over_http1() {
let (base, server) = serve_once("HTTP/1.1 204 No Content").await;
let config = HttpClientConfig {
connect_timeout: Duration::from_secs(2),
..config("litellm-test/9")
};
let response = HttpClientPool::new()
.client(&config, ClientVariant::Provider)
.unwrap()
.get(&base)
.send()
.await
.unwrap();
assert_eq!(response.status(), 204);
assert_eq!(response.version(), reqwest::Version::HTTP_11);
let request = server.await.unwrap();
assert!(request.contains("user-agent: litellm-test/9"), "{request}");
}
#[tokio::test]
async fn no_redirect_variant_returns_the_redirect_instead_of_following_it() {
let (base, server) = serve_once("HTTP/1.1 302 Found").await;
let response = HttpClientPool::new()
.client(&config("a"), ClientVariant::NoRedirect)
.unwrap()
.get(&base)
.send()
.await
.unwrap();
assert_eq!(response.status(), 302);
assert_eq!(response.headers()["location"], "/elsewhere");
server.await.unwrap();
}
}

View file

@ -0,0 +1,171 @@
use std::{path::PathBuf, time::Duration};
/// `litellm.ssl_verify` / `SSL_VERIFY`: a bool or a CA bundle path.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SslVerify {
Enabled,
Disabled,
CaBundle(PathBuf),
}
impl SslVerify {
pub fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"true" => Self::Enabled,
"false" => Self::Disabled,
_ => Self::CaBundle(PathBuf::from(value)),
}
}
}
/// The plain inputs `http_handler.py` reads from `litellm.*` globals and the environment.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpSettings {
pub ssl_verify: Option<SslVerify>,
pub ssl_cert_file: Option<PathBuf>,
pub ssl_certificate: Option<PathBuf>,
pub ssl_security_level: Option<String>,
pub ssl_ecdh_curve: Option<String>,
pub force_ipv4: bool,
pub http2: bool,
pub user_agent: Option<String>,
pub trust_proxy_env: bool,
pub connect_timeout: Duration,
pub request_timeout: Option<Duration>,
}
impl Default for HttpSettings {
fn default() -> Self {
Self {
ssl_verify: None,
ssl_cert_file: None,
ssl_certificate: None,
ssl_security_level: None,
ssl_ecdh_curve: None,
force_ipv4: false,
http2: false,
user_agent: None,
trust_proxy_env: false,
connect_timeout: Duration::from_secs(5),
request_timeout: None,
}
}
}
impl HttpSettings {
/// Overlay the environment variables `http_handler.py` consults, with the same precedence:
/// `SSL_VERIFY`, `SSL_CERTIFICATE`, `SSL_SECURITY_LEVEL`, `SSL_ECDH_CURVE` and
/// `LITELLM_USER_AGENT` win over the configured value; `SSL_CERT_FILE` only applies when
/// verification is on without an explicit bundle; `LITELLM_HTTP2` and `AIOHTTP_TRUST_ENV`
/// can only turn their switch on.
pub fn with_environment(self, env: &(dyn Fn(&str) -> Option<String> + Sync)) -> Self {
let enabled =
|name: &str| env(name).is_some_and(|value| value.trim().eq_ignore_ascii_case("true"));
Self {
ssl_verify: env("SSL_VERIFY")
.map(|value| SslVerify::parse(&value))
.or(self.ssl_verify),
ssl_cert_file: env("SSL_CERT_FILE")
.map(PathBuf::from)
.or(self.ssl_cert_file),
ssl_certificate: env("SSL_CERTIFICATE")
.map(PathBuf::from)
.or(self.ssl_certificate),
ssl_security_level: env("SSL_SECURITY_LEVEL").or(self.ssl_security_level),
ssl_ecdh_curve: env("SSL_ECDH_CURVE").or(self.ssl_ecdh_curve),
http2: self.http2 || enabled("LITELLM_HTTP2"),
user_agent: env("LITELLM_USER_AGENT").or(self.user_agent),
trust_proxy_env: self.trust_proxy_env || enabled("AIOHTTP_TRUST_ENV"),
..self
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
fn env_of(
values: &'static [(&'static str, &'static str)],
) -> impl Fn(&str) -> Option<String> + Sync {
move |name| {
values
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| value.to_string())
}
}
#[rstest]
#[case("true", SslVerify::Enabled)]
#[case(" True ", SslVerify::Enabled)]
#[case("FALSE", SslVerify::Disabled)]
#[case("/etc/ssl/bundle.pem", SslVerify::CaBundle("/etc/ssl/bundle.pem".into()))]
fn ssl_verify_parses_bools_and_treats_anything_else_as_a_bundle_path(
#[case] value: &str,
#[case] expected: SslVerify,
) {
assert_eq!(SslVerify::parse(value), expected);
}
#[test]
fn environment_overrides_configured_ssl_values() {
let settings = HttpSettings {
ssl_verify: Some(SslVerify::Enabled),
ssl_certificate: Some("/configured/client.pem".into()),
ssl_security_level: Some("configured".into()),
user_agent: Some("configured/1".into()),
..HttpSettings::default()
}
.with_environment(&env_of(&[
("SSL_VERIFY", "false"),
("SSL_CERT_FILE", "/env/roots.pem"),
("SSL_CERTIFICATE", "/env/client.pem"),
("SSL_SECURITY_LEVEL", "DEFAULT@SECLEVEL=1"),
("SSL_ECDH_CURVE", "X25519"),
("LITELLM_USER_AGENT", "env/2"),
]));
assert_eq!(settings.ssl_verify, Some(SslVerify::Disabled));
assert_eq!(settings.ssl_cert_file, Some("/env/roots.pem".into()));
assert_eq!(settings.ssl_certificate, Some("/env/client.pem".into()));
assert_eq!(
settings.ssl_security_level.as_deref(),
Some("DEFAULT@SECLEVEL=1")
);
assert_eq!(settings.ssl_ecdh_curve.as_deref(), Some("X25519"));
assert_eq!(settings.user_agent.as_deref(), Some("env/2"));
}
#[test]
fn missing_environment_keeps_configured_values() {
let configured = HttpSettings {
ssl_verify: Some(SslVerify::CaBundle("/configured/roots.pem".into())),
http2: true,
trust_proxy_env: true,
user_agent: Some("configured/1".into()),
..HttpSettings::default()
};
assert_eq!(configured.clone().with_environment(&no_env), configured);
}
#[rstest]
#[case("true", true)]
#[case("True", true)]
#[case("false", false)]
#[case("1", false)]
fn boolean_switches_only_turn_on_for_true(#[case] value: &'static str, #[case] expected: bool) {
let env = move |name: &str| match name {
"LITELLM_HTTP2" | "AIOHTTP_TRUST_ENV" => Some(value.to_string()),
_ => None,
};
let settings = HttpSettings::default().with_environment(&env);
assert_eq!(settings.http2, expected);
assert_eq!(settings.trust_proxy_env, expected);
}
}

View file

@ -17,6 +17,7 @@ litellm-auth-azure.workspace = true
litellm-auth-gcp.workspace = true
litellm-callbacks.workspace = true
litellm-framing.workspace = true
litellm-http.workspace = true
base64.workspace = true
bytes.workspace = true
data-url = "0.3.2"

View file

@ -21,7 +21,6 @@ use crate::{
pub const OCR_RESPONSE_MAX_BYTES: usize = 64 * 1024 * 1024;
pub const OCR_HTTP_TIMEOUT_SECS: u64 = 600;
pub const OCR_CONNECT_TIMEOUT_SECS: u64 = 10;
pub const OCR_INLINE_MAX_BYTES: usize = 50 * 1024 * 1024;
pub const OCR_DOWNLOAD_MAX_BYTES: u64 = 50 * 1024 * 1024;
pub const OCR_MAX_FETCH_REDIRECTS: usize = 10;

View file

@ -1,9 +1,8 @@
use std::{sync::OnceLock, time::Duration};
use bytes::{Bytes, BytesMut};
use futures_util::future::BoxFuture;
use litellm_auth_gcp::VertexAuth;
use litellm_callbacks::event::{Passthrough, WireRequest};
use litellm_http::{ClientVariant, HttpClientConfig, HttpClientPool};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Map, Value};
@ -11,9 +10,8 @@ use crate::{
base_llm::ocr::{
error::Error,
transformation::{
BaseOcrConfig, DecodedOcrResponse, LiteLLMOcrResponse, OCR_CONNECT_TIMEOUT_SECS,
OcrDocument, OcrResponseContext, PreparedOcrRequest, decode_request_value,
decode_response,
BaseOcrConfig, DecodedOcrResponse, LiteLLMOcrResponse, OcrDocument, OcrResponseContext,
PreparedOcrRequest, decode_request_value, decode_response,
},
},
custom_httpx::{
@ -44,30 +42,15 @@ pub struct OcrClient {
}
impl OcrClient {
pub fn new(provider_http: reqwest::Client) -> Result<Self, transport::Error> {
let document_fetcher = MediaFetcher::new().map_err(transport::Error::from)?;
pub fn new(pool: &HttpClientPool, config: &HttpClientConfig) -> Result<Self, transport::Error> {
Ok(Self {
provider_http,
polling_http: no_redirect_http()?,
document_fetcher,
provider_http: pool.client(config, ClientVariant::Provider)?,
polling_http: pool.client(config, ClientVariant::NoRedirect)?,
document_fetcher: MediaFetcher::new(pool, config)?,
vertex_auth: VertexAuth::default(),
})
}
pub fn shared() -> Result<Self, Error> {
static CLIENT: OnceLock<Result<OcrClient, transport::Error>> = OnceLock::new();
let client = CLIENT
.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(OCR_CONNECT_TIMEOUT_SECS))
.build()
.map_err(transport::Error::from)
.and_then(OcrClient::new)
})
.clone()?;
Ok(client)
}
pub fn provider_http(&self) -> &reqwest::Client {
&self.provider_http
}
@ -88,21 +71,16 @@ impl OcrClient {
pub fn for_test(provider_http: reqwest::Client, document_http: reqwest::Client) -> Self {
Self {
provider_http,
polling_http: no_redirect_http().expect("test polling client builds"),
polling_http: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("test polling client builds"),
document_fetcher: MediaFetcher::for_test(document_http),
vertex_auth: VertexAuth::default(),
}
}
}
fn no_redirect_http() -> Result<reqwest::Client, transport::Error> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(OCR_CONNECT_TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(transport::Error::from)
}
/// Rust counterpart of `BaseLLMHTTPHandler.async_ocr`: prepare the provider request,
/// send it, and hand the response to the config for normalization.
pub async fn ocr<C: BaseOcrConfig>(
@ -318,6 +296,8 @@ pub fn body_document(body: &Value) -> Result<OcrDocument, Error> {
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
#[tokio::test]

View file

@ -7,13 +7,12 @@ use std::{
time::Duration,
};
use litellm_http::{ClientVariant, HttpClientConfig, HttpClientPool};
use reqwest::{
Url,
dns::{Addrs, Name, Resolve, Resolving},
};
const MEDIA_CONNECT_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("media URL rejected by network policy")]
@ -63,23 +62,30 @@ pub struct DownloadedMedia {
}
impl MediaFetcher {
pub fn new() -> Result<Self, reqwest::Error> {
Self::with_resolvers(Arc::new(PublicDnsResolver), Arc::new(SystemAddressResolver))
pub fn new(
pool: &HttpClientPool,
config: &HttpClientConfig,
) -> Result<Self, litellm_http::Error> {
Self::with_resolvers(
pool,
config,
Arc::new(PublicDnsResolver),
Arc::new(SystemAddressResolver),
)
}
fn with_resolvers<R>(
pool: &HttpClientPool,
config: &HttpClientConfig,
transport_resolver: Arc<R>,
address_resolver: Arc<dyn AddressResolver>,
) -> Result<Self, reqwest::Error>
) -> Result<Self, litellm_http::Error>
where
R: Resolve + 'static,
{
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(MEDIA_CONNECT_TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.dns_resolver(transport_resolver)
.build()?;
let client = pool.client_with(config, ClientVariant::Media, |builder| {
builder.dns_resolver(transport_resolver)
})?;
Ok(Self {
client,
address_resolver,
@ -281,6 +287,7 @@ impl Resolve for PublicDnsResolver {
mod tests {
use std::collections::HashSet;
use litellm_http::HttpSettings;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
@ -365,6 +372,8 @@ mod tests {
blocked_hosts: HashSet<&'static str>,
) -> MediaFetcher {
MediaFetcher::with_resolvers(
&HttpClientPool::new(),
&HttpClientConfig::resolve(&HttpSettings::default(), None).unwrap(),
Arc::new(LoopbackDnsResolver(address)),
Arc::new(TestAddressResolver { blocked_hosts }),
)
@ -542,7 +551,12 @@ mod tests {
#[tokio::test]
async fn rejects_url_credentials_before_network_access() {
let fetcher = MediaFetcher::new().expect("media fetcher builds");
let fetcher = MediaFetcher::new(
&HttpClientPool::new(),
&HttpClientConfig::resolve(&litellm_http::HttpSettings::default(), None)
.expect("default settings resolve"),
)
.expect("media fetcher builds");
let url =
Url::parse("https://user:password@8.8.8.8/document").expect("credentialed URL parses");
assert!(matches!(

View file

@ -26,6 +26,12 @@ impl From<reqwest::Error> for Error {
}
}
impl From<litellm_http::Error> for Error {
fn from(error: litellm_http::Error) -> Self {
Self::Connect(error.to_string())
}
}
#[cfg(test)]
mod tests {
#[tokio::test]

View file

@ -20,6 +20,7 @@ bytes.workspace = true
litellm-auth.workspace = true
litellm-callbacks-legacy.workspace = true
litellm-core.workspace = true
litellm-http.workspace = true
litellm-llms.workspace = true
litellm-types.workspace = true
litellm-host-python.workspace = true

View file

@ -0,0 +1,234 @@
use std::{path::PathBuf, sync::LazyLock};
use litellm_http::{HttpClientConfig, HttpClientPool, HttpSettings, SslVerify};
use pyo3::{prelude::*, types::PyDict};
use crate::errors::RustBridgeDeclined;
static POOL: LazyLock<HttpClientPool> = LazyLock::new(HttpClientPool::new);
/// Keyword arguments that carry a live Python HTTP client or session. They cannot cross into
/// Rust, so a call that supplies one stays on the Python path.
const LIVE_CLIENT_ARGUMENTS: [&str; 3] = ["client", "shared_session", "aclient_session"];
pub(crate) fn pool() -> &'static HttpClientPool {
&POOL
}
/// The client configuration for one call: the process settings from the `litellm` module and
/// the environment, narrowed by the call's own `ssl_verify`.
pub(crate) fn call_config(
py: Python<'_>,
kwargs: &Bound<'_, PyDict>,
) -> PyResult<HttpClientConfig> {
decline_live_clients(kwargs)?;
let settings = settings(py.import("litellm")?.as_any())?
.with_environment(&|name| std::env::var(name).ok());
let per_call = kwargs
.get_item("ssl_verify")?
.map(|value| ssl_verify(&value, "ssl_verify"))
.transpose()?
.flatten();
HttpClientConfig::resolve(&settings, per_call.as_ref())
.map_err(|error| RustBridgeDeclined::new_err(error.to_string()))
}
pub(crate) fn decline_live_clients(kwargs: &Bound<'_, PyDict>) -> PyResult<()> {
for name in LIVE_CLIENT_ARGUMENTS {
if kwargs.get_item(name)?.is_some_and(|value| !value.is_none()) {
return Err(RustBridgeDeclined::new_err(format!(
"{name} is a live Python HTTP client and cannot be used by the Rust route"
)));
}
}
Ok(())
}
/// Read the `litellm.*` globals `http_handler.py` consults. `globals` is the `litellm` module in
/// production and any attribute holder in tests.
pub(crate) fn settings(globals: &Bound<'_, PyAny>) -> PyResult<HttpSettings> {
Ok(HttpSettings {
ssl_verify: ssl_verify(&globals.getattr("ssl_verify")?, "litellm.ssl_verify")?,
ssl_certificate: optional_path(globals, "ssl_certificate")?,
ssl_security_level: globals.getattr("ssl_security_level")?.extract()?,
ssl_ecdh_curve: globals.getattr("ssl_ecdh_curve")?.extract()?,
force_ipv4: globals.getattr("force_ipv4")?.extract()?,
http2: globals.getattr("http2")?.extract()?,
trust_proxy_env: globals.getattr("aiohttp_trust_env")?.extract()?,
..HttpSettings::default()
})
}
fn optional_path(globals: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<PathBuf>> {
Ok(globals
.getattr(name)?
.extract::<Option<String>>()?
.map(PathBuf::from))
}
fn ssl_verify(value: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<SslVerify>> {
if value.is_none() {
return Ok(None);
}
if let Ok(enabled) = value.extract::<bool>() {
return Ok(Some(if enabled {
SslVerify::Enabled
} else {
SslVerify::Disabled
}));
}
if let Ok(path) = value.extract::<String>() {
return Ok(Some(SslVerify::CaBundle(PathBuf::from(path))));
}
Err(RustBridgeDeclined::new_err(format!(
"{name} is a live Python object and cannot be used by the Rust route"
)))
}
#[cfg(test)]
mod tests {
use litellm_http::Verify;
use rstest::rstest;
use super::*;
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 globals<'py>(py: Python<'py>, overrides: &str) -> Bound<'py, PyAny> {
let source = format!(
"
import types
globals = types.SimpleNamespace(
ssl_verify=True,
ssl_certificate=None,
ssl_security_level=None,
ssl_ecdh_curve=None,
force_ipv4=False,
http2=False,
aiohttp_trust_env=False,
)
{overrides}
"
);
let source = std::ffi::CString::new(source).unwrap();
eval(py, &source).get_item("globals").unwrap().unwrap()
}
#[test]
fn default_globals_produce_default_settings_with_verification_on() {
Python::initialize();
Python::attach(|py| {
let settings = settings(&globals(py, "")).unwrap();
assert_eq!(
settings,
HttpSettings {
ssl_verify: Some(SslVerify::Enabled),
..HttpSettings::default()
}
);
});
}
#[test]
fn globals_flow_into_settings() {
Python::initialize();
Python::attach(|py| {
let settings = settings(&globals(
py,
"
globals.ssl_verify = '/etc/ssl/corp.pem'
globals.ssl_certificate = '/etc/ssl/client.pem'
globals.ssl_security_level = '2'
globals.ssl_ecdh_curve = 'X25519'
globals.force_ipv4 = True
globals.http2 = True
globals.aiohttp_trust_env = True
",
))
.unwrap();
assert_eq!(
settings,
HttpSettings {
ssl_verify: Some(SslVerify::CaBundle("/etc/ssl/corp.pem".into())),
ssl_certificate: Some("/etc/ssl/client.pem".into()),
ssl_security_level: Some("2".into()),
ssl_ecdh_curve: Some("X25519".into()),
force_ipv4: true,
http2: true,
trust_proxy_env: true,
..HttpSettings::default()
}
);
});
}
#[test]
fn disabled_verification_global_resolves_to_disabled() {
Python::initialize();
Python::attach(|py| {
let settings = settings(&globals(py, "globals.ssl_verify = False")).unwrap();
assert_eq!(settings.ssl_verify, Some(SslVerify::Disabled));
let config = HttpClientConfig::resolve(&settings, None).unwrap();
assert_eq!(config.verify, Verify::Disabled);
});
}
#[test]
fn ssl_context_global_declines_instead_of_being_dropped() {
Python::initialize();
Python::attach(|py| {
let error = settings(&globals(py, "globals.ssl_verify = object()")).unwrap_err();
assert!(error.is_instance_of::<RustBridgeDeclined>(py));
assert!(error.value(py).to_string().contains("litellm.ssl_verify"));
});
}
#[rstest]
#[case::client("client")]
#[case::shared_session("shared_session")]
#[case::aclient_session("aclient_session")]
fn live_python_clients_decline_before_dispatch(#[case] name: &str) {
Python::initialize();
Python::attach(|py| {
let kwargs = PyDict::new(py);
kwargs
.set_item(name, py.eval(c"object()", None, None).unwrap())
.unwrap();
let error = decline_live_clients(&kwargs).unwrap_err();
assert!(error.is_instance_of::<RustBridgeDeclined>(py));
assert!(error.value(py).to_string().contains(name));
});
}
#[test]
fn none_valued_client_arguments_are_not_live_clients() {
Python::initialize();
Python::attach(|py| {
let kwargs = PyDict::new(py);
for name in LIVE_CLIENT_ARGUMENTS {
kwargs.set_item(name, py.None()).unwrap();
}
decline_live_clients(&kwargs).unwrap();
});
}
#[rstest]
#[case::disabled(c"False", Some(SslVerify::Disabled))]
#[case::enabled(c"True", Some(SslVerify::Enabled))]
#[case::bundle(c"'/tmp/ca.pem'", Some(SslVerify::CaBundle("/tmp/ca.pem".into())))]
#[case::unset(c"None", None)]
fn per_call_ssl_verify_values_project(
#[case] source: &std::ffi::CStr,
#[case] expected: Option<SslVerify>,
) {
Python::initialize();
Python::attach(|py| {
let value = py.eval(source, None, None).unwrap();
assert_eq!(ssl_verify(&value, "ssl_verify").unwrap(), expected);
});
}
}

View file

@ -1,6 +1,7 @@
mod credentials;
mod diagnostics;
mod errors;
mod http;
mod marshal;
mod routes;
mod token_counter;

View file

@ -12,6 +12,8 @@ use pyo3::{
types::{PyDict, PyTuple},
};
use crate::{errors::RustBridgeDeclined, http};
const SURFACE: LegacySurface = LegacySurface {
call_type: "ocr",
input_description: "OCR document processing",
@ -29,7 +31,9 @@ fn run_ocr(
kwargs: Bound<'_, PyDict>,
asynchronous: bool,
) -> PyResult<Py<PyAny>> {
let client = OcrClient::shared().map_err(errors::to_pyerr)?;
let config = http::call_config(py, &kwargs)?;
let client = OcrClient::new(http::pool(), &config)
.map_err(|error| RustBridgeDeclined::new_err(error.to_string()))?;
run_legacy_call(
py,
if asynchronous { ASYNC_SURFACE } else { SURFACE },