mirror of
https://github.com/deepset-ai/haystack.git
synced 2026-10-12 02:12:50 +00:00
feat: relax OpenAI and HF chat message conversions (#12545)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user