The async-openai crate rust ecosystem gives you a typed, async-native client for the OpenAI REST surface without handing your code over to a tangle of raw reqwest calls. This guide builds a runnable example from zero: a chat completion, a streamed response, retry logic, and a pointer at an OpenAI-compatible gateway. You should walk away with code you can copy into a service and a clear picture of where the sharp edges are.
Prerequisites
- Rust toolchain (rustc 1.75+, current stable is fine)
cargoon PATH- A valid OpenAI API key, or a credential for any OpenAI-compatible endpoint
- Working knowledge of
tokioandasync/await
If you have not used tokio before, stick to the #[tokio::main] macro and treat the client as borrow-safe across awaits.
Project setup
Create the crate and pull dependencies:
cargo new aoai_demo && cd aoai_demo
cargo add async-openai tokio --features tokio/full
cargo add futures serde_json
Your Cargo.toml should contain:
[dependencies]
async-openai = "0.20"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
serde_json = "1"
The async-openai crate rust API is versioned against OpenAI’s spec; pin a minor version in production to avoid surprise breaking changes when the spec shifts.
First synchronous chat call
Replace src/main.rs with a minimal but explicit call:
use async_openai::{
Client,
config::OpenAIConfig,
types::{ChatCompletionRequestMessageArgs, CreateChatCompletionRequestArgs, Role},
};
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = env::var("OPENAI_API_KEY")?;
let config = OpenAIConfig::new().with_api_key(api_key);
let client = Client::with_config(config);
let msg = ChatCompletionRequestMessageArgs::default()
.role(Role::User)
.content("What is the 10th prime number? Answer in one sentence.")
.build()?;
let req = CreateChatCompletionRequestArgs::default()
.model("gpt-4o-mini")
.messages(vec![msg])
.build()?;
let resp = client.chat().create(req).await?;
if let Some(choice) = resp.choices.first() {
println!("{}", choice.message.content.as_deref().unwrap_or("<empty>"));
}
Ok(())
}
Run it:
export OPENAI_API_KEY=sk-...
cargo run --quiet
Expected output:
The 10th prime number is 29.
The types are verbose because the crate models the full request union. That verbosity pays off when you later add response_format or tools without guessing field names.
Streaming tokens
For interactive UX, stream the completion. The async-openai crate rust client exposes create_stream, which returns a futures::Stream of ChatCompletionStreamResponse.
use futures::StreamExt;
let req = CreateChatCompletionRequestArgs::default()
.model("gpt-4o-mini")
.messages(vec![ChatCompletionRequestMessageArgs::default()
.role(Role::User)
.content("Count to 5 slowly, one word per token.")
.build()?])
.build()?;
let mut stream = client.chat().create_stream(req).await?;
while let Some(chunk) = stream.next().await {
match chunk {
Ok(c) => {
for choice in c.choices {
if let Some(delta) = choice.delta.content {
print!("{delta}");
}
}
}
Err(e) => eprintln!("stream error: {e}"),
}
}
println!();
Expected behavior: tokens print incrementally, then a newline. If you see a single flush at the end, your model or proxy buffered the stream—OpenAI-compatible gateways sometimes do this under load.
Targeting an OpenAI-compatible gateway
The client is not hardcoded to OpenAI’s domain. You can repoint it by setting api_base in the config. For example, n4n.ai provides a single OpenAI-compatible endpoint that fronts 240+ models and applies automatic fallback when a provider is rate-limited; the same async-openai crate rust code works by swapping the base URL and model string.
let config = OpenAIConfig::new()
.with_api_key(env::var("N4N_API_KEY")?)
.with_api_base("https://api.n4n.ai/v1");
let client = Client::with_config(config);
let req = CreateChatCompletionRequestArgs::default()
.model("anthropic/claude-3.5-sonnet")
.messages(vec![ChatCompletionRequestMessageArgs::default()
.role(Role::User)
.content("Ping. Reply with 'pong'.")
.build()?])
.build()?;
The gateway forwards provider cache-control hints and meters per token; your client code does not change beyond the model identifier and base URL. This is the correct way to avoid vendor lock-in without writing a new HTTP layer.
Retries with backoff
Transient 429/5xx errors are normal in production. Wrap the call:
use async_openai::error::OpenAIError;
use std::time::Duration;
use tokio::time::sleep;
async fn create_with_retry(
client: &Client,
req: CreateChatCompletionRequestArgs,
max_tries: u32,
) -> Result<async_openai::types::CreateChatCompletionResponse, OpenAIError> {
let mut attempt = 0;
loop {
match client.chat().create(req.build()?).await {
Ok(r) => return Ok(r),
Err(e) => {
attempt += 1;
if attempt >= max_tries {
return Err(e);
}
sleep(Duration::from_secs(2u64.pow(attempt))).await;
}
}
}
}
Keep retries at the boundary, not inside the crate. The client is stateless per call; pass a fresh req build each loop because build() consumes the args builder.
Structured JSON output
If the model supports JSON mode, enforce it:
use async_openai::types::{ResponseFormat, ResponseFormatType};
let req = CreateChatCompletionRequestArgs::default()
.model("gpt-4o-mini")
.messages(vec![ChatCompletionRequestMessageArgs::default()
.role(Role::User)
.content("Return JSON: {\"prime\": int, \"index\": int} for the 10th prime.")
.build()?])
.response_format(ResponseFormat {
r#type: ResponseFormatType::JsonObject,
})
.build()?;
let resp = client.chat().create(req).await?;
let json: serde_json::Value = serde_json::from_str(
resp.choices[0].message.content.as_deref().unwrap_or("{}"),
)?;
println!("{json}");
Expected:
{"prime":29,"index":10}
Do not trust the model to always emit strict JSON—parse defensively and validate with serde structs in real code.
Function calling
Define a tool and let the model emit arguments:
use async_openai::types::{Tool, ToolFunction, ToolType};
let tool = Tool {
r#type: ToolType::Function,
function: ToolFunction {
name: "get_prime".into(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {"n": {"type": "integer"}},
"required": ["n"]
})),
},
};
let req = CreateChatCompletionRequestArgs::default()
.model("gpt-4o-mini")
.messages(vec![ChatCompletionRequestMessageArgs::default()
.role(Role::User)
.content("What is the 10th prime?")
.build()?])
.tools(vec![tool])
.build()?;
Inspect resp.choices[0].message.tool_calls to extract arguments. The async-openai crate rust mapping matches the wire format exactly, so you avoid hand-rolling JSON schema serialization.
Where to go next
You now have a client that talks to OpenAI, streams, retries, parses JSON, and can be repointed at any compatible gateway with a one-line config change. The crate’s strength is its strict types: lean on ChatCompletionRequestMessageArgs and CreateChatCompletionRequestArgs instead of raw serde_json::json! maps, and you will catch model-parameter mistakes at compile time. For long-running services, wrap the Client in an Arc and share it across tasks—it is cheap to clone and carries no per-request mutable state.