From c6a4444680c86330c68e8429046b6d9a28bcab9c Mon Sep 17 00:00:00 2001 From: alanhuangyoo Date: Mon, 31 Aug 2026 04:43:07 +0800 Subject: [PATCH 1/2] fix(sdk): keep the completion a Responses stream yielded Both responses() and aresponses() capture the ResponseCompletedEvent as they drain the stream, then read it back off the wrapper: completed_response = getattr(ret, "completed_response", completed_response) getattr's default only applies when the attribute is absent. A wrapper that exposes `completed_response` and leaves it None overwrites the event the stream just yielded, and the call fails with LLMNoResponseError: Responses stream finished without a completed response even though the stream completed normally. Take the wrapper's value only when it has one, so it can still supply the completion for wrappers that set it late, without a stale None clobbering what iteration already found. Both the sync and async paths had the same line. Closes #4769 --- openhands-sdk/openhands/sdk/llm/llm.py | 22 ++++-- .../llm/test_responses_parsing_and_kwargs.py | 69 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index 85d39409a0..640f7b7fa6 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -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 ) @@ -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 ) diff --git a/tests/sdk/llm/test_responses_parsing_and_kwargs.py b/tests/sdk/llm/test_responses_parsing_and_kwargs.py index 296d521412..93af0dcac4 100644 --- a/tests/sdk/llm/test_responses_parsing_and_kwargs.py +++ b/tests/sdk/llm/test_responses_parsing_and_kwargs.py @@ -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 From 5f976f864e68e5750f665e6bfc291f46e6121018 Mon Sep 17 00:00:00 2001 From: alanhuangyoo Date: Thu, 3 Sep 2026 15:19:07 +0800 Subject: [PATCH 2/2] Re-run description validation The HUMAN note is filled in; that check only re-runs on a push.