Skip to main content

litellm_rs/core/providers/base/
http.rs

1//! Shared HTTP client and canonical provider error mapping utilities for providers.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use reqwest::{IntoUrl, Method};
7use serde_json::Value;
8
9use crate::core::net::{ProviderEndpointAccess, ProviderEndpointPolicy};
10use crate::core::providers::base::BaseConfig;
11use crate::core::providers::base::connection_pool::HeaderPair;
12use crate::core::providers::unified_provider::ProviderError;
13use crate::utils::net::http::{ProviderHttpClient, ProviderRequestBuilder};
14
15const ENDPOINT_POLICY_ERROR_MESSAGE: &str = "Provider endpoint rejected by SSRF protection";
16
17/// Base HTTP client wrapper used by provider implementations.
18#[derive(Debug, Clone)]
19pub struct BaseHttpClient {
20    client: ProviderHttpClient,
21    config: BaseConfig,
22    provider: &'static str,
23}
24
25#[derive(Clone, Copy)]
26enum BaseRedirectMode {
27    Policy,
28    Disabled,
29    Streaming,
30    StreamingDisabled,
31}
32
33impl BaseHttpClient {
34    /// Create a new HTTP client with common configuration.
35    pub fn new(config: BaseConfig) -> Result<Self, ProviderError> {
36        Self::new_for_provider("provider", config)
37    }
38
39    /// Create a policy-aware HTTP client for a named provider.
40    pub fn new_for_provider(
41        provider: &'static str,
42        config: BaseConfig,
43    ) -> Result<Self, ProviderError> {
44        Self::build(provider, config, BaseRedirectMode::Policy)
45    }
46
47    /// Create a policy-aware client that never follows redirects.
48    pub fn new_for_provider_no_redirect(
49        provider: &'static str,
50        config: BaseConfig,
51    ) -> Result<Self, ProviderError> {
52        Self::build(provider, config, BaseRedirectMode::Disabled)
53    }
54
55    /// Create a policy-aware client without a total response-lifetime timeout.
56    pub fn new_for_provider_streaming(
57        provider: &'static str,
58        config: BaseConfig,
59    ) -> Result<Self, ProviderError> {
60        Self::build(provider, config, BaseRedirectMode::Streaming)
61    }
62
63    pub(crate) fn new_for_provider_streaming_no_redirect(
64        provider: &'static str,
65        config: BaseConfig,
66    ) -> Result<Self, ProviderError> {
67        Self::build(provider, config, BaseRedirectMode::StreamingDisabled)
68    }
69
70    fn build(
71        provider: &'static str,
72        config: BaseConfig,
73        redirect_mode: BaseRedirectMode,
74    ) -> Result<Self, ProviderError> {
75        let policy = match config.api_base.as_deref() {
76            Some(api_base) => {
77                ProviderEndpointPolicy::for_base_url(config.endpoint_access, api_base).map_err(
78                    |error| {
79                        ProviderError::configuration(
80                            provider,
81                            format!("invalid provider API base: {error}"),
82                        )
83                    },
84                )?
85            }
86            None if config.endpoint_access == ProviderEndpointAccess::PrivateNetwork => {
87                return Err(ProviderError::configuration(
88                    provider,
89                    "private_network endpoint access requires an API base",
90                ));
91            }
92            None => ProviderEndpointPolicy::public_only(),
93        };
94        let timeout = Duration::from_secs(config.timeout);
95        let client_result = match redirect_mode {
96            BaseRedirectMode::Policy => ProviderHttpClient::new(policy, timeout),
97            BaseRedirectMode::Disabled => ProviderHttpClient::no_redirect(policy, timeout),
98            BaseRedirectMode::Streaming => ProviderHttpClient::streaming(policy),
99            BaseRedirectMode::StreamingDisabled => {
100                ProviderHttpClient::streaming_no_redirect(policy)
101            }
102        };
103        let client = client_result.map_err(|error| {
104            ProviderError::initialization(
105                provider,
106                format!("failed to create policy-aware HTTP client: {error}"),
107            )
108        })?;
109        Ok(Self {
110            client,
111            config,
112            provider,
113        })
114    }
115
116    /// Create a policy-checked request builder.
117    pub fn request<U: IntoUrl>(
118        &self,
119        method: Method,
120        url: U,
121    ) -> Result<ProviderRequestBuilder, ProviderError> {
122        self.client
123            .request(method, url)
124            .map_err(|error| ProviderError::network(self.provider, error.to_string()))
125    }
126
127    pub(crate) fn request_preserving_endpoint_policy<U: IntoUrl>(
128        &self,
129        method: Method,
130        url: U,
131    ) -> Result<ProviderRequestBuilder, ProviderError> {
132        self.client.request(method, url).map_err(|error| {
133            if error.is_endpoint_policy() {
134                ProviderError::configuration(self.provider, ENDPOINT_POLICY_ERROR_MESSAGE)
135            } else {
136                ProviderError::network(self.provider, error.to_string())
137            }
138        })
139    }
140
141    pub(crate) fn map_preserved_request_error(&self, error: reqwest::Error) -> ProviderError {
142        if ProviderHttpClient::request_error_is_endpoint_policy(&error) {
143            ProviderError::configuration(self.provider, ENDPOINT_POLICY_ERROR_MESSAGE)
144        } else if error.is_builder() {
145            ProviderError::configuration(self.provider, "provider request could not be constructed")
146        } else if error.is_timeout() {
147            ProviderError::timeout(self.provider, "Provider request timed out")
148        } else {
149            ProviderError::network(self.provider, error.to_string())
150        }
151    }
152
153    #[cfg(feature = "providers-extended")]
154    pub(crate) fn request_error_may_have_been_dispatched(error: &reqwest::Error) -> bool {
155        !ProviderHttpClient::request_error_is_endpoint_policy(error)
156            && !error.is_builder()
157            && !error.is_connect()
158    }
159
160    /// Create a policy-checked GET request builder.
161    pub fn get<U: IntoUrl>(&self, url: U) -> Result<ProviderRequestBuilder, ProviderError> {
162        self.request(Method::GET, url)
163    }
164
165    /// Create a policy-checked POST request builder.
166    pub fn post<U: IntoUrl>(&self, url: U) -> Result<ProviderRequestBuilder, ProviderError> {
167        self.request(Method::POST, url)
168    }
169
170    /// Get configuration.
171    pub fn config(&self) -> &BaseConfig {
172        &self.config
173    }
174}
175
176/// Apply provider headers without exposing the policy-bound raw client.
177#[inline]
178pub fn apply_provider_headers(
179    mut builder: ProviderRequestBuilder,
180    headers: Vec<HeaderPair>,
181) -> ProviderRequestBuilder {
182    for (key, value) in headers {
183        builder = builder.header(key.as_ref(), value.as_ref());
184    }
185    builder
186}
187
188/// Canonical HTTP status/body -> ProviderError mapper.
189pub struct HttpErrorMapper;
190
191impl HttpErrorMapper {
192    /// Map HTTP status code to provider error.
193    pub fn map_status_code(provider: &'static str, status: u16, body: &str) -> ProviderError {
194        let message = extract_error_message(body).unwrap_or_else(|| body.to_string());
195
196        match status {
197            400 => {
198                if is_context_length_error(body) {
199                    ProviderError::context_length_exceeded(provider, 0, 0)
200                } else if is_content_filter_error(body) {
201                    ProviderError::content_filtered(provider, message, None, Some(false))
202                } else {
203                    ProviderError::invalid_request(provider, message)
204                }
205            }
206            401 => ProviderError::authentication(provider, message),
207            402 => ProviderError::quota_exceeded(provider, message),
208            // Keep upstream 403 as an api_error so clients see permission
209            // failure (403), not an authentication failure (401).
210            403 => ProviderError::api_error(provider, status, message),
211            404 => ProviderError::model_not_found(provider, message),
212            408 | 504 => ProviderError::timeout(provider, message),
213            413 => ProviderError::context_length_exceeded(provider, 0, 0),
214            429 => ProviderError::rate_limit(
215                provider,
216                crate::core::providers::shared::parse_retry_after_from_body(body),
217            ),
218            502 | 503 => ProviderError::provider_unavailable(provider, message),
219            500..=599 => ProviderError::api_error(provider, status, message),
220            _ => ProviderError::api_error(provider, status, message),
221        }
222    }
223
224    /// Map a known error status when its response body could not be read.
225    #[cfg(feature = "providers-extended")]
226    pub(crate) fn map_status_without_body(provider: &'static str, status: u16) -> ProviderError {
227        Self::map_status_code(
228            provider,
229            status,
230            &format!("Provider returned HTTP {status}, but its error body was unavailable"),
231        )
232    }
233
234    /// Parse JSON error response.
235    pub fn parse_json_error(provider: &'static str, json: &Value) -> ProviderError {
236        let message = extract_error_message_from_json(json).unwrap_or("Unknown error");
237
238        let error_type = json
239            .get("error")
240            .and_then(|e| e.get("type"))
241            .and_then(|t| t.as_str())
242            .or_else(|| json.get("type").and_then(|t| t.as_str()));
243        let error_code = json
244            .get("error")
245            .and_then(|e| e.get("code"))
246            .and_then(|c| c.as_str())
247            .or_else(|| json.get("code").and_then(|c| c.as_str()));
248
249        match (error_type, error_code) {
250            (Some("authentication_error" | "permission_error"), _)
251            | (_, Some("invalid_api_key" | "authentication_failed")) => {
252                ProviderError::authentication(provider, message.to_string())
253            }
254            (Some("rate_limit_error"), _) | (_, Some("rate_limit_exceeded")) => {
255                ProviderError::rate_limit(
256                    provider,
257                    json.get("retry_after")
258                        .and_then(|v| v.as_u64())
259                        .or_else(|| {
260                            json.get("error")
261                                .and_then(|e| e.get("retry_after"))
262                                .and_then(|v| v.as_u64())
263                        }),
264                )
265            }
266            (Some("insufficient_quota"), _)
267            | (_, Some("insufficient_quota" | "quota_exceeded")) => {
268                ProviderError::quota_exceeded(provider, message.to_string())
269            }
270            (_, Some("model_not_found")) => ProviderError::model_not_found(provider, message),
271            (Some("invalid_request_error" | "validation_error"), _) => {
272                ProviderError::invalid_request(provider, message.to_string())
273            }
274            (Some("context_length_exceeded"), _) => {
275                ProviderError::context_length_exceeded(provider, 0, 0)
276            }
277            (Some("content_filter" | "content_filter_error"), _) => {
278                ProviderError::content_filtered(provider, message.to_string(), None, Some(false))
279            }
280            (Some("overloaded_error"), _) => {
281                ProviderError::provider_unavailable(provider, message.to_string())
282            }
283            (Some("api_error" | "server_error"), _) => {
284                ProviderError::api_error(provider, 500, message.to_string())
285            }
286            _ => ProviderError::api_error(provider, 500, message.to_string()),
287        }
288    }
289}
290
291fn extract_error_message(body: &str) -> Option<String> {
292    serde_json::from_str::<Value>(body)
293        .ok()
294        .and_then(|json| extract_error_message_from_json(&json).map(str::to_string))
295}
296
297fn extract_error_message_from_json(json: &Value) -> Option<&str> {
298    json.get("error")
299        .and_then(|e| e.get("message"))
300        .and_then(|m| m.as_str())
301        .or_else(|| json.get("message").and_then(|m| m.as_str()))
302        .or_else(|| json.get("detail").and_then(|m| m.as_str()))
303        .or_else(|| json.get("error").and_then(|e| e.as_str()))
304}
305
306fn is_context_length_error(body: &str) -> bool {
307    let lower = body.to_ascii_lowercase();
308    lower.contains("context_length")
309        || lower.contains("context length")
310        || lower.contains("maximum context")
311        || lower.contains("too many tokens")
312}
313
314fn is_content_filter_error(body: &str) -> bool {
315    let lower = body.to_ascii_lowercase();
316    lower.contains("content_filter")
317        || lower.contains("content filter")
318        || lower.contains("safety")
319        || lower.contains("policy")
320}
321
322/// Common URL builder.
323pub struct UrlBuilder {
324    base: String,
325    path: String,
326    query_params: HashMap<String, String>,
327}
328
329impl UrlBuilder {
330    pub fn new(base: &str) -> Self {
331        Self {
332            base: base.trim_end_matches('/').to_string(),
333            path: String::new(),
334            query_params: HashMap::new(),
335        }
336    }
337
338    pub fn with_path(mut self, path: &str) -> Self {
339        self.path = path.trim_start_matches('/').to_string();
340        self
341    }
342
343    pub fn with_query(mut self, key: &str, value: &str) -> Self {
344        self.query_params.insert(key.to_string(), value.to_string());
345        self
346    }
347
348    pub fn with_optional_query(mut self, key: &str, value: Option<&str>) -> Self {
349        if let Some(v) = value {
350            self.query_params.insert(key.to_string(), v.to_string());
351        }
352        self
353    }
354
355    pub fn build(self) -> String {
356        let mut url = format!("{}/{}", self.base, self.path);
357
358        if !self.query_params.is_empty() {
359            let query_string: Vec<String> = self
360                .query_params
361                .iter()
362                .map(|(k, v)| format!("{}={}", k, v.replace(" ", "%20")))
363                .collect();
364
365            url.push('?');
366            url.push_str(&query_string.join("&"));
367        }
368
369        url
370    }
371}
372
373/// Common request transformer for OpenAI-compatible APIs.
374pub struct OpenAIRequestTransformer;
375
376impl OpenAIRequestTransformer {
377    pub fn transform_chat_request(request: &crate::core::types::chat::ChatRequest) -> Value {
378        let mut body = serde_json::json!({
379            "model": request.model,
380            "messages": request.messages,
381        });
382
383        if let Some(temperature) = request.temperature {
384            body["temperature"] = serde_json::json!(temperature);
385        }
386        if let Some(max_tokens) = request.max_tokens {
387            body["max_tokens"] = serde_json::json!(max_tokens);
388        }
389        if let Some(top_p) = request.top_p {
390            body["top_p"] = serde_json::json!(top_p);
391        }
392        if let Some(frequency_penalty) = request.frequency_penalty {
393            body["frequency_penalty"] = serde_json::json!(frequency_penalty);
394        }
395        if let Some(presence_penalty) = request.presence_penalty {
396            body["presence_penalty"] = serde_json::json!(presence_penalty);
397        }
398        if let Some(stop) = &request.stop {
399            body["stop"] = serde_json::json!(stop);
400        }
401
402        body["stream"] = serde_json::json!(request.stream);
403
404        if let Some(user) = &request.user {
405            body["user"] = serde_json::json!(user);
406        }
407        if let Some(tools) = &request.tools {
408            body["tools"] = serde_json::json!(tools);
409        }
410        if let Some(tool_choice) = &request.tool_choice {
411            body["tool_choice"] = serde_json::json!(tool_choice);
412        }
413        if let Some(response_format) = &request.response_format {
414            body["response_format"] = serde_json::json!(response_format);
415        }
416        if let Some(seed) = request.seed {
417            body["seed"] = serde_json::json!(seed);
418        }
419
420        body
421    }
422}
423
424/// Common chat request validation helper.
425pub fn validate_chat_request_common(
426    provider: &'static str,
427    request: &crate::core::types::chat::ChatRequest,
428    max_output_tokens: u32,
429) -> Result<(), ProviderError> {
430    if request.messages.is_empty() {
431        return Err(ProviderError::invalid_request(
432            provider,
433            "Messages cannot be empty",
434        ));
435    }
436
437    if let Some(max_tokens) = request.max_tokens
438        && max_tokens > max_output_tokens
439    {
440        return Err(ProviderError::invalid_request(
441            provider,
442            format!(
443                "max_tokens {} exceeds model limit of {}",
444                max_tokens, max_output_tokens
445            ),
446        ));
447    }
448
449    Ok(())
450}
451
452#[cfg(test)]
453#[path = "http/network_policy_tests.rs"]
454mod network_policy_tests;
455
456#[cfg(test)]
457#[path = "http/source_boundary_tests.rs"]
458mod source_boundary_tests;
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use serde_json::json;
464
465    #[rustfmt::skip]
466    #[test]
467    fn endpoint_policy_preserving_opt_ins_are_native_protocols() {
468        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let (mut stack, mut callers) = (vec![root.clone()], Vec::new());
469        while let Some(directory) = stack.pop() { for entry in std::fs::read_dir(directory).expect("provider sources must be readable") {
470                let path = entry.expect("provider entry must be readable").path(); if path.is_dir() { stack.push(path); continue; }
471                let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
472                if path.extension().and_then(|ext| ext.to_str()) != Some("rs") || name == "tests.rs"
473                    || name.ends_with("_tests.rs") || path == root.join("core/providers/base/connection_pool.rs") || path == root.join("core/providers/base/http.rs") { continue; }
474                let source = std::fs::read_to_string(&path).expect("source must be readable");
475                for method in ["execute_request_preserving_endpoint_policy", "execute_streaming_request_preserving_endpoint_policy", "execute_streaming_get_preserving_endpoint_policy"] {
476                    if source.contains(method) { callers.push((path.strip_prefix(&root).unwrap_or(&path).to_path_buf(), method)); }
477                }
478            }
479        }
480        callers.sort();
481        let mut expected = Vec::new();
482        for path in ["core/providers/openai_like/provider.rs", "core/providers/responses_native.rs"] {
483            for method in ["execute_request_preserving_endpoint_policy", "execute_streaming_request_preserving_endpoint_policy"] { expected.push((std::path::PathBuf::from(path), method)); }
484        }
485        expected.push((std::path::PathBuf::from("core/providers/responses_native.rs"), "execute_streaming_get_preserving_endpoint_policy"));
486        expected.push((std::path::PathBuf::from("core/providers/openai/client.rs"), "execute_request_preserving_endpoint_policy"));
487        expected.sort(); assert_eq!(callers, expected);
488    }
489
490    #[test]
491    fn base_http_client_rejects_public_loopback_base() {
492        let error = BaseHttpClient::new_for_provider(
493            "test",
494            BaseConfig {
495                api_base: Some("http://127.0.0.1:11434/v1".to_string()),
496                ..Default::default()
497            },
498        )
499        .unwrap_err();
500
501        assert!(matches!(error, ProviderError::Configuration { .. }));
502        assert!(error.to_string().contains("SSRF protection"));
503    }
504
505    #[test]
506    fn base_http_client_accepts_private_base_and_pins_authority() {
507        let client = BaseHttpClient::new_for_provider(
508            "test",
509            BaseConfig {
510                api_base: Some("http://127.0.0.1:11434/v1".to_string()),
511                endpoint_access: ProviderEndpointAccess::PrivateNetwork,
512                ..Default::default()
513            },
514        )
515        .unwrap_or_else(|error| panic!("private test client should build: {error}"));
516
517        let error = client
518            .get("http://127.0.0.1:11435/v1/models")
519            .err()
520            .unwrap_or_else(|| panic!("cross-authority request must fail"));
521        assert!(matches!(error, ProviderError::Network { .. }));
522        assert!(error.to_string().contains("does not match"));
523    }
524
525    #[test]
526    fn typed_request_preserves_direct_endpoint_policy_error() {
527        let client = BaseHttpClient::new_for_provider(
528            "test",
529            BaseConfig {
530                api_base: Some("https://api.example.com/v1".to_string()),
531                ..Default::default()
532            },
533        )
534        .expect("public test client should build");
535        let error = match client
536            .request_preserving_endpoint_policy(Method::GET, "ftp://api.example.com/v1")
537        {
538            Err(error) => error,
539            Ok(_) => panic!("unsupported schemes must be rejected"),
540        };
541
542        assert!(matches!(error, ProviderError::Configuration { .. }));
543    }
544
545    #[test]
546    fn base_http_client_requires_base_for_private_access() {
547        let error = BaseHttpClient::new_for_provider(
548            "test",
549            BaseConfig {
550                endpoint_access: ProviderEndpointAccess::PrivateNetwork,
551                ..Default::default()
552            },
553        )
554        .unwrap_err();
555
556        assert!(matches!(error, ProviderError::Configuration { .. }));
557        assert!(error.to_string().contains("requires an API base"));
558    }
559
560    #[test]
561    fn http_mapper_extracts_json_message_for_invalid_request() {
562        let err = HttpErrorMapper::map_status_code(
563            "openai_like",
564            400,
565            r#"{"error":{"message":"bad prompt","type":"invalid_request_error"}}"#,
566        );
567
568        match err {
569            ProviderError::InvalidRequest { provider, message } => {
570                assert_eq!(provider, "openai_like");
571                assert_eq!(message, "bad prompt");
572            }
573            other => panic!("expected invalid request, got {other:?}"),
574        }
575    }
576
577    #[test]
578    fn http_mapper_preserves_retry_after_for_rate_limits() {
579        let err = HttpErrorMapper::map_status_code(
580            "openai_like",
581            429,
582            r#"{"error":{"message":"slow down","retry_after":42}}"#,
583        );
584
585        match err {
586            ProviderError::RateLimit {
587                provider,
588                retry_after,
589                ..
590            } => {
591                assert_eq!(provider, "openai_like");
592                assert_eq!(retry_after, Some(42));
593            }
594            other => panic!("expected rate limit, got {other:?}"),
595        }
596    }
597
598    #[test]
599    fn http_mapper_maps_special_statuses_to_specific_variants() {
600        assert!(matches!(
601            HttpErrorMapper::map_status_code("p", 408, "timeout"),
602            ProviderError::Timeout { .. }
603        ));
604        assert!(matches!(
605            HttpErrorMapper::map_status_code("p", 413, "too many tokens"),
606            ProviderError::ContextLengthExceeded { .. }
607        ));
608        assert!(matches!(
609            HttpErrorMapper::map_status_code("p", 503, "overloaded"),
610            ProviderError::ProviderUnavailable { .. }
611        ));
612    }
613
614    #[test]
615    fn json_error_mapper_uses_type_and_code() {
616        let rate = HttpErrorMapper::parse_json_error(
617            "openai_like",
618            &json!({
619                "error": {
620                    "type": "rate_limit_error",
621                    "message": "slow down",
622                    "retry_after": 17
623                }
624            }),
625        );
626        assert!(matches!(
627            rate,
628            ProviderError::RateLimit {
629                retry_after: Some(17),
630                ..
631            }
632        ));
633
634        let not_found = HttpErrorMapper::parse_json_error(
635            "openai_like",
636            &json!({
637                "error": {
638                    "type": "invalid_request_error",
639                    "code": "model_not_found",
640                    "message": "missing model"
641                }
642            }),
643        );
644        assert!(matches!(not_found, ProviderError::ModelNotFound { .. }));
645    }
646}