Coverage for haystack/dataclasses/chat_message.py: 99%
420 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-09 14:34 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-09 14:34 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import json
6import reprlib
7from collections.abc import Sequence
8from dataclasses import asdict, dataclass, field
9from enum import Enum
10from typing import Any
12from haystack import logging
13from haystack.dataclasses.file_content import FileContent
14from haystack.dataclasses.image_content import ImageContent
15from haystack.utils.dataclasses import _warn_on_inplace_mutation
17logger = logging.getLogger(__name__)
19# Shows OpenAI content parts in error messages without their base64 payloads. The string limit matches the reprs of
20# ImageContent and FileContent.
21_CONTENT_PART_REPR = reprlib.Repr()
22_CONTENT_PART_REPR.maxstring = 100
25def _parse_openai_tool_call_arguments(raw_arguments: Any) -> dict[str, Any]:
26 """
27 Parse OpenAI-style tool call arguments into a dictionary.
29 A dict is returned as is, a missing, null, or empty value becomes `{}`, and anything else is parsed as a
30 JSON string.
32 :raises json.JSONDecodeError: If the arguments are not valid JSON.
33 """
34 if isinstance(raw_arguments, dict):
35 return raw_arguments
36 return json.loads(raw_arguments) if raw_arguments else {}
39class ChatRole(str, Enum):
40 """
41 Enumeration representing the roles within a chat.
42 """
44 #: The user role. A message from the user contains only text.
45 USER = "user"
47 #: The system role. A message from the system contains only text.
48 SYSTEM = "system"
50 #: The assistant role. A message from the assistant can contain text and Tool calls. It can also store metadata.
51 ASSISTANT = "assistant"
53 #: The tool role. A message from a tool contains the result of a Tool invocation.
54 TOOL = "tool"
56 @staticmethod
57 def from_str(string: str) -> "ChatRole":
58 """
59 Convert a string to a ChatRole enum.
60 """
61 enum_map = {e.value: e for e in ChatRole}
62 role = enum_map.get(string)
63 if role is None:
64 msg = f"Unknown chat role '{string}'. Supported roles are: {list(enum_map.keys())}"
65 raise ValueError(msg)
66 return role
69@_warn_on_inplace_mutation
70@dataclass
71class TextContent:
72 """
73 The textual content of a chat message.
75 :param text: The text content of the message.
76 """
78 text: str
80 def to_dict(self) -> dict[str, Any]:
81 """
82 Convert TextContent into a dictionary.
83 """
84 return asdict(self)
86 @classmethod
87 def from_dict(cls, data: dict[str, Any]) -> "TextContent":
88 """
89 Create a TextContent from a dictionary.
90 """
91 return TextContent(**data)
94@_warn_on_inplace_mutation
95@dataclass
96class ToolCall:
97 """
98 Represents a Tool call prepared by the model, usually contained in an assistant message.
100 :param id: The ID of the Tool call.
101 :param tool_name: The name of the Tool to call.
102 :param arguments: The arguments to call the Tool with.
103 :param extra: Dictionary of extra information about the Tool call. Use to store provider-specific
104 information. To avoid serialization issues, values should be JSON serializable.
105 """
107 tool_name: str
108 arguments: dict[str, Any]
109 id: str | None = None # noqa: A003
110 extra: dict[str, Any] | None = None
112 def to_dict(self) -> dict[str, Any]:
113 """
114 Convert ToolCall into a dictionary.
116 :returns: A dictionary with keys 'tool_name', 'arguments', 'id', and 'extra'.
117 """
118 return asdict(self)
120 @classmethod
121 def from_dict(cls, data: dict[str, Any]) -> "ToolCall":
122 """
123 Creates a new ToolCall object from a dictionary.
125 :param data:
126 The dictionary to build the ToolCall object.
127 :returns:
128 The created object.
129 """
130 return ToolCall(**data)
133ToolCallResultContentT = str | Sequence[TextContent | ImageContent | FileContent]
136@_warn_on_inplace_mutation
137@dataclass
138class ToolCallResult:
139 """
140 Represents the result of a Tool invocation.
142 :param result: The result of the Tool invocation.
143 :param origin: The Tool call that produced this result.
144 :param error: Whether the Tool invocation resulted in an error.
145 """
147 result: ToolCallResultContentT
148 origin: ToolCall
149 error: bool
151 def to_dict(self) -> dict[str, Any]:
152 """
153 Converts ToolCallResult into a dictionary.
155 :returns: A dictionary with keys 'result', 'origin', and 'error'.
156 """
157 serialized = asdict(self)
158 if isinstance(self.result, list):
159 if not all(isinstance(part, (TextContent, ImageContent, FileContent)) for part in self.result):
160 raise ValueError(
161 "ToolCallResult result must be a string or a list of TextContent, ImageContent, or FileContent"
162 )
163 serialized["result"] = [_serialize_content_part(part) for part in self.result]
164 return serialized
166 @classmethod
167 def from_dict(cls, data: dict[str, Any]) -> "ToolCallResult":
168 """
169 Creates a ToolCallResult from a dictionary.
171 :param data:
172 The dictionary to build the ToolCallResult object.
173 :returns:
174 The created object.
175 """
176 if not all(x in data for x in ["result", "origin", "error"]):
177 raise ValueError(
178 "Fields `result`, `origin`, `error` are required for ToolCallResult deserialization. "
179 f"Received dictionary with keys {list(data.keys())}"
180 )
182 result = data["result"]
183 if isinstance(result, list):
184 result = [_deserialize_content_part(part) for part in result]
186 return ToolCallResult(result=result, origin=ToolCall.from_dict(data["origin"]), error=data["error"])
189@_warn_on_inplace_mutation
190@dataclass
191class ReasoningContent:
192 """
193 Represents the optional reasoning content prepared by the model, usually contained in an assistant message.
195 :param reasoning_text: The reasoning text produced by the model.
196 :param extra: Dictionary of extra information about the reasoning content. Use to store provider-specific
197 information. To avoid serialization issues, values should be JSON serializable.
198 """
200 reasoning_text: str
201 extra: dict[str, Any] = field(default_factory=dict)
203 def to_dict(self) -> dict[str, Any]:
204 """
205 Convert ReasoningContent into a dictionary.
207 :returns: A dictionary with keys 'reasoning_text', and 'extra'.
208 """
209 return asdict(self)
211 @classmethod
212 def from_dict(cls, data: dict[str, Any]) -> "ReasoningContent":
213 """
214 Creates a new ReasoningContent object from a dictionary.
216 :param data:
217 The dictionary to build the ReasoningContent object.
218 :returns:
219 The created object.
220 """
221 return ReasoningContent(**data)
224ChatMessageContentT = TextContent | ToolCall | ToolCallResult | ImageContent | ReasoningContent | FileContent
226_CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS: dict[type[ChatMessageContentT], str] = {
227 TextContent: "text",
228 ToolCall: "tool_call",
229 ToolCallResult: "tool_call_result",
230 ImageContent: "image",
231 ReasoningContent: "reasoning",
232 FileContent: "file",
233}
236def _deserialize_content_part(part: dict[str, Any]) -> ChatMessageContentT:
237 """
238 Deserialize a single content part of a serialized ChatMessage.
240 :param part:
241 A dictionary representing a single content part of a serialized ChatMessage.
242 :returns:
243 A ChatMessageContentT object.
244 :raises ValueError:
245 If the part is not a valid ChatMessageContentT object.
246 """
247 # handle flat text format separately
248 if "text" in part:
249 return TextContent.from_dict(part)
251 for cls, serialization_key in _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.items():
252 if serialization_key in part:
253 return cls.from_dict(part[serialization_key])
255 # Support for Pydantic's model_dump() output, which produces a flat dictionary without wrapping keys.
256 if "tool_name" in part and "arguments" in part:
257 return ToolCall.from_dict(part)
258 if "result" in part and "origin" in part:
259 return ToolCallResult.from_dict(part)
260 if "reasoning_text" in part:
261 return ReasoningContent.from_dict(part)
262 if "base64_image" in part:
263 return ImageContent.from_dict(part)
264 if "base64_data" in part:
265 return FileContent.from_dict(part)
267 # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
268 msg = (
269 f"Unsupported content part in the serialized ChatMessage: {part}. "
270 "The `content` field of the serialized ChatMessage must be a list of dictionaries, where each dictionary "
271 "contains one of these keys: 'text', 'image', 'file', 'reasoning', 'tool_call', or 'tool_call_result'. "
272 "Valid formats: [{'text': 'Hello'}, {'image': {'base64_image': '...', ...}}, "
273 "{'file': {'base64_data': '...', ...}}, {'reasoning': {'reasoning_text': 'I think...', 'extra': {...}}}, "
274 "{'tool_call': {'tool_name': 'search', 'arguments': {}, 'id': 'call_123'}}, "
275 "{'tool_call_result': {'result': 'data', 'origin': {...}, 'error': false}}]"
276 )
277 raise ValueError(msg)
280def _serialize_content_part(part: ChatMessageContentT) -> dict[str, Any]:
281 """
282 Serialize a single content part of a ChatMessage.
284 :param part:
285 A ChatMessageContentT object.
286 :returns:
287 A dictionary representing the content part.
288 :raises TypeError:
289 If the part is not a valid ChatMessageContentT object.
290 """
291 serialization_key = _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.get(type(part))
292 if serialization_key is None:
293 raise TypeError(f"Unsupported type in ChatMessage content: `{type(part).__name__}` for `{part}`.")
295 # handle flat text format separately
296 if isinstance(part, TextContent):
297 return part.to_dict()
299 return {serialization_key: part.to_dict()}
302@_warn_on_inplace_mutation
303@dataclass
304class ChatMessage:
305 """
306 Represents a message in a LLM chat conversation.
308 Use the `from_assistant`, `from_user`, `from_system`, and `from_tool` class methods to create a ChatMessage.
309 """
311 _role: ChatRole
312 _content: Sequence[ChatMessageContentT]
313 _name: str | None = None
314 _meta: dict[str, Any] = field(default_factory=dict, hash=False)
316 def __len__(self) -> int:
317 return len(self._content)
319 @property
320 def role(self) -> ChatRole:
321 """
322 Returns the role of the entity sending the message.
323 """
324 return self._role
326 @property
327 def meta(self) -> dict[str, Any]:
328 """
329 Returns the metadata associated with the message.
330 """
331 return self._meta
333 @property
334 def name(self) -> str | None:
335 """
336 Returns the name associated with the message.
337 """
338 return self._name
340 @property
341 def texts(self) -> list[str]:
342 """
343 Returns the list of all texts contained in the message.
344 """
345 return [content.text for content in self._content if isinstance(content, TextContent)]
347 @property
348 def text(self) -> str | None:
349 """
350 Returns the first text contained in the message.
351 """
352 if texts := self.texts:
353 return texts[0]
354 return None
356 @property
357 def tool_calls(self) -> list[ToolCall]:
358 """
359 Returns the list of all Tool calls contained in the message.
360 """
361 return [content for content in self._content if isinstance(content, ToolCall)]
363 @property
364 def tool_call(self) -> ToolCall | None:
365 """
366 Returns the first Tool call contained in the message.
367 """
368 if tool_calls := self.tool_calls:
369 return tool_calls[0]
370 return None
372 @property
373 def tool_call_results(self) -> list[ToolCallResult]:
374 """
375 Returns the list of all Tool call results contained in the message.
376 """
377 return [content for content in self._content if isinstance(content, ToolCallResult)]
379 @property
380 def tool_call_result(self) -> ToolCallResult | None:
381 """
382 Returns the first Tool call result contained in the message.
383 """
384 if tool_call_results := self.tool_call_results:
385 return tool_call_results[0]
386 return None
388 @property
389 def images(self) -> list[ImageContent]:
390 """
391 Returns the list of all images contained in the message.
392 """
393 return [content for content in self._content if isinstance(content, ImageContent)]
395 @property
396 def image(self) -> ImageContent | None:
397 """
398 Returns the first image contained in the message.
399 """
400 if images := self.images:
401 return images[0]
402 return None
404 @property
405 def files(self) -> list[FileContent]:
406 """
407 Returns the list of all files contained in the message.
408 """
409 return [content for content in self._content if isinstance(content, FileContent)]
411 @property
412 def file(self) -> FileContent | None:
413 """
414 Returns the first file contained in the message.
415 """
416 if files := self.files:
417 return files[0]
418 return None
420 @property
421 def reasonings(self) -> list[ReasoningContent]:
422 """
423 Returns the list of all reasoning contents contained in the message.
424 """
425 return [content for content in self._content if isinstance(content, ReasoningContent)]
427 @property
428 def reasoning(self) -> ReasoningContent | None:
429 """
430 Returns the first reasoning content contained in the message.
431 """
432 if reasonings := self.reasonings:
433 return reasonings[0]
434 return None
436 def is_from(self, role: ChatRole | str) -> bool:
437 """
438 Check if the message is from a specific role.
440 :param role: The role to check against.
441 :returns: True if the message is from the specified role, False otherwise.
442 """
443 if isinstance(role, str):
444 role = ChatRole.from_str(role)
445 return self._role == role
447 @classmethod
448 def from_user(
449 cls,
450 text: str | None = None,
451 meta: dict[str, Any] | None = None,
452 name: str | None = None,
453 *,
454 content_parts: Sequence[TextContent | str | ImageContent | FileContent] | None = None,
455 ) -> "ChatMessage":
456 """
457 Create a message from the user.
459 :param text: The text content of the message. Specify this or content_parts.
460 :param meta: Additional metadata associated with the message.
461 :param name: An optional name for the participant. This field is only supported by OpenAI.
462 :param content_parts: A list of content parts to include in the message. Specify this or text.
463 :returns: A new ChatMessage instance.
464 :raises ValueError: If neither or both of text and content_parts are provided, or if content_parts is empty.
465 :raises TypeError: If a content part is not a str, TextContent, ImageContent, or FileContent.
466 """
467 if text is None and content_parts is None:
468 raise ValueError("Either text or content_parts must be provided.")
469 if text is not None and content_parts is not None:
470 raise ValueError("Only one of text or content_parts can be provided.")
472 content: list[TextContent | ImageContent | FileContent] = []
474 if text is not None:
475 content = [TextContent(text=text)]
476 elif content_parts is not None:
477 for part in content_parts:
478 if isinstance(part, str):
479 content.append(TextContent(text=part))
480 elif isinstance(part, (TextContent, ImageContent, FileContent)):
481 content.append(part)
482 else:
483 raise TypeError(f"The user message must contain only text or image parts. Unsupported part: {part}")
484 if len(content) == 0:
485 raise ValueError("The user message must contain at least one content part (text, image, file).")
487 return cls(_role=ChatRole.USER, _content=content, _meta=meta or {}, _name=name)
489 @classmethod
490 def from_system(cls, text: str, meta: dict[str, Any] | None = None, name: str | None = None) -> "ChatMessage":
491 """
492 Create a message from the system.
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 :returns: A new ChatMessage instance.
498 """
499 return cls(_role=ChatRole.SYSTEM, _content=[TextContent(text=text)], _meta=meta or {}, _name=name)
501 @classmethod
502 def from_assistant(
503 cls,
504 text: str | None = None,
505 meta: dict[str, Any] | None = None,
506 name: str | None = None,
507 tool_calls: list[ToolCall] | None = None,
508 *,
509 reasoning: str | ReasoningContent | None = None,
510 ) -> "ChatMessage":
511 """
512 Create a message from the assistant.
514 :param text: The text content of the message.
515 :param meta: Additional metadata associated with the message.
516 :param name: An optional name for the participant. This field is only supported by OpenAI.
517 :param tool_calls: The Tool calls to include in the message.
518 :param reasoning: The reasoning content to include in the message.
519 :returns: A new ChatMessage instance.
520 :raises TypeError: If `reasoning` is not a string or ReasoningContent object.
521 """
522 content: list[ChatMessageContentT] = []
523 if reasoning:
524 if isinstance(reasoning, str):
525 content.append(ReasoningContent(reasoning_text=reasoning))
526 elif isinstance(reasoning, ReasoningContent):
527 content.append(reasoning)
528 else:
529 raise TypeError(f"reasoning must be a string or a ReasoningContent object, got {type(reasoning)}")
530 if text is not None:
531 content.append(TextContent(text=text))
532 if tool_calls:
533 content.extend(tool_calls)
535 return cls(_role=ChatRole.ASSISTANT, _content=content, _meta=meta or {}, _name=name)
537 @classmethod
538 def from_tool(
539 cls,
540 tool_result: ToolCallResultContentT,
541 origin: ToolCall,
542 error: bool = False,
543 meta: dict[str, Any] | None = None,
544 ) -> "ChatMessage":
545 """
546 Create a message from a Tool.
548 :param tool_result: The result of the Tool invocation.
549 :param origin: The Tool call that produced this result.
550 :param error: Whether the Tool invocation resulted in an error.
551 :param meta: Additional metadata associated with the message.
552 :returns: A new ChatMessage instance.
553 """
554 return cls(
555 _role=ChatRole.TOOL,
556 _content=[ToolCallResult(result=tool_result, origin=origin, error=error)],
557 _meta=meta or {},
558 )
560 def to_dict(self) -> dict[str, Any]:
561 """
562 Converts ChatMessage into a dictionary.
564 :returns:
565 Serialized version of the object.
566 """
568 serialized: dict[str, Any] = {}
569 serialized["role"] = self._role.value
570 serialized["meta"] = self._meta
571 serialized["name"] = self._name
573 serialized["content"] = [_serialize_content_part(part) for part in self._content]
574 return serialized
576 def _to_trace_dict(self) -> dict[str, Any]:
577 """
578 Convert the ChatMessage to a dictionary representation for tracing.
580 For Image Content objects, the base64_image is replaced with a placeholder string to avoid sending large
581 payloads to the tracing backend.
583 :returns:
584 Serialized version of the object only for tracing purposes.
585 """
587 serialized: dict[str, Any] = {}
588 serialized["role"] = self._role.value
589 serialized["meta"] = self._meta
590 serialized["name"] = self._name
592 serialized["content"] = []
593 for part in self._content:
594 serialized_part = _serialize_content_part(part)
595 if isinstance(part, ImageContent):
596 serialized_part["image"] = part._to_trace_dict()
597 elif isinstance(part, FileContent):
598 serialized_part["file"] = part._to_trace_dict()
599 serialized["content"].append(serialized_part)
601 return serialized
603 @classmethod
604 def from_dict(cls, data: dict[str, Any]) -> "ChatMessage":
605 """
606 Creates a new ChatMessage object from a dictionary.
608 :param data:
609 The dictionary to build the ChatMessage object.
610 :returns:
611 The created object.
612 :raises ValueError: If the `role` field is missing from the dictionary.
613 :raises TypeError: If the `content` field is not a list or string.
614 """
616 # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
617 if "role" not in data and "_role" not in data:
618 raise ValueError(
619 "The `role` field is required in the message dictionary. "
620 f"Expected a dictionary with 'role' field containing one of: {[role.value for role in ChatRole]}. "
621 f"Common roles are 'user' (for user messages) and 'assistant' (for AI responses). "
622 f"Received dictionary with keys: {list(data.keys())}"
623 )
625 if "content" in data:
626 init_params: dict[str, Any] = {
627 "_role": ChatRole(data["role"]),
628 "_name": data.get("name"),
629 "_meta": data.get("meta") or {},
630 }
632 if isinstance(data["content"], list):
633 # current format - the serialized `content` field is a list of dictionaries
634 init_params["_content"] = [_deserialize_content_part(part) for part in data["content"]]
635 elif isinstance(data["content"], str):
636 # pre 2.9.0 format - the `content` field is a string
637 init_params["_content"] = [TextContent(text=data["content"])]
638 else:
639 raise TypeError(f"Unsupported content type in serialized ChatMessage: `{(data['content'])}`")
640 return cls(**init_params)
642 if "_content" in data:
643 # format for versions >=2.9.0 and <2.12.0 - the serialized `_content` field is a list of dictionaries
644 return cls(
645 _role=ChatRole(data["_role"]),
646 _content=[_deserialize_content_part(part) for part in data["_content"]],
647 _name=data.get("_name"),
648 _meta=data.get("_meta") or {},
649 )
651 raise ValueError(f"Missing 'content' or '_content' in serialized ChatMessage: `{data}`")
653 def to_openai_dict_format(self, require_tool_call_ids: bool = True) -> dict[str, Any]:
654 """
655 Convert a ChatMessage to the dictionary format expected by OpenAI's Chat Completions API.
657 The `_meta` field of ChatMessage is removed because it is not supported by OpenAI's Chat Completions API.
658 The exception is a non-empty string in `meta["refusal"]` of an assistant message. It is sent as a refusal
659 content part, or as the `refusal` field if the message also has text. If the message also has tool calls, the
660 refusal is dropped with a warning, because OpenAI ignores the tool calls of a message with a refusal.
662 :param require_tool_call_ids:
663 If True (default), enforces that each Tool Call includes a non-null `id` attribute.
664 Set to False to allow Tool Calls without `id`, which may be suitable for shallow OpenAI-compatible APIs.
665 :returns:
666 The ChatMessage in the format expected by OpenAI's Chat Completions API.
668 :raises ValueError:
669 If the message format is invalid, or if `require_tool_call_ids` is True and any Tool Call is missing an
670 `id` attribute.
671 """
672 has_content = bool(self.texts or self.tool_calls or self.tool_call_results or self.images or self.files)
673 # We convert an assistant message with no content part into a message with empty content, which the API accepts
674 if not has_content and not self.is_from(ChatRole.ASSISTANT):
675 raise ValueError(
676 f"A `ChatMessage` from `{self._role.value}` must contain at least one `TextContent`, `ToolCall`, "
677 "`ToolCallResult`, `ImageContent`, or `FileContent`. Only assistant messages can be empty."
678 )
679 if len(self.tool_call_results) > 0 and len(self._content) > 1:
680 raise ValueError(
681 "For OpenAI compatibility, a `ChatMessage` with a `ToolCallResult` cannot contain any other content."
682 )
684 openai_msg: dict[str, Any] = {"role": self._role.value}
686 if self._name is not None:
687 openai_msg["name"] = self._name
689 if openai_msg["role"] == "user":
690 return self._user_message_to_openai(openai_msg)
692 if self.tool_call_results:
693 return self._tool_result_message_to_openai(openai_msg, require_tool_call_ids)
695 return self._system_assistant_message_to_openai(openai_msg, require_tool_call_ids)
697 def _user_message_to_openai(self, openai_msg: dict[str, Any]) -> dict[str, Any]:
698 """Build OpenAI dict for a user message."""
699 if len(self._content) == 1 and isinstance(self._content[0], TextContent):
700 openai_msg["content"] = self.text
701 return openai_msg
703 content = []
704 for part in self._content:
705 if isinstance(part, TextContent):
706 content.append({"type": "text", "text": part.text})
707 elif isinstance(part, ImageContent):
708 image_item: dict[str, Any] = {
709 "type": "image_url",
710 # If no MIME type is provided, default to JPEG.
711 # OpenAI API appears to tolerate MIME type mismatches.
712 "image_url": {"url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}"},
713 }
714 if part.detail:
715 image_item["image_url"]["detail"] = part.detail
716 content.append(image_item)
717 elif isinstance(part, FileContent):
718 file_item: dict[str, Any] = {
719 "type": "file",
720 "file": {
721 "file_data": f"data:{part.mime_type or 'application/pdf'};base64,{part.base64_data}",
722 # Filename is optional but if not provided, OpenAI expects a file_id of a previous file upload.
723 # We use a dummy filename.
724 "filename": part.filename or "filename",
725 },
726 }
727 content.append(file_item)
728 openai_msg["content"] = content
729 return openai_msg
731 def _tool_result_message_to_openai(self, openai_msg: dict[str, Any], require_tool_call_ids: bool) -> dict[str, Any]:
732 """Build OpenAI dict for a tool result message."""
733 result = self.tool_call_results[0]
734 if isinstance(result.result, str):
735 openai_msg["content"] = result.result
736 # OpenAI Chat Completions API does not support multimodal tool results
737 elif isinstance(result.result, list) and all(isinstance(part, TextContent) for part in result.result):
738 openai_msg["content"] = [{"type": "text", "text": part.text} for part in result.result]
739 else:
740 raise ValueError(
741 f"Unsupported tool result: {result}. If you need to pass images in tool results, "
742 "use OpenAI Responses API instead."
743 )
745 if result.origin.id is not None:
746 openai_msg["tool_call_id"] = result.origin.id
747 elif require_tool_call_ids:
748 raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
749 # OpenAI does not provide a way to communicate errors in tool invocations, so we ignore the error field
750 return openai_msg
752 def _system_assistant_message_to_openai(
753 self, openai_msg: dict[str, Any], require_tool_call_ids: bool
754 ) -> dict[str, Any]:
755 """Build OpenAI dict for system and assistant messages."""
756 # OpenAI Chat Completions API does not support reasoning content, so we ignore it
757 if self.texts:
758 openai_msg["content"] = self.texts[0]
759 refusal = self._meta.get("refusal") if self.is_from(ChatRole.ASSISTANT) else None
760 # Only a string is a refusal. Any other value under this key is ordinary metadata, which isn't sent.
761 if not isinstance(refusal, str):
762 refusal = None
763 elif refusal and self.tool_calls:
764 logger.warning(
765 "Dropping the refusal of an assistant message that also has tool calls, because OpenAI ignores the "
766 "tool calls of a message with a refusal and then rejects the tool results that follow."
767 )
768 refusal = None
769 # OpenAI's content array holds either text parts or exactly one refusal part, so a refusal next to text goes
770 # into the `refusal` field.
771 if refusal and "content" in openai_msg:
772 openai_msg["refusal"] = refusal
773 elif refusal:
774 openai_msg["content"] = [{"type": "refusal", "refusal": refusal}]
775 if self.tool_calls:
776 openai_tool_calls = []
777 for tc in self.tool_calls:
778 openai_tool_call = {
779 "type": "function",
780 # We disable ensure_ascii so special chars like emojis are not converted
781 "function": {"name": tc.tool_name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)},
782 }
783 if tc.id is not None:
784 openai_tool_call["id"] = tc.id
785 elif require_tool_call_ids:
786 raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
787 openai_tool_calls.append(openai_tool_call)
788 openai_msg["tool_calls"] = openai_tool_calls
789 # The API rejects a message carrying neither content nor tool calls, so send empty content for it.
790 if "content" not in openai_msg and "tool_calls" not in openai_msg:
791 openai_msg["content"] = ""
792 return openai_msg
794 @staticmethod
795 def _validate_openai_message(message: dict[str, Any]) -> None:
796 """
797 Validate that a message dictionary follows OpenAI's Chat API format.
799 :param message: The message dictionary to validate
800 :raises ValueError: If the message format is invalid
801 """
802 if "role" not in message:
803 raise ValueError("The `role` field is required in the message dictionary.")
805 role = message["role"]
806 content = message.get("content")
807 tool_calls = message.get("tool_calls")
809 if role not in ["assistant", "user", "system", "developer", "tool"]:
810 raise ValueError(f"Unsupported role: {role}")
812 if content is not None and not isinstance(content, (str, list)):
813 raise ValueError(
814 f"The `content` field must be a string or a list of content parts, got {type(content).__name__}."
815 )
817 if role == "assistant":
818 refusal = message.get("refusal")
819 if refusal is not None and not isinstance(refusal, str):
820 raise ValueError(f"The `refusal` field must be a string, got {type(refusal).__name__}.")
821 # An empty string is valid content for an assistant message: that is how a reply with nothing to send
822 # is serialized. Other falsy content requires tool calls or a refusal.
823 if not content and content != "" and not tool_calls and not refusal:
824 raise ValueError(
825 "For assistant messages, either `content`, `tool_calls`, or `refusal` must be present."
826 )
827 if tool_calls:
828 for tc in tool_calls:
829 if "function" not in tc:
830 raise ValueError("Tool calls must contain the `function` field")
831 elif not content:
832 raise ValueError(f"The `content` field is required for {role} messages.")
834 @staticmethod
835 def _parse_openai_data_url(data_url: Any) -> tuple[str | None, str]:
836 """
837 Split a base64 data URL in OpenAI format into its MIME type and base64 payload.
839 :param data_url: A data URL in the format `data:<mime_type>;base64,<base64_data>`.
840 :returns: A tuple containing the MIME type (or None if absent) and the base64 data.
841 :raises ValueError: If the URL is not a string or not a base64 data URL.
842 """
843 if not isinstance(data_url, str) or not data_url.startswith("data:") or ";base64," not in data_url:
844 raise ValueError(
845 f"Unsupported URL: {_CONTENT_PART_REPR.repr(data_url)}. Only base64 data URLs in the format "
846 "`data:<mime_type>;base64,<base64_data>` are supported."
847 )
848 header, base64_data = data_url.split(";base64,", 1)
849 return header[len("data:") :] or None, base64_data
851 @classmethod
852 def _from_openai_content_parts(cls, content: list[Any]) -> list[TextContent | ImageContent | FileContent]:
853 """
854 Convert a list of content parts in OpenAI format into Haystack content parts.
856 :param content: A list of content parts in OpenAI format.
857 :returns: A list of TextContent, ImageContent, and FileContent objects.
858 :raises ValueError: If a content part is malformed or of an unsupported type.
859 """
860 parts: list[TextContent | ImageContent | FileContent] = []
861 for part in content:
862 part_type = part.get("type") if isinstance(part, dict) else None
863 if part_type == "text":
864 if not isinstance(part.get("text"), str):
865 raise ValueError(
866 f"Unsupported text content part: {_CONTENT_PART_REPR.repr(part)}. "
867 "Text parts must contain a `text` string."
868 )
869 parts.append(TextContent(text=part["text"]))
870 elif part_type == "image_url":
871 image_url = part.get("image_url")
872 if not isinstance(image_url, dict):
873 raise ValueError(
874 f"Unsupported image content part: {_CONTENT_PART_REPR.repr(part)}. "
875 "Image parts must contain an `image_url` object."
876 )
877 mime_type, base64_image = cls._parse_openai_data_url(image_url.get("url"))
878 parts.append(
879 ImageContent(base64_image=base64_image, mime_type=mime_type, detail=image_url.get("detail"))
880 )
881 elif part_type == "file":
882 file = part.get("file")
883 if not isinstance(file, dict):
884 raise ValueError(
885 f"Unsupported file content part: {_CONTENT_PART_REPR.repr(part)}. "
886 "File parts must contain a `file` object."
887 )
888 file_data = file.get("file_data")
889 if not file_data:
890 raise ValueError(
891 f"Unsupported file content part: {_CONTENT_PART_REPR.repr(part)}. Only files with inline "
892 "base64 `file_data` are supported: files referenced by `file_id` cannot be converted."
893 )
894 mime_type, base64_data = cls._parse_openai_data_url(file_data)
895 parts.append(FileContent(base64_data=base64_data, mime_type=mime_type, filename=file.get("filename")))
896 else:
897 raise ValueError(
898 f"Unsupported content part: {_CONTENT_PART_REPR.repr(part)}. "
899 "Supported part types are `text`, `image_url`, and `file`."
900 )
901 return parts
903 @staticmethod
904 def _join_openai_text_parts(content: list[Any], role: str) -> dict[str, str]:
905 """
906 Join the text and refusal content parts of a system, developer, or assistant message in OpenAI format.
908 Text parts are joined into a single text because `to_openai_dict_format` only sends the first text of these
909 messages. Refusal parts are accepted only in assistant messages and are joined separately, so a refusal never
910 ends up in the text.
912 :param content: A list of content parts in OpenAI format.
913 :param role: The role of the message, which decides whether refusal parts are accepted.
914 :returns: A dictionary that maps each part type in `content` to the texts of its parts, joined with a newline.
915 :raises ValueError: If a content part is not a text part with a `text` string, or, in an assistant message, a
916 refusal part with a `refusal` string.
917 """
918 allowed_types = ("text", "refusal") if role == "assistant" else ("text",)
919 texts: dict[str, list[str]] = {}
920 for part in content:
921 # In OpenAI format, a part's text sits under a key named after its type.
922 if (
923 not isinstance(part, dict)
924 or part.get("type") not in allowed_types
925 or not isinstance(part.get(part["type"]), str)
926 ):
927 supported = " and ".join(f"`{t}` parts with a `{t}` string" for t in allowed_types)
928 raise ValueError(
929 f"Unsupported content part in {role} message: {_CONTENT_PART_REPR.repr(part)}. "
930 f"Only {supported} are supported."
931 )
932 texts.setdefault(part["type"], []).append(part[part["type"]])
933 return {part_type: "\n".join(part_texts) for part_type, part_texts in texts.items()}
935 @classmethod
936 def from_openai_dict_format(cls, message: dict[str, Any]) -> "ChatMessage":
937 """
938 Create a ChatMessage from a dictionary in the format expected by OpenAI's Chat API.
940 `content` can be a string or a list of content parts. In user messages, `text` parts become `TextContent`,
941 `image_url` parts with a base64 data URL become `ImageContent`, and `file` parts with inline `file_data` become
942 `FileContent`. System, developer, and assistant messages accept `text` parts, which are joined with a newline
943 into a single text. An assistant's refusal, given as a `refusal` part or as the `refusal` field, is stored in
944 `meta["refusal"]` instead of the text.
946 NOTE: While OpenAI's API requires `tool_call_id` in both tool calls and tool messages, this method
947 accepts messages without it to support shallow OpenAI-compatible APIs.
948 If you plan to use the resulting ChatMessage with OpenAI, you must include `tool_call_id` or you'll
949 encounter validation errors.
951 :param message:
952 The OpenAI dictionary to build the ChatMessage object.
953 :returns:
954 The created ChatMessage object.
956 :raises ValueError:
957 If the message dictionary is missing required fields, if `content` is neither a string nor a list, or if
958 it contains content parts that can't be converted, such as image URLs that are not base64 data URLs or
959 files referenced by `file_id`.
960 """
961 cls._validate_openai_message(message)
963 role = message["role"]
964 content = message.get("content")
965 name = message.get("name")
966 tool_calls = message.get("tool_calls")
967 tool_call_id = message.get("tool_call_id")
969 if role == "assistant":
970 haystack_tool_calls = None
971 if tool_calls:
972 haystack_tool_calls = []
973 for tc in tool_calls:
974 # Zero-argument tool calls from OpenAI-compatible servers may send an
975 # empty string, null, or omit `arguments` entirely; treat all as {}.
976 # Some servers also send a parsed dict instead of a JSON string.
977 raw_arguments = tc["function"].get("arguments")
978 haystack_tc = ToolCall(
979 id=tc.get("id"),
980 tool_name=tc["function"]["name"],
981 arguments=_parse_openai_tool_call_arguments(raw_arguments),
982 )
983 haystack_tool_calls.append(haystack_tc)
984 text = content
985 refusals = [message["refusal"]] if message.get("refusal") else []
986 if isinstance(content, list):
987 joined = cls._join_openai_text_parts(content=content, role=role)
988 text = joined.get("text")
989 if "refusal" in joined:
990 refusals.append(joined["refusal"])
991 # ChatMessage has no refusal content, so the refusal goes to meta, from which to_openai_dict_format
992 # sends it back. dict.fromkeys keeps a refusal sent both as the field and as a part only once.
993 meta = {"refusal": "\n".join(dict.fromkeys(refusals))} if refusals else None
994 return cls.from_assistant(text=text, meta=meta, name=name, tool_calls=haystack_tool_calls)
996 assert content is not None # ensured by _validate_openai_message, but we need to make mypy happy
998 if role == "user":
999 if isinstance(content, str):
1000 return cls.from_user(text=content, name=name)
1001 return cls.from_user(content_parts=cls._from_openai_content_parts(content), name=name)
1002 if role in ["system", "developer"]:
1003 if isinstance(content, list):
1004 content = cls._join_openai_text_parts(content=content, role=role)["text"]
1005 return cls.from_system(text=content, name=name)
1007 if isinstance(content, list):
1008 if not all("text" in el for el in content):
1009 raise ValueError("To be used with OpenAI, tool results must be a string or a list of TextContent")
1010 content = [TextContent(text=el["text"]) for el in content]
1011 return cls.from_tool(
1012 tool_result=content, origin=ToolCall(id=tool_call_id, tool_name="", arguments={}), error=False
1013 )