OpenHands / OpenHands/software-agent-sdk

[Bug]: to_chat_dict emits blank text blocks and empty content arrays that strict OpenAI-compatible providers reject

Open
#4,965 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug llm priority:medium ready-for-dev sdk
Dominant language
Python
Stars
1.1k
Forks
542
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

Message.to_chat_dict() emits OpenAI Chat Completions payloads that some strict OpenAI-compatible providers reject with HTTP 400/422:

  1. A blank text block survives serialization: Message(role="assistant", content=[TextContent(text="")]) produces "content": [{"type": "text", "text": ""}]. Strict upstreams (e.g. TokenRouter's GLM) fail to deserialize this with data did not match any variant of untagged enum MessageContent.
  2. An empty content array for a non-assistant role: Message(role="user", content=[]) produces "content": [], which the same providers also reject.

Both shapes reach the wire because _list_serializer() copies every content item verbatim, and the only cleanup — _normalize_empty_assistant_content() — covers exactly one case: role == "assistant" and content == [] (converting it to "").

Consequence in practice: once a conversation contains an assistant message whose text block is empty (a model "thinking" with no final text, or a trimmed/condensed history), every subsequent LLM call in that conversation fails with the error below. The conversation is bricked until the history is edited by hand. Because the fix is not upstream, I currently re-apply a local patch to every SDK copy in my uv cache after each upgrade, which is why I'd like this fixed in the SDK itself.

Expected Behavior

to_chat_dict() should never emit:

  • a text block whose text is empty/whitespace-only, and
  • an empty content array

for any role. The assistant content == []"" fallback already in _normalize_empty_assistant_content() should generalize: drop blank text blocks everywhere and normalize empty lists for all roles (not just assistant).

Actual Behavior

Reproduced on the latest main (openhands/sdk/llm/message.py), SDK 1.46.0 environment:

>>> Message(role="assistant", content=[TextContent(text="")]).to_chat_dict(
...     cache_enabled=True, vision_enabled=False, function_calling_enabled=False,
...     force_string_serializer=False, send_reasoning_content=False)
{"content": [{"type": "text", "text": ""}], "role": "assistant"}

>>> Message(role="user", content=[]).to_chat_dict(...)  # _normalize only handles assistant
{"content": [], "role": "user"}

Sent to the provider, the request is rejected:

litellm.BadRequestError: OpenAIException - Invalid JSON data: Failed to deserialize the JSON body into the target type: messages[240]: data did not match any variant of untagged enum MessageContent

Repro on software-agent-sdk@main: empty text block and empty content array serialized verbatim, then rejected by the provider

Steps to Reproduce
  1. pip install openhands-sdk (reproduced with openhands-sdk 1.46.0 and on current main).
  2. Run the following snippet (uses the agent-canvas SDK runtime environment; no provider call needed to see the malformed payload):
from openhands.sdk.llm.message import Message, TextContent

for m in (
    Message(role="assistant", content=[TextContent(text="")]),
    Message(role="user", content=[]),
):
    print(m.to_chat_dict(
        cache_enabled=True,
        vision_enabled=False,
        function_calling_enabled=False,
        force_string_serializer=False,
        send_reasoning_content=False,
    ))
  1. Observe "content": [{"type": "text", "text": ""}] and "content": [] in the output.
  2. Direct the same payloads to a strict OpenAI-compatible endpoint (e.g. tokenrouter.com glm-5.3-free through LiteLLM, as used by my agent-canvas sessions) and observe the BadRequestError above — the request dies on deserialization before any model sees it.
Acceptance Criteria
  • Message(role="assistant", content=[TextContent(text="")]).to_chat_dict(...) with caching enabled returns no blank text block (empty content normalized to "", matching the existing assistant fallback behavior).
  • Blank/whitespace-only text blocks are dropped for every role, not just assistant.
  • Message(role="user", content=[]) (and any other non-assistant role) does not serialize content as an empty list.
  • A unit test in tests/sdk/llm/test_message_serialization.py (or equivalent) covers these shapes.
  • A conversation whose history contains an empty assistant text block completes its next LLM call against a strict OpenAI-compatible provider instead of failing with MessageContent deserialization errors.
Installation Method

pip (uv-managed environment; also reproduced by loading openhands/sdk/llm/message.py from the current main branch of this repository)

SDK Version

1.46.0 (reproduced on main as of today)

Version Confirmation
  • I have confirmed this bug exists on the LATEST version of OpenHands SDK
Python Version

3.13

Model Name (if applicable)

tokenrouter/glm-5.3-free (any strict OpenAI-compatible upstream reproduces the deserialization failure; lenient ones silently accept the payloads)

Operating System

Windows

Logs and Error Messages
litellm.BadRequestError: OpenAIException - Invalid JSON data: Failed to deserialize the JSON body into the target type: messages[240]: data did not match any variant of untagged enum MessageContent at line 1 column 812430

The index (240) grows with conversation length — the blank block can sit far back in history; the whole request is rejected regardless.

Minimal Code Sample
from openhands.sdk.llm.message import Message, TextContent

# -> {'content': [{'type': 'text', 'text': ''}], 'role': 'assistant'}  (rejected upstream)
Message(role="assistant", content=[TextContent(text="")]).to_chat_dict(
    cache_enabled=True, vision_enabled=False, function_calling_enabled=False,
    force_string_serializer=False, send_reasoning_content=False,
)

# -> {'content': [], 'role': 'user'}  (rejected upstream)
Message(role="user", content=[]).to_chat_dict(
    cache_enabled=True, vision_enabled=False, function_calling_enabled=False,
    force_string_serializer=False, send_reasoning_content=False,
)
Screenshots and Additional Context
  • Moved here from OpenHands/OpenHands#17277 per maintainer guidance (DevinVinson): fixing it upstream in the SDK fixes it everywhere automatically.
  • Related but distinct existing work: #4003 addresses content normalization for assistant tool-call messages; #4774 / #4824 address MessageEvent.visualize rendering of an empty TextContent block. Neither covers the wire format emitted by _list_serializer() / to_chat_dict() described here.
  • Screenshot: reproduction output on main shown in the Actual Behavior section above.

This issue was created by an AI agent (OpenHands) on behalf of @ivanov84.

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 in openhands/sdk/llm/message.py, tracing _list_serializer(), _normalize_empty_assistant_content(), and Message.to_chat_dict(). Run the reproductions from the issue, then inspect tests/sdk/llm/test_message_serialization.py. Done means blank text blocks are excluded, empty content arrays are normalized for every role, and the specified serialization cases are covered by unit tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, backend-api-design, 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.