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

deepset-ai / haystack / 19114177728

05 Nov 2025 07:41PM UTC coverage: 92.248%. Remained the same
19114177728

Pull #9932

github

web-flow
Merge 3db96ab24 into 510d06361
Pull Request #9932: fix: prompt-builder - jinja2 template set vars still shows required

13531 of 14668 relevant lines covered (92.25%)

0.92 hits per line

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

99.33
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, Optional, Sequence, 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
    :param extra: Dictionary of extra information about the Tool call. Use to store provider-specific
58
        information. To avoid serialization issues, values should be JSON serializable.
59
    """
60

61
    tool_name: str
1✔
62
    arguments: dict[str, Any]
1✔
63
    id: Optional[str] = None  # noqa: A003
1✔
64
    extra: Optional[dict[str, Any]] = None
1✔
65

66
    def to_dict(self) -> dict[str, Any]:
1✔
67
        """
68
        Convert ToolCall into a dictionary.
69

70
        :returns: A dictionary with keys 'tool_name', 'arguments', 'id', and 'extra'.
71
        """
72
        return asdict(self)
1✔
73

74
    @classmethod
1✔
75
    def from_dict(cls, data: dict[str, Any]) -> "ToolCall":
1✔
76
        """
77
        Creates a new ToolCall object from a dictionary.
78

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

86

87
@dataclass
1✔
88
class ToolCallResult:
1✔
89
    """
90
    Represents the result of a Tool invocation.
91

92
    :param result: The result of the Tool invocation.
93
    :param origin: The Tool call that produced this result.
94
    :param error: Whether the Tool invocation resulted in an error.
95
    """
96

97
    result: str
1✔
98
    origin: ToolCall
1✔
99
    error: bool
1✔
100

101
    def to_dict(self) -> dict[str, Any]:
1✔
102
        """
103
        Converts ToolCallResult into a dictionary.
104

105
        :returns: A dictionary with keys 'result', 'origin', and 'error'.
106
        """
107
        return asdict(self)
1✔
108

109
    @classmethod
1✔
110
    def from_dict(cls, data: dict[str, Any]) -> "ToolCallResult":
1✔
111
        """
112
        Creates a ToolCallResult from a dictionary.
113

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

126

127
@dataclass
1✔
128
class TextContent:
1✔
129
    """
130
    The textual content of a chat message.
131

132
    :param text: The text content of the message.
133
    """
134

135
    text: str
1✔
136

137
    def to_dict(self) -> dict[str, Any]:
1✔
138
        """
139
        Convert TextContent into a dictionary.
140
        """
141
        return asdict(self)
1✔
142

143
    @classmethod
1✔
144
    def from_dict(cls, data: dict[str, Any]) -> "TextContent":
1✔
145
        """
146
        Create a TextContent from a dictionary.
147
        """
148
        return TextContent(**data)
1✔
149

150

151
@dataclass
1✔
152
class ReasoningContent:
1✔
153
    """
154
    Represents the optional reasoning content prepared by the model, usually contained in an assistant message.
155

156
    :param reasoning_text: The reasoning text produced by the model.
157
    :param extra: Dictionary of extra information about the reasoning content. Use to store provider-specific
158
        information. To avoid serialization issues, values should be JSON serializable.
159
    """
160

161
    reasoning_text: str
1✔
162
    extra: dict[str, Any] = field(default_factory=dict)
1✔
163

164
    def to_dict(self) -> dict[str, Any]:
1✔
165
        """
166
        Convert ReasoningContent into a dictionary.
167

168
        :returns: A dictionary with keys 'reasoning_text', and 'extra'.
169
        """
170
        return asdict(self)
1✔
171

172
    @classmethod
1✔
173
    def from_dict(cls, data: dict[str, Any]) -> "ReasoningContent":
1✔
174
        """
175
        Creates a new ReasoningContent object from a dictionary.
176

177
        :param data:
178
            The dictionary to build the ReasoningContent object.
179
        :returns:
180
            The created object.
181
        """
182
        return ReasoningContent(**data)
1✔
183

184

185
ChatMessageContentT = Union[TextContent, ToolCall, ToolCallResult, ImageContent, ReasoningContent]
1✔
186

187
_CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS: dict[type[ChatMessageContentT], str] = {
1✔
188
    TextContent: "text",
189
    ToolCall: "tool_call",
190
    ToolCallResult: "tool_call_result",
191
    ImageContent: "image",
192
    ReasoningContent: "reasoning",
193
}
194

195

196
def _deserialize_content_part(part: dict[str, Any]) -> ChatMessageContentT:
1✔
197
    """
198
    Deserialize a single content part of a serialized ChatMessage.
199

200
    :param part:
201
        A dictionary representing a single content part of a serialized ChatMessage.
202
    :returns:
203
        A ChatMessageContentT object.
204
    :raises ValueError:
205
        If the part is not a valid ChatMessageContentT object.
206
    """
207
    # handle flat text format separately
208
    if "text" in part:
1✔
209
        return TextContent.from_dict(part)
1✔
210

211
    for cls, serialization_key in _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.items():
1✔
212
        if serialization_key in part:
1✔
213
            return cls.from_dict(part[serialization_key])
1✔
214

215
    # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
216
    msg = (
1✔
217
        f"Unsupported content part in the serialized ChatMessage: {part}. "
218
        "The `content` field of the serialized ChatMessage must be a list of dictionaries, where each "
219
        "dictionary contains one of these keys: 'text', 'image', 'reasoning', 'tool_call', or 'tool_call_result'. "
220
        "Valid formats: [{'text': 'Hello'}, {'image': {'base64_image': '...', ...}}, "
221
        "{'reasoning': {'reasoning_text': 'I think...', 'extra': {...}}}, "
222
        "{'tool_call': {'tool_name': 'search', 'arguments': {}, 'id': 'call_123'}}, "
223
        "{'tool_call_result': {'result': 'data', 'origin': {...}, 'error': false}}]"
224
    )
225
    raise ValueError(msg)
1✔
226

227

228
def _serialize_content_part(part: ChatMessageContentT) -> dict[str, Any]:
1✔
229
    """
230
    Serialize a single content part of a ChatMessage.
231

232
    :param part:
233
        A ChatMessageContentT object.
234
    :returns:
235
        A dictionary representing the content part.
236
    :raises TypeError:
237
        If the part is not a valid ChatMessageContentT object.
238
    """
239
    serialization_key = _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.get(type(part))
1✔
240
    if serialization_key is None:
1✔
241
        raise TypeError(f"Unsupported type in ChatMessage content: `{type(part).__name__}` for `{part}`.")
1✔
242

243
    # handle flat text format separately
244
    if isinstance(part, TextContent):
1✔
245
        return part.to_dict()
1✔
246

247
    return {serialization_key: part.to_dict()}
1✔
248

249

250
@dataclass
1✔
251
class ChatMessage:  # pylint: disable=too-many-public-methods # it's OK since we expose several properties
1✔
252
    """
253
    Represents a message in a LLM chat conversation.
254

255
    Use the `from_assistant`, `from_user`, `from_system`, and `from_tool` class methods to create a ChatMessage.
256
    """
257

258
    _role: ChatRole
1✔
259
    _content: Sequence[ChatMessageContentT]
1✔
260
    _name: Optional[str] = None
1✔
261
    _meta: dict[str, Any] = field(default_factory=dict, hash=False)
1✔
262

263
    def __new__(cls, *args, **kwargs):
1✔
264
        """
265
        This method is reimplemented to make the changes to the `ChatMessage` dataclass more visible.
266
        """
267

268
        general_msg = (
1✔
269
            "Use the `from_assistant`, `from_user`, `from_system`, and `from_tool` class methods to create a "
270
            "ChatMessage. For more information about the new API and how to migrate, see the documentation:"
271
            " https://docs.haystack.deepset.ai/docs/chatmessage"
272
        )
273

274
        if any(param in kwargs for param in LEGACY_INIT_PARAMETERS):
1✔
275
            raise TypeError(
1✔
276
                "The `role`, `content`, `meta`, and `name` init parameters of `ChatMessage` have been removed. "
277
                f"{general_msg}"
278
            )
279

280
        return super(ChatMessage, cls).__new__(cls)
1✔
281

282
    def __getattribute__(self, name):
1✔
283
        """
284
        This method is reimplemented to make the `content` attribute removal more visible.
285
        """
286

287
        if name == "content":
1✔
288
            msg = (
1✔
289
                "The `content` attribute of `ChatMessage` has been removed. "
290
                "Use the `text` property to access the textual value. "
291
                "For more information about the new API and how to migrate, see the documentation: "
292
                "https://docs.haystack.deepset.ai/docs/chatmessage"
293
            )
294
            raise AttributeError(msg)
1✔
295
        return object.__getattribute__(self, name)
1✔
296

297
    def __len__(self):
1✔
298
        return len(self._content)
1✔
299

300
    @property
1✔
301
    def role(self) -> ChatRole:
1✔
302
        """
303
        Returns the role of the entity sending the message.
304
        """
305
        return self._role
1✔
306

307
    @property
1✔
308
    def meta(self) -> dict[str, Any]:
1✔
309
        """
310
        Returns the metadata associated with the message.
311
        """
312
        return self._meta
1✔
313

314
    @property
1✔
315
    def name(self) -> Optional[str]:
1✔
316
        """
317
        Returns the name associated with the message.
318
        """
319
        return self._name
1✔
320

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

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

337
    @property
1✔
338
    def tool_calls(self) -> list[ToolCall]:
1✔
339
        """
340
        Returns the list of all Tool calls contained in the message.
341
        """
342
        return [content for content in self._content if isinstance(content, ToolCall)]
1✔
343

344
    @property
1✔
345
    def tool_call(self) -> Optional[ToolCall]:
1✔
346
        """
347
        Returns the first Tool call contained in the message.
348
        """
349
        if tool_calls := self.tool_calls:
1✔
350
            return tool_calls[0]
1✔
351
        return None
1✔
352

353
    @property
1✔
354
    def tool_call_results(self) -> list[ToolCallResult]:
1✔
355
        """
356
        Returns the list of all Tool call results contained in the message.
357
        """
358
        return [content for content in self._content if isinstance(content, ToolCallResult)]
1✔
359

360
    @property
1✔
361
    def tool_call_result(self) -> Optional[ToolCallResult]:
1✔
362
        """
363
        Returns the first Tool call result contained in the message.
364
        """
365
        if tool_call_results := self.tool_call_results:
1✔
366
            return tool_call_results[0]
1✔
367
        return None
1✔
368

369
    @property
1✔
370
    def images(self) -> list[ImageContent]:
1✔
371
        """
372
        Returns the list of all images contained in the message.
373
        """
374
        return [content for content in self._content if isinstance(content, ImageContent)]
1✔
375

376
    @property
1✔
377
    def image(self) -> Optional[ImageContent]:
1✔
378
        """
379
        Returns the first image contained in the message.
380
        """
381
        if images := self.images:
1✔
382
            return images[0]
×
383
        return None
1✔
384

385
    @property
1✔
386
    def reasonings(self) -> list[ReasoningContent]:
1✔
387
        """
388
        Returns the list of all reasoning contents contained in the message.
389
        """
390
        return [content for content in self._content if isinstance(content, ReasoningContent)]
1✔
391

392
    @property
1✔
393
    def reasoning(self) -> Optional[ReasoningContent]:
1✔
394
        """
395
        Returns the first reasoning content contained in the message.
396
        """
397
        if reasonings := self.reasonings:
1✔
398
            return reasonings[0]
1✔
399
        return None
1✔
400

401
    def is_from(self, role: Union[ChatRole, str]) -> bool:
1✔
402
        """
403
        Check if the message is from a specific role.
404

405
        :param role: The role to check against.
406
        :returns: True if the message is from the specified role, False otherwise.
407
        """
408
        if isinstance(role, str):
1✔
409
            role = ChatRole.from_str(role)
1✔
410
        return self._role == role
1✔
411

412
    @classmethod
1✔
413
    def from_user(
1✔
414
        cls,
415
        text: Optional[str] = None,
416
        meta: Optional[dict[str, Any]] = None,
417
        name: Optional[str] = None,
418
        *,
419
        content_parts: Optional[Sequence[Union[TextContent, str, ImageContent]]] = None,
420
    ) -> "ChatMessage":
421
        """
422
        Create a message from the user.
423

424
        :param text: The text content of the message. Specify this or content_parts.
425
        :param meta: Additional metadata associated with the message.
426
        :param name: An optional name for the participant. This field is only supported by OpenAI.
427
        :param content_parts: A list of content parts to include in the message. Specify this or text.
428
        :returns: A new ChatMessage instance.
429
        """
430
        if text is None and content_parts is None:
1✔
431
            raise ValueError("Either text or content_parts must be provided.")
1✔
432
        if text is not None and content_parts is not None:
1✔
433
            raise ValueError("Only one of text or content_parts can be provided.")
1✔
434

435
        content: list[Union[TextContent, ImageContent]] = []
1✔
436

437
        if text is not None:
1✔
438
            content = [TextContent(text=text)]
1✔
439
        elif content_parts is not None:
1✔
440
            for part in content_parts:
1✔
441
                if isinstance(part, str):
1✔
442
                    content.append(TextContent(text=part))
1✔
443
                elif isinstance(part, (TextContent, ImageContent)):
1✔
444
                    content.append(part)
1✔
445
                else:
446
                    raise ValueError(
1✔
447
                        f"The user message must contain only text or image parts. Unsupported part: {part}"
448
                    )
449
            if len(content) == 0:
1✔
450
                raise ValueError("The user message must contain at least one textual or image part.")
1✔
451

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

454
    @classmethod
1✔
455
    def from_system(cls, text: str, meta: Optional[dict[str, Any]] = None, name: Optional[str] = None) -> "ChatMessage":
1✔
456
        """
457
        Create a message from the system.
458

459
        :param text: The text content of the message.
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
        :returns: A new ChatMessage instance.
463
        """
464
        return cls(_role=ChatRole.SYSTEM, _content=[TextContent(text=text)], _meta=meta or {}, _name=name)
1✔
465

466
    @classmethod
1✔
467
    def from_assistant(
1✔
468
        cls,
469
        text: Optional[str] = None,
470
        meta: Optional[dict[str, Any]] = None,
471
        name: Optional[str] = None,
472
        tool_calls: Optional[list[ToolCall]] = None,
473
        *,
474
        reasoning: Optional[Union[str, ReasoningContent]] = None,
475
    ) -> "ChatMessage":
476
        """
477
        Create a message from the assistant.
478

479
        :param text: The text content of the message.
480
        :param meta: Additional metadata associated with the message.
481
        :param name: An optional name for the participant. This field is only supported by OpenAI.
482
        :param tool_calls: The Tool calls to include in the message.
483
        :param reasoning: The reasoning content to include in the message.
484
        :returns: A new ChatMessage instance.
485
        """
486
        content: list[ChatMessageContentT] = []
1✔
487
        if reasoning:
1✔
488
            if isinstance(reasoning, str):
1✔
489
                content.append(ReasoningContent(reasoning_text=reasoning))
1✔
490
            elif isinstance(reasoning, ReasoningContent):
1✔
491
                content.append(reasoning)
1✔
492
            else:
493
                raise TypeError(f"reasoning must be a string or a ReasoningContent object, got {type(reasoning)}")
1✔
494
        if text is not None:
1✔
495
            content.append(TextContent(text=text))
1✔
496
        if tool_calls:
1✔
497
            content.extend(tool_calls)
1✔
498

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

501
    @classmethod
1✔
502
    def from_tool(
1✔
503
        cls, tool_result: str, origin: ToolCall, error: bool = False, meta: Optional[dict[str, Any]] = None
504
    ) -> "ChatMessage":
505
        """
506
        Create a message from a Tool.
507

508
        :param tool_result: The result of the Tool invocation.
509
        :param origin: The Tool call that produced this result.
510
        :param error: Whether the Tool invocation resulted in an error.
511
        :param meta: Additional metadata associated with the message.
512
        :returns: A new ChatMessage instance.
513
        """
514
        return cls(
1✔
515
            _role=ChatRole.TOOL,
516
            _content=[ToolCallResult(result=tool_result, origin=origin, error=error)],
517
            _meta=meta or {},
518
        )
519

520
    def to_dict(self) -> dict[str, Any]:
1✔
521
        """
522
        Converts ChatMessage into a dictionary.
523

524
        :returns:
525
            Serialized version of the object.
526
        """
527

528
        serialized: dict[str, Any] = {}
1✔
529
        serialized["role"] = self._role.value
1✔
530
        serialized["meta"] = self._meta
1✔
531
        serialized["name"] = self._name
1✔
532

533
        serialized["content"] = [_serialize_content_part(part) for part in self._content]
1✔
534
        return serialized
1✔
535

536
    @classmethod
1✔
537
    def from_dict(cls, data: dict[str, Any]) -> "ChatMessage":
1✔
538
        """
539
        Creates a new ChatMessage object from a dictionary.
540

541
        :param data:
542
            The dictionary to build the ChatMessage object.
543
        :returns:
544
            The created object.
545
        """
546

547
        # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
548
        if not "role" in data and not "_role" in data:
1✔
549
            raise ValueError(
1✔
550
                "The `role` field is required in the message dictionary. "
551
                f"Expected a dictionary with 'role' field containing one of: {[role.value for role in ChatRole]}. "
552
                f"Common roles are 'user' (for user messages) and 'assistant' (for AI responses). "
553
                f"Received dictionary with keys: {list(data.keys())}"
554
            )
555

556
        if "content" in data:
1✔
557
            init_params: dict[str, Any] = {
1✔
558
                "_role": ChatRole(data["role"]),
559
                "_name": data.get("name"),
560
                "_meta": data.get("meta") or {},
561
            }
562

563
            if isinstance(data["content"], list):
1✔
564
                # current format - the serialized `content` field is a list of dictionaries
565
                init_params["_content"] = [_deserialize_content_part(part) for part in data["content"]]
1✔
566
            elif isinstance(data["content"], str):
1✔
567
                # pre 2.9.0 format - the `content` field is a string
568
                init_params["_content"] = [TextContent(text=data["content"])]
1✔
569
            else:
570
                raise TypeError(f"Unsupported content type in serialized ChatMessage: `{(data['content'])}`")
×
571
            return cls(**init_params)
1✔
572

573
        if "_content" in data:
1✔
574
            # format for versions >=2.9.0 and <2.12.0 - the serialized `_content` field is a list of dictionaries
575
            return cls(
1✔
576
                _role=ChatRole(data["_role"]),
577
                _content=[_deserialize_content_part(part) for part in data["_content"]],
578
                _name=data.get("_name"),
579
                _meta=data.get("_meta") or {},
580
            )
581

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

584
    def to_openai_dict_format(self, require_tool_call_ids: bool = True) -> dict[str, Any]:
1✔
585
        """
586
        Convert a ChatMessage to the dictionary format expected by OpenAI's Chat API.
587

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

594
        :raises ValueError:
595
            If the message format is invalid, or if `require_tool_call_ids` is True and any Tool Call is missing an
596
            `id` attribute.
597
        """
598
        text_contents = self.texts
1✔
599
        tool_calls = self.tool_calls
1✔
600
        tool_call_results = self.tool_call_results
1✔
601
        images = self.images
1✔
602

603
        if not text_contents and not tool_calls and not tool_call_results and not images:
1✔
604
            raise ValueError(
1✔
605
                "A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, "
606
                "`ToolCallResult`, or `ImageContent`."
607
            )
608
        if len(tool_call_results) > 0 and len(self._content) > 1:
1✔
609
            raise ValueError(
1✔
610
                "For OpenAI compatibility, a `ChatMessage` with a `ToolCallResult` cannot contain any other content."
611
            )
612

613
        openai_msg: dict[str, Any] = {"role": self._role.value}
1✔
614

615
        # Add name field if present
616
        if self._name is not None:
1✔
617
            openai_msg["name"] = self._name
1✔
618

619
        # user message
620
        if openai_msg["role"] == "user":
1✔
621
            if len(self._content) == 1 and isinstance(self._content[0], TextContent):
1✔
622
                openai_msg["content"] = self.text
1✔
623
                return openai_msg
1✔
624

625
            # if the user message contains a list of text and images, OpenAI expects a list of dictionaries
626
            content = []
1✔
627
            for part in self._content:
1✔
628
                if isinstance(part, TextContent):
1✔
629
                    content.append({"type": "text", "text": part.text})
1✔
630
                elif isinstance(part, ImageContent):
1✔
631
                    image_item: dict[str, Any] = {
1✔
632
                        "type": "image_url",
633
                        # If no MIME type is provided, default to JPEG.
634
                        # OpenAI API appears to tolerate MIME type mismatches.
635
                        "image_url": {"url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}"},
636
                    }
637
                    if part.detail:
1✔
638
                        image_item["image_url"]["detail"] = part.detail
1✔
639
                    content.append(image_item)
1✔
640
            openai_msg["content"] = content
1✔
641
            return openai_msg
1✔
642

643
        # tool message
644
        if tool_call_results:
1✔
645
            result = tool_call_results[0]
1✔
646
            openai_msg["content"] = result.result
1✔
647
            if result.origin.id is not None:
1✔
648
                openai_msg["tool_call_id"] = result.origin.id
1✔
649
            elif require_tool_call_ids:
1✔
650
                raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
1✔
651
            # OpenAI does not provide a way to communicate errors in tool invocations, so we ignore the error field
652
            return openai_msg
1✔
653

654
        # system and assistant messages
655
        # OpenAI Chat Completions API does not support reasoning content, so we ignore it
656
        if text_contents:
1✔
657
            openai_msg["content"] = text_contents[0]
1✔
658
        if tool_calls:
1✔
659
            openai_tool_calls = []
1✔
660
            for tc in tool_calls:
1✔
661
                openai_tool_call = {
1✔
662
                    "type": "function",
663
                    # We disable ensure_ascii so special chars like emojis are not converted
664
                    "function": {"name": tc.tool_name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)},
665
                }
666
                if tc.id is not None:
1✔
667
                    openai_tool_call["id"] = tc.id
1✔
668
                elif require_tool_call_ids:
1✔
669
                    raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
1✔
670
                openai_tool_calls.append(openai_tool_call)
1✔
671
            openai_msg["tool_calls"] = openai_tool_calls
1✔
672
        return openai_msg
1✔
673

674
    @staticmethod
1✔
675
    def _validate_openai_message(message: dict[str, Any]) -> None:
1✔
676
        """
677
        Validate that a message dictionary follows OpenAI's Chat API format.
678

679
        :param message: The message dictionary to validate
680
        :raises ValueError: If the message format is invalid
681
        """
682
        if "role" not in message:
1✔
683
            raise ValueError("The `role` field is required in the message dictionary.")
1✔
684

685
        role = message["role"]
1✔
686
        content = message.get("content")
1✔
687
        tool_calls = message.get("tool_calls")
1✔
688

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

692
        if role == "assistant":
1✔
693
            if not content and not tool_calls:
1✔
694
                raise ValueError("For assistant messages, either `content` or `tool_calls` must be present.")
1✔
695
            if tool_calls:
1✔
696
                for tc in tool_calls:
1✔
697
                    if "function" not in tc:
1✔
698
                        raise ValueError("Tool calls must contain the `function` field")
1✔
699
        elif not content:
1✔
700
            raise ValueError(f"The `content` field is required for {role} messages.")
1✔
701

702
    @classmethod
1✔
703
    def from_openai_dict_format(cls, message: dict[str, Any]) -> "ChatMessage":
1✔
704
        """
705
        Create a ChatMessage from a dictionary in the format expected by OpenAI's Chat API.
706

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

712
        :param message:
713
            The OpenAI dictionary to build the ChatMessage object.
714
        :returns:
715
            The created ChatMessage object.
716

717
        :raises ValueError:
718
            If the message dictionary is missing required fields.
719
        """
720
        cls._validate_openai_message(message)
1✔
721

722
        role = message["role"]
1✔
723
        content = message.get("content")
1✔
724
        name = message.get("name")
1✔
725
        tool_calls = message.get("tool_calls")
1✔
726
        tool_call_id = message.get("tool_call_id")
1✔
727

728
        if role == "assistant":
1✔
729
            haystack_tool_calls = None
1✔
730
            if tool_calls:
1✔
731
                haystack_tool_calls = []
1✔
732
                for tc in tool_calls:
1✔
733
                    haystack_tc = ToolCall(
1✔
734
                        id=tc.get("id"),
735
                        tool_name=tc["function"]["name"],
736
                        arguments=json.loads(tc["function"]["arguments"]),
737
                    )
738
                    haystack_tool_calls.append(haystack_tc)
1✔
739
            return cls.from_assistant(text=content, name=name, tool_calls=haystack_tool_calls)
1✔
740

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

743
        if role == "user":
1✔
744
            return cls.from_user(text=content, name=name)
1✔
745
        if role in ["system", "developer"]:
1✔
746
            return cls.from_system(text=content, name=name)
1✔
747

748
        return cls.from_tool(
1✔
749
            tool_result=content, origin=ToolCall(id=tool_call_id, tool_name="", arguments={}), error=False
750
        )
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

© 2025 Coveralls, Inc