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}