Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions openhands-sdk/openhands/sdk/llm/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1856,9 +1856,14 @@ def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse:
if stream_callback is not None and delta_chunk is not None:
stream_callback(delta_chunk)

completed_response = getattr(
ret, "completed_response", completed_response
)
# Keep the completion the stream yielded. A wrapper may also
# carry a `completed_response` attribute that is still None
# after iteration, and getattr's default does not apply when the
# attribute exists, so reading it back unconditionally discards
# a valid event and the call then raises LLMNoResponseError.
wrapper_completed = getattr(ret, "completed_response", None)
if wrapper_completed is not None:
completed_response = wrapper_completed
return self._finalize_stream_response(
completed_response, collected_output_items
)
Expand Down Expand Up @@ -2033,9 +2038,14 @@ async def _one_attempt(
if stream_cb is not None and delta_chunk is not None:
await _invoke_token_callback(stream_cb, delta_chunk)

completed_response = getattr(
ret, "completed_response", completed_response
)
# Keep the completion the stream yielded. A wrapper may also
# carry a `completed_response` attribute that is still None
# after iteration, and getattr's default does not apply when the
# attribute exists, so reading it back unconditionally discards
# a valid event and the call then raises LLMNoResponseError.
wrapper_completed = getattr(ret, "completed_response", None)
if wrapper_completed is not None:
completed_response = wrapper_completed
return self._finalize_stream_response(
completed_response, collected_output_items
)
Expand Down
69 changes: 69 additions & 0 deletions tests/sdk/llm/test_responses_parsing_and_kwargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,3 +596,72 @@ async def _events():
assert [chunk.choices[0].delta.content for chunk in received] == [
"Hello wrapped stream"
]


class _StaleCompletedResponseStream:
"""A streaming wrapper that yields the completion and also exposes a stale None.

Reproduces https://github.com/OpenHands/software-agent-sdk/issues/4769: the
attribute exists, so `getattr(ret, "completed_response", default)` returns
its None rather than the default, discarding the event the stream yielded.
"""

def __init__(self, events):
self._events = events
self.completed_response = None

def __iter__(self):
return iter(self._events)


@patch("openhands.sdk.llm.llm.litellm_responses")
def test_responses_streaming_keeps_the_yielded_completion(mock_responses):
events, completed_response = _make_wrapped_response_stream_events()
mock_responses.return_value = _StaleCompletedResponseStream(events)

llm = LLM(
model="gpt-4o",
api_key=SecretStr("test_key"),
usage_id="test-llm",
num_retries=1,
retry_min_wait=1,
retry_max_wait=2,
)

received = []
response = llm.responses(
[Message(role="user", content=[TextContent(text="Hello")])],
stream=True,
on_token=received.append,
)

assert response.raw_response is completed_response


@pytest.mark.asyncio
@patch("openhands.sdk.llm.llm.litellm_aresponses", new_callable=AsyncMock)
async def test_aresponses_streaming_keeps_the_yielded_completion(mock_aresponses):
events, completed_response = _make_wrapped_response_stream_events()

def _return_stale_wrapper(*args, **kwargs):
return _StaleCompletedResponseStream(events)

mock_aresponses.side_effect = _return_stale_wrapper

llm = LLM(
model="gpt-4o",
api_key=SecretStr("test_key"),
usage_id="test-llm",
num_retries=1,
retry_min_wait=1,
retry_max_wait=2,
)

received = []
response = await llm.aresponses(
[Message(role="user", content=[TextContent(text="Hello")])],
stream=True,
on_token=received.append,
)

assert response.raw_response is completed_response
Loading