Skip to main content

openai_interface/
lib.rs

1//! A low-level Rust interface for interacting with OpenAI's API.
2//!
3//! This crate provides a simple, efficient, and low-level way to interact with OpenAI's API,
4//! supporting both streaming and non-streaming responses. It leverages Rust's powerful type
5//! system for safety and performance, while exposing the full flexibility of the API.
6//!
7//! # Features
8//!
9//! - **Chat Completions**: Full support for OpenAI's chat completion and completion API,
10//!   including both streaming and non-streaming responses, and multimodal user
11//!   messages (text / image / audio / file content parts).
12//! - **Responses**: Create model responses with the Responses API (string or
13//!   item-based input, function tools, built-in web search, streaming events),
14//!   retrieve and delete stored responses. Tested against DeepSeek and Qwen.
15//! - **Models**: List, retrieve and delete models.
16//! - **Embeddings**: Create embedding vectors from text input.
17//! - **Moderations**: Classify whether text and/or image input is potentially
18//!   harmful (untested).
19//! - **Images**: Generate, edit, and create variations of images (untested).
20//! - **Audio**: Text-to-speech, transcription, and translation endpoints (untested).
21//! - **Files**: Support for the OpenAI file API (upload, list, retrieve, delete,
22//!   download content).
23//! - **Streaming and Non-streaming**: Support for both streaming and non-streaming responses.
24//! - **Strong Typing**: Complete type definitions for all API requests and responses,
25//!   utilizing Rust's powerful type system.
26//! - **Configurable HTTP Client**: Every request method takes a [`reqwest::Client`], so
27//!   proxies, timeouts and connection pooling are under your control. See
28//!   [`rest::default_client`] for a sensible default, and
29//!   [`rest::install_crypto_provider`] to pick the TLS backend.
30//! - **Error Handling**: Comprehensive error handling with detailed error types defined in
31//!   the [`errors`] module. Failed requests carry the API's error message, type and code.
32//! - **Async/Await**: Built with async/await support.
33//! - **Musl Support**: Designed to work with musl libc out-of-the-box; TLS is
34//!   pure Rust, so no OpenSSL or C toolchain is needed.
35//! - **Multiple Provider Support**: Expected to work with OpenAI, DeepSeek, Qwen, and other
36//!   compatible API providers.
37//!
38//! ## Cargo Features
39//!
40//! Fields that are proprietary to a single provider are opt-in via cargo
41//! features. Cross-vendor de-facto standards — such as `reasoning_content`
42//! (streamed by DeepSeek, Qwen3, ollama, vLLM and OpenRouter alike) and
43//! `reasoning_effort` — are always available:
44//!
45//! - **`reasoning`** (default): cross-vendor reasoning fields —
46//!   `reasoning_content` on assistant messages (request and response),
47//!   streamed deltas, and logprobs, plus its accumulation in
48//!   [`chat::create::accumulator::ChatCompletionAccumulator`].
49//!
50//! - **`deepseek`**: enables DeepSeek's proprietary fields — the Beta chat
51//!   prefix completion fields (`prefix`, and `reasoning_content` as the
52//!   prefix-completion CoT input), the `thinking` and `user_id` request
53//!   parameters, and the `prompt_cache_hit_tokens` /
54//!   `prompt_cache_miss_tokens` usage statistics. Implies `reasoning`. See
55//!   [api-docs.deepseek.com](https://api-docs.deepseek.com/).
56//!
57//! - **`qwen`**: Enables Qwen's proprietary fields — the chat request
58//!   parameters `enable_thinking`, `thinking_budget` and `top_k`, the
59//!   Responses API input part `input_file`, the built-in tools
60//!   (`web_extractor`, `code_interpreter`, `web_search_image`,
61//!   `image_search`, `file_search`, `mcp`), the corresponding output items
62//!   and streaming events, and the `x_details` / `x_tools` usage
63//!   statistics. Implies `reasoning`. See
64//!   [the Qwen OpenAI-compatible Chat API docs](https://www.alibabacloud.com/help/zh/model-studio/qwen-api-via-openai-chat-completions)
65//!   and
66//!   [the Qwen Responses API docs](https://www.alibabacloud.com/help/zh/model-studio/qwen-api-via-openai-responses).
67//!
68//! - **`azure`**: deprecated no-op. Streaming `delta.annotations` and
69//!   `delta.audio` are now always available (the non-streaming message
70//!   fields were never gated). The empty feature remains defined so
71//!   existing manifests keep compiling.
72//!
73//! There is one feature unrelated to request fields:
74//!
75//! - **`ferritls`**: Adds the pure-Rust `ferritls-rustls` TLS crypto backend
76//!   and the [`rest::install_crypto_provider`] helper that installs it. Off by
77//!   default, so the crate never dictates your crypto backend; when you leave
78//!   it off, install a [`rustls::crypto::CryptoProvider`] yourself before
79//!   building any client. See ["TLS Crypto Provider"](#tls-crypto-provider).
80//!
81//! ## Implemented APIs
82//!
83//! - Chat Completions (create / retrieve / update / delete)
84//! - Responses (create / retrieve / delete)
85//! - Completions
86//! - Models (list / retrieve / delete)
87//! - Embeddings
88//! - Moderations (untested)
89//! - Images (generate / edit / variation, untested)
90//! - Audio (speech / transcriptions / translations, untested)
91//! - Files (create / list / retrieve / delete / download content)
92//!
93//! # TLS Crypto Provider
94//!
95//! HTTP is done by `reqwest`, depended on with its `rustls-no-provider`
96//! feature: the rustls stack is compiled **without** a crypto backend, which
97//! keeps the pure-Rust build (no C or asm toolchain needed) and leaves the
98//! backend choice to the application. Consequently, exactly one
99//! [`rustls::crypto::CryptoProvider`] must be installed as the process default
100//! before any [`reqwest::Client`] is built — including the one returned by
101//! [`rest::default_client`]. If none is installed, reqwest panics at client
102//! construction time.
103//!
104//! This crate never installs a provider on your behalf. The optional
105//! **`ferritls`** cargo feature adds the pure-Rust `ferritls-rustls` backend
106//! together with [`rest::install_crypto_provider`], so you can delegate that
107//! one decision to the crate:
108//!
109//! ```toml
110//! [dependencies]
111//! openai-interface = { version = "0.12", features = ["ferritls"] }
112//! ```
113//!
114//! ```rust,no_run
115//! # #[cfg(feature = "ferritls")] {
116//! // Choose the backend once, before building any client:
117//! openai_interface::rest::install_crypto_provider()
118//!     .expect("a rustls crypto provider was already installed");
119//! # }
120//! ```
121//!
122//! To use a different backend (`ring`, `aws-lc-rs`, or a hand-picked
123//! [`rustls::crypto::CryptoProvider`]), leave the feature off and install it
124//! yourself — first install wins, so whichever provider is in place when the
125//! first client is built is the one everything in the process uses:
126//!
127//! ```rust,ignore
128//! // In the application crate, with `rustls = "0.23"` (feature `ring` or
129//! // `aws-lc-rs`) as one of its own dependencies:
130//! rustls::crypto::ring::default_provider()
131//!     .install_default()
132//!     .expect("a rustls crypto provider was already installed");
133//! ```
134//!
135//! ## When nothing needs to be installed
136//!
137//! Cargo features are additive across the dependency tree, so if your project
138//! depends on `reqwest` itself with a crypto backend compiled in — its default
139//! `default-tls`, or `rustls` explicitly — reqwest falls back to the
140//! `aws-lc-rs` provider it ships with, and no install step is needed at all.
141//! Enabling `native-tls` instead routes TLS through the system stack, so the
142//! rustls path is never taken.
143//!
144//! ```toml
145//! [dependencies]
146//! reqwest = "0.13"          # default features: `default-tls` -> `rustls`
147//! openai-interface = "0.12" # no provider of its own
148//! ```
149//!
150//! The catch is that the backend is then decided by feature unification rather
151//! than by you, and an unrelated dependency change can move it. To pin the
152//! choice, enable the `ferritls` feature or install a provider yourself.
153//!
154//! # Examples
155//!
156//! ## Non-streaming Chat Completion
157//!
158//! This example demonstrates how to make a non-streaming request to the chat completion API.
159//!
160//! ```rust,no_run
161//! use openai_interface::chat::create::request::{Message, RequestBody};
162//! use openai_interface::chat::create::response::no_streaming::ChatCompletion;
163//! use openai_interface::rest::{RequestOptions, default_client, post::PostNoStream};
164//!
165//! const DEEPSEEK_CHAT_URL: &'static str = "https://api.deepseek.com";
166//! const DEEPSEEK_MODEL: &'static str = "deepseek-v4-flash";
167//!
168//! #[tokio::main]
169//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
170//!     // Needs the `ferritls` cargo feature; leave it out if you install
171//!     // your own rustls crypto provider. See the "TLS Crypto Provider"
172//!     // section above.
173//!     # #[cfg(feature = "ferritls")]
174//!     openai_interface::rest::install_crypto_provider().ok();
175//!
176//!     let request = RequestBody {
177//!         messages: vec![
178//!             Message::system("You are a helpful assistant."),
179//!             Message::user("Hello, how are you?"),
180//!         ],
181//!         model: DEEPSEEK_MODEL.to_string(),
182//!         stream: Some(false),
183//!         ..Default::default()
184//!     };
185//!
186//!     // Send the request
187//!     let chat_completion: ChatCompletion = request
188//!         .get_response(&default_client(), DEEPSEEK_CHAT_URL, &RequestOptions::bearer("YOUR_API_KEY"))
189//!         .await?;
190//!     let text = chat_completion.choices[0]
191//!         .message
192//!         .content
193//!         .as_deref()
194//!         .unwrap();
195//!     println!("{:?}", text);
196//!     Ok(())
197//! }
198//! ```
199//!
200//! ## Streaming Chat Completion
201//!
202//! This example demonstrates how to handle streaming responses from the API. As with the non-streaming
203//! example, all API parameters can be adjusted directly through the request struct.
204//!
205//! ```rust,no_run
206//! use openai_interface::chat::create::request::{Message, RequestBody};
207//! use openai_interface::chat::create::response::streaming::ChatCompletionChunk;
208//! use openai_interface::rest::{RequestOptions, default_client, post::PostStream};
209//! use futures_util::StreamExt;
210//!
211//! const DEEPSEEK_CHAT_URL: &'static str = "https://api.deepseek.com";
212//! const DEEPSEEK_MODEL: &'static str = "deepseek-v4-flash";
213//!
214//! #[tokio::main]
215//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
216//!     let request = RequestBody {
217//!         messages: vec![
218//!             Message::system("You are a helpful assistant."),
219//!             Message::user("Who are you?"),
220//!         ],
221//!         model: DEEPSEEK_MODEL.to_string(),
222//!         stream: Some(true),
223//!         ..Default::default()
224//!     };
225//!
226//!     // Send the request
227//!     let mut response_stream = request
228//!         .get_stream_response(&default_client(), DEEPSEEK_CHAT_URL, &RequestOptions::bearer("YOUR_API_KEY"))
229//!         .await?;
230//!
231//!     let mut message = String::new();
232//!
233//!     while let Some(chunk_result) = response_stream.next().await {
234//!         let chunk: ChatCompletionChunk = chunk_result?;
235//!         if let Some(content) = chunk.choices[0].delta.content.as_deref() {
236//!             println!("content chunk: {}", content);
237//!             message.push_str(content);
238//!         }
239//!     }
240//!
241//!     println!("complete message: {}", message);
242//!     Ok(())
243//! }
244//! ```
245//!
246//! # Musl Build
247//!
248//! This crate is designed to work with musl libc, making it suitable for
249//! lightweight deployments in containerized environments. TLS is provided by
250//! rustls with a pure-Rust crypto backend, so OpenSSL does not need to be
251//! built from source. See [`rest::install_crypto_provider`] for how the
252//! backend is selected at runtime.
253//!
254//! To build for musl:
255//! ```bash
256//! rustup target add x86_64-unknown-linux-musl
257//! cargo build --target x86_64-unknown-linux-musl
258//! ```
259
260/// Implements `FromStr` for JSON response types by deserializing them with
261/// `serde_json`, mapping any parse failure to
262/// [`OapiError::DeserializationError`](crate::errors::OapiError::DeserializationError).
263macro_rules! impl_from_str {
264    ($($target:ty),* $(,)?) => {
265        $(
266            impl std::str::FromStr for $target {
267                type Err = crate::errors::OapiError;
268
269                fn from_str(content: &str) -> Result<Self, Self::Err> {
270                    serde_json::from_str(content).map_err(|e| {
271                        crate::errors::OapiError::DeserializationError(e.to_string())
272                    })
273                }
274            }
275        )*
276    };
277}
278
279pub(crate) use impl_from_str;
280
281/// Defines a wire-fidelity string enum: a closed set of unit variants with
282/// explicit wire names, plus an `Unknown(String)` catch-all.
283///
284/// Upstream gateways routinely invent values the official API never
285/// documented (new finish reasons, roles, service tiers). A closed enum
286/// turns any of them into a hard deserialization error that kills the whole
287/// chunk; the `Unknown(String)` variant keeps the chunk alive and preserves
288/// the original string, so proxies can serialize it back out unchanged.
289///
290/// Also derives `Clone`, `PartialEq`, `Eq`, `Hash`, and implements
291/// `AsRef<str>` / `Display` (the wire representation, original string for
292/// `Unknown`).
293macro_rules! wire_string_enum {
294    (
295        $(#[$meta:meta])*
296        $vis:vis enum $name:ident {
297            $($(#[$vmeta:meta])* $variant:ident => $wire:literal),* $(,)?
298        }
299    ) => {
300        $(#[$meta])*
301        #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
302        $vis enum $name {
303            $(
304                $(#[$vmeta])*
305                #[serde(rename = $wire)]
306                $variant,
307            )*
308            /// Any other value emitted by the backend, preserved verbatim.
309            #[serde(untagged)]
310            Unknown(String),
311        }
312
313        impl $name {
314            /// The wire representation of this value.
315            #[must_use]
316            pub fn as_str(&self) -> &str {
317                match self {
318                    $(Self::$variant => $wire,)*
319                    Self::Unknown(raw) => raw,
320                }
321            }
322        }
323
324        impl AsRef<str> for $name {
325            fn as_ref(&self) -> &str {
326                self.as_str()
327            }
328        }
329
330        impl std::fmt::Display for $name {
331            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332                f.write_str(self.as_str())
333            }
334        }
335    };
336}
337
338pub(crate) use wire_string_enum;
339
340/// Defines a top-level JSON request-body struct with the standard
341/// [`extra_body_map`](struct@Self::extra_body_map) catch-all field appended.
342///
343/// The flattened `Option<serde_json::Map<String, serde_json::Value>>` field
344/// merges unknown keys into the serialized JSON on the way out and captures
345/// unknown keys on the way in (so proxies can forward fields this crate
346/// does not model losslessly). Two forms are supported, with and without a
347/// lifetime parameter.
348macro_rules! request_body {
349    (
350        $(#[$meta:meta])*
351        $vis:vis struct $name:ident {
352            $($(#[$fmeta:meta])* $fvis:vis $field:ident : $ftype:ty),* $(,)?
353        }
354    ) => {
355        $(#[$meta])*
356        $vis struct $name {
357            $($(#[$fmeta])* $fvis $field: $ftype,)*
358            /// Additional JSON properties flattened into the request body,
359            /// for fields not covered by the typed struct.
360            #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
361            pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
362        }
363    };
364    (
365        $(#[$meta:meta])*
366        $vis:vis struct $name:ident<$lt:lifetime> {
367            $($(#[$fmeta:meta])* $fvis:vis $field:ident : $ftype:ty),* $(,)?
368        }
369    ) => {
370        $(#[$meta])*
371        $vis struct $name<$lt> {
372            $($(#[$fmeta])* $fvis $field: $ftype,)*
373            /// Additional JSON properties flattened into the request body,
374            /// for fields not covered by the typed struct.
375            #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
376            pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
377        }
378    };
379}
380
381pub(crate) use request_body;
382
383// The two most-referenced types, available without the module path
384// (`openai_interface::errors::OapiError` also works).
385pub use errors::{ApiError, OapiError};
386
387pub mod audio;
388pub mod batches;
389pub mod chat;
390pub mod completions;
391pub mod containers;
392pub mod conversations;
393pub mod embeddings;
394pub mod errors;
395pub mod evals;
396pub mod files;
397pub mod fine_tuning;
398pub mod images;
399pub mod models;
400pub mod moderations;
401pub mod pagination;
402pub mod realtime;
403pub mod responses;
404pub mod rest;
405pub mod uploads;
406pub mod vector_stores;
407
408#[cfg(test)]
409mod tests {
410    use crate::chat::create::request::{Message, RequestBody};
411    use crate::chat::create::response::streaming::ChatCompletionChunk;
412    use crate::rest::{
413        RequestOptions, default_client,
414        post::{PostNoStream, PostStream},
415    };
416    use futures_util::StreamExt;
417
418    const DEEPSEEK_CHAT_URL: &str = "https://api.deepseek.com";
419    const DEEPSEEK_MODEL: &str = "deepseek-v4-flash";
420
421    fn deepseek_api_key() -> Option<String> {
422        std::env::var("DEEPSEEK_API_KEY")
423            .ok()
424            .map(|key| key.trim().to_string())
425            .filter(|key| !key.is_empty())
426    }
427
428    #[tokio::test]
429    async fn test_no_streaming() -> Result<(), Box<dyn std::error::Error>> {
430        let Some(api_key) = deepseek_api_key() else {
431            println!("Skipping: set DEEPSEEK_API_KEY to run this test");
432            return Ok(());
433        };
434
435        let request = RequestBody {
436            messages: vec![
437                Message::system("You are a helpful assistant."),
438                Message::user("Hello, how are you?"),
439            ],
440            model: DEEPSEEK_MODEL.to_string(),
441            stream: Some(false),
442            ..Default::default()
443        };
444
445        // Send the request
446        let chat_completion: crate::chat::create::response::no_streaming::ChatCompletion = request
447            .get_response(
448                &default_client(),
449                DEEPSEEK_CHAT_URL,
450                &RequestOptions::bearer(&api_key),
451            )
452            .await?;
453        let text = chat_completion.choices[0]
454            .message
455            .content
456            .as_deref()
457            .unwrap();
458        println!("lib::test_no_streaming message: {}", text);
459        Ok(())
460    }
461
462    #[tokio::test]
463    async fn test_streaming() -> Result<(), Box<dyn std::error::Error>> {
464        let Some(api_key) = deepseek_api_key() else {
465            println!("Skipping: set DEEPSEEK_API_KEY to run this test");
466            return Ok(());
467        };
468
469        let request = RequestBody {
470            messages: vec![
471                Message::system("You are a helpful assistant."),
472                Message::user("Who are you?"),
473            ],
474            model: DEEPSEEK_MODEL.to_string(),
475            stream: Some(true),
476            ..Default::default()
477        };
478
479        // Send the request
480        let mut response_stream = request
481            .get_stream_response(
482                &default_client(),
483                DEEPSEEK_CHAT_URL,
484                &RequestOptions::bearer(&api_key),
485            )
486            .await?;
487
488        let mut message = String::new();
489
490        while let Some(chunk_result) = response_stream.next().await {
491            let chunk: ChatCompletionChunk = chunk_result?;
492            if let Some(content) = chunk.choices[0].delta.content.as_deref() {
493                println!("lib::test_streaming message: {}", content);
494                message.push_str(content);
495            }
496        }
497
498        println!("lib::test_streaming message: {}", message);
499        Ok(())
500    }
501}