• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

deepset-ai / haystack / 16744961255

05 Aug 2025 08:35AM UTC coverage: 91.923% (+0.02%) from 91.906%
16744961255

Pull #9679

github

web-flow
Merge 4c13c5b85 into d0de78ec0
Pull Request #9679: feat: add serde methods to `ImageContent` and `TextContent`

12792 of 13916 relevant lines covered (91.92%)

0.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.26
haystack/dataclasses/chat_message.py
1
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
#
3
# SPDX-License-Identifier: Apache-2.0
4

5
import json
1✔
6
from dataclasses import asdict, dataclass, field
1✔
7
from enum import Enum
1✔
8
from typing import Any, Dict, List, Optional, Sequence, Type, Union
1✔
9

10
from haystack import logging
1✔
11
from haystack.dataclasses.image_content import ImageContent
1✔
12

13
logger = logging.getLogger(__name__)
1✔
14

15

16
LEGACY_INIT_PARAMETERS = {"role", "content", "meta", "name"}
1✔
17

18

19
class ChatRole(str, Enum):
1✔
20
    """
21
    Enumeration representing the roles within a chat.
22
    """
23

24
    #: The user role. A message from the user contains only text.
25
    USER = "user"
1✔
26

27
    #: The system role. A message from the system contains only text.
28
    SYSTEM = "system"
1✔
29

30
    #: The assistant role. A message from the assistant can contain text and Tool calls. It can also store metadata.
31
    ASSISTANT = "assistant"
1✔
32

33
    #: The tool role. A message from a tool contains the result of a Tool invocation.
34
    TOOL = "tool"
1✔
35

36
    @staticmethod
1✔
37
    def from_str(string: str) -> "ChatRole":
1✔
38
        """
39
        Convert a string to a ChatRole enum.
40
        """
41
        enum_map = {e.value: e for e in ChatRole}
1✔
42
        role = enum_map.get(string)
1✔
43
        if role is None:
1✔
44
            msg = f"Unknown chat role '{string}'. Supported roles are: {list(enum_map.keys())}"
1✔
45
            raise ValueError(msg)
1✔
46
        return role
1✔
47

48

49
@dataclass
1✔
50
class ToolCall:
1✔
51
    """
52
    Represents a Tool call prepared by the model, usually contained in an assistant message.
53

54
    :param id: The ID of the Tool call.
55
    :param tool_name: The name of the Tool to call.
56
    :param arguments: The arguments to call the Tool with.
57
    """
58

59
    tool_name: str
1✔
60
    arguments: Dict[str, Any]
1✔
61
    id: Optional[str] = None  # noqa: A003
1✔
62

63
    def to_dict(self) -> Dict[str, Any]:
1✔
64
        """
65
        Convert ToolCall into a dictionary.
66

67
        :returns: A dictionary with keys 'tool_name', 'arguments', and 'id'.
68
        """
69
        return asdict(self)
1✔
70

71
    @classmethod
1✔
72
    def from_dict(cls, data: Dict[str, Any]) -> "ToolCall":
1✔
73
        """
74
        Creates a new ToolCall object from a dictionary.
75

76
        :param data:
77
            The dictionary to build the ToolCall object.
78
        :returns:
79
            The created object.
80
        """
81
        return ToolCall(**data)
1✔
82

83

84
@dataclass
1✔
85
class ToolCallResult:
1✔
86
    """
87
    Represents the result of a Tool invocation.
88

89
    :param result: The result of the Tool invocation.
90
    :param origin: The Tool call that produced this result.
91
    :param error: Whether the Tool invocation resulted in an error.
92
    """
93

94
    result: str
1✔
95
    origin: ToolCall
1✔
96
    error: bool
1✔
97

98
    def to_dict(self) -> Dict[str, Any]:
1✔
99
        """
100
        Converts ToolCallResult into a dictionary.
101

102
        :returns: A dictionary with keys 'result', 'origin', and 'error'.
103
        """
104
        return asdict(self)
1✔
105

106
    @classmethod
1✔
107
    def from_dict(cls, data: Dict[str, Any]) -> "ToolCallResult":
1✔
108
        """
109
        Creates a ToolCallResult from a dictionary.
110

111
        :param data:
112
            The dictionary to build the ToolCallResult object.
113
        :returns:
114
            The created object.
115
        """
116
        if not all(x in data for x in ["result", "origin", "error"]):
1✔
117
            raise ValueError(
1✔
118
                "Fields `result`, `origin`, `error` are required for ToolCallResult deserialization. "
119
                f"Received dictionary with keys {list(data.keys())}"
120
            )
121
        return ToolCallResult(result=data["result"], origin=ToolCall.from_dict(data["origin"]), error=data["error"])
1✔
122

123

124
@dataclass
1✔
125
class TextContent:
1✔
126
    """
127
    The textual content of a chat message.
128

129
    :param text: The text content of the message.
130
    """
131

132
    text: str
1✔
133

134
    def to_dict(self) -> Dict[str, Any]:
1✔
135
        """
136
        Convert TextContent into a dictionary.
137
        """
138
        return asdict(self)
1✔
139

140
    @classmethod
1✔
141
    def from_dict(cls, data: Dict[str, Any]) -> "TextContent":
1✔
142
        """
143
        Create a TextContent from a dictionary.
144
        """
145
        return TextContent(**data)
1✔
146

147

148
ChatMessageContentT = Union[TextContent, ToolCall, ToolCallResult, ImageContent]
1✔
149

150
_CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS: Dict[Type, str] = {
1✔
151
    TextContent: "text",
152
    ToolCall: "tool_call",
153
    ToolCallResult: "tool_call_result",
154
    ImageContent: "image",
155
}
156

157

158
def _deserialize_content_part(part: Dict[str, Any]) -> ChatMessageContentT:
1✔
159
    """
160
    Deserialize a single content part of a serialized ChatMessage.
161

162
    :param part:
163
        A dictionary representing a single content part of a serialized ChatMessage.
164
    :returns:
165
        A ChatMessageContentT object.
166
    :raises ValueError:
167
        If the part is not a valid ChatMessageContentT object.
168
    """
169
    # handle flat text format separately
170
    if "text" in part:
1✔
171
        return TextContent.from_dict(part)
1✔
172

173
    for cls, serialization_key in _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.items():
1✔
174
        if serialization_key in part:
1✔
175
            return cls.from_dict(part[serialization_key])
1✔
176

177
    raise ValueError(f"Unsupported content part in the serialized ChatMessage: `{part}`")
1✔
178

179

180
def _serialize_content_part(part: ChatMessageContentT) -> Dict[str, Any]:
1✔
181
    """
182
    Serialize a single content part of a ChatMessage.
183

184
    :param part:
185
        A ChatMessageContentT object.
186
    :returns:
187
        A dictionary representing the content part.
188
    :raises TypeError:
189
        If the part is not a valid ChatMessageContentT object.
190
    """
191
    serialization_key = _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.get(type(part))
1✔
192
    if serialization_key is None:
1✔
193
        raise TypeError(f"Unsupported type in ChatMessage content: `{type(part).__name__}` for `{part}`.")
1✔
194

195
    # handle flat text format separately
196
    if isinstance(part, TextContent):
1✔
197
        return part.to_dict()
1✔
198

199
    return {serialization_key: part.to_dict()}
1✔
200

201

202
@dataclass
1✔
203
class ChatMessage:
1✔
204
    """
205
    Represents a message in a LLM chat conversation.
206

207
    Use the `from_assistant`, `from_user`, `from_system`, and `from_tool` class methods to create a ChatMessage.
208
    """
209

210
    _role: ChatRole
1✔
211
    _content: Sequence[ChatMessageContentT]
1✔
212
    _name: Optional[str] = None
1✔
213
    _meta: Dict[str, Any] = field(default_factory=dict, hash=False)
1✔
214

215
    def __new__(cls, *args, **kwargs):
1✔
216
        """
217
        This method is reimplemented to make the changes to the `ChatMessage` dataclass more visible.
218
        """
219

220
        general_msg = (
1✔
221
            "Use the `from_assistant`, `from_user`, `from_system`, and `from_tool` class methods to create a "
222
            "ChatMessage. For more information about the new API and how to migrate, see the documentation:"
223
            " https://docs.haystack.deepset.ai/docs/chatmessage"
224
        )
225

226
        if any(param in kwargs for param in LEGACY_INIT_PARAMETERS):
1✔
227
            raise TypeError(
1✔
228
                "The `role`, `content`, `meta`, and `name` init parameters of `ChatMessage` have been removed. "
229
                f"{general_msg}"
230
            )
231

232
        return super(ChatMessage, cls).__new__(cls)
1✔
233

234
    def __getattribute__(self, name):
1✔
235
        """
236
        This method is reimplemented to make the `content` attribute removal more visible.
237
        """
238

239
        if name == "content":
1✔
240
            msg = (
1✔
241
                "The `content` attribute of `ChatMessage` has been removed. "
242
                "Use the `text` property to access the textual value. "
243
                "For more information about the new API and how to migrate, see the documentation: "
244
                "https://docs.haystack.deepset.ai/docs/chatmessage"
245
            )
246
            raise AttributeError(msg)
1✔
247
        return object.__getattribute__(self, name)
1✔
248

249
    def __len__(self):
1✔
250
        return len(self._content)
1✔
251

252
    @property
1✔
253
    def role(self) -> ChatRole:
1✔
254
        """
255
        Returns the role of the entity sending the message.
256
        """
257
        return self._role
1✔
258

259
    @property
1✔
260
    def meta(self) -> Dict[str, Any]:
1✔
261
        """
262
        Returns the metadata associated with the message.
263
        """
264
        return self._meta
1✔
265

266
    @property
1✔
267
    def name(self) -> Optional[str]:
1✔
268
        """
269
        Returns the name associated with the message.
270
        """
271
        return self._name
1✔
272

273
    @property
1✔
274
    def texts(self) -> List[str]:
1✔
275
        """
276
        Returns the list of all texts contained in the message.
277
        """
278
        return [content.text for content in self._content if isinstance(content, TextContent)]
1✔
279

280
    @property
1✔
281
    def text(self) -> Optional[str]:
1✔
282
        """
283
        Returns the first text contained in the message.
284
        """
285
        if texts := self.texts:
1✔
286
            return texts[0]
1✔
287
        return None
1✔
288

289
    @property
1✔
290
    def tool_calls(self) -> List[ToolCall]:
1✔
291
        """
292
        Returns the list of all Tool calls contained in the message.
293
        """
294
        return [content for content in self._content if isinstance(content, ToolCall)]
1✔
295

296
    @property
1✔
297
    def tool_call(self) -> Optional[ToolCall]:
1✔
298
        """
299
        Returns the first Tool call contained in the message.
300
        """
301
        if tool_calls := self.tool_calls:
1✔
302
            return tool_calls[0]
1✔
303
        return None
1✔
304

305
    @property
1✔
306
    def tool_call_results(self) -> List[ToolCallResult]:
1✔
307
        """
308
        Returns the list of all Tool call results contained in the message.
309
        """
310
        return [content for content in self._content if isinstance(content, ToolCallResult)]
1✔
311

312
    @property
1✔
313
    def tool_call_result(self) -> Optional[ToolCallResult]:
1✔
314
        """
315
        Returns the first Tool call result contained in the message.
316
        """
317
        if tool_call_results := self.tool_call_results:
1✔
318
            return tool_call_results[0]
1✔
319
        return None
1✔
320

321
    @property
1✔
322
    def images(self) -> List[ImageContent]:
1✔
323
        """
324
        Returns the list of all images contained in the message.
325
        """
326
        return [content for content in self._content if isinstance(content, ImageContent)]
1✔
327

328
    @property
1✔
329
    def image(self) -> Optional[ImageContent]:
1✔
330
        """
331
        Returns the first image contained in the message.
332
        """
333
        if images := self.images:
1✔
334
            return images[0]
×
335
        return None
1✔
336

337
    def is_from(self, role: Union[ChatRole, str]) -> bool:
1✔
338
        """
339
        Check if the message is from a specific role.
340

341
        :param role: The role to check against.
342
        :returns: True if the message is from the specified role, False otherwise.
343
        """
344
        if isinstance(role, str):
1✔
345
            role = ChatRole.from_str(role)
1✔
346
        return self._role == role
1✔
347

348
    @classmethod
1✔
349
    def from_user(
1✔
350
        cls,
351
        text: Optional[str] = None,
352
        meta: Optional[Dict[str, Any]] = None,
353
        name: Optional[str] = None,
354
        *,
355
        content_parts: Optional[Sequence[Union[TextContent, str, ImageContent]]] = None,
356
    ) -> "ChatMessage":
357
        """
358
        Create a message from the user.
359

360
        :param text: The text content of the message. Specify this or content_parts.
361
        :param meta: Additional metadata associated with the message.
362
        :param name: An optional name for the participant. This field is only supported by OpenAI.
363
        :param content_parts: A list of content parts to include in the message. Specify this or text.
364
        :returns: A new ChatMessage instance.
365
        """
366
        if text is None and content_parts is None:
1✔
367
            raise ValueError("Either text or content_parts must be provided.")
1✔
368
        if text is not None and content_parts is not None:
1✔
369
            raise ValueError("Only one of text or content_parts can be provided.")
1✔
370

371
        content: Sequence[Union[TextContent, ImageContent]] = []
1✔
372

373
        if text is not None:
1✔
374
            content = [TextContent(text=text)]
1✔
375
        elif content_parts is not None:
1✔
376
            content = [TextContent(el) if isinstance(el, str) else el for el in content_parts]
1✔
377
            if not any(isinstance(el, TextContent) for el in content):
1✔
378
                raise ValueError("The user message must contain at least one textual part.")
1✔
379

380
            unsupported_parts = [el for el in content if not isinstance(el, (ImageContent, TextContent))]
1✔
381
            if unsupported_parts:
1✔
382
                raise ValueError(
1✔
383
                    f"The user message must contain only text or image parts. Unsupported parts: {unsupported_parts}"
384
                )
385

386
        return cls(_role=ChatRole.USER, _content=content, _meta=meta or {}, _name=name)
1✔
387

388
    @classmethod
1✔
389
    def from_system(cls, text: str, meta: Optional[Dict[str, Any]] = None, name: Optional[str] = None) -> "ChatMessage":
1✔
390
        """
391
        Create a message from the system.
392

393
        :param text: The text content of the message.
394
        :param meta: Additional metadata associated with the message.
395
        :param name: An optional name for the participant. This field is only supported by OpenAI.
396
        :returns: A new ChatMessage instance.
397
        """
398
        return cls(_role=ChatRole.SYSTEM, _content=[TextContent(text=text)], _meta=meta or {}, _name=name)
1✔
399

400
    @classmethod
1✔
401
    def from_assistant(
1✔
402
        cls,
403
        text: Optional[str] = None,
404
        meta: Optional[Dict[str, Any]] = None,
405
        name: Optional[str] = None,
406
        tool_calls: Optional[List[ToolCall]] = None,
407
    ) -> "ChatMessage":
408
        """
409
        Create a message from the assistant.
410

411
        :param text: The text content of the message.
412
        :param meta: Additional metadata associated with the message.
413
        :param tool_calls: The Tool calls to include in the message.
414
        :param name: An optional name for the participant. This field is only supported by OpenAI.
415
        :returns: A new ChatMessage instance.
416
        """
417
        content: List[ChatMessageContentT] = []
1✔
418
        if text is not None:
1✔
419
            content.append(TextContent(text=text))
1✔
420
        if tool_calls:
1✔
421
            content.extend(tool_calls)
1✔
422

423
        return cls(_role=ChatRole.ASSISTANT, _content=content, _meta=meta or {}, _name=name)
1✔
424

425
    @classmethod
1✔
426
    def from_tool(
1✔
427
        cls, tool_result: str, origin: ToolCall, error: bool = False, meta: Optional[Dict[str, Any]] = None
428
    ) -> "ChatMessage":
429
        """
430
        Create a message from a Tool.
431

432
        :param tool_result: The result of the Tool invocation.
433
        :param origin: The Tool call that produced this result.
434
        :param error: Whether the Tool invocation resulted in an error.
435
        :param meta: Additional metadata associated with the message.
436
        :returns: A new ChatMessage instance.
437
        """
438
        return cls(
1✔
439
            _role=ChatRole.TOOL,
440
            _content=[ToolCallResult(result=tool_result, origin=origin, error=error)],
441
            _meta=meta or {},
442
        )
443

444
    def to_dict(self) -> Dict[str, Any]:
1✔
445
        """
446
        Converts ChatMessage into a dictionary.
447

448
        :returns:
449
            Serialized version of the object.
450
        """
451

452
        serialized: Dict[str, Any] = {}
1✔
453
        serialized["role"] = self._role.value
1✔
454
        serialized["meta"] = self._meta
1✔
455
        serialized["name"] = self._name
1✔
456

457
        serialized["content"] = [_serialize_content_part(part) for part in self._content]
1✔
458
        return serialized
1✔
459

460
    @classmethod
1✔
461
    def from_dict(cls, data: Dict[str, Any]) -> "ChatMessage":
1✔
462
        """
463
        Creates a new ChatMessage object from a dictionary.
464

465
        :param data:
466
            The dictionary to build the ChatMessage object.
467
        :returns:
468
            The created object.
469
        """
470
        if not "role" in data and not "_role" in data:
1✔
471
            raise ValueError(
1✔
472
                "The `role` field is required in the message dictionary. "
473
                f"Expected a dictionary with 'role' field containing one of: {[role.value for role in ChatRole]}. "
474
                f"Common roles are 'user' (for user messages) and 'assistant' (for AI responses). "
475
                f"Received dictionary with keys: {list(data.keys())}"
476
            )
477

478
        if "content" in data:
1✔
479
            init_params: Dict[str, Any] = {
1✔
480
                "_role": ChatRole(data["role"]),
481
                "_name": data.get("name"),
482
                "_meta": data.get("meta") or {},
483
            }
484

485
            if isinstance(data["content"], list):
1✔
486
                # current format - the serialized `content` field is a list of dictionaries
487
                init_params["_content"] = [_deserialize_content_part(part) for part in data["content"]]
1✔
488
            elif isinstance(data["content"], str):
1✔
489
                # pre 2.9.0 format - the `content` field is a string
490
                init_params["_content"] = [TextContent(text=data["content"])]
1✔
491
            else:
492
                raise TypeError(f"Unsupported content type in serialized ChatMessage: `{(data['content'])}`")
×
493
            return cls(**init_params)
1✔
494

495
        if "_content" in data:
1✔
496
            # format for versions >=2.9.0 and <2.12.0 - the serialized `_content` field is a list of dictionaries
497
            return cls(
1✔
498
                _role=ChatRole(data["_role"]),
499
                _content=[_deserialize_content_part(part) for part in data["_content"]],
500
                _name=data.get("_name"),
501
                _meta=data.get("_meta") or {},
502
            )
503

504
        raise ValueError(f"Missing 'content' or '_content' in serialized ChatMessage: `{data}`")
1✔
505

506
    def to_openai_dict_format(self, require_tool_call_ids: bool = True) -> Dict[str, Any]:
1✔
507
        """
508
        Convert a ChatMessage to the dictionary format expected by OpenAI's Chat API.
509

510
        :param require_tool_call_ids:
511
            If True (default), enforces that each Tool Call includes a non-null `id` attribute.
512
            Set to False to allow Tool Calls without `id`, which may be suitable for shallow OpenAI-compatible APIs.
513
        :returns:
514
            The ChatMessage in the format expected by OpenAI's Chat API.
515

516
        :raises ValueError:
517
            If the message format is invalid, or if `require_tool_call_ids` is True and any Tool Call is missing an
518
            `id` attribute.
519
        """
520
        text_contents = self.texts
1✔
521
        tool_calls = self.tool_calls
1✔
522
        tool_call_results = self.tool_call_results
1✔
523

524
        if not text_contents and not tool_calls and not tool_call_results:
1✔
525
            raise ValueError(
1✔
526
                "A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, or `ToolCallResult`."
527
            )
528
        if len(text_contents) + len(tool_call_results) > 1:
1✔
529
            raise ValueError(
1✔
530
                "For OpenAI compatibility, a `ChatMessage` can only contain one `TextContent` or one `ToolCallResult`."
531
            )
532

533
        openai_msg: Dict[str, Any] = {"role": self._role.value}
1✔
534

535
        # Add name field if present
536
        if self._name is not None:
1✔
537
            openai_msg["name"] = self._name
1✔
538

539
        # user message
540
        if openai_msg["role"] == "user":
1✔
541
            if len(self._content) == 1:
1✔
542
                openai_msg["content"] = self.text
1✔
543
                return openai_msg
1✔
544

545
            # if the user message contains a list of text and images, OpenAI expects a list of dictionaries
546
            content = []
1✔
547
            for part in self._content:
1✔
548
                if isinstance(part, TextContent):
1✔
549
                    content.append({"type": "text", "text": part.text})
1✔
550
                elif isinstance(part, ImageContent):
1✔
551
                    image_item: Dict[str, Any] = {
1✔
552
                        "type": "image_url",
553
                        # If no MIME type is provided, default to JPEG.
554
                        # OpenAI API appears to tolerate MIME type mismatches.
555
                        "image_url": {"url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}"},
556
                    }
557
                    if part.detail:
1✔
558
                        image_item["image_url"]["detail"] = part.detail
1✔
559
                    content.append(image_item)
1✔
560
            openai_msg["content"] = content
1✔
561
            return openai_msg
1✔
562

563
        # tool message
564
        if tool_call_results:
1✔
565
            result = tool_call_results[0]
1✔
566
            openai_msg["content"] = result.result
1✔
567
            if result.origin.id is not None:
1✔
568
                openai_msg["tool_call_id"] = result.origin.id
1✔
569
            elif require_tool_call_ids:
1✔
570
                raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
1✔
571
            # OpenAI does not provide a way to communicate errors in tool invocations, so we ignore the error field
572
            return openai_msg
1✔
573

574
        # system and assistant messages
575
        if text_contents:
1✔
576
            openai_msg["content"] = text_contents[0]
1✔
577
        if tool_calls:
1✔
578
            openai_tool_calls = []
1✔
579
            for tc in tool_calls:
1✔
580
                openai_tool_call = {
1✔
581
                    "type": "function",
582
                    # We disable ensure_ascii so special chars like emojis are not converted
583
                    "function": {"name": tc.tool_name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)},
584
                }
585
                if tc.id is not None:
1✔
586
                    openai_tool_call["id"] = tc.id
1✔
587
                elif require_tool_call_ids:
1✔
588
                    raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
1✔
589
                openai_tool_calls.append(openai_tool_call)
1✔
590
            openai_msg["tool_calls"] = openai_tool_calls
1✔
591
        return openai_msg
1✔
592

593
    @staticmethod
1✔
594
    def _validate_openai_message(message: Dict[str, Any]) -> None:
1✔
595
        """
596
        Validate that a message dictionary follows OpenAI's Chat API format.
597

598
        :param message: The message dictionary to validate
599
        :raises ValueError: If the message format is invalid
600
        """
601
        if "role" not in message:
1✔
602
            raise ValueError("The `role` field is required in the message dictionary.")
1✔
603

604
        role = message["role"]
1✔
605
        content = message.get("content")
1✔
606
        tool_calls = message.get("tool_calls")
1✔
607

608
        if role not in ["assistant", "user", "system", "developer", "tool"]:
1✔
609
            raise ValueError(f"Unsupported role: {role}")
1✔
610

611
        if role == "assistant":
1✔
612
            if not content and not tool_calls:
1✔
613
                raise ValueError("For assistant messages, either `content` or `tool_calls` must be present.")
1✔
614
            if tool_calls:
1✔
615
                for tc in tool_calls:
1✔
616
                    if "function" not in tc:
1✔
617
                        raise ValueError("Tool calls must contain the `function` field")
1✔
618
        elif not content:
1✔
619
            raise ValueError(f"The `content` field is required for {role} messages.")
1✔
620

621
    @classmethod
1✔
622
    def from_openai_dict_format(cls, message: Dict[str, Any]) -> "ChatMessage":
1✔
623
        """
624
        Create a ChatMessage from a dictionary in the format expected by OpenAI's Chat API.
625

626
        NOTE: While OpenAI's API requires `tool_call_id` in both tool calls and tool messages, this method
627
        accepts messages without it to support shallow OpenAI-compatible APIs.
628
        If you plan to use the resulting ChatMessage with OpenAI, you must include `tool_call_id` or you'll
629
        encounter validation errors.
630

631
        :param message:
632
            The OpenAI dictionary to build the ChatMessage object.
633
        :returns:
634
            The created ChatMessage object.
635

636
        :raises ValueError:
637
            If the message dictionary is missing required fields.
638
        """
639
        cls._validate_openai_message(message)
1✔
640

641
        role = message["role"]
1✔
642
        content = message.get("content")
1✔
643
        name = message.get("name")
1✔
644
        tool_calls = message.get("tool_calls")
1✔
645
        tool_call_id = message.get("tool_call_id")
1✔
646

647
        if role == "assistant":
1✔
648
            haystack_tool_calls = None
1✔
649
            if tool_calls:
1✔
650
                haystack_tool_calls = []
1✔
651
                for tc in tool_calls:
1✔
652
                    haystack_tc = ToolCall(
1✔
653
                        id=tc.get("id"),
654
                        tool_name=tc["function"]["name"],
655
                        arguments=json.loads(tc["function"]["arguments"]),
656
                    )
657
                    haystack_tool_calls.append(haystack_tc)
1✔
658
            return cls.from_assistant(text=content, name=name, tool_calls=haystack_tool_calls)
1✔
659

660
        assert content is not None  # ensured by _validate_openai_message, but we need to make mypy happy
1✔
661

662
        if role == "user":
1✔
663
            return cls.from_user(text=content, name=name)
1✔
664
        if role in ["system", "developer"]:
1✔
665
            return cls.from_system(text=content, name=name)
1✔
666

667
        return cls.from_tool(
1✔
668
            tool_result=content, origin=ToolCall(id=tool_call_id, tool_name="", arguments={}), error=False
669
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc