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 9f71880c610..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
@@ -24767,10 +24771,16 @@ export interface components {
redirect_uri?: string;
/** Refresh Token */
refresh_token?: string | null;
+ /** Requested Token Type */
+ requested_token_type?: string | null;
/** Resource */
resource?: string | null;
/** Scope */
scope?: string | null;
+ /** Subject Token */
+ subject_token?: string | null;
+ /** Subject Token Type */
+ subject_token_type?: string | null;
};
/** Body_token_endpoint_token_post */
Body_token_endpoint_token_post: {
@@ -24788,10 +24798,16 @@ export interface components {
redirect_uri?: string;
/** Refresh Token */
refresh_token?: string | null;
+ /** Requested Token Type */
+ requested_token_type?: string | null;
/** Resource */
resource?: string | null;
/** Scope */
scope?: string | null;
+ /** Subject Token */
+ subject_token?: string | null;
+ /** Subject Token Type */
+ subject_token_type?: string | null;
};
/** Body_upload_logo_upload_logo_post */
Body_upload_logo_upload_logo_post: {
@@ -25225,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 */
@@ -25234,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 */
@@ -26686,6 +26703,13 @@ export interface components {
* @description cancel the in-flight upstream LLM request (non-streaming) when the client disconnects, freeing backend capacity (e.g. a vLLM GPU slot); the request is logged as a 499 failure
*/
cancel_on_disconnect?: boolean | null;
+ /**
+ * Claude Code Gateway Managed Settings
+ * @description Claude Code managed-settings.json served verbatim at the gateway's /claude_code_gateway/managed/settings endpoint. When unset the endpoint returns 404 (no managed policy)
+ */
+ claude_code_gateway_managed_settings?: {
+ [key: string]: unknown;
+ } | null;
/**
* Completion Model
* @description proxy level default model for all chat completion calls
@@ -26780,6 +26804,11 @@ export interface components {
* @description If True, disables ownership enforcement on Responses API ids. Keys may then retrieve, cancel, delete, and chain from any response id, including ids belonging to another user or team and ids this proxy never issued. WARNING: this removes tenant isolation on /v1/responses
*/
disable_responses_id_security?: boolean | null;
+ /**
+ * Enable Claude Code Gateway
+ * @description serve the Claude Code gateway protocol (https://code.claude.com/docs/en/claude-apps-gateway) under /claude_code_gateway: OAuth device-flow sign-in reusing proxy SSO, plus managed settings and OTLP telemetry ingestion. Off by default
+ */
+ enable_claude_code_gateway?: boolean | null;
/**
* Enable Openai Websocket Passthrough
* @description Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default.
@@ -30641,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 */
@@ -30870,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 */
@@ -31771,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
@@ -41292,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 */
@@ -41521,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 */