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}