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 delta field with partial role or content and an optional finish_reason on the terminal chunk.
  • AsyncOpenAI: The asynchronous OpenAI Python client used to call chat.completions.create(stream=True) and iterate ChatCompletionChunk objects with async for inside FastAPI handlers.
  • SSE frame: A Server-Sent Events wire message — event: and data: fields terminated by a double newline — that the adapter emits per delta so any EventSource client 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/stream endpoint with provider=openai as 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 return arrow (async response).
  • 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 if delta.content is 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_reason is 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 dataclass decorator provides lightweight structure for SSE frames, while Pydantic's BaseModel gives runtime validation for incoming chat messages. The Literal type restricts role to the three values the OpenAI API accepts.
  • Lines 6-8: ChatMessage uses Pydantic validation to reject any message whose role is not "system", "user", or "assistant". This catches malformed requests before they reach the provider API, preventing cryptic upstream errors.
  • Lines 10-14: The SSEFrame dataclass carries four fields. The event field maps to the SSE event: line that the browser's EventSource.addEventListener uses for dispatch. The data field holds the payload—a token string for content events, or a JSON-encoded metadata object for control events. The optional id enables Last-Event-ID reconnection, and retry overrides the browser's default reconnection interval.
  • Lines 16-24: The serialize method produces the exact wire format defined by the W3C SSE specification. Multi-line data values are split across separate data: 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 AsyncOpenAI for non-blocking HTTP/2 streaming, the shared ChatMessage and SSEFrame models, and Python's logging module. Using AsyncOpenAI rather than the synchronous OpenAI client 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 because AsyncOpenAI manages its own internal connection pool.
  • Lines 15-20: The stream_chat method signature defines the provider-agnostic interface. The return type AsyncGenerator[SSEFrame, None] tells callers they will receive a sequence of SSEFrame objects via async 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(). The chunk_index counter tracks how many content-bearing chunks have been emitted, providing monotonic id values for SSE reconnection semantics.
  • Lines 23-30: The create call passes stream=True to activate the streaming protocol. The stream_options parameter with include_usage set 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.choices is empty—this happens on the final usage-reporting chunk when include_usage is enabled. When chunk.usage is not None, the adapter yields a usage event carrying prompt and completion token counts as JSON. The continue statement 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 its delta. The OpenAI API guarantees at least one choice when n=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.content is not None, the adapter wraps the token string in an SSEFrame with event type "token". The id field receives the monotonically increasing chunk_index, enabling browsers to resume from the last received event via the Last-Event-ID header if the connection drops.
  • Lines 55-61: Finish-reason detection. The finish_reason field 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 a done event. 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/except catches 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 an error event. 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

  1. Do guard delta.content for None before emitting SSEFrame(event="token") — The first ChatCompletionChunk carries only a role delta with no content, and intermediate chunks can also carry None during API metadata injections; forwarding a None delta produces a literal data: None SSE line that breaks any EventSource client expecting text fragments.
  2. Do emit an explicit SSEFrame(event="done", data=finish_reason) whenever finish_reason is non-None — Without a typed termination frame, EventSource clients have no signal that generation is complete and will hang waiting for more tokens; the finish_reason value ("stop", "length", "tool_calls") also gives the client the information it needs to decide whether to show a truncation warning.
  3. Do enforce ChatMessage's Literal["system", "user", "assistant"] role constraint via Pydantic before any call to chat.completions.create — Invalid roles that bypass the Pydantic boundary surface as an opaque OpenAI 400 error deep inside OpenAIStreamAdapter rather than a clear validation failure at the FastAPI input layer, making the failure much harder to attribute and fix.

Don'ts

  1. Don't use the synchronous OpenAI client where AsyncOpenAI is required — Calling the blocking client inside a FastAPI StreamingResponse generator 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.
  2. 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.
  3. Don't collapse the delta.content and finish_reason checks into a mutually exclusive if/elif — Both conditions can be true on the same ChatCompletionChunk; using elif means the final content fragment is silently dropped the moment finish_reason is 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.

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering