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
245 .send_json::<chat::ChatCompletion>(&path, &args, "create_chat")
246 .await;
247 match result {
248 Ok(parsed) => return Ok(parsed),
249 Err(e) => {
250 let is_transient = is_transient_error(&e);
251 if !is_transient || attempt >= self.max_retries {
252 return Err(e);
253 }
254 let backoff = backoff_for_attempt(attempt);
255 tokio::time::sleep(Duration::from_secs(backoff)).await;
256 attempt += 1;
257 }
258 }
259 }
260 }
261
262 pub async fn create_chat_stream(
263 &self,
264 args: chat::ChatArguments,
265 opt_url_path: Option<String>,
266 ) -> Result<chat::stream::ChatCompletionChunkStream> {
267 let mut url = self.base_url.clone();
268 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/chat/completions")));
269
270 let mut args = args;
271 args.stream = Some(true);
272
273 let res = self
274 .req_client
275 .post(url)
276 .bearer_auth(&self.key)
277 .json(&args)
278 .send()
279 .await?;
280
281 if res.status() == 200 {
282 Ok(chat::stream::ChatCompletionChunkStream::new(Box::pin(
283 res.bytes_stream(),
284 )))
285 } else {
286 let status = res.status();
287 let body = res.text().await.unwrap_or_default();
288 Err(anyhow!(
289 "create_chat_stream failed: status={} body={}",
290 status,
291 truncate_for_error(&body, 4096)
292 ))
293 }
294 }
295
296 pub async fn create_completion(
297 &self,
298 args: completions::CompletionArguments,
299 opt_url_path: Option<String>,
300 ) -> Result<completions::CompletionResponse> {
301 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/completions"));
302 self.send_json(&path, &args, "create_completion").await
303 }
304
305 pub async fn create_embeddings(
306 &self,
307 args: embeddings::EmbeddingsArguments,
308 opt_url_path: Option<String>,
309 ) -> Result<embeddings::EmbeddingsResponse> {
310 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/embeddings"));
311 self.send_json(&path, &args, "create_embeddings").await
312 }
313
314 pub async fn create_image_old(
315 &self,
316 args: images::ImageArguments,
317 opt_url_path: Option<String>,
318 ) -> Result<Vec<String>> {
319 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/images/generations"));
320 let response: images::ImageResponse =
321 self.send_json(&path, &args, "create_image_old").await?;
322 Ok(response
323 .data
324 .iter()
325 .map(|o| match o {
326 images::ImageObject::Url(s) => s.to_string(),
327 images::ImageObject::Base64JSON(s) => s.to_string(),
328 })
329 .collect())
330 }
331
332 pub async fn create_image(
333 &self,
334 args: images::ImageArguments,
335 opt_url_path: Option<String>,
336 ) -> Result<Vec<String>> {
337 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/images/generations"));
338 let image_args = images::ImageArguments {
339 prompt: args.prompt,
340 model: Some("gpt-image-1".to_string()),
341 n: Some(1),
342 size: Some("1024x1024".to_string()),
343 quality: Some("auto".to_string()),
344 user: None,
345 };
346 let response: images::ImageResponse =
347 self.send_json(&path, &image_args, "create_image").await?;
348 Ok(response
349 .data
350 .iter()
351 .map(|o| match o {
352 images::ImageObject::Url(s) => s.to_string(),
353 images::ImageObject::Base64JSON(s) => s.to_string(),
354 })
355 .collect())
356 }
357
358 pub async fn create_responses(
388 &self,
389 args: chat::ResponsesArguments,
390 opt_url_path: Option<String>,
391 ) -> Result<chat::ResponsesCompletion, anyhow::Error> {
392 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/responses"));
393 self.send_json(&path, &args, "create_responses").await
394 }
395
396 pub async fn create_openai_responses(
428 &self,
429 args: chat::OpenAIResponsesArguments,
430 opt_url_path: Option<String>,
431 ) -> Result<chat::ResponsesCompletion, anyhow::Error> {
432 let path = opt_url_path.unwrap_or_else(|| String::from("/v1/responses"));
433 self.send_json(&path, &args, "create_openai_responses")
434 .await
435 }
436}
437
438fn backoff_for_attempt(attempt: u32) -> u64 {
442 (2u64.saturating_pow(attempt)).min(30)
443}
444
445fn truncate_for_error(text: &str, max_bytes: usize) -> String {
448 if text.len() <= max_bytes {
449 text.to_string()
450 } else {
451 let mut cut = max_bytes;
452 while !text.is_char_boundary(cut) && cut > 0 {
453 cut -= 1;
454 }
455 format!("{}…[truncated, total {} bytes]", &text[..cut], text.len())
456 }
457}
458
459fn is_transient_error(err: &anyhow::Error) -> bool {
463 let msg = format!("{:#}", err);
464 if msg.contains("error decoding response body") {
470 return true;
471 }
472 if msg.contains("(status 429")
473 || msg.contains("(status 500")
474 || msg.contains("(status 502")
475 || msg.contains("(status 503")
476 || msg.contains("(status 504")
477 {
478 return true;
479 }
480 false
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use anyhow::anyhow;
490
491 #[test]
492 fn truncate_for_error_keeps_short_strings() {
493 let s = "hello";
494 assert_eq!(truncate_for_error(s, 10), "hello");
495 }
496
497 #[test]
498 fn truncate_for_error_marks_cut_point() {
499 let s = "a".repeat(100);
500 let out = truncate_for_error(&s, 10);
501 assert!(out.starts_with(&"a".repeat(10)));
502 assert!(out.contains("truncated"));
503 assert!(out.contains("100 bytes"));
504 }
505
506 #[test]
507 fn truncate_for_error_respects_char_boundaries() {
508 let s = "ááááá";
509 let out = truncate_for_error(s, 3);
510 let _ = std::str::from_utf8(out.as_bytes()).unwrap();
512 }
513
514 #[test]
515 fn backoff_grows_then_caps() {
516 assert_eq!(backoff_for_attempt(0), 1);
517 assert_eq!(backoff_for_attempt(1), 2);
518 assert_eq!(backoff_for_attempt(2), 4);
519 assert_eq!(backoff_for_attempt(3), 8);
520 assert_eq!(backoff_for_attempt(10), 30);
521 }
522
523 #[test]
524 fn transient_classifier_recognises_known_signals() {
525 assert!(is_transient_error(&anyhow!(
526 "create_chat failed to read response body (status 200): error decoding response body"
527 )));
528 assert!(is_transient_error(&anyhow!(
529 "create_chat API error (status 429): rate limited"
530 )));
531 assert!(is_transient_error(&anyhow!(
532 "create_chat API error (status 503): unavailable"
533 )));
534 assert!(!is_transient_error(&anyhow!(
535 "create_chat failed to parse JSON response (status 200): expected `,` at line 1"
536 )));
537 assert!(!is_transient_error(&anyhow!(
538 "create_chat API error (status 401): unauthorized"
539 )));
540 assert!(!is_transient_error(&anyhow!(
541 "create_chat API error (status 404): not found"
542 )));
543 }
544}