1use 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#[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 pub fn new(config: BaseConfig) -> Result<Self, ProviderError> {
36 Self::new_for_provider("provider", config)
37 }
38
39 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 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 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 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 pub fn get<U: IntoUrl>(&self, url: U) -> Result<ProviderRequestBuilder, ProviderError> {
162 self.request(Method::GET, url)
163 }
164
165 pub fn post<U: IntoUrl>(&self, url: U) -> Result<ProviderRequestBuilder, ProviderError> {
167 self.request(Method::POST, url)
168 }
169
170 pub fn config(&self) -> &BaseConfig {
172 &self.config
173 }
174}
175
176#[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
188pub struct HttpErrorMapper;
190
191impl HttpErrorMapper {
192 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 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 #[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 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
322pub 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
373pub 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
424pub 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}