Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

Learn RustChapter 10: Streaming replies

Lesson 10.3 · 16 min

Streaming the real model

After lesson 10.2 the pretend model types its answer a word at a time, but the real one still sits silent until its whole answer is ready, then drops it on the screen in one go. A real model writes word by word too; it just needs to be asked to send each piece as soon as it exists. Reading those pieces is where the network shows its rough edges: it can cut a message in the middle of a line, and even in the middle of a letter.

Adds to ChatterboxThe real model’s answer appears as it is written.

The real model still makes you wait

In lesson 10.2, Model::reply started a task, and the task called ask, which waited for the whole answer and then sent it down the channel as a single piece. Ask the real model for a long story and the screen sits still for as long as the whole answer takes to write, then fills all at once. Meanwhile the model was writing the answer a word at a time, and you saw none of it.

The channel is already built for pieces: answer() in main.rs prints each one the moment it arrives. So nothing in main.rs changes in this lesson. All the work is in replier.rs, in how the task reads the reply.

Asking for pieces

The request gains one field, stream: bool, and Chatterbox sets it to true. That tells the service: do not wait until you are finished, send each piece as you write it. The reply then stops being one JSON object. It becomes server-sent events: plain lines of text that keep arriving over the same connection, one event at a time. A short answer looks like this (spaces matter; each piece of text starts with its own space):

data: {"choices":[{"delta":{"role":"assistant"}}]}

data: {"choices":[{"delta":{"content":"Hello"}}]}

data: {"choices":[{"delta":{"content":" there"}}]}

: keep-alive

data: [DONE]

Four facts cover all Chatterbox needs:

  • An event is a line starting with data:, followed by some JSON. A blank line separates one event from the next.
  • Instead of message, each piece has a delta (the word means "the change"): just the new text since the last piece.
  • Some pieces carry no text at all. The first one often only says who is speaking. So content is an Option<String> in the new Delta struct.
  • A line starting with : is a comment, which servers send to keep a quiet connection open. data: [DONE], which is not JSON at all, means the answer is complete. That one is the chat API's own convention, not a rule of server-sent events.

One line, one meaning

Every line of the stream means one of three things, so Chatterbox names them with an enum (lesson 5.3):

  • Event::Piece(String): more of the answer, to print and to send down the channel.
  • Event::Done: the answer is complete.
  • Event::Other: anything else. A blank line, a comment, a piece with no text, or a line that is not valid JSON. Chatterbox skips it.

The function that decides is parse_event(line: &str) -> Event. It takes off the data: at the front with strip_prefix (which gives back an Option: Some of the rest, or None if the line does not start that way), trims the space after it, checks for [DONE], then lets serde read the JSON into the new structs Chunk, ChunkChoice and Delta. You write it yourself at the end of this lesson.

The network does not deliver lines

Here is the catch: the stream does not arrive line by line. response.chunk().await? gives you the next chunk of the reply: whatever bytes have arrived so far, as Some(bytes), or None once the reply is over. A chunk can be any size. It can hold three events, or half of one. It can end in the middle of a line.

Worse, it can end in the middle of a letter. Remember from lesson 2.5 that text is stored as UTF-8 bytes, and that a letter outside plain English takes more than one: é is 2 bytes, ☕ is 3. The network knows nothing about letters: it happily delivers the first byte of é in one chunk and the second byte in the next. What happens if you try to read the first chunk as text on its own?

PredictHalf a letter
fn main() {
let word = "café";
let bytes = word.as_bytes();
// the first chunk ends after the first of the two bytes of é
let first_chunk = bytes[..4].to_vec();
let text = String::from_utf8(first_chunk).unwrap();
println!("{text}");
}

The first chunk ends after the first byte of é. What happens when you turn it into a String?

  • It compiles and runs
  • It compiles, then panics
  • Compile error E0308
Reveal the answer

It compiles, then panics at run time. String::from_utf8 checks that the bytes are valid UTF-8 and returns a Result. A lone first byte of é is not a letter, so it is an Err, and unwrap() on an Err stops the program (lessons 5.4 and 8.2). It compiles fine: the compiler cannot know where the network will cut.

The gentler cousin, String::from_utf8_lossy, never fails: it swaps any bytes that are not a whole letter for the replacement character �. That sounds safer and is worse, because the damage is silent. Read each chunk on its own and the reader sees caf� then another �, where the model wrote café:

The same five bytes, read in two halves and then together. extend_from_slice copies a slice of bytes onto the end of a Vec.
fn main() {
let word = "café";
let bytes = word.as_bytes();
println!("{} letters, {} bytes", word.chars().count(), bytes.len());
 
// The network cuts the word after the first byte of é.
let first = &bytes[..4];
let second = &bytes[4..];
println!("first chunk alone: {}", String::from_utf8_lossy(first));
println!("second chunk alone: {}", String::from_utf8_lossy(second));
 
// Keep the bytes until both halves are here, then read them.
let mut pending: Vec<u8> = Vec::new();
pending.extend_from_slice(first);
pending.extend_from_slice(second);
println!("both, together: {}", String::from_utf8_lossy(&pending));
}
$ cargo run
4 letters, 5 bytes
first chunk alone:  caf�
second chunk alone: �
both, together:     café

Keep the bytes until a line is whole

The fix is a waiting room. Chatterbox keeps everything that has arrived in a Vec<u8> called pending: bytes, not text, so half a letter can sit there safely. After each chunk it asks: is there a whole line in here yet? A line ends with a newline byte, written b'\n' (the b makes it a single byte, a u8, rather than a char).

  • pending.iter().position(|&b| b == b'\n') finds the first newline and gives back its index as an Option. The & in |&b| is a pattern (lesson 5.5) that copies the byte out of the reference the iterator hands over.
  • pending.drain(..=end) takes the bytes from the start up to and including end out of the Vec (..= is a range that includes its last number), and collect() gathers them into a new Vec<u8>. Whatever came after the newline stays behind for next time.
  • Only now, with a whole line in hand, does from_utf8_lossy turn it into text. A whole line always holds whole letters. In UTF-8, every byte of a multi-byte letter is 0x80 or above, and the newline byte is 0x0A, so a newline never appears inside a letter and splitting at one can never cut a letter in half.

The inner while let (lesson 5.5) matters: one chunk may hold several lines, and they should all be read before waiting for more.

PredictThree chunks, how many lines?
fn main() {
// Three chunks, cut wherever the network felt like it.
let chunks = ["data: He", "llo\ndata: wo", "rld\ndata: [DO"];
let mut pending: Vec<u8> = Vec::new();
let mut lines: Vec<String> = Vec::new();
for chunk in chunks {
pending.extend_from_slice(chunk.as_bytes());
while let Some(end) = pending.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = pending.drain(..=end).collect();
lines.push(String::from_utf8_lossy(&line).trim().to_string());
}
}
println!("{} lines; still waiting: {}", lines.len(), String::from_utf8_lossy(&pending));
}

Three chunks arrive, cut in awkward places. What does this print?

  • 3 lines; still waiting:
  • 2 lines; still waiting: data: [DO
  • 5 lines; still waiting:
  • 2 lines; still waiting:
Reveal the answer

The program prints: 2 lines; still waiting: data: [DO Only two newlines ever arrive, so there are exactly two lines, data: Hello and data: world, however the chunks were cut. The first chunk alone has no newline, so nothing is read after it. The last piece, data: [DO, has no newline yet, so it waits in pending for the rest of its line to arrive. Counting chunks instead of newlines is exactly the mistake this loop exists to avoid.

A tempting shortcut is to collect the drained bytes straight into a String. The compiler refuses:

Compiler CoachThe compiler, translated

This is the exact output of the real compiler. Toggle the English, then pick a fix and see what actually happens.

fn main() {
let mut pending: Vec<u8> = Vec::new();
pending.extend_from_slice("data: café\n".as_bytes());
if let Some(end) = pending.iter().position(|&b| b == b'\n') {
let line: String = pending.drain(..=end).collect();
println!("{}", line.trim());
}
}
error[E0277]: a value of type `String` cannot be built from an iterator over elements of type `u8`
 --> src/main.rs:5:50
  |
5 |         let line: String = pending.drain(..=end).collect();
  |                                                  ^^^^^^^ value of type `String` cannot be built from `std::iter::Iterator<Item=u8>`
  |
  = help: the trait `FromIterator<u8>` is not implemented for `String`
  = help: `String` implements trait `FromIterator<A>`:
            FromIterator<&char>
            FromIterator<&std::ascii::Char>
            FromIterator<&str>
            FromIterator<Box<str, A>>
            FromIterator<Cow<'_, str>>
            FromIterator<String>
            FromIterator<char>
            FromIterator<std::ascii::Char>
note: the method call chain might not have had the expected associated types
 --> src/main.rs:5:36
  |
2 |     let mut pending: Vec<u8> = Vec::new();
  |                                ---------- this expression has type `Vec<u8>`
...
5 |         let line: String = pending.drain(..=end).collect();
  |                                    ^^^^^^^^^^^^^ `Iterator::Item` is `u8` here
note: required by a bound in `collect`
 --> /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/core/src/iter/traits/iterator.rs:2073:4

E0277 in the compiler error atlas →

  1. a value of type `String` cannot be built from an iterator over elements of type `u8`: drain hands out bytes one at a time, and collect cannot make text out of loose bytes.
  2. This is a promise check (a trait, lesson 8.4): String knows how to be collected from chars and from pieces of text, and nothing in the standard library promises it can be collected from bytes.
  3. That refusal is on purpose. Bytes are only text once someone has decided what to do about bytes that do not form a letter, and collect has no way to ask you.
  4. So collect what you actually have, a Vec<u8>, and then choose how to read it.
  5. The list of types under `String` implements trait `FromIterator<A>` can be skipped: the first line says it all.

Fixes that were compiled for real

  • Collect bytes, then read them as text — compiles. This is what Chatterbox does. The bytes go into a Vec&lt;u8&gt;, and from_utf8_lossy turns the whole line into text in one step.
  • Collect into a &str instead — still E0277. Same refusal. A &amp;str only borrows text that already exists somewhere; there is no text yet to borrow.
  • Turn every byte into a char — compiles. It compiles, and it is wrong. <code>b as char</code> turns each byte into a letter on its own, so the two bytes of é become two different letters, and this prints <code>data: café</code>. Compiling is not the same as being right.

Chatterbox v1.1: the real model streams

In replier.rs, ask becomes stream. It still sends the request with .send().await? and checks the status with .error_for_status()?, but then, instead of reading the whole body as JSON, it loops: while let Some(bytes) = response.chunk().await?. Each chunk goes onto the end of pending; each whole line goes through parse_event. A Piece goes straight down the channel with tx.send(Ok(text)).await, and if that send fails, nobody is listening any more, so the function stops. Done ends the answer.

The channel now has room for 16 waiting pieces instead of 1. Reading the network pauses only if 16 pieces sit unread, the pause from lesson 10.2 that stops a fast writer drowning a slow reader.

One small rule closes it: finished(sent_any). A stream that ends, with [DONE] or by simply closing, without a single piece of text is not an answer. It is ChatError::Empty, the same error a reply with no choices was in lesson 9.2, and main.rs already prints it and carries on.

The run below is the proof. The course build starts a stand-in server that streams the way a real service does, one word per event, and it is deliberately unkind: it cuts every event into two chunks, right after the first byte of a multi-byte letter when there is one. The message has three such letters. Every one survived.

Chatterbox v1.1 after lesson 10.3: the whole program (green lines are new in this lesson). The run had the three settings pointing at the course's stand-in streaming server, which cut every event inside a letter.
You type
Sam
café ☕ naïve
/quit
Chatterbox prints (a real run)
Chatterbox v1.1 (replies by: real)
What should I call you?
Hi Sam! I'm Chatterbox. Ask me anything.
bot: mock model saw 1 message; last: café ☕ naïve
bot: bye, Sam!
messages so far: 1
saved 2 messages to chatterbox.json
Show the whole program (open it after you have tried the exercise below: it contains the answer)
src/main.rsunchanged
// Chatterbox: a small chat program, built one lesson at a time.
mod command;
mod message;
mod replier;
mod store;
 
use command::{Command, parse};
use message::Message;
use replier::{Echo, Model, Pretend, Replier};
use std::collections::HashMap;
use std::io::{self, Write};
use tokio::io::{AsyncBufReadExt, BufReader};
 
/// What the bot is called. A constant: fixed for the whole program.
const BOT: &str = "Chatterbox";
/// The version as a pair of numbers: (major, minor).
const VERSION: (u32, u32) = (1, 1);
/// The longest message we accept, in bytes.
const MAX_LEN: usize = 200;
/// What /help prints.
const HELP: &str = "commands: /help, /again, /history, /count, /stats, /remember <name> <fact>, /recall <name>, /find <word>, /last <n>, /model <echo|pretend|real>, /say <text>, /shout <text>, /debug <text>, /quit (or /h, /q, /exit)";
 
/// The first line Chatterbox prints.
fn banner() -> String {
format!("{} v{}.{}", BOT, VERSION.0, VERSION.1)
}
 
/// The greeting, with a name filled in.
fn greeting(name: &str) -> String {
let mut text = format!("Hi {name}! I'm {BOT}.");
text.push_str(" Ask me anything.");
text
}
 
/// The last `n` items of any slice: messages, numbers, words, anything.
/// `<T>` says "for any type T"; the body never needs to know which one.
fn last_n<T>(items: &[T], n: usize) -> &[T] {
let start = items.len().saturating_sub(n);
&items[start..]
}
 
/// Print an answer as its pieces arrive, and give back the whole of it
/// (None if it went wrong: what went wrong has been printed instead).
async fn answer(replier: &dyn Replier, history: &[Message]) -> Result<Option<String>, io::Error> {
let mut pieces = replier.reply(history);
let mut whole = String::new();
print!("bot: ");
while let Some(piece) = pieces.recv().await {
match piece {
Ok(text) => {
print!("{text}");
io::stdout().flush()?; // show it now, not at the end of the line
whole.push_str(&text);
}
Err(e) => {
println!("(that went wrong: {e})");
return Ok(None);
}
}
}
println!();
Ok(Some(whole))
}
 
// #[tokio::main] starts tokio's runtime, the part that runs async code, and
// then runs this async main inside it.
#[tokio::main]
async fn main() -> Result<(), io::Error> {
let mut messages_seen: u32 = 0;
let quiet = false;
let mut last: Option<String> = None; // the last thing you said, for /again
let mut history: Vec<Message> = store::load(store::FILE)?; // last time's conversation, if any
let mut facts: HashMap<String, String> = HashMap::new(); // name -> fact, for /recall
// Box<dyn Replier>: "some Replier, decided while the program runs".
// With a key in the environment, start with the real model.
let mut replier: Box<dyn Replier> = match Model::from_env() {
Ok(model) => Box::new(model),
Err(_) => Box::new(Echo { max_len: MAX_LEN }),
};
 
println!("{} (replies by: {})", banner(), replier.name());
// The keyboard, read one line at a time without blocking the program.
let mut lines = BufReader::new(tokio::io::stdin()).lines();
println!("What should I call you?");
let name_line = lines.next_line().await?.unwrap_or_default();
let name = name_line.trim();
if !quiet {
println!("{}", greeting(name));
}
if !history.is_empty() {
println!(
"(I remember {} messages from last time. /history shows them.)",
history.len()
);
}
 
// next_line gives None when there is no more input: the keyboard was closed.
while let Some(line) = lines.next_line().await? {
let message = line.trim();
let cmd = match parse(message) {
Ok(cmd) => cmd,
Err(e) => {
println!("bot: {e}");
continue;
}
};
match cmd {
Command::Empty => {} // nothing typed: nothing to answer
Command::Quit => {
println!("bot: bye, {name}!");
break;
}
Command::Help => println!("bot: {HELP}"),
Command::Again => match &last {
Some(text) => {
answer(replier.as_ref(), &[Message::new("you", text)]).await?;
}
None => println!("bot: nothing to repeat yet"),
},
Command::History => {
if history.is_empty() {
println!("bot: nothing said yet");
} else {
for (i, m) in history.iter().enumerate() {
println!("{}. {}", i + 1, m.to_line());
}
}
}
Command::Count => {
let bytes: usize = history.iter().map(|m| m.text.len()).sum();
println!("bot: {} messages, {} bytes of text", history.len(), bytes);
}
Command::Stats => {
// One chain of steps: your messages -> their words -> a count per word.
let mut counts: HashMap<String, u32> = HashMap::new();
for word in history
.iter()
.filter(|m| m.author == "you")
.flat_map(|m| m.text.split_whitespace())
{
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
let mut top: Vec<(String, u32)> = counts.into_iter().collect();
// most used first; ties in alphabetical order, so the answer never changes
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let words: Vec<String> = top
.iter()
.take(3)
.map(|(word, n)| format!("{word} ({n})"))
.collect();
if words.is_empty() {
println!("bot: nothing said yet");
} else {
println!("bot: your top words: {}", words.join(", "));
}
}
Command::Remember(key, fact) => {
println!("bot: got it: {key} is {fact}");
facts.insert(key, fact);
}
Command::Recall(Some(key)) => match facts.get(&key) {
Some(fact) => println!("bot: {key} is {fact}"),
None => println!("bot: I don't know anything about {key}"),
},
Command::Recall(None) => {
if facts.is_empty() {
println!("bot: I don't remember anything yet. Try /remember");
} else {
// A HashMap has no order, so sort the names before printing them.
let mut names = Vec::new();
for name in facts.keys() {
names.push(name.as_str());
}
names.sort();
println!("bot: I remember: {}", names.join(", "));
}
}
Command::Find(word) => {
// The closure borrows `word` from here: a small function made on the spot.
let hits: Vec<&Message> = history
.iter()
.filter(|m| m.author == "you" && m.text.contains(&word))
.collect();
if hits.is_empty() {
println!("bot: you never said {word}");
} else {
for m in hits {
println!("{}", m.to_line());
}
}
}
Command::Last(n) => {
for m in last_n(&history, n) {
println!("{}", m.to_line());
}
}
Command::Model(choice) => {
let next: Result<Box<dyn Replier>, String> = match choice.as_str() {
"echo" => Ok(Box::new(Echo { max_len: MAX_LEN })),
"pretend" => Ok(Box::new(Pretend)),
"real" => match Model::from_env() {
Ok(model) => Ok(Box::new(model)),
Err(e) => Err(e.to_string()),
},
_ => Err(format!(
"I don't know a model called {choice}. Try echo, pretend or real"
)),
};
match next {
Ok(r) => {
replier = r;
println!("bot: replies now come from {}", replier.name());
}
Err(why) => println!("bot: {why}"),
}
}
Command::Say(text) => println!("bot: {text}"),
Command::Shout(text) => println!("bot: {}", Message::new("you", &text).shout()),
Command::Debug(text) => println!("bot: {:?}", parse(&text)),
Command::Chat(text) => {
messages_seen += 1;
history.push(Message::new("you", &text));
// the history holds its own copy, so `text` can move into `last`
last = Some(text);
if let Some(reply) = answer(replier.as_ref(), &history).await? {
history.push(Message::new("bot", &reply));
}
}
}
}
println!("messages so far: {messages_seen}");
store::save(store::FILE, &history)?;
let s = if history.len() == 1 { "" } else { "s" };
println!("saved {} message{s} to {}", history.len(), store::FILE);
Ok(())
}
 
// Tests: `cargo test` builds and runs them; the program itself never sees them.
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn last_n_takes_from_the_end() {
assert_eq!(last_n(&[1, 2, 3, 4], 2), &[3, 4]);
assert_eq!(last_n(&["a", "b"], 5), &["a", "b"]);
assert!(last_n(&[1, 2, 3], 0).is_empty());
}
}
src/command.rsunchanged
//! Commands: what a line of input means, and what can be wrong with it.
use std::fmt;
 
/// Everything a line of input can mean. One enum, so a `match` on it must
/// handle every kind of line: the compiler checks that nothing is forgotten.
#[derive(Debug)]
pub enum Command {
Empty,
Help,
Quit,
Again,
History,
Count,
Stats,
Remember(String, String),
Recall(Option<String>),
Find(String),
Last(usize),
Model(String),
Say(String),
Shout(String),
Debug(String),
Chat(String),
}
 
/// Everything that can be wrong with a line of input. parse() returns one of
/// these instead of a Command, and main prints it.
#[derive(Debug)]
pub enum CommandError {
Unknown(String),
Missing(String, String),
Usage(String),
NotACount(String),
}
 
/// How a CommandError reads when printed with {}.
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CommandError::Unknown(word) => {
write!(f, "I don't know the command {word}. Try /help.")
}
CommandError::Missing(verb, example) => {
write!(f, "{verb} what? Try /{verb} {example}")
}
CommandError::Usage(form) => write!(f, "try {form}"),
CommandError::NotACount(text) => write!(f, "'{text}' is not a count. Try /last 3"),
}
}
}
 
/// The text after a command word, or the error that says it is missing.
fn need(line: &str, verb: &str, example: &str) -> Result<String, CommandError> {
match arg(line) {
Some(text) => Ok(String::from(text)),
None => Err(CommandError::Missing(
String::from(verb),
String::from(example),
)),
}
}
 
/// How many messages /last should show. "two" or "-1" is an error.
fn parse_count(text: &str) -> Result<usize, CommandError> {
match text.parse::<usize>() {
Ok(n) => Ok(n),
Err(_) => Err(CommandError::NotACount(String::from(text))),
}
}
 
/// Turn one line of input into a Command, or say what is wrong with it.
/// Each `?` means: if this step failed, return its error right here.
pub fn parse(line: &str) -> Result<Command, CommandError> {
let cmd = match first_word(line) {
"" => Command::Empty,
"/help" | "/h" => Command::Help,
"/quit" | "/q" | "/exit" => Command::Quit,
"/again" => Command::Again,
"/history" => Command::History,
"/count" => Command::Count,
"/stats" => Command::Stats,
"/remember" => {
let usage = CommandError::Usage(String::from("/remember <name> <fact>"));
let rest = match arg(line) {
Some(rest) => rest,
None => return Err(usage),
};
match arg(rest) {
Some(fact) => Command::Remember(String::from(first_word(rest)), String::from(fact)),
None => return Err(usage),
}
}
"/recall" => match arg(line) {
Some(name) => Command::Recall(Some(String::from(name))),
None => Command::Recall(None),
},
"/find" => Command::Find(need(line, "find", "hello")?),
"/last" => Command::Last(parse_count(&need(line, "last", "3")?)?),
"/model" => Command::Model(need(line, "model", "pretend")?),
"/debug" => Command::Debug(need(line, "debug", "/say hi")?),
"/say" => Command::Say(need(line, "say", "hello")?),
"/shout" => Command::Shout(need(line, "shout", "hello")?),
word => {
if is_command(word) {
return Err(CommandError::Unknown(String::from(word)));
}
Command::Chat(String::from(line))
}
};
Ok(cmd)
}
 
/// True when the text is a command: it starts with a slash.
fn is_command(text: &str) -> bool {
text.starts_with('/')
}
 
/// The first word of the text: a slice (&str) pointing into it, not a copy.
fn first_word(text: &str) -> &str {
match text.find(' ') {
Some(i) => &text[..i],
None => text,
}
}
 
/// The text after the command word, if there is any: Some("hello") for
/// "/say hello", None for a bare "/say". No more "" standing in for nothing.
fn arg(line: &str) -> Option<&str> {
match line.find(' ') {
Some(i) => {
let rest = line[i + 1..].trim();
if rest.is_empty() { None } else { Some(rest) }
}
None => None,
}
}
 
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn plain_text_is_chat() {
assert!(matches!(parse("hello there"), Ok(Command::Chat(t)) if t == "hello there"));
}
 
#[test]
fn help_has_a_short_form() {
assert!(matches!(parse("/help"), Ok(Command::Help)));
assert!(matches!(parse("/h"), Ok(Command::Help)));
}
 
#[test]
fn say_needs_text() {
assert_eq!(
parse("/say").unwrap_err().to_string(),
"say what? Try /say hello"
);
}
 
#[test]
fn last_needs_a_number() {
assert!(matches!(parse("/last 3"), Ok(Command::Last(3))));
assert_eq!(
parse("/last two").unwrap_err().to_string(),
"'two' is not a count. Try /last 3"
);
}
 
#[test]
fn unknown_commands_are_errors() {
assert_eq!(
parse("/dance").unwrap_err().to_string(),
"I don't know the command /dance. Try /help."
);
}
 
#[test]
fn remember_takes_a_name_and_a_fact() {
assert!(
matches!(parse("/remember pizza extra cheese"), Ok(Command::Remember(k, v)) if k == "pizza" && v == "extra cheese")
);
assert!(parse("/remember pizza").is_err());
}
}
src/message.rsunchanged
//! A Message: one line of the conversation.
use serde::{Deserialize, Serialize};
 
/// One line of the conversation: who said it, and what they said.
#[derive(Serialize, Deserialize)]
pub struct Message {
pub author: String,
pub text: String,
}
 
impl Message {
/// Make a Message from two pieces of borrowed text.
pub fn new(author: &str, text: &str) -> Message {
Message {
author: String::from(author),
text: String::from(text),
}
}
 
/// The message as one transcript line: "you: hello".
pub fn to_line(&self) -> String {
format!("{}: {}", self.author, self.text)
}
 
/// The text in capitals, for /shout.
pub fn shout(&self) -> String {
self.text.to_uppercase()
}
}
src/replier.rs105 new lines
//! Who answers: anything that can reply, behind one trait.
use crate::message::Message;
use serde::{Deserialize, Serialize};
use std::env;
use std::fmt;
use std::time::Duration;
use tokio::sync::mpsc;
 
/// What can go wrong while getting a reply.
#[derive(Debug)]
pub enum ChatError {
/// The request did not get a good answer (no network, bad key, server error).
Http(reqwest::Error),
/// The model answered, but with nothing in it.
Empty,
/// /model real was asked for, but a setting is missing.
NotSet(&'static str),
}
 
impl fmt::Display for ChatError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ChatError::Http(e) => write!(f, "the model call failed: {e}"),
ChatError::Empty => write!(
f,
"there was nothing to reply with (an empty conversation or an empty answer)"
),
ChatError::NotSet(name) => write!(f, "set {name} first (see lesson 9.2)"),
}
}
}
 
/// Lets `?` turn a reqwest error into a ChatError on its own.
impl From<reqwest::Error> for ChatError {
fn from(e: reqwest::Error) -> Self {
ChatError::Http(e)
}
}
 
/// The receiving end of an answer: its pieces arrive in order, as they are
/// ready, and the channel closes when the answer is complete.
pub type Pieces = mpsc::Receiver<Result<String, ChatError>>;
 
/// A promise with a name: any type that implements Replier can answer a
/// conversation. `reply` returns at once; the answer arrives on the channel.
pub trait Replier {
/// A short name, for "/model" and the banner.
fn name(&self) -> &str;
/// Start answering the conversation so far (the last message is the newest).
fn reply(&self, history: &[Message]) -> Pieces;
}
 
/// A channel that already holds a whole answer, or an error, and is closed.
fn all_at_once(answer: Result<String, ChatError>) -> Pieces {
let (tx, rx) = mpsc::channel(1);
// A new channel with room for one cannot be full, so this send always works.
tx.try_send(answer).expect("a new channel has room for one");
rx
}
 
/// The echo Chatterbox has had since chapter 3. It answers all at once.
pub struct Echo {
pub max_len: usize,
}
 
impl Replier for Echo {
fn name(&self) -> &str {
"echo"
}
 
fn reply(&self, history: &[Message]) -> Pieces {
let message = match history.last() {
Some(m) => &m.text,
None => return all_at_once(Err(ChatError::Empty)),
};
let text = if message.len() > self.max_len {
format!("that is too long (the limit is {} bytes)", self.max_len)
} else if message.ends_with('?') {
String::from("Good question! I can only echo. Try /model real to ask a real model.")
} else {
format!("you said: {message}")
};
all_at_once(Ok(text))
}
}
 
/// A stand-in for a real model. It types its answer a word at a time, the way
/// a real model does, so you can watch an answer arrive in pieces.
pub struct Pretend;
 
impl Replier for Pretend {
fn name(&self) -> &str {
"pretend"
}
 
fn reply(&self, history: &[Message]) -> Pieces {
let last = match history.last() {
Some(m) => &m.text,
None => return all_at_once(Err(ChatError::Empty)),
};
let text = format!(
"(pretend model) I read all {} message{}. You last said: {last}",
history.len(),
if history.len() == 1 { "" } else { "s" },
);
let (tx, rx) = mpsc::channel(8);
// The task runs on after this function has returned, so it cannot
// borrow anything from here: `move` hands `text` and `tx` over to it.
tokio::spawn(async move {
for (i, word) in text.split(' ').enumerate() {
let piece = if i == 0 {
word.to_string()
} else {
format!(" {word}")
};
if tx.send(Ok(piece)).await.is_err() {
return; // nobody is listening any more
}
tokio::time::sleep(Duration::from_millis(40)).await;
}
});
rx
}
}
 
/// One message as a chat API expects it: a role and some content.
#[derive(Serialize)]
struct ApiMessage {
role: String,
content: String,
}
 
/// The body we send: which model, the whole conversation, and a request to
/// send the answer as it is written instead of all at the end.
#[derive(Serialize)]
struct Request {
model: String,
messages: Vec<ApiMessage>,
stream: bool,
}
 
/// One piece of a streamed answer. serde ignores every other field.
#[derive(Deserialize)]
struct Chunk {
choices: Vec<ChunkChoice>,
}
 
#[derive(Deserialize)]
struct ChunkChoice {
delta: Delta,
}
 
/// The new text in this piece. Some pieces carry none (the first one often
/// only says who is speaking), so it is an Option.
#[derive(Deserialize)]
struct Delta {
content: Option<String>,
}
 
/// What one line of the stream means.
#[derive(Debug, PartialEq)]
enum Event {
/// More of the answer.
Piece(String),
/// The answer is complete.
Done,
/// Anything else: a blank line between events, a comment, a piece with no text.
Other,
}
 
/// Read one line of a server-sent event stream as the chat API sends it:
/// `data: {json}` for each piece, then `data: [DONE]`.
fn parse_event(line: &str) -> Event {
let data = match line.strip_prefix("data:") {
Some(rest) => rest.trim(),
None => return Event::Other,
};
if data == "[DONE]" {
return Event::Done;
}
let chunk: Chunk = match serde_json::from_str(data) {
Ok(chunk) => chunk,
Err(_) => return Event::Other,
};
match chunk.choices.into_iter().next() {
Some(choice) => match choice.delta.content {
Some(text) if !text.is_empty() => Event::Piece(text),
_ => Event::Other,
},
None => Event::Other,
}
}
 
/// A real model behind an OpenAI-style chat endpoint, configured from
/// environment variables so the key never sits in the code.
pub struct Model {
url: String,
key: String,
model: String,
}
 
impl Model {
/// Read the three settings, or say which one is missing.
pub fn from_env() -> Result<Model, ChatError> {
let key =
env::var("CHATTERBOX_API_KEY").map_err(|_| ChatError::NotSet("CHATTERBOX_API_KEY"))?;
let url = env::var("CHATTERBOX_URL").map_err(|_| ChatError::NotSet("CHATTERBOX_URL"))?;
let model =
env::var("CHATTERBOX_MODEL").map_err(|_| ChatError::NotSet("CHATTERBOX_MODEL"))?;
Ok(Model { url, key, model })
}
}
 
/// Send the conversation and pass each piece of the answer to `tx` as it
/// arrives over the network.
async fn stream(
url: &str,
key: &str,
body: &Request,
tx: &mpsc::Sender<Result<String, ChatError>>,
) -> Result<(), ChatError> {
let mut response = reqwest::Client::new()
.post(url)
.bearer_auth(key)
.json(body)
.send()
.await?
.error_for_status()?;
// The network delivers bytes in chunks of any size: a chunk can end in the
// middle of a line, or in the middle of a letter that takes several bytes.
// So keep the bytes until a whole line has arrived, and only then read it.
let mut pending: Vec<u8> = Vec::new();
let mut sent_any = false;
while let Some(bytes) = response.chunk().await? {
pending.extend_from_slice(&bytes);
while let Some(end) = pending.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = pending.drain(..=end).collect();
match parse_event(String::from_utf8_lossy(&line).trim()) {
Event::Piece(text) => {
sent_any = true;
if tx.send(Ok(text)).await.is_err() {
return Ok(()); // nobody is listening any more
}
}
Event::Done => return finished(sent_any),
Event::Other => {}
}
}
}
finished(sent_any)
}
 
/// An answer that ended without a single piece of text is an empty answer.
fn finished(sent_any: bool) -> Result<(), ChatError> {
if sent_any {
Ok(())
} else {
Err(ChatError::Empty)
}
}
 
impl Replier for Model {
fn name(&self) -> &str {
"real"
}
 
fn reply(&self, history: &[Message]) -> Pieces {
// Our names ("you", "bot") become the API's ("user", "assistant").
let mut messages = Vec::new();
for m in history {
let role = if m.author == "you" {
"user"
} else {
"assistant"
};
messages.push(ApiMessage {
role: String::from(role),
content: m.text.clone(),
});
}
let body = Request {
model: self.model.clone(),
messages,
stream: true,
};
// Owned copies: the task may outlive `self`, so it cannot borrow from it.
let url = self.url.clone();
let key = self.key.clone();
let (tx, rx) = mpsc::channel(16);
tokio::spawn(async move {
if let Err(e) = stream(&url, &key, &body, &tx).await {
// If nobody is listening any more, there is no one to tell.
let _ = tx.send(Err(e)).await;
}
});
rx
}
}
 
#[cfg(test)]
mod tests {
use super::*;
 
fn said(text: &str) -> Vec<Message> {
vec![Message::new("you", text)]
}
 
/// Wait for every piece of an answer and join them.
async fn whole(mut pieces: Pieces) -> Result<String, ChatError> {
let mut text = String::new();
while let Some(piece) = pieces.recv().await {
text.push_str(&piece?);
}
Ok(text)
}
 
#[tokio::test]
async fn echo_repeats_you() {
let answer = whole(Echo { max_len: 200 }.reply(&said("hi"))).await;
assert_eq!(answer.unwrap(), "you said: hi");
}
 
#[tokio::test]
async fn echo_refuses_long_messages() {
let long = "x".repeat(10);
let answer = whole(Echo { max_len: 5 }.reply(&said(&long))).await;
assert!(answer.unwrap().starts_with("that is too long"));
}
 
#[tokio::test]
async fn pretend_reads_the_whole_history() {
let history = vec![
Message::new("you", "a"),
Message::new("bot", "b"),
Message::new("you", "c"),
];
assert_eq!(
whole(Pretend.reply(&history)).await.unwrap(),
"(pretend model) I read all 3 messages. You last said: c"
);
}
 
#[tokio::test]
async fn pretend_answers_a_word_at_a_time() {
let mut pieces = Pretend.reply(&said("one two"));
let mut count = 0;
while let Some(piece) = pieces.recv().await {
piece.unwrap();
count += 1;
}
assert!(count > 1, "the answer came in {count} piece(s)");
}
 
#[test]
fn a_data_line_is_a_piece() {
let line = r#"data: {"choices":[{"delta":{"content":"Hel"}}]}"#;
assert_eq!(parse_event(line), Event::Piece(String::from("Hel")));
}
 
#[test]
fn done_ends_the_answer() {
assert_eq!(parse_event("data: [DONE]"), Event::Done);
}
 
#[test]
fn lines_without_text_are_skipped() {
assert_eq!(parse_event(""), Event::Other);
assert_eq!(parse_event(": keep-alive"), Event::Other);
let role_only = r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#;
assert_eq!(parse_event(role_only), Event::Other);
}
 
#[tokio::test]
async fn nothing_to_reply_to_is_an_error() {
let answer = whole(Echo { max_len: 200 }.reply(&[])).await;
assert!(matches!(answer, Err(ChatError::Empty)));
}
}
src/store.rsunchanged
//! Saving the conversation to a file and reading it back, as JSON.
use crate::message::Message;
use std::fs;
use std::io;
 
/// Where the history is kept, next to wherever you run Chatterbox.
pub const FILE: &str = "chatterbox.json";
 
/// The saved history, or an empty one the very first time (no file yet).
pub fn load(path: &str) -> Result<Vec<Message>, io::Error> {
match fs::read_to_string(path) {
// `?` turns a JSON error into an io::Error for us (serde_json says how).
Ok(text) => Ok(serde_json::from_str(&text)?),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
 
/// Write the whole history to the file as pretty-printed JSON.
pub fn save(path: &str, history: &[Message]) -> Result<(), io::Error> {
let text = serde_json::to_string_pretty(history)?;
fs::write(path, text)
}

Download this version (zip)Unzip, then cargo run inside the folder.

Going deeper Where this run was recorded

The stream needs the network, so the run above was recorded when the course was built, against that stand-in server on the same computer, with real bytes over a real connection. To see it word by word against a real service, download the project and set the same three environment variables as in lesson 9.2. Nothing else changes: any service that speaks chat completions streams in this format when asked.

Try itYour turn: read one line of the stream

This is Chatterbox v1.1 with the body of parse_event replaced by todo!(). Write it: a line starting with data: (with or without a space after it) whose JSON carries some text is a Piece; data: [DONE] is Done; everything else, including a piece whose text is missing or empty and a line that is not valid JSON, is Other. The structs Chunk, ChunkChoice and Delta are already there for serde. (In this copy, parse_event and Event are marked pub, because the hidden tests sit outside replier, and without pub code outside a module cannot reach what is inside it, as in lesson 8.1.)

This is Chatterbox v1.1 with the body of parse_event replaced by todo!(). Write it: a line starting with data: (with or without a space after it) whose JSON carries some text is a Piece; data: [DONE] is Done; everything else, including a piece whose text is missing or empty and a line that is not valid JSON, is Other. The structs Chunk, ChunkChoice and Delta are already there for serde. (In this copy, parse_event and Event are marked pub, because the hidden tests sit outside replier, and without pub code outside a module cannot reach what is inside it, as in lesson 8.1.)

// Chatterbox: a small chat program, built one lesson at a time.
mod command {
//! Commands: what a line of input means, and what can be wrong with it.
use std::fmt;
 
/// Everything a line of input can mean. One enum, so a `match` on it must
/// handle every kind of line: the compiler checks that nothing is forgotten.
#[derive(Debug)]
pub enum Command {
Empty,
Help,
Quit,
Again,
History,
Count,
Stats,
Remember(String, String),
Recall(Option<String>),
Find(String),
Last(usize),
Model(String),
Say(String),
Shout(String),
Debug(String),
Chat(String),
}
 
/// Everything that can be wrong with a line of input. parse() returns one of
/// these instead of a Command, and main prints it.
#[derive(Debug)]
pub enum CommandError {
Unknown(String),
Missing(String, String),
Usage(String),
NotACount(String),
}
 
/// How a CommandError reads when printed with {}.
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CommandError::Unknown(word) => {
write!(f, "I don't know the command {word}. Try /help.")
}
CommandError::Missing(verb, example) => {
write!(f, "{verb} what? Try /{verb} {example}")
}
CommandError::Usage(form) => write!(f, "try {form}"),
CommandError::NotACount(text) => write!(f, "'{text}' is not a count. Try /last 3"),
}
}
}
 
/// The text after a command word, or the error that says it is missing.
fn need(line: &str, verb: &str, example: &str) -> Result<String, CommandError> {
match arg(line) {
Some(text) => Ok(String::from(text)),
None => Err(CommandError::Missing(
String::from(verb),
String::from(example),
)),
}
}
 
/// How many messages /last should show. "two" or "-1" is an error.
fn parse_count(text: &str) -> Result<usize, CommandError> {
match text.parse::<usize>() {
Ok(n) => Ok(n),
Err(_) => Err(CommandError::NotACount(String::from(text))),
}
}
 
/// Turn one line of input into a Command, or say what is wrong with it.
/// Each `?` means: if this step failed, return its error right here.
pub fn parse(line: &str) -> Result<Command, CommandError> {
let cmd = match first_word(line) {
"" => Command::Empty,
"/help" | "/h" => Command::Help,
"/quit" | "/q" | "/exit" => Command::Quit,
"/again" => Command::Again,
"/history" => Command::History,
"/count" => Command::Count,
"/stats" => Command::Stats,
"/remember" => {
let usage = CommandError::Usage(String::from("/remember <name> <fact>"));
let rest = match arg(line) {
Some(rest) => rest,
None => return Err(usage),
};
match arg(rest) {
Some(fact) => Command::Remember(String::from(first_word(rest)), String::from(fact)),
None => return Err(usage),
}
}
"/recall" => match arg(line) {
Some(name) => Command::Recall(Some(String::from(name))),
None => Command::Recall(None),
},
"/find" => Command::Find(need(line, "find", "hello")?),
"/last" => Command::Last(parse_count(&need(line, "last", "3")?)?),
"/model" => Command::Model(need(line, "model", "pretend")?),
"/debug" => Command::Debug(need(line, "debug", "/say hi")?),
"/say" => Command::Say(need(line, "say", "hello")?),
"/shout" => Command::Shout(need(line, "shout", "hello")?),
word => {
if is_command(word) {
return Err(CommandError::Unknown(String::from(word)));
}
Command::Chat(String::from(line))
}
};
Ok(cmd)
}
 
/// True when the text is a command: it starts with a slash.
fn is_command(text: &str) -> bool {
text.starts_with('/')
}
 
/// The first word of the text: a slice (&str) pointing into it, not a copy.
fn first_word(text: &str) -> &str {
match text.find(' ') {
Some(i) => &text[..i],
None => text,
}
}
 
/// The text after the command word, if there is any: Some("hello") for
/// "/say hello", None for a bare "/say". No more "" standing in for nothing.
fn arg(line: &str) -> Option<&str> {
match line.find(' ') {
Some(i) => {
let rest = line[i + 1..].trim();
if rest.is_empty() { None } else { Some(rest) }
}
None => None,
}
}
 
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn plain_text_is_chat() {
assert!(matches!(parse("hello there"), Ok(Command::Chat(t)) if t == "hello there"));
}
 
#[test]
fn help_has_a_short_form() {
assert!(matches!(parse("/help"), Ok(Command::Help)));
assert!(matches!(parse("/h"), Ok(Command::Help)));
}
 
#[test]
fn say_needs_text() {
assert_eq!(
parse("/say").unwrap_err().to_string(),
"say what? Try /say hello"
);
}
 
#[test]
fn last_needs_a_number() {
assert!(matches!(parse("/last 3"), Ok(Command::Last(3))));
assert_eq!(
parse("/last two").unwrap_err().to_string(),
"'two' is not a count. Try /last 3"
);
}
 
#[test]
fn unknown_commands_are_errors() {
assert_eq!(
parse("/dance").unwrap_err().to_string(),
"I don't know the command /dance. Try /help."
);
}
 
#[test]
fn remember_takes_a_name_and_a_fact() {
assert!(
matches!(parse("/remember pizza extra cheese"), Ok(Command::Remember(k, v)) if k == "pizza" && v == "extra cheese")
);
assert!(parse("/remember pizza").is_err());
}
}
}
mod message {
//! A Message: one line of the conversation.
use serde::{Deserialize, Serialize};
 
/// One line of the conversation: who said it, and what they said.
#[derive(Serialize, Deserialize)]
pub struct Message {
pub author: String,
pub text: String,
}
 
impl Message {
/// Make a Message from two pieces of borrowed text.
pub fn new(author: &str, text: &str) -> Message {
Message {
author: String::from(author),
text: String::from(text),
}
}
 
/// The message as one transcript line: "you: hello".
pub fn to_line(&self) -> String {
format!("{}: {}", self.author, self.text)
}
 
/// The text in capitals, for /shout.
pub fn shout(&self) -> String {
self.text.to_uppercase()
}
}
}
mod replier {
//! Who answers: anything that can reply, behind one trait.
use crate::message::Message;
use serde::{Deserialize, Serialize};
use std::env;
use std::fmt;
use std::time::Duration;
use tokio::sync::mpsc;
 
/// What can go wrong while getting a reply.
#[derive(Debug)]
pub enum ChatError {
/// The request did not get a good answer (no network, bad key, server error).
Http(reqwest::Error),
/// The model answered, but with nothing in it.
Empty,
/// /model real was asked for, but a setting is missing.
NotSet(&'static str),
}
 
impl fmt::Display for ChatError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ChatError::Http(e) => write!(f, "the model call failed: {e}"),
ChatError::Empty => write!(
f,
"there was nothing to reply with (an empty conversation or an empty answer)"
),
ChatError::NotSet(name) => write!(f, "set {name} first (see lesson 9.2)"),
}
}
}
 
/// Lets `?` turn a reqwest error into a ChatError on its own.
impl From<reqwest::Error> for ChatError {
fn from(e: reqwest::Error) -> Self {
ChatError::Http(e)
}
}
 
/// The receiving end of an answer: its pieces arrive in order, as they are
/// ready, and the channel closes when the answer is complete.
pub type Pieces = mpsc::Receiver<Result<String, ChatError>>;
 
/// A promise with a name: any type that implements Replier can answer a
/// conversation. `reply` returns at once; the answer arrives on the channel.
pub trait Replier {
/// A short name, for "/model" and the banner.
fn name(&self) -> &str;
/// Start answering the conversation so far (the last message is the newest).
fn reply(&self, history: &[Message]) -> Pieces;
}
 
/// A channel that already holds a whole answer, or an error, and is closed.
fn all_at_once(answer: Result<String, ChatError>) -> Pieces {
let (tx, rx) = mpsc::channel(1);
// A new channel with room for one cannot be full, so this send always works.
tx.try_send(answer).expect("a new channel has room for one");
rx
}
 
/// The echo Chatterbox has had since chapter 3. It answers all at once.
pub struct Echo {
pub max_len: usize,
}
 
impl Replier for Echo {
fn name(&self) -> &str {
"echo"
}
 
fn reply(&self, history: &[Message]) -> Pieces {
let message = match history.last() {
Some(m) => &m.text,
None => return all_at_once(Err(ChatError::Empty)),
};
let text = if message.len() > self.max_len {
format!("that is too long (the limit is {} bytes)", self.max_len)
} else if message.ends_with('?') {
String::from("Good question! I can only echo. Try /model real to ask a real model.")
} else {
format!("you said: {message}")
};
all_at_once(Ok(text))
}
}
 
/// A stand-in for a real model. It types its answer a word at a time, the way
/// a real model does, so you can watch an answer arrive in pieces.
pub struct Pretend;
 
impl Replier for Pretend {
fn name(&self) -> &str {
"pretend"
}
 
fn reply(&self, history: &[Message]) -> Pieces {
let last = match history.last() {
Some(m) => &m.text,
None => return all_at_once(Err(ChatError::Empty)),
};
let text = format!(
"(pretend model) I read all {} message{}. You last said: {last}",
history.len(),
if history.len() == 1 { "" } else { "s" },
);
let (tx, rx) = mpsc::channel(8);
// The task runs on after this function has returned, so it cannot
// borrow anything from here: `move` hands `text` and `tx` over to it.
tokio::spawn(async move {
for (i, word) in text.split(' ').enumerate() {
let piece = if i == 0 {
word.to_string()
} else {
format!(" {word}")
};
if tx.send(Ok(piece)).await.is_err() {
return; // nobody is listening any more
}
tokio::time::sleep(Duration::from_millis(40)).await;
}
});
rx
}
}
 
/// One message as a chat API expects it: a role and some content.
#[derive(Serialize)]
struct ApiMessage {
role: String,
content: String,
}
 
/// The body we send: which model, the whole conversation, and a request to
/// send the answer as it is written instead of all at the end.
#[derive(Serialize)]
struct Request {
model: String,
messages: Vec<ApiMessage>,
stream: bool,
}
 
/// One piece of a streamed answer. serde ignores every other field.
#[derive(Deserialize)]
struct Chunk {
choices: Vec<ChunkChoice>,
}
 
#[derive(Deserialize)]
struct ChunkChoice {
delta: Delta,
}
 
/// The new text in this piece. Some pieces carry none (the first one often
/// only says who is speaking), so it is an Option.
#[derive(Deserialize)]
struct Delta {
content: Option<String>,
}
 
/// What one line of the stream means.
#[derive(Debug, PartialEq)]
pub enum Event {
/// More of the answer.
Piece(String),
/// The answer is complete.
Done,
/// Anything else: a blank line between events, a comment, a piece with no text.
Other,
}
 
/// Read one line of a server-sent event stream as the chat API sends it:
/// `data: {json}` for each piece, then `data: [DONE]`.
pub fn parse_event(line: &str) -> Event {
todo!()
}
 
/// A real model behind an OpenAI-style chat endpoint, configured from
/// environment variables so the key never sits in the code.
pub struct Model {
url: String,
key: String,
model: String,
}
 
impl Model {
/// Read the three settings, or say which one is missing.
pub fn from_env() -> Result<Model, ChatError> {
let key =
env::var("CHATTERBOX_API_KEY").map_err(|_| ChatError::NotSet("CHATTERBOX_API_KEY"))?;
let url = env::var("CHATTERBOX_URL").map_err(|_| ChatError::NotSet("CHATTERBOX_URL"))?;
let model =
env::var("CHATTERBOX_MODEL").map_err(|_| ChatError::NotSet("CHATTERBOX_MODEL"))?;
Ok(Model { url, key, model })
}
}
 
/// Send the conversation and pass each piece of the answer to `tx` as it
/// arrives over the network.
async fn stream(
url: &str,
key: &str,
body: &Request,
tx: &mpsc::Sender<Result<String, ChatError>>,
) -> Result<(), ChatError> {
let mut response = reqwest::Client::new()
.post(url)
.bearer_auth(key)
.json(body)
.send()
.await?
.error_for_status()?;
// The network delivers bytes in chunks of any size: a chunk can end in the
// middle of a line, or in the middle of a letter that takes several bytes.
// So keep the bytes until a whole line has arrived, and only then read it.
let mut pending: Vec<u8> = Vec::new();
let mut sent_any = false;
while let Some(bytes) = response.chunk().await? {
pending.extend_from_slice(&bytes);
while let Some(end) = pending.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = pending.drain(..=end).collect();
match parse_event(String::from_utf8_lossy(&line).trim()) {
Event::Piece(text) => {
sent_any = true;
if tx.send(Ok(text)).await.is_err() {
return Ok(()); // nobody is listening any more
}
}
Event::Done => return finished(sent_any),
Event::Other => {}
}
}
}
finished(sent_any)
}
 
/// An answer that ended without a single piece of text is an empty answer.
fn finished(sent_any: bool) -> Result<(), ChatError> {
if sent_any {
Ok(())
} else {
Err(ChatError::Empty)
}
}
 
impl Replier for Model {
fn name(&self) -> &str {
"real"
}
 
fn reply(&self, history: &[Message]) -> Pieces {
// Our names ("you", "bot") become the API's ("user", "assistant").
let mut messages = Vec::new();
for m in history {
let role = if m.author == "you" {
"user"
} else {
"assistant"
};
messages.push(ApiMessage {
role: String::from(role),
content: m.text.clone(),
});
}
let body = Request {
model: self.model.clone(),
messages,
stream: true,
};
// Owned copies: the task may outlive `self`, so it cannot borrow from it.
let url = self.url.clone();
let key = self.key.clone();
let (tx, rx) = mpsc::channel(16);
tokio::spawn(async move {
if let Err(e) = stream(&url, &key, &body, &tx).await {
// If nobody is listening any more, there is no one to tell.
let _ = tx.send(Err(e)).await;
}
});
rx
}
}
 
#[cfg(test)]
mod tests {
use super::*;
 
fn said(text: &str) -> Vec<Message> {
vec![Message::new("you", text)]
}
 
/// Wait for every piece of an answer and join them.
async fn whole(mut pieces: Pieces) -> Result<String, ChatError> {
let mut text = String::new();
while let Some(piece) = pieces.recv().await {
text.push_str(&piece?);
}
Ok(text)
}
 
#[tokio::test]
async fn echo_repeats_you() {
let answer = whole(Echo { max_len: 200 }.reply(&said("hi"))).await;
assert_eq!(answer.unwrap(), "you said: hi");
}
 
#[tokio::test]
async fn echo_refuses_long_messages() {
let long = "x".repeat(10);
let answer = whole(Echo { max_len: 5 }.reply(&said(&long))).await;
assert!(answer.unwrap().starts_with("that is too long"));
}
 
#[tokio::test]
async fn pretend_reads_the_whole_history() {
let history = vec![
Message::new("you", "a"),
Message::new("bot", "b"),
Message::new("you", "c"),
];
assert_eq!(
whole(Pretend.reply(&history)).await.unwrap(),
"(pretend model) I read all 3 messages. You last said: c"
);
}
 
#[tokio::test]
async fn pretend_answers_a_word_at_a_time() {
let mut pieces = Pretend.reply(&said("one two"));
let mut count = 0;
while let Some(piece) = pieces.recv().await {
piece.unwrap();
count += 1;
}
assert!(count > 1, "the answer came in {count} piece(s)");
}
 
#[test]
fn a_data_line_is_a_piece() {
let line = r#"data: {"choices":[{"delta":{"content":"Hel"}}]}"#;
assert_eq!(parse_event(line), Event::Piece(String::from("Hel")));
}
 
#[test]
fn done_ends_the_answer() {
assert_eq!(parse_event("data: [DONE]"), Event::Done);
}
 
#[test]
fn lines_without_text_are_skipped() {
assert_eq!(parse_event(""), Event::Other);
assert_eq!(parse_event(": keep-alive"), Event::Other);
let role_only = r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#;
assert_eq!(parse_event(role_only), Event::Other);
}
 
#[tokio::test]
async fn nothing_to_reply_to_is_an_error() {
let answer = whole(Echo { max_len: 200 }.reply(&[])).await;
assert!(matches!(answer, Err(ChatError::Empty)));
}
}
}
mod store {
//! Saving the conversation to a file and reading it back, as JSON.
use crate::message::Message;
use std::fs;
use std::io;
 
/// Where the history is kept, next to wherever you run Chatterbox.
pub const FILE: &str = "chatterbox.json";
 
/// The saved history, or an empty one the very first time (no file yet).
pub fn load(path: &str) -> Result<Vec<Message>, io::Error> {
match fs::read_to_string(path) {
// `?` turns a JSON error into an io::Error for us (serde_json says how).
Ok(text) => Ok(serde_json::from_str(&text)?),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
 
/// Write the whole history to the file as pretty-printed JSON.
pub fn save(path: &str, history: &[Message]) -> Result<(), io::Error> {
let text = serde_json::to_string_pretty(history)?;
fs::write(path, text)
}
}
use command::{Command, parse};
use message::Message;
use replier::{Echo, Model, Pretend, Replier};
use std::collections::HashMap;
use std::io::{self, Write};
use tokio::io::{AsyncBufReadExt, BufReader};
 
/// What the bot is called. A constant: fixed for the whole program.
const BOT: &str = "Chatterbox";
/// The version as a pair of numbers: (major, minor).
const VERSION: (u32, u32) = (1, 1);
/// The longest message we accept, in bytes.
const MAX_LEN: usize = 200;
/// What /help prints.
const HELP: &str = "commands: /help, /again, /history, /count, /stats, /remember <name> <fact>, /recall <name>, /find <word>, /last <n>, /model <echo|pretend|real>, /say <text>, /shout <text>, /debug <text>, /quit (or /h, /q, /exit)";
 
/// The first line Chatterbox prints.
fn banner() -> String {
format!("{} v{}.{}", BOT, VERSION.0, VERSION.1)
}
 
/// The greeting, with a name filled in.
fn greeting(name: &str) -> String {
let mut text = format!("Hi {name}! I'm {BOT}.");
text.push_str(" Ask me anything.");
text
}
 
/// The last `n` items of any slice: messages, numbers, words, anything.
/// `<T>` says "for any type T"; the body never needs to know which one.
fn last_n<T>(items: &[T], n: usize) -> &[T] {
let start = items.len().saturating_sub(n);
&items[start..]
}
 
/// Print an answer as its pieces arrive, and give back the whole of it
/// (None if it went wrong: what went wrong has been printed instead).
async fn answer(replier: &dyn Replier, history: &[Message]) -> Result<Option<String>, io::Error> {
let mut pieces = replier.reply(history);
let mut whole = String::new();
print!("bot: ");
while let Some(piece) = pieces.recv().await {
match piece {
Ok(text) => {
print!("{text}");
io::stdout().flush()?; // show it now, not at the end of the line
whole.push_str(&text);
}
Err(e) => {
println!("(that went wrong: {e})");
return Ok(None);
}
}
}
println!();
Ok(Some(whole))
}
 
// #[tokio::main] starts tokio's runtime, the part that runs async code, and
// then runs this async main inside it.
#[tokio::main]
async fn main() -> Result<(), io::Error> {
let mut messages_seen: u32 = 0;
let quiet = false;
let mut last: Option<String> = None; // the last thing you said, for /again
let mut history: Vec<Message> = store::load(store::FILE)?; // last time's conversation, if any
let mut facts: HashMap<String, String> = HashMap::new(); // name -> fact, for /recall
// Box<dyn Replier>: "some Replier, decided while the program runs".
// With a key in the environment, start with the real model.
let mut replier: Box<dyn Replier> = match Model::from_env() {
Ok(model) => Box::new(model),
Err(_) => Box::new(Echo { max_len: MAX_LEN }),
};
 
println!("{} (replies by: {})", banner(), replier.name());
// The keyboard, read one line at a time without blocking the program.
let mut lines = BufReader::new(tokio::io::stdin()).lines();
println!("What should I call you?");
let name_line = lines.next_line().await?.unwrap_or_default();
let name = name_line.trim();
if !quiet {
println!("{}", greeting(name));
}
if !history.is_empty() {
println!(
"(I remember {} messages from last time. /history shows them.)",
history.len()
);
}
 
// next_line gives None when there is no more input: the keyboard was closed.
while let Some(line) = lines.next_line().await? {
let message = line.trim();
let cmd = match parse(message) {
Ok(cmd) => cmd,
Err(e) => {
println!("bot: {e}");
continue;
}
};
match cmd {
Command::Empty => {} // nothing typed: nothing to answer
Command::Quit => {
println!("bot: bye, {name}!");
break;
}
Command::Help => println!("bot: {HELP}"),
Command::Again => match &last {
Some(text) => {
answer(replier.as_ref(), &[Message::new("you", text)]).await?;
}
None => println!("bot: nothing to repeat yet"),
},
Command::History => {
if history.is_empty() {
println!("bot: nothing said yet");
} else {
for (i, m) in history.iter().enumerate() {
println!("{}. {}", i + 1, m.to_line());
}
}
}
Command::Count => {
let bytes: usize = history.iter().map(|m| m.text.len()).sum();
println!("bot: {} messages, {} bytes of text", history.len(), bytes);
}
Command::Stats => {
// One chain of steps: your messages -> their words -> a count per word.
let mut counts: HashMap<String, u32> = HashMap::new();
for word in history
.iter()
.filter(|m| m.author == "you")
.flat_map(|m| m.text.split_whitespace())
{
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
let mut top: Vec<(String, u32)> = counts.into_iter().collect();
// most used first; ties in alphabetical order, so the answer never changes
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let words: Vec<String> = top
.iter()
.take(3)
.map(|(word, n)| format!("{word} ({n})"))
.collect();
if words.is_empty() {
println!("bot: nothing said yet");
} else {
println!("bot: your top words: {}", words.join(", "));
}
}
Command::Remember(key, fact) => {
println!("bot: got it: {key} is {fact}");
facts.insert(key, fact);
}
Command::Recall(Some(key)) => match facts.get(&key) {
Some(fact) => println!("bot: {key} is {fact}"),
None => println!("bot: I don't know anything about {key}"),
},
Command::Recall(None) => {
if facts.is_empty() {
println!("bot: I don't remember anything yet. Try /remember");
} else {
// A HashMap has no order, so sort the names before printing them.
let mut names = Vec::new();
for name in facts.keys() {
names.push(name.as_str());
}
names.sort();
println!("bot: I remember: {}", names.join(", "));
}
}
Command::Find(word) => {
// The closure borrows `word` from here: a small function made on the spot.
let hits: Vec<&Message> = history
.iter()
.filter(|m| m.author == "you" && m.text.contains(&word))
.collect();
if hits.is_empty() {
println!("bot: you never said {word}");
} else {
for m in hits {
println!("{}", m.to_line());
}
}
}
Command::Last(n) => {
for m in last_n(&history, n) {
println!("{}", m.to_line());
}
}
Command::Model(choice) => {
let next: Result<Box<dyn Replier>, String> = match choice.as_str() {
"echo" => Ok(Box::new(Echo { max_len: MAX_LEN })),
"pretend" => Ok(Box::new(Pretend)),
"real" => match Model::from_env() {
Ok(model) => Ok(Box::new(model)),
Err(e) => Err(e.to_string()),
},
_ => Err(format!(
"I don't know a model called {choice}. Try echo, pretend or real"
)),
};
match next {
Ok(r) => {
replier = r;
println!("bot: replies now come from {}", replier.name());
}
Err(why) => println!("bot: {why}"),
}
}
Command::Say(text) => println!("bot: {text}"),
Command::Shout(text) => println!("bot: {}", Message::new("you", &text).shout()),
Command::Debug(text) => println!("bot: {:?}", parse(&text)),
Command::Chat(text) => {
messages_seen += 1;
history.push(Message::new("you", &text));
// the history holds its own copy, so `text` can move into `last`
last = Some(text);
if let Some(reply) = answer(replier.as_ref(), &history).await? {
history.push(Message::new("bot", &reply));
}
}
}
}
println!("messages so far: {messages_seen}");
store::save(store::FILE, &history)?;
let s = if history.len() == 1 { "" } else { "s" };
println!("saved {} message{s} to {}", history.len(), store::FILE);
Ok(())
}
 
// Tests: `cargo test` builds and runs them; the program itself never sees them.
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn last_n_takes_from_the_end() {
assert_eq!(last_n(&[1, 2, 3, 4], 2), &[3, 4]);
assert_eq!(last_n(&["a", "b"], 5), &["a", "b"]);
assert!(last_n(&[1, 2, 3], 0).is_empty());
}
}

Hidden tests (6): an_empty_piece_is_skipped a_data_line_is_a_piece broken_json_and_no_choices_are_skipped done_ends_the_answer lines_without_text_are_skipped the_space_after_data_is_optional

This exercise runs real Rust in your browser through the official Rust Playground, so it needs JavaScript.

Self-check

Say it out loud, then reveal

Self-checkRetrieval beats re-reading
What does a chat API send back when the request says <code>"stream": true</code>?Reveal
Server-sent events: lines of data: {json}, one per piece of the answer, with blank lines between them and data: [DONE] at the end.
Why is <code>content</code> an <code>Option&lt;String&gt;</code> in <code>Delta</code>?Reveal
Some pieces carry no text, such as the first one, which often only says who is speaking. serde would refuse a piece with no content if the field were a plain String.
Why does Chatterbox keep incoming bytes in a <code>Vec&lt;u8&gt;</code> instead of turning each chunk into text?Reveal
A chunk can end in the middle of a line or in the middle of a letter that takes several bytes. Reading only whole lines, which always hold whole letters, means no letter is ever broken.
What does <code>pending.drain(..=end)</code> do?Reveal
It removes the bytes from the start up to and including index end (the newline) from pending and hands them out; anything after the newline stays for later.
When is a streamed answer an error even though nothing failed on the network?Reveal
When it ends without a single piece of text: finished(false) returns ChatError::Empty.

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

Questions people ask

What are server-sent events?

A simple format for a server to keep sending messages over one HTTP connection. Each message is a line starting with data: and messages are separated by blank lines. Chat APIs use it to stream an answer piece by piece.

How do I stream a chat completion in Rust?

Send the request with stream set to true, then call chunk() on the reqwest response in a loop. Keep the bytes in a Vec of u8, split off each complete line at the newline, and parse every line that starts with data: as JSON until data: [DONE] arrives.

Why does my streamed text show a replacement character in the middle of a word?

A network chunk ended in the middle of a letter that takes more than one byte in UTF-8, and each chunk was turned into text on its own. Keep the bytes until a whole line has arrived and only then convert them.

Does reqwest need the stream feature to read a response in pieces?

No. Response::chunk is available in reqwest without any extra feature. The stream feature adds a different interface, bytes_stream, which this course does not use.

What is the difference between String::from_utf8 and String::from_utf8_lossy?

from_utf8 returns a Result and gives an error if the bytes are not valid UTF-8. from_utf8_lossy never fails: it replaces any invalid bytes with the replacement character.

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