Free lesson · GenAI Application Engineering
Implement an OpenAI GPT-4o streaming adapter
You will create an OpenAIStreamAdapter class in adapters/openai_stream.py wrapping openai.AsyncOpenAI. The adapter exposes stream_chat(messages: list[ChatMessage], model: str, temperature: float) -> AsyncGenerator[StreamChunk, None] calling client.chat.completions.create(model='gpt-4o', stream=True, stream_options={'include_usage': True}) and iterating over the async response. For each ChatCompletionChunk, you extract chunk.choices[0].delta.content, map it into a StreamChunk Pydantic model with fields content, finish_reason, model, provider, and usage, then yield. You handle finish_reason='stop' by capturing token usage from chunk.usage. The adapter reads OPENAI_API_KEY via a Settings model using pydantic-settings and raises ProviderAuthError if missing. Tests verify chunk transformation using a mock AsyncOpenAI client.
Course: Full-Stack GenAI Applications · Chapter 1 · Chat Completion API with Streaming
Free to read — no subscription required.
Introduction
When you call the OpenAI Chat Completions API for the first time, the difference between a synchronous five-second wait and a token-by-token stream is the difference between a broken UX and a conversational one. This lesson walks you through integrating the OpenAI Chat Completions API in streaming mode. By the end you'll be able to consume ChatCompletionChunk deltas via the AsyncOpenAI client, normalize them into provider-agnostic SSE frames, and handle empty deltas, finish_reason transitions, and client disconnects without dropping tokens.
Key Terminology
- ChatCompletionChunk: The OpenAI streaming response object representing a single incremental piece of a chat completion, carrying a
deltafield with partialroleorcontentand an optionalfinish_reasonon the terminal chunk. - AsyncOpenAI: The asynchronous OpenAI Python client used to call
chat.completions.create(stream=True)and iterateChatCompletionChunkobjects withasync forinside FastAPI handlers. - SSE frame: A Server-Sent Events wire message —
event:anddata:fields terminated by a double newline — that the adapter emits per delta so anyEventSourceclient can consume normalized provider-agnostic tokens.
Concepts
Handling edge cases in production
Three edge cases deserve special attention when operating OpenAI streaming at scale. First, empty delta content: the OpenAI API occasionally sends chunks where delta.content is an empty string "" rather than None. An empty string is truthy in the is not None check, so your adapter will yield SSE frames containing zero-length data. This is correct behavior—the W3C spec allows empty data: fields—but if your frontend concatenates tokens, it should silently handle empty strings rather than inserting visible artifacts.
Second, client disconnects mid-stream. When a user navigates away or closes the browser tab, the EventSource connection drops. FastAPI detects this through the ASGI protocol: the send callable raises a ClientDisconnect exception. Your async generator continues yielding frames into the void until the FastAPI StreamingResponse wrapper catches the disconnect and cancels the generator's task. The generator then receives an asyncio.CancelledError, which you should handle gracefully—close the OpenAI stream, log the partial completion, and exit. If you do not handle cancellation, the OpenAI API continues generating tokens that nobody consumes, wasting both inference cost and connection resources.
Third, rate-limit backpressure. When the OpenAI API returns HTTP 429, the AsyncOpenAI client raises a RateLimitError. Your adapter's error handler catches this and emits an error SSE frame. However, a more sophisticated approach inspects the retry-after header from the exception and emits an SSE retry: field, telling the browser to wait the specified duration before reconnecting. This transforms server-side rate limiting into a cooperative client-side backoff without custom JavaScript logic.
Code Walkthrough
The ChatCompletionChunk lifecycle
Every streaming session follows a deterministic lifecycle. The first chunk carries the role field inside the delta (typically "assistant") but no content. Subsequent chunks carry content fragments—usually one to three tokens each. The final chunk sets finish_reason to a non-None value such as "stop", "length", or "tool_calls", signaling that generation is complete. Between these milestones, you may receive chunks where delta.content is None or an empty string, particularly when the model pauses between sentences or when the API injects usage metadata.
The following diagram illustrates the complete data flow from the OpenAI API through your streaming adapter to the client browser, showing how raw ChatCompletionChunk objects are transformed into W3C-compliant SSE frames that any EventSource client can consume:
When a browser opens an EventSource connection to GET /chat/stream?provider=openai, FastAPI delegates to OpenAIStreamAdapter.stream_chat(), which calls chat.completions.create(stream=True). Each ChatCompletionChunk flows back through the adapter, which inspects delta.content and finish_reason to emit typed SSEFrame objects — either event="token" for partial text or event="done" to signal completion. This sequence diagram maps the full request lifecycle from browser to OpenAI API and back, clarifying where each streaming transformation occurs.
Code snippet mermaid
Loading diagram...
- Line 1: Declares this as a Mermaid sequence diagram, used to visualize interactions between components over time.
- Lines 2-5: Define the four participants (actors) in the diagram: the Browser using EventSource for SSE, a FastAPI endpoint, an OpenAIStreamAdapter that mediates streaming, and the OpenAI API itself.
- Line 7: The Browser initiates the flow by sending a GET request to the
/chat/streamendpoint withprovider=openaias a query parameter. - Line 8: FastAPI delegates to the Adapter by calling stream_chat with the conversation messages, model name, and temperature setting.
- Line 9: The Adapter calls the OpenAI API's chat.completions.create method with stream=True to request a streaming response.
- Line 11: Begins a loop block that iterates over each ChatCompletionChunk received from the OpenAI streaming response.
- Line 12: OpenAI sends back a chunk containing a delta (partial content) to the Adapter via a dashed
returnarrow (asyncresponse). - Line 13: The Adapter performs a self-call to extract the delta.content field from the received chunk.
- Lines 14-16: A conditional (
alt) block that checks ifdelta.contentis not None; if true, the Adapter sends an SSE frame back to FastAPI with event type"token"and the content text as the data payload. - Lines 17-19: A second conditional block that checks if
finish_reasonis not None (indicating the stream is complete); if true, the Adapter sends an SSE frame to FastAPI with event type"done"and the finish reason (e.g.,"stop") as the data payload. - Line 20: Closes the loop block, ending the per-chunk iteration.
- Line 22: FastAPI sends the entire accumulated stream back to the Browser as a StreamingResponse with content type
text/event-stream, completing the SSE connection.
This architecture decouples the OpenAI-specific chunk parsing from the SSE serialization. The adapter speaks OpenAI's protocol internally but emits a normalized SSEFrame that the FastAPI layer forwards without knowing which provider generated it. This same pattern extends to Gemini, Anthropic Claude, and Together.ai Llama endpoints—each adapter normalizes to the identical frame format.
Defining the unified SSE frame contract
Before writing the OpenAI adapter, you need a shared data structure that every provider adapter emits. The SSEFrame dataclass defines the contract between adapters and the transport layer. The ChatMessage model provides type-safe input validation using Pydantic. Together, these two structures ensure that the FastAPI endpoint never needs provider-specific logic—it simply iterates over frames and serializes them to the text/event-stream wire format.
The following code defines the SSEFrame dataclass and ChatMessage Pydantic model that form the shared contract between all provider adapters. The SSEFrame.serialize method produces W3C-compliant SSE text with proper event: and data: field formatting, while ChatMessage validates the role and content fields that every provider requires:
ChatMessage enforces strict role typing via Pydantic's Literal constraint, restricting inputs to "system", "user", or "assistant" — catching invalid roles before they reach any LLM provider. The SSEFrame dataclass encapsulates a single Server-Sent Event with optional id and retry fields, while its serialize() method handles the SSE wire format correctly, including multi-line data splitting and the mandatory double-newline terminator that browsers require to flush each event.
Code snippet python
1# models/sse.py 2from dataclasses import dataclass, field 3from pydantic import BaseModel 4from typing import Literal 5import json 6 7class ChatMessage(BaseModel): 8 role: Literal["system", "user", "assistant"] 9 content: str 10 11@dataclass 12class SSEFrame: 13 event: str 14 data: str 15 id: str | None = None 16 retry: int | None = None 17 18 def serialize(self) -> str: 19 lines = [] 20 if self.id is not None: 21 lines.append(f"id: {self.id}") 22 if self.retry is not None: 23 lines.append(f"retry: {self.retry}") 24 lines.append(f"event: {self.event}") 25 for data_line in self.data.split("\n"): 26 lines.append(f"data: {data_line}") 27 lines.append("") 28 lines.append("") 29 return "\n".join(lines)
- Lines 1-4: Import the required modules. The
dataclassdecorator provides lightweight structure for SSE frames, while Pydantic'sBaseModelgives runtime validation for incoming chat messages. TheLiteraltype restrictsroleto the three values the OpenAI API accepts. - Lines 6-8:
ChatMessageuses Pydantic validation to reject any message whoseroleis not"system","user", or"assistant". This catches malformed requests before they reach the provider API, preventing cryptic upstream errors. - Lines 10-14: The
SSEFramedataclass carries four fields. Theeventfield maps to the SSEevent:line that the browser'sEventSource.addEventListeneruses for dispatch. Thedatafield holds the payload—a token string for content events, or a JSON-encoded metadata object for control events. The optionalidenablesLast-Event-IDreconnection, andretryoverrides the browser's default reconnection interval. - Lines 16-24: The
serializemethod produces the exact wire format defined by the W3C SSE specification. Multi-line data values are split across separatedata:lines per the spec. The two trailing newlines form the mandatory blank-line event terminator that tells the browser one complete event has ended.
Building the OpenAI streaming adapter
The OpenAIStreamAdapter class encapsulates all OpenAI-specific logic: client initialization, the streaming API call, delta extraction, and error recovery. The class exposes a single public method, stream_chat, which is an async generator yielding SSEFrame objects. This design lets the FastAPI endpoint consume any provider adapter through an identical interface—call stream_chat, iterate over frames, and serialize.
The implementation below shows the complete OpenAIStreamAdapter class with its init constructor accepting an API key, and the stream_chat async generator method that calls self.client.chat.completions.create with stream=True. Pay attention to how the code checks delta.content for None before yielding, and how it inspects finish_reason to emit a terminal done event:
Code snippet python
1# adapters/openai_stream.py 2import asyncio 3from openai import AsyncOpenAI 4from models.sse import ChatMessage, SSEFrame 5from typing import AsyncGenerator 6import json 7import logging 8 9logger = logging.getLogger(__name__) 10 11class OpenAIStreamAdapter: 12 def __init__(self, api_key: str): 13 self.client = AsyncOpenAI(api_key=api_key) 14 15 async def stream_chat( 16 self, 17 messages: list[ChatMessage], 18 model: str = "gpt-4o", 19 temperature: float = 0.7, 20 ) -> AsyncGenerator[SSEFrame, None]: 21 formatted = [m.model_dump() for m in messages] 22 chunk_index = 0 23 try: 24 stream = await self.client.chat.completions.create( 25 model=model, 26 messages=formatted, 27 temperature=temperature, 28 stream=True, 29 stream_options={"include_usage": True}, 30 ) 31 async for chunk in stream: 32 if not chunk.choices: 33 if chunk.usage is not None: 34 yield SSEFrame( 35 event="usage", 36 data=json.dumps({ 37 "prompt_tokens": chunk.usage.prompt_tokens, 38 "completion_tokens": chunk.usage.completion_tokens, 39 }), 40 ) 41 continue 42 43 choice = chunk.choices[0] 44 delta = choice.delta 45 46 if delta.content is not None: 47 yield SSEFrame( 48 event="token", 49 data=delta.content, 50 id=str(chunk_index), 51 ) 52 chunk_index += 1 53 54 if choice.finish_reason is not None: 55 yield SSEFrame( 56 event="done", 57 data=json.dumps({ 58 "finish_reason": choice.finish_reason, 59 "chunk_count": chunk_index, 60 }), 61 ) 62 63 except Exception as exc: 64 logger.error("OpenAI stream error: %s", exc) 65 yield SSEFrame( 66 event="error", 67 data=json.dumps({"error": str(exc)}), 68 )
- Lines 1-9: Imports bring in
AsyncOpenAIfor non-blocking HTTP/2 streaming, the sharedChatMessageandSSEFramemodels, and Python'sloggingmodule. UsingAsyncOpenAIrather than the synchronousOpenAIclient is critical—synchronous streaming would block the entire FastAPI event loop, preventing concurrent request handling. - Lines 11-13: The constructor accepts a raw API key string and instantiates
AsyncOpenAI. In production, this key comes from environment variables or a secret manager, never from client-submitted parameters. The client instance is reusable across requests becauseAsyncOpenAImanages its own internal connection pool. - Lines 15-20: The
stream_chatmethod signature defines the provider-agnostic interface. ThereturntypeAsyncGenerator[SSEFrame, None]tells callers they will receive a sequence ofSSEFrameobjects viaasync for. The None send-type means this generator does not accept values sent into it via.asend(). - Lines 21-22: Messages are converted from Pydantic models to plain dictionaries using
model_dump(). Thechunk_indexcounter tracks how many content-bearing chunks have been emitted, providing monotonicidvalues for SSE reconnection semantics. - Lines 23-30: The
createcall passesstream=Trueto activate the streaming protocol. Thestream_optionsparameter withinclude_usageset to True is an OpenAI-specific feature that appends a final chunk containing token counts. Without this option, you cannot track usage for billing or rate-limit enforcement during streaming sessions. - Lines 31-42: The inner loop iterates over chunks. The first guard checks whether
chunk.choicesis empty—this happens on the final usage-reporting chunk wheninclude_usageis enabled. Whenchunk.usageis not None, the adapter yields ausageevent carrying prompt and completion token counts as JSON. Thecontinuestatement skips the rest of the loop body since usage chunks have no delta content. - Lines 44-45: For normal chunks, the code extracts
choices[0]and itsdelta. The OpenAI API guarantees at least one choice whenn=1(the default), but the empty-choices guard on line 32 handles the usage-only edge case. - Lines 47-53: The core content extraction. When
delta.contentis not None, the adapter wraps the token string in anSSEFramewith event type"token". Theidfield receives the monotonically increasingchunk_index, enabling browsers to resume from the last received event via theLast-Event-IDheader if the connection drops. - Lines 55-61: Finish-reason detection. The
finish_reasonfield is None on every chunk except the last content-bearing one. When it becomes"stop"(normal completion),"length"(max-token cutoff), or"tool_calls"(function-calling mode), the adapter emits adoneevent. The payload includes both the reason and the total chunk count, giving the frontend enough information to display completion status or trigger follow-up actions. - Lines 63-67: The outer
try/exceptcatches any exception—network timeouts, authentication failures, rate-limit errors (HTTP 429), or malformed responses. Rather than letting the exception propagate and crash the streaming response, the adapter emits anerrorevent. This is essential for production resilience: the frontend receives a structured error frame instead of a broken TCP connection with no explanation.
Do's and Don'ts
Do's
- ✓Do guard
delta.contentforNonebefore emittingSSEFrame(event="token")— The firstChatCompletionChunkcarries only aroledelta with nocontent, and intermediate chunks can also carryNoneduring API metadata injections; forwarding aNonedelta produces a literaldata: NoneSSE line that breaks anyEventSourceclient expecting text fragments. - ✓Do emit an explicit
SSEFrame(event="done", data=finish_reason)wheneverfinish_reasonis non-None— Without a typed termination frame,EventSourceclients have no signal that generation is complete and will hang waiting for more tokens; thefinish_reasonvalue ("stop","length","tool_calls") also gives the client the information it needs to decide whether to show a truncation warning. - ✓Do enforce
ChatMessage'sLiteral["system", "user", "assistant"]role constraint via Pydantic before any call tochat.completions.create— Invalid roles that bypass the Pydantic boundary surface as an opaque OpenAI 400 error deep insideOpenAIStreamAdapterrather than a clear validation failure at the FastAPI input layer, making the failure much harder to attribute and fix.
Don'ts
- ✗Don't use the synchronous
OpenAIclient whereAsyncOpenAIis required — Calling the blocking client inside a FastAPIStreamingResponsegenerator holds the event loop for the entire stream duration, serializing all concurrent connections and turning async token-by-token delivery into a blocking bottleneck that negates the streaming architecture entirely. - ✗Don't omit the double-newline terminator in
SSEFrame.serialize()— The W3C SSE wire format requires each event to end with a blank line (\n\n); without it, browsers buffer every frame until the TCP connection closes, making the token-by-token stream visually indistinguishable from the synchronous five-second wait the adapter was designed to replace. - ✗Don't collapse the
delta.contentandfinish_reasonchecks into a mutually exclusiveif/elif— Both conditions can be true on the sameChatCompletionChunk; usingelifmeans the final content fragment is silently dropped the momentfinish_reasonis non-None, producing a stream that consistently loses its last token with no error to debug.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.
From · cancel anytime
Listen to this lesson
Audio overviews of this lesson's labs and its chapter, from GenBodha Bytes.
- Chat Completion API with StreamingChapter overview23 min
More free lessons in Full-Stack GenAI Applications
- Ch 1Build a FastAPI SSE streaming response endpoint
- Ch 1Implement an OpenAI GPT-4o streaming adapterYou are here
- Ch 1Implement a Gemini 2.5 Flash streaming adapter with thinking budget
- Ch 1Implement an Anthropic Claude streaming adapter
- Ch 1Build a Llama 4 Maverick streaming adapter via Together.ai
- Ch 3Implement Anthropic prompt caching with cache_control markers
- Ch 8Build an MCP server exposing business logic as tools