1pub extern crate futures_util;
2use anyhow::{anyhow, Result};
3use lazy_static::lazy_static;
4use std::time::Duration;
5
6lazy_static! {
7 static ref DEFAULT_BASE_URL: reqwest::Url =
8 reqwest::Url::parse("https://api.openai.com/v1/models").unwrap();
9
10 static ref SHARED_HTTP_CLIENT: reqwest::Client = {
20 reqwest::ClientBuilder::new()
21 .pool_idle_timeout(Some(Duration::from_secs(90)))
22 .pool_max_idle_per_host(100)
23 .tcp_keepalive(Some(Duration::from_secs(60)))
24 .timeout(Duration::from_secs(300))
25 .connect_timeout(Duration::from_secs(10))
26 .tcp_nodelay(true)
27 .build()
28 .expect("Failed to build shared HTTP client")
29 };
30}
31
32pub struct Client {
33 req_client: reqwest::Client,
34 key: String,
35 base_url: reqwest::Url,
36 max_retries: u32,
41}
42
43impl Default for Client {
44 fn default() -> Self {
45 Self {
46 req_client: SHARED_HTTP_CLIENT.clone(),
47 key: String::new(),
48 base_url: DEFAULT_BASE_URL.clone(),
49 max_retries: 3,
50 }
51 }
52}
53
54pub mod chat;
55pub mod completions;
56pub mod edits;
57pub mod embeddings;
58pub mod images;
59pub mod models;
60
61impl Client {
62 pub fn new(api_key: &str) -> Client {
65 Self {
66 req_client: SHARED_HTTP_CLIENT.clone(),
67 key: api_key.to_owned(),
68 base_url: DEFAULT_BASE_URL.clone(),
69 max_retries: 3,
70 }
71 }
72
73 pub fn new_with_client(api_key: &str, req_client: reqwest::Client) -> Client {
76 Self {
77 req_client,
78 key: api_key.to_owned(),
79 base_url: DEFAULT_BASE_URL.clone(),
80 max_retries: 3,
81 }
82 }
83
84 pub fn new_with_base_url(api_key: &str, base_url: &str) -> Client {
86 let base_url = reqwest::Url::parse(base_url).unwrap();
87 Self {
88 req_client: SHARED_HTTP_CLIENT.clone(),
89 key: api_key.to_owned(),
90 base_url,
91 max_retries: 3,
92 }
93 }
94
95 pub fn new_with_client_and_base_url(
97 api_key: &str,
98 req_client: reqwest::Client,
99 base_url: &str,
100 ) -> Client {
101 Self {
102 req_client,
103 key: api_key.to_owned(),
104 base_url: reqwest::Url::parse(base_url).unwrap(),
105 max_retries: 3,
106 }
107 }
108
109 pub fn shared_client() -> &'static reqwest::Client {
111 &SHARED_HTTP_CLIENT
112 }
113
114 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
116 self.max_retries = max_retries;
117 self
118 }
119
120 pub async fn read_and_parse_json<T: serde::de::DeserializeOwned>(
130 res: reqwest::Response,
131 error_context: &str,
132 ) -> Result<T, anyhow::Error> {
133 let status = res.status();
134 match res.text().await {
135 Ok(text) => serde_json::from_str(&text).map_err(|e| {
136 anyhow!(
137 "{} failed to parse JSON response (status {}): {}. Raw body ({} bytes): {}",
138 error_context,
139 status,
140 e,
141 text.len(),
142 truncate_for_error(&text, 4096)
143 )
144 }),
145 Err(e) => Err(anyhow!(
146 "{} failed to read response body (status {}): {}",
147 error_context,
148 status,
149 e
150 )),
151 }
152 }
153
154 async fn send_json<T: serde::de::DeserializeOwned>(
157 &self,
158 path: &str,
159 body: &impl serde::Serialize,
160 error_context: &str,
161 ) -> Result<T, anyhow::Error> {
162 let mut url = self.base_url.clone();
163 url.set_path(path);
164
165 let res = self
166 .req_client
167 .post(url)
168 .bearer_auth(&self.key)
169 .json(body)
170 .send()
171 .await?;
172
173 if res.status().is_success() {
174 Self::read_and_parse_json(res, error_context).await
175 } else {
176 let status = res.status();
177 let body = res.text().await.unwrap_or_default();
178 Err(anyhow!(
179 "{} API error (status {}): {}",
180 error_context,
181 status,
182 truncate_for_error(&body, 4096)
183 ))
184 }
185 }
186
187 async fn send_get<T: serde::de::DeserializeOwned>(
189 &self,
190 path: &str,
191 error_context: &str,
192 ) -> Result<T, anyhow::Error> {
193 let mut url = self.base_url.clone();
194 url.set_path(path);
195
196 let res = self
197 .req_client
198 .get(url)
199 .bearer_auth(&self.key)
200 .send()
201 .await?;
202
203 if res.status().is_success() {
204 Self::read_and_parse_json(res, error_context).await
205 } else {
206 let status = res.status();
207 let body = res.text().await.unwrap_or_default();
208 Err(anyhow!(
209 "{} API error (status {}): {}",
210 error_context,
211 status,
212 truncate_for_error(&body, 4096)
213 ))
214 }
215 }
216
217 pub async fn list_models(
218 &self,
219 opt_url_path: Option<String>,
220 ) -> Result<Vec<models::Model>, anyhow::Error> {
221 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/models"));
222 #[derive(serde::Deserialize)]
223 struct ListModelsResponse {
224 data: Vec<models::Model>,
225 }
226 let response: ListModelsResponse = self.send_get(&path, "list_models").await?;
227 Ok(response.data)
228 }
229
230 pub async fn create_chat(
231 &self,
232 args: chat::ChatArguments,
233 opt_url_path: Option<String>,
234 ) -> Result<chat::ChatCompletion, anyhow::Error> {
235 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/chat/completions"));
236 let mut attempt: u32 = 0;
243 loop {
244 let result = self.send_json::<chat::ChatCompletion>(&path, &args, "create_chat").await;
245 match result {
246 Ok(parsed) => return Ok(parsed),
247 Err(e) => {
248 let is_transient = is_transient_error(&e);
249 if !is_transient || attempt >= self.max_retries {
250 return Err(e);
251 }
252 let backoff = backoff_for_attempt(attempt);
253 tokio::time::sleep(Duration::from_secs(backoff)).await;
254 attempt += 1;
255 }
256 }
257 }
258 }
259
260 pub async fn create_chat_stream(
261 &self,
262 args: chat::ChatArguments,
263 opt_url_path: Option<String>,
264 ) -> Result<chat::stream::ChatCompletionChunkStream> {
265 let mut url = self.base_url.clone();
266 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/chat/completions")));
267
268 let mut args = args;
269 args.stream = Some(true);
270
271 let res = self
272 .req_client
273 .post(url)
274 .bearer_auth(&self.key)
275 .json(&args)
276 .send()
277 .await?;
278
279 if res.status() == 200 {
280 Ok(chat::stream::ChatCompletionChunkStream::new(Box::pin(
281 res.bytes_stream(),
282 )))
283 } else {
284 let status = res.status();
285 let body = res.text().await.unwrap_or_default();
286 Err(anyhow!(
287 "create_chat_stream failed: status={} body={}",
288 status,
289 truncate_for_error(&body, 4096)
290 ))
291 }
292 }
293
294 pub async fn create_completion(
295 &self,
296 args: completions::CompletionArguments,
297 opt_url_path: Option<String>,
298 ) -> Result<completions::CompletionResponse> {
299 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/completions"));
300 self.send_json(&path, &args, "create_completion").await
301 }
302
303 pub async fn create_embeddings(
304 &self,
305 args: embeddings::EmbeddingsArguments,
306 opt_url_path: Option<String>,
307 ) -> Result<embeddings::EmbeddingsResponse> {
308 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/embeddings"));
309 self.send_json(&path, &args, "create_embeddings").await
310 }
311
312 pub async fn create_image_old(
313 &self,
314 args: images::ImageArguments,
315 opt_url_path: Option<String>,
316 ) -> Result<Vec<String>> {
317 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/images/generations"));
318 let response: images::ImageResponse =
319 self.send_json(&path, &args, "create_image_old").await?;
320 Ok(response
321 .data
322 .iter()
323 .map(|o| match o {
324 images::ImageObject::Url(s) => s.to_string(),
325 images::ImageObject::Base64JSON(s) => s.to_string(),
326 })
327 .collect())
328 }
329
330 pub async fn create_image(
331 &self,
332 args: images::ImageArguments,
333 opt_url_path: Option<String>,
334 ) -> Result<Vec<String>> {
335 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/images/generations"));
336 let image_args = images::ImageArguments {
337 prompt: args.prompt,
338 model: Some("gpt-image-1".to_string()),
339 n: Some(1),
340 size: Some("1024x1024".to_string()),
341 quality: Some("auto".to_string()),
342 user: None,
343 };
344 let response: images::ImageResponse =
345 self.send_json(&path, &image_args, "create_image").await?;
346 Ok(response
347 .data
348 .iter()
349 .map(|o| match o {
350 images::ImageObject::Url(s) => s.to_string(),
351 images::ImageObject::Base64JSON(s) => s.to_string(),
352 })
353 .collect())
354 }
355
356 pub async fn create_responses(
386 &self,
387 args: chat::ResponsesArguments,
388 opt_url_path: Option<String>,
389 ) -> Result<chat::ResponsesCompletion, anyhow::Error> {
390 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/responses"));
391 self.send_json(&path, &args, "create_responses").await
392 }
393
394 pub async fn create_openai_responses(
426 &self,
427 args: chat::OpenAIResponsesArguments,
428 opt_url_path: Option<String>,
429 ) -> Result<chat::ResponsesCompletion, anyhow::Error> {
430 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/responses"));
431 self.send_json(&path, &args, "create_openai_responses")
432 .await
433 }
434}
435
436fn backoff_for_attempt(attempt: u32) -> u64 {
440 (2u64.saturating_pow(attempt)).min(30)
441}
442
443fn truncate_for_error(text: &str, max_bytes: usize) -> String {
446 if text.len() <= max_bytes {
447 text.to_string()
448 } else {
449 let mut cut = max_bytes;
450 while !text.is_char_boundary(cut) && cut > 0 {
451 cut -= 1;
452 }
453 format!(
454 "{}…[truncated, total {} bytes]",
455 &text[..cut],
456 text.len()
457 )
458 }
459}
460
461fn is_transient_error(err: &anyhow::Error) -> bool {
465 let msg = format!("{:#}", err);
466 if msg.contains("error decoding response body") {
472 return true;
473 }
474 if msg.contains("(status 429")
475 || msg.contains("(status 500")
476 || msg.contains("(status 502")
477 || msg.contains("(status 503")
478 || msg.contains("(status 504")
479 {
480 return true;
481 }
482 false
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use anyhow::anyhow;
492
493 #[test]
494 fn truncate_for_error_keeps_short_strings() {
495 let s = "hello";
496 assert_eq!(truncate_for_error(s, 10), "hello");
497 }
498
499 #[test]
500 fn truncate_for_error_marks_cut_point() {
501 let s = "a".repeat(100);
502 let out = truncate_for_error(&s, 10);
503 assert!(out.starts_with(&"a".repeat(10)));
504 assert!(out.contains("truncated"));
505 assert!(out.contains("100 bytes"));
506 }
507
508 #[test]
509 fn truncate_for_error_respects_char_boundaries() {
510 let s = "ááááá";
511 let out = truncate_for_error(&s, 3);
512 let _ = std::str::from_utf8(out.as_bytes()).unwrap();
514 }
515
516 #[test]
517 fn backoff_grows_then_caps() {
518 assert_eq!(backoff_for_attempt(0), 1);
519 assert_eq!(backoff_for_attempt(1), 2);
520 assert_eq!(backoff_for_attempt(2), 4);
521 assert_eq!(backoff_for_attempt(3), 8);
522 assert_eq!(backoff_for_attempt(10), 30);
523 }
524
525 #[test]
526 fn transient_classifier_recognises_known_signals() {
527 assert!(is_transient_error(&anyhow!(
528 "create_chat failed to read response body (status 200): error decoding response body"
529 )));
530 assert!(is_transient_error(&anyhow!(
531 "create_chat API error (status 429): rate limited"
532 )));
533 assert!(is_transient_error(&anyhow!(
534 "create_chat API error (status 503): unavailable"
535 )));
536 assert!(!is_transient_error(&anyhow!(
537 "create_chat failed to parse JSON response (status 200): expected `,` at line 1"
538 )));
539 assert!(!is_transient_error(&anyhow!(
540 "create_chat API error (status 401): unauthorized"
541 )));
542 assert!(!is_transient_error(&anyhow!(
543 "create_chat API error (status 404): not found"
544 )));
545 }
546}