Skip to main content

a3s_code_core/llm/
http.rs

1//! HTTP utilities and abstraction for LLM API calls
2
3use 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
12/// HTTP response from a non-streaming POST request
13pub struct HttpResponse {
14    pub status: u16,
15    pub body: String,
16}
17
18/// HTTP response from a streaming POST request
19pub struct StreamingHttpResponse {
20    pub status: u16,
21    /// Retry-After header value (if present)
22    pub retry_after: Option<String>,
23    /// Byte stream (valid when status is 2xx)
24    pub byte_stream: Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes>> + Send>>,
25    /// Error body (populated when status is not 2xx)
26    pub error_body: String,
27}
28
29/// Information about an HTTP request for metrics collection.
30#[derive(Debug, Clone)]
31pub struct HttpMetricsRecord {
32    /// The target URL
33    pub url: String,
34    /// HTTP method (currently only POST is used for LLM calls)
35    pub method: String,
36    /// Response status code
37    pub status: u16,
38    /// Request duration in milliseconds
39    pub duration_ms: f64,
40    /// Number of bytes sent (request body size)
41    pub request_bytes: u64,
42    /// Number of bytes received (response body size)
43    pub response_bytes: u64,
44    /// Whether this was a streaming request
45    pub streaming: bool,
46}
47
48/// Callback function type for HTTP metrics collection.
49/// The callback is called after each HTTP request completes.
50pub type HttpMetricsCallback = Arc<dyn Fn(HttpMetricsRecord) + Send + Sync>;
51
52/// Global HTTP metrics callback registry.
53///
54/// Set this to enable HTTP metrics collection for LLM API calls.
55/// The callback will be invoked after each HTTP request completes.
56static HTTP_METRICS_CALLBACK: std::sync::RwLock<Option<HttpMetricsCallback>> =
57    std::sync::RwLock::new(None);
58
59/// Register a global HTTP metrics callback.
60/// The callback will be invoked after each HTTP request completes.
61pub fn set_http_metrics_callback(callback: HttpMetricsCallback) {
62    *HTTP_METRICS_CALLBACK.write().unwrap() = Some(callback);
63}
64
65/// Clear the global HTTP metrics callback.
66pub 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/// Abstraction over HTTP POST requests for LLM API calls.
77///
78/// Enables dependency injection for testing without hitting real HTTP endpoints.
79#[async_trait]
80pub trait HttpClient: Send + Sync {
81    /// Make a POST request and return status + body
82    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    /// Make a POST request and return a streaming response.
91    /// If cancel_token is cancelled during the request, the HTTP connection is aborted.
92    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
101/// Default HTTP client backed by reqwest
102pub 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        // For streaming, we record metrics after sending but before consuming the stream
215        // Note: response_bytes is estimated as we can't know the full stream size upfront
216        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, // Unknown for streaming
224            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            // Return an empty stream for error responses
240            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
251/// Create a default HTTP client
252pub 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
262/// Build a reqwest client without consulting system proxy settings.
263///
264/// On macOS test runners, the system proxy lookup path can panic inside the
265/// `system-configuration` crate when no dynamic store is available. Disabling
266/// implicit proxy discovery keeps client construction deterministic while still
267/// honoring standard proxy environment variables explicitly.
268pub(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
315/// Normalize base URL by stripping trailing /v1
316pub(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}