What you'll learn: how server-sent events carry model text to a client, why a failure after the first byte must travel inside the stream, and how to classify a finished reply as valid, truncated or malformed.
At a glance
Figure 1. A stream: the status is sent first, the outcome last
How to read this: the client learns about a failure only from an event, because the status line was already sent.
- Streaming shows text sooner; the model is no faster.
- After the first byte the HTTP status is fixed at 200, so failures must be events.
- Every stream ends with exactly one terminal event:
doneorerror. - Use structured output only after the whole reply is validated.
- Truncated, malformed and wrong-shaped replies need different cures.
Scenario
Composite scenario, not a real company. Lumen Industrial Supply's sales engineers say the copilot "freezes for ten seconds and then dumps a page". The team streams the answer. Then a new complaint arrives: an answer stops mid-sentence and looks finished. The stream had broken, and the interface took the end of the connection for the end of the answer.
What you will be able to do
- Serve a stream of events from FastAPI and read it back as a client would.
- End every stream with exactly one terminal event, even when the source fails.
- Classify a reply as valid, fenced, empty, truncated, malformed or wrong-shaped.
- Explain why a partial parse is unsafe for tool arguments.
Prerequisites
Calling a provider gives the complete contract. Structured outputs and schema validation covers the repair step. Tool calling with one read-only tool shows what happens to valid arguments.
Core ideas
What streaming changes
| One response | A stream | |
|---|---|---|
| First visible text | After the whole answer | After the first delta |
| Failure midway | An HTTP error status | An event; the status was already 200 |
| When you can validate | On the reply | Only after the last delta |
| Idle connections | One long wait | Proxies may drop a silent connection |
The provider's API can also send an error event after returning 200 (checked 2026-11-02). Your endpoint has the same shape.
Server-sent events on the wire
SSE is a text format over one HTTP response. Per the WHATWG specification:
| Line | Meaning |
|---|---|
event: name |
The event type; message if absent |
data: text |
One line of payload; several data lines are joined with newlines |
id: value |
The last event id, sent back as Last-Event-ID on reconnect |
: text |
A comment, ignored; used as a keep-alive |
| a blank line | Dispatches the event; an event cut off before it is discarded |
FastAPI 0.135.0 added fastapi.sse with EventSourceResponse and ServerSentEvent (this build runs 0.162.0). It JSON-encodes data and sends a keep-alive comment every 15 seconds.
Verified: capstone/fde-product-discovery-copilot/lessons/M2-L6/m2_l6_streaming.py
def create_demo_app(make_deltas: Any, model: type[BaseModel] | None = None) -> FastAPI:
app = FastAPI()
@app.get("/stream", response_class=EventSourceResponse)
def stream() -> Iterable[ServerSentEvent]:
yield from stream_events(make_deltas(), model)
return app
A test client requesting /stream gets this body (real output):
event: delta
data: {"text":"alpha beta "}
event: delta
data: {"text":"gamma"}
event: done
data: {"chars":16,"result":null}
One terminal event, even when the source breaks
Verified: capstone/fde-product-discovery-copilot/lessons/M2-L6/m2_l6_streaming.py
def stream_events(deltas: Iterable[str], model: type[BaseModel] | None = None) -> Iterator[ServerSentEvent]:
"""Forward deltas as they arrive, then finish with exactly one `done` or one `error` event."""
parts: list[str] = []
try:
for piece in deltas:
parts.append(piece)
yield ServerSentEvent(data=Delta(text=piece), event="delta")
except Exception: # the cause may hold internal detail: send a fixed message, log the cause elsewhere
failed = Failed(code="stream_interrupted", message="The answer stopped before it was complete.", received_chars=len("".join(parts)))
yield ServerSentEvent(data=failed, event="error")
return
text = "".join(parts)
result: dict[str, Any] | None = None
if model is not None:
outcome, value = check_output(text, model)
if value is None:
failed = Failed(code=f"llm_{outcome}", message="The answer was not valid structured output.", received_chars=len(text))
yield ServerSentEvent(data=failed, event="error")
return
result = value.model_dump()
yield ServerSentEvent(data=Done(chars=len(text), result=result), event="done")
- A delta is forwarded the moment it arrives.
- If the source raises, the client gets one
errorevent with a fixed message. doneis sent only after the whole text is validated.
The capstone's MockProvider does not stream. word_deltas cuts a finished text into pieces, testing the handling, not timing.
Spot the flaw. A teammate writes this to cut latency. Find every defect.
Pseudocode.