diff --git a/.circleci/config.yml b/.circleci/config.yml
index 87f1ee604cf..602604714bd 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -3015,7 +3015,7 @@ workflows:
name: integration-<< matrix.suite >>
matrix:
parameters:
- suite: [management, accounting, database, providers, extensions, sdk, browser]
+ suite: [management, accounting, database, providers, extensions, sdk, cost, browser]
filters:
branches:
only:
diff --git a/.circleci/scripts/run_integration.sh b/.circleci/scripts/run_integration.sh
index 80f9cb0a4e0..0d6cdcabd57 100644
--- a/.circleci/scripts/run_integration.sh
+++ b/.circleci/scripts/run_integration.sh
@@ -9,6 +9,7 @@ fi
suite="${1:?integration suite required}"
results="test-results/integration-${suite}"
mkdir -p "$results"
+shard_timeout=11m
integration_identity="$(.venv/bin/python -c 'import uuid; print(uuid.uuid4().hex)')"
upstream_pid=""
proxy_pid=""
@@ -108,13 +109,26 @@ awk '$3 == "REJECT" && $1 > 0 { rejected=1 } END { exit !rejected }' "$results/e
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
.venv/bin/python -m integration._support.upstream > "$results/upstream.log" 2>&1 &
upstream_pid=$!
+if [ "$suite" = cost ]; then
+ export INTEGRATION_WORKERS=8
+fi
start_proxy() {
local port="$1"
local log_name="$2"
+ local -a cost_map_env
+ if [ "$suite" = cost ]; then
+ cost_map_env=(
+ "LITELLM_MODEL_COST_MAP_URL=$INTEGRATION_UPSTREAM_URL/_cost_map"
+ "MODEL_COST_MAP_MIN_MODEL_COUNT=1"
+ "MODEL_COST_MAP_MAX_SHRINK_RATIO=0"
+ )
+ else
+ cost_map_env=("LITELLM_LOCAL_MODEL_COST_MAP=True")
+ fi
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
DATABASE_URL="$DATABASE_URL" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \
- LITELLM_MODE=PRODUCTION LITELLM_LOCAL_MODEL_COST_MAP=True STORE_MODEL_IN_DB=True \
+ LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True "${cost_map_env[@]}" \
AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
.venv/bin/python -m integration._support.proxy --config tests/integration/proxy_config.yaml \
--host 127.0.0.1 --port "$port" --num_workers 1 --telemetry False \
@@ -158,11 +172,12 @@ if [ "$suite" = browser ]; then
exit 0
fi
-timeout --signal=TERM --kill-after=20s 11m env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
+timeout --signal=TERM --kill-after=20s "$shard_timeout" env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
INTEGRATION_RUN_ID="$integration_identity" \
DATABASE_URL="$DATABASE_URL" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
INTEGRATION_PROXY_URL="$INTEGRATION_PROXY_URL" INTEGRATION_PEER_URL="$INTEGRATION_PEER_URL" \
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
+ INTEGRATION_WORKERS="${INTEGRATION_WORKERS:-1}" \
INTEGRATION_MASTER_KEY="$INTEGRATION_MASTER_KEY" LITELLM_MODE=PRODUCTION \
INTEGRATION_SEED="$INTEGRATION_SEED" \
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml
index bbf0cb4e891..617b09a8075 100644
--- a/.github/workflows/_test-unit-base.yml
+++ b/.github/workflows/_test-unit-base.yml
@@ -37,6 +37,18 @@ on:
required: false
type: number
default: 60
+ test-timeout-seconds:
+ description: >-
+ Per-test ceiling enforced by pytest-timeout, covering fixture setup and
+ teardown as well as the test body. A test that hangs fails with a
+ traceback of where it was stuck instead of idling the shard until
+ `timeout-minutes` cancels it. Timed-out tests are excluded from reruns
+ because pytest-timeout arms its timer once per test and
+ pytest-rerunfailures reruns inside that same window, so a rerun of a
+ timed-out test would run with no timer at all.
+ required: false
+ type: number
+ default: 120
max-failures:
description: "Stop after this many failures"
required: false
@@ -137,6 +149,7 @@ jobs:
MAX_FAILURES: ${{ inputs.max-failures }}
WORKERS: ${{ inputs.workers }}
RERUNS: ${{ inputs.reruns }}
+ TEST_TIMEOUT_SECONDS: ${{ inputs.test-timeout-seconds }}
DIST: ${{ inputs.dist }}
COVERAGE_CORE: sysmon
run: |
@@ -146,6 +159,8 @@ jobs:
--maxfail="${MAX_FAILURES}" \
--reruns "${RERUNS}" \
--reruns-delay 1 \
+ --timeout="${TEST_TIMEOUT_SECONDS}" \
+ --rerun-except "from pytest-timeout" \
--durations=20 \
--cov=./litellm --cov=./enterprise/litellm_enterprise \
--cov-report=xml:coverage.xml \
@@ -157,6 +172,8 @@ jobs:
-n "${WORKERS}" \
--reruns "${RERUNS}" \
--reruns-delay 1 \
+ --timeout="${TEST_TIMEOUT_SECONDS}" \
+ --rerun-except "from pytest-timeout" \
--dist="${DIST}" \
--durations=20 \
--cov=./litellm --cov=./enterprise/litellm_enterprise \
diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock
index d4b32659ba1..726d2f484da 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -1976,6 +1976,7 @@ dependencies = [
"azure_identity",
"litellm-auth",
"moka",
+ "rstest",
"serde_json",
"sha2 0.10.9",
"strum",
@@ -2137,11 +2138,14 @@ version = "0.1.0"
dependencies = [
"http 1.4.2",
"hyper-util",
+ "litellm-core-utils",
"reqwest 0.12.28",
"rstest",
"rustls 0.23.42",
+ "serde_json",
"thiserror 2.0.19",
"tokio",
+ "veil",
"webpki-roots",
]
@@ -2187,6 +2191,7 @@ dependencies = [
"litellm-auth-gcp",
"litellm-callbacks-legacy",
"litellm-core",
+ "litellm-core-utils",
"litellm-host-python",
"litellm-http",
"litellm-llms",
diff --git a/litellm-rust/crates/auth-azure/Cargo.toml b/litellm-rust/crates/auth-azure/Cargo.toml
index 9f8260c7b3f..8099506d2e5 100644
--- a/litellm-rust/crates/auth-azure/Cargo.toml
+++ b/litellm-rust/crates/auth-azure/Cargo.toml
@@ -18,4 +18,5 @@ azure_core = "1.0.0"
azure_identity = { version = "1.0.0", features = ["tokio"] }
[dev-dependencies]
+rstest.workspace = true
tokio.workspace = true
diff --git a/litellm-rust/crates/auth-azure/src/types.rs b/litellm-rust/crates/auth-azure/src/types.rs
index 2a510de1f43..87e883a6a54 100644
--- a/litellm-rust/crates/auth-azure/src/types.rs
+++ b/litellm-rust/crates/auth-azure/src/types.rs
@@ -1,11 +1,10 @@
-use serde_json::{Map, Value};
use std::collections::BTreeMap;
-use strum::EnumString;
-use litellm_auth::Error;
use litellm_auth::{
- CredentialResolverHandle, InputSource, SecretValue, Sourced, TokenProviderHandle,
+ CredentialResolverHandle, Error, InputSource, SecretValue, Sourced, TokenProviderHandle,
};
+use serde_json::{Map, Value};
+use strum::EnumString;
pub const DEFAULT_AZURE_SCOPE: &str = "https://cognitiveservices.azure.com/.default";
@@ -52,6 +51,16 @@ pub struct AzureAuthInputs {
}
impl AzureAuthInputs {
+ pub fn or_configured_token_refresh(self, enabled: bool) -> Self {
+ if *self.enable_azure_ad_token_refresh.value() || !enabled {
+ return self;
+ }
+ Self {
+ enable_azure_ad_token_refresh: Sourced::new(true, InputSource::Deployment),
+ ..self
+ }
+ }
+
#[cfg(test)]
pub fn from_optional_params(params: &Map) -> Result {
Self::from_sourced_optional_params(params, &BTreeMap::new())
@@ -115,12 +124,12 @@ fn source_for(sources: &BTreeMap, name: &str) -> InputSourc
#[cfg(test)]
mod tests {
- use serde_json::json;
-
use std::collections::BTreeMap;
- use super::{AzureAuthInputs, AzureCredentialType, ConfigValue};
use litellm_auth::{InputSource, Sourced};
+ use serde_json::json;
+
+ use super::{AzureAuthInputs, AzureCredentialType, ConfigValue};
#[test]
fn selector_parsing_is_exact() {
@@ -189,4 +198,29 @@ mod tests {
assert!(!debug.contains("token-value"));
assert!(!debug.contains("secret-value"));
}
+
+ #[rstest::rstest]
+ #[case::global_turns_refresh_on(json!({}), true, true, InputSource::Deployment)]
+ #[case::global_overrides_a_call_false_like_python(json!({"enable_azure_ad_token_refresh": false}), true, true, InputSource::Deployment)]
+ #[case::call_true_survives_a_global_false(json!({"enable_azure_ad_token_refresh": true}), false, true, InputSource::Request)]
+ #[case::both_off(json!({}), false, false, InputSource::Request)]
+ fn token_refresh_follows_the_configured_global(
+ #[case] params: serde_json::Value,
+ #[case] global: bool,
+ #[case] enabled: bool,
+ #[case] source: InputSource,
+ ) {
+ let sources = BTreeMap::from([(
+ "enable_azure_ad_token_refresh".to_string(),
+ InputSource::Request,
+ )]);
+ let inputs =
+ AzureAuthInputs::from_sourced_optional_params(params.as_object().unwrap(), &sources)
+ .unwrap()
+ .or_configured_token_refresh(global);
+ assert_eq!(
+ inputs.enable_azure_ad_token_refresh,
+ Sourced::new(enabled, source)
+ );
+ }
}
diff --git a/litellm-rust/crates/auth-gcp/src/lib.rs b/litellm-rust/crates/auth-gcp/src/lib.rs
index f8402624edc..bf619fee144 100644
--- a/litellm-rust/crates/auth-gcp/src/lib.rs
+++ b/litellm-rust/crates/auth-gcp/src/lib.rs
@@ -1,17 +1,13 @@
-use std::collections::BTreeMap;
-use std::future::Future;
-use std::path::Path;
-use std::pin::Pin;
-use std::sync::Arc;
+use std::{collections::BTreeMap, future::Future, path::Path, pin::Pin, sync::Arc};
use gcp_auth::{CustomServiceAccount, TokenProvider};
+use litellm_auth::{
+ CredentialPlacement, Error, InputSource, SecretValue, Sourced, http::apply_credential,
+};
use moka::future::Cache;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
-use litellm_auth::http::apply_credential;
-use litellm_auth::{CredentialPlacement, Error, InputSource, SecretValue, Sourced};
-
const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
const GOOGLE_OAUTH_TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
const GOOGLE_APPLICATION_CREDENTIALS_ENV: &str = "GOOGLE_APPLICATION_CREDENTIALS";
@@ -45,6 +41,16 @@ impl VertexConfig {
})
}
+ pub fn or_configured(self, project_id: Option<&str>, location: Option<&str>) -> Self {
+ let configured =
+ |value: Option<&str>| value.filter(|value| !value.is_empty()).map(str::to_string);
+ Self {
+ project_id: self.project_id.or_else(|| configured(project_id)),
+ location: self.location.or_else(|| configured(location)),
+ ..self
+ }
+ }
+
pub fn project_id(&self) -> Option<&str> {
self.project_id.as_deref()
}
@@ -571,4 +577,29 @@ mod tests {
assert_eq!(loads.load(Ordering::SeqCst), 1);
assert_eq!(calls.load(Ordering::SeqCst), 4);
}
+
+ #[test]
+ fn configured_defaults_sit_between_call_params_and_the_environment() {
+ let env = |name: &str| Some(format!("env-{name}"));
+ let from_config =
+ VertexConfig::default().or_configured(Some("global-project"), Some("global-location"));
+ assert_eq!(
+ get_vertex_ai_project(&from_config, &env).as_deref(),
+ Some("global-project")
+ );
+ assert_eq!(
+ get_vertex_ai_location(&from_config, &env).as_deref(),
+ Some("global-location")
+ );
+ let from_call =
+ config(json!({"vertex_project":"call-project","vertex_location":"call-location"}))
+ .or_configured(Some("global-project"), Some("global-location"));
+ assert_eq!(from_call.project_id(), Some("call-project"));
+ assert_eq!(from_call.location(), Some("call-location"));
+ let empty_global = VertexConfig::default().or_configured(Some(""), None);
+ assert_eq!(
+ get_vertex_ai_project(&empty_global, &env).as_deref(),
+ Some("env-VERTEXAI_PROJECT")
+ );
+ }
}
diff --git a/litellm-rust/crates/core-utils/src/lib.rs b/litellm-rust/crates/core-utils/src/lib.rs
index fcb232d8980..ceb0e9eb3f2 100644
--- a/litellm-rust/crates/core-utils/src/lib.rs
+++ b/litellm-rust/crates/core-utils/src/lib.rs
@@ -6,4 +6,5 @@ pub mod params;
pub mod prompt_templates;
pub mod secret_redaction;
pub mod serde_compat;
+pub mod settings;
pub mod url_utils;
diff --git a/litellm-rust/crates/core-utils/src/settings.rs b/litellm-rust/crates/core-utils/src/settings.rs
new file mode 100644
index 00000000000..59c76ce3015
--- /dev/null
+++ b/litellm-rust/crates/core-utils/src/settings.rs
@@ -0,0 +1,144 @@
+use std::str::FromStr;
+
+pub trait Lookup {
+ fn get(&self, name: &str) -> Option;
+
+ fn truthy(&self, name: &str) -> Option {
+ self.get(name).filter(|value| !value.is_empty())
+ }
+
+ fn enabled(&self, name: &str) -> Option {
+ self.get(name)
+ .is_some_and(|value| value.trim().eq_ignore_ascii_case("true"))
+ .then_some(true)
+ }
+
+ fn parsed(&self, name: &str) -> Option
+ where
+ Self: Sized,
+ {
+ self.get(name).and_then(|value| value.trim().parse().ok())
+ }
+}
+
+impl Option> Lookup for F {
+ fn get(&self, name: &str) -> Option {
+ self(name)
+ }
+}
+
+pub struct ProcessEnvironment;
+
+impl Lookup for ProcessEnvironment {
+ fn get(&self, name: &str) -> Option {
+ std::env::var(name).ok()
+ }
+}
+
+pub trait Layer: Default {
+ fn or(self, lower: Self) -> Self;
+}
+
+pub fn merge(highest_precedence_first: impl IntoIterator- ) -> L {
+ highest_precedence_first
+ .into_iter()
+ .reduce(L::or)
+ .unwrap_or_default()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn env_of(values: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option {
+ move |name| {
+ values
+ .iter()
+ .find(|(key, _)| *key == name)
+ .map(|(_, value)| value.to_string())
+ }
+ }
+
+ #[test]
+ fn get_keeps_a_present_empty_value_like_os_getenv_with_a_fallback() {
+ let env = env_of(&[("EMPTY", "")]);
+ assert_eq!(env.get("EMPTY"), Some(String::new()));
+ assert_eq!(env.get("ABSENT"), None);
+ }
+
+ #[test]
+ fn truthy_drops_an_empty_value_like_a_python_or_chain() {
+ let env = env_of(&[("EMPTY", ""), ("SET", "value")]);
+ assert_eq!(env.truthy("EMPTY"), None);
+ assert_eq!(env.truthy("SET").as_deref(), Some("value"));
+ }
+
+ #[test]
+ fn enabled_only_switches_on_for_true_and_never_forces_off() {
+ let env = env_of(&[
+ ("LOWER", "true"),
+ ("PADDED", " True "),
+ ("OFF", "false"),
+ ("ONE", "1"),
+ ]);
+ assert_eq!(env.enabled("LOWER"), Some(true));
+ assert_eq!(env.enabled("PADDED"), Some(true));
+ assert_eq!(env.enabled("OFF"), None);
+ assert_eq!(env.enabled("ONE"), None);
+ assert_eq!(env.enabled("ABSENT"), None);
+ }
+
+ #[test]
+ fn parsed_trims_and_skips_values_that_do_not_parse() {
+ let env = env_of(&[("PADDED", " 45 "), ("WORD", "soon"), ("FRACTION", "0.5")]);
+ assert_eq!(env.parsed::("PADDED"), Some(45));
+ assert_eq!(env.parsed::("WORD"), None);
+ assert_eq!(env.parsed::("FRACTION"), Some(0.5));
+ assert_eq!(env.parsed::("ABSENT"), None);
+ }
+
+ #[derive(Debug, Default, PartialEq)]
+ struct Pair {
+ first: Option,
+ second: Option,
+ }
+
+ impl Layer for Pair {
+ fn or(self, lower: Self) -> Self {
+ Self {
+ first: self.first.or(lower.first),
+ second: self.second.or(lower.second),
+ }
+ }
+ }
+
+ #[test]
+ fn merge_takes_each_field_from_the_highest_layer_that_sets_it() {
+ let merged = merge([
+ Pair {
+ first: Some(1),
+ second: None,
+ },
+ Pair {
+ first: Some(2),
+ second: Some(2),
+ },
+ Pair {
+ first: Some(3),
+ second: Some(3),
+ },
+ ]);
+ assert_eq!(
+ merged,
+ Pair {
+ first: Some(1),
+ second: Some(2),
+ }
+ );
+ }
+
+ #[test]
+ fn merging_no_layers_yields_the_empty_layer() {
+ assert_eq!(merge(Vec::::new()), Pair::default());
+ }
+}
diff --git a/litellm-rust/crates/core/AGENTS.md b/litellm-rust/crates/core/AGENTS.md
index 449c3e647f7..0c8a747019d 100644
--- a/litellm-rust/crates/core/AGENTS.md
+++ b/litellm-rust/crates/core/AGENTS.md
@@ -5,10 +5,11 @@ litellm-core is the LiteLLM SDK in Rust — it makes the LLM call. Each top-leve
Each crate mirrors one top-level Python package, so a Rust path reads as its Python path with the crate name in place of the package directory. Dependencies only point down:
- `litellm-types` mirrors `litellm/types/`: pure serde data, no I/O
-- `litellm-core-utils` mirrors `litellm/litellm_core_utils/`: pure helpers (provider resolution, prompt factory, call arguments), no network I/O
-- `litellm-llms` mirrors `litellm/llms/`: `base_llm//transformation.rs`, `//transformation.rs`, and `custom_httpx/` (HTTP helpers and the OCR request handler)
+- `litellm-core-utils` mirrors `litellm/litellm_core_utils/`: pure helpers (provider resolution, prompt factory, call arguments, settings lookup and layer merge), no network I/O
+- `litellm-http` is Rust-only and route-neutral: settings resolution, the pooled `reqwest` clients, TLS, proxies, the SSRF-safe media fetcher, request and header helpers, and transport errors. Python's `litellm/llms/custom_httpx/` is split by responsibility instead of mirrored: its transport half lives here, its OCR handler in `litellm-llms`
+- `litellm-llms` mirrors `litellm/llms/`: `base_llm//transformation.rs`, `//transformation.rs`, and `base_llm/ocr/handler.rs` (the OCR request handler)
- `litellm-core` mirrors the route packages (`litellm/ocr/`, `litellm/messages/`, ...): entrypoints, route request types, provider dispatch, the route machine, and hooks
-A route module owns the call entrypoint, route request types (`*Request<'a>`), credential fallback, provider dispatch, and the handler glue that runs a provider config. Provider code never imports from core; when it needs the caller's hooks mid-call it goes through `litellm_llms::custom_httpx::llm_http_handler::CallHooks`, which each route implements over its host. Import every item from its canonical path. Never re-export another crate's items or give an item a second public path; the only re-export allowed is a private submodule surfacing its item at its module root (`mod error; pub use error::Error;`). Handlers belong in core or llms, never in a host crate
+A route module owns the call entrypoint, route request types (`*Request<'a>`), credential fallback, provider dispatch, and the handler glue that runs a provider config. Provider code never imports from core; when it needs the caller's hooks mid-call it goes through `litellm_llms::base_llm::ocr::handler::CallHooks`, which each route implements over its host. Import every item from its canonical path. Never re-export another crate's items or give an item a second public path; the only re-export allowed is a private submodule surfacing its item at its module root (`mod error; pub use error::Error;`). Handlers belong in core or llms, never in a host crate
Not here: serving HTTP (axum routes, extractors), config file reading, rollout state, databases, or callback execution of any kind. Core runs each route as a machine that yields host operations and call events; which integrations consume those events is the host's business.
diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml
index ab04fb8d4ae..69ae8004d46 100644
--- a/litellm-rust/crates/core/Cargo.toml
+++ b/litellm-rust/crates/core/Cargo.toml
@@ -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"
@@ -36,7 +37,6 @@ veil.workspace = true
[dev-dependencies]
litellm-auth-gcp.workspace = true
-litellm-http.workspace = true
litellm-llms = { workspace = true, features = ["test-support"] }
rstest.workspace = true
rstest_reuse.workspace = true
diff --git a/litellm-rust/crates/core/src/audio_transcription/error.rs b/litellm-rust/crates/core/src/audio_transcription/error.rs
index 39b08e882f5..122cbab358f 100644
--- a/litellm-rust/crates/core/src/audio_transcription/error.rs
+++ b/litellm-rust/crates/core/src/audio_transcription/error.rs
@@ -20,9 +20,9 @@ pub enum Error {
#[error(transparent)]
Auth(#[from] litellm_auth::Error),
#[error(transparent)]
- Transport(#[from] litellm_llms::custom_httpx::transport::Error),
+ Transport(#[from] litellm_http::transport::Error),
#[error(transparent)]
- Headers(#[from] litellm_llms::custom_httpx::http_handler::HeaderError),
+ Headers(#[from] litellm_http::request::HeaderError),
#[error(transparent)]
Aws(#[from] litellm_auth_aws::Error),
}
diff --git a/litellm-rust/crates/core/src/audio_transcription/handler.rs b/litellm-rust/crates/core/src/audio_transcription/handler.rs
index 0704f9391b0..503cc922966 100644
--- a/litellm-rust/crates/core/src/audio_transcription/handler.rs
+++ b/litellm-rust/crates/core/src/audio_transcription/handler.rs
@@ -1,4 +1,4 @@
-use litellm_llms::custom_httpx::http_handler::{http_request, truncate_error_body};
+use litellm_http::request::{http_request, truncate_error_body};
use serde_json::Value;
use super::{Error, client::http_client};
@@ -18,23 +18,17 @@ pub async fn execute_audio_transcription_provider_call(
request_builder = request_builder.timeout(duration);
}
let response = http_request(request_builder).await.map_err(|error| {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
- error.to_string(),
- ))
+ Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
- error.to_string(),
- ))
+ Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
})?;
if !status.is_success() {
- return Err(Error::Transport(
- litellm_llms::custom_httpx::transport::Error::Http {
- status: status.as_u16(),
- body: truncate_error_body(&text),
- },
- ));
+ return Err(Error::Transport(litellm_http::transport::Error::Http {
+ status: status.as_u16(),
+ body: truncate_error_body(&text),
+ }));
}
let response_json = serde_json::from_str(&text)
.map_err(|error| Error::InvalidResponse(format!("invalid audio response JSON: {error}")))?;
diff --git a/litellm-rust/crates/core/src/audio_transcription/prepare.rs b/litellm-rust/crates/core/src/audio_transcription/prepare.rs
index 193122db733..829617d26bd 100644
--- a/litellm-rust/crates/core/src/audio_transcription/prepare.rs
+++ b/litellm-rust/crates/core/src/audio_transcription/prepare.rs
@@ -1,10 +1,10 @@
use litellm_core_utils::get_llm_provider_logic::{CustomLlmProvider, get_custom_llm_provider};
+use litellm_http::request::{has_header, string_headers};
use litellm_llms::{
base_llm::audio_transcription::transformation::{
AudioTranscriptionAuth, BaseAudioTranscriptionConfig,
},
bedrock::audio_transcription::BEDROCK_AUDIO_TRANSCRIPTION_CONFIG,
- custom_httpx::http_handler::{has_header, string_headers},
};
use super::Error;
diff --git a/litellm-rust/crates/core/src/chat_completions/common_utils.rs b/litellm-rust/crates/core/src/chat_completions/common_utils.rs
index cc9459793df..4ed39a90366 100644
--- a/litellm-rust/crates/core/src/chat_completions/common_utils.rs
+++ b/litellm-rust/crates/core/src/chat_completions/common_utils.rs
@@ -1,8 +1,8 @@
+use litellm_http::request::string_headers as shared_string_headers;
use litellm_llms::{
anthropic::chat::transformation::ANTHROPIC_CHAT_COMPLETIONS_CONFIG,
base_llm::chat::transformation::BaseConfig,
bedrock::chat::converse_transformation::BEDROCK_CHAT_COMPLETIONS_CONFIG,
- custom_httpx::http_handler::string_headers as shared_string_headers,
};
use serde_json::{Map, Value};
diff --git a/litellm-rust/crates/core/src/chat_completions/error.rs b/litellm-rust/crates/core/src/chat_completions/error.rs
index 39b08e882f5..122cbab358f 100644
--- a/litellm-rust/crates/core/src/chat_completions/error.rs
+++ b/litellm-rust/crates/core/src/chat_completions/error.rs
@@ -20,9 +20,9 @@ pub enum Error {
#[error(transparent)]
Auth(#[from] litellm_auth::Error),
#[error(transparent)]
- Transport(#[from] litellm_llms::custom_httpx::transport::Error),
+ Transport(#[from] litellm_http::transport::Error),
#[error(transparent)]
- Headers(#[from] litellm_llms::custom_httpx::http_handler::HeaderError),
+ Headers(#[from] litellm_http::request::HeaderError),
#[error(transparent)]
Aws(#[from] litellm_auth_aws::Error),
}
diff --git a/litellm-rust/crates/core/src/chat_completions/handler.rs b/litellm-rust/crates/core/src/chat_completions/handler.rs
index 034408bdf17..b73d4838760 100644
--- a/litellm-rust/crates/core/src/chat_completions/handler.rs
+++ b/litellm-rust/crates/core/src/chat_completions/handler.rs
@@ -1,7 +1,5 @@
-use litellm_llms::{
- base_llm::chat::transformation::{ChatCompletionsAuth, ProviderChatResponseData},
- custom_httpx::http_handler::{http_request, truncate_error_body},
-};
+use litellm_http::request::{http_request, truncate_error_body};
+use litellm_llms::base_llm::chat::transformation::{ChatCompletionsAuth, ProviderChatResponseData};
use litellm_types::utils::ChatCompletionsResponse;
use serde_json::Value;
@@ -34,30 +32,22 @@ pub(super) async fn execute_chat_completions_provider_call(
// so the host can still serve it. Everything else here, a timeout
// above all, may have reached the provider and been answered.
if err.is_connect() || err.is_builder() {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Connect(
- err.to_string(),
- ))
+ Error::Transport(litellm_http::transport::Error::Connect(err.to_string()))
} else {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
- err.to_string(),
- ))
+ Error::Transport(litellm_http::transport::Error::Network(err.to_string()))
}
})?;
let status = response.status();
let text = response.text().await.map_err(|err| {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
- err.to_string(),
- ))
+ Error::Transport(litellm_http::transport::Error::Network(err.to_string()))
})?;
if !status.is_success() {
- return Err(Error::Transport(
- litellm_llms::custom_httpx::transport::Error::Http {
- status: status.as_u16(),
- body: truncate_error_body(&text),
- },
- ));
+ return Err(Error::Transport(litellm_http::transport::Error::Http {
+ status: status.as_u16(),
+ body: truncate_error_body(&text),
+ }));
}
let body: Value = serde_json::from_str(&text).map_err(|err| {
@@ -82,9 +72,7 @@ pub(super) async fn execute_chat_completions_provider_call(
pub(super) fn as_response_error(err: Error) -> Error {
match err {
already @ (Error::InvalidResponse(_)
- | Error::Transport(litellm_llms::custom_httpx::transport::Error::Http {
- ..
- })) => already,
+ | Error::Transport(litellm_http::transport::Error::Http { .. })) => already,
other => Error::InvalidResponse(other.to_string()),
}
}
diff --git a/litellm-rust/crates/core/src/chat_completions/prepare.rs b/litellm-rust/crates/core/src/chat_completions/prepare.rs
index d408ea6574e..d0aa1e88011 100644
--- a/litellm-rust/crates/core/src/chat_completions/prepare.rs
+++ b/litellm-rust/crates/core/src/chat_completions/prepare.rs
@@ -1,8 +1,6 @@
use litellm_core_utils::get_llm_provider_logic::{CustomLlmProvider, get_custom_llm_provider};
-use litellm_llms::{
- base_llm::chat::transformation::{BaseConfig, ChatCompletionsAuth},
- custom_httpx::http_handler::has_header,
-};
+use litellm_http::request::has_header;
+use litellm_llms::base_llm::chat::transformation::{BaseConfig, ChatCompletionsAuth};
use litellm_types::llms::openai::ChatMessage;
use serde_json::Value;
diff --git a/litellm-rust/crates/core/src/chat_completions/tests.rs b/litellm-rust/crates/core/src/chat_completions/tests.rs
index cbc4995ce0d..dcaa3397add 100644
--- a/litellm-rust/crates/core/src/chat_completions/tests.rs
+++ b/litellm-rust/crates/core/src/chat_completions/tests.rs
@@ -265,7 +265,7 @@ fn rejects_non_string_extra_headers() {
call.extra_headers = Some(Map::from_iter([("x-trace".to_string(), json!(7))]));
assert_eq!(
decline(call),
- Error::Headers(litellm_llms::custom_httpx::http_handler::HeaderError {
+ Error::Headers(litellm_http::request::HeaderError {
context: "chat completions",
name: "x-trace".to_string(),
actual: "number",
@@ -771,10 +771,7 @@ mod round_trip {
assert!(
matches!(
err,
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Http {
- status: 429,
- ..
- })
+ Error::Transport(litellm_http::transport::Error::Http { status: 429, .. })
),
"expected a 429, got {err:?}"
);
@@ -801,7 +798,7 @@ mod round_trip {
assert!(
matches!(
err,
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Connect(_))
+ Error::Transport(litellm_http::transport::Error::Connect(_))
),
"expected a pre-send connect failure, got {err:?}"
);
@@ -825,16 +822,11 @@ mod round_trip {
}
// An upstream status is already unambiguous, so it survives intact.
assert!(matches!(
- as_response_error(Error::Transport(
- litellm_llms::custom_httpx::transport::Error::Http {
- status: 500,
- body: "boom".to_string()
- }
- )),
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Http {
+ as_response_error(Error::Transport(litellm_http::transport::Error::Http {
status: 500,
- ..
- })
+ body: "boom".to_string()
+ })),
+ Error::Transport(litellm_http::transport::Error::Http { status: 500, .. })
));
}
}
diff --git a/litellm-rust/crates/core/src/messages/common_utils.rs b/litellm-rust/crates/core/src/messages/common_utils.rs
index ec392324784..dcefa3ebffc 100644
--- a/litellm-rust/crates/core/src/messages/common_utils.rs
+++ b/litellm-rust/crates/core/src/messages/common_utils.rs
@@ -1,11 +1,9 @@
-pub(super) use litellm_llms::custom_httpx::http_handler::{
- has_bearer_auth, has_header, truncate_error_body,
-};
+use litellm_http::request::string_headers as shared_string_headers;
+pub(super) use litellm_http::request::{has_bearer_auth, has_header, truncate_error_body};
use litellm_llms::{
anthropic::experimental_pass_through::messages::transformation::ANTHROPIC_MESSAGES_CONFIG,
azure_ai::anthropic::messages_transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG,
base_llm::anthropic_messages::transformation::BaseAnthropicMessagesConfig,
- custom_httpx::http_handler::string_headers as shared_string_headers,
};
use serde_json::{Map, Value};
diff --git a/litellm-rust/crates/core/src/messages/error.rs b/litellm-rust/crates/core/src/messages/error.rs
index 71bb748c50d..51fb764032c 100644
--- a/litellm-rust/crates/core/src/messages/error.rs
+++ b/litellm-rust/crates/core/src/messages/error.rs
@@ -15,9 +15,9 @@ pub enum Error {
#[error(transparent)]
Auth(#[from] litellm_auth::Error),
#[error(transparent)]
- Transport(#[from] litellm_llms::custom_httpx::transport::Error),
+ Transport(#[from] litellm_http::transport::Error),
#[error(transparent)]
- Headers(#[from] litellm_llms::custom_httpx::http_handler::HeaderError),
+ Headers(#[from] litellm_http::request::HeaderError),
}
impl From for Error {
diff --git a/litellm-rust/crates/core/src/messages/handler.rs b/litellm-rust/crates/core/src/messages/handler.rs
index 22e2c398ff7..fe7e8bb4b80 100644
--- a/litellm-rust/crates/core/src/messages/handler.rs
+++ b/litellm-rust/crates/core/src/messages/handler.rs
@@ -1,9 +1,7 @@
use std::time::Duration;
-use litellm_llms::{
- base_llm::anthropic_messages::transformation::BaseAnthropicMessagesConfig,
- custom_httpx::{http_handler::http_request, transport::Error as TransportError},
-};
+use litellm_http::{request::http_request, transport::Error as TransportError};
+use litellm_llms::base_llm::anthropic_messages::transformation::BaseAnthropicMessagesConfig;
use litellm_types::llms::anthropic_messages::anthropic_response::AnthropicMessagesResponse;
use serde_json::Value;
diff --git a/litellm-rust/crates/core/src/messages/tests.rs b/litellm-rust/crates/core/src/messages/tests.rs
index 55d8ead8e8b..057b42a316c 100644
--- a/litellm-rust/crates/core/src/messages/tests.rs
+++ b/litellm-rust/crates/core/src/messages/tests.rs
@@ -82,7 +82,7 @@ fn string_headers_rejects_non_string_values() {
let err = string_headers(Some(headers)).expect_err("non-string header rejected");
assert_eq!(
err,
- Error::Headers(litellm_llms::custom_httpx::http_handler::HeaderError {
+ Error::Headers(litellm_http::request::HeaderError {
context: "messages",
name: "x-count".to_string(),
actual: "number",
@@ -432,7 +432,7 @@ async fn messages_maps_provider_error_status_to_http_error() {
assert!(matches!(
err,
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Http { status: 401, .. })
+ Error::Transport(litellm_http::transport::Error::Http { status: 401, .. })
));
}
diff --git a/litellm-rust/crates/core/src/ocr/client.rs b/litellm-rust/crates/core/src/ocr/client.rs
index c7b4751bd9e..e635f93a294 100644
--- a/litellm-rust/crates/core/src/ocr/client.rs
+++ b/litellm-rust/crates/core/src/ocr/client.rs
@@ -1,6 +1,5 @@
-use litellm_llms::{
- base_llm::ocr::{error::Error, transformation::LiteLLMOcrResponse},
- custom_httpx::llm_http_handler::OcrClient,
+use litellm_llms::base_llm::ocr::{
+ error::Error, handler::OcrClient, transformation::LiteLLMOcrResponse,
};
use crate::ocr::{
diff --git a/litellm-rust/crates/core/src/ocr/handler.rs b/litellm-rust/crates/core/src/ocr/handler.rs
index bbf9cfa0e02..19037e49033 100644
--- a/litellm-rust/crates/core/src/ocr/handler.rs
+++ b/litellm-rust/crates/core/src/ocr/handler.rs
@@ -1,12 +1,10 @@
use futures_util::future::BoxFuture;
use litellm_auth::SecretValue;
use litellm_host::event::{MachineEvent, RawResponse, RequestContext, WireRequest};
-use litellm_llms::{
- base_llm::ocr::{
- error::Error,
- transformation::{LiteLLMOcrResponse, PreparedOcrRequest},
- },
- custom_httpx::llm_http_handler::{CallHooks, OcrClient},
+use litellm_llms::base_llm::ocr::{
+ error::Error,
+ handler::{CallHooks, OcrClient},
+ transformation::{LiteLLMOcrResponse, PreparedOcrRequest},
};
use serde_json::Value;
@@ -24,7 +22,7 @@ pub(crate) async fn perform_ocr_request(
) -> Result {
request.response_format()?;
let config = request.config;
- let request = prepare_request(request, caller_document);
+ let request = prepare_request(request, caller_document, client);
let hooks = OcrCallHooks::new(host.clone(), &request, config);
config.ocr(client, &request, &hooks).await
}
diff --git a/litellm-rust/crates/core/src/ocr/prepare.rs b/litellm-rust/crates/core/src/ocr/prepare.rs
index 8ac038290b7..f1e1dcaaa6d 100644
--- a/litellm-rust/crates/core/src/ocr/prepare.rs
+++ b/litellm-rust/crates/core/src/ocr/prepare.rs
@@ -1,6 +1,7 @@
use litellm_auth::{InputSource, SecretValue, Sourced};
-use litellm_llms::base_llm::ocr::transformation::{
- OcrConnection, OcrCredentialInputs, PreparedOcrRequest, credential_env,
+use litellm_llms::base_llm::ocr::{
+ handler::OcrClient,
+ transformation::{OcrConnection, OcrCredentialInputs, PreparedOcrRequest},
};
use super::provider_config::OcrProvider;
@@ -9,26 +10,31 @@ use crate::ocr::types::{LiteLLMOcrRequest, ResolvedOcrRequest};
pub(crate) fn prepare_request(
request: ResolvedOcrRequest,
caller_document: bool,
+ client: &OcrClient,
) -> PreparedOcrRequest {
let credentials = request.credentials.clone();
- let api_base_env = match request.config.provider() {
- OcrProvider::Mistral => Some("MISTRAL_API_BASE"),
- OcrProvider::AzureAi => Some("AZURE_AI_API_BASE"),
- OcrProvider::Cohere | OcrProvider::Reducto | OcrProvider::VertexAi => None,
+ let (preferred_api_key_env, api_base_env) = match request.config.provider() {
+ OcrProvider::Mistral => (
+ Some("MISTRAL_AZURE_API_KEY"),
+ Some("MISTRAL_AZURE_API_BASE"),
+ ),
+ OcrProvider::AzureAi => (None, Some("AZURE_AI_API_BASE")),
+ OcrProvider::Cohere | OcrProvider::Reducto | OcrProvider::VertexAi => (None, None),
};
+ let secret = |name: &str| client.secrets().truthy(name);
let dynamic_api_key = credentials.dynamic_api_key.or_else(|| {
credentials.api_key.clone().or_else(|| {
- request
- .config
- .get_api_key_env_var()
- .and_then(credential_env)
+ preferred_api_key_env
+ .into_iter()
+ .chain(request.config.get_api_key_env_var())
+ .find_map(secret)
.map(|value| Sourced::new(SecretValue::new(value), InputSource::Environment))
})
});
let dynamic_api_base = credentials.dynamic_api_base.or_else(|| {
credentials.api_base.clone().or_else(|| {
api_base_env
- .and_then(credential_env)
+ .and_then(secret)
.map(|value| Sourced::new(value, InputSource::Environment))
})
});
@@ -51,7 +57,12 @@ pub(crate) fn prepare_request(
PreparedOcrRequest {
model,
document,
- connection: OcrConnection::new(resolved, transport),
+ connection: OcrConnection::new(
+ resolved,
+ transport,
+ client.settings().clone(),
+ client.secrets().clone(),
+ ),
caller_document,
optional_params,
input_sources,
@@ -61,7 +72,11 @@ pub(crate) fn prepare_request(
#[cfg(test)]
pub(crate) fn prepare_request_for_test(request: ResolvedOcrRequest) -> PreparedOcrRequest {
- prepare_request(request, true)
+ prepare_request(
+ request,
+ true,
+ &OcrClient::for_test(reqwest::Client::new(), reqwest::Client::new()),
+ )
}
#[cfg(test)]
diff --git a/litellm-rust/crates/core/src/ocr/provider_config.rs b/litellm-rust/crates/core/src/ocr/provider_config.rs
index 14b34ea4564..ee9ba76928d 100644
--- a/litellm-rust/crates/core/src/ocr/provider_config.rs
+++ b/litellm-rust/crates/core/src/ocr/provider_config.rs
@@ -7,13 +7,13 @@ use litellm_llms::{
},
base_llm::ocr::{
error::Error,
+ handler::{self, CallHooks, OcrClient},
transformation::{
BaseOcrConfig, LiteLLMOcrResponse, OcrCredentialInputs, OcrDocument,
PreparedOcrRequest, ResolvedOcrCredentials,
},
},
cohere::ocr::transformation::CohereParseConfig,
- custom_httpx::llm_http_handler::{self, CallHooks, OcrClient},
mistral::ocr::transformation::MistralOcrConfig,
reducto::ocr::transformation::{ReductoParseLegacyConfig, ReductoParseV3Config},
vertex_ai::ocr::{
@@ -116,7 +116,7 @@ impl OcrConfigKind {
request: &PreparedOcrRequest,
hooks: &dyn CallHooks,
) -> Result {
- with_config!(self, config => llm_http_handler::ocr(&config, client, request, hooks).await)
+ with_config!(self, config => handler::ocr(&config, client, request, hooks).await)
}
}
diff --git a/litellm-rust/crates/core/src/ocr/route.rs b/litellm-rust/crates/core/src/ocr/route.rs
index bfc8c5ca965..26c9ac27102 100644
--- a/litellm-rust/crates/core/src/ocr/route.rs
+++ b/litellm-rust/crates/core/src/ocr/route.rs
@@ -6,9 +6,8 @@ use litellm_host::{
machine::{HostChannel, HostTokenProvider, MachineFault, RouteMachine, TokenRoute},
route::Route,
};
-use litellm_llms::{
- base_llm::ocr::{error::Error, transformation::LiteLLMOcrResponse},
- custom_httpx::llm_http_handler::OcrClient,
+use litellm_llms::base_llm::ocr::{
+ error::Error, handler::OcrClient, transformation::LiteLLMOcrResponse,
};
use super::handler::perform_ocr_request;
diff --git a/litellm-rust/crates/core/src/ocr/types.rs b/litellm-rust/crates/core/src/ocr/types.rs
index 6316088dec8..59c9cec8da9 100644
--- a/litellm-rust/crates/core/src/ocr/types.rs
+++ b/litellm-rust/crates/core/src/ocr/types.rs
@@ -277,7 +277,7 @@ mod tests {
vec![("x-a".to_string(), "1".to_string())]
);
assert_eq!(request.transport.extra_headers_source, InputSource::Request);
- assert_eq!(request.transport.timeout, Duration::from_secs(7));
+ assert_eq!(request.transport.timeout, Some(Duration::from_secs(7)));
assert_eq!(request.input_sources.len(), 2);
let defaulted = LiteLLMOcrRequest::from_inputs(
diff --git a/litellm-rust/crates/core/src/responses/error.rs b/litellm-rust/crates/core/src/responses/error.rs
index 677db2e08de..1c940d8ed9b 100644
--- a/litellm-rust/crates/core/src/responses/error.rs
+++ b/litellm-rust/crates/core/src/responses/error.rs
@@ -11,7 +11,7 @@ pub enum Error {
#[error(transparent)]
Auth(#[from] litellm_auth::Error),
#[error(transparent)]
- Transport(#[from] litellm_llms::custom_httpx::transport::Error),
+ Transport(#[from] litellm_http::transport::Error),
#[error(transparent)]
- Headers(#[from] litellm_llms::custom_httpx::http_handler::HeaderError),
+ Headers(#[from] litellm_http::request::HeaderError),
}
diff --git a/litellm-rust/crates/core/src/responses/websocket.rs b/litellm-rust/crates/core/src/responses/websocket.rs
index ccf4aa75149..f57ba65a6fb 100644
--- a/litellm-rust/crates/core/src/responses/websocket.rs
+++ b/litellm-rust/crates/core/src/responses/websocket.rs
@@ -95,9 +95,7 @@ impl ResponsesWebSocketConnection {
timeout: Option,
) -> Result {
let mut request = url.into_client_request().map_err(|error| {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
- error.to_string(),
- ))
+ Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
})?;
for (name, value) in headers {
let header_name = name
@@ -110,7 +108,7 @@ impl ResponsesWebSocketConnection {
let connect = connect_upstream(request);
let result = match timeout {
Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
+ Error::Transport(litellm_http::transport::Error::Network(
"Responses WebSocket connection timed out".into(),
))
})?,
@@ -118,14 +116,12 @@ impl ResponsesWebSocketConnection {
};
let (socket, _) = result.map_err(|error| match *error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Http {
+ Error::Transport(litellm_http::transport::Error::Http {
status: response.status().as_u16(),
body: String::new(),
})
}
- other => Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
- other.to_string(),
- )),
+ other => Error::Transport(litellm_http::transport::Error::Network(other.to_string())),
})?;
Ok(Self {
socket: Arc::new(Mutex::new(Some(socket))),
@@ -135,16 +131,12 @@ impl ResponsesWebSocketConnection {
pub async fn send_text(&self, text: String) -> Result<(), Error> {
let mut socket = self.socket.lock().await;
let Some(socket) = socket.as_mut() else {
- return Err(Error::Transport(
- litellm_llms::custom_httpx::transport::Error::Network(
- "Responses WebSocket is closed".into(),
- ),
- ));
+ return Err(Error::Transport(litellm_http::transport::Error::Network(
+ "Responses WebSocket is closed".into(),
+ )));
};
socket.send(Message::Text(text)).await.map_err(|error| {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
- error.to_string(),
- ))
+ Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
})
}
@@ -160,9 +152,9 @@ impl ResponsesWebSocketConnection {
.map_err(|error| Error::InvalidResponse(error.to_string())),
Some(Ok(Message::Close(_))) | None => Ok(None),
Some(Ok(_)) => Ok(None),
- Some(Err(error)) => Err(Error::Transport(
- litellm_llms::custom_httpx::transport::Error::Network(error.to_string()),
- )),
+ Some(Err(error)) => Err(Error::Transport(litellm_http::transport::Error::Network(
+ error.to_string(),
+ ))),
}
}
@@ -170,9 +162,7 @@ impl ResponsesWebSocketConnection {
let mut socket = self.socket.lock().await;
if let Some(socket) = socket.as_mut() {
socket.close(None).await.map_err(|error| {
- Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
- error.to_string(),
- ))
+ Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
})?;
}
*socket = None;
diff --git a/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs b/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs
index 3cbe6fe3159..6dc9bfa5e7e 100644
--- a/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs
+++ b/litellm-rust/crates/core/tests/azure_document_intelligence_ocr.rs
@@ -1,10 +1,12 @@
use litellm_host::event::{CallEvent, MachineEvent};
-use litellm_llms::base_llm::ocr::error::Error;
+use litellm_llms::base_llm::ocr::{error::Error, settings::OcrSettings};
use rstest::rstest;
use serde_json::{Value, json};
use super::{
- test_support::{MockResponse, mock_server, perform_ocr, perform_ocr_with, wire_request},
+ test_support::{
+ MockResponse, mock_server, ocr_client, perform_ocr, perform_ocr_with, wire_request,
+ },
wire::{OcrWireRequest, decode_request},
};
use crate::ocr::route::LocalOcrHost;
@@ -200,6 +202,42 @@ async fn immediate_response_normalizes_pages_and_preserves_native() {
);
}
+#[tokio::test]
+async fn client_settings_choose_the_api_version_and_the_inch_to_pixel_dpi() {
+ let (base, seen, server) = mock_server(vec![MockResponse::json(json!({
+ "status":"succeeded",
+ "analyzeResult":{"pages":[{"pageNumber":1,"width":8.5,"height":11,"unit":"inch"}]}
+ }))])
+ .await;
+ let client = ocr_client().with_settings(OcrSettings {
+ document_intelligence_api_version: "2099-01-01".into(),
+ document_intelligence_dpi: 72,
+ ..OcrSettings::default()
+ });
+
+ let result = crate::ocr::client::perform(
+ &client,
+ wire_request("azure_ai/doc-intelligence/prebuilt-read", &base, json!({})),
+ )
+ .await
+ .unwrap();
+ server.await.unwrap();
+
+ let target = seen.lock().unwrap()[0]
+ .split_whitespace()
+ .nth(1)
+ .unwrap()
+ .to_string();
+ assert_eq!(
+ query_value(&format!("{base}{target}"), "api-version").as_deref(),
+ Some("2099-01-01")
+ );
+ assert_eq!(
+ serde_json::to_value(&result.pages[0].dimensions).unwrap(),
+ json!({"width":612,"height":792,"dpi":72})
+ );
+}
+
#[tokio::test]
async fn accepted_response_polls_to_success_with_only_credentials() {
let operation = json!({"status":"succeeded","analyzeResult":{"pages":[]}});
@@ -425,13 +463,19 @@ async fn polling_deadline_bounds_retry_delay() {
},
])
.await;
- let mut request = wire_request("azure_ai/doc-intelligence/prebuilt-read", &base, json!({}));
- request.transport.poll_timeout = std::time::Duration::from_millis(100);
+ let request = wire_request("azure_ai/doc-intelligence/prebuilt-read", &base, json!({}));
+ let client = ocr_client().with_settings(OcrSettings {
+ poll_timeout: std::time::Duration::from_millis(100),
+ ..OcrSettings::default()
+ });
- let error = tokio::time::timeout(std::time::Duration::from_secs(1), perform_ocr(request))
- .await
- .unwrap()
- .unwrap_err();
+ let error = tokio::time::timeout(
+ std::time::Duration::from_secs(1),
+ crate::ocr::client::perform(&client, request),
+ )
+ .await
+ .unwrap()
+ .unwrap_err();
server.await.unwrap();
assert!(error.to_string().contains("timed out"));
}
diff --git a/litellm-rust/crates/core/tests/ocr.rs b/litellm-rust/crates/core/tests/ocr.rs
index e7a8fc0abc1..3aedc7b9023 100644
--- a/litellm-rust/crates/core/tests/ocr.rs
+++ b/litellm-rust/crates/core/tests/ocr.rs
@@ -6,16 +6,15 @@ use litellm_host::{
host::{Host, HostOp, HostResult},
machine::{HostFailure, Machine, MachineStep},
};
-use litellm_http::{HttpClientPool, HttpSettings, Resolution};
-use litellm_llms::{
- base_llm::ocr::{
- error::Error as OcrError,
- transformation::{LiteLLMOcrResponse, OCR_RESPONSE_MAX_BYTES, OcrTransportConfig},
- },
- custom_httpx::{
- llm_http_handler::OcrClient,
- media::{PublicDnsResolver, UrlPolicy},
- },
+use litellm_http::{
+ HttpClientPool, HttpSettings, Resolution,
+ media::{PublicDnsResolver, UrlPolicy},
+};
+use litellm_llms::base_llm::ocr::{
+ error::Error as OcrError,
+ handler::OcrClient,
+ settings::OcrSettings,
+ transformation::{LiteLLMOcrResponse, OCR_RESPONSE_MAX_BYTES, OcrTransportConfig},
};
use rstest::rstest;
use serde_json::{Value, json};
@@ -175,6 +174,43 @@ async fn facade_retains_native_response_when_requested() {
);
}
+#[rstest]
+#[case::plain_key(&[("MISTRAL_API_KEY", "plain")], "plain")]
+#[case::azure_key_wins(&[("MISTRAL_AZURE_API_KEY", "azure"), ("MISTRAL_API_KEY", "plain")], "azure")]
+#[case::empty_azure_key_falls_through(&[("MISTRAL_AZURE_API_KEY", ""), ("MISTRAL_API_KEY", "plain")], "plain")]
+#[tokio::test]
+async fn mistral_env_fallbacks_follow_python_through_the_injected_secret_source(
+ #[case] secrets: &'static [(&'static str, &'static str)],
+ #[case] expected_key: &str,
+) {
+ let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
+ let secret_base = base.clone();
+ let client = ocr_client().with_secrets(Arc::new(move |name: &str| match name {
+ "MISTRAL_AZURE_API_BASE" => Some(secret_base.clone()),
+ "MISTRAL_API_BASE" => Some("http://127.0.0.1:9/never-read".into()),
+ _ => secrets
+ .iter()
+ .find(|(key, _)| *key == name)
+ .map(|(_, value)| value.to_string()),
+ }));
+ let request = decode_request(OcrWireRequest {
+ model: "mistral/model".into(),
+ document: json!({"type":"document_url","document_url":"data:application/pdf;base64,YWJj"}),
+ api_key: None,
+ api_base: None,
+ custom_llm_provider: None,
+ extra_headers: None,
+ optional_params: Default::default(),
+ input_sources: Default::default(),
+ timeout_seconds: Some(2.0),
+ })
+ .unwrap();
+
+ crate::ocr::client::perform(&client, request).await.unwrap();
+ server.await.unwrap();
+ assert!(seen.lock().unwrap()[0].contains(&format!("authorization: Bearer {expected_key}")));
+}
+
#[tokio::test]
async fn ocr_client_uses_the_injected_http_pool_configuration() {
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
@@ -187,6 +223,8 @@ async fn ocr_client_uses_the_injected_http_pool_configuration() {
&Resolution::from(&settings).config,
UrlPolicy::default(),
VertexAuth::default(),
+ OcrSettings::default(),
+ Arc::new(litellm_core_utils::settings::ProcessEnvironment),
)
.unwrap();
crate::ocr::client::perform(&client, wire_request("mistral/model", &base, json!({})))
@@ -624,7 +662,7 @@ async fn read_bounded_response(response: Vec, limit: usize) -> Result {
+ OcrError::Transport(litellm_http::transport::Error::Http { status, body }) => {
assert_eq!(status, 429);
assert_eq!(body, prefix);
}
diff --git a/litellm-rust/crates/core/tests/ocr/support.rs b/litellm-rust/crates/core/tests/ocr/support.rs
index b368a754656..974fa3d6655 100644
--- a/litellm-rust/crates/core/tests/ocr/support.rs
+++ b/litellm-rust/crates/core/tests/ocr/support.rs
@@ -2,9 +2,10 @@ use std::sync::{Arc, Mutex};
use futures_util::future::BoxFuture;
use litellm_host::event::WireRequest;
-use litellm_llms::{
- base_llm::ocr::{error::Error, transformation::LiteLLMOcrResponse},
- custom_httpx::llm_http_handler::{CallHooks, OcrClient},
+use litellm_llms::base_llm::ocr::{
+ error::Error,
+ handler::{CallHooks, OcrClient},
+ transformation::LiteLLMOcrResponse,
};
use serde_json::{Value, json};
use tokio::{
diff --git a/litellm-rust/crates/core/tests/vertex_ai_ocr.rs b/litellm-rust/crates/core/tests/vertex_ai_ocr.rs
index 1f1186c7827..399b7cac39a 100644
--- a/litellm-rust/crates/core/tests/vertex_ai_ocr.rs
+++ b/litellm-rust/crates/core/tests/vertex_ai_ocr.rs
@@ -1,8 +1,8 @@
use litellm_auth::InputSource;
-use litellm_llms::base_llm::ocr::transformation::OcrResponseFormat;
+use litellm_llms::base_llm::ocr::{settings::OcrSettings, transformation::OcrResponseFormat};
use serde_json::{Value, json};
-use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request};
+use super::test_support::{MockResponse, mock_server, ocr_client, perform_ocr, wire_request};
fn request_body(request: &str) -> Value {
serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap()
@@ -48,6 +48,27 @@ async fn facade_executes_vertex_mistral_with_resolved_project_and_location() {
);
}
+#[tokio::test]
+async fn configured_project_and_location_apply_when_the_call_sets_neither() {
+ let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
+ let client = ocr_client().with_settings(OcrSettings {
+ vertex_project: Some("configured-project".into()),
+ vertex_location: Some("europe-west4".into()),
+ ..OcrSettings::default()
+ });
+
+ crate::ocr::client::perform(
+ &client,
+ wire_request("vertex_ai/mistral-ocr-maas", &base, json!({})),
+ )
+ .await
+ .unwrap();
+ server.await.unwrap();
+ assert!(seen.lock().unwrap()[0].starts_with(
+ "POST /v1/projects/configured-project/locations/europe-west4/publishers/mistralai/models/mistral-ocr-maas:rawPredict "
+ ));
+}
+
#[tokio::test]
async fn supplied_authorization_is_forwarded_without_a_static_token() {
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
diff --git a/litellm-rust/crates/http/Cargo.toml b/litellm-rust/crates/http/Cargo.toml
index 0ac09a9d155..d4457f5685c 100644
--- a/litellm-rust/crates/http/Cargo.toml
+++ b/litellm-rust/crates/http/Cargo.toml
@@ -5,12 +5,19 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
+[features]
+test-support = []
+
[dependencies]
http.workspace = true
+litellm-core-utils.workspace = true
hyper-util.workspace = true
reqwest.workspace = true
rustls.workspace = true
+serde_json.workspace = true
thiserror.workspace = true
+tokio.workspace = true
+veil.workspace = true
webpki-roots.workspace = true
[dev-dependencies]
diff --git a/litellm-rust/crates/http/src/config.rs b/litellm-rust/crates/http/src/config.rs
index 10f28b44eec..bf8ecef85a8 100644
--- a/litellm-rust/crates/http/src/config.rs
+++ b/litellm-rust/crates/http/src/config.rs
@@ -6,6 +6,7 @@ use std::{
use crate::{
error::Error,
+ proxy::EnvironmentProxies,
settings::{HttpSettings, SslVerify, TcpKeepalive},
tls::{CipherSelection, KeyExchangeGroup, Tls12CipherSuite, Unsupported},
};
@@ -26,7 +27,7 @@ pub struct HttpClientConfig {
pub force_ipv4: bool,
pub http2: bool,
pub user_agent: Option,
- pub trust_proxy_env: bool,
+ pub proxies: EnvironmentProxies,
pub connect_timeout: Duration,
pub tcp_keepalive: Option,
pub pool_idle_timeout: Duration,
@@ -72,7 +73,11 @@ impl From<&HttpSettings> for Resolution {
force_ipv4: settings.force_ipv4,
http2: settings.http2,
user_agent: settings.user_agent.clone(),
- trust_proxy_env: settings.trust_proxy_env,
+ proxies: if settings.trust_proxy_env {
+ settings.proxies.clone()
+ } else {
+ EnvironmentProxies::default()
+ },
connect_timeout: settings.connect_timeout,
tcp_keepalive: settings.tcp_keepalive,
pool_idle_timeout: settings.pool_idle_timeout,
@@ -111,11 +116,11 @@ impl TryFrom<&HttpClientConfig> for reqwest::ClientBuilder {
Some(agent) => with_protocol.user_agent(agent),
None => with_protocol,
};
- Ok(if config.trust_proxy_env {
- with_agent
- } else {
- with_agent.no_proxy()
- })
+ Ok(config
+ .proxies
+ .reqwest_proxies()
+ .into_iter()
+ .fold(with_agent.no_proxy(), reqwest::ClientBuilder::proxy))
}
}
@@ -227,6 +232,25 @@ mod tests {
);
}
+ fn proxies() -> EnvironmentProxies {
+ EnvironmentProxies::from_environment(&|name: &str| {
+ (name == "HTTPS_PROXY").then(|| "http://proxy.corp:3128".to_string())
+ })
+ }
+
+ #[test]
+ fn proxies_are_dropped_when_the_transport_does_not_trust_the_environment() {
+ let settings = HttpSettings {
+ trust_proxy_env: false,
+ proxies: proxies(),
+ ..HttpSettings::default()
+ };
+ assert_eq!(
+ Resolution::from(&settings).config.proxies,
+ EnvironmentProxies::default()
+ );
+ }
+
#[test]
fn connection_settings_carry_over_unchanged() {
let keepalive = TcpKeepalive {
@@ -240,6 +264,7 @@ mod tests {
http2: true,
user_agent: Some("litellm/1.0".into()),
trust_proxy_env: true,
+ proxies: proxies(),
connect_timeout: Duration::from_secs(7),
tcp_keepalive: Some(keepalive),
pool_idle_timeout: Duration::from_secs(45),
@@ -256,7 +281,7 @@ mod tests {
force_ipv4: true,
http2: true,
user_agent: Some("litellm/1.0".into()),
- trust_proxy_env: true,
+ proxies: proxies(),
connect_timeout: Duration::from_secs(7),
tcp_keepalive: Some(keepalive),
pool_idle_timeout: Duration::from_secs(45),
diff --git a/litellm-rust/crates/http/src/lib.rs b/litellm-rust/crates/http/src/lib.rs
index ddbc3b63b08..c6d9959348d 100644
--- a/litellm-rust/crates/http/src/lib.rs
+++ b/litellm-rust/crates/http/src/lib.rs
@@ -1,9 +1,12 @@
mod config;
mod error;
+pub mod media;
mod pool;
mod proxy;
+pub mod request;
mod settings;
mod tls;
+pub mod transport;
pub use config::{HttpClientConfig, Resolution, Verify};
pub use error::Error;
diff --git a/litellm-rust/crates/llms/src/custom_httpx/media.rs b/litellm-rust/crates/http/src/media.rs
similarity index 96%
rename from litellm-rust/crates/llms/src/custom_httpx/media.rs
rename to litellm-rust/crates/http/src/media.rs
index 572e7f12e54..3b29c9e28a7 100644
--- a/litellm-rust/crates/llms/src/custom_httpx/media.rs
+++ b/litellm-rust/crates/http/src/media.rs
@@ -7,12 +7,13 @@ use std::{
time::Duration,
};
-use litellm_http::{ClientVariant, EnvironmentProxies, HttpClientConfig, HttpClientPool};
use reqwest::{
Url,
dns::{Addrs, Name, Resolve, Resolving},
};
+use crate::{ClientVariant, HttpClientConfig, HttpClientPool};
+
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("media URL rejected by network policy")]
@@ -32,7 +33,7 @@ pub enum Error {
#[error("media download timed out")]
Timeout,
#[error("{0}")]
- Transport(#[from] crate::custom_httpx::transport::Error),
+ Transport(#[from] crate::transport::Error),
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -101,13 +102,8 @@ impl MediaFetcher {
pool: &HttpClientPool,
config: &HttpClientConfig,
url_policy: UrlPolicy,
- ) -> Result {
- let uses_proxy: ProxyMatch = if config.trust_proxy_env {
- let proxies = EnvironmentProxies::from_environment();
- Arc::new(move |url| proxies.apply_to(url))
- } else {
- Arc::new(|_| false)
- };
+ ) -> Result {
+ let uses_proxy: ProxyMatch = Arc::new(config.proxies.matcher());
Self::with_resolution(
pool,
config,
@@ -123,7 +119,7 @@ impl MediaFetcher {
url_policy: UrlPolicy,
address_resolver: Arc,
uses_proxy: ProxyMatch,
- ) -> Result {
+ ) -> Result {
Ok(Self {
pinned: pool.client(config, ClientVariant::Media)?,
unpinned: pool.client(config, ClientVariant::UnpinnedMedia)?,
@@ -168,7 +164,7 @@ impl MediaFetcher {
.get(url.clone())
.send()
.await
- .map_err(crate::custom_httpx::transport::Error::from)?;
+ .map_err(crate::transport::Error::from)?;
if response.status().is_redirection() {
if redirects_followed == policy.max_redirects {
return Err(Error::TooManyRedirects);
@@ -199,7 +195,7 @@ impl MediaFetcher {
while let Some(chunk) = response
.chunk()
.await
- .map_err(crate::custom_httpx::transport::Error::from)?
+ .map_err(crate::transport::Error::from)?
{
enforce_download_size(bytes.len() as u64 + chunk.len() as u64, policy.max_bytes)?;
bytes.extend_from_slice(&chunk);
@@ -249,7 +245,7 @@ impl MediaFetcher {
.address_resolver
.resolve(host, port)
.await
- .map_err(|error| crate::custom_httpx::transport::Error::Network(error.to_string()))?;
+ .map_err(|error| crate::transport::Error::Network(error.to_string()))?;
validate_addresses(&addresses)
}
}
@@ -350,13 +346,13 @@ impl Resolve for PublicDnsResolver {
mod tests {
use std::collections::HashSet;
- use litellm_http::{HttpSettings, Resolution};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
use super::*;
+ use crate::{HttpSettings, Resolution};
async fn serve(response: &'static [u8]) -> (Url, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -443,10 +439,7 @@ mod tests {
url_policy: UrlPolicy,
uses_proxy: bool,
) -> MediaFetcher {
- let direct = HttpClientConfig {
- trust_proxy_env: false,
- ..Resolution::from(&HttpSettings::default()).config
- };
+ let direct = Resolution::from(&HttpSettings::default()).config;
MediaFetcher::with_resolution(
&HttpClientPool::new(Arc::new(LoopbackDnsResolver(pinned_address))),
&direct,
diff --git a/litellm-rust/crates/http/src/pool.rs b/litellm-rust/crates/http/src/pool.rs
index 330d6de29e8..ee47e5dc52a 100644
--- a/litellm-rust/crates/http/src/pool.rs
+++ b/litellm-rust/crates/http/src/pool.rs
@@ -6,7 +6,7 @@ use std::{
use reqwest::dns::Resolve;
-use crate::{config::HttpClientConfig, error::Error};
+use crate::{config::HttpClientConfig, error::Error, proxy::EnvironmentProxies};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ClientVariant {
@@ -52,7 +52,7 @@ impl HttpClientPool {
let effective = match variant {
ClientVariant::Media => HttpClientConfig {
client_certificate: None,
- trust_proxy_env: false,
+ proxies: EnvironmentProxies::default(),
..config.clone()
},
ClientVariant::UnpinnedMedia => HttpClientConfig {
@@ -138,6 +138,13 @@ mod tests {
}
}
+ fn proxied_through(proxy: &str) -> EnvironmentProxies {
+ let proxy = proxy.to_owned();
+ EnvironmentProxies::from_environment(&move |name: &str| {
+ (name == "HTTP_PROXY").then(|| proxy.clone())
+ })
+ }
+
async fn serve(
status_line: &'static str,
) -> (SocketAddr, Arc, Arc>>) {
@@ -202,6 +209,50 @@ mod tests {
assert_eq!(connections.load(Ordering::SeqCst), 3);
}
+ #[tokio::test]
+ async fn provider_clients_route_through_the_resolved_proxy_not_the_process_environment() {
+ let (proxy, connections, requests) = serve("HTTP/1.1 204 No Content").await;
+ let config = HttpClientConfig {
+ proxies: proxied_through(&format!("http://user:secret@{proxy}")),
+ ..config("a")
+ };
+ let response = get(
+ &pool(),
+ &config,
+ ClientVariant::Provider,
+ "http://upstream.invalid/v1/ocr",
+ )
+ .await;
+ assert_eq!(response.status(), 204);
+ assert_eq!(connections.load(Ordering::SeqCst), 1);
+ let request = requests.lock().unwrap().concat();
+ assert!(request.starts_with("GET http://upstream.invalid/v1/ocr HTTP/1.1"));
+ assert!(request.contains("proxy-authorization: Basic dXNlcjpzZWNyZXQ="));
+ }
+
+ #[tokio::test]
+ async fn no_proxy_hosts_bypass_the_resolved_proxy() {
+ let (upstream, _, _) = serve("HTTP/1.1 204 No Content").await;
+ let (proxy, proxy_connections, _) = serve("HTTP/1.1 502 Bad Gateway").await;
+ let config = HttpClientConfig {
+ proxies: EnvironmentProxies::from_environment(&move |name: &str| match name {
+ "HTTP_PROXY" => Some(format!("http://{proxy}")),
+ "NO_PROXY" => Some("127.0.0.1".into()),
+ _ => None,
+ }),
+ ..config("a")
+ };
+ let response = get(
+ &pool(),
+ &config,
+ ClientVariant::Provider,
+ &format!("http://{upstream}/v1/ocr"),
+ )
+ .await;
+ assert_eq!(response.status(), 204);
+ assert_eq!(proxy_connections.load(Ordering::SeqCst), 0);
+ }
+
#[tokio::test]
async fn expired_clients_are_rebuilt() {
let (address, connections, _) = serve("HTTP/1.1 204 No Content").await;
@@ -220,9 +271,12 @@ mod tests {
let (address, connections, _) = serve("HTTP/1.1 204 No Content").await;
let pool = HttpClientPool::new(Arc::new(FixedResolver(address)));
let url = format!("http://media.invalid:{}/doc", address.port());
- for trust_proxy_env in [true, false] {
+ for proxies in [
+ proxied_through("http://proxy.invalid:3128"),
+ EnvironmentProxies::default(),
+ ] {
let config = HttpClientConfig {
- trust_proxy_env,
+ proxies,
..config("a")
};
get(&pool, &config, ClientVariant::Media, &url).await;
diff --git a/litellm-rust/crates/http/src/proxy.rs b/litellm-rust/crates/http/src/proxy.rs
index 4dc4bf778b8..eb960d8200d 100644
--- a/litellm-rust/crates/http/src/proxy.rs
+++ b/litellm-rust/crates/http/src/proxy.rs
@@ -1,15 +1,164 @@
use hyper_util::client::proxy::matcher::Matcher;
+use litellm_core_utils::settings::Lookup;
+use veil::Redact;
-pub struct EnvironmentProxies(Matcher);
+#[derive(Clone, Redact, Default, PartialEq, Eq, Hash)]
+pub struct EnvironmentProxies {
+ #[redact]
+ all: String,
+ #[redact]
+ http: String,
+ #[redact]
+ https: String,
+ no: String,
+}
impl EnvironmentProxies {
- pub fn from_environment() -> Self {
- Self(Matcher::from_system())
+ pub fn from_environment(env: &impl Lookup) -> Self {
+ Self::resolve(env, cfg!(windows))
}
- pub fn apply_to(&self, url: &reqwest::Url) -> bool {
- url.as_str()
- .parse::()
- .is_ok_and(|uri| self.0.intercept(&uri).is_some())
+ fn resolve(env: &impl Lookup, names_ignore_case: bool) -> Self {
+ let lowercase_first = |upper: Option<&str>, lower: &str| {
+ env.get(lower)
+ .or_else(|| upper.and_then(|name| env.truthy(name)))
+ .unwrap_or_default()
+ };
+ let is_cgi = env.get("REQUEST_METHOD").is_some();
+ Self {
+ all: lowercase_first(Some("ALL_PROXY"), "all_proxy"),
+ http: if is_cgi && names_ignore_case {
+ String::new()
+ } else {
+ lowercase_first((!is_cgi).then_some("HTTP_PROXY"), "http_proxy")
+ },
+ https: lowercase_first(Some("HTTPS_PROXY"), "https_proxy"),
+ no: lowercase_first(Some("NO_PROXY"), "no_proxy"),
+ }
+ }
+
+ pub(crate) fn matcher(&self) -> impl Fn(&reqwest::Url) -> bool + Send + Sync + use<> {
+ let matcher = Matcher::builder()
+ .all(self.all.clone())
+ .http(self.http.clone())
+ .https(self.https.clone())
+ .no(self.no.clone())
+ .build();
+ move |url| {
+ url.as_str()
+ .parse::()
+ .is_ok_and(|uri| matcher.intercept(&uri).is_some())
+ }
+ }
+
+ pub(crate) fn reqwest_proxies(&self) -> Vec {
+ let no_proxy = reqwest::NoProxy::from_string(&self.no);
+ [
+ reqwest::Proxy::http(self.http.as_str()),
+ reqwest::Proxy::https(self.https.as_str()),
+ reqwest::Proxy::all(self.all.as_str()),
+ ]
+ .into_iter()
+ .filter_map(Result::ok)
+ .map(|proxy| proxy.no_proxy(no_proxy.clone()))
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use rstest::rstest;
+
+ use super::*;
+
+ fn env_of(values: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option {
+ move |name| {
+ values
+ .iter()
+ .find(|(key, _)| *key == name)
+ .map(|(_, value)| value.to_string())
+ }
+ }
+
+ fn url(value: &str) -> reqwest::Url {
+ reqwest::Url::parse(value).unwrap()
+ }
+
+ #[rstest]
+ #[case::http_only(&[("HTTP_PROXY", "http://proxy:3128")], "http://api.test/", true)]
+ #[case::http_proxy_skips_https(&[("HTTP_PROXY", "http://proxy:3128")], "https://api.test/", false)]
+ #[case::all_covers_https(&[("ALL_PROXY", "http://proxy:3128")], "https://api.test/", true)]
+ #[case::lowercase(&[("https_proxy", "http://proxy:3128")], "https://api.test/", true)]
+ #[case::no_proxy_bypass(&[("HTTPS_PROXY", "http://proxy:3128"), ("NO_PROXY", "api.test")], "https://api.test/", false)]
+ fn proxies_follow_the_injected_environment(
+ #[case] env: &'static [(&'static str, &'static str)],
+ #[case] target: &str,
+ #[case] expected: bool,
+ ) {
+ let proxies = EnvironmentProxies::from_environment(&env_of(env));
+ assert_eq!(proxies.matcher()(&url(target)), expected);
+ }
+
+ #[rstest]
+ #[case::lowercase_wins(&[("HTTPS_PROXY", "http://upper:3128"), ("https_proxy", "http://lower:3128")], &[("https_proxy", "http://lower:3128")])]
+ #[case::empty_uppercase_falls_through_to_lowercase(&[("HTTPS_PROXY", ""), ("https_proxy", "http://lower:3128")], &[("https_proxy", "http://lower:3128")])]
+ #[case::empty_uppercase_alone_is_unset(&[("HTTPS_PROXY", "")], &[])]
+ #[case::empty_lowercase_clears_the_uppercase_value(&[("HTTPS_PROXY", "http://upper:3128"), ("https_proxy", "")], &[])]
+ #[case::lowercase_no_proxy_wins(&[("NO_PROXY", "upper.test"), ("no_proxy", "lower.test")], &[("no_proxy", "lower.test")])]
+ #[case::cgi_forgets_the_client_settable_http_proxy(&[("REQUEST_METHOD", "GET"), ("HTTP_PROXY", "http://attacker:3128")], &[])]
+ #[case::cgi_keeps_lowercase_http_proxy(&[("REQUEST_METHOD", "GET"), ("HTTP_PROXY", "http://attacker:3128"), ("http_proxy", "http://lower:3128")], &[("http_proxy", "http://lower:3128")])]
+ #[case::cgi_keeps_every_other_variable(&[("REQUEST_METHOD", "GET"), ("HTTPS_PROXY", "http://proxy:3128"), ("ALL_PROXY", "http://all:3128"), ("NO_PROXY", "internal.test")], &[("HTTPS_PROXY", "http://proxy:3128"), ("ALL_PROXY", "http://all:3128"), ("NO_PROXY", "internal.test")])]
+ fn variables_resolve_like_urllib_getproxies_environment(
+ #[case] env: &'static [(&'static str, &'static str)],
+ #[case] equivalent: &'static [(&'static str, &'static str)],
+ ) {
+ assert_eq!(
+ EnvironmentProxies::from_environment(&env_of(env)),
+ EnvironmentProxies::from_environment(&env_of(equivalent))
+ );
+ }
+
+ #[test]
+ fn cgi_drops_http_proxy_entirely_where_variable_names_ignore_case() {
+ let windows_env = |name: &str| match name.to_ascii_uppercase().as_str() {
+ "REQUEST_METHOD" => Some("GET".to_string()),
+ "HTTP_PROXY" => Some("http://attacker:3128".to_string()),
+ "HTTPS_PROXY" => Some("http://proxy:3128".to_string()),
+ _ => None,
+ };
+ let proxies = EnvironmentProxies::resolve(&windows_env, true);
+ assert!(!proxies.matcher()(&url("http://api.test/")));
+ assert!(proxies.matcher()(&url("https://api.test/")));
+ assert!(EnvironmentProxies::resolve(&windows_env, false).matcher()(
+ &url("http://api.test/")
+ ));
+ }
+
+ #[test]
+ fn a_cgi_request_still_proxies_https_through_the_configured_proxy() {
+ let proxies = EnvironmentProxies::from_environment(&env_of(&[
+ ("REQUEST_METHOD", "GET"),
+ ("HTTPS_PROXY", "http://proxy:3128"),
+ ]));
+ assert!(proxies.matcher()(&url("https://api.test/")));
+ }
+
+ #[test]
+ fn debug_output_hides_proxy_credentials_but_shows_which_variables_are_set() {
+ let proxies = EnvironmentProxies::from_environment(&env_of(&[
+ ("HTTPS_PROXY", "http://operator:hunter2@proxy.corp:3128"),
+ ("NO_PROXY", "internal.test"),
+ ]));
+ let debug = format!("{proxies:?}");
+ assert!(!debug.contains("hunter2") && !debug.contains("operator"));
+ assert!(debug.contains("internal.test"));
+ assert_ne!(debug, format!("{:?}", EnvironmentProxies::default()));
+ }
+
+ #[test]
+ fn an_empty_environment_proxies_nothing() {
+ let proxies = EnvironmentProxies::from_environment(&env_of(&[]));
+ assert_eq!(proxies, EnvironmentProxies::default());
+ assert!(proxies.reqwest_proxies().is_empty());
}
}
diff --git a/litellm-rust/crates/llms/src/custom_httpx/http_handler.rs b/litellm-rust/crates/http/src/request.rs
similarity index 93%
rename from litellm-rust/crates/llms/src/custom_httpx/http_handler.rs
rename to litellm-rust/crates/http/src/request.rs
index e629be37336..874a0f3abf9 100644
--- a/litellm-rust/crates/llms/src/custom_httpx/http_handler.rs
+++ b/litellm-rust/crates/http/src/request.rs
@@ -13,20 +13,12 @@ use serde_json::{Map, Value};
/// before truncation, so provider bodies are bounded and data-minimized.
const UPSTREAM_ERROR_BODY_MAX_CHARS: usize = 256;
-#[allow(
- dead_code,
- reason = "used by the OCR architecture in the next stacked PR"
-)]
pub enum HeaderPolicy<'a> {
All,
Only(&'a [&'a str]),
Except(&'a [&'a str]),
}
-#[allow(
- dead_code,
- reason = "used by the OCR architecture in the next stacked PR"
-)]
pub fn with_headers(
builder: reqwest::RequestBuilder,
headers: &[(String, String)],
@@ -107,18 +99,6 @@ pub fn has_bearer_auth(headers: &[(String, String)]) -> bool {
})
}
-#[allow(
- dead_code,
- reason = "used by the OCR architecture in the next stacked PR"
-)]
-pub fn deserialize_optional_param<'de, D, T>(deserializer: D) -> Result
))}
+
);
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index a5ede63bf7f..d43adfe1ae4 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -7693,6 +7693,10 @@ export interface paths {
* - max_budget: Optional[float] - Max budget for key
* - team_id: Optional[str] - Team ID associated with key
* - tags: Optional[List[str]] - Tags for organizing keys
+ * - object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission, as on /key/update
+ *
+ * Only the fields an item carries are written: a field left out keeps its current value, and a field
+ * sent explicitly, null included, is applied exactly as /key/update applies it.
*
* Returns:
* - total_requested: int - Total number of keys requested for update
@@ -25237,7 +25241,7 @@ export interface components {
};
/**
* BulkUpdateKeyRequestItem
- * @description Individual key update request item
+ * @description One /key/bulk_update item; only the fields it carries are written.
*/
BulkUpdateKeyRequestItem: {
/** Budget Id */
@@ -25246,6 +25250,7 @@ export interface components {
key: string;
/** Max Budget */
max_budget?: number | null;
+ object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null;
/** Tags */
tags?: string[] | null;
/** Team Id */
@@ -30665,6 +30670,8 @@ export interface components {
allow_client_keepalive_override: boolean | null;
/** Annotation Cost Per Page */
annotation_cost_per_page?: number | null;
+ /** Annotation Cost Per Page Batches */
+ annotation_cost_per_page_batches?: number | null;
/** Api Base */
api_base?: string | null;
/** Api Key */
@@ -30894,6 +30901,8 @@ export interface components {
ocr_cost_per_credit?: number | null;
/** Ocr Cost Per Page */
ocr_cost_per_page?: number | null;
+ /** Ocr Cost Per Page Batches */
+ ocr_cost_per_page_batches?: number | null;
/** Organization */
organization?: string | null;
/** Otpm */
@@ -31795,9 +31804,8 @@ export interface components {
/**
* Api Version
* @description API version for Javelin service
- * @default v1
*/
- api_version: string | null;
+ api_version?: string | null;
/**
* Application
* @description Application name for Javelin service
@@ -41316,6 +41324,8 @@ export interface components {
allow_client_keepalive_override: boolean | null;
/** Annotation Cost Per Page */
annotation_cost_per_page?: number | null;
+ /** Annotation Cost Per Page Batches */
+ annotation_cost_per_page_batches?: number | null;
/** Api Base */
api_base?: string | null;
/** Api Key */
@@ -41545,6 +41555,8 @@ export interface components {
ocr_cost_per_credit?: number | null;
/** Ocr Cost Per Page */
ocr_cost_per_page?: number | null;
+ /** Ocr Cost Per Page Batches */
+ ocr_cost_per_page_batches?: number | null;
/** Organization */
organization?: string | null;
/** Otpm */