fix(rust): leave calls with a custom URL policy on the Python route

litellm.user_url_validation and litellm.user_url_allowed_hosts are only implemented by the Python document fetcher, so an allowlisted internal document was rejected by the Rust route's network policy. The bridge now declines when either is changed from its default
This commit is contained in:
Yujong Lee 2026-09-18 18:09:14 -07:00
parent 8d2476465f
commit 157fa58947
5 changed files with 86 additions and 2 deletions

View file

@ -10,5 +10,9 @@
"disable_aiohttp_trust_env",
"disable_aiohttp_transport",
"user_agent"
],
"url_policy": [
"user_url_validation",
"user_url_allowed_hosts"
]
}

View file

@ -24,6 +24,7 @@ pub(crate) fn call_config(
asynchronous: bool,
) -> PyResult<HttpClientConfig> {
decline_live_clients(kwargs)?;
decline_custom_url_policy(&PythonSettings::UrlPolicy.read(py)?)?;
let configured = settings(&PythonSettings::Http.read(py)?)?
.with_environment(&|name| std::env::var(name).ok());
let settings = for_call(configured, call_ssl_verify(kwargs)?, asynchronous)
@ -63,6 +64,23 @@ pub(crate) fn decline_live_clients(kwargs: &Bound<'_, PyDict>) -> PyResult<()> {
Ok(())
}
#[derive(FromPyObject)]
struct PythonUrlPolicy {
user_url_validation: bool,
user_url_allowed_hosts: Vec<String>,
}
fn decline_custom_url_policy(value: &Bound<'_, PyAny>) -> PyResult<()> {
match value.extract::<PythonUrlPolicy>() {
Ok(policy) if policy.user_url_validation && policy.user_url_allowed_hosts.is_empty() => {
Ok(())
}
Ok(_) | Err(_) => Err(RustBridgeDeclined::new_err(
"litellm.user_url_validation / user_url_allowed_hosts are applied by the Python route",
)),
}
}
#[derive(FromPyObject)]
struct PythonHttpSettings<'py> {
ssl_verify: Bound<'py, PyAny>,
@ -245,6 +263,37 @@ user_agent='litellm/9.9.9',
});
}
fn url_policy<'py>(py: Python<'py>, fields: &str) -> Bound<'py, PyAny> {
let source = std::ffi::CString::new(format!(
"import types\npolicy = types.SimpleNamespace({fields})"
))
.unwrap();
let locals = PyDict::new(py);
py.run(&source, Some(&locals), Some(&locals)).unwrap();
locals.get_item("policy").unwrap().unwrap()
}
#[test]
fn default_url_policy_stays_on_the_rust_route() {
Python::initialize();
Python::attach(|py| {
let policy = url_policy(py, "user_url_validation=True, user_url_allowed_hosts=[]");
decline_custom_url_policy(&policy).unwrap();
});
}
#[rstest]
#[case::validation_off("user_url_validation=False, user_url_allowed_hosts=[]")]
#[case::allowlist("user_url_validation=True, user_url_allowed_hosts=['docs.internal']")]
#[case::mistyped("user_url_validation=True, user_url_allowed_hosts=None")]
fn custom_url_policy_declines_so_python_applies_it(#[case] fields: &str) {
Python::initialize();
Python::attach(|py| {
let error = decline_custom_url_policy(&url_policy(py, fields)).unwrap_err();
assert!(error.is_instance_of::<RustBridgeDeclined>(py));
});
}
#[test]
fn mistyped_python_settings_decline_instead_of_raising() {
Python::initialize();

View file

@ -5,15 +5,17 @@ const MODULE: &str = "litellm.rust_bridge.settings";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PythonSettings {
Http,
UrlPolicy,
}
impl PythonSettings {
#[cfg(test)]
pub(crate) const ALL: [Self; 1] = [Self::Http];
pub(crate) const ALL: [Self; 2] = [Self::Http, Self::UrlPolicy];
pub(crate) fn name(self) -> &'static str {
match self {
Self::Http => "http_settings",
Self::UrlPolicy => "url_policy",
}
}

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
@ -17,6 +18,21 @@ class HttpSettings:
user_agent: str
@dataclass(frozen=True, slots=True)
class UrlPolicy:
user_url_validation: bool
user_url_allowed_hosts: Sequence[str]
def url_policy() -> UrlPolicy:
import litellm
return UrlPolicy(
user_url_validation=litellm.user_url_validation,
user_url_allowed_hosts=litellm.user_url_allowed_hosts,
)
def http_settings() -> HttpSettings:
import litellm
from litellm.llms.custom_httpx.http_handler import default_user_agent

View file

@ -15,7 +15,20 @@ CONTRACT_PATH: Final = Path(__file__).parents[3] / "litellm-rust/crates/python-b
def test_the_rust_contract_matches_the_returned_fields() -> None:
contract: Final = TypeAdapter(dict[str, list[str]]).validate_json(CONTRACT_PATH.read_text())
assert contract == {"http_settings": [field.name for field in dataclasses.fields(settings.http_settings())]}
assert contract == {
"http_settings": [field.name for field in dataclasses.fields(settings.http_settings())],
"url_policy": [field.name for field in dataclasses.fields(settings.url_policy())],
}
def test_url_policy_reads_the_litellm_globals(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "user_url_validation", False)
monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["docs.internal:8443"])
assert settings.url_policy() == settings.UrlPolicy(
user_url_validation=False,
user_url_allowed_hosts=["docs.internal:8443"],
)
def test_http_settings_reads_the_litellm_globals(monkeypatch: pytest.MonkeyPatch) -> None: