1use anyhow::{Context, Result};
4use async_trait::async_trait;
5use futures::StreamExt;
6use std::env;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio_util::sync::CancellationToken;
11
12pub struct HttpResponse {
14 pub status: u16,
15 pub body: String,
16}
17
18pub struct StreamingHttpResponse {
20 pub status: u16,
21 pub retry_after: Option<String>,
23 pub byte_stream: Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes>> + Send>>,
25 pub error_body: String,
27}
28
29#[derive(Debug, Clone)]
31pub struct HttpMetricsRecord {
32 pub url: String,
34 pub method: String,
36 pub status: u16,
38 pub duration_ms: f64,
40 pub request_bytes: u64,
42 pub response_bytes: u64,
44 pub streaming: bool,
46}
47
48pub type HttpMetricsCallback = Arc<dyn Fn(HttpMetricsRecord) + Send + Sync>;
51
52static HTTP_METRICS_CALLBACK: std::sync::RwLock<Option<HttpMetricsCallback>> =
57 std::sync::RwLock::new(None);
58
59pub fn set_http_metrics_callback(callback: HttpMetricsCallback) {
62 *HTTP_METRICS_CALLBACK.write().unwrap() = Some(callback);
63}
64
65pub fn clear_http_metrics_callback() {
67 *HTTP_METRICS_CALLBACK.write().unwrap() = None;
68}
69
70fn maybe_record_metrics(record: HttpMetricsRecord) {
71 if let Some(callback) = HTTP_METRICS_CALLBACK.read().unwrap().as_ref() {
72 callback(record);
73 }
74}
75
76#[async_trait]
80pub trait HttpClient: Send + Sync {
81 async fn post(
83 &self,
84 url: &str,
85 headers: Vec<(&str, &str)>,
86 body: &serde_json::Value,
87 cancel_token: CancellationToken,
88 ) -> Result<HttpResponse>;
89
90 async fn post_streaming(
93 &self,
94 url: &str,
95 headers: Vec<(&str, &str)>,
96 body: &serde_json::Value,
97 cancel_token: CancellationToken,
98 ) -> Result<StreamingHttpResponse>;
99}
100
101pub struct ReqwestHttpClient {
103 client: reqwest::Client,
104}
105
106impl ReqwestHttpClient {
107 pub fn new() -> Self {
108 Self {
109 client: build_reqwest_client(None, None).expect("failed to build default HTTP client"),
110 }
111 }
112
113 pub fn with_timeout(timeout: Duration) -> Result<Self> {
114 Ok(Self {
115 client: build_reqwest_client(Some(timeout), None)?,
116 })
117 }
118}
119
120impl Default for ReqwestHttpClient {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126#[async_trait]
127impl HttpClient for ReqwestHttpClient {
128 async fn post(
129 &self,
130 url: &str,
131 headers: Vec<(&str, &str)>,
132 body: &serde_json::Value,
133 cancel_token: CancellationToken,
134 ) -> Result<HttpResponse> {
135 let start = std::time::Instant::now();
136 let request_body = serde_json::to_string(body).unwrap_or_default();
137 let request_bytes = request_body.len() as u64;
138
139 tracing::debug!(
140 "HTTP POST to {}: {}",
141 url,
142 serde_json::to_string_pretty(body)?
143 );
144
145 let mut request = self.client.post(url);
146 for (key, value) in headers {
147 request = request.header(key, value);
148 }
149 request = request.json(body);
150
151 let response = tokio::select! {
152 _ = cancel_token.cancelled() => {
153 anyhow::bail!("HTTP request cancelled");
154 }
155 result = request.send() => {
156 result.context(format!("Failed to send request to {}", url))?
157 }
158 };
159
160 let status = response.status().as_u16();
161 let response_body = response.text().await?;
162 let response_bytes = response_body.len() as u64;
163 let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
164
165 maybe_record_metrics(HttpMetricsRecord {
166 url: url.to_string(),
167 method: "POST".to_string(),
168 status,
169 duration_ms,
170 request_bytes,
171 response_bytes,
172 streaming: false,
173 });
174
175 Ok(HttpResponse {
176 status,
177 body: response_body,
178 })
179 }
180
181 async fn post_streaming(
182 &self,
183 url: &str,
184 headers: Vec<(&str, &str)>,
185 body: &serde_json::Value,
186 cancel_token: CancellationToken,
187 ) -> Result<StreamingHttpResponse> {
188 let start = std::time::Instant::now();
189 let request_body = serde_json::to_string(body).unwrap_or_default();
190 let request_bytes = request_body.len() as u64;
191
192 let mut request = self.client.post(url);
193 for (key, value) in headers {
194 request = request.header(key, value);
195 }
196 request = request.json(body);
197
198 let response = tokio::select! {
199 _ = cancel_token.cancelled() => {
200 anyhow::bail!("HTTP streaming request cancelled");
201 }
202 result = request.send() => {
203 result.context(format!("Failed to send streaming request to {}", url))?
204 }
205 };
206
207 let status = response.status().as_u16();
208 let retry_after = response
209 .headers()
210 .get("retry-after")
211 .and_then(|v| v.to_str().ok())
212 .map(String::from);
213
214 let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
217 maybe_record_metrics(HttpMetricsRecord {
218 url: url.to_string(),
219 method: "POST".to_string(),
220 status,
221 duration_ms,
222 request_bytes,
223 response_bytes: 0, streaming: true,
225 });
226
227 if (200..300).contains(&status) {
228 let byte_stream = response
229 .bytes_stream()
230 .map(|r| r.map_err(|e| anyhow::anyhow!("Stream error: {}", e)));
231 Ok(StreamingHttpResponse {
232 status,
233 retry_after,
234 byte_stream: Box::pin(byte_stream),
235 error_body: String::new(),
236 })
237 } else {
238 let error_body = response.text().await.unwrap_or_default();
239 let empty: futures::stream::Empty<Result<bytes::Bytes>> = futures::stream::empty();
241 Ok(StreamingHttpResponse {
242 status,
243 retry_after,
244 byte_stream: Box::pin(empty),
245 error_body,
246 })
247 }
248 }
249}
250
251pub fn default_http_client() -> Arc<dyn HttpClient> {
253 Arc::new(ReqwestHttpClient::new())
254}
255
256#[derive(Debug, Clone, Default, PartialEq, Eq)]
257struct ExplicitProxyConfig {
258 http: Option<String>,
259 https: Option<String>,
260}
261
262pub(crate) fn build_reqwest_client(
269 timeout: Option<Duration>,
270 default_headers: Option<reqwest::header::HeaderMap>,
271) -> Result<reqwest::Client> {
272 let mut builder = reqwest::Client::builder().no_proxy();
273
274 if let Some(timeout) = timeout {
275 builder = builder.timeout(timeout);
276 }
277
278 if let Some(default_headers) = default_headers {
279 builder = builder.default_headers(default_headers);
280 }
281
282 let proxy_config = explicit_proxy_config_from_env();
283 if let Some(http_proxy) = proxy_config.http.as_deref() {
284 builder = builder.proxy(
285 reqwest::Proxy::http(http_proxy)
286 .with_context(|| format!("Invalid HTTP proxy URL: {http_proxy}"))?,
287 );
288 }
289 if let Some(https_proxy) = proxy_config.https.as_deref() {
290 builder = builder.proxy(
291 reqwest::Proxy::https(https_proxy)
292 .with_context(|| format!("Invalid HTTPS proxy URL: {https_proxy}"))?,
293 );
294 }
295
296 builder.build().context("Failed to build reqwest client")
297}
298
299fn explicit_proxy_config_from_env() -> ExplicitProxyConfig {
300 let http = first_non_empty_env(&["http_proxy", "HTTP_PROXY"]);
301 let https = first_non_empty_env(&["https_proxy", "HTTPS_PROXY"]).or_else(|| http.clone());
302
303 ExplicitProxyConfig { http, https }
304}
305
306fn first_non_empty_env(keys: &[&str]) -> Option<String> {
307 keys.iter().find_map(|key| {
308 env::var(key)
309 .ok()
310 .map(|value| value.trim().to_string())
311 .filter(|value| !value.is_empty())
312 })
313}
314
315pub(crate) fn normalize_base_url(base_url: &str) -> String {
317 base_url
318 .trim_end_matches('/')
319 .trim_end_matches("/v1")
320 .trim_end_matches('/')
321 .to_string()
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use std::sync::{Mutex, OnceLock};
328 use tokio::io::{AsyncReadExt, AsyncWriteExt};
329
330 fn proxy_env_lock() -> &'static Mutex<()> {
331 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
332 LOCK.get_or_init(|| Mutex::new(()))
333 }
334
335 fn clear_proxy_env() {
336 for key in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"] {
337 unsafe { env::remove_var(key) };
338 }
339 }
340
341 #[test]
342 fn test_normalize_base_url() {
343 assert_eq!(
344 normalize_base_url("https://api.example.com"),
345 "https://api.example.com"
346 );
347 assert_eq!(
348 normalize_base_url("https://api.example.com/"),
349 "https://api.example.com"
350 );
351 assert_eq!(
352 normalize_base_url("https://api.example.com/v1"),
353 "https://api.example.com"
354 );
355 assert_eq!(
356 normalize_base_url("https://api.example.com/v1/"),
357 "https://api.example.com"
358 );
359 }
360
361 #[test]
362 fn test_normalize_base_url_edge_cases() {
363 assert_eq!(
364 normalize_base_url("http://localhost:8080/v1"),
365 "http://localhost:8080"
366 );
367 assert_eq!(
368 normalize_base_url("http://localhost:8080"),
369 "http://localhost:8080"
370 );
371 assert_eq!(
372 normalize_base_url("https://api.example.com/v1/"),
373 "https://api.example.com"
374 );
375 }
376
377 #[test]
378 fn test_normalize_base_url_multiple_trailing_slashes() {
379 assert_eq!(
380 normalize_base_url("https://api.example.com//"),
381 "https://api.example.com"
382 );
383 }
384
385 #[test]
386 fn test_normalize_base_url_with_port() {
387 assert_eq!(
388 normalize_base_url("http://localhost:11434/v1/"),
389 "http://localhost:11434"
390 );
391 }
392
393 #[test]
394 fn test_normalize_base_url_already_normalized() {
395 assert_eq!(
396 normalize_base_url("https://api.openai.com"),
397 "https://api.openai.com"
398 );
399 }
400
401 #[test]
402 fn test_normalize_base_url_empty_string() {
403 assert_eq!(normalize_base_url(""), "");
404 }
405
406 #[test]
407 fn test_default_http_client_creation() {
408 let _client = default_http_client();
409 }
410
411 #[tokio::test]
412 async fn test_reqwest_http_client_timeout_applies_to_api_call() {
413 let mut last_refused = None;
414 for _ in 0..3 {
415 let (elapsed, err) = post_to_slow_local_server().await;
416 assert!(
417 elapsed < Duration::from_secs(1),
418 "API timeout should fail quickly, elapsed={elapsed:?}"
419 );
420
421 let msg = format!("{err:?}").to_ascii_lowercase();
422 if msg.contains("connection refused") {
423 last_refused = Some(err);
424 continue;
425 }
426
427 assert!(
428 msg.contains("timed out") || msg.contains("timeout"),
429 "expected timeout error, got: {err:?}"
430 );
431 return;
432 }
433
434 panic!(
435 "local timeout server was not reachable after retries; last error: {:?}",
436 last_refused.expect("at least one connection-refused error")
437 );
438 }
439
440 async fn post_to_slow_local_server() -> (Duration, anyhow::Error) {
441 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
442 let addr = listener.local_addr().unwrap();
443
444 let server = tokio::spawn(async move {
445 let (mut stream, _) = listener.accept().await.unwrap();
446 let mut buf = [0_u8; 1024];
447 let _ = stream.read(&mut buf).await;
448 tokio::time::sleep(Duration::from_millis(250)).await;
449 let _ = stream
450 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
451 .await;
452 });
453
454 let client = ReqwestHttpClient::with_timeout(Duration::from_millis(50)).unwrap();
455 let started = std::time::Instant::now();
456 let err = match client
457 .post(
458 &format!("http://{addr}/v1/chat/completions"),
459 Vec::new(),
460 &serde_json::json!({"model": "test"}),
461 CancellationToken::new(),
462 )
463 .await
464 {
465 Ok(_) => panic!("expected API timeout error"),
466 Err(err) => err,
467 };
468
469 server.abort();
470 (started.elapsed(), err)
471 }
472
473 #[test]
474 fn test_explicit_proxy_config_from_env_prefers_lowercase_vars() {
475 let _guard = proxy_env_lock().lock().unwrap();
476 clear_proxy_env();
477 unsafe {
478 env::set_var("http_proxy", "http://lower-http:3128");
479 env::set_var("HTTP_PROXY", "http://upper-http:3128");
480 env::set_var("https_proxy", "http://lower-https:3128");
481 env::set_var("HTTPS_PROXY", "http://upper-https:3128");
482 }
483
484 let proxy_config = explicit_proxy_config_from_env();
485
486 assert_eq!(
487 proxy_config,
488 ExplicitProxyConfig {
489 http: Some("http://lower-http:3128".to_string()),
490 https: Some("http://lower-https:3128".to_string()),
491 }
492 );
493 clear_proxy_env();
494 }
495
496 #[test]
497 fn test_explicit_proxy_config_from_env_falls_back_to_http_for_https() {
498 let _guard = proxy_env_lock().lock().unwrap();
499 clear_proxy_env();
500 unsafe {
501 env::set_var("HTTP_PROXY", "http://proxy.example:3128");
502 }
503
504 let proxy_config = explicit_proxy_config_from_env();
505
506 assert_eq!(
507 proxy_config,
508 ExplicitProxyConfig {
509 http: Some("http://proxy.example:3128".to_string()),
510 https: Some("http://proxy.example:3128".to_string()),
511 }
512 );
513 clear_proxy_env();
514 }
515
516 #[test]
517 fn test_build_reqwest_client_accepts_proxy_env_urls() {
518 let _guard = proxy_env_lock().lock().unwrap();
519 clear_proxy_env();
520 unsafe {
521 env::set_var("http_proxy", "http://127.0.0.1:3128");
522 env::set_var("https_proxy", "http://127.0.0.1:3128");
523 }
524
525 let client = build_reqwest_client(None, None);
526 assert!(client.is_ok());
527 clear_proxy_env();
528 }
529}