OpenHands / OpenHands/software-agent-sdk
[Bug]: Responses streaming overwrites a yielded completion event with stale wrapper state
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 539
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 137
Description
Is there an existing issue for the same bug?
- I have searched existing issues and this is not a duplicate.
Bug Description
LLM.responses and LLM.aresponses can discard a valid ResponseCompletedEvent yielded by a streaming wrapper when that wrapper also exposes a stale completed_response=None attribute.
During iteration, both methods correctly save the yielded completion event. After iteration, however, they read ret.completed_response again. Because the attribute exists, getattr returns its None value instead of the valid event supplied as the default.
The SDK consequently raises:
LLMNoResponseError: Responses stream finished without a completed response
even though the stream yielded a valid completion event.
Both the synchronous and asynchronous Responses API paths are affected.
Expected Behavior
A valid ResponseCompletedEvent yielded during stream iteration should be preserved unless the wrapper subsequently exposes a non-null completed response.
Both APIs should return a successful LLMResponse whose raw_response is the response carried by the yielded completion event:
response.raw_response is completed_response
A wrapper's stale completed_response=None value should not overwrite an event already observed from the stream.
Streams that yield no completion event and expose no completed response should continue to raise LLMNoResponseError.
Actual Behavior
Both synchronous and asynchronous calls raise:
openhands.sdk.llm.exceptions.types.LLMNoResponseError:
Responses stream finished without a completed response
The exception occurs after the valid ResponseCompletedEvent has been consumed.
The provider calls are mocked in the reproduction, so no external model or network request is required.
Steps to Reproduce
-
Check out release
v1.44.1at commit9d143aac35c2dcec9cbb046ff9f35ac5eb072f6a. -
Set up the development environment:
make build
- Create
tests/sdk/llm/test_responses_stale_completed_response.py:
from unittest.mock import AsyncMock, patch
import pytest
from litellm.types.llms.openai import (
ResponseAPIUsage,
ResponseCompletedEvent,
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
)
from openai.types.responses.response_output_message import (
ResponseOutputMessage,
)
from openai.types.responses.response_output_text import (
ResponseOutputText,
)
from pydantic import SecretStr
from openhands.sdk.llm import LLM
from openhands.sdk.llm.message import Message, TextContent
def make_completed_event():
message = ResponseOutputMessage.model_construct(
id="message-1",
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(
type="output_text",
text="stream complete",
annotations=[],
)
],
)
response = ResponsesAPIResponse(
id="response-1",
created_at=0,
output=[message],
parallel_tool_calls=False,
tool_choice="auto",
top_p=None,
tools=[],
usage=ResponseAPIUsage(
input_tokens=1,
output_tokens=1,
total_tokens=2,
),
instructions="",
status="completed",
)
event = ResponseCompletedEvent(
type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
response=response,
)
return event, response
def make_llm():
return LLM(
model="gpt-4o",
api_key=SecretStr("test_key"),
usage_id="test-llm",
num_retries=0,
)
class SyncWrapper:
completed_response = None
def __init__(self, events):
self.events = events
def __iter__(self):
return iter(self.events)
class AsyncWrapper:
completed_response = None
def __init__(self, events):
self.events = iter(events)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.events)
except StopIteration:
raise StopAsyncIteration
@patch("openhands.sdk.llm.llm.litellm_responses")
def test_responses_preserves_yielded_completion_event(
mock_responses,
):
event, completed_response = make_completed_event()
mock_responses.return_value = SyncWrapper([event])
response = make_llm().responses(
[
Message(
role="user",
content=[TextContent(text="Hello")],
)
],
stream=True,
on_token=lambda _chunk: None,
)
assert response.raw_response is completed_response
@pytest.mark.asyncio
@patch(
"openhands.sdk.llm.llm.litellm_aresponses",
new_callable=AsyncMock,
)
async def test_aresponses_preserves_yielded_completion_event(
mock_aresponses,
):
event, completed_response = make_completed_event()
mock_aresponses.return_value = AsyncWrapper([event])
response = await make_llm().aresponses(
[
Message(
role="user",
content=[TextContent(text="Hello")],
)
],
stream=True,
on_token=lambda _chunk: None,
)
assert response.raw_response is completed_response
- Run:
uv run pytest \
tests/sdk/llm/test_responses_stale_completed_response.py \
-q
- Observe that both tests fail with
LLMNoResponseError.
Acceptance Criteria
-
LLM.responsespreserves a yieldedResponseCompletedEventwhen the wrapper'scompleted_responseattribute isNone. -
LLM.aresponsespreserves the event under the same condition. - The returned
LLMResponse.raw_responsecontains the completed API response. - A non-null wrapper
completed_responseremains supported. - A stream with no completion event still raises
LLMNoResponseError. - Existing plain iterable, synchronous generator, and asynchronous generator behavior remains unchanged.
Installation Method
Source checkout using make build (uv sync --dev).
If you selected "Other", please specify
Not applicable.
SDK Version
1.44.1, release v1.44.1@9d143aac35c2dcec9cbb046ff9f35ac5eb072f6a. The same implementation remains present on main@704cbe6015e3d59cabe04632175d99df2d448999.
Version Confirmation
- I have confirmed this bug exists on the LATEST version of OpenHands SDK
Python Version
3.13.9
Model Name (if applicable)
gpt-4o configuration with provider calls mocked. No model request is made.
Operating System
MacOS
Logs and Error Messages
platform darwin -- Python 3.13.9, pytest-9.0.3 collected 2 items
test_responses_stale_completed_response.py FF
FAILED test_responses_preserves_yielded_completion_event openhands.sdk.llm.exceptions.types.LLMNoResponseError:
Responses stream finished without a completed response
FAILED test_aresponses_preserves_yielded_completion_event openhands.sdk.llm.exceptions.types.LLMNoResponseError:
Responses stream finished without a completed response
2 failed in 0.27s
The synchronous stack reaches:
LLM.responses
-> _one_attempt
-> _finalize_stream_response
-> LLMNoResponseError
The asynchronous stack reaches:
LLM.aresponses
-> _one_attempt
-> _finalize_stream_response
-> LLMNoResponseError
Minimal Code Sample
from unittest.mock import AsyncMock, patch
import pytest
from litellm.types.llms.openai import (
ResponseAPIUsage,
ResponseCompletedEvent,
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
)
from openai.types.responses.response_output_message import (
ResponseOutputMessage,
)
from openai.types.responses.response_output_text import (
ResponseOutputText,
)
from pydantic import SecretStr
from openhands.sdk.llm import LLM
from openhands.sdk.llm.message import Message, TextContent
def make_completed_event():
message = ResponseOutputMessage.model_construct(
id="message-1",
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(
type="output_text",
text="stream complete",
annotations=[],
)
],
)
response = ResponsesAPIResponse(
id="response-1",
created_at=0,
output=[message],
parallel_tool_calls=False,
tool_choice="auto",
top_p=None,
tools=[],
usage=ResponseAPIUsage(
input_tokens=1,
output_tokens=1,
total_tokens=2,
),
instructions="",
status="completed",
)
event = ResponseCompletedEvent(
type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
response=response,
)
return event, response
def make_llm():
return LLM(
model="gpt-4o",
api_key=SecretStr("test_key"),
usage_id="test-llm",
num_retries=0,
)
class SyncWrapper:
completed_response = None
def __init__(self, events):
self.events = events
def __iter__(self):
return iter(self.events)
class AsyncWrapper:
completed_response = None
def __init__(self, events):
self.events = iter(events)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.events)
except StopIteration:
raise StopAsyncIteration
@patch("openhands.sdk.llm.llm.litellm_responses")
def test_responses_preserves_yielded_completion_event(
mock_responses,
):
event, completed_response = make_completed_event()
mock_responses.return_value = SyncWrapper([event])
response = make_llm().responses(
[
Message(
role="user",
content=[TextContent(text="Hello")],
)
],
stream=True,
on_token=lambda _chunk: None,
)
assert response.raw_response is completed_response
@pytest.mark.asyncio
@patch(
"openhands.sdk.llm.llm.litellm_aresponses",
new_callable=AsyncMock,
)
async def test_aresponses_preserves_yielded_completion_event(
mock_aresponses,
):
event, completed_response = make_completed_event()
mock_aresponses.return_value = AsyncWrapper([event])
response = await make_llm().aresponses(
[
Message(
role="user",
content=[TextContent(text="Hello")],
)
],
stream=True,
on_token=lambda _chunk: None,
)
assert response.raw_response is completed_response
Screenshots and Additional Context
No screenshot is required; this is a deterministic unit-level reproduction.
The synchronous implementation first initializes its local state from the wrapper:
completed_response = getattr(
ret,
"completed_response",
None,
)
It then correctly observes the completion event:
for event in stream:
if isinstance(event, ResponseCompletedEvent):
completed_response = event
After iteration, it performs another attribute lookup:
completed_response = getattr(
ret,
"completed_response",
completed_response,
)
The default argument to getattr is used only when the attribute is absent. If the wrapper exposes completed_response=None, this expression returns None and overwrites the valid event saved during iteration.
LLM.aresponses contains the same final lookup after draining either a synchronous or asynchronous iterable.
This conflicts with the surrounding implementation's explicit support for third-party iterable wrappers. Such a wrapper can yield a valid completion event without updating its own optional completed_response attribute.
A possible fix is to overwrite the observed event only when the wrapper provides a non-null value:
wrapper_completed_response = getattr(
ret,
"completed_response",
None,
)
if wrapper_completed_response is not None:
completed_response = wrapper_completed_response
Applying this guard to both the synchronous and asynchronous paths makes both reproduction tests pass:
2 passed in 0.02s
Without the guard, a completed provider stream is converted into LLMNoResponseError, which can trigger retries, fallback handling, or a final request failure despite a valid completion having already been received.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with LLM.responses and LLM.aresponses, following their _one_attempt and _finalize_stream_response paths. Run tests/sdk/llm/test_responses_stale_completed_response.py to reproduce both failures. Done means yielded completion events survive a None wrapper attribute, non-null wrapper responses still work, and streams without completion events still raise LLMNoResponseError.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100