openai-interface 0.14.0

A low-level Rust interface for the OpenAI API
Documentation
//! REST API client module for OpenAI interface
//!
//! This module provides the core HTTP functionality for making requests to OpenAI-compatible APIs.
//! It includes traits and implementations for both streaming and non-streaming API calls.
//!
//! # Overview
//!
//! The `rest` module contains:
//! - [`post`]: HTTP POST request functionality with streaming and non-streaming support
//! - [`get`]: HTTP GET request functionality with various parameter handling options
//! - [`delete`]: HTTP DELETE request functionality
//! - [`options`]: Per-request authentication ([`RequestOptions`], [`Auth`])
//!   taken by every request method
//! - [`default_client`]: A `reqwest::Client` constructor shared by all request traits
//! - [`check_status`]: Shared non-2xx response handling which parses the error body
//! - [`skip_deserialization_errors`]: A stream adapter that drops chunks
//!   which fail to deserialize
//!
//! # Usage
//!
//! The module is designed to be used through the higher-level API modules (`chat`, `completions`,
//! etc.). However, you can use the traits directly if needed:
//!
//! ## POST Requests
//!
//! ```rust
//! use openai_interface::rest::{post::{Post, PostNoStream}, RequestOptions};
//! use openai_interface::errors::OapiError;
//! use serde::{Serialize, Deserialize};
//!
//! use std::str::FromStr;
//!
//! #[derive(Serialize)]
//! struct MyRequest {
//!     prompt: String,
//!     stream: bool,
//! }
//!
//! #[derive(Deserialize)]
//! struct MyResponse {
//!     // Define the fields of your response here
//!     id: String,
//! }
//!
//! impl FromStr for MyResponse {
//!     type Err = OapiError;
//!
//!     fn from_str(content: &str) -> Result<Self, Self::Err> {
//!         let parse_result: Result<Self, _> = serde_json::from_str(content)
//!             .map_err(|e| OapiError::DeserializationError(e.to_string()));
//!         parse_result
//!     }
//! }
//!
//! impl Post for MyRequest {
//!     fn is_streaming(&self) -> bool {
//!         self.stream
//!     }
//!     fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
//!         Ok(format!("{}/service", base_url))
//!     }
//! }
//!
//! impl PostNoStream for MyRequest {
//!     type Response = MyResponse;
//! }
//!
//! // Send it with a client:
//! // let client = openai_interface::rest::default_client();
//! // let response: MyResponse = request
//! //     .get_response(&client, "https://api.openai.com/v1/chat/completions", &RequestOptions::bearer("API_KEY"))
//! //     .await?;
//! ```
//!
//! ## GET Requests
//!
//! ```rust
//! use openai_interface::rest::get::Get;
//! use openai_interface::errors::OapiError;
//!
//! // GET request with URL building
//! struct ComplexRequest {
//!     resource_id: String,
//!     limit: Option<u32>,
//! }
//!
//! impl Get for ComplexRequest {
//!     fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
//!         let mut url = format!("{}/{}", base_url, self.resource_id);
//!         if let Some(limit) = self.limit {
//!             url.push_str(&format!("?limit={}", limit));
//!         }
//!         Ok(url)
//!     }
//! }
//! ```
//!
//! # Client configuration
//!
//! Every request method takes the client as its first argument, so callers
//! control proxies, timeouts and connection pooling. Use
//! [`default_client`] for a sensible default, or build your own, e.g. with a
//! proxy:
//!
//! ```rust
//! # #[cfg(feature = "ferritls")] {
//! // Building a client needs an installed provider: this one comes from the
//! // `ferritls` feature (see "TLS crypto provider" below).
//! openai_interface::rest::install_crypto_provider().ok();
//! let client = reqwest::Client::builder()
//!     .proxy(reqwest::Proxy::http("http://127.0.0.1:10808")?)
//!     .timeout(std::time::Duration::from_secs(60))
//!     .build()?;
//! # }
//! # Ok::<(), reqwest::Error>(())
//! ```
//!
//! # TLS crypto provider
//!
//! This crate depends on reqwest with its `rustls-no-provider` feature, so
//! the rustls stack is compiled **without** a crypto backend. That leaves the
//! choice of backend to the application: exactly one
//! [`rustls::crypto::CryptoProvider`] must be installed as the process
//! default before any `reqwest::Client` is built, otherwise reqwest panics at
//! construction time.
//!
//! Nothing in this crate installs a provider for you — neither
//! [`default_client`] nor any request method touches the global state, so an
//! application that picked a provider first keeps it, and an application that
//! never builds a client through this crate is free to install its own
//! whenever it likes.
//!
//! This example is the regression lock for that promise. It runs as
//! `should_panic`, deliberately with no provider installed and no hidden
//! `ferritls` setup: if `default_client()` ever learns to install one on the
//! side, it stops panicking and `cargo test` fails.
//!
//! ```rust,should_panic
//! # // Silence reqwest's expected panic message so `--nocapture` stays clean;
//! # // the payload still propagates, so should_panic matches as usual.
//! # std::panic::set_hook(Box::new(|_| {}));
//! let _client = openai_interface::rest::default_client();
//! ```
//!
//! ## With the `ferritls` feature
//!
//! The optional `ferritls` feature (off by default) adds the pure-Rust
//! `ferritls-rustls` backend and the [`install_crypto_provider`] helper.
//! Enable it when you are happy to let this crate pick a provider for you:
//!
//! ```toml
//! [dependencies]
//! openai-interface = { version = "0.10", features = ["ferritls"] }
//! ```
//!
//! Then call it once at startup, before the first client:
//!
//! ```rust
//! # #[cfg(feature = "ferritls")] {
//! openai_interface::rest::install_crypto_provider()
//!     .expect("a rustls crypto provider was already installed");
//! let client = openai_interface::rest::default_client();
//! # }
//! ```
//!
//! ## Without it
//!
//! With the feature off, `ferritls-rustls` is not in the dependency tree at
//! all and [`install_crypto_provider`] does not exist. Install a provider
//! yourself instead — first install wins, so do it before any client is
//! built:
//!
//! ```rust,ignore
//! // In the application crate, with `rustls = "0.23"` (feature `ring` or
//! // `aws-lc-rs`) as one of its own dependencies:
//! rustls::crypto::ring::default_provider()
//!     .install_default()
//!     .expect("a rustls crypto provider was already installed");
//! ```
//!
//! ## When reqwest already has a backend
//!
//! Because Cargo features are additive, a project that depends on `reqwest`
//! itself with `default-tls` / `rustls` (its defaults, which fall back to the
//! bundled `aws-lc-rs` provider) or with `native-tls` (which skips the rustls
//! path entirely) needs no provider installed here at all. That backend is
//! then picked by feature unification instead of by you; see the
//! ["TLS Crypto Provider"][crate#when-nothing-needs-to-be-installed] section
//! of the crate docs for the trade-off.

pub mod delete;
pub mod get;
pub mod options;
pub mod post;

pub use options::{Auth, RequestOptions};

use futures_util::{Stream, StreamExt};

use crate::errors::{ApiError, OapiError};

/// Installs the pure-Rust [`ferritls-rustls`](https://crates.io/crates/ferritls-rustls)
/// crypto provider as the process-wide default for rustls.
///
/// Only compiled with the `ferritls` cargo feature, which is what puts
/// `ferritls-rustls` in the dependency tree at all; without the feature this
/// crate ships no crypto backend, so you either install a
/// [`rustls::crypto::CryptoProvider`] of your own or depend on `reqwest`
/// yourself with a backend compiled in — see
/// [When reqwest already has a backend](self#when-reqwest-already-has-a-backend).
/// (`doc` builds include this function anyway so its entry and the links to it
/// exist regardless of features.)
///
/// This crate depends on reqwest with the `rustls-no-provider` feature, so
/// no crypto backend is compiled in by default and building a
/// [`reqwest::Client`] without an installed provider panics. This helper lets
/// an application delegate that choice to the crate; it is **never called
/// implicitly** — no function here mutates the global provider on its own.
///
/// First install wins: if any crate (including the application itself) got
/// there first, that provider is kept and the `Err` variant carries it. Call
/// this once at startup, before the first client is built.
///
/// # Errors
///
/// Never fails; the `Err` variant carries the existing provider when one is
/// already installed.
#[cfg(any(doc, test, feature = "ferritls"))]
pub fn install_crypto_provider() -> Result<(), std::sync::Arc<rustls::crypto::CryptoProvider>> {
    ferritls_rustls::default_provider().install_default()
}

/// Builds a [`reqwest::Client`] with library defaults.
///
/// The client has a 60-second connect timeout and a 300-second *read*
/// timeout: the read timeout applies to each read operation and resets
/// after every successful read, so a streaming (SSE) response may run
/// indefinitely as long as the server keeps sending data, while a
/// connection that stalls mid-response still errors out after 300 idle
/// seconds. Pass your own client to any request method if you need a
/// different configuration (proxy, timeouts, pooling, ...).
///
/// This function does not select a TLS backend: see the
/// [module docs][self#tls-crypto-provider] for why, and install a provider
/// (with [`install_crypto_provider`] under the `ferritls` feature, or one of
/// your own) before the first client is built.
///
/// # Panics
///
/// Panics if no rustls crypto provider has been installed as the process
/// default yet — reqwest's `rustls-no-provider` build requires one at
/// client construction time. This mirrors the panic behavior of
/// [`reqwest::Client::new`].
#[must_use]
pub fn default_client() -> reqwest::Client {
    // Inline unit tests are compiled into the test harness (`cfg(test)`), so
    // they get a provider without per-test boilerplate. Library users, and
    // the doctests (which link the non-test build), must install one
    // explicitly — see the module docs.
    #[cfg(test)]
    let _ = install_crypto_provider();

    reqwest::Client::builder()
        .connect_timeout(std::time::Duration::from_secs(60))
        .read_timeout(std::time::Duration::from_secs(300))
        .build()
        .expect("Failed to build the default HTTP client")
}

/// Checks a response status, turning a non-2xx response into an
/// [`OapiError::ApiError`] that carries the parsed error body.
///
/// For a non-2xx status, this consumes the response and attempts to parse
/// the body as an [`ApiError`]. If the body cannot be parsed, the raw text
/// is kept as the error message. The response is returned unchanged when
/// the status is a success.
pub async fn check_status(response: reqwest::Response) -> Result<reqwest::Response, OapiError> {
    if response.status().is_success() {
        return Ok(response);
    }

    let status = response.status().as_u16();
    let text = response.text().await.map_err(|e| {
        OapiError::ResponseError(format!(
            "HTTP {status} failed, and the error body could not be read: {e}"
        ))
    })?;

    // The official error shape nests the fields under an `error` key. It
    // must be tried FIRST: the flat shape would also deserialize (every
    // field has a default), silently losing the real message.
    let mut api_error = serde_json::from_str::<crate::errors::NestedApiError>(&text)
        .map(|nested| nested.error)
        .or_else(|_| serde_json::from_str::<ApiError>(&text))
        .unwrap_or(ApiError {
            status,
            message: text,
            error_type: None,
            code: None,
        });

    if api_error.message.is_empty() {
        api_error.message = format!("HTTP {status}");
    }
    api_error.status = status;

    Err(OapiError::ApiError(api_error))
}

/// Parses a response body as text, applying [`check_status`] first.
pub(crate) async fn response_text_checked(
    response: reqwest::Response,
) -> Result<String, OapiError> {
    let response = check_status(response).await?;
    response
        .text()
        .await
        .map_err(|e| OapiError::ResponseError(format!("Failed to get response text: {e}")))
}

/// Parses a response body as raw bytes, applying [`check_status`] first.
pub(crate) async fn response_bytes_checked(
    response: reqwest::Response,
) -> Result<Vec<u8>, OapiError> {
    let response = check_status(response).await?;
    response
        .bytes()
        .await
        .map(|bytes| bytes.to_vec())
        .map_err(|e| OapiError::ResponseError(format!("Failed to get response bytes: {e}")))
}

/// Drops the items of a parsed stream that failed to deserialize.
///
/// Chat providers occasionally emit chunks your [`serde`] model cannot
/// parse — an unknown field shape, an intermediate event, a provider quirk.
/// By default such a chunk surfaces as [`OapiError::DeserializationError`]
/// and a consumer propagating it with `?` abandons the rest of the stream.
/// Wrapping the stream with this adapter filters those items out; every
/// other error (transport failures, [`OapiError::SseParseError`], ...) is
/// still yielded, and the stream still ends after the `data: [DONE]`
/// sentinel.
///
/// # Example
///
/// ```rust,no_run
/// use futures_util::StreamExt;
/// use openai_interface::chat::create::request::{Message, RequestBody};
/// use openai_interface::rest::{default_client, post::PostStream, RequestOptions, skip_deserialization_errors};
///
/// # async fn example(api_key: String) -> Result<(), Box<dyn std::error::Error>> {
/// let request = RequestBody {
///     messages: vec![Message::user("What's your name?")],
///     model: "deepseek-chat".to_string(),
///     stream: Some(true),
///     ..Default::default()
/// };
///
/// let stream = request
///     .get_stream_response(&default_client(), "https://api.deepseek.com", &RequestOptions::bearer(api_key))
///     .await?;
/// let mut stream = skip_deserialization_errors(stream);
///
/// while let Some(chunk) = stream.next().await {
///     println!("{chunk:?}");
/// }
/// # Ok(())
/// # }
/// ```
pub fn skip_deserialization_errors<S, T>(
    stream: S,
) -> impl Stream<Item = Result<T, OapiError>> + Send + Unpin
where
    S: Stream<Item = Result<T, OapiError>> + Send + Unpin,
    T: Send,
{
    stream.filter(|result| {
        std::future::ready(!matches!(result, Err(OapiError::DeserializationError(_))))
    })
}