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
11pub struct Client {
12 req_client: reqwest::Client,
13 key: String,
14 base_url: reqwest::Url,
15 timeout: Duration,
16 max_retries: u32,
17}
18
19pub mod chat;
20pub mod completions;
21pub mod edits;
22pub mod embeddings;
23pub mod images;
24pub mod models;
25
26#[derive(Debug, Clone)]
27pub struct ClientBuilder {
28 api_key: String,
29 base_url: Option<String>,
30 timeout: Duration,
31 max_retries: u32,
32}
33
34impl Default for ClientBuilder {
35 fn default() -> Self {
36 Self {
37 api_key: String::new(),
38 base_url: None,
39 timeout: Duration::from_secs(60),
40 max_retries: 3,
41 }
42 }
43}
44
45impl ClientBuilder {
46 pub fn new(api_key: impl Into<String>) -> Self {
47 Self {
48 api_key: api_key.into(),
49 ..Default::default()
50 }
51 }
52
53 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
54 self.base_url = Some(base_url.into());
55 self
56 }
57
58 pub fn with_timeout(mut self, timeout: Duration) -> Self {
59 self.timeout = timeout;
60 self
61 }
62
63 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
64 self.max_retries = max_retries;
65 self
66 }
67
68 pub fn build(self) -> Result<Client> {
69 let req_client = reqwest::ClientBuilder::new()
70 .timeout(self.timeout)
71 .build()
72 .map_err(|e| anyhow!("failed to build reqwest client: {}", e))?;
73 let base_url = match self.base_url {
74 Some(url) => reqwest::Url::parse(&url)
75 .map_err(|e| anyhow!("invalid base URL '{}': {}", url, e))?,
76 None => DEFAULT_BASE_URL.clone(),
77 };
78 Ok(Client {
79 req_client,
80 key: self.api_key,
81 base_url,
82 timeout: self.timeout,
83 max_retries: self.max_retries,
84 })
85 }
86}
87
88impl Client {
89 pub fn new(api_key: &str) -> Client {
90 let timeout = Duration::from_secs(60);
91 let req_client = reqwest::ClientBuilder::new()
92 .timeout(timeout)
93 .build()
94 .unwrap();
95 Client {
96 req_client,
97 key: api_key.to_owned(),
98 base_url: DEFAULT_BASE_URL.clone(),
99 timeout,
100 max_retries: 3,
101 }
102 }
103
104 pub fn new_with_client(api_key: &str, req_client: reqwest::Client) -> Client {
105 let timeout = Duration::from_secs(60);
106 Client {
107 req_client,
108 key: api_key.to_owned(),
109 base_url: DEFAULT_BASE_URL.clone(),
110 timeout,
111 max_retries: 3,
112 }
113 }
114
115 pub fn new_with_base_url(api_key: &str, base_url: &str) -> Client {
116 let timeout = Duration::from_secs(60);
117 let req_client = reqwest::ClientBuilder::new()
118 .timeout(timeout)
119 .build()
120 .unwrap();
121 let base_url = reqwest::Url::parse(base_url).unwrap();
122 Client {
123 req_client,
124 key: api_key.to_owned(),
125 base_url,
126 timeout,
127 max_retries: 3,
128 }
129 }
130
131 pub fn new_with_client_and_base_url(
132 api_key: &str,
133 req_client: reqwest::Client,
134 base_url: &str,
135 ) -> Client {
136 let timeout = Duration::from_secs(60);
137 Client {
138 req_client,
139 key: api_key.to_owned(),
140 base_url: reqwest::Url::parse(base_url).unwrap(),
141 timeout,
142 max_retries: 3,
143 }
144 }
145
146 pub async fn read_and_parse_json<T: serde::de::DeserializeOwned>(
156 res: reqwest::Response,
157 error_context: &str,
158 ) -> Result<T, anyhow::Error> {
159 let status = res.status();
160 match res.text().await {
161 Ok(text) => serde_json::from_str(&text).map_err(|e| {
162 anyhow!(
163 "{} failed to parse JSON response (status {}): {}. Raw body ({} bytes): {}",
164 error_context,
165 status,
166 e,
167 text.len(),
168 truncate_for_error(&text, 4096)
169 )
170 }),
171 Err(e) => Err(anyhow!(
172 "{} failed to read response body (status {}): {}",
173 error_context,
174 status,
175 e
176 )),
177 }
178 }
179
180 pub async fn list_models(
181 &self,
182 opt_url_path: Option<String>,
183 ) -> Result<Vec<models::Model>, anyhow::Error> {
184 let mut url = self.base_url.clone();
185 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/models")));
186
187 let res = self
188 .req_client
189 .get(url)
190 .bearer_auth(&self.key)
191 .send()
192 .await?;
193
194 if res.status() == 200 {
195 let parsed: models::ListModelsResponse =
196 Self::read_and_parse_json(res, "list_models").await?;
197 Ok(parsed.data)
198 } else {
199 let status = res.status();
200 let body = res.text().await.unwrap_or_default();
201 Err(anyhow!(
202 "list_models failed: status={} body={}",
203 status,
204 truncate_for_error(&body, 4096)
205 ))
206 }
207 }
208
209 pub async fn create_chat(
210 &self,
211 args: chat::ChatArguments,
212 opt_url_path: Option<String>,
213 ) -> Result<chat::ChatCompletion, anyhow::Error> {
214 let mut url = self.base_url.clone();
215 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/chat/completions")));
216
217 let mut attempt = 0;
218 loop {
219 let res = self
220 .req_client
221 .post(url.clone())
222 .bearer_auth(&self.key)
223 .json(&args)
224 .send()
225 .await?;
226
227 let status = res.status();
228 if status.is_success() {
229 match Self::read_and_parse_json::<chat::ChatCompletion>(res, "create_chat").await {
234 Ok(parsed) => return Ok(parsed),
235 Err(e) => {
236 if attempt >= self.max_retries {
237 return Err(e);
238 }
239 let backoff = backoff_for_attempt(attempt);
240 tokio::time::sleep(Duration::from_secs(backoff)).await;
241 attempt += 1;
242 continue;
243 }
244 }
245 }
246
247 let should_retry = status == 429
248 || status.as_u16() >= 500
249 || matches!(
250 status.as_u16(),
251 502 | 503 | 504
252 );
253
254 if !should_retry || attempt >= self.max_retries {
255 let body_text = res.text().await.unwrap_or_default();
256 return Err(anyhow!(
257 "create_chat failed after {} attempts: status={} body={}",
258 attempt + 1,
259 status,
260 truncate_for_error(&body_text, 4096)
261 ));
262 }
263
264 let retry_after = res
265 .headers()
266 .get("retry-after")
267 .and_then(|v| v.to_str().ok())
268 .and_then(|s| s.parse::<u64>().ok())
269 .unwrap_or_else(|| 2u64.saturating_pow(attempt));
270
271 let _ = res.text().await;
274
275 tokio::time::sleep(Duration::from_secs(retry_after)).await;
276 attempt += 1;
277 }
278 }
279
280 pub async fn create_chat_stream(
281 &self,
282 args: chat::ChatArguments,
283 opt_url_path: Option<String>,
284 ) -> Result<chat::stream::ChatCompletionChunkStream> {
285 let mut url = self.base_url.clone();
286 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/chat/completions")));
287
288 let mut args = args;
289 args.stream = Some(true);
290
291 let res = self
292 .req_client
293 .post(url)
294 .bearer_auth(&self.key)
295 .json(&args)
296 .send()
297 .await?;
298
299 if res.status() == 200 {
300 Ok(chat::stream::ChatCompletionChunkStream::new(Box::pin(
301 res.bytes_stream(),
302 )))
303 } else {
304 let status = res.status();
305 let body = res.text().await.unwrap_or_default();
306 Err(anyhow!(
307 "create_chat_stream failed: status={} body={}",
308 status,
309 truncate_for_error(&body, 4096)
310 ))
311 }
312 }
313
314 pub async fn create_completion(
315 &self,
316 args: completions::CompletionArguments,
317 opt_url_path: Option<String>,
318 ) -> Result<completions::CompletionResponse> {
319 let mut url = self.base_url.clone();
320 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/completions")));
321
322 let res = self
323 .req_client
324 .post(url)
325 .bearer_auth(&self.key)
326 .json(&args)
327 .send()
328 .await?;
329
330 if res.status() == 200 {
331 Self::read_and_parse_json(res, "create_completion").await
332 } else {
333 let status = res.status();
334 let body = res.text().await.unwrap_or_default();
335 Err(anyhow!(
336 "create_completion failed: status={} body={}",
337 status,
338 truncate_for_error(&body, 4096)
339 ))
340 }
341 }
342
343 pub async fn create_embeddings(
344 &self,
345 args: embeddings::EmbeddingsArguments,
346 opt_url_path: Option<String>,
347 ) -> Result<embeddings::EmbeddingsResponse> {
348 let mut url = self.base_url.clone();
349 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/embeddings")));
350
351 let res = self
352 .req_client
353 .post(url)
354 .bearer_auth(&self.key)
355 .json(&args)
356 .send()
357 .await?;
358
359 if res.status() == 200 {
360 Self::read_and_parse_json(res, "create_embeddings").await
361 } else {
362 let status = res.status();
363 let body = res.text().await.unwrap_or_default();
364 Err(anyhow!(
365 "create_embeddings failed: status={} body={}",
366 status,
367 truncate_for_error(&body, 4096)
368 ))
369 }
370 }
371
372 pub async fn create_image_old(
373 &self,
374 args: images::ImageArguments,
375 opt_url_path: Option<String>,
376 ) -> Result<Vec<String>> {
377 let mut url = self.base_url.clone();
378 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/images/generations")));
379
380 let res = self
381 .req_client
382 .post(url)
383 .bearer_auth(&self.key)
384 .json(&args)
385 .send()
386 .await?;
387
388 if res.status() == 200 {
389 let parsed: images::ImageResponse =
390 Self::read_and_parse_json(res, "create_image_old").await?;
391 Ok(parsed
392 .data
393 .iter()
394 .map(|o| match o {
395 images::ImageObject::Url(s) => s.to_string(),
396 images::ImageObject::Base64JSON(s) => s.to_string(),
397 })
398 .collect())
399 } else {
400 let status = res.status();
401 let body = res.text().await.unwrap_or_default();
402 Err(anyhow!(
403 "create_image_old failed: status={} body={}",
404 status,
405 truncate_for_error(&body, 4096)
406 ))
407 }
408 }
409
410 pub async fn create_image(
411 &self,
412 args: images::ImageArguments,
413 opt_url_path: Option<String>,
414 ) -> Result<Vec<String>> {
415 let mut url = self.base_url.clone();
416 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/images/generations")));
417
418 let image_args = images::ImageArguments {
419 prompt: args.prompt,
420 model: Some("gpt-image-1".to_string()),
421 n: Some(1),
422 size: Some("1024x1024".to_string()),
423 quality: Some("auto".to_string()), user: None,
426 };
427
428 let res = self
429 .req_client
430 .post(url)
431 .bearer_auth(&self.key)
432 .json(&image_args)
433 .send()
434 .await?;
435
436 if res.status() == 200 {
437 let parsed: images::ImageResponse =
438 Self::read_and_parse_json(res, "create_image").await?;
439 Ok(parsed
440 .data
441 .iter()
442 .map(|o| match o {
443 images::ImageObject::Url(s) => s.to_string(),
444 images::ImageObject::Base64JSON(s) => s.to_string(),
445 })
446 .collect())
447 } else {
448 let status = res.status();
449 let body = res.text().await.unwrap_or_default();
450 Err(anyhow!(
451 "create_image failed: status={} body={}",
452 status,
453 truncate_for_error(&body, 4096)
454 ))
455 }
456 }
457
458 pub async fn create_responses(
488 &self,
489 args: chat::ResponsesArguments,
490 opt_url_path: Option<String>,
491 ) -> Result<chat::ResponsesCompletion, anyhow::Error> {
492 let mut url = self.base_url.clone();
493 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/responses")));
494
495 let res = self
496 .req_client
497 .post(url)
498 .bearer_auth(&self.key)
499 .json(&args)
500 .send()
501 .await?;
502
503 if res.status() == 200 {
504 Self::read_and_parse_json(res, "create_responses").await
505 } else {
506 let status = res.status();
507 let body = res.text().await.unwrap_or_default();
508 Err(anyhow!(
509 "create_responses failed: status={} body={}",
510 status,
511 truncate_for_error(&body, 4096)
512 ))
513 }
514 }
515
516 pub async fn create_openai_responses(
548 &self,
549 args: chat::OpenAIResponsesArguments,
550 opt_url_path: Option<String>,
551 ) -> Result<chat::ResponsesCompletion, anyhow::Error> {
552 let mut url = self.base_url.clone();
553 url.set_path(&opt_url_path.unwrap_or_else(|| String::from("/v1/responses")));
554
555 let res = self
556 .req_client
557 .post(url)
558 .bearer_auth(&self.key)
559 .json(&args)
560 .send()
561 .await?;
562
563 if res.status() == 200 {
564 Self::read_and_parse_json(res, "create_openai_responses").await
565 } else {
566 let status = res.status();
567 let body = res.text().await.unwrap_or_default();
568 Err(anyhow!(
569 "create_openai_responses failed: status={} body={}",
570 status,
571 truncate_for_error(&body, 4096)
572 ))
573 }
574 }
575}
576
577fn backoff_for_attempt(attempt: u32) -> u64 {
581 (2u64.saturating_pow(attempt)).min(30)
582}
583
584fn truncate_for_error(text: &str, max_bytes: usize) -> String {
587 if text.len() <= max_bytes {
588 text.to_string()
589 } else {
590 let mut cut = max_bytes;
591 while !text.is_char_boundary(cut) && cut > 0 {
592 cut -= 1;
593 }
594 format!("{}…[truncated, total {} bytes]", &text[..cut], text.len())
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn truncate_for_error_keeps_short_strings() {
604 let s = "hello".to_string();
605 assert_eq!(truncate_for_error(&s, 10), "hello");
606 }
607
608 #[test]
609 fn truncate_for_error_marks_cut_point() {
610 let s = "a".repeat(100);
611 let out = truncate_for_error(&s, 10);
612 assert!(out.starts_with(&"a".repeat(10)));
613 assert!(out.contains("truncated"));
614 assert!(out.contains("100 bytes"));
615 }
616
617 #[test]
618 fn truncate_for_error_respects_char_boundaries() {
619 let s = "ááááá";
621 let out = truncate_for_error(&s, 3);
622 assert!(out.is_char_boundary(out.len() - 0));
625 }
626
627 #[test]
628 fn backoff_grows_then_caps() {
629 assert_eq!(backoff_for_attempt(0), 1);
630 assert_eq!(backoff_for_attempt(1), 2);
631 assert_eq!(backoff_for_attempt(2), 4);
632 assert_eq!(backoff_for_attempt(3), 8);
633 assert_eq!(backoff_for_attempt(10), 30); }
635}