Skip to content

Commit f8269ad

Browse files
fix(openai-v2): Ports the fix from upstream opentelemetry-python-contrib PR #4184 (fixes upstream issue #4113) (#331)
* feat(langchain): Release version 0.1.14 * fix(openai-v2): add __getattr__ to StreamWrapper to proxy unknown attributes StreamWrapper was missing __getattr__, causing AttributeError when LiteLLM (and other clients) access raw_response.headers after calling with_raw_response.create(stream=True). Ports the fix from upstream opentelemetry-python-contrib PR #4184 (fixes issue #4113). Co-authored-by: Cursor <cursoragent@cursor.com> * docs(openai-v2): add changelog entry for StreamWrapper __getattr__ fix Co-authored-by: Cursor <cursoragent@cursor.com> * fix(openai-v2): preserve LegacyAPIResponse headers on StreamWrapper for with_raw_response streaming When LiteLLM calls with_raw_response.create(stream=True), the OpenAI SDK returns a LegacyAPIResponse (with .headers). SDOT's _parse_response was calling .parse() on it to get the AsyncStream before wrapping in StreamWrapper, discarding the headers. LiteLLM then accesses raw_response.headers, which failed with AttributeError since AsyncStream has no .headers attribute. Fix: - Capture LegacyAPIResponse.headers before _parse_response discards it - Store captured headers as StreamWrapper.headers (direct attribute, not proxied) - Add parse() returning self so callers can treat StreamWrapper as a raw response - Keep __getattr__ for any other unknown attribute proxying to the underlying stream Co-authored-by: Cursor <cursoragent@cursor.com> * style: apply ruff formatting to test_patch_unit.py Co-authored-by: Cursor <cursoragent@cursor.com> * test(openai-v2): add standalone reproducer for with_raw_response streaming headers bug Reproduces the production error reported in lab0: AttributeError: 'StreamWrapper' object has no attribute 'headers' Mirrors the pattern from upstream issues: #4032 - StreamWrapper missing .parse() #4113 - StreamWrapper missing .headers Co-authored-by: Cursor <cursoragent@cursor.com> * test(openai-v2): use make_azure_openai_chat_completion_request verbatim in reproducer Co-authored-by: Cursor <cursoragent@cursor.com> * chore(openai-v2): move reproducer script to examples/scripts/ Co-authored-by: Cursor <cursoragent@cursor.com> * fix(openai-v2): remove unused time import from reproducer Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0ea0849 commit f8269ad

4 files changed

Lines changed: 245 additions & 0 deletions

File tree

instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Fixed
11+
12+
- Fix `AttributeError: 'StreamWrapper' object has no attribute 'headers'` when
13+
using `with_raw_response.create(stream=True)` (e.g. via LiteLLM's Azure provider).
14+
`_parse_response` was calling `.parse()` on the `LegacyAPIResponse` before wrapping
15+
in `StreamWrapper`, discarding the raw HTTP headers. `StreamWrapper` now captures
16+
headers from the `LegacyAPIResponse` before it is parsed and exposes them directly,
17+
and adds a `parse()` method returning `self` so callers can treat the wrapper as
18+
a drop-in for the raw response. Also adds `__getattr__` to proxy any other unknown
19+
attributes to the underlying stream. Inspired by upstream fix
20+
([opentelemetry-python-contrib#4184](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4184),
21+
fixes [#4113](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4113)).
22+
1023
### Added
1124

1225
- Add `gen_ai.tool.definitions` attribute on LLM spans when
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""
2+
Reproducer for: StreamWrapper missing .headers when LiteLLM calls
3+
with_raw_response.create(stream=True) on an SDOT-instrumented Azure OpenAI client.
4+
5+
Production traceback (lab0, 2026-05-15):
6+
File "litellm/llms/azure/azure.py", line 619, in async_streaming
7+
headers, response = await self.make_azure_openai_chat_completion_request(...)
8+
File "litellm/llms/azure/azure.py", line 176, in make_azure_openai_chat_completion_request
9+
headers = dict(raw_response.headers)
10+
AttributeError: 'StreamWrapper' object has no attribute 'headers'
11+
12+
This reproducer calls make_azure_openai_chat_completion_request verbatim to
13+
confirm the fix in SDOT's StreamWrapper resolves the crash.
14+
15+
Related upstream issues:
16+
#4032 - StreamWrapper missing .parse() (fixed upstream)
17+
#4113 - StreamWrapper missing .headers (fixed upstream via __getattr__,
18+
but SDOT needed a deeper fix: preserve LegacyAPIResponse.headers
19+
before _parse_response discards it)
20+
21+
Run:
22+
pip install openai litellm opentelemetry-sdk splunk-otel-instrumentation-openai
23+
python reproduce_raw_response_streaming.py
24+
"""
25+
26+
import asyncio
27+
from unittest.mock import AsyncMock, MagicMock, patch
28+
29+
import httpx
30+
from openai import AsyncAzureOpenAI
31+
32+
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor
33+
from opentelemetry.sdk.trace import TracerProvider
34+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
35+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
36+
InMemorySpanExporter,
37+
)
38+
39+
# ---------------------------------------------------------------------------
40+
# Minimal SSE streaming chunks that mimic Azure OpenAI
41+
# ---------------------------------------------------------------------------
42+
SSE_CHUNKS = [
43+
b'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n',
44+
b'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":"stop"}]}\n\n',
45+
b"data: [DONE]\n\n",
46+
]
47+
48+
49+
def _make_mock_httpx_response() -> httpx.Response:
50+
"""Build a fake httpx.Response that the OpenAI SDK treats as a raw streaming response.
51+
52+
The request must carry X-Stainless-Raw-Response: true so the OpenAI SDK
53+
returns LegacyAPIResponse (sync .parse()) rather than AsyncAPIResponse
54+
(async .parse()). SDOT's _parse_response calls .parse() synchronously.
55+
"""
56+
response_headers = {
57+
"content-type": "text/event-stream",
58+
"x-request-id": "test-request-id-abc123",
59+
"openai-model": "gpt-4o",
60+
"ms-azureml-model-session": "d0",
61+
}
62+
63+
async def aiter_bytes(_chunk_size=None):
64+
for chunk in SSE_CHUNKS:
65+
yield chunk
66+
67+
mock_request = MagicMock(spec=httpx.Request)
68+
# RAW_RESPONSE_HEADER value that async_to_raw_response_wrapper injects
69+
mock_request.headers = httpx.Headers({"X-Stainless-Raw-Response": "true"})
70+
71+
mock_response = MagicMock(spec=httpx.Response)
72+
mock_response.status_code = 200
73+
mock_response.headers = httpx.Headers(response_headers)
74+
mock_response.aiter_bytes = aiter_bytes
75+
mock_response.aclose = AsyncMock()
76+
mock_response.request = mock_request
77+
mock_response.http_version = "HTTP/1.1"
78+
mock_response.elapsed = MagicMock()
79+
return mock_response
80+
81+
82+
# ---------------------------------------------------------------------------
83+
# Verbatim copy of LiteLLM's make_azure_openai_chat_completion_request
84+
# (litellm/llms/azure/azure.py lines 154-179)
85+
# ---------------------------------------------------------------------------
86+
async def make_azure_openai_chat_completion_request(
87+
azure_client, data, timeout
88+
):
89+
"""
90+
Helper to:
91+
- call chat.completions.create.with_raw_response when litellm.return_response_headers is True
92+
- call chat.completions.create by default
93+
"""
94+
try:
95+
raw_response = (
96+
await azure_client.chat.completions.with_raw_response.create(
97+
**data, timeout=timeout
98+
)
99+
)
100+
101+
headers = dict(raw_response.headers)
102+
response = raw_response.parse()
103+
return headers, response
104+
except Exception as e:
105+
raise e
106+
107+
108+
# ---------------------------------------------------------------------------
109+
# Reproducer
110+
# ---------------------------------------------------------------------------
111+
async def reproducer():
112+
exporter = InMemorySpanExporter()
113+
provider = TracerProvider()
114+
provider.add_span_processor(SimpleSpanProcessor(exporter))
115+
116+
instrumentor = OpenAIInstrumentor()
117+
instrumentor.instrument(tracer_provider=provider)
118+
119+
azure_client = AsyncAzureOpenAI(
120+
api_key="test-key",
121+
azure_endpoint="https://test.openai.azure.com",
122+
api_version="2024-02-15-preview",
123+
)
124+
125+
data = {
126+
"model": "gpt-4o",
127+
"messages": [{"role": "user", "content": "Say hello"}],
128+
"max_tokens": 10,
129+
"stream": True,
130+
}
131+
132+
mock_httpx_response = _make_mock_httpx_response()
133+
134+
with patch.object(
135+
azure_client._client,
136+
"send",
137+
new_callable=AsyncMock,
138+
return_value=mock_httpx_response,
139+
):
140+
# This is the exact call that crashed in production
141+
headers, response = await make_azure_openai_chat_completion_request(
142+
azure_client=azure_client,
143+
data=data,
144+
timeout=60.0,
145+
)
146+
147+
print(f"✓ headers accessible: {dict(headers)}")
148+
print(f"✓ response type: {type(response).__name__}")
149+
150+
collected = []
151+
async for chunk in response:
152+
for choice in chunk.choices:
153+
if choice.delta.content:
154+
collected.append(choice.delta.content)
155+
156+
print(f"✓ streamed content: {''.join(collected)!r}")
157+
158+
spans = exporter.get_finished_spans()
159+
print(f"✓ OTel spans: {[s.name for s in spans]}")
160+
161+
instrumentor.uninstrument()
162+
print(
163+
"\nReproducer passed — 'StreamWrapper' has no attribute 'headers' is fixed."
164+
)
165+
166+
167+
if __name__ == "__main__":
168+
asyncio.run(reproducer())

instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,12 +418,14 @@ def traced_method(wrapped, instance, args, kwargs):
418418
raise
419419

420420
try:
421+
raw_headers = getattr(result, "headers", None)
421422
parsed_result = _parse_response(result)
422423
if is_streaming(kwargs):
423424
return StreamWrapper(
424425
parsed_result,
425426
invocation,
426427
handler,
428+
raw_headers=raw_headers,
427429
)
428430

429431
if span and span.is_recording():
@@ -471,12 +473,14 @@ async def traced_method(wrapped, instance, args, kwargs):
471473
raise
472474

473475
try:
476+
raw_headers = getattr(result, "headers", None)
474477
parsed_result = _parse_response(result)
475478
if is_streaming(kwargs):
476479
return StreamWrapper(
477480
parsed_result,
478481
invocation,
479482
handler,
483+
raw_headers=raw_headers,
480484
)
481485

482486
if span and span.is_recording():
@@ -755,11 +759,13 @@ def __init__(
755759
stream: Stream,
756760
invocation: LLMInvocation,
757761
handler,
762+
raw_headers=None,
758763
):
759764
self.stream = stream
760765
self.invocation = invocation
761766
self.span = getattr(invocation, "span", None)
762767
self.handler = handler
768+
self.headers = raw_headers
763769
self.choice_buffers = []
764770
self.finish_reasons = [] # Instance-level to avoid cross-request contamination
765771
self._span_started = False
@@ -768,6 +774,15 @@ def __init__(
768774
self._first_chunk_processed = False
769775
self.setup()
770776

777+
def __getattr__(self, name: str):
778+
return getattr(self.stream, name)
779+
780+
def parse(self):
781+
"""Proxy for with_raw_response callers (e.g. LiteLLM) that call
782+
.parse() on the result of with_raw_response.create(stream=True).
783+
Returns self so the caller can iterate the stream directly."""
784+
return self
785+
771786
def setup(self):
772787
if not self._span_started:
773788
self._span_started = True

instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_patch_unit.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,55 @@ def test_time_to_first_chunk_only_captured_once(self):
202202
"time_to_first_chunk should not change after first chunk"
203203
)
204204

205+
def test_raw_response_headers_stored_from_legacy_api_response(self):
206+
"""Test the real production scenario: LiteLLM calls with_raw_response.create(stream=True),
207+
which makes the OpenAI SDK return a LegacyAPIResponse. SDOT's _parse_response calls
208+
.parse() on it to get the AsyncStream — discarding .headers. StreamWrapper must
209+
preserve the headers captured before _parse_response so LiteLLM can access them.
210+
211+
Regression test for the AttributeError seen in production:
212+
File "litellm/llms/azure/azure.py", line 176
213+
headers = dict(raw_response.headers)
214+
AttributeError: 'StreamWrapper' object has no attribute 'headers'
215+
"""
216+
invocation = LLMInvocation(request_model="gpt-4o")
217+
mock_stream = MagicMock(spec=[]) # AsyncStream has no .headers
218+
mock_handler = MagicMock()
219+
raw_headers = {
220+
"content-type": "text/event-stream",
221+
"x-request-id": "abc123",
222+
}
223+
224+
wrapper = StreamWrapper(
225+
stream=mock_stream,
226+
invocation=invocation,
227+
handler=mock_handler,
228+
raw_headers=raw_headers,
229+
)
230+
231+
# LiteLLM: headers = dict(raw_response.headers)
232+
assert wrapper.headers == raw_headers
233+
# LiteLLM: response = raw_response.parse()
234+
assert wrapper.parse() is wrapper
235+
236+
def test_getattr_proxies_unknown_attributes_to_stream(self):
237+
"""Test that unknown attributes on StreamWrapper are proxied to the
238+
underlying stream object.
239+
Regression test for https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4113
240+
"""
241+
invocation = LLMInvocation(request_model="gpt-4o")
242+
mock_stream = MagicMock()
243+
mock_stream.some_custom_attr = "value"
244+
mock_handler = MagicMock()
245+
246+
wrapper = StreamWrapper(
247+
stream=mock_stream,
248+
invocation=invocation,
249+
handler=mock_handler,
250+
)
251+
252+
assert wrapper.some_custom_attr == "value"
253+
205254
def test_time_to_first_chunk_not_captured_without_start_time(self):
206255
"""Test that time_to_first_chunk is not captured without _start_time."""
207256
invocation = LLMInvocation(request_model="gpt-4o")

0 commit comments

Comments
 (0)