Repository: deepset-ai/haystack

Issue:
`OpenAIChatGenerator` does not support multiple textual parts in `ChatMessage`
**Describe the bug**
Creating a `ChatMessage` with multiple textual parts raises an error if used with `OpenAIChatGenerator`.

**To Reproduce**
```python
from haystack.dataclasses import ChatMessage
from haystack.components.generators.chat import OpenAIChatGenerator

user_message = ChatMessage.from_user(
	content_parts=[
		"I am passionate about horror movies.",
		"List 10 horror movies titles.",
		])

llm = OpenAIChatGenerator(model="gpt-4o-mini")

response = llm.run(messages=[user_message])
```

**Error message**
```
  File "...", line 30, in <module>
    response = llm.run(messages=[user_message])
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/stefano.fiorucci/Library/Application Support/hatch/env/virtual/ollama-haystack/Ys13_DeY/ollama-haystack/lib/python3.12/site-packages/haystack/components/generators/chat/openai.py", line 271, in run
    api_args = self._prepare_api_call(
               ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/stefano.fiorucci/Library/Application Support/hatch/env/virtual/ollama-haystack/Ys13_DeY/ollama-haystack/lib/python3.12/site-packages/haystack/components/generators/chat/openai.py", line 393, in _prepare_api_call
    openai_formatted_messages = [message.to_openai_dict_format() for message in messages]
                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/stefano.fiorucci/Library/Application Support/hatch/env/virtual/ollama-haystack/Ys13_DeY/ollama-haystack/lib/python3.12/site-packages/haystack/dataclasses/chat_message.py", line 534, in to_openai_dict_format
    raise ValueError(
ValueError: For OpenAI compatibility, a `ChatMessage` can only contain one `TextContent` or one `ToolCallResult`.
```
**Expected behavior**
Passing multiple textual parts should be supported because the OpenAI API allows that.

```python
from openai import OpenAI

client = OpenAI()
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "I am passionate about horror movies."},
                {"type": "text", "text": "List 10 horror movies titles."},

            ],
        }
    ],
    max_tokens=300,
)

print(response.choices[0])

# THIS WORKS
```

**Additional context**
This is especially relevant in multimodal scenarios.

The new `ChatPromptBuilder` string templates creates multiple content parts when the content type changes.

```
{% message role="user" %}
Here are some images:
{% for img in image_contents %}
  {{ img | templatize_part }}
{% endfor %}
Compare them.
{% endmessage %}
```
This template would produce 2 textual parts + N image parts.

**System:**
 - Haystack version (commit or version number): 2.16.1

`ChatMessage.from_user` forces at least one textual part in the message content
https://github.com/deepset-ai/haystack/blob/f8a71ecf397711c7187a23f56eb35cd767c3c4bd/haystack/dataclasses/chat_message.py#L377-L378

This check is wrong.

In fact, OpenAI, Anthropic, Google and HF API support sending an image without text to the LLM.

If the provider does not support this, we should handle this in the specific Chat Generator. 


Discussion:

Patch:
diff --git a/haystack/dataclasses/chat_message.py b/haystack/dataclasses/chat_message.py
index 5f8e0d89da..b0a86fb470 100644
--- a/haystack/dataclasses/chat_message.py
+++ b/haystack/dataclasses/chat_message.py
@@ -368,20 +368,22 @@ def from_user(
         if text is not None and content_parts is not None:
             raise ValueError("Only one of text or content_parts can be provided.")
 
-        content: Sequence[Union[TextContent, ImageContent]] = []
+        content: List[Union[TextContent, ImageContent]] = []
 
         if text is not None:
             content = [TextContent(text=text)]
         elif content_parts is not None:
-            content = [TextContent(el) if isinstance(el, str) else el for el in content_parts]
-            if not any(isinstance(el, TextContent) for el in content):
-                raise ValueError("The user message must contain at least one textual part.")
-
-            unsupported_parts = [el for el in content if not isinstance(el, (ImageContent, TextContent))]
-            if unsupported_parts:
-                raise ValueError(
-                    f"The user message must contain only text or image parts. Unsupported parts: {unsupported_parts}"
-                )
+            for part in content_parts:
+                if isinstance(part, str):
+                    content.append(TextContent(text=part))
+                elif isinstance(part, (TextContent, ImageContent)):
+                    content.append(part)
+                else:
+                    raise ValueError(
+                        f"The user message must contain only text or image parts. Unsupported part: {part}"
+                    )
+            if len(content) == 0:
+                raise ValueError("The user message must contain at least one textual or image part.")
 
         return cls(_role=ChatRole.USER, _content=content, _meta=meta or {}, _name=name)
 
@@ -520,14 +522,16 @@ def to_openai_dict_format(self, require_tool_call_ids: bool = True) -> Dict[str,
         text_contents = self.texts
         tool_calls = self.tool_calls
         tool_call_results = self.tool_call_results
+        images = self.images
 
-        if not text_contents and not tool_calls and not tool_call_results:
+        if not text_contents and not tool_calls and not tool_call_results and not images:
             raise ValueError(
-                "A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, or `ToolCallResult`."
+                "A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, "
+                "`ToolCallResult`, or `ImageContent`."
             )
-        if len(text_contents) + len(tool_call_results) > 1:
+        if len(tool_call_results) > 0 and len(self._content) > 1:
             raise ValueError(
-                "For OpenAI compatibility, a `ChatMessage` can only contain one `TextContent` or one `ToolCallResult`."
+                "For OpenAI compatibility, a `ChatMessage` with a `ToolCallResult` cannot contain any other content."
             )
 
         openai_msg: Dict[str, Any] = {"role": self._role.value}
@@ -538,7 +542,7 @@ def to_openai_dict_format(self, require_tool_call_ids: bool = True) -> Dict[str,
 
         # user message
         if openai_msg["role"] == "user":
-            if len(self._content) == 1:
+            if len(self._content) == 1 and isinstance(self._content[0], TextContent):
                 openai_msg["content"] = self.text
                 return openai_msg
 
diff --git a/releasenotes/notes/chatmessage-bugfixes-effe6667c35966cb.yaml b/releasenotes/notes/chatmessage-bugfixes-effe6667c35966cb.yaml
new file mode 100644
index 0000000000..5ee8dcf251
--- /dev/null
+++ b/releasenotes/notes/chatmessage-bugfixes-effe6667c35966cb.yaml
@@ -0,0 +1,9 @@
+---
+fixes:
+  - |
+    Addressed incorrect assumptions in the `ChatMessage` class that raised errors in valid usage scenario.
+    1. `ChatMessage.from_user` with `content_parts`: Previously, at least one text part was required, even though some
+    model providers support messages with only image parts. This restriction has been removed.
+    If a provider has such a limitation, it should now be enforced in the provider's implementation.
+    2. `ChatMessage.to_openai_dict_format`: Messages containing multiple text parts weren't supported, despite this
+    being allowed by the OpenAI API. This has now been corrected.


Test patch:
diff --git a/test/dataclasses/test_chat_message.py b/test/dataclasses/test_chat_message.py
index e978c7c0d3..69ad5056bd 100644
--- a/test/dataclasses/test_chat_message.py
+++ b/test/dataclasses/test_chat_message.py
@@ -10,626 +10,584 @@
 from haystack.dataclasses.image_content import ImageContent
 
 
-def test_chat_role_from_str():
-    assert ChatRole.from_str("user") == ChatRole.USER
+class TestChatRole:
+    def test_chat_role_from_str(self):
+        assert ChatRole.from_str("user") == ChatRole.USER
+
+        with pytest.raises(ValueError):
+            ChatRole.from_str("invalid")
+
+    def test_function_role_removed(self):
+        with pytest.raises(ValueError):
+            ChatRole.from_str("function")
+
+
+class TestContentParts:
+    def test_tool_call_init(self):
+        tc = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
+        assert tc.id == "123"
+        assert tc.tool_name == "mytool"
+        assert tc.arguments == {"a": 1}
+
+    def test_tool_call_to_dict(self):
+        tc = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
+        assert tc.to_dict() == {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}}
+
+    def test_tool_call_from_dict(self):
+        tc = ToolCall.from_dict({"id": "123", "tool_name": "mytool", "arguments": {"a": 1}})
+        assert tc.id == "123"
+        assert tc.tool_name == "mytool"
+        assert tc.arguments == {"a": 1}
+
+    def test_tool_call_result_init(self):
+        tcr = ToolCallResult(
+            result="result", origin=ToolCall(id="123", tool_name="mytool", arguments={"a": 1}), error=True
+        )
+        assert tcr.result == "result"
+        assert tcr.origin == ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
+        assert tcr.error
+
+    def test_tool_call_result_to_dict(self):
+        tcr = ToolCallResult(
+            result="result", origin=ToolCall(id="123", tool_name="mytool", arguments={"a": 1}), error=True
+        )
+        assert tcr.to_dict() == {
+            "result": "result",
+            "origin": {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}},
+            "error": True,
+        }
+
+    def test_tool_call_result_from_dict(self):
+        tcr = ToolCallResult.from_dict(
+            {"result": "result", "origin": {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}}, "error": True}
+        )
+        assert tcr.result == "result"
+        assert tcr.origin == ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
+        assert tcr.error
+
+        with pytest.raises(ValueError):
+            ToolCallResult.from_dict({"result": "result", "error": False})
+
+    def test_text_content_init(self):
+        tc = TextContent(text="Hello")
+        assert tc.text == "Hello"
+
+    def test_text_content_to_dict(self):
+        tc = TextContent(text="Hello")
+        assert tc.to_dict() == {"text": "Hello"}
+
+    def test_text_content_from_dict(self):
+        tc = TextContent.from_dict({"text": "Hello"})
+        assert tc.text == "Hello"
+
+
+class TestChatMessage:
+    def test_from_assistant_with_valid_content(self):
+        text = "Hello, how can I assist you?"
+        message = ChatMessage.from_assistant(text)
+
+        assert message.role == ChatRole.ASSISTANT
+        assert message._content == [TextContent(text)]
+        assert message.name is None
+
+        assert message.text == text
+        assert message.texts == [text]
+
+        assert not message.tool_calls
+        assert not message.tool_call
+        assert not message.tool_call_results
+        assert not message.tool_call_result
+        assert not message.images
+        assert not message.image
+
+    def test_from_assistant_with_tool_calls(self):
+        tool_calls = [
+            ToolCall(id="123", tool_name="mytool", arguments={"a": 1}),
+            ToolCall(id="456", tool_name="mytool2", arguments={"b": 2}),
+        ]
+
+        message = ChatMessage.from_assistant(tool_calls=tool_calls)
+
+        assert message.role == ChatRole.ASSISTANT
+        assert message._content == tool_calls
+
+        assert message.tool_calls == tool_calls
+        assert message.tool_call == tool_calls[0]
+
+        assert not message.texts
+        assert not message.text
+        assert not message.tool_call_results
+        assert not message.tool_call_result
+        assert not message.images
+        assert not message.image
+
+    def test_from_user_with_valid_content(self):
+        text = "I have a question."
+        message = ChatMessage.from_user(text=text)
+
+        assert message.role == ChatRole.USER
+        assert message._content == [TextContent(text)]
+        assert message.name is None
+
+        assert message.text == text
+        assert message.texts == [text]
+
+        assert not message.tool_calls
+        assert not message.tool_call
+        assert not message.tool_call_results
+        assert not message.tool_call_result
+        assert not message.images
+        assert not message.image
+
+    def test_from_user_with_name(self):
+        text = "I have a question."
+        message = ChatMessage.from_user(text=text, name="John")
+
+        assert message.name == "John"
+        assert message.role == ChatRole.USER
+        assert message._content == [TextContent(text)]
+
+    def test_from_user_fails_if_no_text_or_content_parts(self):
+        with pytest.raises(ValueError):
+            ChatMessage.from_user()
+
+    def test_from_user_fails_if_text_and_content_parts(self):
+        with pytest.raises(ValueError):
+            ChatMessage.from_user(text="text", content_parts=[TextContent(text="text")])
 
-    with pytest.raises(ValueError):
-        ChatRole.from_str("invalid")
+    def test_from_user_empty_text(self):
+        message = ChatMessage.from_user(text="")
+        assert message.role == ChatRole.USER
+        assert message._content == [TextContent(text="")]
+        assert message.text == ""
+        assert message.texts == [""]
 
+    def test_from_user_with_content_parts(self, base64_image_string):
+        content_parts = [TextContent(text="text"), ImageContent(base64_image=base64_image_string)]
+        message = ChatMessage.from_user(content_parts=content_parts)
+
+        assert message.role == ChatRole.USER
+        assert message._content == content_parts
+
+        content_parts = ["text", ImageContent(base64_image=base64_image_string)]
+        message = ChatMessage.from_user(content_parts=content_parts)
 
-def test_tool_call_init():
-    tc = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
-    assert tc.id == "123"
-    assert tc.tool_name == "mytool"
-    assert tc.arguments == {"a": 1}
-
-
-def test_tool_call_to_dict():
-    tc = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
-    assert tc.to_dict() == {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}}
-
-
-def test_tool_call_from_dict():
-    tc = ToolCall.from_dict({"id": "123", "tool_name": "mytool", "arguments": {"a": 1}})
-    assert tc.id == "123"
-    assert tc.tool_name == "mytool"
-    assert tc.arguments == {"a": 1}
-
-
-def test_tool_call_result_init():
-    tcr = ToolCallResult(result="result", origin=ToolCall(id="123", tool_name="mytool", arguments={"a": 1}), error=True)
-    assert tcr.result == "result"
-    assert tcr.origin == ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
-    assert tcr.error
-
-
-def test_tool_call_result_to_dict():
-    tcr = ToolCallResult(result="result", origin=ToolCall(id="123", tool_name="mytool", arguments={"a": 1}), error=True)
-    assert tcr.to_dict() == {
-        "result": "result",
-        "origin": {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}},
-        "error": True,
-    }
-
-
-def test_tool_call_result_from_dict():
-    tcr = ToolCallResult.from_dict(
-        {"result": "result", "origin": {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}}, "error": True}
-    )
-    assert tcr.result == "result"
-    assert tcr.origin == ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
-    assert tcr.error
-
-    with pytest.raises(ValueError):
-        ToolCallResult.from_dict({"result": "result", "error": False})
-
-
-def test_text_content_init():
-    tc = TextContent(text="Hello")
-    assert tc.text == "Hello"
-
-
-def test_text_content_to_dict():
-    tc = TextContent(text="Hello")
-    assert tc.to_dict() == {"text": "Hello"}
-
-
-def test_text_content_from_dict():
-    tc = TextContent.from_dict({"text": "Hello"})
-    assert tc.text == "Hello"
-
-
-def test_from_assistant_with_valid_content():
-    text = "Hello, how can I assist you?"
-    message = ChatMessage.from_assistant(text)
-
-    assert message.role == ChatRole.ASSISTANT
-    assert message._content == [TextContent(text)]
-    assert message.name is None
-
-    assert message.text == text
-    assert message.texts == [text]
-
-    assert not message.tool_calls
-    assert not message.tool_call
-    assert not message.tool_call_results
-    assert not message.tool_call_result
-    assert not message.images
-    assert not message.image
-
-
-def test_from_assistant_with_tool_calls():
-    tool_calls = [
-        ToolCall(id="123", tool_name="mytool", arguments={"a": 1}),
-        ToolCall(id="456", tool_name="mytool2", arguments={"b": 2}),
-    ]
-
-    message = ChatMessage.from_assistant(tool_calls=tool_calls)
-
-    assert message.role == ChatRole.ASSISTANT
-    assert message._content == tool_calls
-
-    assert message.tool_calls == tool_calls
-    assert message.tool_call == tool_calls[0]
-
-    assert not message.texts
-    assert not message.text
-    assert not message.tool_call_results
-    assert not message.tool_call_result
-    assert not message.images
-    assert not message.image
-
-
-def test_from_user_with_valid_content():
-    text = "I have a question."
-    message = ChatMessage.from_user(text=text)
-
-    assert message.role == ChatRole.USER
-    assert message._content == [TextContent(text)]
-    assert message.name is None
-
-    assert message.text == text
-    assert message.texts == [text]
-
-    assert not message.tool_calls
-    assert not message.tool_call
-    assert not message.tool_call_results
-    assert not message.tool_call_result
-    assert not message.images
-    assert not message.image
-
-
-def test_from_user_with_name():
-    text = "I have a question."
-    message = ChatMessage.from_user(text=text, name="John")
-
-    assert message.name == "John"
-    assert message.role == ChatRole.USER
-    assert message._content == [TextContent(text)]
-
-
-def test_from_user_fails_if_no_text_or_content_parts():
-    with pytest.raises(ValueError):
-        ChatMessage.from_user()
-
-
-def test_from_user_fails_if_text_and_content_parts():
-    with pytest.raises(ValueError):
-        ChatMessage.from_user(text="text", content_parts=[TextContent(text="text")])
-
-
-def test_from_user_empty_text():
-    message = ChatMessage.from_user(text="")
-    assert message.role == ChatRole.USER
-    assert message._content == [TextContent(text="")]
-    assert message.text == ""
-    assert message.texts == [""]
-
-
-def test_from_user_with_content_parts(base64_image_string):
-    content_parts = [TextContent(text="text"), ImageContent(base64_image=base64_image_string)]
-    message = ChatMessage.from_user(content_parts=content_parts)
-
-    assert message.role == ChatRole.USER
-    assert message._content == content_parts
-
-    content_parts = ["text", ImageContent(base64_image=base64_image_string)]
-    message = ChatMessage.from_user(content_parts=content_parts)
-
-    assert message.role == ChatRole.USER
-    assert message._content == [TextContent(text="text"), ImageContent(base64_image=base64_image_string)]
-
-
-def test_from_user_with_content_parts_fails_if_no_textual_parts(base64_image_string):
-    with pytest.raises(ValueError):
-        ChatMessage.from_user(content_parts=[ImageContent(base64_image=base64_image_string)])
-
-
-def test_from_user_with_content_parts_fails_unsupported_parts():
-    with pytest.raises(ValueError):
-        ChatMessage.from_user(content_parts=["text part", ToolCall(id="123", tool_name="mytool", arguments={"a": 1})])
-
-
-def test_from_system_with_valid_content():
-    text = "I have a question."
-    message = ChatMessage.from_system(text=text)
-
-    assert message.role == ChatRole.SYSTEM
-    assert message._content == [TextContent(text)]
-
-    assert message.text == text
-    assert message.texts == [text]
-
-    assert not message.tool_calls
-    assert not message.tool_call
-    assert not message.tool_call_results
-    assert not message.tool_call_result
-    assert not message.images
-    assert not message.image
-
-
-def test_from_tool_with_valid_content():
-    tool_result = "Tool result"
-    origin = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
-    message = ChatMessage.from_tool(tool_result, origin, error=False)
-
-    tcr = ToolCallResult(result=tool_result, origin=origin, error=False)
-
-    assert message._content == [tcr]
-    assert message.role == ChatRole.TOOL
-
-    assert message.tool_call_result == tcr
-    assert message.tool_call_results == [tcr]
-
-    assert not message.tool_calls
-    assert not message.tool_call
-    assert not message.texts
-    assert not message.text
-    assert not message.images
-    assert not message.image
-
-
-def test_multiple_text_segments():
-    texts = [TextContent(text="Hello"), TextContent(text="World")]
-    message = ChatMessage(_role=ChatRole.USER, _content=texts)
-
-    assert message.texts == ["Hello", "World"]
-    assert len(message) == 2
-
-
-def test_mixed_content():
-    content = [TextContent(text="Hello"), ToolCall(id="123", tool_name="mytool", arguments={"a": 1})]
-
-    message = ChatMessage(_role=ChatRole.ASSISTANT, _content=content)
-
-    assert len(message) == 2
-    assert message.texts == ["Hello"]
-    assert message.text == "Hello"
-
-    assert message.tool_calls == [content[1]]
-    assert message.tool_call == content[1]
-
-
-def test_function_role_removed():
-    with pytest.raises(ValueError):
-        ChatRole.from_str("function")
-
-
-def test_from_function_class_method_removed():
-    with pytest.raises(AttributeError):
-        ChatMessage.from_function("Result of function invocation", "my_function")
-
-
-def test_serde(base64_image_string):
-    # the following message is created just for testing purposes and does not make sense in a real use case
-
-    role = ChatRole.ASSISTANT
-
-    text_content = TextContent(text="Hello")
-    tool_call = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
-    tool_call_result = ToolCallResult(result="result", origin=tool_call, error=False)
-    image_content = ImageContent(
-        base64_image=base64_image_string, mime_type="image/png", detail="auto", meta={"key": "value"}, validation=True
-    )
-    meta = {"some": "info"}
-
-    message = ChatMessage(_role=role, _content=[text_content, tool_call, tool_call_result, image_content], _meta=meta)
-
-    serialized_message = message.to_dict()
-    assert serialized_message == {
-        "content": [
-            {"text": "Hello"},
-            {"tool_call": {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}}},
-            {
-                "tool_call_result": {
-                    "result": "result",
-                    "error": False,
-                    "origin": {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}},
+        assert message.role == ChatRole.USER
+        assert message._content == [TextContent(text="text"), ImageContent(base64_image=base64_image_string)]
+
+        content_parts = [ImageContent(base64_image=base64_image_string)]
+        message = ChatMessage.from_user(content_parts=content_parts)
+
+        assert message.role == ChatRole.USER
+        assert message._content == [ImageContent(base64_image=base64_image_string)]
+
+    def test_from_user_with_content_parts_fails_unsupported_parts(self):
+        with pytest.raises(ValueError):
+            ChatMessage.from_user(
+                content_parts=["text part", ToolCall(id="123", tool_name="mytool", arguments={"a": 1})]
+            )
+
+    def test_from_user_with_content_parts_fails_with_empty_parts(self):
+        with pytest.raises(ValueError):
+            ChatMessage.from_user(content_parts=[])
+
+    def test_from_system_with_valid_content(self):
+        text = "I have a question."
+        message = ChatMessage.from_system(text=text)
+
+        assert message.role == ChatRole.SYSTEM
+        assert message._content == [TextContent(text)]
+
+        assert message.text == text
+        assert message.texts == [text]
+
+        assert not message.tool_calls
+        assert not message.tool_call
+        assert not message.tool_call_results
+        assert not message.tool_call_result
+        assert not message.images
+        assert not message.image
+
+    def test_from_tool_with_valid_content(self):
+        tool_result = "Tool result"
+        origin = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
+        message = ChatMessage.from_tool(tool_result, origin, error=False)
+
+        tcr = ToolCallResult(result=tool_result, origin=origin, error=False)
+
+        assert message._content == [tcr]
+        assert message.role == ChatRole.TOOL
+
+        assert message.tool_call_result == tcr
+        assert message.tool_call_results == [tcr]
+
+        assert not message.tool_calls
+        assert not message.tool_call
+        assert not message.texts
+        assert not message.text
+        assert not message.images
+        assert not message.image
+
+    def test_multiple_text_segments(self):
+        texts = [TextContent(text="Hello"), TextContent(text="World")]
+        message = ChatMessage(_role=ChatRole.USER, _content=texts)
+
+        assert message.texts == ["Hello", "World"]
+        assert len(message) == 2
+
+    def test_mixed_content(self):
+        content = [TextContent(text="Hello"), ToolCall(id="123", tool_name="mytool", arguments={"a": 1})]
+
+        message = ChatMessage(_role=ChatRole.ASSISTANT, _content=content)
+
+        assert len(message) == 2
+        assert message.texts == ["Hello"]
+        assert message.text == "Hello"
+
+        assert message.tool_calls == [content[1]]
+        assert message.tool_call == content[1]
+
+    def test_from_function_class_method_removed(self):
+        with pytest.raises(AttributeError):
+            ChatMessage.from_function("Result of function invocation", "my_function")
+
+    def test_serde(self, base64_image_string):
+        # the following message is created just for testing purposes and does not make sense in a real use case
+
+        role = ChatRole.ASSISTANT
+
+        text_content = TextContent(text="Hello")
+        tool_call = ToolCall(id="123", tool_name="mytool", arguments={"a": 1})
+        tool_call_result = ToolCallResult(result="result", origin=tool_call, error=False)
+        image_content = ImageContent(
+            base64_image=base64_image_string,
+            mime_type="image/png",
+            detail="auto",
+            meta={"key": "value"},
+            validation=True,
+        )
+        meta = {"some": "info"}
+
+        message = ChatMessage(
+            _role=role, _content=[text_content, tool_call, tool_call_result, image_content], _meta=meta
+        )
+
+        serialized_message = message.to_dict()
+        assert serialized_message == {
+            "content": [
+                {"text": "Hello"},
+                {"tool_call": {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}}},
+                {
+                    "tool_call_result": {
+                        "result": "result",
+                        "error": False,
+                        "origin": {"id": "123", "tool_name": "mytool", "arguments": {"a": 1}},
+                    }
+                },
+                {
+                    "image": {
+                        "base64_image": base64_image_string,
+                        "mime_type": "image/png",
+                        "detail": "auto",
+                        "meta": {"key": "value"},
+                        "validation": True,
+                    }
+                },
+            ],
+            "role": "assistant",
+            "name": None,
+            "meta": {"some": "info"},
+        }
+
+        deserialized_message = ChatMessage.from_dict(serialized_message)
+        assert deserialized_message == message
+
+    def test_to_dict_with_invalid_content_type(self):
+        text_content = TextContent(text="Hello")
+        invalid_content = "invalid"
+
+        message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[text_content, invalid_content])
+
+        with pytest.raises(TypeError):
+            message.to_dict()
+
+    def test_from_dict_with_invalid_content_type(self):
+        data = {"role": "assistant", "content": [{"text": "Hello"}, "invalid"]}
+        with pytest.raises(ValueError, match="Unsupported content part in the serialized ChatMessage"):
+            ChatMessage.from_dict(data)
+
+        data = {"role": "assistant", "content": [{"text": "Hello"}, {"invalid": "invalid"}]}
+        with pytest.raises(ValueError, match="Unsupported content part in the serialized ChatMessage"):
+            ChatMessage.from_dict(data)
+
+    def test_from_dict_with_missing_role(self):
+        data = {"content": [{"text": "Hello"}], "meta": {}}
+
+        with pytest.raises(ValueError, match=r"The `role` field is required"):
+            ChatMessage.from_dict(data)
+
+    def test_from_dict_with_pre29_format(self):
+        """
+        Test that we can deserialize messages serialized with pre-2.9.0 format, where the `content` field is a string.
+        """
+        serialized_msg_pre_29 = {
+            "role": "user",
+            "content": "This is a message",
+            "name": "some_name",
+            "meta": {"some": "info"},
+        }
+        msg = ChatMessage.from_dict(serialized_msg_pre_29)
+
+        assert msg.role == ChatRole.USER
+        assert msg._content == [TextContent(text="This is a message")]
+        assert msg.name == "some_name"
+        assert msg.meta == {"some": "info"}
+
+    def test_from_dict_with_pre29_format_some_fields_missing(self):
+        serialized_msg_pre_29 = {"role": "user", "content": "This is a message"}
+        msg = ChatMessage.from_dict(serialized_msg_pre_29)
+
+        assert msg.role == ChatRole.USER
+        assert msg._content == [TextContent(text="This is a message")]
+        assert msg.name is None
+        assert msg.meta == {}
+
+    def test_from_dict_with_pre212_format(self):
+        """
+        Test for ChatMessage.from_dict
+
+        Test that we can deserialize messages serialized with versions >=2.9.0 and <2.12.0,
+        where the serialized message has fields `_role`, `_content`, `_name`, and `_meta`.
+        """
+        serialized_msg_pre_212 = {
+            "_role": "user",
+            "_content": [{"text": "This is a message"}],
+            "_name": "some_name",
+            "_meta": {"some": "info"},
+        }
+        msg = ChatMessage.from_dict(serialized_msg_pre_212)
+
+        assert msg.role == ChatRole.USER
+        assert msg._content == [TextContent(text="This is a message")]
+        assert msg.name == "some_name"
+        assert msg.meta == {"some": "info"}
+
+    def test_from_dict_with_pre212_format_some_fields_missing(self):
+        serialized_msg_pre_212 = {"_role": "user", "_content": [{"text": "This is a message"}]}
+        msg = ChatMessage.from_dict(serialized_msg_pre_212)
+
+        assert msg.role == ChatRole.USER
+        assert msg._content == [TextContent(text="This is a message")]
+        assert msg.name is None
+        assert msg.meta == {}
+
+    def test_from_dict_some_fields_missing(self):
+        serialized_msg = {"role": "user", "content": [{"text": "This is a message"}]}
+        msg = ChatMessage.from_dict(serialized_msg)
+
+        assert msg.role == ChatRole.USER
+        assert msg._content == [TextContent(text="This is a message")]
+        assert msg.name is None
+        assert msg.meta == {}
+
+    def test_from_dict_missing_content_field(self):
+        serialized_msg = {"role": "user", "name": "some_name", "meta": {"some": "info"}}
+        with pytest.raises(ValueError):
+            ChatMessage.from_dict(serialized_msg)
+
+    def test_chat_message_content_attribute_removed(self):
+        message = ChatMessage.from_user(text="This is a message")
+        with pytest.raises(AttributeError):
+            message.content
+
+    def test_chat_message_init_parameters_removed(self):
+        with pytest.raises(TypeError):
+            ChatMessage(role="irrelevant", content="This is a message")
+
+
+class TestToOpenaiDictFormat:
+    def test_to_openai_dict_format_system_message(self):
+        message = ChatMessage.from_system("You are good assistant")
+        assert message.to_openai_dict_format() == {"role": "system", "content": "You are good assistant"}
+
+    def test_to_openai_dict_format_user_message(self):
+        message = ChatMessage.from_user("I have a question")
+        assert message.to_openai_dict_format() == {"role": "user", "content": "I have a question"}
+
+    def test_to_openai_dict_format_multimodal_user_message(self, base64_image_string):
+        message = ChatMessage.from_user(
+            content_parts=[
+                TextContent("I have a question"),
+                ImageContent(base64_image=base64_image_string, detail="auto"),
+            ]
+        )
+        assert message.to_openai_dict_format() == {
+            "role": "user",
+            "content": [
+                {"type": "text", "text": "I have a question"},
+                {
+                    "type": "image_url",
+                    "image_url": {"url": f"data:image/png;base64,{base64_image_string}", "detail": "auto"},
+                },
+            ],
+        }
+
+        # image content only should be supported as well
+        message = ChatMessage.from_user(content_parts=[ImageContent(base64_image=base64_image_string, detail="auto")])
+        assert message.to_openai_dict_format() == {
+            "role": "user",
+            "content": [
+                {
+                    "type": "image_url",
+                    "image_url": {"url": f"data:image/png;base64,{base64_image_string}", "detail": "auto"},
                 }
-            },
-            {
-                "image": {
-                    "base64_image": base64_image_string,
-                    "mime_type": "image/png",
-                    "detail": "auto",
-                    "meta": {"key": "value"},
-                    "validation": True,
-                }
-            },
-        ],
-        "role": "assistant",
-        "name": None,
-        "meta": {"some": "info"},
-    }
-
-    deserialized_message = ChatMessage.from_dict(serialized_message)
-    assert deserialized_message == message
-
-
-def test_to_dict_with_invalid_content_type():
-    text_content = TextContent(text="Hello")
-    invalid_content = "invalid"
-
-    message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[text_content, invalid_content])
-
-    with pytest.raises(TypeError):
-        message.to_dict()
-
-
-def test_from_dict_with_invalid_content_type():
-    data = {"role": "assistant", "content": [{"text": "Hello"}, "invalid"]}
-    with pytest.raises(ValueError, match="Unsupported content part in the serialized ChatMessage"):
-        ChatMessage.from_dict(data)
-
-    data = {"role": "assistant", "content": [{"text": "Hello"}, {"invalid": "invalid"}]}
-    with pytest.raises(ValueError, match="Unsupported content part in the serialized ChatMessage"):
-        ChatMessage.from_dict(data)
-
-
-def test_from_dict_with_missing_role():
-    data = {"content": [{"text": "Hello"}], "meta": {}}
-
-    with pytest.raises(ValueError, match=r"The `role` field is required"):
-        ChatMessage.from_dict(data)
-
-
-def test_from_dict_with_pre29_format():
-    """Test that we can deserialize messages serialized with pre-2.9.0 format, where the `content` field is a string."""
-    serialized_msg_pre_29 = {
-        "role": "user",
-        "content": "This is a message",
-        "name": "some_name",
-        "meta": {"some": "info"},
-    }
-    msg = ChatMessage.from_dict(serialized_msg_pre_29)
-
-    assert msg.role == ChatRole.USER
-    assert msg._content == [TextContent(text="This is a message")]
-    assert msg.name == "some_name"
-    assert msg.meta == {"some": "info"}
-
-
-def test_from_dict_with_pre29_format_some_fields_missing():
-    serialized_msg_pre_29 = {"role": "user", "content": "This is a message"}
-    msg = ChatMessage.from_dict(serialized_msg_pre_29)
-
-    assert msg.role == ChatRole.USER
-    assert msg._content == [TextContent(text="This is a message")]
-    assert msg.name is None
-    assert msg.meta == {}
-
-
-def test_from_dict_with_pre212_format():
-    """
-    Test for ChatMessage.from_dict
-
-    Test that we can deserialize messages serialized with versions >=2.9.0 and <2.12.0,
-    where the serialized message has fields `_role`, `_content`, `_name`, and `_meta`.
-    """
-    serialized_msg_pre_212 = {
-        "_role": "user",
-        "_content": [{"text": "This is a message"}],
-        "_name": "some_name",
-        "_meta": {"some": "info"},
-    }
-    msg = ChatMessage.from_dict(serialized_msg_pre_212)
-
-    assert msg.role == ChatRole.USER
-    assert msg._content == [TextContent(text="This is a message")]
-    assert msg.name == "some_name"
-    assert msg.meta == {"some": "info"}
-
-
-def test_from_dict_with_pre212_format_some_fields_missing():
-    serialized_msg_pre_212 = {"_role": "user", "_content": [{"text": "This is a message"}]}
-    msg = ChatMessage.from_dict(serialized_msg_pre_212)
-
-    assert msg.role == ChatRole.USER
-    assert msg._content == [TextContent(text="This is a message")]
-    assert msg.name is None
-    assert msg.meta == {}
-
-
-def test_from_dict_some_fields_missing():
-    serialized_msg = {"role": "user", "content": [{"text": "This is a message"}]}
-    msg = ChatMessage.from_dict(serialized_msg)
-
-    assert msg.role == ChatRole.USER
-    assert msg._content == [TextContent(text="This is a message")]
-    assert msg.name is None
-    assert msg.meta == {}
-
-
-def test_from_dict_missing_content_field():
-    serialized_msg = {"role": "user", "name": "some_name", "meta": {"some": "info"}}
-    with pytest.raises(ValueError):
-        ChatMessage.from_dict(serialized_msg)
-
-
-def test_chat_message_content_attribute_removed():
-    message = ChatMessage.from_user(text="This is a message")
-    with pytest.raises(AttributeError):
-        message.content
-
-
-def test_chat_message_init_parameters_removed():
-    with pytest.raises(TypeError):
-        ChatMessage(role="irrelevant", content="This is a message")
-
-
-def test_to_openai_dict_format():
-    message = ChatMessage.from_system("You are good assistant")
-    assert message.to_openai_dict_format() == {"role": "system", "content": "You are good assistant"}
-
-    message = ChatMessage.from_user("I have a question")
-    assert message.to_openai_dict_format() == {"role": "user", "content": "I have a question"}
-
-    message = ChatMessage.from_assistant(text="I have an answer", meta={"finish_reason": "stop"})
-    assert message.to_openai_dict_format() == {"role": "assistant", "content": "I have an answer"}
-
-    message = ChatMessage.from_assistant(
-        tool_calls=[ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})]
-    )
-    assert message.to_openai_dict_format() == {
-        "role": "assistant",
-        "tool_calls": [
-            {"id": "123", "type": "function", "function": {"name": "weather", "arguments": '{"city": "Paris"}'}}
-        ],
-    }
-
-    tool_result = json.dumps({"weather": "sunny", "temperature": "25"})
-    message = ChatMessage.from_tool(
-        tool_result=tool_result, origin=ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})
-    )
-    assert message.to_openai_dict_format() == {"role": "tool", "content": tool_result, "tool_call_id": "123"}
-
-    message = ChatMessage.from_user(text="I have a question", name="John")
-    assert message.to_openai_dict_format() == {"role": "user", "content": "I have a question", "name": "John"}
-
-    message = ChatMessage.from_assistant(text="I have an answer", name="Assistant1")
-    assert message.to_openai_dict_format() == {"role": "assistant", "content": "I have an answer", "name": "Assistant1"}
-
-
-def test_to_openai_dict_format_system_message():
-    message = ChatMessage.from_system("You are good assistant")
-    assert message.to_openai_dict_format() == {"role": "system", "content": "You are good assistant"}
-
-
-def test_to_openai_dict_format_user_message():
-    message = ChatMessage.from_user("I have a question")
-    assert message.to_openai_dict_format() == {"role": "user", "content": "I have a question"}
-
-
-def test_to_openai_dict_format_multimodal_user_message(base64_image_string):
-    message = ChatMessage.from_user(
-        content_parts=[TextContent("I have a question"), ImageContent(base64_image=base64_image_string, detail="auto")]
-    )
-    assert message.to_openai_dict_format() == {
-        "role": "user",
-        "content": [
-            {"type": "text", "text": "I have a question"},
-            {
-                "type": "image_url",
-                "image_url": {"url": f"data:image/png;base64,{base64_image_string}", "detail": "auto"},
-            },
-        ],
-    }
-
-
-def test_to_openai_dict_format_assistant_message():
-    message = ChatMessage.from_assistant(text="I have an answer", meta={"finish_reason": "stop"})
-    assert message.to_openai_dict_format() == {"role": "assistant", "content": "I have an answer"}
-
-    message = ChatMessage.from_assistant(
-        tool_calls=[ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})]
-    )
-    assert message.to_openai_dict_format() == {
-        "role": "assistant",
-        "tool_calls": [
-            {"id": "123", "type": "function", "function": {"name": "weather", "arguments": '{"city": "Paris"}'}}
-        ],
-    }
-
-
-def test_to_openai_dict_format_tool_message():
-    tool_result = json.dumps({"weather": "sunny", "temperature": "25"})
-    message = ChatMessage.from_tool(
-        tool_result=tool_result, origin=ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})
-    )
-    assert message.to_openai_dict_format() == {"role": "tool", "content": tool_result, "tool_call_id": "123"}
-
-
-def test_to_openai_dict_format_with_name():
-    message = ChatMessage.from_user(text="I have a question", name="John")
-    assert message.to_openai_dict_format() == {"role": "user", "content": "I have a question", "name": "John"}
-
-    message = ChatMessage.from_assistant(text="I have an answer", name="Assistant1")
-    assert message.to_openai_dict_format() == {"role": "assistant", "content": "I have an answer", "name": "Assistant1"}
-
-
-def test_to_openai_dict_format_invalid():
-    message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[])
-    with pytest.raises(ValueError):
-        message.to_openai_dict_format()
-
-    message = ChatMessage(
-        _role=ChatRole.ASSISTANT,
-        _content=[TextContent(text="I have an answer"), TextContent(text="I have another answer")],
-    )
-    with pytest.raises(ValueError):
-        message.to_openai_dict_format()
-
-    tool_call_null_id = ToolCall(id=None, tool_name="weather", arguments={"city": "Paris"})
-    message = ChatMessage.from_assistant(tool_calls=[tool_call_null_id])
-    with pytest.raises(ValueError):
-        message.to_openai_dict_format()
-
-    message = ChatMessage.from_tool(tool_result="result", origin=tool_call_null_id)
-    with pytest.raises(ValueError):
-        message.to_openai_dict_format()
-
-
-def test_to_openai_dict_format_require_tool_call_ids():
-    tool_call_null_id = ToolCall(id=None, tool_name="weather", arguments={"city": "Paris"})
-    message = ChatMessage.from_assistant(tool_calls=[tool_call_null_id])
-    with pytest.raises(ValueError):
-        message.to_openai_dict_format(require_tool_call_ids=True)
-
-    message = ChatMessage.from_tool(tool_result="result", origin=tool_call_null_id)
-    with pytest.raises(ValueError):
-        message.to_openai_dict_format(require_tool_call_ids=True)
-
-
-def test_to_openai_dict_format_require_tool_call_ids_false():
-    tool_call_null_id = ToolCall(id=None, tool_name="weather", arguments={"city": "Paris"})
-    message = ChatMessage.from_assistant(tool_calls=[tool_call_null_id])
-    openai_msg = message.to_openai_dict_format(require_tool_call_ids=False)
-
-    assert openai_msg == {
-        "role": "assistant",
-        "tool_calls": [{"type": "function", "function": {"name": "weather", "arguments": '{"city": "Paris"}'}}],
-    }
-
-    message = ChatMessage.from_tool(tool_result="result", origin=tool_call_null_id)
-    openai_msg = message.to_openai_dict_format(require_tool_call_ids=False)
-    assert openai_msg == {"role": "tool", "content": "result"}
-
-
-def test_from_openai_dict_format_user_message():
-    openai_msg = {"role": "user", "content": "Hello, how are you?", "name": "John"}
-    message = ChatMessage.from_openai_dict_format(openai_msg)
-    assert message.role.value == "user"
-    assert message.text == "Hello, how are you?"
-    assert message.name == "John"
-
-
-def test_from_openai_dict_format_system_message():
-    openai_msg = {"role": "system", "content": "You are a helpful assistant"}
-    message = ChatMessage.from_openai_dict_format(openai_msg)
-    assert message.role.value == "system"
-    assert message.text == "You are a helpful assistant"
-
-
-def test_from_openai_dict_format_assistant_message_with_content():
-    openai_msg = {"role": "assistant", "content": "I can help with that"}
-    message = ChatMessage.from_openai_dict_format(openai_msg)
-    assert message.role.value == "assistant"
-    assert message.text == "I can help with that"
-
-
-def test_from_openai_dict_format_assistant_message_with_tool_calls():
-    openai_msg = {
-        "role": "assistant",
-        "content": None,
-        "tool_calls": [{"id": "call_123", "function": {"name": "get_weather", "arguments": '{"location": "Berlin"}'}}],
-    }
-    message = ChatMessage.from_openai_dict_format(openai_msg)
-    assert message.role.value == "assistant"
-    assert message.text is None
-    assert len(message.tool_calls) == 1
-    tool_call = message.tool_calls[0]
-    assert tool_call.id == "call_123"
-    assert tool_call.tool_name == "get_weather"
-    assert tool_call.arguments == {"location": "Berlin"}
-
-
-def test_from_openai_dict_format_tool_message():
-    openai_msg = {"role": "tool", "content": "The weather is sunny", "tool_call_id": "call_123"}
-    message = ChatMessage.from_openai_dict_format(openai_msg)
-    assert message.role.value == "tool"
-    assert message.tool_call_result.result == "The weather is sunny"
-    assert message.tool_call_result.origin.id == "call_123"
-
-
-def test_from_openai_dict_format_tool_without_id():
-    openai_msg = {"role": "tool", "content": "The weather is sunny"}
-    message = ChatMessage.from_openai_dict_format(openai_msg)
-    assert message.role.value == "tool"
-    assert message.tool_call_result.result == "The weather is sunny"
-    assert message.tool_call_result.origin.id is None
-
-
-def test_from_openai_dict_format_missing_role():
-    with pytest.raises(ValueError):
-        ChatMessage.from_openai_dict_format({"content": "test"})
-
-
-def test_from_openai_dict_format_missing_content():
-    with pytest.raises(ValueError):
-        ChatMessage.from_openai_dict_format({"role": "user"})
-
-
-def test_from_openai_dict_format_invalid_tool_calls():
-    openai_msg = {"role": "assistant", "tool_calls": [{"invalid": "format"}]}
-    with pytest.raises(ValueError):
-        ChatMessage.from_openai_dict_format(openai_msg)
-
-
-def test_from_openai_dict_format_unsupported_role():
-    with pytest.raises(ValueError):
-        ChatMessage.from_openai_dict_format({"role": "invalid", "content": "test"})
-
-
-def test_from_openai_dict_format_assistant_missing_content_and_tool_calls():
-    with pytest.raises(ValueError):
-        ChatMessage.from_openai_dict_format({"role": "assistant", "irrelevant": "irrelevant"})
+            ],
+        }
+
+    def test_to_openai_dict_format_assistant_message(self):
+        message = ChatMessage.from_assistant(text="I have an answer", meta={"finish_reason": "stop"})
+        assert message.to_openai_dict_format() == {"role": "assistant", "content": "I have an answer"}
+
+        message = ChatMessage.from_assistant(
+            tool_calls=[ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})]
+        )
+        assert message.to_openai_dict_format() == {
+            "role": "assistant",
+            "tool_calls": [
+                {"id": "123", "type": "function", "function": {"name": "weather", "arguments": '{"city": "Paris"}'}}
+            ],
+        }
+
+    def test_to_openai_dict_format_tool_message(self):
+        tool_result = json.dumps({"weather": "sunny", "temperature": "25"})
+        message = ChatMessage.from_tool(
+            tool_result=tool_result, origin=ToolCall(id="123", tool_name="weather", arguments={"city": "Paris"})
+        )
+        assert message.to_openai_dict_format() == {"role": "tool", "content": tool_result, "tool_call_id": "123"}
+
+    def test_to_openai_dict_format_with_name(self):
+        message = ChatMessage.from_user(text="I have a question", name="John")
+        assert message.to_openai_dict_format() == {"role": "user", "content": "I have a question", "name": "John"}
+
+        message = ChatMessage.from_assistant(text="I have an answer", name="Assistant1")
+        assert message.to_openai_dict_format() == {
+            "role": "assistant",
+            "content": "I have an answer",
+            "name": "Assistant1",
+        }
+
+    def test_to_openai_dict_format_invalid(self):
+        message = ChatMessage(_role=ChatRole.ASSISTANT, _content=[])
+        with pytest.raises(ValueError):
+            message.to_openai_dict_format()
+
+        message = ChatMessage(
+            _role=ChatRole.USER,
+            _content=[
+                TextContent(text="I have an answer"),
+                ToolCallResult(
+                    result="I have another answer",
+                    origin=ToolCall(id="123", tool_name="mytool", arguments={"a": 1}),
+                    error=False,
+                ),
+            ],
+        )
+        with pytest.raises(ValueError):
+            message.to_openai_dict_format()
+
+    def test_to_openai_dict_format_require_tool_call_ids(self):
+        tool_call_null_id = ToolCall(id=None, tool_name="weather", arguments={"city": "Paris"})
+        message = ChatMessage.from_assistant(tool_calls=[tool_call_null_id])
+        with pytest.raises(ValueError):
+            message.to_openai_dict_format(require_tool_call_ids=True)
+
+        message = ChatMessage.from_tool(tool_result="result", origin=tool_call_null_id)
+        with pytest.raises(ValueError):
+            message.to_openai_dict_format(require_tool_call_ids=True)
+
+    def test_to_openai_dict_format_require_tool_call_ids_false(self):
+        tool_call_null_id = ToolCall(id=None, tool_name="weather", arguments={"city": "Paris"})
+        message = ChatMessage.from_assistant(tool_calls=[tool_call_null_id])
+        openai_msg = message.to_openai_dict_format(require_tool_call_ids=False)
+
+        assert openai_msg == {
+            "role": "assistant",
+            "tool_calls": [{"type": "function", "function": {"name": "weather", "arguments": '{"city": "Paris"}'}}],
+        }
+
+        message = ChatMessage.from_tool(tool_result="result", origin=tool_call_null_id)
+        openai_msg = message.to_openai_dict_format(require_tool_call_ids=False)
+        assert openai_msg == {"role": "tool", "content": "result"}
+
+
+class TestFromOpenaiDictFormat:
+    def test_from_openai_dict_format_user_message(self):
+        openai_msg = {"role": "user", "content": "Hello, how are you?", "name": "John"}
+        message = ChatMessage.from_openai_dict_format(openai_msg)
+        assert message.role.value == "user"
+        assert message.text == "Hello, how are you?"
+        assert message.name == "John"
+
+    def test_from_openai_dict_format_system_message(self):
+        openai_msg = {"role": "system", "content": "You are a helpful assistant"}
+        message = ChatMessage.from_openai_dict_format(openai_msg)
+        assert message.role.value == "system"
+        assert message.text == "You are a helpful assistant"
+
+    def test_from_openai_dict_format_assistant_message_with_content(self):
+        openai_msg = {"role": "assistant", "content": "I can help with that"}
+        message = ChatMessage.from_openai_dict_format(openai_msg)
+        assert message.role.value == "assistant"
+        assert message.text == "I can help with that"
+
+    def test_from_openai_dict_format_assistant_message_with_tool_calls(self):
+        openai_msg = {
+            "role": "assistant",
+            "content": None,
+            "tool_calls": [
+                {"id": "call_123", "function": {"name": "get_weather", "arguments": '{"location": "Berlin"}'}}
+            ],
+        }
+        message = ChatMessage.from_openai_dict_format(openai_msg)
+        assert message.role.value == "assistant"
+        assert message.text is None
+        assert len(message.tool_calls) == 1
+        tool_call = message.tool_calls[0]
+        assert tool_call.id == "call_123"
+        assert tool_call.tool_name == "get_weather"
+        assert tool_call.arguments == {"location": "Berlin"}
+
+    def test_from_openai_dict_format_tool_message(self):
+        openai_msg = {"role": "tool", "content": "The weather is sunny", "tool_call_id": "call_123"}
+        message = ChatMessage.from_openai_dict_format(openai_msg)
+        assert message.role.value == "tool"
+        assert message.tool_call_result.result == "The weather is sunny"
+        assert message.tool_call_result.origin.id == "call_123"
+
+    def test_from_openai_dict_format_tool_without_id(self):
+        openai_msg = {"role": "tool", "content": "The weather is sunny"}
+        message = ChatMessage.from_openai_dict_format(openai_msg)
+        assert message.role.value == "tool"
+        assert message.tool_call_result.result == "The weather is sunny"
+        assert message.tool_call_result.origin.id is None
+
+    def test_from_openai_dict_format_missing_role(self):
+        with pytest.raises(ValueError):
+            ChatMessage.from_openai_dict_format({"content": "test"})
+
+    def test_from_openai_dict_format_missing_content(self):
+        with pytest.raises(ValueError):
+            ChatMessage.from_openai_dict_format({"role": "user"})
+
+    def test_from_openai_dict_format_invalid_tool_calls(self):
+        openai_msg = {"role": "assistant", "tool_calls": [{"invalid": "format"}]}
+        with pytest.raises(ValueError):
+            ChatMessage.from_openai_dict_format(openai_msg)
+
+    def test_from_openai_dict_format_unsupported_role(self):
+        with pytest.raises(ValueError):
+            ChatMessage.from_openai_dict_format({"role": "invalid", "content": "test"})
+
+    def test_from_openai_dict_format_assistant_missing_content_and_tool_calls(self):
+        with pytest.raises(ValueError):
+            ChatMessage.from_openai_dict_format({"role": "assistant", "irrelevant": "irrelevant"})