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
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    /// Create a response using xAI's Responses API with agentic tool calling.
359    ///
360    /// This method calls the `/v1/responses` endpoint which supports server-side
361    /// tools like web_search, x_search, code_execution, and more.
362    ///
363    /// # Arguments
364    /// * `args` - The ResponsesArguments containing model, input messages, and tools
365    /// * `opt_url_path` - Optional URL path override (defaults to `/v1/responses`)
366    ///
367    /// # Example
368    /// ```rust,no_run
369    /// use openai_rust2::chat::{ResponsesArguments, ResponsesMessage, GrokTool};
370    /// use openai_rust2::Client;
371    ///
372    /// async fn example() -> anyhow::Result<()> {
373    ///     let client = Client::new_with_base_url("your-api-key", "https://api.x.ai/v1");
374    ///     let args = ResponsesArguments::new(
375    ///         "grok-4-1-fast-reasoning",
376    ///         vec![ResponsesMessage {
377    ///             role: "user".to_string(),
378    ///             content: "What is the current Bitcoin price?".to_string(),
379    ///         }],
380    ///     ).with_tools(vec![GrokTool::web_search()]);
381    ///
382    ///     let response = client.create_responses(args, None).await?;
383    ///     println!("{}", response.get_text_content());
384    ///     Ok(())
385    /// }
386    /// ```
387    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    /// Create a response using OpenAI's Responses API with agentic tool calling.
397    ///
398    /// This method calls the `/v1/responses` endpoint which supports server-side
399    /// tools like web_search, file_search, and code_interpreter.
400    ///
401    /// Supported models: gpt-5, gpt-4o, and other models with tool support.
402    ///
403    /// # Arguments
404    /// * `args` - The OpenAIResponsesArguments containing model, input messages, and tools
405    /// * `opt_url_path` - Optional URL path override (defaults to `/v1/responses`)
406    ///
407    /// # Example
408    /// ```rust,no_run
409    /// use openai_rust2::chat::{OpenAIResponsesArguments, ResponsesMessage, OpenAITool};
410    /// use openai_rust2::Client;
411    ///
412    /// async fn example() -> anyhow::Result<()> {
413    ///     let client = Client::new("your-openai-api-key");
414    ///     let args = OpenAIResponsesArguments::new(
415    ///         "gpt-5",
416    ///         vec![ResponsesMessage {
417    ///             role: "user".to_string(),
418    ///             content: "What are the latest developments in AI?".to_string(),
419    ///         }],
420    ///     ).with_tools(vec![OpenAITool::web_search()]);
421    ///
422    ///     let response = client.create_openai_responses(args, None).await?;
423    ///     println!("{}", response.get_text_content());
424    ///     Ok(())
425    /// }
426    /// ```
427    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
438/// Exponential backoff for `attempt` (0-based): 1, 2, 4, 8 … seconds,
439/// capped at 30s so a runaway retry loop can never sleep for a minute
440/// per attempt. Used for the body-decode retry path inside `create_chat`.
441fn backoff_for_attempt(attempt: u32) -> u64 {
442    (2u64.saturating_pow(attempt)).min(30)
443}
444
445/// Truncate a string for inclusion in error messages, marking the cut point
446/// when bytes were dropped so logs don't get spammed by 10MB error bodies.
447fn 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
459/// Heuristic: is the given `anyhow::Error` something that is safe to retry
460/// on a fresh HTTP request? Used by `create_chat` to decide between
461/// retrying the whole request and bubbling the error up.
462fn is_transient_error(err: &anyhow::Error) -> bool {
463    let msg = format!("{:#}", err);
464    // Body-decode failures (reqwest's "error decoding response body") and
465    // HTTP 429/5xx responses are the two retryable classes. Body-decode
466    // failures show up here because `send_json` now embeds the reqwest
467    // message verbatim: "create_chat failed to read response body (status
468    // 200): error decoding response body".
469    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    // reqwest connection-level errors look like
481    // "error sending request" / "error decoding response body" / body stream
482    // errors. Fall through to false for parse errors, 4xx, etc.
483    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        // Re-serializing must not panic on a split codepoint.
511        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}