Skip to main content

openai_interface/rest/
mod.rs

1//! REST API client module for OpenAI interface
2//!
3//! This module provides the core HTTP functionality for making requests to OpenAI-compatible APIs.
4//! It includes traits and implementations for both streaming and non-streaming API calls.
5//!
6//! # Overview
7//!
8//! The `rest` module contains:
9//! - [`post`]: HTTP POST request functionality with streaming and non-streaming support
10//! - [`get`]: HTTP GET request functionality with various parameter handling options
11//! - [`delete`]: HTTP DELETE request functionality
12//! - [`options`]: Per-request authentication ([`RequestOptions`], [`Auth`])
13//!   taken by every request method
14//! - [`default_client`]: A `reqwest::Client` constructor shared by all request traits
15//! - [`check_status`]: Shared non-2xx response handling which parses the error body
16//! - [`skip_deserialization_errors`]: A stream adapter that drops chunks
17//!   which fail to deserialize
18//!
19//! # Usage
20//!
21//! The module is designed to be used through the higher-level API modules (`chat`, `completions`,
22//! etc.). However, you can use the traits directly if needed:
23//!
24//! ## POST Requests
25//!
26//! ```rust
27//! use openai_interface::rest::{post::{Post, PostNoStream}, RequestOptions};
28//! use openai_interface::errors::OapiError;
29//! use serde::{Serialize, Deserialize};
30//!
31//! use std::str::FromStr;
32//!
33//! #[derive(Serialize)]
34//! struct MyRequest {
35//!     prompt: String,
36//!     stream: bool,
37//! }
38//!
39//! #[derive(Deserialize)]
40//! struct MyResponse {
41//!     // Define the fields of your response here
42//!     id: String,
43//! }
44//!
45//! impl FromStr for MyResponse {
46//!     type Err = OapiError;
47//!
48//!     fn from_str(content: &str) -> Result<Self, Self::Err> {
49//!         let parse_result: Result<Self, _> = serde_json::from_str(content)
50//!             .map_err(|e| OapiError::DeserializationError(e.to_string()));
51//!         parse_result
52//!     }
53//! }
54//!
55//! impl Post for MyRequest {
56//!     fn is_streaming(&self) -> bool {
57//!         self.stream
58//!     }
59//!     fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
60//!         Ok(format!("{}/service", base_url))
61//!     }
62//! }
63//!
64//! impl PostNoStream for MyRequest {
65//!     type Response = MyResponse;
66//! }
67//!
68//! // Send it with a client:
69//! // let client = openai_interface::rest::default_client();
70//! // let response: MyResponse = request
71//! //     .get_response(&client, "https://api.openai.com/v1/chat/completions", &RequestOptions::bearer("API_KEY"))
72//! //     .await?;
73//! ```
74//!
75//! ## GET Requests
76//!
77//! ```rust
78//! use openai_interface::rest::get::Get;
79//! use openai_interface::errors::OapiError;
80//!
81//! // GET request with URL building
82//! struct ComplexRequest {
83//!     resource_id: String,
84//!     limit: Option<u32>,
85//! }
86//!
87//! impl Get for ComplexRequest {
88//!     fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
89//!         let mut url = format!("{}/{}", base_url, self.resource_id);
90//!         if let Some(limit) = self.limit {
91//!             url.push_str(&format!("?limit={}", limit));
92//!         }
93//!         Ok(url)
94//!     }
95//! }
96//! ```
97//!
98//! # Client configuration
99//!
100//! Every request method takes the client as its first argument, so callers
101//! control proxies, timeouts and connection pooling. Use
102//! [`default_client`] for a sensible default, or build your own, e.g. with a
103//! proxy:
104//!
105//! ```rust
106//! # #[cfg(feature = "ferritls")] {
107//! // Building a client needs an installed provider: this one comes from the
108//! // `ferritls` feature (see "TLS crypto provider" below).
109//! openai_interface::rest::install_crypto_provider().ok();
110//! let client = reqwest::Client::builder()
111//!     .proxy(reqwest::Proxy::http("http://127.0.0.1:10808")?)
112//!     .timeout(std::time::Duration::from_secs(60))
113//!     .build()?;
114//! # }
115//! # Ok::<(), reqwest::Error>(())
116//! ```
117//!
118//! # TLS crypto provider
119//!
120//! This crate depends on reqwest with its `rustls-no-provider` feature, so
121//! the rustls stack is compiled **without** a crypto backend. That leaves the
122//! choice of backend to the application: exactly one
123//! [`rustls::crypto::CryptoProvider`] must be installed as the process
124//! default before any `reqwest::Client` is built, otherwise reqwest panics at
125//! construction time.
126//!
127//! Nothing in this crate installs a provider for you — neither
128//! [`default_client`] nor any request method touches the global state, so an
129//! application that picked a provider first keeps it, and an application that
130//! never builds a client through this crate is free to install its own
131//! whenever it likes.
132//!
133//! This example is the regression lock for that promise. It runs as
134//! `should_panic`, deliberately with no provider installed and no hidden
135//! `ferritls` setup: if `default_client()` ever learns to install one on the
136//! side, it stops panicking and `cargo test` fails.
137//!
138//! ```rust,should_panic
139//! # // Silence reqwest's expected panic message so `--nocapture` stays clean;
140//! # // the payload still propagates, so should_panic matches as usual.
141//! # std::panic::set_hook(Box::new(|_| {}));
142//! let _client = openai_interface::rest::default_client();
143//! ```
144//!
145//! ## With the `ferritls` feature
146//!
147//! The optional `ferritls` feature (off by default) adds the pure-Rust
148//! `ferritls-rustls` backend and the [`install_crypto_provider`] helper.
149//! Enable it when you are happy to let this crate pick a provider for you:
150//!
151//! ```toml
152//! [dependencies]
153//! openai-interface = { version = "0.10", features = ["ferritls"] }
154//! ```
155//!
156//! Then call it once at startup, before the first client:
157//!
158//! ```rust
159//! # #[cfg(feature = "ferritls")] {
160//! openai_interface::rest::install_crypto_provider()
161//!     .expect("a rustls crypto provider was already installed");
162//! let client = openai_interface::rest::default_client();
163//! # }
164//! ```
165//!
166//! ## Without it
167//!
168//! With the feature off, `ferritls-rustls` is not in the dependency tree at
169//! all and [`install_crypto_provider`] does not exist. Install a provider
170//! yourself instead — first install wins, so do it before any client is
171//! built:
172//!
173//! ```rust,ignore
174//! // In the application crate, with `rustls = "0.23"` (feature `ring` or
175//! // `aws-lc-rs`) as one of its own dependencies:
176//! rustls::crypto::ring::default_provider()
177//!     .install_default()
178//!     .expect("a rustls crypto provider was already installed");
179//! ```
180//!
181//! ## When reqwest already has a backend
182//!
183//! Because Cargo features are additive, a project that depends on `reqwest`
184//! itself with `default-tls` / `rustls` (its defaults, which fall back to the
185//! bundled `aws-lc-rs` provider) or with `native-tls` (which skips the rustls
186//! path entirely) needs no provider installed here at all. That backend is
187//! then picked by feature unification instead of by you; see the
188//! ["TLS Crypto Provider"][crate#when-nothing-needs-to-be-installed] section
189//! of the crate docs for the trade-off.
190
191pub mod delete;
192pub mod get;
193pub mod options;
194pub mod post;
195
196pub use options::{Auth, RequestOptions};
197
198use futures_util::{Stream, StreamExt};
199
200use crate::errors::{ApiError, OapiError};
201
202/// Installs the pure-Rust [`ferritls-rustls`](https://crates.io/crates/ferritls-rustls)
203/// crypto provider as the process-wide default for rustls.
204///
205/// Only compiled with the `ferritls` cargo feature, which is what puts
206/// `ferritls-rustls` in the dependency tree at all; without the feature this
207/// crate ships no crypto backend, so you either install a
208/// [`rustls::crypto::CryptoProvider`] of your own or depend on `reqwest`
209/// yourself with a backend compiled in — see
210/// [When reqwest already has a backend](self#when-reqwest-already-has-a-backend).
211/// (`doc` builds include this function anyway so its entry and the links to it
212/// exist regardless of features.)
213///
214/// This crate depends on reqwest with the `rustls-no-provider` feature, so
215/// no crypto backend is compiled in by default and building a
216/// [`reqwest::Client`] without an installed provider panics. This helper lets
217/// an application delegate that choice to the crate; it is **never called
218/// implicitly** — no function here mutates the global provider on its own.
219///
220/// First install wins: if any crate (including the application itself) got
221/// there first, that provider is kept and the `Err` variant carries it. Call
222/// this once at startup, before the first client is built.
223///
224/// # Errors
225///
226/// Never fails; the `Err` variant carries the existing provider when one is
227/// already installed.
228#[cfg(any(doc, test, feature = "ferritls"))]
229pub fn install_crypto_provider() -> Result<(), std::sync::Arc<rustls::crypto::CryptoProvider>> {
230    ferritls_rustls::default_provider().install_default()
231}
232
233/// Builds a [`reqwest::Client`] with library defaults.
234///
235/// The client has a 60-second connect timeout and a 300-second *read*
236/// timeout: the read timeout applies to each read operation and resets
237/// after every successful read, so a streaming (SSE) response may run
238/// indefinitely as long as the server keeps sending data, while a
239/// connection that stalls mid-response still errors out after 300 idle
240/// seconds. Pass your own client to any request method if you need a
241/// different configuration (proxy, timeouts, pooling, ...).
242///
243/// This function does not select a TLS backend: see the
244/// [module docs][self#tls-crypto-provider] for why, and install a provider
245/// (with [`install_crypto_provider`] under the `ferritls` feature, or one of
246/// your own) before the first client is built.
247///
248/// # Panics
249///
250/// Panics if no rustls crypto provider has been installed as the process
251/// default yet — reqwest's `rustls-no-provider` build requires one at
252/// client construction time. This mirrors the panic behavior of
253/// [`reqwest::Client::new`].
254#[must_use]
255pub fn default_client() -> reqwest::Client {
256    // Inline unit tests are compiled into the test harness (`cfg(test)`), so
257    // they get a provider without per-test boilerplate. Library users, and
258    // the doctests (which link the non-test build), must install one
259    // explicitly — see the module docs.
260    #[cfg(test)]
261    let _ = install_crypto_provider();
262
263    reqwest::Client::builder()
264        .connect_timeout(std::time::Duration::from_secs(60))
265        .read_timeout(std::time::Duration::from_secs(300))
266        .build()
267        .expect("Failed to build the default HTTP client")
268}
269
270/// Checks a response status, turning a non-2xx response into an
271/// [`OapiError::ApiError`] that carries the parsed error body.
272///
273/// For a non-2xx status, this consumes the response and attempts to parse
274/// the body as an [`ApiError`]. If the body cannot be parsed, the raw text
275/// is kept as the error message. The response is returned unchanged when
276/// the status is a success.
277pub async fn check_status(response: reqwest::Response) -> Result<reqwest::Response, OapiError> {
278    if response.status().is_success() {
279        return Ok(response);
280    }
281
282    let status = response.status().as_u16();
283    let text = response.text().await.map_err(|e| {
284        OapiError::ResponseError(format!(
285            "HTTP {status} failed, and the error body could not be read: {e}"
286        ))
287    })?;
288
289    // The official error shape nests the fields under an `error` key. It
290    // must be tried FIRST: the flat shape would also deserialize (every
291    // field has a default), silently losing the real message.
292    let mut api_error = serde_json::from_str::<crate::errors::NestedApiError>(&text)
293        .map(|nested| nested.error)
294        .or_else(|_| serde_json::from_str::<ApiError>(&text))
295        .unwrap_or(ApiError {
296            status,
297            message: text,
298            error_type: None,
299            code: None,
300        });
301
302    if api_error.message.is_empty() {
303        api_error.message = format!("HTTP {status}");
304    }
305    api_error.status = status;
306
307    Err(OapiError::ApiError(api_error))
308}
309
310/// Parses a response body as text, applying [`check_status`] first.
311pub(crate) async fn response_text_checked(
312    response: reqwest::Response,
313) -> Result<String, OapiError> {
314    let response = check_status(response).await?;
315    response
316        .text()
317        .await
318        .map_err(|e| OapiError::ResponseError(format!("Failed to get response text: {e}")))
319}
320
321/// Parses a response body as raw bytes, applying [`check_status`] first.
322pub(crate) async fn response_bytes_checked(
323    response: reqwest::Response,
324) -> Result<Vec<u8>, OapiError> {
325    let response = check_status(response).await?;
326    response
327        .bytes()
328        .await
329        .map(|bytes| bytes.to_vec())
330        .map_err(|e| OapiError::ResponseError(format!("Failed to get response bytes: {e}")))
331}
332
333/// Drops the items of a parsed stream that failed to deserialize.
334///
335/// Chat providers occasionally emit chunks your [`serde`] model cannot
336/// parse — an unknown field shape, an intermediate event, a provider quirk.
337/// By default such a chunk surfaces as [`OapiError::DeserializationError`]
338/// and a consumer propagating it with `?` abandons the rest of the stream.
339/// Wrapping the stream with this adapter filters those items out; every
340/// other error (transport failures, [`OapiError::SseParseError`], ...) is
341/// still yielded, and the stream still ends after the `data: [DONE]`
342/// sentinel.
343///
344/// # Example
345///
346/// ```rust,no_run
347/// use futures_util::StreamExt;
348/// use openai_interface::chat::create::request::{Message, RequestBody};
349/// use openai_interface::rest::{default_client, post::PostStream, RequestOptions, skip_deserialization_errors};
350///
351/// # async fn example(api_key: String) -> Result<(), Box<dyn std::error::Error>> {
352/// let request = RequestBody {
353///     messages: vec![Message::User {
354///         content: "What's your name?".into(),
355///         name: None,
356///     }],
357///     model: "deepseek-chat".to_string(),
358///     stream: Some(true),
359///     ..Default::default()
360/// };
361///
362/// let stream = request
363///     .get_stream_response(&default_client(), "https://api.deepseek.com", &RequestOptions::bearer(api_key))
364///     .await?;
365/// let mut stream = skip_deserialization_errors(stream);
366///
367/// while let Some(chunk) = stream.next().await {
368///     println!("{chunk:?}");
369/// }
370/// # Ok(())
371/// # }
372/// ```
373pub fn skip_deserialization_errors<S, T>(
374    stream: S,
375) -> impl Stream<Item = Result<T, OapiError>> + Send + Unpin
376where
377    S: Stream<Item = Result<T, OapiError>> + Send + Unpin,
378    T: Send,
379{
380    stream.filter(|result| {
381        std::future::ready(!matches!(result, Err(OapiError::DeserializationError(_))))
382    })
383}