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}