Course ES

Chapter 4 of 21 · basic

Streaming responses

What this session covers

The first-call chapter waited for the whole answer before printing anything. That works for one sentence. For a paragraph it feels broken — the reader sits there watching nothing happen, then sees a blob. Streaming swaps that for a typewriter. The model ships tokens as it generates them, the terminal prints them as they arrive. Same model, same prompt, same total latency. What changes is how it feels, and that's most of what users notice.

OpenAI

One keyword argument. stream=True flips the response from "give me the final blob" to "give me a sequence of typed events". Iterating yields several event kinds — created, in-progress, output-text deltas, done, completed. Only the deltas carry text; the rest is metadata I ignore. Each delta gets printed with end="" and flush=True so the terminal types it out instead of buffering a whole line.

"""Week 3 - Streaming responses (OpenAI).

Same one API call as week 1, with stream=True. The SDK yields typed
events as tokens come back from the server; we filter for the
output-text delta events and print each delta as it arrives. The
terminal shows the answer take shape instead of waiting for the full
blob.
"""
import os
import sys

from dotenv import find_dotenv, load_dotenv
from openai import OpenAI

load_dotenv(find_dotenv())

if not os.environ.get("OPENAI_API_KEY"):
    sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")

client = OpenAI()

# stream=True turns the call into a server-sent-event stream. Iterating
# the response yields one event per server-side delta; we only want the
# text deltas, so we filter by event.type.
stream = client.responses.create(
    model="gpt-5.4-nano",
    input="In one short paragraph, explain why streaming responses matter for chat UX.",
    stream=True,
)

for event in stream:
    if event.type == "response.output_text.delta":
        # end="" + flush=True is what turns this into a typewriter.
        # Without flush the terminal buffers a whole line at a time.
        print(event.delta, end="", flush=True)
print()

A few things to watch. The event types are stable across the Responses API, so the filter on response.output_text.delta holds across models. The deltas are server-side chunks, not single tokens — sometimes a word, sometimes a sentence. That's fine; the lesson is "don't wait", not "do something with each token". And flush=True is non-negotiable. Python buffers stdout by default. Without flush, the typewriter falls apart.

Gemini

Gemini picked the other ergonomic bet. Streaming gets its own method — generate_content_stream, the streaming twin of generate_content. The return type is plainer: an iterator of chunks, each chunk carrying a .text attribute that's either a string delta or empty.

"""Week 3 - Streaming responses (Gemini).

The Gemini SDK exposes streaming as a separate method
(`generate_content_stream`) that returns an iterator of chunks. Each
chunk carries a partial text delta. Same teaching point as the OpenAI
example: print as you go, no buffering.
"""
import os
import sys

from dotenv import find_dotenv, load_dotenv
from google import genai

load_dotenv(find_dotenv())

if not os.environ.get("GEMINI_API_KEY"):
    sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")

client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

# generate_content_stream is the streaming twin of generate_content.
# Each iteration yields a partial response carrying a `.text` delta.
stream = client.models.generate_content_stream(
    model="gemini-3.1-flash-lite",
    contents="In one short paragraph, explain why streaming responses matter for chat UX.",
)

for chunk in stream:
    if chunk.text:
        print(chunk.text, end="", flush=True)
print()

Same UX, different SDK shape. OpenAI overloaded one method with a flag; Google split it into two. Both work. Pick whichever reads clearer in your codebase and stay consistent across the project.

Put it to work

Three showcases, three docker-compose projects. Each one is a browser tab with a form. bash bootstrap-secrets.sh, then docker compose up --build, then http://localhost:3000. The book's sidebar lists them as cards. Each card opens a landing page with the source code, a try-it widget you can hit live, and a one-command zip download.

Showcase 1 — Live explainer

Ask a question, watch the answer take shape. Same one-API-call shape as week 1, pointed at a Q&A prompt, with stream=True doing the work. The point is the feel: waiting for one blob vs. watching the response build. Same total latency. Only the experience changes.

Showcase 2 — Stream translate

Paste text, pick a target language, watch the translation arrive sentence by sentence. Translation is where streaming earns its keep — long-ish output, no value in batching, and you can start reading the first sentence while the model is still on the third. The input carries the target language in a small target: <language> header so the def run(input) contract stays one string.

Showcase 3 — Live rewrite

Paste any draft message. A tighter version streams in. Same facts, fewer words. This one is the closest to a daily-use tool — the streaming UI makes the rewrite feel like editing alongside someone, which is the experience you want in a writing assistant.

Across all three, the design that keeps the code honest is the two-callable shape. run_stream yields tokens as they arrive — that is the lesson. run is a thin accumulator over run_stream that returns the final string. The local frontend hits /api/ai/stream for the typewriter; the website's try-it widget hits /api/ai for the accumulated text. One streaming path, two surfaces, no duplicated logic.

Run it

README.md has the prereqs, the basic-CLI commands, and the three per-showcase run blocks. Short version: python code/openai/main.py for the simplest demo, or cd into any showcase, bash bootstrap-secrets.sh, and docker compose up --build.

Takeaways

Streaming is one keyword argument away from a better-feeling app, and the model never got faster. That trick — changing the experience of waiting without changing the actual latency — is one of the cheaper wins in AI work. Worth doing. Keep the provider logic in a run_stream generator so you control the loop, not the SDK. Accumulate the stream into a string when you need one at the edge. Don't reach for streaming on one-line answers; the overhead isn't worth it. Reach for it when the output is long enough that the reader would tab away otherwise.