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    /// Shared HTTP client with optimized connection pooling for high-throughput LLM workloads.
11    ///
12    /// Configuration rationale:
13    /// - pool_max_idle_per_host: 100 (handle burst traffic without connection churn)
14    /// - pool_idle_timeout: 90s (keep connections warm between requests)
15    /// - tcp_keepalive: 60s (detect dead connections proactively)
16    /// - connect_timeout: 10s (fail fast on connection issues)
17    /// - timeout: 300s (generous for large model responses)
18    /// - tcp_nodelay: true (reduce latency for small requests)
19    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    /// Per-call attempt budget. The shared HTTP client already does connection
37    /// pooling + retries on the transport layer (see SHARED_HTTP_CLIENT);
38    /// this knob is for application-level retries on top of that, used by
39    /// `create_chat` for transient 200-stream body failures and 429/5xx.
40    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    /// Create a new client with the shared optimized HTTP client.
63    /// Uses connection pooling with keep-alive for high-throughput workloads.
64    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    /// Create a new client with a custom reqwest::Client.
74    /// Use this when you need custom TLS, proxy, or connection pool settings.
75    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    /// Create a new client with the shared optimized HTTP client and custom base URL.
85    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    /// Create a new client with a custom reqwest::Client and custom base URL.
96    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    /// Get a reference to the shared HTTP client for advanced usage.
110    pub fn shared_client() -> &'static reqwest::Client {
111        &SHARED_HTTP_CLIENT
112    }
113
114    /// Override the per-call retry budget (default: 3). 0 disables retries.
115    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
116        self.max_retries = max_retries;
117        self
118    }
119
120    /// Read the response body as text and deserialize it as JSON, surfacing
121    /// the HTTP status and a (truncated) raw body in the error message.
122    ///
123    /// This is the path that every `create_*` / `list_models` method uses
124    /// for the success branch. Routing everything through a single helper
125    /// guarantees that callers never see a bare reqwest "error decoding
126    /// response body" with no context, which previously made it impossible
127    /// to tell whether a 200 response was actually a non-UTF-8 binary blob,
128    /// a truncated stream, or some other transport-level failure.
129    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    /// Helper to send a POST request with JSON body and parse the response.
155    /// Returns the raw text for non-200 responses, or parses JSON for 200 responses.
156    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    /// Helper for GET requests.
188    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        // Chat completions can hit transient transport failures (connection
237        // reset, truncated SSE → 200 stream, upstream 429/5xx). Retry on
238        // the ones that are safe to retry, with exponential backoff and an
239        // upper bound. The body-decode retry path is the one that fixed
240        // the "error decoding response body" failure mode that previously
241        // surfaced as a single fatal error.
242        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    /// Create a response using xAI's Responses API with agentic tool calling.
357    ///
358    /// This method calls the `/v1/responses` endpoint which supports server-side
359    /// tools like web_search, x_search, code_execution, and more.
360    ///
361    /// # Arguments
362    /// * `args` - The ResponsesArguments containing model, input messages, and tools
363    /// * `opt_url_path` - Optional URL path override (defaults to `/v1/responses`)
364    ///
365    /// # Example
366    /// ```rust,no_run
367    /// use openai_rust2::chat::{ResponsesArguments, ResponsesMessage, GrokTool};
368    /// use openai_rust2::Client;
369    ///
370    /// async fn example() -> anyhow::Result<()> {
371    ///     let client = Client::new_with_base_url("your-api-key", "https://api.x.ai/v1");
372    ///     let args = ResponsesArguments::new(
373    ///         "grok-4-1-fast-reasoning",
374    ///         vec![ResponsesMessage {
375    ///             role: "user".to_string(),
376    ///             content: "What is the current Bitcoin price?".to_string(),
377    ///         }],
378    ///     ).with_tools(vec![GrokTool::web_search()]);
379    ///
380    ///     let response = client.create_responses(args, None).await?;
381    ///     println!("{}", response.get_text_content());
382    ///     Ok(())
383    /// }
384    /// ```
385    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    /// Create a response using OpenAI's Responses API with agentic tool calling.
395    ///
396    /// This method calls the `/v1/responses` endpoint which supports server-side
397    /// tools like web_search, file_search, and code_interpreter.
398    ///
399    /// Supported models: gpt-5, gpt-4o, and other models with tool support.
400    ///
401    /// # Arguments
402    /// * `args` - The OpenAIResponsesArguments containing model, input messages, and tools
403    /// * `opt_url_path` - Optional URL path override (defaults to `/v1/responses`)
404    ///
405    /// # Example
406    /// ```rust,no_run
407    /// use openai_rust2::chat::{OpenAIResponsesArguments, ResponsesMessage, OpenAITool};
408    /// use openai_rust2::Client;
409    ///
410    /// async fn example() -> anyhow::Result<()> {
411    ///     let client = Client::new("your-openai-api-key");
412    ///     let args = OpenAIResponsesArguments::new(
413    ///         "gpt-5",
414    ///         vec![ResponsesMessage {
415    ///             role: "user".to_string(),
416    ///             content: "What are the latest developments in AI?".to_string(),
417    ///         }],
418    ///     ).with_tools(vec![OpenAITool::web_search()]);
419    ///
420    ///     let response = client.create_openai_responses(args, None).await?;
421    ///     println!("{}", response.get_text_content());
422    ///     Ok(())
423    /// }
424    /// ```
425    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
436/// Exponential backoff for `attempt` (0-based): 1, 2, 4, 8 … seconds,
437/// capped at 30s so a runaway retry loop can never sleep for a minute
438/// per attempt. Used for the body-decode retry path inside `create_chat`.
439fn backoff_for_attempt(attempt: u32) -> u64 {
440    (2u64.saturating_pow(attempt)).min(30)
441}
442
443/// Truncate a string for inclusion in error messages, marking the cut point
444/// when bytes were dropped so logs don't get spammed by 10MB error bodies.
445fn 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
461/// Heuristic: is the given `anyhow::Error` something that is safe to retry
462/// on a fresh HTTP request? Used by `create_chat` to decide between
463/// retrying the whole request and bubbling the error up.
464fn is_transient_error(err: &anyhow::Error) -> bool {
465    let msg = format!("{:#}", err);
466    // Body-decode failures (reqwest's "error decoding response body") and
467    // HTTP 429/5xx responses are the two retryable classes. Body-decode
468    // failures show up here because `send_json` now embeds the reqwest
469    // message verbatim: "create_chat failed to read response body (status
470    // 200): error decoding response body".
471    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    // reqwest connection-level errors look like
483    // "error sending request" / "error decoding response body" / body stream
484    // errors. Fall through to false for parse errors, 4xx, etc.
485    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        // Re-serializing must not panic on a split codepoint.
513        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}