Skip to content

Python: fix streaming when GenAI tracing replaces the raw response (#7461) - #7705

Open
madanmishra1223 wants to merge 4 commits into
microsoft:mainfrom
madanmishra1223:fix/7461-tracing-wrapped-stream
Open

Python: fix streaming when GenAI tracing replaces the raw response (#7461)#7705
madanmishra1223 wants to merge 4 commits into
microsoft:mainfrom
madanmishra1223:fix/7461-tracing-wrapped-stream

Conversation

@madanmishra1223

Copy link
Copy Markdown

Fixes#7461

Problem

With AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true, every streaming request fails:

AttributeError: 'AsyncStreamWrapper' object has no attribute 'parse'

The Azure GenAI instrumentor replaces the SDK's raw-response wrapper with an object that is the event stream and exposes neither .parse() nor .headers.

Both streaming paths in _chat_client.py already anticipate this for headers, and say so in a comment:

# 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))
asyncwithraw_stream_response.parse() asstream_response: # <-- not defensive

.headers is read through getattr, then .parse() is called unconditionally on that same object. So the defense is half-applied: the attribute that only degrades a nice-to-have is guarded, and the attribute that breaks the entire call is not.

Fix

Read .parse defensively too. _open_event_stream() uses .parse() when present — preserving the async with so the socket still closes deterministically — and otherwise iterates the object directly, letting the instrumentor own the stream's lifetime.

Behavior is unchanged for the normal SDK wrapper; only the instrumented case changes, from raising to streaming.

Both affected streaming call sites are updated (the retrieve continuation path and the create path).

The two non-streaming .parse() sites are deliberately left alone: the instrumentor's wrapper is stream-specific (AsyncStreamWrapper), and non-streaming was not reported as failing. Happy to extend if maintainers prefer symmetry.

Verification

Repro before the fix, using a stand-in wrapper that is async-iterable with no .parse()/.headers:

instrumented object: AsyncStreamWrapper
has .parse() : False
has .headers : False
async-iterable: True
BUG: ChatClientException: (... service failed to complete the prompt:
'AsyncStreamWrapper' object has no attribute 'parse'", AttributeError(...))

After:

RESULT: streamed 'Hello world'
OK: streaming survived the instrumented wrapper

Added test_streaming_survives_instrumented_response_without_parse, which fails on main with the exact reported AttributeError and passes with this change.

Checks run locally:

CheckResult
pytest packages/openai/tests453 passed, 89 skipped
pytest packages/core/tests4080 passed, 23 skipped, 2 xfailed
pyright (openai package)0 errors — same as baseline
mypy (test file)clean
ruff format --check / ruff checkclean

Note

The issue is assigned to Tao Chen (@TaoChenOSU). There was no linked PR after ~2.5 weeks, so I picked it up — happy to close this if it is already in progress.

…ft#7461)
Setting AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true made every streaming
request fail with:
AttributeError: 'AsyncStreamWrapper' object has no attribute 'parse'
The Azure GenAI instrumentor replaces the SDK's raw-response wrapper with an
object that *is* the event stream and exposes neither .parse() nor .headers.
Both streaming paths already read .headers defensively via getattr, with a
comment explaining that instrumentors wrap the response -- but then called
.parse() unconditionally on that same object.
Read .parse defensively too: _open_event_stream() uses .parse() when present
and otherwise iterates the object directly, letting the instrumentor own the
stream's lifetime. Behavior is unchanged for the normal SDK wrapper.
The non-streaming .parse() call sites are left alone: the instrumentor's
wrapper is stream-specific and those paths were not reported as failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a defensive streaming path so chat streaming continues to work when telemetry/tracing replaces the SDK raw-response wrapper (removing .parse() / .headers), and introduces a regression test for that scenario.

Changes:

  • Added _open_event_stream() async context manager to safely obtain an event stream with/without .parse().
  • Updated streaming code paths to use _open_event_stream() instead of unconditionally calling .parse().
  • Added a regression test that simulates an instrumented bare async stream without .parse() / .headers.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

FileDescription
python/packages/openai/agent_framework_openai/_chat_client.pyAdds _open_event_stream() and switches streaming to use it to avoid .parse() AttributeError under instrumentation.
python/packages/openai/tests/openai/test_openai_chat_client.pyAdds regression coverage for streaming when the raw response is replaced by a bare async stream.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment threadpython/packages/openai/agent_framework_openai/_chat_client.py Outdated
@agent-framework-automationagent-framework-automationBot added the python Usage: [Issues, PRs], Target: Python label Aug 17, 2026
Review feedback: yielding the telemetry wrapper as-is only moved the
AttributeError. Verified against azure-ai-projects==2.3.0 with openai==2.53.0:
with_raw_response.create() routes through the instrumented AsyncResponses.create,
so AsyncStreamWrapper.stream_async_iter is the still-unparsed LegacyAPIResponse,
which is not an async iterator. Iterating the wrapper fails on the first
__anext__ and traced streaming stays broken.
Parse that inner raw response and hand it back to the wrapper instead, so the
wrapper stays in the iteration path and keeps recording telemetry while real
events flow through it.
The previous test patched with_raw_response.create, i.e. above the layer that
does the wrapping, so it could not catch this. The test now models the observed
object graph -- a wrapper with no parse/headers whose stream_async_iter is an
unparsed raw response -- and fails against the previous fix. A second test
covers a bare event stream with nothing to parse.
Also guard with callable() rather than an is-None check, per review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@madanmishra1223

Copy link
Copy Markdown
Author

Thanks both — Evan Mattson (@moonbox3) you were right, and the PR is updated.

Verified the failure you described

I reproduced it against the real instrumentor (azure-ai-projects==2.3.0, openai==2.53.0, AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true), with a mocked transport so no Azure resources are needed. The instrumentor needs both an OTel TracerProviderandazure-core-tracing-opentelemetry for _create_responses_span_from_parameters to return a span — without them it returns the result unwrapped and the bug doesn't appear at all, which is what made this easy to miss.

With tracing genuinely active:

[wrap] _wrap_async_streaming_response receives stream=openai._legacy_response.LegacyAPIResponse
with_raw_response.create() -> azure.ai.projects.telemetry._responses_instrumentor.AsyncStreamWrapper
has .parse() : False
has .headers : False
.stream_async_iter = openai._legacy_response.LegacyAPIResponse
has __anext__: False has parse: True
--- iterating the wrapper directly (what the first commit did) ---
FAILS: AttributeError: 'LegacyAPIResponse' object has no attribute '__anext__'

Exactly as you said: with_raw_response.create() routes through the instrumented AsyncResponses.create, so stream_async_iter is the still-unparsed LegacyAPIResponse. My first commit just traded one AttributeError for another and traced streaming stayed broken.

You were also right about why the test missed it: it patched with_raw_response.create, which is above the layer that installs the wrapper, so the wrapper's inner raw response never existed in that test.

What changed

_open_event_stream() now parses the raw response the wrapper holds and hands it back, so the wrapper stays in the iteration path and keeps recording telemetry:

inner=getattr(raw_response, "stream_async_iter", None)
inner_parse=getattr(inner, "parse", None)
ifcallable(inner_parse):
asyncwithcast("Any", inner_parse()) asstream:
raw_response.stream_async_iter=streamyieldraw_responsereturn

End-to-end against the real instrumentor now:

raw from with_raw_response : AsyncStreamWrapper
.stream_async_iter : LegacyAPIResponse
_open_event_stream yielded: AsyncStreamWrapper
RESULT: streamed 'Hello world'
telemetry wrapper still in iteration path: True

The test now models that observed object graph — a wrapper with no parse/headers whose stream_async_iter is an unparsed raw response — and fails against my previous commit with AttributeError: '_UnparsedRawResponse' object has no attribute '__aiter__', mirroring the real failure. I added a second test for a bare event stream with nothing to parse, covering the remaining branch.

I kept the fake rather than adding azure-ai-projects as a test dependency, since that would be a heavy dep for this package — happy to add a marked integration test instead if you'd prefer the real thing in CI.

On the two Copilot comments

  • callable(parse) — good catch, adopted for both the outer and inner lookups.
  • AsyncIterator vs AsyncGenerator — respectfully leaving this as AsyncGenerator. This repo's pinned pyright (1.1.411) reports the opposite as an error: Annotating the return type as -> AsyncIterator[Foo] with @asynccontextmanager is deprecated. Use -> AsyncGenerator[Foo] instead. (reportDeprecated). I had it as AsyncIterator first and pyright failed on it; AsyncGenerator is what keeps the package at 0 errors.

Checks

CheckResult
pytest packages/openai/tests454 passed, 89 skipped
pytest packages/core/tests4080 passed, 23 skipped, 2 xfailed
pyright (openai package)0 errors — matches baseline
mypy (test file)clean
ruff format --check / ruff checkclean
e2e vs real azure-ai-projects==2.3.0streams correctly, wrapper retained

@moonbox3

Copy link
Copy Markdown
Contributor

madanmishra1223 please resolve all open comments if they've been addressed

@github-actions

github-actionsBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/openai/agent_framework_openai
_chat_client.py140511991%361, 374, 728–734, 743–746, 752–756, 764, 808–813, 817–820, 822–824, 831–833, 836, 893, 901, 924, 1141, 1200, 1202, 1204, 1206, 1272, 1286, 1366, 1376, 1381, 1424, 1536–1537, 1552, 1831, 1938, 1943–1944, 2027, 2037, 2064, 2070, 2080, 2086, 2091, 2097, 2102–2103, 2183, 2227, 2230–2233, 2247, 2257–2258, 2270, 2312, 2377, 2394, 2397, 2424–2426, 2465, 2482, 2485, 2547, 2554, 2591–2592, 2627, 2665–2666, 2684–2685, 2857–2858, 2876, 2962–2970, 3148, 3163, 3252–3254, 3264–3265, 3271, 3286, 3419–3420
TOTAL47141437390%

Python Unit Test Overview

TestsSkippedFailuresErrorsTime
956336 💤0 ❌0 🔥2m 36s ⏱️

Test Typing Checks caught two problems in the new test helpers:
- ty: the wrapper's stream_async_iter was annotated `object`, so delegating to
__aiter__/__anext__ was an attribute error. Annotate it `Any`, which also
makes the two `type: ignore` comments unnecessary.
- zuban: `_BareEventStream` was declared inside the test function, and its own
forward-referenced return annotation does not resolve there. Move it to
module scope alongside the other stream fakes.
Verified with the task CI runs, `poe test-typing -P openai`: mypy, pyrefly, ty,
zuban and pyright all pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TaoChenOSU

Copy link
Copy Markdown
Contributor

Hi madanmishra1223,

Thanks for the contributions! Could you also create an issue in the Azure SDKs repo and link it here?

@madanmishra1223

Copy link
Copy Markdown
Author

Thanks Tao Chen (@TaoChenOSU) — filed upstream: Azure/azure-sdk-for-python#48646

[azure-ai-projects] ResponsesInstrumentor breaks openai with_raw_response streaming (AsyncStreamWrapper wraps an unparsed LegacyAPIResponse)

It has a standalone repro (openai + azure-ai-projects only, mocked transport, no Azure resources) and frames it as the raw-response contract being broken by the instrumentor:

  • with_raw_response.create(..., stream=True) returns AsyncStreamWrapper instead of a raw response
  • no .parse(), no .headers
  • stream_async_iter is the still-unparsed LegacyAPIResponse, so iterating raises AttributeError: 'LegacyAPIResponse' object has no attribute '__anext__'
  • uninstrumenting restores the correct LegacyAPIResponse with .parse() / .headers

I called out there that reproducing needs both an OTel TracerProviderandazure-core-tracing-opentelemetry — without them _create_responses_span_from_parameters returns None, the instrumentor returns the result unwrapped, and the bug silently doesn't appear. That tripped me up initially, so it seemed worth stating explicitly for whoever picks it up.

I also linked this PR from that issue and noted the workaround here can be dropped once it's fixed upstream.

One process note: this branch is still showing action_required on the workflows, so the Test Typing Checks fix in c85069cd hasn't been re-verified by CI yet. It passes locally via the task CI uses (poe test-typing -P openai: mypy, pyrefly, ty, zuban, pyright all green) — but it would need a workflow approval to confirm in CI.

@TaoChenOSU

Copy link
Copy Markdown
Contributor

Hi madanmishra1223,

Thanks! Please also resolve all open comments when you can.

@madanmishra1223

Copy link
Copy Markdown
Author

Tao Chen (@TaoChenOSU) all three review threads are resolved. Since resolving collapses them, here is what each one says, so it is readable without expanding:

1. Copilot — guard parse with callable() (r3799495111)
Adopted. Both lookups now use callable() instead of an is None check, which matters more than it first looked since the helper probes parse on two different objects:

parse=getattr(raw_response, "parse", None)
ifcallable(parse): ...
inner_parse=getattr(inner, "parse", None)
ifcallable(inner_parse): ...

2. Copilot — use AsyncIterator instead of AsyncGenerator (r3799495126)
Not applied, deliberately. This repo's pinned pyright reports the opposite as an error. I had it as AsyncIterator first and pyright==1.1.411 failed the package:

Annotating the return type as `-> AsyncIterator[Foo]` with `@asynccontextmanager` is deprecated.
Use `-> AsyncGenerator[Foo]` instead. (reportDeprecated)

Baseline for the package is 0 errors; AsyncIterator took it to 1. Switching back would break Test Typing Checks. Happy to revisit if you would rather match the convention and suppress the rule.

3. Evan Mattson (@moonbox3) — exercise it through the real azure-ai-projects instrumentor (r3800092642)
You were right, and this changed the fix. Reproduced against azure-ai-projects==2.3.0 + openai==2.53.0 with a mocked transport: with_raw_response.create() returns AsyncStreamWrapper whose stream_async_iter is the still-unparsed LegacyAPIResponse, so my first commit only traded one AttributeError for another:

AttributeError: 'LegacyAPIResponse' object has no attribute '__anext__'

Your point about the test was right too — it patched with_raw_response.create, above the layer that installs the wrapper, so the inner raw response never existed in it.

_open_event_stream() now parses the wrapped raw response and hands it back, keeping the wrapper in the iteration path so telemetry still records. End-to-end against the real package:

raw from with_raw_response : AsyncStreamWrapper
.stream_async_iter : LegacyAPIResponse
_open_event_stream yielded: AsyncStreamWrapper
RESULT: streamed 'Hello world'
telemetry wrapper still in iteration path: True

The test now models that object graph and fails against my previous commit.


Status: upstream issue filed and linked (Azure/azure-sdk-for-python#48646), branch up to date with main, and CI is green on the current head — 35 checks, 23 success / 12 skipped / 0 failed, including the Test Typing Checks job that was previously failing.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pythonUsage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true crashes agent-framework-openai streaming

5 participants

@madanmishra1223@moonbox3@TaoChenOSU@madanmishra