OpenHands / OpenHands/software-agent-sdk
[Bug]: to_chat_dict emits blank text blocks and empty content arrays that strict OpenAI-compatible providers reject
Nobody has claimed this yet.
- 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:
- 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 withdata did not match any variant of untagged enum MessageContent. - 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
textis empty/whitespace-only, and - an empty
contentarray
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

Steps to Reproduce
pip install openhands-sdk(reproduced withopenhands-sdk 1.46.0and on currentmain).- 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,
))
- Observe
"content": [{"type": "text", "text": ""}]and"content": []in the output. - Direct the same payloads to a strict OpenAI-compatible endpoint (e.g.
tokenrouter.comglm-5.3-freethrough LiteLLM, as used by my agent-canvas sessions) and observe theBadRequestErrorabove — 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 (emptycontentnormalized 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 serializecontentas 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
MessageContentdeserialization 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
contentnormalization for assistant tool-call messages; #4774 / #4824 addressMessageEvent.visualizerendering of an emptyTextContentblock. Neither covers the wire format emitted by_list_serializer()/to_chat_dict()described here. - Screenshot: reproduction output on
mainshown in the Actual Behavior section above.
This issue was created by an AI agent (OpenHands) on behalf of @ivanov84.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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