Skip to content

Commit 3725fa3

Browse files
fix(openai): preserve async parsing for streaming responses (#607)
resolves https://linear.app/braintrustdata/issue/SDK-86/async-streaming-response-parsing-not-preserved ## Summary - await asynchronous raw-response parsing before wrapping OpenAI Responses and Chat Completions streams - preserve the awaitable `parse()` contract exposed by `with_streaming_response` - extend existing cassette-backed streaming tests to await and consume the traced streams without mocks or new cassette churn ## Root cause `openai.AsyncAPIResponse.parse()` is asynchronous, but the tracing proxy replaced it with a synchronous method that returned the traced stream directly. The wrappers also attempted to instrument the un-awaited parser coroutine rather than the parsed iterator. This broke callers such as OpenAI Agents that enter `with_streaming_response` and then call `await api_response.parse()`. Chat Completions had the same issue on its async raw streaming path. The wrappers now detect and await asynchronous parsing before constructing the traced iterator, remember the original parse contract, and expose an awaitable `parse()` on the raw-response proxy. Synchronous raw-response behavior remains unchanged. ## Validation - reproduced the Chat Completions failure with existing VCR coverage: `parse()` was not awaitable and the underlying parser coroutine was never awaited - full `test_openai(latest)` session with cassette playback: 70 tests passed, plus attachment and OpenRouter gateway suites - focused cassette playback for OpenAI 1.92.0, 1.77.0, and 1.71.0 - pre-commit hooks: Ruff format/check, cassette consistency, pytest-pin synchronization, codespell, and whitespace checks --------- Co-authored-by: Abhijeet Prasad <abhijeet@braintrustdata.com>
1 parent de233f8 commit 3725fa3

2 files changed

Lines changed: 55 additions & 24 deletions

File tree

‎py/src/braintrust/integrations/openai/test_openai.py‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,16 +1066,23 @@ async def test_openai_chat_streaming_async(memory_logger):
10661066
for client, is_wrapped in clients:
10671067
start = time.time()
10681068

1069-
stream = await client.chat.completions.create(
1070-
model=TEST_MODEL,
1071-
messages=[{"role": "user", "content": TEST_PROMPT}],
1072-
stream=True,
1073-
stream_options={"include_usage": True},
1074-
)
1075-
1076-
chunks = []
1077-
async for chunk in stream:
1078-
chunks.append(chunk)
1069+
create_kwargs = {
1070+
"model": TEST_MODEL,
1071+
"messages": [{"role": "user", "content": TEST_PROMPT}],
1072+
"stream": True,
1073+
"stream_options": {"include_usage": True},
1074+
}
1075+
if is_wrapped:
1076+
async with client.chat.completions.with_streaming_response.create(**create_kwargs) as raw_response:
1077+
assert raw_response.headers
1078+
parse_result = raw_response.parse()
1079+
assert inspect.isawaitable(parse_result)
1080+
stream = await parse_result
1081+
assert stream.response
1082+
chunks = [chunk async for chunk in stream]
1083+
else:
1084+
stream = await client.chat.completions.create(**create_kwargs)
1085+
chunks = [chunk async for chunk in stream]
10791086
end = time.time()
10801087

10811088
assert chunks
@@ -1978,7 +1985,7 @@ async def test_openai_responses_with_raw_response_async(memory_logger):
19781985
@pytest.mark.asyncio
19791986
@pytest.mark.vcr
19801987
async def test_openai_responses_with_raw_response_create_stream_async(memory_logger):
1981-
"""Async version of test_openai_responses_with_raw_response_create_stream."""
1988+
"""Async raw-response variants preserve headers, parsing, streams, and tracing."""
19821989
assert not memory_logger.pop()
19831990

19841991
# Unwrapped client: headers accessible, stream iterable via parse(), no spans.
@@ -1996,21 +2003,23 @@ async def test_openai_responses_with_raw_response_create_stream_async(memory_log
19962003
assert "24" in "".join(chunks) or "twenty-four" in "".join(chunks).lower()
19972004
assert not memory_logger.pop()
19982005

1999-
# Wrapped client: headers still accessible, parse() yields traced stream, span generated.
2006+
# Wrapped client: the newer streaming-response wrapper keeps async parse() and tracing.
20002007
client = wrap_openai(AsyncOpenAI())
20012008
start = time.time()
2002-
raw = await client.responses.with_raw_response.create(
2009+
async with client.responses.with_streaming_response.create(
20032010
model=TEST_MODEL,
20042011
input=TEST_PROMPT,
20052012
stream=True,
2006-
)
2007-
assert raw.headers
2008-
stream = raw.parse()
2009-
assert stream.response # SDK-specific attribute preserved
2010-
chunks = []
2011-
async for chunk in stream:
2012-
if chunk.type == "response.output_text.delta":
2013-
chunks.append(chunk.delta)
2013+
) as raw:
2014+
assert raw.headers
2015+
parse_result = raw.parse()
2016+
assert inspect.isawaitable(parse_result)
2017+
stream = await parse_result
2018+
assert stream.response # SDK-specific attribute preserved
2019+
chunks = []
2020+
async for chunk in stream:
2021+
if chunk.type == "response.output_text.delta":
2022+
chunks.append(chunk.delta)
20142023
end = time.time()
20152024
assert "24" in "".join(chunks) or "twenty-four" in "".join(chunks).lower()
20162025

‎py/src/braintrust/integrations/openai/tracing.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,12 @@ async def acreate(self, *args: Any, **kwargs: Any) -> Any:
499499
start = time.time()
500500
create_response = await self.acreate_fn(*args, **kwargs)
501501

502+
parse_was_awaitable = False
502503
if hasattr(create_response, "parse"):
503504
raw_response = create_response.parse()
505+
if inspect.isawaitable(raw_response):
506+
parse_was_awaitable = True
507+
raw_response = await raw_response
504508
log_headers(create_response, span)
505509
else:
506510
raw_response = create_response
@@ -529,7 +533,11 @@ async def gen():
529533
should_end = False
530534
streamer = gen()
531535
if raw_requested and hasattr(create_response, "parse"):
532-
return _RawResponseWithTracedStream(create_response, _AsyncTracedStream(raw_response, streamer))
536+
return _RawResponseWithTracedStream(
537+
create_response,
538+
_AsyncTracedStream(raw_response, streamer),
539+
async_parse=parse_was_awaitable,
540+
)
533541
return _AsyncTracedStream(raw_response, streamer)
534542
else:
535543
log_response = _try_to_dict(raw_response)
@@ -844,11 +852,17 @@ class _RawResponseWithTracedStream(NamedWrapper):
844852
"""Proxy for LegacyAPIResponse that replaces parse() with a traced stream,
845853
so that with_raw_response + stream=True preserves both headers and tracing."""
846854

847-
def __init__(self, raw_response: Any, traced_stream: Any) -> None:
855+
def __init__(self, raw_response: Any, traced_stream: Any, *, async_parse: bool = False) -> None:
848856
self._traced_stream = traced_stream
857+
self._async_parse = async_parse
849858
super().__init__(raw_response)
850859

851860
def parse(self, *args: Any, **kwargs: Any) -> Any:
861+
if self._async_parse:
862+
return self._aparse()
863+
return self._traced_stream
864+
865+
async def _aparse(self) -> Any:
852866
return self._traced_stream
853867

854868

@@ -1066,8 +1080,12 @@ async def acreate(self, *args: Any, **kwargs: Any) -> Any:
10661080
try:
10671081
start = time.time()
10681082
create_response = await self.acreate_fn(*args, **kwargs)
1083+
parse_was_awaitable = False
10691084
if hasattr(create_response, "parse"):
10701085
raw_response = create_response.parse()
1086+
if inspect.isawaitable(raw_response):
1087+
parse_was_awaitable = True
1088+
raw_response = await raw_response
10711089
log_headers(create_response, span)
10721090
else:
10731091
raw_response = create_response
@@ -1097,7 +1115,11 @@ async def gen():
10971115
should_end = False
10981116
streamer = gen()
10991117
if raw_requested and hasattr(create_response, "parse"):
1100-
return _RawResponseWithTracedStream(create_response, _AsyncTracedStream(raw_response, streamer))
1118+
return _RawResponseWithTracedStream(
1119+
create_response,
1120+
_AsyncTracedStream(raw_response, streamer),
1121+
async_parse=parse_was_awaitable,
1122+
)
11011123
return _AsyncTracedStream(raw_response, streamer)
11021124
else:
11031125
log_response = _try_to_dict(raw_response)

0 commit comments

Comments
 (0)