Python: read headers defensively to support stream wrappers without .headers (#6028) (#6029)

`OpenAIChatClient._inner_get_response()` reads `.headers` on the raw streaming
response returned by `client.responses.with_raw_response.create(stream=True)`
(and its three sibling call sites - retrieve-streaming, non-streaming create
and background retrieve) to surface the `x-ms-served-model` Azure header,
introduced in #5910.

When `azure-ai-projects>=2.1.0` experimental GenAI tracing is enabled
(`AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true`), the instrumentor wraps the
raw streaming response in an inline `AsyncStreamWrapper` that exposes
`.response` but not `.headers`. Reading `raw_create_response.headers` then
raises `AttributeError: 'AsyncStreamWrapper' object has no attribute 'headers'`,
which `FoundryChatClient` rethrows as a `ChatClientException` and breaks every
streaming call (workflows and free chat).

Fix: read the header dict via `getattr(raw_response, "headers", None)` at all
four call sites. `_extract_served_model()` already short-circuits on `None`,
so the served-model surfacing degrades gracefully (model stays the deployment
alias) instead of crashing when the response is wrapped by an instrumentor
that does not proxy `.headers`.

Regression test added:
`test_streaming_response_without_headers_attribute_does_not_crash`
simulates a stream wrapper that raises `AttributeError` on `.headers` and
asserts the stream still completes with the deployment alias as `update.model`.

Fixes #6028

Co-authored-by: Emilien Mottet <emilien.mottet@michelin.com>
This commit is contained in:
Emilien Mottet
2026-05-28 10:37:38 +02:00
committed by GitHub
Unverified
parent efdabd56dc
commit 3db2004e49
2 changed files with 93 additions and 4 deletions
@@ -636,7 +636,11 @@ class RawOpenAIChatClient( # type: ignore[misc]
continuation_token["response_id"],
stream=True,
)
served_model = self._extract_served_model(raw_stream_response.headers)
# Read headers defensively: telemetry instrumentors (e.g. azure-ai-projects
# experimental tracing) wrap the streaming response in objects that do not
# proxy ``.headers``. Degrade gracefully so the served-model surfacing is
# best-effort instead of crashing the whole call.
served_model = self._extract_served_model(getattr(raw_stream_response, "headers", None))
async with raw_stream_response.parse() as stream_response:
async for chunk in stream_response:
update = self._parse_chunk_from_openai(
@@ -677,7 +681,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
raw_create_response = await client.responses.with_raw_response.create(
stream=True, **run_options
)
served_model = self._extract_served_model(raw_create_response.headers)
# See note above on ``raw_stream_response.headers``.
served_model = self._extract_served_model(getattr(raw_create_response, "headers", None))
async with raw_create_response.parse() as stream_response:
async for chunk in stream_response:
update = self._parse_chunk_from_openai(
@@ -706,7 +711,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
except Exception as ex:
self._handle_request_error(ex)
chat_response = self._parse_response_from_openai(response, options=validated_options)
served_model = self._extract_served_model(raw_response.headers)
# See note above on ``raw_stream_response.headers``.
served_model = self._extract_served_model(getattr(raw_response, "headers", None))
if served_model is not None:
chat_response.model = served_model
# Once the background response completes, drop the continuation_token from
@@ -728,7 +734,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
except Exception as ex:
self._handle_request_error(ex)
chat_response = self._parse_response_from_openai(response, options=validated_options)
served_model = self._extract_served_model(raw_response.headers)
# See note above on ``raw_stream_response.headers``.
served_model = self._extract_served_model(getattr(raw_response, "headers", None))
if served_model is not None:
chat_response.model = served_model
return chat_response