Azure OpenAI streaming (SSE) differences from OpenAI — content filter chunks, prompt filter results (2026) — Fix Guide (2026) | AI Error Hub
Home Providers Azure OpenAI Streaming differences
Azure OpenAI Streaming · SSE Severity: Medium HTTP 200

Azure OpenAI streaming — extra prompt_filter_results and content_filter_results chunks

Copy-pasted OpenAI streaming code usually works on Azure. Then one day it emits a chunk that has no choices array — the code crashes and the outage is on you.

By Sana K. · Cloud AI Reliability Engineer Published 2026-07-26 Verified 2026-07-26

Quick fix (TL;DR)

Resolution: Azure OpenAI streams the same SSE format as openai.com but adds two extra chunk shapes: (1) a leading chunk with only prompt_filter_results and an empty choices array, and (2) per-token content_filter_results attached to delta chunks. Fix by (a) always guarding if not chunk.choices: continue, (b) checking choice.finish_reason == "content_filter" alongside stop/length, and (c) reading the filter results only after the stream completes for full context.

Real error messages you'll see

These are the exact strings returned by the Azure OpenAI service and its SDKs when this error occurs. Copy-paste-searching any of them should land on this page.

Python — IndexError on first chunk
Traceback (most recent call last):
  File "app.py", line 42, in stream_response
    delta = chunk.choices[0].delta
IndexError: list index out of range
Prompt filter chunk (Azure adds this before content starts)
data: {"id":"chatcmpl-...","object":"chat.completion.chunk",
"created":1728000000,"model":"gpt-4o-2024-08-06",
"prompt_filter_results":[{"prompt_index":0,"content_filter_results":
{"hate":{"filtered":false,"severity":"safe"},...}}],
"choices":[]}
Content filter chunk mid-stream
data: {"id":"chatcmpl-...","object":"chat.completion.chunk",
"choices":[{"delta":{},"finish_reason":"content_filter","index":0,
"content_filter_results":{"sexual":{"filtered":true,"severity":"medium"}}}]}

Reference

Chunk shapes you will see on an Azure OpenAI stream

Chunk typeWhen it arriveschoices array?How to handle
Prompt filter resultsFirst chunk of stream (Azure only)Empty []if not chunk.choices: continue
Role deltaEarly — usually 2nd chunkOne item with role onlyEmit nothing to UI, remember the role
Content deltaEvery subsequent chunkOne item with contentAppend to buffer, emit to UI
Tool call deltaWhen model calls a toolOne item with tool_calls deltaAccumulate tool call fragments
Content filter mid-streamRare — response was filtered while generatingfinish_reason=content_filterAbort UI stream, show filter message
Final chunkLast chunkfinish_reason setEmit end-of-stream event
Usage chunkAfter final chunk (when stream_options.include_usage=true)Empty [], has usageRecord usage for billing

Azure-specific fields not present on openai.com

FieldLocationPurpose
prompt_filter_resultsTop-level of first chunkContent filter analysis of the prompt
content_filter_resultsPer choiceContent filter analysis of the completion (or delta)
prompt_filter_resultOn non-stream response objectPer-prompt filter results (index-keyed for multi-prompt requests)

Root causes, ranked by frequency

Based on developer reports across Azure OpenAI SDK forums, GitHub issues, and Microsoft Q&A during 2025–2026.

  • 40%
    Code assumes chunk.choices[0] exists. The prompt-filter chunk has an empty choices array. Any code that does not guard first crashes on the very first chunk.
  • 20%
    Streaming parser expects only OpenAI-shaped chunks. Third-party libraries pinned to old openai package versions do not know about content_filter chunks; they may skip them, mishandle them, or emit warnings that flood logs.
  • 14%
    Client discards partial output on content_filter without showing the user why. UX regression — user sees a spinner then nothing.
  • 10%
    Reasoning models (o1, o3) send no text tokens until reasoning is complete — the stream appears "stuck" for 20-60 seconds. Clients that timeout waiting for first token disconnect.
  • 7%
    Usage chunk arriving after finish_reason. Client closes the stream on finish_reason=stop and misses the usage chunk. Billing / cost telemetry undercounts.
  • 5%
    Tool-call deltas fragmented across chunks. arguments field arrives as many small deltas that must be concatenated — parsing each chunk as a complete tool call fails.
  • 3%
    Content filter severity threshold mismatch. Client treats "low" severity as blocking even though the response was returned; premature abort.
  • 1%
    Server-side buffering by API Management or gateway. Corporate gateways sometimes buffer SSE, defeating streaming — client sees all chunks at once.

Fixes — copy-paste solutions

Fix #1

Write a streaming parser that handles all Azure chunk shapes

The canonical parser for production use.

Iterate the stream and emit structured events for tokens, tool calls, filter results, and end-of-stream. Guard against empty choices arrays on every chunk. This parser handles both Azure and openai.com correctly.

azure_stream_parser.py
from typing import Iterator, Dict, Any
from openai import AzureOpenAI

client = AzureOpenAI(api_key="...", azure_endpoint="...", api_version="2024-10-21")

def stream_chat(messages: list, deployment: str = "gpt4o-prod") -> Iterator[Dict[str, Any]]:
    """Yield structured events from an Azure OpenAI streaming completion.

    Events:
        {'type': 'prompt_filter', 'results': {...}}
        {'type': 'role', 'role': 'assistant'}
        {'type': 'token', 'content': '...'}
        {'type': 'tool_call_delta', 'index': 0, 'name': '...', 'arguments_delta': '...'}
        {'type': 'content_filter', 'category': '...', 'severity': '...'}
        {'type': 'finish', 'reason': 'stop|length|content_filter|tool_calls'}
        {'type': 'usage', 'prompt_tokens': int, 'completion_tokens': int, 'total_tokens': int}
    """
    stream = client.chat.completions.create(
        model=deployment,
        messages=messages,
        stream=True,
        stream_options={"include_usage": True},
    )

    for chunk in stream:
        # Azure-specific: prompt filter results with empty choices
        pfr = getattr(chunk, "prompt_filter_results", None)
        if pfr:
            yield {"type": "prompt_filter", "results": pfr}

        # Usage chunk (empty choices, has usage)
        if chunk.usage:
            yield {
                "type": "usage",
                "prompt_tokens": chunk.usage.prompt_tokens,
                "completion_tokens": chunk.usage.completion_tokens,
                "total_tokens": chunk.usage.total_tokens,
            }

        # Guard the empty-choices case — critical for Azure
        if not chunk.choices:
            continue

        choice = chunk.choices[0]
        delta = choice.delta

        if delta and delta.role:
            yield {"type": "role", "role": delta.role}

        if delta and delta.content:
            yield {"type": "token", "content": delta.content}

        if delta and delta.tool_calls:
            for tc in delta.tool_calls:
                yield {
                    "type": "tool_call_delta",
                    "index": tc.index,
                    "name": (tc.function.name if tc.function else None),
                    "arguments_delta": (tc.function.arguments if tc.function else None),
                }

        # Azure-specific: per-choice content filter results
        cfr = getattr(choice, "content_filter_results", None) or {}
        for category, result in cfr.items():
            if result.get("filtered"):
                yield {
                    "type": "content_filter",
                    "category": category,
                    "severity": result.get("severity"),
                }

        if choice.finish_reason:
            yield {"type": "finish", "reason": choice.finish_reason}

# Usage
for event in stream_chat([{"role": "user", "content": "Hello"}]):
    if event["type"] == "token":
        print(event["content"], end="", flush=True)
    elif event["type"] == "finish":
        print(f"\n[Finished: {event['reason']}]")
    elif event["type"] == "usage":
        print(f"[Tokens: {event['total_tokens']}]")
Use this pattern for every Azure OpenAI streaming client. The empty-choices guard is not optional — Azure sends 1-2 chunks per stream where choices == [].
Fix #2

Accumulate tool-call deltas across chunks correctly

Tool call arguments arrive as fragments — reassemble before parsing JSON.

Each tool_call_delta contains a fragment of the JSON arguments string. Only the complete concatenation is valid JSON. Buffer per (index) and parse at finish_reason.

tool_call_accumulator.py
import json
from typing import List, Dict, Any
from azure_stream_parser import stream_chat  # from previous fix

def stream_with_tool_calls(messages: list, tools: list, deployment: str = "gpt4o-prod"):
    """Run a stream that may return tool calls; yield tokens and reconstruct tool calls."""
    from openai import AzureOpenAI
    client = AzureOpenAI(api_key="...", azure_endpoint="...", api_version="2024-10-21")

    stream = client.chat.completions.create(
        model=deployment,
        messages=messages,
        tools=tools,
        stream=True,
    )

    # Accumulators keyed by tool_call index
    tool_buffers: Dict[int, Dict[str, Any]] = {}

    for chunk in stream:
        if not chunk.choices:
            continue
        choice = chunk.choices[0]
        delta = choice.delta

        if delta and delta.content:
            yield {"type": "token", "content": delta.content}

        if delta and delta.tool_calls:
            for tc in delta.tool_calls:
                idx = tc.index
                buf = tool_buffers.setdefault(idx, {"id": None, "name": None, "arguments": ""})
                if tc.id:
                    buf["id"] = tc.id
                if tc.function:
                    if tc.function.name:
                        buf["name"] = tc.function.name
                    if tc.function.arguments:
                        buf["arguments"] += tc.function.arguments

        if choice.finish_reason == "tool_calls":
            # Finalise: parse each buffered arguments JSON
            for idx in sorted(tool_buffers):
                buf = tool_buffers[idx]
                try:
                    args = json.loads(buf["arguments"])
                except json.JSONDecodeError as e:
                    # Model produced malformed JSON — surface for handling
                    yield {"type": "tool_call_error", "index": idx, "raw": buf["arguments"], "error": str(e)}
                    continue
                yield {
                    "type": "tool_call",
                    "index": idx,
                    "id": buf["id"],
                    "name": buf["name"],
                    "arguments": args,
                }
Do not try to parse JSON on each delta — it will fail on almost every chunk. Only parse after finish_reason=tool_calls, when the full string is available.
Fix #3

Handle mid-stream content filter cleanly in the UI

When the model self-filters, discard partial output and show a clear signal.

The response streams normally then hits content_filter mid-way. The UI must discard whatever partial content was shown and display a clear filtered-state message rather than an ambiguous half-response.

ui_stream_handler.py
from azure_stream_parser import stream_chat

def handle_stream_for_ui(user_msg: str):
    """Emit UI events; roll back partial content if content_filter triggers mid-stream."""
    buffer = []
    ui_state = "streaming"

    for event in stream_chat([{"role": "user", "content": user_msg}]):
        if event["type"] == "prompt_filter":
            # Pre-generation filter — decide whether to proceed
            triggered = [k for k, v in event["results"][0]["content_filter_results"].items()
                         if v.get("filtered")]
            if triggered:
                yield {"ui": "error", "message": f"Content policy: {triggered}"}
                return

        elif event["type"] == "token":
            buffer.append(event["content"])
            yield {"ui": "append", "content": event["content"]}

        elif event["type"] == "content_filter":
            # Mid-stream filter — roll back what we streamed
            ui_state = "filtered"
            yield {"ui": "clear"}
            yield {"ui": "error",
                   "message": f"The response was filtered ({event['category']}, {event['severity']}). "
                              "Please rephrase your question."}
            return

        elif event["type"] == "finish":
            if event["reason"] == "content_filter" and ui_state == "streaming":
                # Filter fired at the very end (no per-token filter event)
                yield {"ui": "clear"}
                yield {"ui": "error", "message": "Response was filtered — please rephrase."}
                return
            yield {"ui": "done", "reason": event["reason"], "full_text": "".join(buffer)}
The "clear then error" pattern is worse UX than never showing partial content. For sensitive workloads, buffer the first 5-10 tokens client-side and only start painting after content_filter has been safe on those chunks.

Prevention checklist

Ship these seven safeguards once and this error stops appearing in your logs.

  • Always guard if not chunk.choices: continue at the top of your stream loop.
  • Include stream_options={"include_usage": true} when you need token accounting — it is off by default.
  • Parse tool call arguments only at finish_reason=tool_calls, never per-chunk.
  • Distinguish all four finish_reasons in your UI: stop, length, content_filter, tool_calls.
  • Log both prompt_filter_results and content_filter_results to your observability stack — patterns reveal quality issues.
  • For reasoning models, set a first-token timeout of 60-90 seconds, not the default 10 — thinking time is long.
  • Disable buffering in any gateway between client and Azure OpenAI (API Management, Front Door). SSE requires immediate flush.

Frequently asked questions

Azure OpenAI runs the content filter on the input prompt before streaming begins. The results are returned as the first SSE chunk before any completion tokens are generated. openai.com does not do this — it starts token streaming immediately.
No — they are part of Azure OpenAI's content safety guarantees. You can ignore them by guarding empty choices arrays, but you cannot suppress them at the API level.
On non-streaming responses, usage is always in the response object. On streaming, usage is only sent when you pass stream_options={"include_usage": true} and arrives as an extra chunk after the finish chunk. Many clients close the stream on finish_reason and miss this usage chunk.
Reasoning models generate internal reasoning tokens first, then produce output. During reasoning, no content chunks arrive — the stream appears silent for 20-60 seconds. You still get chunks (usage counters update, small keepalives), but no visible text. Clients should not timeout on missing content chunks for reasoning-capable deployments.
No, per-token cost is identical. Streaming reduces perceived latency (users see output start immediately) but does not change billing. The one caveat: incomplete streams that fail after some tokens are still billed for those tokens.

Get the weekly AI-error digest

New fixes, provider status recaps, and one deep tutorial — every Tuesday. 8,400+ engineers.