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