1use async_trait::async_trait;
7use bytes::Bytes;
8use futures_core::Stream;
9use futures_util::StreamExt;
10use std::pin::Pin;
11
12use super::error::LlmError;
13use super::raw_adapter::RawRequest;
14
15#[async_trait]
21pub trait HttpClient: Send + Sync {
22 async fn send(&self, request: &RawRequest) -> Result<HttpResponse, LlmError>;
24}
25
26pub type ByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>>;
28
29pub struct HttpResponse {
34 status: u16,
35 body_stream: Option<ByteStream>,
36 body_text: Option<String>,
37}
38
39impl HttpResponse {
40 pub fn new(status: u16, body_stream: ByteStream) -> Self {
42 Self {
43 status,
44 body_stream: Some(body_stream),
45 body_text: None,
46 }
47 }
48
49 pub fn from_text(status: u16, body_text: String) -> Self {
51 Self {
52 status,
53 body_stream: None,
54 body_text: Some(body_text),
55 }
56 }
57
58 pub fn status(&self) -> u16 {
60 self.status
61 }
62
63 pub fn is_success(&self) -> bool {
65 self.status >= 200 && self.status < 300
66 }
67
68 pub async fn text(self) -> String {
70 if let Some(text) = self.body_text {
71 return text;
72 }
73
74 if let Some(mut stream) = self.body_stream {
76 use futures_util::StreamExt;
77 let mut body = String::new();
78 while let Some(chunk) = stream.next().await {
79 match chunk {
80 Ok(bytes) => body.push_str(&String::from_utf8_lossy(&bytes)),
81 Err(_) => break,
82 }
83 }
84 body
85 } else {
86 String::new()
87 }
88 }
89
90 pub fn bytes_stream(self) -> ByteStream {
92 self.body_stream
93 .unwrap_or_else(|| Box::pin(futures_util::stream::empty()))
94 }
95}
96
97pub struct ReqwestHttpClient {
99 client: reqwest::Client,
100}
101
102impl ReqwestHttpClient {
103 pub fn new(client: reqwest::Client) -> Self {
104 Self { client }
105 }
106}
107
108#[async_trait]
109impl HttpClient for ReqwestHttpClient {
110 async fn send(&self, request: &RawRequest) -> Result<HttpResponse, LlmError> {
111 use super::raw_adapter::HttpMethod;
112
113 let mut builder = match request.method {
114 HttpMethod::Post => self.client.post(&request.url),
115 HttpMethod::Get => self.client.get(&request.url),
116 HttpMethod::Put => self.client.put(&request.url),
117 HttpMethod::Delete => self.client.delete(&request.url),
118 };
119
120 for (key, value) in &request.headers {
121 builder = builder.header(key.as_str(), value.as_str());
122 }
123
124 builder = builder
125 .header("Content-Type", "application/json")
126 .json(&request.body);
127
128 let response = builder.send().await.map_err(|e| {
129 tracing::error!(error = %e, url = %request.url, "HTTP request failed");
130 LlmError::llm(format!("HTTP request failed: {e}"))
131 })?;
132
133 let status = response.status().as_u16();
134
135 let reqwest_stream = response.bytes_stream();
137 let byte_stream = Box::pin(
138 reqwest_stream
139 .map(|r| r.map_err(|e| LlmError::stream(format!("Stream read error: {e}")))),
140 );
141
142 Ok(HttpResponse::new(status, byte_stream))
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use bytes::Bytes;
150 use futures_util::StreamExt;
151
152 fn stream_of(chunks: Vec<Result<Bytes, LlmError>>) -> ByteStream {
153 Box::pin(futures_util::stream::iter(chunks))
154 }
155
156 #[test]
157 fn is_success_covers_only_2xx() {
158 for status in [200u16, 201, 204, 299] {
159 let response = HttpResponse::from_text(status, String::new());
160 assert_eq!(response.status(), status);
161 assert!(response.is_success(), "{status} should be a success");
162 }
163 for status in [100u16, 199, 300, 400, 401, 429, 500, 503] {
164 let response = HttpResponse::from_text(status, String::new());
165 assert!(!response.is_success(), "{status} should not be a success");
166 }
167 }
168
169 #[tokio::test]
170 async fn from_text_body_is_returned_verbatim() {
171 let body = "{\"error\":\"bad request\"}";
172 let response = HttpResponse::from_text(400, body.to_string());
173 assert_eq!(response.text().await, body);
174 }
175
176 #[tokio::test]
177 async fn text_concatenates_stream_chunks() {
178 let response = HttpResponse::new(
179 200,
180 stream_of(vec![
181 Ok(Bytes::from_static(b"data: ")),
182 Ok(Bytes::from_static(b"[DONE]")),
183 ]),
184 );
185 assert_eq!(response.text().await, "data: [DONE]");
186 }
187
188 #[tokio::test]
189 async fn text_of_an_empty_body_is_an_empty_string() {
190 assert_eq!(HttpResponse::from_text(204, String::new()).text().await, "");
191 }
192
193 #[tokio::test]
194 async fn bytes_stream_of_a_text_response_is_empty() {
195 assert_eq!(
198 HttpResponse::from_text(204, String::new())
199 .bytes_stream()
200 .count()
201 .await,
202 0
203 );
204 }
205
206 #[tokio::test]
207 async fn stream_read_error_yields_partial_body_without_panicking() {
208 let response = HttpResponse::new(
211 200,
212 stream_of(vec![
213 Ok(Bytes::from_static(b"head")),
214 Err(LlmError::stream("connection reset")),
215 Ok(Bytes::from_static(b"never-read")),
216 ]),
217 );
218 assert_eq!(response.text().await, "head");
219 }
220
221 #[tokio::test]
222 async fn invalid_utf8_is_decoded_lossily() {
223 let response = HttpResponse::new(
225 200,
226 stream_of(vec![Ok(Bytes::from_static(&[0xf0, 0x28, 0x8c]))]),
227 );
228 let text = response.text().await;
229 assert!(
230 text.contains('\u{fffd}'),
231 "expected replacement char: {text:?}"
232 );
233 assert!(text.contains('('), "valid byte should survive: {text:?}");
234 }
235
236 #[tokio::test]
237 async fn bytes_stream_preserves_chunk_boundaries() {
238 let response = HttpResponse::new(
241 200,
242 stream_of(vec![
243 Ok(Bytes::from_static(b"a")),
244 Ok(Bytes::from_static(b"b")),
245 Ok(Bytes::from_static(b"c")),
246 ]),
247 );
248 let sizes: Vec<usize> = response
249 .bytes_stream()
250 .map(|c| c.unwrap().len())
251 .collect()
252 .await;
253 assert_eq!(sizes, vec![1, 1, 1]);
254 }
255}