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