Coverage for haystack/dataclasses/chat_message.py: 99%
341 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import json
6from collections.abc import Sequence
7from dataclasses import asdict, dataclass, field
8from enum import Enum
9from typing import Any
11from haystack import logging
12from haystack.dataclasses.file_content import FileContent
13from haystack.dataclasses.image_content import ImageContent
14from haystack.utils.dataclasses import _warn_on_inplace_mutation
16logger = logging.getLogger(__name__)
19class ChatRole(str, Enum):
20 """
21 Enumeration representing the roles within a chat.
22 """
24 #: The user role. A message from the user contains only text.
25 USER = "user"
27 #: The system role. A message from the system contains only text.
28 SYSTEM = "system"
30 #: The assistant role. A message from the assistant can contain text and Tool calls. It can also store metadata.
31 ASSISTANT = "assistant"
33 #: The tool role. A message from a tool contains the result of a Tool invocation.
34 TOOL = "tool"
36 @staticmethod
37 def from_str(string: str) -> "ChatRole":
38 """
39 Convert a string to a ChatRole enum.
40 """
41 enum_map = {e.value: e for e in ChatRole}
42 role = enum_map.get(string)
43 if role is None:
44 msg = f"Unknown chat role '{string}'. Supported roles are: {list(enum_map.keys())}"
45 raise ValueError(msg)
46 return role
49@_warn_on_inplace_mutation
50@dataclass
51class TextContent:
52 """
53 The textual content of a chat message.
55 :param text: The text content of the message.
56 """
58 text: str
60 def to_dict(self) -> dict[str, Any]:
61 """
62 Convert TextContent into a dictionary.
63 """
64 return asdict(self)
66 @classmethod
67 def from_dict(cls, data: dict[str, Any]) -> "TextContent":
68 """
69 Create a TextContent from a dictionary.
70 """
71 return TextContent(**data)
74@_warn_on_inplace_mutation
75@dataclass
76class ToolCall:
77 """
78 Represents a Tool call prepared by the model, usually contained in an assistant message.
80 :param id: The ID of the Tool call.
81 :param tool_name: The name of the Tool to call.
82 :param arguments: The arguments to call the Tool with.
83 :param extra: Dictionary of extra information about the Tool call. Use to store provider-specific
84 information. To avoid serialization issues, values should be JSON serializable.
85 """
87 tool_name: str
88 arguments: dict[str, Any]
89 id: str | None = None # noqa: A003
90 extra: dict[str, Any] | None = None
92 def to_dict(self) -> dict[str, Any]:
93 """
94 Convert ToolCall into a dictionary.
96 :returns: A dictionary with keys 'tool_name', 'arguments', 'id', and 'extra'.
97 """
98 return asdict(self)
100 @classmethod
101 def from_dict(cls, data: dict[str, Any]) -> "ToolCall":
102 """
103 Creates a new ToolCall object from a dictionary.
105 :param data:
106 The dictionary to build the ToolCall object.
107 :returns:
108 The created object.
109 """
110 return ToolCall(**data)
113ToolCallResultContentT = str | Sequence[TextContent | ImageContent | FileContent]
116@_warn_on_inplace_mutation
117@dataclass
118class ToolCallResult:
119 """
120 Represents the result of a Tool invocation.
122 :param result: The result of the Tool invocation.
123 :param origin: The Tool call that produced this result.
124 :param error: Whether the Tool invocation resulted in an error.
125 """
127 result: ToolCallResultContentT
128 origin: ToolCall
129 error: bool
131 def to_dict(self) -> dict[str, Any]:
132 """
133 Converts ToolCallResult into a dictionary.
135 :returns: A dictionary with keys 'result', 'origin', and 'error'.
136 """
137 serialized = asdict(self)
138 if isinstance(self.result, list):
139 if not all(isinstance(part, (TextContent, ImageContent, FileContent)) for part in self.result):
140 raise ValueError(
141 "ToolCallResult result must be a string or a list of TextContent, ImageContent, or FileContent"
142 )
143 serialized["result"] = [_serialize_content_part(part) for part in self.result]
144 return serialized
146 @classmethod
147 def from_dict(cls, data: dict[str, Any]) -> "ToolCallResult":
148 """
149 Creates a ToolCallResult from a dictionary.
151 :param data:
152 The dictionary to build the ToolCallResult object.
153 :returns:
154 The created object.
155 """
156 if not all(x in data for x in ["result", "origin", "error"]):
157 raise ValueError(
158 "Fields `result`, `origin`, `error` are required for ToolCallResult deserialization. "
159 f"Received dictionary with keys {list(data.keys())}"
160 )
162 result = data["result"]
163 if isinstance(result, list):
164 result = [_deserialize_content_part(part) for part in result]
166 return ToolCallResult(result=result, origin=ToolCall.from_dict(data["origin"]), error=data["error"])
169@_warn_on_inplace_mutation
170@dataclass
171class ReasoningContent:
172 """
173 Represents the optional reasoning content prepared by the model, usually contained in an assistant message.
175 :param reasoning_text: The reasoning text produced by the model.
176 :param extra: Dictionary of extra information about the reasoning content. Use to store provider-specific
177 information. To avoid serialization issues, values should be JSON serializable.
178 """
180 reasoning_text: str
181 extra: dict[str, Any] = field(default_factory=dict)
183 def to_dict(self) -> dict[str, Any]:
184 """
185 Convert ReasoningContent into a dictionary.
187 :returns: A dictionary with keys 'reasoning_text', and 'extra'.
188 """
189 return asdict(self)
191 @classmethod
192 def from_dict(cls, data: dict[str, Any]) -> "ReasoningContent":
193 """
194 Creates a new ReasoningContent object from a dictionary.
196 :param data:
197 The dictionary to build the ReasoningContent object.
198 :returns:
199 The created object.
200 """
201 return ReasoningContent(**data)
204ChatMessageContentT = TextContent | ToolCall | ToolCallResult | ImageContent | ReasoningContent | FileContent
206_CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS: dict[type[ChatMessageContentT], str] = {
207 TextContent: "text",
208 ToolCall: "tool_call",
209 ToolCallResult: "tool_call_result",
210 ImageContent: "image",
211 ReasoningContent: "reasoning",
212 FileContent: "file",
213}
216def _deserialize_content_part(part: dict[str, Any]) -> ChatMessageContentT:
217 """
218 Deserialize a single content part of a serialized ChatMessage.
220 :param part:
221 A dictionary representing a single content part of a serialized ChatMessage.
222 :returns:
223 A ChatMessageContentT object.
224 :raises ValueError:
225 If the part is not a valid ChatMessageContentT object.
226 """
227 # handle flat text format separately
228 if "text" in part:
229 return TextContent.from_dict(part)
231 for cls, serialization_key in _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.items():
232 if serialization_key in part:
233 return cls.from_dict(part[serialization_key])
235 # Support for Pydantic's model_dump() output, which produces a flat dictionary without wrapping keys.
236 if "tool_name" in part and "arguments" in part:
237 return ToolCall.from_dict(part)
238 if "result" in part and "origin" in part:
239 return ToolCallResult.from_dict(part)
240 if "reasoning_text" in part:
241 return ReasoningContent.from_dict(part)
242 if "base64_image" in part:
243 return ImageContent.from_dict(part)
244 if "base64_data" in part:
245 return FileContent.from_dict(part)
247 # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
248 msg = (
249 f"Unsupported content part in the serialized ChatMessage: {part}. "
250 "The `content` field of the serialized ChatMessage must be a list of dictionaries, where each dictionary "
251 "contains one of these keys: 'text', 'image', 'file', 'reasoning', 'tool_call', or 'tool_call_result'. "
252 "Valid formats: [{'text': 'Hello'}, {'image': {'base64_image': '...', ...}}, "
253 "{'file': {'base64_data': '...', ...}}, {'reasoning': {'reasoning_text': 'I think...', 'extra': {...}}}, "
254 "{'tool_call': {'tool_name': 'search', 'arguments': {}, 'id': 'call_123'}}, "
255 "{'tool_call_result': {'result': 'data', 'origin': {...}, 'error': false}}]"
256 )
257 raise ValueError(msg)
260def _serialize_content_part(part: ChatMessageContentT) -> dict[str, Any]:
261 """
262 Serialize a single content part of a ChatMessage.
264 :param part:
265 A ChatMessageContentT object.
266 :returns:
267 A dictionary representing the content part.
268 :raises TypeError:
269 If the part is not a valid ChatMessageContentT object.
270 """
271 serialization_key = _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.get(type(part))
272 if serialization_key is None:
273 raise TypeError(f"Unsupported type in ChatMessage content: `{type(part).__name__}` for `{part}`.")
275 # handle flat text format separately
276 if isinstance(part, TextContent):
277 return part.to_dict()
279 return {serialization_key: part.to_dict()}
282@_warn_on_inplace_mutation
283@dataclass
284class ChatMessage:
285 """
286 Represents a message in a LLM chat conversation.
288 Use the `from_assistant`, `from_user`, `from_system`, and `from_tool` class methods to create a ChatMessage.
289 """
291 _role: ChatRole
292 _content: Sequence[ChatMessageContentT]
293 _name: str | None = None
294 _meta: dict[str, Any] = field(default_factory=dict, hash=False)
296 def __len__(self) -> int:
297 return len(self._content)
299 @property
300 def role(self) -> ChatRole:
301 """
302 Returns the role of the entity sending the message.
303 """
304 return self._role
306 @property
307 def meta(self) -> dict[str, Any]:
308 """
309 Returns the metadata associated with the message.
310 """
311 return self._meta
313 @property
314 def name(self) -> str | None:
315 """
316 Returns the name associated with the message.
317 """
318 return self._name
320 @property
321 def texts(self) -> list[str]:
322 """
323 Returns the list of all texts contained in the message.
324 """
325 return [content.text for content in self._content if isinstance(content, TextContent)]
327 @property
328 def text(self) -> str | None:
329 """
330 Returns the first text contained in the message.
331 """
332 if texts := self.texts:
333 return texts[0]
334 return None
336 @property
337 def tool_calls(self) -> list[ToolCall]:
338 """
339 Returns the list of all Tool calls contained in the message.
340 """
341 return [content for content in self._content if isinstance(content, ToolCall)]
343 @property
344 def tool_call(self) -> ToolCall | None:
345 """
346 Returns the first Tool call contained in the message.
347 """
348 if tool_calls := self.tool_calls:
349 return tool_calls[0]
350 return None
352 @property
353 def tool_call_results(self) -> list[ToolCallResult]:
354 """
355 Returns the list of all Tool call results contained in the message.
356 """
357 return [content for content in self._content if isinstance(content, ToolCallResult)]
359 @property
360 def tool_call_result(self) -> ToolCallResult | None:
361 """
362 Returns the first Tool call result contained in the message.
363 """
364 if tool_call_results := self.tool_call_results:
365 return tool_call_results[0]
366 return None
368 @property
369 def images(self) -> list[ImageContent]:
370 """
371 Returns the list of all images contained in the message.
372 """
373 return [content for content in self._content if isinstance(content, ImageContent)]
375 @property
376 def image(self) -> ImageContent | None:
377 """
378 Returns the first image contained in the message.
379 """
380 if images := self.images:
381 return images[0]
382 return None
384 @property
385 def files(self) -> list[FileContent]:
386 """
387 Returns the list of all files contained in the message.
388 """
389 return [content for content in self._content if isinstance(content, FileContent)]
391 @property
392 def file(self) -> FileContent | None:
393 """
394 Returns the first file contained in the message.
395 """
396 if files := self.files:
397 return files[0]
398 return None
400 @property
401 def reasonings(self) -> list[ReasoningContent]:
402 """
403 Returns the list of all reasoning contents contained in the message.
404 """
405 return [content for content in self._content if isinstance(content, ReasoningContent)]
407 @property
408 def reasoning(self) -> ReasoningContent | None:
409 """
410 Returns the first reasoning content contained in the message.
411 """
412 if reasonings := self.reasonings:
413 return reasonings[0]
414 return None
416 def is_from(self, role: ChatRole | str) -> bool:
417 """
418 Check if the message is from a specific role.
420 :param role: The role to check against.
421 :returns: True if the message is from the specified role, False otherwise.
422 """
423 if isinstance(role, str):
424 role = ChatRole.from_str(role)
425 return self._role == role
427 @classmethod
428 def from_user(
429 cls,
430 text: str | None = None,
431 meta: dict[str, Any] | None = None,
432 name: str | None = None,
433 *,
434 content_parts: Sequence[TextContent | str | ImageContent | FileContent] | None = None,
435 ) -> "ChatMessage":
436 """
437 Create a message from the user.
439 :param text: The text content of the message. Specify this or content_parts.
440 :param meta: Additional metadata associated with the message.
441 :param name: An optional name for the participant. This field is only supported by OpenAI.
442 :param content_parts: A list of content parts to include in the message. Specify this or text.
443 :returns: A new ChatMessage instance.
444 :raises ValueError: If neither or both of text and content_parts are provided, or if content_parts is empty.
445 :raises TypeError: If a content part is not a str, TextContent, ImageContent, or FileContent.
446 """
447 if text is None and content_parts is None:
448 raise ValueError("Either text or content_parts must be provided.")
449 if text is not None and content_parts is not None:
450 raise ValueError("Only one of text or content_parts can be provided.")
452 content: list[TextContent | ImageContent | FileContent] = []
454 if text is not None:
455 content = [TextContent(text=text)]
456 elif content_parts is not None:
457 for part in content_parts:
458 if isinstance(part, str):
459 content.append(TextContent(text=part))
460 elif isinstance(part, (TextContent, ImageContent, FileContent)):
461 content.append(part)
462 else:
463 raise TypeError(f"The user message must contain only text or image parts. Unsupported part: {part}")
464 if len(content) == 0:
465 raise ValueError("The user message must contain at least one content part (text, image, file).")
467 return cls(_role=ChatRole.USER, _content=content, _meta=meta or {}, _name=name)
469 @classmethod
470 def from_system(cls, text: str, meta: dict[str, Any] | None = None, name: str | None = None) -> "ChatMessage":
471 """
472 Create a message from the system.
474 :param text: The text content of the message.
475 :param meta: Additional metadata associated with the message.
476 :param name: An optional name for the participant. This field is only supported by OpenAI.
477 :returns: A new ChatMessage instance.
478 """
479 return cls(_role=ChatRole.SYSTEM, _content=[TextContent(text=text)], _meta=meta or {}, _name=name)
481 @classmethod
482 def from_assistant(
483 cls,
484 text: str | None = None,
485 meta: dict[str, Any] | None = None,
486 name: str | None = None,
487 tool_calls: list[ToolCall] | None = None,
488 *,
489 reasoning: str | ReasoningContent | None = None,
490 ) -> "ChatMessage":
491 """
492 Create a message from the assistant.
494 :param text: The text content of the message.
495 :param meta: Additional metadata associated with the message.
496 :param name: An optional name for the participant. This field is only supported by OpenAI.
497 :param tool_calls: The Tool calls to include in the message.
498 :param reasoning: The reasoning content to include in the message.
499 :returns: A new ChatMessage instance.
500 :raises TypeError: If `reasoning` is not a string or ReasoningContent object.
501 """
502 content: list[ChatMessageContentT] = []
503 if reasoning:
504 if isinstance(reasoning, str):
505 content.append(ReasoningContent(reasoning_text=reasoning))
506 elif isinstance(reasoning, ReasoningContent):
507 content.append(reasoning)
508 else:
509 raise TypeError(f"reasoning must be a string or a ReasoningContent object, got {type(reasoning)}")
510 if text is not None:
511 content.append(TextContent(text=text))
512 if tool_calls:
513 content.extend(tool_calls)
515 return cls(_role=ChatRole.ASSISTANT, _content=content, _meta=meta or {}, _name=name)
517 @classmethod
518 def from_tool(
519 cls,
520 tool_result: ToolCallResultContentT,
521 origin: ToolCall,
522 error: bool = False,
523 meta: dict[str, Any] | None = None,
524 ) -> "ChatMessage":
525 """
526 Create a message from a Tool.
528 :param tool_result: The result of the Tool invocation.
529 :param origin: The Tool call that produced this result.
530 :param error: Whether the Tool invocation resulted in an error.
531 :param meta: Additional metadata associated with the message.
532 :returns: A new ChatMessage instance.
533 """
534 return cls(
535 _role=ChatRole.TOOL,
536 _content=[ToolCallResult(result=tool_result, origin=origin, error=error)],
537 _meta=meta or {},
538 )
540 def to_dict(self) -> dict[str, Any]:
541 """
542 Converts ChatMessage into a dictionary.
544 :returns:
545 Serialized version of the object.
546 """
548 serialized: dict[str, Any] = {}
549 serialized["role"] = self._role.value
550 serialized["meta"] = self._meta
551 serialized["name"] = self._name
553 serialized["content"] = [_serialize_content_part(part) for part in self._content]
554 return serialized
556 def _to_trace_dict(self) -> dict[str, Any]:
557 """
558 Convert the ChatMessage to a dictionary representation for tracing.
560 For Image Content objects, the base64_image is replaced with a placeholder string to avoid sending large
561 payloads to the tracing backend.
563 :returns:
564 Serialized version of the object only for tracing purposes.
565 """
567 serialized: dict[str, Any] = {}
568 serialized["role"] = self._role.value
569 serialized["meta"] = self._meta
570 serialized["name"] = self._name
572 serialized["content"] = []
573 for part in self._content:
574 serialized_part = _serialize_content_part(part)
575 if isinstance(part, ImageContent):
576 serialized_part["image"] = part._to_trace_dict()
577 elif isinstance(part, FileContent):
578 serialized_part["file"] = part._to_trace_dict()
579 serialized["content"].append(serialized_part)
581 return serialized
583 @classmethod
584 def from_dict(cls, data: dict[str, Any]) -> "ChatMessage":
585 """
586 Creates a new ChatMessage object from a dictionary.
588 :param data:
589 The dictionary to build the ChatMessage object.
590 :returns:
591 The created object.
592 :raises ValueError: If the `role` field is missing from the dictionary.
593 :raises TypeError: If the `content` field is not a list or string.
594 """
596 # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
597 if "role" not in data and "_role" not in data:
598 raise ValueError(
599 "The `role` field is required in the message dictionary. "
600 f"Expected a dictionary with 'role' field containing one of: {[role.value for role in ChatRole]}. "
601 f"Common roles are 'user' (for user messages) and 'assistant' (for AI responses). "
602 f"Received dictionary with keys: {list(data.keys())}"
603 )
605 if "content" in data:
606 init_params: dict[str, Any] = {
607 "_role": ChatRole(data["role"]),
608 "_name": data.get("name"),
609 "_meta": data.get("meta") or {},
610 }
612 if isinstance(data["content"], list):
613 # current format - the serialized `content` field is a list of dictionaries
614 init_params["_content"] = [_deserialize_content_part(part) for part in data["content"]]
615 elif isinstance(data["content"], str):
616 # pre 2.9.0 format - the `content` field is a string
617 init_params["_content"] = [TextContent(text=data["content"])]
618 else:
619 raise TypeError(f"Unsupported content type in serialized ChatMessage: `{(data['content'])}`")
620 return cls(**init_params)
622 if "_content" in data:
623 # format for versions >=2.9.0 and <2.12.0 - the serialized `_content` field is a list of dictionaries
624 return cls(
625 _role=ChatRole(data["_role"]),
626 _content=[_deserialize_content_part(part) for part in data["_content"]],
627 _name=data.get("_name"),
628 _meta=data.get("_meta") or {},
629 )
631 raise ValueError(f"Missing 'content' or '_content' in serialized ChatMessage: `{data}`")
633 def to_openai_dict_format(self, require_tool_call_ids: bool = True) -> dict[str, Any]:
634 """
635 Convert a ChatMessage to the dictionary format expected by OpenAI's Chat Completions API.
637 The `_meta` field of ChatMessage is removed because it is not supported by OpenAI's Chat Completions API.
639 :param require_tool_call_ids:
640 If True (default), enforces that each Tool Call includes a non-null `id` attribute.
641 Set to False to allow Tool Calls without `id`, which may be suitable for shallow OpenAI-compatible APIs.
642 :returns:
643 The ChatMessage in the format expected by OpenAI's Chat Completions API.
645 :raises ValueError:
646 If the message format is invalid, or if `require_tool_call_ids` is True and any Tool Call is missing an
647 `id` attribute.
648 """
649 if not self.texts and not self.tool_calls and not self.tool_call_results and not self.images and not self.files:
650 raise ValueError(
651 "A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, "
652 "`ToolCallResult`, `ImageContent`, or `FileContent`."
653 )
654 if len(self.tool_call_results) > 0 and len(self._content) > 1:
655 raise ValueError(
656 "For OpenAI compatibility, a `ChatMessage` with a `ToolCallResult` cannot contain any other content."
657 )
659 openai_msg: dict[str, Any] = {"role": self._role.value}
661 if self._name is not None:
662 openai_msg["name"] = self._name
664 if openai_msg["role"] == "user":
665 return self._user_message_to_openai(openai_msg)
667 if self.tool_call_results:
668 return self._tool_result_message_to_openai(openai_msg, require_tool_call_ids)
670 return self._system_assistant_message_to_openai(openai_msg, require_tool_call_ids)
672 def _user_message_to_openai(self, openai_msg: dict[str, Any]) -> dict[str, Any]:
673 """Build OpenAI dict for a user message."""
674 if len(self._content) == 1 and isinstance(self._content[0], TextContent):
675 openai_msg["content"] = self.text
676 return openai_msg
678 content = []
679 for part in self._content:
680 if isinstance(part, TextContent):
681 content.append({"type": "text", "text": part.text})
682 elif isinstance(part, ImageContent):
683 image_item: dict[str, Any] = {
684 "type": "image_url",
685 # If no MIME type is provided, default to JPEG.
686 # OpenAI API appears to tolerate MIME type mismatches.
687 "image_url": {"url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}"},
688 }
689 if part.detail:
690 image_item["image_url"]["detail"] = part.detail
691 content.append(image_item)
692 elif isinstance(part, FileContent):
693 file_item: dict[str, Any] = {
694 "type": "file",
695 "file": {
696 "file_data": f"data:{part.mime_type or 'application/pdf'};base64,{part.base64_data}",
697 # Filename is optional but if not provided, OpenAI expects a file_id of a previous file upload.
698 # We use a dummy filename.
699 "filename": part.filename or "filename",
700 },
701 }
702 content.append(file_item)
703 openai_msg["content"] = content
704 return openai_msg
706 def _tool_result_message_to_openai(self, openai_msg: dict[str, Any], require_tool_call_ids: bool) -> dict[str, Any]:
707 """Build OpenAI dict for a tool result message."""
708 result = self.tool_call_results[0]
709 if isinstance(result.result, str):
710 openai_msg["content"] = result.result
711 # OpenAI Chat Completions API does not support multimodal tool results
712 elif isinstance(result.result, list) and all(isinstance(part, TextContent) for part in result.result):
713 openai_msg["content"] = [{"type": "text", "text": part.text} for part in result.result]
714 else:
715 raise ValueError(
716 f"Unsupported tool result: {result}. If you need to pass images in tool results, "
717 "use OpenAI Responses API instead."
718 )
720 if result.origin.id is not None:
721 openai_msg["tool_call_id"] = result.origin.id
722 elif require_tool_call_ids:
723 raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
724 # OpenAI does not provide a way to communicate errors in tool invocations, so we ignore the error field
725 return openai_msg
727 def _system_assistant_message_to_openai(
728 self, openai_msg: dict[str, Any], require_tool_call_ids: bool
729 ) -> dict[str, Any]:
730 """Build OpenAI dict for system and assistant messages."""
731 # OpenAI Chat Completions API does not support reasoning content, so we ignore it
732 if self.texts:
733 openai_msg["content"] = self.texts[0]
734 if self.tool_calls:
735 openai_tool_calls = []
736 for tc in self.tool_calls:
737 openai_tool_call = {
738 "type": "function",
739 # We disable ensure_ascii so special chars like emojis are not converted
740 "function": {"name": tc.tool_name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)},
741 }
742 if tc.id is not None:
743 openai_tool_call["id"] = tc.id
744 elif require_tool_call_ids:
745 raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
746 openai_tool_calls.append(openai_tool_call)
747 openai_msg["tool_calls"] = openai_tool_calls
748 return openai_msg
750 @staticmethod
751 def _validate_openai_message(message: dict[str, Any]) -> None:
752 """
753 Validate that a message dictionary follows OpenAI's Chat API format.
755 :param message: The message dictionary to validate
756 :raises ValueError: If the message format is invalid
757 """
758 if "role" not in message:
759 raise ValueError("The `role` field is required in the message dictionary.")
761 role = message["role"]
762 content = message.get("content")
763 tool_calls = message.get("tool_calls")
765 if role not in ["assistant", "user", "system", "developer", "tool"]:
766 raise ValueError(f"Unsupported role: {role}")
768 if role == "assistant":
769 if not content and not tool_calls:
770 raise ValueError("For assistant messages, either `content` or `tool_calls` must be present.")
771 if tool_calls:
772 for tc in tool_calls:
773 if "function" not in tc:
774 raise ValueError("Tool calls must contain the `function` field")
775 elif not content:
776 raise ValueError(f"The `content` field is required for {role} messages.")
778 @classmethod
779 def from_openai_dict_format(cls, message: dict[str, Any]) -> "ChatMessage":
780 """
781 Create a ChatMessage from a dictionary in the format expected by OpenAI's Chat API.
783 NOTE: While OpenAI's API requires `tool_call_id` in both tool calls and tool messages, this method
784 accepts messages without it to support shallow OpenAI-compatible APIs.
785 If you plan to use the resulting ChatMessage with OpenAI, you must include `tool_call_id` or you'll
786 encounter validation errors.
788 :param message:
789 The OpenAI dictionary to build the ChatMessage object.
790 :returns:
791 The created ChatMessage object.
793 :raises ValueError:
794 If the message dictionary is missing required fields.
795 """
796 cls._validate_openai_message(message)
798 role = message["role"]
799 content = message.get("content")
800 name = message.get("name")
801 tool_calls = message.get("tool_calls")
802 tool_call_id = message.get("tool_call_id")
804 if role == "assistant":
805 haystack_tool_calls = None
806 if tool_calls:
807 haystack_tool_calls = []
808 for tc in tool_calls:
809 # Zero-argument tool calls from OpenAI-compatible servers may send an
810 # empty string, null, or omit `arguments` entirely; treat all as {}.
811 raw_arguments = tc["function"].get("arguments")
812 haystack_tc = ToolCall(
813 id=tc.get("id"),
814 tool_name=tc["function"]["name"],
815 arguments=json.loads(raw_arguments) if raw_arguments else {},
816 )
817 haystack_tool_calls.append(haystack_tc)
818 return cls.from_assistant(text=content, name=name, tool_calls=haystack_tool_calls)
820 assert content is not None # ensured by _validate_openai_message, but we need to make mypy happy
822 if role == "user":
823 return cls.from_user(text=content, name=name)
824 if role in ["system", "developer"]:
825 return cls.from_system(text=content, name=name)
827 if isinstance(content, list):
828 if not all("text" in el for el in content):
829 raise ValueError("To be used with OpenAI, tool results must be a string or a list of TextContent")
830 content = [TextContent(text=el["text"]) for el in content]
831 return cls.from_tool(
832 tool_result=content, origin=ToolCall(id=tool_call_id, tool_name="", arguments={}), error=False
833 )