Skip to main content

openai_rust2/
lib.rs

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    /// Read the response body as text and deserialize it as JSON, surfacing
147    /// the HTTP status and the raw body in the error when either step fails.
148    ///
149    /// This is the path that `create_chat` and the other `create_*` methods
150    /// use for the success branch. Routing everything through a single helper
151    /// guarantees that callers never see a bare "error decoding response body"
152    /// from reqwest without context, which previously made it impossible to
153    /// tell whether a 200 response was actually a non-UTF-8 binary blob, a
154    /// truncated stream, or some other transport-level failure.
155    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                // Use the helper so that body read failures (reqwest's
230                // "error decoding response body" from a connection reset or
231                // truncated stream) are treated as transient and retried
232                // instead of bubbling up as a bare `Kind::Decode` error.
233                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            // Consume the body (best-effort) so the connection can be reused
272            // before we sleep. Errors here are non-fatal.
273            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()), // valid quality values are 'low', 'medium', 'high' and 'auto'
424            //TODO: Make this an enum parameter to create_image
425            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    /// Create a response using xAI's Responses API with agentic tool calling.
459    ///
460    /// This method calls the `/v1/responses` endpoint which supports server-side
461    /// tools like web_search, x_search, code_execution, and more.
462    ///
463    /// # Arguments
464    /// * `args` - The ResponsesArguments containing model, input messages, and tools
465    /// * `opt_url_path` - Optional URL path override (defaults to `/v1/responses`)
466    ///
467    /// # Example
468    /// ```rust,no_run
469    /// use openai_rust2::chat::{ResponsesArguments, ResponsesMessage, GrokTool};
470    /// use openai_rust2::Client;
471    ///
472    /// async fn example() -> anyhow::Result<()> {
473    ///     let client = Client::new_with_base_url("your-api-key", "https://api.x.ai/v1");
474    ///     let args = ResponsesArguments::new(
475    ///         "grok-4-1-fast-reasoning",
476    ///         vec![ResponsesMessage {
477    ///             role: "user".to_string(),
478    ///             content: "What is the current Bitcoin price?".to_string(),
479    ///         }],
480    ///     ).with_tools(vec![GrokTool::web_search()]);
481    ///
482    ///     let response = client.create_responses(args, None).await?;
483    ///     println!("{}", response.get_text_content());
484    ///     Ok(())
485    /// }
486    /// ```
487    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    /// Create a response using OpenAI's Responses API with agentic tool calling.
517    ///
518    /// This method calls the `/v1/responses` endpoint which supports server-side
519    /// tools like web_search, file_search, and code_interpreter.
520    ///
521    /// Supported models: gpt-5, gpt-4o, and other models with tool support.
522    ///
523    /// # Arguments
524    /// * `args` - The OpenAIResponsesArguments containing model, input messages, and tools
525    /// * `opt_url_path` - Optional URL path override (defaults to `/v1/responses`)
526    ///
527    /// # Example
528    /// ```rust,no_run
529    /// use openai_rust2::chat::{OpenAIResponsesArguments, ResponsesMessage, OpenAITool};
530    /// use openai_rust2::Client;
531    ///
532    /// async fn example() -> anyhow::Result<()> {
533    ///     let client = Client::new("your-openai-api-key");
534    ///     let args = OpenAIResponsesArguments::new(
535    ///         "gpt-5",
536    ///         vec![ResponsesMessage {
537    ///             role: "user".to_string(),
538    ///             content: "What are the latest developments in AI?".to_string(),
539    ///         }],
540    ///     ).with_tools(vec![OpenAITool::web_search()]);
541    ///
542    ///     let response = client.create_openai_responses(args, None).await?;
543    ///     println!("{}", response.get_text_content());
544    ///     Ok(())
545    /// }
546    /// ```
547    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
577/// Exponential backoff for `attempt` (0-based): 1, 2, 4, 8 … seconds,
578/// capped at 30s so a runaway retry loop can never sleep for a minute
579/// per attempt. Used for the body-decode retry path inside `create_chat`.
580fn backoff_for_attempt(attempt: u32) -> u64 {
581    (2u64.saturating_pow(attempt)).min(30)
582}
583
584/// Truncate a string for inclusion in error messages, marking the cut point
585/// when bytes were dropped so logs don't get spammed by 10MB error bodies.
586fn 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        // "á" is 2 bytes in UTF-8; cutting at byte 1 would split it.
620        let s = "ááááá";
621        let out = truncate_for_error(&s, 3);
622        // We should have cut down to a valid char boundary, never producing
623        // a panic when downstream code tries to re-serialize the string.
624        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); // capped
634    }
635}