Coverage for haystack/dataclasses/chat_message.py: 99%

341 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import json 

6from collections.abc import Sequence 

7from dataclasses import asdict, dataclass, field 

8from enum import Enum 

9from typing import Any 

10 

11from haystack import logging 

12from haystack.dataclasses.file_content import FileContent 

13from haystack.dataclasses.image_content import ImageContent 

14from haystack.utils.dataclasses import _warn_on_inplace_mutation 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class ChatRole(str, Enum): 

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" 

26 

27 #: The system role. A message from the system contains only text. 

28 SYSTEM = "system" 

29 

30 #: The assistant role. A message from the assistant can contain text and Tool calls. It can also store metadata. 

31 ASSISTANT = "assistant" 

32 

33 #: The tool role. A message from a tool contains the result of a Tool invocation. 

34 TOOL = "tool" 

35 

36 @staticmethod 

37 def from_str(string: str) -> "ChatRole": 

38 """ 

39 Convert a string to a ChatRole enum. 

40 """ 

41 enum_map = {e.value: e for e in ChatRole} 

42 role = enum_map.get(string) 

43 if role is None: 

44 msg = f"Unknown chat role '{string}'. Supported roles are: {list(enum_map.keys())}" 

45 raise ValueError(msg) 

46 return role 

47 

48 

49@_warn_on_inplace_mutation 

50@dataclass 

51class TextContent: 

52 """ 

53 The textual content of a chat message. 

54 

55 :param text: The text content of the message. 

56 """ 

57 

58 text: str 

59 

60 def to_dict(self) -> dict[str, Any]: 

61 """ 

62 Convert TextContent into a dictionary. 

63 """ 

64 return asdict(self) 

65 

66 @classmethod 

67 def from_dict(cls, data: dict[str, Any]) -> "TextContent": 

68 """ 

69 Create a TextContent from a dictionary. 

70 """ 

71 return TextContent(**data) 

72 

73 

74@_warn_on_inplace_mutation 

75@dataclass 

76class ToolCall: 

77 """ 

78 Represents a Tool call prepared by the model, usually contained in an assistant message. 

79 

80 :param id: The ID of the Tool call. 

81 :param tool_name: The name of the Tool to call. 

82 :param arguments: The arguments to call the Tool with. 

83 :param extra: Dictionary of extra information about the Tool call. Use to store provider-specific 

84 information. To avoid serialization issues, values should be JSON serializable. 

85 """ 

86 

87 tool_name: str 

88 arguments: dict[str, Any] 

89 id: str | None = None # noqa: A003 

90 extra: dict[str, Any] | None = None 

91 

92 def to_dict(self) -> dict[str, Any]: 

93 """ 

94 Convert ToolCall into a dictionary. 

95 

96 :returns: A dictionary with keys 'tool_name', 'arguments', 'id', and 'extra'. 

97 """ 

98 return asdict(self) 

99 

100 @classmethod 

101 def from_dict(cls, data: dict[str, Any]) -> "ToolCall": 

102 """ 

103 Creates a new ToolCall object from a dictionary. 

104 

105 :param data: 

106 The dictionary to build the ToolCall object. 

107 :returns: 

108 The created object. 

109 """ 

110 return ToolCall(**data) 

111 

112 

113ToolCallResultContentT = str | Sequence[TextContent | ImageContent | FileContent] 

114 

115 

116@_warn_on_inplace_mutation 

117@dataclass 

118class ToolCallResult: 

119 """ 

120 Represents the result of a Tool invocation. 

121 

122 :param result: The result of the Tool invocation. 

123 :param origin: The Tool call that produced this result. 

124 :param error: Whether the Tool invocation resulted in an error. 

125 """ 

126 

127 result: ToolCallResultContentT 

128 origin: ToolCall 

129 error: bool 

130 

131 def to_dict(self) -> dict[str, Any]: 

132 """ 

133 Converts ToolCallResult into a dictionary. 

134 

135 :returns: A dictionary with keys 'result', 'origin', and 'error'. 

136 """ 

137 serialized = asdict(self) 

138 if isinstance(self.result, list): 

139 if not all(isinstance(part, (TextContent, ImageContent, FileContent)) for part in self.result): 

140 raise ValueError( 

141 "ToolCallResult result must be a string or a list of TextContent, ImageContent, or FileContent" 

142 ) 

143 serialized["result"] = [_serialize_content_part(part) for part in self.result] 

144 return serialized 

145 

146 @classmethod 

147 def from_dict(cls, data: dict[str, Any]) -> "ToolCallResult": 

148 """ 

149 Creates a ToolCallResult from a dictionary. 

150 

151 :param data: 

152 The dictionary to build the ToolCallResult object. 

153 :returns: 

154 The created object. 

155 """ 

156 if not all(x in data for x in ["result", "origin", "error"]): 

157 raise ValueError( 

158 "Fields `result`, `origin`, `error` are required for ToolCallResult deserialization. " 

159 f"Received dictionary with keys {list(data.keys())}" 

160 ) 

161 

162 result = data["result"] 

163 if isinstance(result, list): 

164 result = [_deserialize_content_part(part) for part in result] 

165 

166 return ToolCallResult(result=result, origin=ToolCall.from_dict(data["origin"]), error=data["error"]) 

167 

168 

169@_warn_on_inplace_mutation 

170@dataclass 

171class ReasoningContent: 

172 """ 

173 Represents the optional reasoning content prepared by the model, usually contained in an assistant message. 

174 

175 :param reasoning_text: The reasoning text produced by the model. 

176 :param extra: Dictionary of extra information about the reasoning content. Use to store provider-specific 

177 information. To avoid serialization issues, values should be JSON serializable. 

178 """ 

179 

180 reasoning_text: str 

181 extra: dict[str, Any] = field(default_factory=dict) 

182 

183 def to_dict(self) -> dict[str, Any]: 

184 """ 

185 Convert ReasoningContent into a dictionary. 

186 

187 :returns: A dictionary with keys 'reasoning_text', and 'extra'. 

188 """ 

189 return asdict(self) 

190 

191 @classmethod 

192 def from_dict(cls, data: dict[str, Any]) -> "ReasoningContent": 

193 """ 

194 Creates a new ReasoningContent object from a dictionary. 

195 

196 :param data: 

197 The dictionary to build the ReasoningContent object. 

198 :returns: 

199 The created object. 

200 """ 

201 return ReasoningContent(**data) 

202 

203 

204ChatMessageContentT = TextContent | ToolCall | ToolCallResult | ImageContent | ReasoningContent | FileContent 

205 

206_CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS: dict[type[ChatMessageContentT], str] = { 

207 TextContent: "text", 

208 ToolCall: "tool_call", 

209 ToolCallResult: "tool_call_result", 

210 ImageContent: "image", 

211 ReasoningContent: "reasoning", 

212 FileContent: "file", 

213} 

214 

215 

216def _deserialize_content_part(part: dict[str, Any]) -> ChatMessageContentT: 

217 """ 

218 Deserialize a single content part of a serialized ChatMessage. 

219 

220 :param part: 

221 A dictionary representing a single content part of a serialized ChatMessage. 

222 :returns: 

223 A ChatMessageContentT object. 

224 :raises ValueError: 

225 If the part is not a valid ChatMessageContentT object. 

226 """ 

227 # handle flat text format separately 

228 if "text" in part: 

229 return TextContent.from_dict(part) 

230 

231 for cls, serialization_key in _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.items(): 

232 if serialization_key in part: 

233 return cls.from_dict(part[serialization_key]) 

234 

235 # Support for Pydantic's model_dump() output, which produces a flat dictionary without wrapping keys. 

236 if "tool_name" in part and "arguments" in part: 

237 return ToolCall.from_dict(part) 

238 if "result" in part and "origin" in part: 

239 return ToolCallResult.from_dict(part) 

240 if "reasoning_text" in part: 

241 return ReasoningContent.from_dict(part) 

242 if "base64_image" in part: 

243 return ImageContent.from_dict(part) 

244 if "base64_data" in part: 

245 return FileContent.from_dict(part) 

246 

247 # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs 

248 msg = ( 

249 f"Unsupported content part in the serialized ChatMessage: {part}. " 

250 "The `content` field of the serialized ChatMessage must be a list of dictionaries, where each dictionary " 

251 "contains one of these keys: 'text', 'image', 'file', 'reasoning', 'tool_call', or 'tool_call_result'. " 

252 "Valid formats: [{'text': 'Hello'}, {'image': {'base64_image': '...', ...}}, " 

253 "{'file': {'base64_data': '...', ...}}, {'reasoning': {'reasoning_text': 'I think...', 'extra': {...}}}, " 

254 "{'tool_call': {'tool_name': 'search', 'arguments': {}, 'id': 'call_123'}}, " 

255 "{'tool_call_result': {'result': 'data', 'origin': {...}, 'error': false}}]" 

256 ) 

257 raise ValueError(msg) 

258 

259 

260def _serialize_content_part(part: ChatMessageContentT) -> dict[str, Any]: 

261 """ 

262 Serialize a single content part of a ChatMessage. 

263 

264 :param part: 

265 A ChatMessageContentT object. 

266 :returns: 

267 A dictionary representing the content part. 

268 :raises TypeError: 

269 If the part is not a valid ChatMessageContentT object. 

270 """ 

271 serialization_key = _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.get(type(part)) 

272 if serialization_key is None: 

273 raise TypeError(f"Unsupported type in ChatMessage content: `{type(part).__name__}` for `{part}`.") 

274 

275 # handle flat text format separately 

276 if isinstance(part, TextContent): 

277 return part.to_dict() 

278 

279 return {serialization_key: part.to_dict()} 

280 

281 

282@_warn_on_inplace_mutation 

283@dataclass 

284class ChatMessage: 

285 """ 

286 Represents a message in a LLM chat conversation. 

287 

288 Use the `from_assistant`, `from_user`, `from_system`, and `from_tool` class methods to create a ChatMessage. 

289 """ 

290 

291 _role: ChatRole 

292 _content: Sequence[ChatMessageContentT] 

293 _name: str | None = None 

294 _meta: dict[str, Any] = field(default_factory=dict, hash=False) 

295 

296 def __len__(self) -> int: 

297 return len(self._content) 

298 

299 @property 

300 def role(self) -> ChatRole: 

301 """ 

302 Returns the role of the entity sending the message. 

303 """ 

304 return self._role 

305 

306 @property 

307 def meta(self) -> dict[str, Any]: 

308 """ 

309 Returns the metadata associated with the message. 

310 """ 

311 return self._meta 

312 

313 @property 

314 def name(self) -> str | None: 

315 """ 

316 Returns the name associated with the message. 

317 """ 

318 return self._name 

319 

320 @property 

321 def texts(self) -> list[str]: 

322 """ 

323 Returns the list of all texts contained in the message. 

324 """ 

325 return [content.text for content in self._content if isinstance(content, TextContent)] 

326 

327 @property 

328 def text(self) -> str | None: 

329 """ 

330 Returns the first text contained in the message. 

331 """ 

332 if texts := self.texts: 

333 return texts[0] 

334 return None 

335 

336 @property 

337 def tool_calls(self) -> list[ToolCall]: 

338 """ 

339 Returns the list of all Tool calls contained in the message. 

340 """ 

341 return [content for content in self._content if isinstance(content, ToolCall)] 

342 

343 @property 

344 def tool_call(self) -> ToolCall | None: 

345 """ 

346 Returns the first Tool call contained in the message. 

347 """ 

348 if tool_calls := self.tool_calls: 

349 return tool_calls[0] 

350 return None 

351 

352 @property 

353 def tool_call_results(self) -> list[ToolCallResult]: 

354 """ 

355 Returns the list of all Tool call results contained in the message. 

356 """ 

357 return [content for content in self._content if isinstance(content, ToolCallResult)] 

358 

359 @property 

360 def tool_call_result(self) -> ToolCallResult | None: 

361 """ 

362 Returns the first Tool call result contained in the message. 

363 """ 

364 if tool_call_results := self.tool_call_results: 

365 return tool_call_results[0] 

366 return None 

367 

368 @property 

369 def images(self) -> list[ImageContent]: 

370 """ 

371 Returns the list of all images contained in the message. 

372 """ 

373 return [content for content in self._content if isinstance(content, ImageContent)] 

374 

375 @property 

376 def image(self) -> ImageContent | None: 

377 """ 

378 Returns the first image contained in the message. 

379 """ 

380 if images := self.images: 

381 return images[0] 

382 return None 

383 

384 @property 

385 def files(self) -> list[FileContent]: 

386 """ 

387 Returns the list of all files contained in the message. 

388 """ 

389 return [content for content in self._content if isinstance(content, FileContent)] 

390 

391 @property 

392 def file(self) -> FileContent | None: 

393 """ 

394 Returns the first file contained in the message. 

395 """ 

396 if files := self.files: 

397 return files[0] 

398 return None 

399 

400 @property 

401 def reasonings(self) -> list[ReasoningContent]: 

402 """ 

403 Returns the list of all reasoning contents contained in the message. 

404 """ 

405 return [content for content in self._content if isinstance(content, ReasoningContent)] 

406 

407 @property 

408 def reasoning(self) -> ReasoningContent | None: 

409 """ 

410 Returns the first reasoning content contained in the message. 

411 """ 

412 if reasonings := self.reasonings: 

413 return reasonings[0] 

414 return None 

415 

416 def is_from(self, role: ChatRole | str) -> bool: 

417 """ 

418 Check if the message is from a specific role. 

419 

420 :param role: The role to check against. 

421 :returns: True if the message is from the specified role, False otherwise. 

422 """ 

423 if isinstance(role, str): 

424 role = ChatRole.from_str(role) 

425 return self._role == role 

426 

427 @classmethod 

428 def from_user( 

429 cls, 

430 text: str | None = None, 

431 meta: dict[str, Any] | None = None, 

432 name: str | None = None, 

433 *, 

434 content_parts: Sequence[TextContent | str | ImageContent | FileContent] | None = None, 

435 ) -> "ChatMessage": 

436 """ 

437 Create a message from the user. 

438 

439 :param text: The text content of the message. Specify this or content_parts. 

440 :param meta: Additional metadata associated with the message. 

441 :param name: An optional name for the participant. This field is only supported by OpenAI. 

442 :param content_parts: A list of content parts to include in the message. Specify this or text. 

443 :returns: A new ChatMessage instance. 

444 :raises ValueError: If neither or both of text and content_parts are provided, or if content_parts is empty. 

445 :raises TypeError: If a content part is not a str, TextContent, ImageContent, or FileContent. 

446 """ 

447 if text is None and content_parts is None: 

448 raise ValueError("Either text or content_parts must be provided.") 

449 if text is not None and content_parts is not None: 

450 raise ValueError("Only one of text or content_parts can be provided.") 

451 

452 content: list[TextContent | ImageContent | FileContent] = [] 

453 

454 if text is not None: 

455 content = [TextContent(text=text)] 

456 elif content_parts is not None: 

457 for part in content_parts: 

458 if isinstance(part, str): 

459 content.append(TextContent(text=part)) 

460 elif isinstance(part, (TextContent, ImageContent, FileContent)): 

461 content.append(part) 

462 else: 

463 raise TypeError(f"The user message must contain only text or image parts. Unsupported part: {part}") 

464 if len(content) == 0: 

465 raise ValueError("The user message must contain at least one content part (text, image, file).") 

466 

467 return cls(_role=ChatRole.USER, _content=content, _meta=meta or {}, _name=name) 

468 

469 @classmethod 

470 def from_system(cls, text: str, meta: dict[str, Any] | None = None, name: str | None = None) -> "ChatMessage": 

471 """ 

472 Create a message from the system. 

473 

474 :param text: The text content of the message. 

475 :param meta: Additional metadata associated with the message. 

476 :param name: An optional name for the participant. This field is only supported by OpenAI. 

477 :returns: A new ChatMessage instance. 

478 """ 

479 return cls(_role=ChatRole.SYSTEM, _content=[TextContent(text=text)], _meta=meta or {}, _name=name) 

480 

481 @classmethod 

482 def from_assistant( 

483 cls, 

484 text: str | None = None, 

485 meta: dict[str, Any] | None = None, 

486 name: str | None = None, 

487 tool_calls: list[ToolCall] | None = None, 

488 *, 

489 reasoning: str | ReasoningContent | None = None, 

490 ) -> "ChatMessage": 

491 """ 

492 Create a message from the assistant. 

493 

494 :param text: The text content of the message. 

495 :param meta: Additional metadata associated with the message. 

496 :param name: An optional name for the participant. This field is only supported by OpenAI. 

497 :param tool_calls: The Tool calls to include in the message. 

498 :param reasoning: The reasoning content to include in the message. 

499 :returns: A new ChatMessage instance. 

500 :raises TypeError: If `reasoning` is not a string or ReasoningContent object. 

501 """ 

502 content: list[ChatMessageContentT] = [] 

503 if reasoning: 

504 if isinstance(reasoning, str): 

505 content.append(ReasoningContent(reasoning_text=reasoning)) 

506 elif isinstance(reasoning, ReasoningContent): 

507 content.append(reasoning) 

508 else: 

509 raise TypeError(f"reasoning must be a string or a ReasoningContent object, got {type(reasoning)}") 

510 if text is not None: 

511 content.append(TextContent(text=text)) 

512 if tool_calls: 

513 content.extend(tool_calls) 

514 

515 return cls(_role=ChatRole.ASSISTANT, _content=content, _meta=meta or {}, _name=name) 

516 

517 @classmethod 

518 def from_tool( 

519 cls, 

520 tool_result: ToolCallResultContentT, 

521 origin: ToolCall, 

522 error: bool = False, 

523 meta: dict[str, Any] | None = None, 

524 ) -> "ChatMessage": 

525 """ 

526 Create a message from a Tool. 

527 

528 :param tool_result: The result of the Tool invocation. 

529 :param origin: The Tool call that produced this result. 

530 :param error: Whether the Tool invocation resulted in an error. 

531 :param meta: Additional metadata associated with the message. 

532 :returns: A new ChatMessage instance. 

533 """ 

534 return cls( 

535 _role=ChatRole.TOOL, 

536 _content=[ToolCallResult(result=tool_result, origin=origin, error=error)], 

537 _meta=meta or {}, 

538 ) 

539 

540 def to_dict(self) -> dict[str, Any]: 

541 """ 

542 Converts ChatMessage into a dictionary. 

543 

544 :returns: 

545 Serialized version of the object. 

546 """ 

547 

548 serialized: dict[str, Any] = {} 

549 serialized["role"] = self._role.value 

550 serialized["meta"] = self._meta 

551 serialized["name"] = self._name 

552 

553 serialized["content"] = [_serialize_content_part(part) for part in self._content] 

554 return serialized 

555 

556 def _to_trace_dict(self) -> dict[str, Any]: 

557 """ 

558 Convert the ChatMessage to a dictionary representation for tracing. 

559 

560 For Image Content objects, the base64_image is replaced with a placeholder string to avoid sending large 

561 payloads to the tracing backend. 

562 

563 :returns: 

564 Serialized version of the object only for tracing purposes. 

565 """ 

566 

567 serialized: dict[str, Any] = {} 

568 serialized["role"] = self._role.value 

569 serialized["meta"] = self._meta 

570 serialized["name"] = self._name 

571 

572 serialized["content"] = [] 

573 for part in self._content: 

574 serialized_part = _serialize_content_part(part) 

575 if isinstance(part, ImageContent): 

576 serialized_part["image"] = part._to_trace_dict() 

577 elif isinstance(part, FileContent): 

578 serialized_part["file"] = part._to_trace_dict() 

579 serialized["content"].append(serialized_part) 

580 

581 return serialized 

582 

583 @classmethod 

584 def from_dict(cls, data: dict[str, Any]) -> "ChatMessage": 

585 """ 

586 Creates a new ChatMessage object from a dictionary. 

587 

588 :param data: 

589 The dictionary to build the ChatMessage object. 

590 :returns: 

591 The created object. 

592 :raises ValueError: If the `role` field is missing from the dictionary. 

593 :raises TypeError: If the `content` field is not a list or string. 

594 """ 

595 

596 # NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs 

597 if "role" not in data and "_role" not in data: 

598 raise ValueError( 

599 "The `role` field is required in the message dictionary. " 

600 f"Expected a dictionary with 'role' field containing one of: {[role.value for role in ChatRole]}. " 

601 f"Common roles are 'user' (for user messages) and 'assistant' (for AI responses). " 

602 f"Received dictionary with keys: {list(data.keys())}" 

603 ) 

604 

605 if "content" in data: 

606 init_params: dict[str, Any] = { 

607 "_role": ChatRole(data["role"]), 

608 "_name": data.get("name"), 

609 "_meta": data.get("meta") or {}, 

610 } 

611 

612 if isinstance(data["content"], list): 

613 # current format - the serialized `content` field is a list of dictionaries 

614 init_params["_content"] = [_deserialize_content_part(part) for part in data["content"]] 

615 elif isinstance(data["content"], str): 

616 # pre 2.9.0 format - the `content` field is a string 

617 init_params["_content"] = [TextContent(text=data["content"])] 

618 else: 

619 raise TypeError(f"Unsupported content type in serialized ChatMessage: `{(data['content'])}`") 

620 return cls(**init_params) 

621 

622 if "_content" in data: 

623 # format for versions >=2.9.0 and <2.12.0 - the serialized `_content` field is a list of dictionaries 

624 return cls( 

625 _role=ChatRole(data["_role"]), 

626 _content=[_deserialize_content_part(part) for part in data["_content"]], 

627 _name=data.get("_name"), 

628 _meta=data.get("_meta") or {}, 

629 ) 

630 

631 raise ValueError(f"Missing 'content' or '_content' in serialized ChatMessage: `{data}`") 

632 

633 def to_openai_dict_format(self, require_tool_call_ids: bool = True) -> dict[str, Any]: 

634 """ 

635 Convert a ChatMessage to the dictionary format expected by OpenAI's Chat Completions API. 

636 

637 The `_meta` field of ChatMessage is removed because it is not supported by OpenAI's Chat Completions API. 

638 

639 :param require_tool_call_ids: 

640 If True (default), enforces that each Tool Call includes a non-null `id` attribute. 

641 Set to False to allow Tool Calls without `id`, which may be suitable for shallow OpenAI-compatible APIs. 

642 :returns: 

643 The ChatMessage in the format expected by OpenAI's Chat Completions API. 

644 

645 :raises ValueError: 

646 If the message format is invalid, or if `require_tool_call_ids` is True and any Tool Call is missing an 

647 `id` attribute. 

648 """ 

649 if not self.texts and not self.tool_calls and not self.tool_call_results and not self.images and not self.files: 

650 raise ValueError( 

651 "A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, " 

652 "`ToolCallResult`, `ImageContent`, or `FileContent`." 

653 ) 

654 if len(self.tool_call_results) > 0 and len(self._content) > 1: 

655 raise ValueError( 

656 "For OpenAI compatibility, a `ChatMessage` with a `ToolCallResult` cannot contain any other content." 

657 ) 

658 

659 openai_msg: dict[str, Any] = {"role": self._role.value} 

660 

661 if self._name is not None: 

662 openai_msg["name"] = self._name 

663 

664 if openai_msg["role"] == "user": 

665 return self._user_message_to_openai(openai_msg) 

666 

667 if self.tool_call_results: 

668 return self._tool_result_message_to_openai(openai_msg, require_tool_call_ids) 

669 

670 return self._system_assistant_message_to_openai(openai_msg, require_tool_call_ids) 

671 

672 def _user_message_to_openai(self, openai_msg: dict[str, Any]) -> dict[str, Any]: 

673 """Build OpenAI dict for a user message.""" 

674 if len(self._content) == 1 and isinstance(self._content[0], TextContent): 

675 openai_msg["content"] = self.text 

676 return openai_msg 

677 

678 content = [] 

679 for part in self._content: 

680 if isinstance(part, TextContent): 

681 content.append({"type": "text", "text": part.text}) 

682 elif isinstance(part, ImageContent): 

683 image_item: dict[str, Any] = { 

684 "type": "image_url", 

685 # If no MIME type is provided, default to JPEG. 

686 # OpenAI API appears to tolerate MIME type mismatches. 

687 "image_url": {"url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}"}, 

688 } 

689 if part.detail: 

690 image_item["image_url"]["detail"] = part.detail 

691 content.append(image_item) 

692 elif isinstance(part, FileContent): 

693 file_item: dict[str, Any] = { 

694 "type": "file", 

695 "file": { 

696 "file_data": f"data:{part.mime_type or 'application/pdf'};base64,{part.base64_data}", 

697 # Filename is optional but if not provided, OpenAI expects a file_id of a previous file upload. 

698 # We use a dummy filename. 

699 "filename": part.filename or "filename", 

700 }, 

701 } 

702 content.append(file_item) 

703 openai_msg["content"] = content 

704 return openai_msg 

705 

706 def _tool_result_message_to_openai(self, openai_msg: dict[str, Any], require_tool_call_ids: bool) -> dict[str, Any]: 

707 """Build OpenAI dict for a tool result message.""" 

708 result = self.tool_call_results[0] 

709 if isinstance(result.result, str): 

710 openai_msg["content"] = result.result 

711 # OpenAI Chat Completions API does not support multimodal tool results 

712 elif isinstance(result.result, list) and all(isinstance(part, TextContent) for part in result.result): 

713 openai_msg["content"] = [{"type": "text", "text": part.text} for part in result.result] 

714 else: 

715 raise ValueError( 

716 f"Unsupported tool result: {result}. If you need to pass images in tool results, " 

717 "use OpenAI Responses API instead." 

718 ) 

719 

720 if result.origin.id is not None: 

721 openai_msg["tool_call_id"] = result.origin.id 

722 elif require_tool_call_ids: 

723 raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.") 

724 # OpenAI does not provide a way to communicate errors in tool invocations, so we ignore the error field 

725 return openai_msg 

726 

727 def _system_assistant_message_to_openai( 

728 self, openai_msg: dict[str, Any], require_tool_call_ids: bool 

729 ) -> dict[str, Any]: 

730 """Build OpenAI dict for system and assistant messages.""" 

731 # OpenAI Chat Completions API does not support reasoning content, so we ignore it 

732 if self.texts: 

733 openai_msg["content"] = self.texts[0] 

734 if self.tool_calls: 

735 openai_tool_calls = [] 

736 for tc in self.tool_calls: 

737 openai_tool_call = { 

738 "type": "function", 

739 # We disable ensure_ascii so special chars like emojis are not converted 

740 "function": {"name": tc.tool_name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)}, 

741 } 

742 if tc.id is not None: 

743 openai_tool_call["id"] = tc.id 

744 elif require_tool_call_ids: 

745 raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.") 

746 openai_tool_calls.append(openai_tool_call) 

747 openai_msg["tool_calls"] = openai_tool_calls 

748 return openai_msg 

749 

750 @staticmethod 

751 def _validate_openai_message(message: dict[str, Any]) -> None: 

752 """ 

753 Validate that a message dictionary follows OpenAI's Chat API format. 

754 

755 :param message: The message dictionary to validate 

756 :raises ValueError: If the message format is invalid 

757 """ 

758 if "role" not in message: 

759 raise ValueError("The `role` field is required in the message dictionary.") 

760 

761 role = message["role"] 

762 content = message.get("content") 

763 tool_calls = message.get("tool_calls") 

764 

765 if role not in ["assistant", "user", "system", "developer", "tool"]: 

766 raise ValueError(f"Unsupported role: {role}") 

767 

768 if role == "assistant": 

769 if not content and not tool_calls: 

770 raise ValueError("For assistant messages, either `content` or `tool_calls` must be present.") 

771 if tool_calls: 

772 for tc in tool_calls: 

773 if "function" not in tc: 

774 raise ValueError("Tool calls must contain the `function` field") 

775 elif not content: 

776 raise ValueError(f"The `content` field is required for {role} messages.") 

777 

778 @classmethod 

779 def from_openai_dict_format(cls, message: dict[str, Any]) -> "ChatMessage": 

780 """ 

781 Create a ChatMessage from a dictionary in the format expected by OpenAI's Chat API. 

782 

783 NOTE: While OpenAI's API requires `tool_call_id` in both tool calls and tool messages, this method 

784 accepts messages without it to support shallow OpenAI-compatible APIs. 

785 If you plan to use the resulting ChatMessage with OpenAI, you must include `tool_call_id` or you'll 

786 encounter validation errors. 

787 

788 :param message: 

789 The OpenAI dictionary to build the ChatMessage object. 

790 :returns: 

791 The created ChatMessage object. 

792 

793 :raises ValueError: 

794 If the message dictionary is missing required fields. 

795 """ 

796 cls._validate_openai_message(message) 

797 

798 role = message["role"] 

799 content = message.get("content") 

800 name = message.get("name") 

801 tool_calls = message.get("tool_calls") 

802 tool_call_id = message.get("tool_call_id") 

803 

804 if role == "assistant": 

805 haystack_tool_calls = None 

806 if tool_calls: 

807 haystack_tool_calls = [] 

808 for tc in tool_calls: 

809 # Zero-argument tool calls from OpenAI-compatible servers may send an 

810 # empty string, null, or omit `arguments` entirely; treat all as {}. 

811 raw_arguments = tc["function"].get("arguments") 

812 haystack_tc = ToolCall( 

813 id=tc.get("id"), 

814 tool_name=tc["function"]["name"], 

815 arguments=json.loads(raw_arguments) if raw_arguments else {}, 

816 ) 

817 haystack_tool_calls.append(haystack_tc) 

818 return cls.from_assistant(text=content, name=name, tool_calls=haystack_tool_calls) 

819 

820 assert content is not None # ensured by _validate_openai_message, but we need to make mypy happy 

821 

822 if role == "user": 

823 return cls.from_user(text=content, name=name) 

824 if role in ["system", "developer"]: 

825 return cls.from_system(text=content, name=name) 

826 

827 if isinstance(content, list): 

828 if not all("text" in el for el in content): 

829 raise ValueError("To be used with OpenAI, tool results must be a string or a list of TextContent") 

830 content = [TextContent(text=el["text"]) for el in content] 

831 return cls.from_tool( 

832 tool_result=content, origin=ToolCall(id=tool_call_id, tool_name="", arguments={}), error=False 

833 )