Free lesson · GenAI Application Engineering

Build a streaming SSE test harness across 4 LLM providers

Build a StreamingTestClient class wrapping httpx.AsyncClient to test FastAPI streaming endpoints via Server-Sent Events. Implement parse_sse_stream() that consumes async byte iterators and yields ParsedSSEEvent objects with event type, data payload, and timing metadata. Create three assertion helpers: assert_stream_completes(timeout_seconds) verifying the stream terminates with a [DONE] sentinel, assert_token_count_within(min_tokens, max_tokens) validating output length, and assert_no_stream_errors() checking for mid-stream error events. Write a provider_matrix pytest fixture using @pytest.mark.parametrize that runs each test against /v1/chat/stream for OpenAI GPT-4o, Gemini 2.5 Flash, Anthropic Claude, and Llama 4. Build test_streaming_latency() using pytest-asyncio to measure time-to-first-token and inter-token latency p95. Validate responses with StreamChunkModel Pydantic schema.

Course: Full-Stack GenAI Applications · Chapter 15 · Testing & Evaluation for GenAI APIs

Free to read — no subscription required.

Introduction

When you ship a streaming LLM endpoint, the bugs that surface in production are rarely the ones a JSON-shaped integration test catches. Half a frame arrives, the provider drops the connection before sending [DONE], or an Anthropic stream ends with message_stop while your parser still waits for OpenAI's terminator — and every silent failure reaches users as a hung browser tab or a truncated answer. By the end of this lesson you'll be able to design a provider-agnostic SSE test harness that validates token delivery, error propagation, and stream termination across OpenAI, Anthropic, Gemini, and Llama 4, and wires the results into a CI gate.

Key Terminology

  • TTFT (Time to First Token) — elapsed time from request dispatch to the first chunk carrying non-empty content. It is the primary user-perceived latency metric your harness must measure on every streaming run, because a stream that connects but stalls is the failure mode REST tests cannot see.
  • Backpressure — the condition where the consumer (your test client) reads slower than the producer (the LLM provider) sends, causing buffer accumulation that can trigger timeouts or dropped frames. Your harness needs to reproduce it deliberately so the endpoint's flow-control behavior is exercised, not just observed in production incidents.
  • SSE Sentinel — the protocol-level signal indicating stream completion, such as data: [DONE] (OpenAI / Llama 4) or event: message_stop (Anthropic). Each provider terminates differently, so the harness must assert on the right sentinel per provider to catch silently-truncated streams.

Concepts

Error Injection and Edge-Case Coverage

Beyond the happy path, a robust harness must validate how your streaming endpoint behaves under failure conditions. Three categories of errors demand explicit test coverage:

  • Mid-stream provider failures: The upstream LLM returns an HTTP 200 and begins streaming, then sends an SSE frame containing an error object (e.g., OpenAI's {"error": {"message": "rate limit"}}) instead of a content delta. Your endpoint must propagate this as a structured error chunk, not silently drop it.
  • Connection resets: The provider closes the TCP socket before sending [DONE]. Your FastAPI endpoint must detect the incomplete stream and surface an appropriate error to the client rather than returning a truncated response that appears successful.
  • Malformed SSE frames: Partial JSON payloads, missing data: prefixes, or binary garbage injected into the stream. The parse_sse_stream function's json.JSONDecodeError handler covers this, but your test must verify the handler fires and the chunk is still yielded with the _raw fallback.

To test these scenarios without hitting live providers, inject failures at the ASGI transport layer using a mock that yields crafted byte sequences. For example, a mock that sends three valid chunks followed by a connection close (no [DONE]) validates your incomplete-stream detection logic. Pair this with pytest.raises or assertion on a stream_error field in your response model.

Operational Integration with CI/CD

Streaming tests inherently depend on timing, which makes them flaky candidates for CI pipelines. Apply these hardening strategies:

For CI integration, run streaming tests against deterministic mock providers that return canned SSE sequences with fixed timing. Reserve live-provider streaming tests for a nightly or pre-release gate where flakiness is tolerable and results feed into Promptfoo evaluation dashboards for trend analysis. Tag streaming tests with @pytest.mark.streaming so teams can include or exclude them from fast feedback loops. Store TTFT percentiles (p50, p95, p99) as CI artifacts and alert when the p95 regresses beyond a configurable threshold—this catches infrastructure degradation (DNS resolution delays, TLS handshake changes, container resource limits) before it reaches users.

The streaming test harness you have built here becomes the foundation for the evaluation layers covered in the remaining goals: Promptfoo pipelines consume these same endpoints to run assertion-based prompt regression tests, RAGAS evaluation suites verify the factual quality of the streamed content, DeepEval benchmarks compare cross-provider output quality, and security red-teaming campaigns fuzz these endpoints with adversarial inputs designed to break the SSE parser or bypass content guardrails. Each subsequent layer builds on the confidence that the transport itself is reliable.

Code Walkthrough

Building on the error-injection categories and CI/CD integration patterns from the previous section, the harness comes together in three layers, each backed by code you can run against a FastAPI streaming endpoint today. First, you map the SSE contract onto each provider so the parser knows which frames mean "token", which mean "done", and which mean "error". Second, you build a StreamingTestClient that wraps httpx.AsyncClient and a parse_sse_stream() coroutine that yields uniform ParsedChunk dataclasses regardless of upstream framing. Third, you layer provider-aware assertions and fault-injection mocks on top so a single pytest invocation validates OpenAI, Anthropic, Gemini, and Llama 4/vLLM streams — including mid-stream errors and premature disconnects — without burning live tokens.

The SSE Contract and Why It Breaks Differently Per Provider

Before writing a single test, you must internalize what the SSE specification actually guarantees—and where each LLM provider deviates. The SSE protocol (defined in the W3C EventSource spec) transmits UTF-8 text frames separated by double newlines (\n\n), each containing optional event:, data:, id:, and retry: fields. The data: field carries the payload. A stream terminates when the server sends data: [DONE] (OpenAI convention) or simply closes the TCP connection.

Here is where provider divergence creates real bugs:

  • OpenAI sends data: {"choices": [{"delta": {"content": "token"}}]} with a final data: [DONE] sentinel. Empty delta objects appear for role-only chunks at stream start.
  • Anthropic uses a typed event system: event: content_block_delta with data: {"type": "content_block_delta", "delta": {"text": "token"}}. The stream ends with event: message_stop. There is no [DONE] sentinel.
  • Gemini streams JSON objects where each chunk contains candidates[0].content.parts[0].text. The stream termination signal is an empty response or connection close—no explicit sentinel.
  • Llama 4 (served via vLLM or TGI) follows the OpenAI-compatible format when using the /v1/chat/completions endpoint, but raw inference endpoints return newline-delimited JSON (NDJSON), not SSE.

These differences mean a single parse_sse_stream() function must handle variable framing, and your assertions must be provider-aware. The following diagram illustrates the test harness architecture that abstracts these concerns:

StreamingTestClient fires POST requests against a FastAPI streaming endpoint, capturing raw SSE byte streams that parse_sse_stream converts into typed ParsedChunk objects. A Provider Router then dispatches each chunk to provider-specific assertion modules—OpenAI, Anthropic, Gemini, and Llama 4/vLLM—before rolling results into a Unified Test Verdict. This architecture validates that every LLM provider's streaming contract holds before the CI/CD Pipeline Gate promotes a build, catching format regressions across four distinct SSE schemas in a single test pass.

Code snippet mermaid
Loading diagram...
  • Line 1: Declares a Mermaid flowchart with top-down (TD) layout direction.
  • Line 2: Defines node A ("StreamingTestClient") sending a POST /v1/chat/completions request to node B ("FastAPI Streaming Endpoint"), representing the test client initiating an SSE streaming call.
  • Line 3: Shows node B returning a Server-Sent Events byte stream to node C ("parse_sse_stream"), the function that deserializes the raw SSE data.
  • Line 4: Shows parse_sse_stream yielding typed ParsedChunk objects into node D, a decision diamond labeled "Provider Router" that branches based on the LLM provider format.
  • Lines 5-8: Define four parallel edges from the Provider Router to provider-specific assertion nodes: E for OpenAI format, F for Anthropic format, G for Gemini format, and H for Llama 4 / vLLM format, each applying provider-specific validation rules to the parsed chunks.
  • Lines 9-12: Converge all four assertion nodes (E, F, G, H) into a single node I ("Unified Test Verdict"), aggregating pass/fail results from every provider into one combined outcome.
  • Line 13: Connects the Unified Test Verdict to node J ("CI/CD Pipeline Gate"), forwarding metrics and failure details to determine whether the pipeline build passes or is blocked.

This architecture separates transport parsing (the parse_sse_stream layer) from semantic validation (provider-specific assertion sets), allowing you to add new providers without rewriting core test infrastructure.

Building the StreamingTestClient and SSE Parser

The foundation of the harness is a StreamingTestClient class that wraps httpx.AsyncClient to issue streaming requests against your FastAPI application and a parse_sse_stream() coroutine generator that consumes raw async byte iterators, reassembles SSE frames, and yields structured ParsedChunk dataclass instances. The client must handle connection-level timeouts separately from per-chunk read timeouts—a critical distinction because a stream that starts successfully but stalls mid-generation should fail differently than one that never connects. The ParsedChunk dataclass normalizes the heterogeneous provider payloads into a uniform shape with event_type, content, raw_data, is_done, and latency_ms fields, enabling downstream assertions to operate provider-agnostically where possible.

Code snippet python
1import httpx 2import json 3import time 4from dataclasses import dataclass, field 5from typing import AsyncIterator, Optional 6 7@dataclass 8class ParsedChunk: 9 event_type: str = "message" 10 content: str = "" 11 raw_data: dict = field(default_factory=dict) 12 is_done: bool = False 13 latency_ms: float = 0.0 14 15async def parse_sse_stream( 16 byte_stream: AsyncIterator[bytes], 17 provider: str = "openai", 18) -> AsyncIterator[ParsedChunk]: 19 buffer = "" 20 current_event = "message" 21 stream_start = time.perf_counter() 22 23 async for raw_bytes in byte_stream: 24 buffer += raw_bytes.decode("utf-8", errors="replace") 25 while "\n\n" in buffer: 26 frame, buffer = buffer.split("\n\n", 1) 27 chunk = ParsedChunk( 28 latency_ms=(time.perf_counter() - stream_start) * 1000 29 ) 30 for line in frame.strip().split("\n"): 31 if line.startswith("event:"): 32 current_event = line[len("event:"):].strip() 33 chunk.event_type = current_event 34 elif line.startswith("data:"): 35 data_str = line[len("data:"):].strip() 36 if data_str == "[DONE]": 37 chunk.is_done = True 38 yield chunk 39 return 40 try: 41 chunk.raw_data = json.loads(data_str) 42 except json.JSONDecodeError: 43 chunk.raw_data = {"_raw": data_str} 44 45 if not chunk.is_done and chunk.raw_data: 46 chunk.content = _extract_content(chunk.raw_data, provider) 47 if current_event == "message_stop": 48 chunk.is_done = True 49 yield chunk 50 51def _extract_content(data: dict, provider: str) -> str: 52 if provider in ("openai", "llama4"): 53 delta = data.get("choices", [{}])[0].get("delta", {}) 54 return delta.get("content", "") 55 elif provider == "anthropic": 56 return data.get("delta", {}).get("text", "") 57 elif provider == "gemini": 58 parts = data.get("candidates", [{}])[0].get("content", {}).get("parts", []) 59 return parts[0].get("text", "") if parts else "" 60 return ""
  • Lines 1-4: Imports httpx for async HTTP, json for SSE payload parsing, time for latency measurement, and dataclass utilities for the structured chunk type.
  • Lines 6-11: The ParsedChunk dataclass defines the normalized output contract. Every SSE frame becomes one of these, regardless of provider. The is_done field defaults to False and flips to True only when a terminal signal is detected.
  • Lines 13-16: parse_sse_stream accepts an async byte iterator (what httpx returns during streaming) and a provider string that controls content extraction logic. It returns an async generator of ParsedChunk objects.
  • Lines 17-19: A string buffer accumulates raw bytes because TCP delivery does not guarantee SSE frame alignment—a single recv() call might contain half a frame or three frames.
  • Lines 21-23: The outer loop reads raw bytes and appends decoded UTF-8 to the buffer. The errors="replace" parameter prevents crashes on malformed multi-byte sequences mid-chunk.
  • Lines 24-25: The inner while loop splits on \n\n (the SSE frame delimiter). Each split produces one complete frame for parsing while leftover bytes stay in the buffer.
  • Lines 26-28: A fresh ParsedChunk is created per frame with the current latency calculated from stream start.
  • Lines 29-33: Each line within a frame is inspected: lines starting with event: set the event type (critical for Anthropic's typed event system); lines starting with data: carry the payload.
  • Lines 34-36: The [DONE] sentinel (used by OpenAI and Llama 4) triggers immediate yield and generator return—no further frames are processed.
  • Lines 37-40: JSON parsing is attempted on the data payload. If the provider sends malformed JSON (a real production scenario during overload), the raw string is preserved under a _raw key rather than crashing the entire test.
  • Lines 42-46: For non-terminal frames with valid data, content is extracted via the provider-specific _extract_content helper, and the Anthropic message_stop event type is detected as a done signal.
  • Lines 48-57: _extract_content is a pure function that navigates the different JSON nesting structures for each provider. OpenAI and Llama 4 share the choices[0].delta.content path, Anthropic uses delta.text, and Gemini nests under candidates[0].content.parts[0].text. Returning an empty string for unknown providers ensures graceful degradation rather than a KeyError crash.

Writing Provider-Parameterized Async Tests

With the parser in place, the next layer is a pytest-based async test suite using pytest.mark.parametrize to run identical test logic across all four providers. The test below validates three critical streaming properties: that at least one non-empty content chunk arrives, that the concatenated output forms coherent text (not garbled bytes), and that the stream terminates with an explicit done signal within a configurable timeout. The StreamingTestClient class wraps httpx.AsyncClient with the application's ASGI transport so tests execute in-process without needing a running server, while anyio provides the async test backend.

Code snippet python
1import pytest 2import httpx 3from httpx import ASGITransport 4from app.main import app # your FastAPI application 5 6class StreamingTestClient: 7 def __init__(self, timeout: float = 30.0): 8 transport = ASGITransport(app=app) 9 self.client = httpx.AsyncClient(transport=transport, base_url="http://test") 10 self.timeout = httpx.Timeout(timeout, connect=5.0) 11 12 async def stream_completion(self, provider: str, prompt: str): 13 payload = {"model": f"{provider}/default", "messages": [{"role": "user", "content": prompt}], "stream": True} 14 async with self.client.stream("POST", "/v1/chat/completions", json=payload, timeout=self.timeout) as resp: 15 assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" 16 chunks = [] 17 async for chunk in parse_sse_stream(resp.aiter_bytes(), provider): 18 chunks.append(chunk) 19 return chunks 20 21PROVIDERS = ["openai", "anthropic", "gemini", "llama4"] 22 23@pytest.mark.anyio 24@pytest.mark.parametrize("provider", PROVIDERS) 25async def test_stream_delivers_content(provider: str): 26 client = StreamingTestClient(timeout=45.0) 27 chunks = await client.stream_completion(provider, "Say hello in one sentence.") 28 content_chunks = [c for c in chunks if c.content] 29 assert len(content_chunks) > 0, f"{provider}: no content chunks received" 30 full_text = "".join(c.content for c in content_chunks) 31 assert len(full_text) > 5, f"{provider}: response too short: {full_text!r}" 32 assert chunks[-1].is_done is True, f"{provider}: stream did not terminate cleanly" 33 34@pytest.mark.anyio 35@pytest.mark.parametrize("provider", PROVIDERS) 36async def test_first_chunk_latency(provider: str): 37 client = StreamingTestClient(timeout=45.0) 38 chunks = await client.stream_completion(provider, "Say hello.") 39 first_content = next((c for c in chunks if c.content), None) 40 assert first_content is not None, f"{provider}: no content chunk found" 41 assert first_content.latency_ms < 3000, ( 42 f"{provider}: TTFT {first_content.latency_ms:.0f}ms exceeds 3s threshold" 43 )
  • Lines 1-4: Test dependencies are imported—httpx for the async client, ASGITransport for in-process ASGI testing without spawning a server, and the FastAPI app instance from your application module.
  • Lines 6-10: StreamingTestClient.__init__ creates an httpx.AsyncClient bound to the ASGI transport. The Timeout object separates connection timeout (5 seconds) from overall read timeout, which prevents slow provider responses from hanging CI indefinitely.
  • Lines 12-19: stream_completion constructs a standard chat completion payload with stream set to True, opens a streaming POST request, asserts the HTTP status is 200, and collects all parsed chunks into a list for assertion.
  • Line 21: The PROVIDERS list serves as the parametrize source. Adding a fifth provider later requires only appending to this list.
  • Lines 23-33: test_stream_delivers_content validates three invariants per provider: at least one chunk carries non-empty content, the assembled text exceeds a minimum length (catching edge cases where providers return only whitespace), and the final chunk has is_done set to True.
  • Lines 35-44: test_first_chunk_latency measures time-to-first-token (TTFT) by inspecting the latency_ms of the first chunk that contains content. The 3-second threshold is aggressive for production but appropriate for CI, where provider round-trip time should be predictable behind a mock or staging endpoint. Using next() with a generator expression returns None if no content chunk exists, which the subsequent assertion catches cleanly.

Do's and Don'ts

Do's

  1. Do normalize heterogeneous provider payloads into a uniform ParsedChunk dataclass inside parse_sse_stream() — keeping transport parsing separate from provider-specific assertion modules means you can add a fifth provider without rewriting OpenAI, Anthropic, or Gemini assertion logic, and a single pytest run covers all four formats in one pass.
  2. Do configure connection-level and per-chunk read timeouts as distinct parameters in StreamingTestClient — a stream that opens a TCP connection and then stalls mid-generation is a different failure mode from one that never connects, and collapsing both into a single timeout causes the harness to misclassify hung streams as connectivity errors.
  3. Do write provider-specific termination assertions behind the Provider Router for each of the four distinct stream-end signals — OpenAI's data: [DONE] sentinel, Anthropic's event: message_stop, Gemini's connection-close, and Llama 4/vLLM's NDJSON format each require separate checks, so a router that dispatches ParsedChunk objects to the right assertion module catches format regressions a single generic validator will silently miss.

Don'ts

  1. Don't assume OpenAI's data: [DONE] sentinel applies to Anthropic or Gemini streams — Anthropic ends with event: message_stop and emits no [DONE] frame, while Gemini signals termination via connection close; a parser that waits for [DONE] will hang indefinitely on both providers, reproducing exactly the hung browser-tab failure the harness is designed to catch.
  2. Don't point the StreamingTestClient at Llama 4's raw inference endpoint expecting SSE frames — raw vLLM and TGI inference routes return NDJSON (newline-delimited JSON), not SSE; only the /v1/chat/completions OpenAI-compatible endpoint sends SSE, so parse_sse_stream() must not be applied to the raw route or it will yield zero ParsedChunk objects and pass vacuously.
  3. Don't use live API calls to exercise mid-stream error paths like premature disconnects or dropped connections — fault-injection mocks let the CI gate validate that StreamingTestClient propagates those errors correctly across all four providers without burning tokens, hitting rate limits, or making test results non-deterministic on network variance.

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 · Already a subscriber? Sign in →

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering