n4nAI

Streaming chat completions in Rust with SSE

Build rust sse streaming chat completions in Rust: configure tokio and reqwest, parse Server-Sent Events manually, extract deltas, and verify token output.

n4n Team2 min read517 words

Audio narration

Coming soon — every post will get a voice note here.

Writing rust sse streaming chat completions code in Rust means dealing with chunked HTTP transfer and the OpenAI SSE framing. This guide walks through a minimal async client that opens a stream, parses data: lines, and prints tokens as they arrive, without pulling in a heavyweight event-source library.

Step 1: Scaffold the project and dependencies

Create a new binary crate and add the crates you actually need. reqwest with the stream feature gives you bytes_stream(); tokio-util adapts that byte stream into something we can call .lines() on.

[package]
name = "rust-sse-chat"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio-util = { version = "0.7", features = ["io"] }
anyhow = "1"

Do not add the eventsource crate. OpenAI-compatible streams omit event: and id: fields and use a single unnamed event type. A generic SSE client adds reconnect logic that masks transient 429s and complicates error handling.

Step 2: Define the wire types

The request shape is the same as a non-streaming call plus stream: true. The response chunks carry choices[].delta.content. Usage is only sent in the final chunk if you request it via stream_options.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Debug)]
struct ChatRequest {
    model: String,
    messages: Vec<Message>,
    stream: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    stream_options: Option<StreamOptions>,
}

#[derive(Serialize, Debug)]
struct StreamOptions {
    include_usage: bool,
}

#[derive(Serialize, Debug)]
struct Message {
    role: String,
    content: String,
}

#[derive(Deserialize, Debug)]
struct Chunk {
    choices: Vec<Choice>,
    #[serde(default)]
    usage: Option<Usage>,
}

#[derive(Deserialize, Debug)]
struct Choice {
    delta: Delta,
}

#[derive(Deserialize, Debug)]
struct Delta {
    #[serde(default)]
    content: Option<String>,
}

#[derive(Deserialize, Debug)]
struct Usage {
    prompt_tokens: u32,
    completion_tokens: u32,
    total_tokens: u32,
}

Step 3: Open the streaming request

Build the POST with Bearer auth and stream: true. Check the status before touching the body—a 401 or 429 should fail fast, not hang on an empty stream.

use reqwest::Client;
use std::env;

async fn stream_chat(model: &str, prompt: &str) -> anyhow::Result<()> {
    let api_key = env::var("LLM_API_KEY")?;
    let client = Client::new();
    let req = ChatRequest {
        model: model.to_string(),
        messages: vec![Message {
            role: "user".into(),
            content: prompt.into(),
        }],
        stream: true,
        stream_options: Some(StreamOptions { include_usage: true }),
    };

    let res = client
        .post("https://api.openai.com/v1/chat/completions")
        .bearer_auth(api_key)
        .json(&req)
        .send()
        .await?;

    if !res.status().is_success() {
        anyhow::bail!("upstream returned {}", res.status());
    }
    // If you point this at n4n.ai, the same OpenAI-compatible endpoint fronts
    // 240+ models and auto-falls-back when a provider is degraded—no client
    // retry logic required for rust sse streaming chat completions.
    Ok(())
}

Step 4: Adapt the byte stream and read lines

reqwest::Response::bytes_stream() yields Result<Bytes>. Wrap it with StreamReader so we can use BufReader::lines(). This avoids hand-rolling a byte buffer and \n splitter.

use tokio_util::io::StreamReader;
use futures::TryStreamExt;
use tokio::io::{BufReader, AsyncBufReadExt};

let byte_stream = res
    .bytes_stream()
    .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
let reader = StreamReader::new(byte_stream);
let mut lines = BufReader::new(reader).lines();

Step 5: Parse SSE frames and print deltas

Loop over lines. Skip blanks. For lines starting with data:, strip the prefix. [DONE] signals end of stream. Everything else is a JSON Chunk. Flush stdout after each token so output appears incrementally.

use std::io::Write;

while let Some(line) = lines.next_line().await? {
    let line = line.trim();
    if line.is_empty() {
        continue;
    }
    if let Some(data) = line.strip_prefix("data:") {
        let data = data.trim();
        if data == "[DONE]" {
            break;
        }
        match serde_json::from_str::<Chunk>(data) {
            Ok(chunk) => {
                for choice in chunk.choices {
                    if let Some(text) = choice.delta.content {
                        print!("{text}");
                        std::io::stdout().flush()?;
                    }
                }
                if let Some(usage) = chunk.usage {
                    eprintln!("\n[usage] prompt={} completion={} total={}",
                        usage.prompt_tokens,
                        usage.completion_tokens,
                        usage.total_tokens);
                }
            }
            Err(e) => eprintln!("parse error: {e}"),
        }
    }
}

The eprintln! for usage writes to stderr so it does not corrupt the stdout token stream if you pipe it elsewhere.

Step 6: Harden the client

Wrap the whole call in a timeout and propagate errors with anyhow. A streaming connection can stall; tokio::time::timeout is the simplest guard.

use tokio::time::{timeout, Duration};

match timeout(Duration::from_secs(60), stream_chat("gpt-4o-mini", "Explain SSE in one line")).await {
    Ok(Ok(())) => {},
    Ok(Err(e)) => eprintln!("stream failed: {e}"),
    Err(_) => eprintln!("stream timed out after 60s"),
}

If you need to cancel mid-stream (e.g., user hits stop), pass a tokio::sync::oneshot or check a AtomicBool inside the line loop and break. Do not rely on dropping the stream alone—some runtimes buffer.

Step 7: Verify the stream end to end

Set the key and run:

export LLM_API_KEY=sk-...
cargo run --release

Success looks like this:

  • Tokens print one or a few at a time, not all at once after a delay.
  • The process exits 0 shortly after the final token.
  • Stderr shows a [usage] line with non-zero token counts (if include_usage was set).

Cross-check with curl to confirm the server is actually streaming and not your client buffering:

curl -N https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}'

You should see raw data: {...} lines arrive incrementally. If curl streams but your Rust binary does not, you forgot stdout().flush() or you are reading the body as a single string.

Notes on production use

For rust sse streaming chat completions in a real service, clone the reqwest::Client (it is cheap) and reuse it across requests. Set a TCP_NODELAY and an idle timeout. Parse errors on individual chunks should be logged and skipped, not abort the whole stream—upstreams occasionally emit keep-alive comments (\n\n) which your line parser already ignores.

If you route through a gateway that honors client routing directives, send x-routing-* headers or provider hints as needed; the SSE parsing stays identical. The only difference is the base URL and possibly the auth scheme.

Tagsruststreamingssechat-completions

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All rust llm api client posts →