Vibe Engines
YouTube

Lesson 8.3 · 14 min

One HTTP call to a model

This is the lesson the whole book has been walking toward: Chatterbox sends your messages to a model over the internet and prints what comes back. It is one request and one reply, and almost every piece of it is something you already know. The few new words are listed below.

Adds to ChatterboxChatterbox calls a model.

What goes over the wire

Most hosted models, and many local ones, speak the same small dialect called chat completions. You send an HTTP POST with a JSON body: a model name and a list of messages. You get back JSON with a list called choices, and the reply sits in choices[0].message. That is the whole conversation.

You already have the Message from the last lesson, with role and content. The request and the reply are just two more structs around it.

src/main.rs: the shapes, written down as structs
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Message {
role: String,
content: String,
}
 
#[derive(Serialize)]
struct Request {
model: String,
messages: Vec<Message>,
}
 
#[derive(Deserialize)]
struct Response {
choices: Vec<Choice>,
}
 
#[derive(Deserialize)]
struct Choice {
message: Message,
}
 
#[derive(Debug)]
enum ChatError {
Http(reqwest::Error),
Empty,
}

Request only needs to be written out, so it derives Serialize. Response and Choice only need to be read, so they derive Deserialize. A real reply has more fields than this (an id, token counts and more). serde quietly ignores any field your struct does not name, so you write down only what you use.

Two more things that can fail

In chapter 7 a failure had a name, ChatError. Now there are new ways to fail: the network is down, the key is wrong, or the reply has an empty choices list. This lesson's ChatError has just two variants, Http and Empty, and replaces the earlier variants such as BadCount; the model call has no use for them.

The impl From<reqwest::Error> for ChatError below is the From trait that lesson 7.2 promised. impl From<X> for Y says how to build a Y from an X, and that is exactly what lets ? convert an X error into a Y on its way out of a function. So a reqwest::Error becomes our ChatError automatically.

The error type grows, and the trait takes the whole history
impl From<reqwest::Error> for ChatError {
fn from(e: reqwest::Error) -> Self {
ChatError::Http(e)
}
}
 
impl std::fmt::Display for ChatError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ChatError::Http(e) => write!(f, "the model call failed: {e}"),
ChatError::Empty => write!(f, "nothing to reply to"),
}
}
}
 
trait Replier {
fn reply(&self, history: &[Message]) -> Result<String, ChatError>;
}

The call itself

Here is ModelReplier. Read it as a sentence: build the body, POST it to the URL with the key, fail if the status is not a success, read the JSON into Response, take the first choice, and give back its text.

ModelReplier: the real call
struct ModelReplier {
url: String,
key: String,
model: String,
}
 
impl Replier for ModelReplier {
fn reply(&self, history: &[Message]) -> Result<String, ChatError> {
let body = Request { model: self.model.clone(), messages: history.to_vec() };
let resp: Response = reqwest::blocking::Client::new()
.post(&self.url)
.bearer_auth(&self.key)
.json(&body)
.send()?
.error_for_status()?
.json()?;
let first = resp.choices.into_iter().next().ok_or(ChatError::Empty)?;
Ok(first.message.content)
}
}

There are three ? marks in a row, and each one can end the function early with an error. send() fails if the request never reaches the server. error_for_status() turns an HTTP 401 or 500 into an error rather than a reply you try to parse. json() fails if the body does not fit Response. blocking means the call waits right there until the reply arrives, which is the simplest thing and fine for a chat program that is waiting on you anyway.

The key never appears in the source. pick_replier reads it from the environment: if CHATTERBOX_API_KEY is set, build a ModelReplier; if not, fall back to the echo. Remember Box<dyn Replier> from last lesson, this is where it pays off.

Pick the replier from the environment
fn pick_replier() -> Box<dyn Replier> {
match env::var("CHATTERBOX_API_KEY") {
Ok(key) => Box::new(ModelReplier {
url: env::var("CHATTERBOX_URL")
.unwrap_or("https://api.openai.com/v1/chat/completions".to_string()),
key,
model: env::var("CHATTERBOX_MODEL").unwrap_or("gpt-4o-mini".to_string()),
}),
Err(_) => Box::new(EchoReplier),
}
}

Be honest about where this runs

This lesson cannot run in your browser. The page you are reading has no network access to a model and no place to keep a secret, so the buttons that ran earlier code do not exist here. You have to run it on your own machine: cargo add reqwest --features blocking,json next to serde and serde_json, paste the code in, and set a key.

The sessions below are real runs of this exact program, but with one difference you should know about. I did not call a paid model. I wrote a tiny stand-in server that listens on 127.0.0.1:8089, checks for the key test-key and answers in the chat-completions shape, so the full path (struct, JSON, POST, parse, print) is exercised for real. Point CHATTERBOX_URL at a real provider and the code does not change.

Chatterbox v0.8 against a local stand-in server (a real run; the key is an env var)
$ export CHATTERBOX_URL=http://127.0.0.1:8089/v1/chat/completions
export CHATTERBOX_API_KEY=test-key
printf 'hello\nwhat is Rust?\n/quit\n' | cargo run --release --quiet
Chatterbox v0.8 (type /quit to leave)
bot> Mock model heard 1 messages; last was: hello
bot> Mock model heard 3 messages; last was: what is Rust?

Look at the second reply: it says 3 messages. The history now holds your first message, the bot's first answer, and your second message. Because reply receives the whole history, the model sees the conversation, not just the last line. That is the same Vec you built in chapter 6.

Now the two failures, also real runs. A wrong key and a server that is not there both come back as a ChatError that prints a line, and the program carries on.

Wrong key, then no server at all (real runs)
$ export CHATTERBOX_API_KEY=wrong
printf 'hello\n/quit\n' | cargo run --release --quiet
Chatterbox v0.8 (type /quit to leave)
bot> (error: the model call failed: HTTP status client error (401 Unauthorized) for url (http://127.0.0.1:8089/v1/chat/completions))
$ export CHATTERBOX_URL=http://127.0.0.1:1/v1
printf 'hello\n/quit\n' | cargo run --release --quiet
Chatterbox v0.8 (type /quit to leave)
bot> (error: the model call failed: error sending request for url (http://127.0.0.1:1/v1))

No unwrap sat in the path of that failure. That is the payoff of chapter 7: a network error is an ordinary value to handle, and the chat loop prints it and keeps going.

Self-check

Say it out loud, then reveal

Self-checkRetrieval beats re-reading
Where does the model's reply text sit in a chat-completions response?Reveal
In choices[0].message.content: the first choice, its message, its content.
Why does the key come from <code>env::var</code> and not a string in the code?Reveal
A string in the code ends up in version control and logs. An environment variable stays outside the program.
What does <code>error_for_status()</code> add?Reveal
It turns an HTTP error status such as 401 or 500 into a Rust error, so you do not try to parse an error page as a reply.

That is lesson 8.3. Tick it off so the course remembers where you are.

Next: 8.4 Ship it →

Questions people ask

What does reqwest::blocking do?

It is the simple, non-async interface of the reqwest crate. A call waits until the reply arrives, which is fine for a small command-line program.

Can I use a model other than OpenAI?

Yes. Many providers and local servers accept the same chat-completions shape. Change CHATTERBOX_URL and CHATTERBOX_MODEL and the code stays the same.

Why does the reply struct only list a few fields?

serde ignores fields in the JSON that your struct does not name, so you only declare the ones you use.

Learn Rust by building a chat app · Lesson 8.3 · every compiler message above is real output from rustc 1.99.0 (b940084d7 2026-09-28) (edition 2024) · Vibe Engines · 2026