feat: relax OpenAI and HF chat message conversions (#12545)

This commit is contained in:
Sebastian Husch Lee
2026-09-02 09:57:18 +02:00
committed by GitHub
parent c7c57f9509
commit 879d8b07eb
9 changed files with 84 additions and 19 deletions
@@ -16,6 +16,7 @@ from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.generators.utils import _normalize_messages, _serialize_object
from haystack.dataclasses import (
ChatMessage,
ChatRole,
ComponentInfo,
FileContent,
ImageContent,
@@ -992,10 +993,13 @@ def _convert_chat_message_to_responses_api_format(message: ChatMessage) -> list[
reasonings = message.reasonings
files = message.files
if not any([text_contents, tool_calls, tool_call_results, images, reasonings, files]):
has_content = any([text_contents, tool_calls, tool_call_results, images, reasonings, files])
# We convert an assistant message with no content part into a message with empty content, which the API accepts
if not has_content and not message.is_from(ChatRole.ASSISTANT):
raise ValueError(
"""A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, `ToolCallResult`,
`ImageContent`, `FileContent`, or `ReasoningContent`."""
f"A `ChatMessage` from `{message._role.value}` must contain at least one `TextContent`, `ToolCall`, "
"`ToolCallResult`, `ImageContent`, `FileContent`, or `ReasoningContent`. Only assistant messages can "
"be empty."
)
if len(tool_call_results) > 0 and len(message._content) > 1:
raise ValueError(
@@ -1068,7 +1072,7 @@ def _convert_chat_message_to_responses_api_format(message: ChatMessage) -> list[
formatted_messages.extend(formatted_tool_calls)
# system and assistant messages
if text_contents:
if text_contents or not formatted_messages:
openai_msg["content"] = " ".join(text_contents)
formatted_messages.append(openai_msg)
+11 -4
View File
@@ -646,10 +646,12 @@ class ChatMessage:
If the message format is invalid, or if `require_tool_call_ids` is True and any Tool Call is missing an
`id` attribute.
"""
if not self.texts and not self.tool_calls and not self.tool_call_results and not self.images and not self.files:
has_content = bool(self.texts or self.tool_calls or self.tool_call_results or self.images or self.files)
# We convert an assistant message with no content part into a message with empty content, which the API accepts
if not has_content and not self.is_from(ChatRole.ASSISTANT):
raise ValueError(
"A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, "
"`ToolCallResult`, `ImageContent`, or `FileContent`."
f"A `ChatMessage` from `{self._role.value}` must contain at least one `TextContent`, `ToolCall`, "
"`ToolCallResult`, `ImageContent`, or `FileContent`. Only assistant messages can be empty."
)
if len(self.tool_call_results) > 0 and len(self._content) > 1:
raise ValueError(
@@ -745,6 +747,9 @@ class ChatMessage:
raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
openai_tool_calls.append(openai_tool_call)
openai_msg["tool_calls"] = openai_tool_calls
# The API rejects a message carrying neither content nor tool calls, so send empty content for it.
if "content" not in openai_msg and "tool_calls" not in openai_msg:
openai_msg["content"] = ""
return openai_msg
@staticmethod
@@ -766,7 +771,9 @@ class ChatMessage:
raise ValueError(f"Unsupported role: {role}")
if role == "assistant":
if not content and not tool_calls:
# An empty string is valid content for an assistant message: that is how a reply with nothing to send
# is serialized. Other falsy content requires tool calls.
if not content and content != "" and not tool_calls:
raise ValueError("For assistant messages, either `content` or `tool_calls` must be present.")
if tool_calls:
for tc in tool_calls:
+6 -3
View File
@@ -5,7 +5,7 @@
from typing import Any
from haystack import logging
from haystack.dataclasses import ChatMessage, ImageContent, ReasoningContent, TextContent
from haystack.dataclasses import ChatMessage, ChatRole, ImageContent, ReasoningContent, TextContent
from haystack.lazy_imports import LazyImport
with LazyImport(message="Run 'pip install \"transformers[torch]\"'") as torch_import:
@@ -69,9 +69,12 @@ def convert_message_to_hf_format(message: ChatMessage) -> dict[str, Any]:
# ReasoningContent is for human transparency only, not sent to the API
non_reasoning_content = [c for c in message._content if not isinstance(c, ReasoningContent)]
if not text_contents and not tool_calls and not tool_call_results and not images:
has_content = bool(text_contents or tool_calls or tool_call_results or images)
# We convert an assistant message with no content part into a message with empty content, which the API accepts
if not has_content and not message.is_from(ChatRole.ASSISTANT):
raise ValueError(
"A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, `ToolCallResult`, or `ImageContent`."
f"A `ChatMessage` from `{message._role.value}` must contain at least one `TextContent`, `ToolCall`, "
"`ToolCallResult`, or `ImageContent`. Only assistant messages can be empty."
)
if len(tool_call_results) > 0 and len(non_reasoning_content) > 1:
raise ValueError(
@@ -0,0 +1,10 @@
---
fixes:
- |
The OpenAI Chat Completions and Responses converters no longer raise on an assistant message with no content
parts, which a Chat Generator returns when it discards a malformed tool call. It is sent with empty content,
which the APIs accept, so the next LLM call goes through. ``ChatMessage.from_openai_dict_format`` accepts the
same empty content, so such a message round-trips.
- |
The Hugging Face message converter no longer raises on an assistant message with no content parts, and sends it
with empty content instead.
@@ -889,8 +889,9 @@ class TestOpenAIChatGenerator:
)
@pytest.mark.integration
def test_live_run(self) -> None:
chat_messages = [ChatMessage.from_user("What's the capital of France")]
# The trailing assistant message has no content parts, as a reply whose only tool call was discarded does.
# It serializes with empty content, so this also checks the API accepts that, not just the converter.
chat_messages = [ChatMessage.from_user("What's the capital of France"), ChatMessage.from_assistant(text=None)]
component = OpenAIChatGenerator(model="gpt-4.1-nano", generation_kwargs={"n": 1})
results = component.run(chat_messages)
assert len(results["replies"]) == 1
@@ -907,7 +908,6 @@ class TestOpenAIChatGenerator:
)
@pytest.mark.integration
def test_live_run_with_response_format_pydantic_model(self, calendar_event_model: type) -> None:
chat_messages = [
ChatMessage.from_user("The marketing summit takes place on October12th at the Hilton Hotel downtown.")
]
@@ -764,7 +764,9 @@ class TestRun:
class TestIntegration:
def test_live_run(self) -> None:
chat_messages = [ChatMessage.from_user("What's the capital of France")]
# The trailing assistant message has no content parts, as a reply whose only tool call was discarded does.
# It serializes with empty content, so this also checks the API accepts that, not just the converter.
chat_messages = [ChatMessage.from_user("What's the capital of France"), ChatMessage.from_assistant(text=None)]
component = OpenAIResponsesChatGenerator(
model="gpt-4.1-nano", generation_kwargs={"include": ["message.output_text.logprobs"]}
)
@@ -1562,9 +1562,21 @@ class TestResponseToChatMessage:
}
]
def test_convert_invalid(self) -> None:
def test_convert_contentless_assistant_message(self) -> None:
# A Chat Generator that discards a malformed tool call returns a contentless reply. The API rejects an input
# item with no `content`, so it is sent with empty content.
message = ChatMessage.from_assistant(text=None)
assert _convert_chat_message_to_responses_api_format(message) == [{"role": "assistant", "content": ""}]
message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[])
def test_convert_reasoning_only_message_adds_no_empty_message(self) -> None:
# Reasoning already produces an input item, so no empty assistant message is appended alongside it.
message = ChatMessage.from_assistant(reasoning="thinking")
assert _convert_chat_message_to_responses_api_format(message) == [
{"summary": [{"text": "thinking", "type": "summary_text"}]}
]
def test_convert_invalid(self) -> None:
message = ChatMessage(_role=ChatRole.USER, _content=[])
with pytest.raises(ValueError):
_convert_chat_message_to_responses_api_format(message)
+14 -1
View File
@@ -955,8 +955,21 @@ class TestToOpenaiDictFormat:
"name": "Assistant1",
}
def test_to_openai_dict_format_contentless_assistant_message(self):
# A Chat Generator that discards a malformed tool call returns a reply with no content parts. The API rejects
# an assistant message with no `content` key, so it is sent with empty content, and it round-trips.
message = ChatMessage.from_assistant(text=None)
assert message.to_openai_dict_format() == {"role": "assistant", "content": ""}
assert ChatMessage.from_openai_dict_format({"role": "assistant", "content": ""}).text == ""
def test_to_openai_dict_format_reasoning_only_assistant_message(self):
# Reasoning is dropped by this format, so a reply carrying only reasoning is sent with empty content, the
# same as a reply carrying reasoning alongside text.
message = ChatMessage.from_assistant(reasoning="only reasoning")
assert message.to_openai_dict_format() == {"role": "assistant", "content": ""}
def test_to_openai_dict_format_invalid(self):
message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[])
message = ChatMessage(_role=ChatRole.USER, _content=[])
with pytest.raises(ValueError):
message.to_openai_dict_format()
+15 -1
View File
@@ -49,8 +49,22 @@ def test_convert_message_to_hf_format():
assert convert_message_to_hf_format(message) == {"role": "tool", "content": tool_result}
def test_convert_contentless_assistant_message_to_hf_format():
# A Chat Generator that discards a malformed tool call returns a reply with no content parts. This format always
# carries a content field, so the reply is sent with it empty.
message = ChatMessage.from_assistant(text=None)
assert convert_message_to_hf_format(message) == {"role": "assistant", "content": ""}
def test_convert_reasoning_only_assistant_message_to_hf_format():
# Reasoning is dropped by this format, so a reply carrying only reasoning is sent with empty content, the same
# as a reply carrying reasoning alongside text.
message = ChatMessage.from_assistant(reasoning="only reasoning")
assert convert_message_to_hf_format(message) == {"role": "assistant", "content": ""}
def test_convert_message_to_hf_invalid():
message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[])
message = ChatMessage(_role=ChatRole.USER, _content=[])
with pytest.raises(ValueError):
convert_message_to_hf_format(message)