OpenHands / OpenHands/software-agent-sdk

[Bug]: Responses streaming overwrites a yielded completion event with stale wrapper state

Open
#4,769 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug llm priority:medium sdk
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
  1. Check out release v1.44.1 at commit 9d143aac35c2dcec9cbb046ff9f35ac5eb072f6a.

  2. Set up the development environment:

make build
  1. 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
  1. Run:
uv run pytest \
    tests/sdk/llm/test_responses_stale_completed_response.py \
    -q
  1. Observe that both tests fail with LLMNoResponseError.
Acceptance Criteria
  • LLM.responses preserves a yielded ResponseCompletedEvent when the wrapper's completed_response attribute is None.
  • LLM.aresponses preserves the event under the same condition.
  • The returned LLMResponse.raw_response contains the completed API response.
  • A non-null wrapper completed_response remains 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.