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