Users will not stare at a spinner for 8 seconds while a completion generates.
Tier: Core · Workflow area: Building LLM Features
Streaming fixes perceived latency, but it moves complexity into your backend: you become a relay between the provider's stream and your client's, and you now own cancellation, mid-stream failures, and the question of what the frontend should do when a stream dies at token 400. This playbook builds the relay with an explicit contract for all three.
Skip streaming for machine-to-machine calls returning structured JSON (pb-44) — partial JSON is useless to a consumer, and buffering defeats the point.
pip install fastapi anthropic sse-starlette (or your stack's SSE primitive).Define the wire contract first. Three SSE event types, versioned like any API:
delta — {"text": "..."} incremental tokensdone — {"usage": {"input_tokens": n, "output_tokens": n}, "finish": "end_turn"}error — {"code": "upstream_error", "recoverable": true} — the frontend keeps partial text, shows a retry affordance.Writing this down is the deliverable the frontend team actually needs from you.
Build the relay with a passthrough generator. Consume the provider stream, re-emit your contract:
import json, anthropic
from fastapi import FastAPI, Request
from sse_starlette.sse import EventSourceResponse
app = FastAPI()
client = anthropic.Anthropic(timeout=60.0)
@app.post("/chat/stream")
async def chat_stream(request: Request):
body = await request.json()
def relay():
try:
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=64000,
messages=[{"role": "user", "content": body["message"]}],
) as stream:
for text in stream.text_stream:
yield {"event": "delta", "data": json.dumps({"text": text})}
final = stream.get_final_message()
yield {"event": "done", "data": json.dumps({
"usage": {"input_tokens": final.usage.input_tokens,
"output_tokens": final.usage.output_tokens},
"finish": final.stop_reason,
})}
except anthropic.APIError:
yield {"event": "error",
"data": json.dumps({"code": "upstream_error", "recoverable": True})}
return EventSourceResponse(relay())
Handle client disconnects — stop paying for abandoned streams. When the user closes the tab, stop consuming the upstream: check await request.is_disconnected() in an async variant (or rely on generator teardown closing the with block, which aborts the provider stream). Log abandoned streams separately — a high abandon rate is a product signal, not just a cost leak.
Buffer deliberately, in one place. Per-token SSE events can overwhelm mobile clients and proxies. Coalesce to ~50–100ms flushes in the relay if needed — never in the frontend, and never so aggressively that time-to-first-token exceeds a second. First token fast is the entire point.
Account at stream end. get_final_message() gives you the complete message and usage even though you streamed — log model, prompt version, tokens, latency-to-first-token, total latency, and finish reason. finish: "max_tokens" at any meaningful rate means your cap is truncating real answers.
Test the ugly paths. Kill the upstream mid-stream (drop network) and verify the client receives error with partial text intact; verify a closed client tab actually cancels the upstream call (watch the provider dashboard / logs); verify proxies in your path (nginx, ALB) are not buffering SSE — X-Accel-Buffering: no, no gzip on the stream route.
A stream that dies silently looks identical to a completed short answer. The done event is not optional decoration — it is how the frontend distinguishes "finished" from "died".
client.messages.stream(...) context manager; text_stream for deltas, get_final_message() for accounting.stream=True yields typed events (response.output_text.delta, response.completed); same relay pattern, map their events to your contract.EventSource — default to SSE.ReadableStream/res.write on Express or route handlers on Next.js; the contract from step 1 is unchanged.error, keeps partial text, retry works.curl -N against the endpoint through your full proxy chain shows tokens arriving incrementally, not one buffered blob.done events reconcile with the provider dashboard within a few percent over a day.AnthropicBedrockMantle) or Azure OpenAI streaming — both support SSE-style streams inside the tenant boundary.