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

420 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-09 14:34 +0000

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

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import json 

6import reprlib 

7from collections.abc import Sequence 

8from dataclasses import asdict, dataclass, field 

9from enum import Enum 

10from typing import Any 

11 

12from haystack import logging 

13from haystack.dataclasses.file_content import FileContent 

14from haystack.dataclasses.image_content import ImageContent 

15from haystack.utils.dataclasses import _warn_on_inplace_mutation 

16 

17logger = logging.getLogger(__name__) 

18 

19# Shows OpenAI content parts in error messages without their base64 payloads. The string limit matches the reprs of 

20# ImageContent and FileContent. 

21_CONTENT_PART_REPR = reprlib.Repr() 

22_CONTENT_PART_REPR.maxstring = 100 

23 

24 

25def _parse_openai_tool_call_arguments(raw_arguments: Any) -> dict[str, Any]: 

26 """ 

27 Parse OpenAI-style tool call arguments into a dictionary. 

28 

29 A dict is returned as is, a missing, null, or empty value becomes `{}`, and anything else is parsed as a 

30 JSON string. 

31 

32 :raises json.JSONDecodeError: If the arguments are not valid JSON. 

33 """ 

34 if isinstance(raw_arguments, dict): 

35 return raw_arguments 

36 return json.loads(raw_arguments) if raw_arguments else {} 

37 

38 

39class ChatRole(str, Enum): 

40 """ 

41 Enumeration representing the roles within a chat. 

42 """ 

43 

44 #: The user role. A message from the user contains only text. 

45 USER = "user" 

46 

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

48 SYSTEM = "system" 

49 

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

51 ASSISTANT = "assistant" 

52 

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

54 TOOL = "tool" 

55 

56 @staticmethod 

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

58 """ 

59 Convert a string to a ChatRole enum. 

60 """ 

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

62 role = enum_map.get(string) 

63 if role is None: 

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

65 raise ValueError(msg) 

66 return role 

67 

68 

69@_warn_on_inplace_mutation 

70@dataclass 

71class TextContent: 

72 """ 

73 The textual content of a chat message. 

74 

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

76 """ 

77 

78 text: str 

79 

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

81 """ 

82 Convert TextContent into a dictionary. 

83 """ 

84 return asdict(self) 

85 

86 @classmethod 

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

88 """ 

89 Create a TextContent from a dictionary. 

90 """ 

91 return TextContent(**data) 

92 

93 

94@_warn_on_inplace_mutation 

95@dataclass 

96class ToolCall: 

97 """ 

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

99 

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

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

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

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

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

105 """ 

106 

107 tool_name: str 

108 arguments: dict[str, Any] 

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

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

111 

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

113 """ 

114 Convert ToolCall into a dictionary. 

115 

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

117 """ 

118 return asdict(self) 

119 

120 @classmethod 

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

122 """ 

123 Creates a new ToolCall object from a dictionary. 

124 

125 :param data: 

126 The dictionary to build the ToolCall object. 

127 :returns: 

128 The created object. 

129 """ 

130 return ToolCall(**data) 

131 

132 

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

134 

135 

136@_warn_on_inplace_mutation 

137@dataclass 

138class ToolCallResult: 

139 """ 

140 Represents the result of a Tool invocation. 

141 

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

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

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

145 """ 

146 

147 result: ToolCallResultContentT 

148 origin: ToolCall 

149 error: bool 

150 

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

152 """ 

153 Converts ToolCallResult into a dictionary. 

154 

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

156 """ 

157 serialized = asdict(self) 

158 if isinstance(self.result, list): 

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

160 raise ValueError( 

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

162 ) 

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

164 return serialized 

165 

166 @classmethod 

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

168 """ 

169 Creates a ToolCallResult from a dictionary. 

170 

171 :param data: 

172 The dictionary to build the ToolCallResult object. 

173 :returns: 

174 The created object. 

175 """ 

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

177 raise ValueError( 

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

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

180 ) 

181 

182 result = data["result"] 

183 if isinstance(result, list): 

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

185 

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

187 

188 

189@_warn_on_inplace_mutation 

190@dataclass 

191class ReasoningContent: 

192 """ 

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

194 

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

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

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

198 """ 

199 

200 reasoning_text: str 

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

202 

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

204 """ 

205 Convert ReasoningContent into a dictionary. 

206 

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

208 """ 

209 return asdict(self) 

210 

211 @classmethod 

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

213 """ 

214 Creates a new ReasoningContent object from a dictionary. 

215 

216 :param data: 

217 The dictionary to build the ReasoningContent object. 

218 :returns: 

219 The created object. 

220 """ 

221 return ReasoningContent(**data) 

222 

223 

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

225 

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

227 TextContent: "text", 

228 ToolCall: "tool_call", 

229 ToolCallResult: "tool_call_result", 

230 ImageContent: "image", 

231 ReasoningContent: "reasoning", 

232 FileContent: "file", 

233} 

234 

235 

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

237 """ 

238 Deserialize a single content part of a serialized ChatMessage. 

239 

240 :param part: 

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

242 :returns: 

243 A ChatMessageContentT object. 

244 :raises ValueError: 

245 If the part is not a valid ChatMessageContentT object. 

246 """ 

247 # handle flat text format separately 

248 if "text" in part: 

249 return TextContent.from_dict(part) 

250 

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

252 if serialization_key in part: 

253 return cls.from_dict(part[serialization_key]) 

254 

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

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

257 return ToolCall.from_dict(part) 

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

259 return ToolCallResult.from_dict(part) 

260 if "reasoning_text" in part: 

261 return ReasoningContent.from_dict(part) 

262 if "base64_image" in part: 

263 return ImageContent.from_dict(part) 

264 if "base64_data" in part: 

265 return FileContent.from_dict(part) 

266 

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

268 msg = ( 

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

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

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

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

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

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

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

276 ) 

277 raise ValueError(msg) 

278 

279 

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

281 """ 

282 Serialize a single content part of a ChatMessage. 

283 

284 :param part: 

285 A ChatMessageContentT object. 

286 :returns: 

287 A dictionary representing the content part. 

288 :raises TypeError: 

289 If the part is not a valid ChatMessageContentT object. 

290 """ 

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

292 if serialization_key is None: 

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

294 

295 # handle flat text format separately 

296 if isinstance(part, TextContent): 

297 return part.to_dict() 

298 

299 return {serialization_key: part.to_dict()} 

300 

301 

302@_warn_on_inplace_mutation 

303@dataclass 

304class ChatMessage: 

305 """ 

306 Represents a message in a LLM chat conversation. 

307 

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

309 """ 

310 

311 _role: ChatRole 

312 _content: Sequence[ChatMessageContentT] 

313 _name: str | None = None 

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

315 

316 def __len__(self) -> int: 

317 return len(self._content) 

318 

319 @property 

320 def role(self) -> ChatRole: 

321 """ 

322 Returns the role of the entity sending the message. 

323 """ 

324 return self._role 

325 

326 @property 

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

328 """ 

329 Returns the metadata associated with the message. 

330 """ 

331 return self._meta 

332 

333 @property 

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

335 """ 

336 Returns the name associated with the message. 

337 """ 

338 return self._name 

339 

340 @property 

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

342 """ 

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

344 """ 

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

346 

347 @property 

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

349 """ 

350 Returns the first text contained in the message. 

351 """ 

352 if texts := self.texts: 

353 return texts[0] 

354 return None 

355 

356 @property 

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

358 """ 

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

360 """ 

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

362 

363 @property 

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

365 """ 

366 Returns the first Tool call contained in the message. 

367 """ 

368 if tool_calls := self.tool_calls: 

369 return tool_calls[0] 

370 return None 

371 

372 @property 

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

374 """ 

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

376 """ 

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

378 

379 @property 

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

381 """ 

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

383 """ 

384 if tool_call_results := self.tool_call_results: 

385 return tool_call_results[0] 

386 return None 

387 

388 @property 

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

390 """ 

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

392 """ 

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

394 

395 @property 

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

397 """ 

398 Returns the first image contained in the message. 

399 """ 

400 if images := self.images: 

401 return images[0] 

402 return None 

403 

404 @property 

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

406 """ 

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

408 """ 

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

410 

411 @property 

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

413 """ 

414 Returns the first file contained in the message. 

415 """ 

416 if files := self.files: 

417 return files[0] 

418 return None 

419 

420 @property 

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

422 """ 

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

424 """ 

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

426 

427 @property 

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

429 """ 

430 Returns the first reasoning content contained in the message. 

431 """ 

432 if reasonings := self.reasonings: 

433 return reasonings[0] 

434 return None 

435 

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

437 """ 

438 Check if the message is from a specific role. 

439 

440 :param role: The role to check against. 

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

442 """ 

443 if isinstance(role, str): 

444 role = ChatRole.from_str(role) 

445 return self._role == role 

446 

447 @classmethod 

448 def from_user( 

449 cls, 

450 text: str | None = None, 

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

452 name: str | None = None, 

453 *, 

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

455 ) -> "ChatMessage": 

456 """ 

457 Create a message from the user. 

458 

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

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

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

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

463 :returns: A new ChatMessage instance. 

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

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

466 """ 

467 if text is None and content_parts is None: 

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

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

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

471 

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

473 

474 if text is not None: 

475 content = [TextContent(text=text)] 

476 elif content_parts is not None: 

477 for part in content_parts: 

478 if isinstance(part, str): 

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

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

481 content.append(part) 

482 else: 

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

484 if len(content) == 0: 

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

486 

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

488 

489 @classmethod 

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

491 """ 

492 Create a message from the system. 

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 :returns: A new ChatMessage instance. 

498 """ 

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

500 

501 @classmethod 

502 def from_assistant( 

503 cls, 

504 text: str | None = None, 

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

506 name: str | None = None, 

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

508 *, 

509 reasoning: str | ReasoningContent | None = None, 

510 ) -> "ChatMessage": 

511 """ 

512 Create a message from the assistant. 

513 

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

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

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

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

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

519 :returns: A new ChatMessage instance. 

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

521 """ 

522 content: list[ChatMessageContentT] = [] 

523 if reasoning: 

524 if isinstance(reasoning, str): 

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

526 elif isinstance(reasoning, ReasoningContent): 

527 content.append(reasoning) 

528 else: 

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

530 if text is not None: 

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

532 if tool_calls: 

533 content.extend(tool_calls) 

534 

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

536 

537 @classmethod 

538 def from_tool( 

539 cls, 

540 tool_result: ToolCallResultContentT, 

541 origin: ToolCall, 

542 error: bool = False, 

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

544 ) -> "ChatMessage": 

545 """ 

546 Create a message from a Tool. 

547 

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

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

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

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

552 :returns: A new ChatMessage instance. 

553 """ 

554 return cls( 

555 _role=ChatRole.TOOL, 

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

557 _meta=meta or {}, 

558 ) 

559 

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

561 """ 

562 Converts ChatMessage into a dictionary. 

563 

564 :returns: 

565 Serialized version of the object. 

566 """ 

567 

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

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

570 serialized["meta"] = self._meta 

571 serialized["name"] = self._name 

572 

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

574 return serialized 

575 

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

577 """ 

578 Convert the ChatMessage to a dictionary representation for tracing. 

579 

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

581 payloads to the tracing backend. 

582 

583 :returns: 

584 Serialized version of the object only for tracing purposes. 

585 """ 

586 

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

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

589 serialized["meta"] = self._meta 

590 serialized["name"] = self._name 

591 

592 serialized["content"] = [] 

593 for part in self._content: 

594 serialized_part = _serialize_content_part(part) 

595 if isinstance(part, ImageContent): 

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

597 elif isinstance(part, FileContent): 

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

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

600 

601 return serialized 

602 

603 @classmethod 

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

605 """ 

606 Creates a new ChatMessage object from a dictionary. 

607 

608 :param data: 

609 The dictionary to build the ChatMessage object. 

610 :returns: 

611 The created object. 

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

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

614 """ 

615 

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

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

618 raise ValueError( 

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

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

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

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

623 ) 

624 

625 if "content" in data: 

626 init_params: dict[str, Any] = { 

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

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

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

630 } 

631 

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

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

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

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

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

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

638 else: 

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

640 return cls(**init_params) 

641 

642 if "_content" in data: 

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

644 return cls( 

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

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

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

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

649 ) 

650 

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

652 

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

654 """ 

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

656 

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

658 The exception is a non-empty string in `meta["refusal"]` of an assistant message. It is sent as a refusal 

659 content part, or as the `refusal` field if the message also has text. If the message also has tool calls, the 

660 refusal is dropped with a warning, because OpenAI ignores the tool calls of a message with a refusal. 

661 

662 :param require_tool_call_ids: 

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

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

665 :returns: 

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

667 

668 :raises ValueError: 

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

670 `id` attribute. 

671 """ 

672 has_content = bool(self.texts or self.tool_calls or self.tool_call_results or self.images or self.files) 

673 # We convert an assistant message with no content part into a message with empty content, which the API accepts 

674 if not has_content and not self.is_from(ChatRole.ASSISTANT): 

675 raise ValueError( 

676 f"A `ChatMessage` from `{self._role.value}` must contain at least one `TextContent`, `ToolCall`, " 

677 "`ToolCallResult`, `ImageContent`, or `FileContent`. Only assistant messages can be empty." 

678 ) 

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

680 raise ValueError( 

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

682 ) 

683 

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

685 

686 if self._name is not None: 

687 openai_msg["name"] = self._name 

688 

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

690 return self._user_message_to_openai(openai_msg) 

691 

692 if self.tool_call_results: 

693 return self._tool_result_message_to_openai(openai_msg, require_tool_call_ids) 

694 

695 return self._system_assistant_message_to_openai(openai_msg, require_tool_call_ids) 

696 

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

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

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

700 openai_msg["content"] = self.text 

701 return openai_msg 

702 

703 content = [] 

704 for part in self._content: 

705 if isinstance(part, TextContent): 

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

707 elif isinstance(part, ImageContent): 

708 image_item: dict[str, Any] = { 

709 "type": "image_url", 

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

711 # OpenAI API appears to tolerate MIME type mismatches. 

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

713 } 

714 if part.detail: 

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

716 content.append(image_item) 

717 elif isinstance(part, FileContent): 

718 file_item: dict[str, Any] = { 

719 "type": "file", 

720 "file": { 

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

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

723 # We use a dummy filename. 

724 "filename": part.filename or "filename", 

725 }, 

726 } 

727 content.append(file_item) 

728 openai_msg["content"] = content 

729 return openai_msg 

730 

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

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

733 result = self.tool_call_results[0] 

734 if isinstance(result.result, str): 

735 openai_msg["content"] = result.result 

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

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

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

739 else: 

740 raise ValueError( 

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

742 "use OpenAI Responses API instead." 

743 ) 

744 

745 if result.origin.id is not None: 

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

747 elif require_tool_call_ids: 

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

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

750 return openai_msg 

751 

752 def _system_assistant_message_to_openai( 

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

754 ) -> dict[str, Any]: 

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

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

757 if self.texts: 

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

759 refusal = self._meta.get("refusal") if self.is_from(ChatRole.ASSISTANT) else None 

760 # Only a string is a refusal. Any other value under this key is ordinary metadata, which isn't sent. 

761 if not isinstance(refusal, str): 

762 refusal = None 

763 elif refusal and self.tool_calls: 

764 logger.warning( 

765 "Dropping the refusal of an assistant message that also has tool calls, because OpenAI ignores the " 

766 "tool calls of a message with a refusal and then rejects the tool results that follow." 

767 ) 

768 refusal = None 

769 # OpenAI's content array holds either text parts or exactly one refusal part, so a refusal next to text goes 

770 # into the `refusal` field. 

771 if refusal and "content" in openai_msg: 

772 openai_msg["refusal"] = refusal 

773 elif refusal: 

774 openai_msg["content"] = [{"type": "refusal", "refusal": refusal}] 

775 if self.tool_calls: 

776 openai_tool_calls = [] 

777 for tc in self.tool_calls: 

778 openai_tool_call = { 

779 "type": "function", 

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

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

782 } 

783 if tc.id is not None: 

784 openai_tool_call["id"] = tc.id 

785 elif require_tool_call_ids: 

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

787 openai_tool_calls.append(openai_tool_call) 

788 openai_msg["tool_calls"] = openai_tool_calls 

789 # The API rejects a message carrying neither content nor tool calls, so send empty content for it. 

790 if "content" not in openai_msg and "tool_calls" not in openai_msg: 

791 openai_msg["content"] = "" 

792 return openai_msg 

793 

794 @staticmethod 

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

796 """ 

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

798 

799 :param message: The message dictionary to validate 

800 :raises ValueError: If the message format is invalid 

801 """ 

802 if "role" not in message: 

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

804 

805 role = message["role"] 

806 content = message.get("content") 

807 tool_calls = message.get("tool_calls") 

808 

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

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

811 

812 if content is not None and not isinstance(content, (str, list)): 

813 raise ValueError( 

814 f"The `content` field must be a string or a list of content parts, got {type(content).__name__}." 

815 ) 

816 

817 if role == "assistant": 

818 refusal = message.get("refusal") 

819 if refusal is not None and not isinstance(refusal, str): 

820 raise ValueError(f"The `refusal` field must be a string, got {type(refusal).__name__}.") 

821 # An empty string is valid content for an assistant message: that is how a reply with nothing to send 

822 # is serialized. Other falsy content requires tool calls or a refusal. 

823 if not content and content != "" and not tool_calls and not refusal: 

824 raise ValueError( 

825 "For assistant messages, either `content`, `tool_calls`, or `refusal` must be present." 

826 ) 

827 if tool_calls: 

828 for tc in tool_calls: 

829 if "function" not in tc: 

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

831 elif not content: 

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

833 

834 @staticmethod 

835 def _parse_openai_data_url(data_url: Any) -> tuple[str | None, str]: 

836 """ 

837 Split a base64 data URL in OpenAI format into its MIME type and base64 payload. 

838 

839 :param data_url: A data URL in the format `data:<mime_type>;base64,<base64_data>`. 

840 :returns: A tuple containing the MIME type (or None if absent) and the base64 data. 

841 :raises ValueError: If the URL is not a string or not a base64 data URL. 

842 """ 

843 if not isinstance(data_url, str) or not data_url.startswith("data:") or ";base64," not in data_url: 

844 raise ValueError( 

845 f"Unsupported URL: {_CONTENT_PART_REPR.repr(data_url)}. Only base64 data URLs in the format " 

846 "`data:<mime_type>;base64,<base64_data>` are supported." 

847 ) 

848 header, base64_data = data_url.split(";base64,", 1) 

849 return header[len("data:") :] or None, base64_data 

850 

851 @classmethod 

852 def _from_openai_content_parts(cls, content: list[Any]) -> list[TextContent | ImageContent | FileContent]: 

853 """ 

854 Convert a list of content parts in OpenAI format into Haystack content parts. 

855 

856 :param content: A list of content parts in OpenAI format. 

857 :returns: A list of TextContent, ImageContent, and FileContent objects. 

858 :raises ValueError: If a content part is malformed or of an unsupported type. 

859 """ 

860 parts: list[TextContent | ImageContent | FileContent] = [] 

861 for part in content: 

862 part_type = part.get("type") if isinstance(part, dict) else None 

863 if part_type == "text": 

864 if not isinstance(part.get("text"), str): 

865 raise ValueError( 

866 f"Unsupported text content part: {_CONTENT_PART_REPR.repr(part)}. " 

867 "Text parts must contain a `text` string." 

868 ) 

869 parts.append(TextContent(text=part["text"])) 

870 elif part_type == "image_url": 

871 image_url = part.get("image_url") 

872 if not isinstance(image_url, dict): 

873 raise ValueError( 

874 f"Unsupported image content part: {_CONTENT_PART_REPR.repr(part)}. " 

875 "Image parts must contain an `image_url` object." 

876 ) 

877 mime_type, base64_image = cls._parse_openai_data_url(image_url.get("url")) 

878 parts.append( 

879 ImageContent(base64_image=base64_image, mime_type=mime_type, detail=image_url.get("detail")) 

880 ) 

881 elif part_type == "file": 

882 file = part.get("file") 

883 if not isinstance(file, dict): 

884 raise ValueError( 

885 f"Unsupported file content part: {_CONTENT_PART_REPR.repr(part)}. " 

886 "File parts must contain a `file` object." 

887 ) 

888 file_data = file.get("file_data") 

889 if not file_data: 

890 raise ValueError( 

891 f"Unsupported file content part: {_CONTENT_PART_REPR.repr(part)}. Only files with inline " 

892 "base64 `file_data` are supported: files referenced by `file_id` cannot be converted." 

893 ) 

894 mime_type, base64_data = cls._parse_openai_data_url(file_data) 

895 parts.append(FileContent(base64_data=base64_data, mime_type=mime_type, filename=file.get("filename"))) 

896 else: 

897 raise ValueError( 

898 f"Unsupported content part: {_CONTENT_PART_REPR.repr(part)}. " 

899 "Supported part types are `text`, `image_url`, and `file`." 

900 ) 

901 return parts 

902 

903 @staticmethod 

904 def _join_openai_text_parts(content: list[Any], role: str) -> dict[str, str]: 

905 """ 

906 Join the text and refusal content parts of a system, developer, or assistant message in OpenAI format. 

907 

908 Text parts are joined into a single text because `to_openai_dict_format` only sends the first text of these 

909 messages. Refusal parts are accepted only in assistant messages and are joined separately, so a refusal never 

910 ends up in the text. 

911 

912 :param content: A list of content parts in OpenAI format. 

913 :param role: The role of the message, which decides whether refusal parts are accepted. 

914 :returns: A dictionary that maps each part type in `content` to the texts of its parts, joined with a newline. 

915 :raises ValueError: If a content part is not a text part with a `text` string, or, in an assistant message, a 

916 refusal part with a `refusal` string. 

917 """ 

918 allowed_types = ("text", "refusal") if role == "assistant" else ("text",) 

919 texts: dict[str, list[str]] = {} 

920 for part in content: 

921 # In OpenAI format, a part's text sits under a key named after its type. 

922 if ( 

923 not isinstance(part, dict) 

924 or part.get("type") not in allowed_types 

925 or not isinstance(part.get(part["type"]), str) 

926 ): 

927 supported = " and ".join(f"`{t}` parts with a `{t}` string" for t in allowed_types) 

928 raise ValueError( 

929 f"Unsupported content part in {role} message: {_CONTENT_PART_REPR.repr(part)}. " 

930 f"Only {supported} are supported." 

931 ) 

932 texts.setdefault(part["type"], []).append(part[part["type"]]) 

933 return {part_type: "\n".join(part_texts) for part_type, part_texts in texts.items()} 

934 

935 @classmethod 

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

937 """ 

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

939 

940 `content` can be a string or a list of content parts. In user messages, `text` parts become `TextContent`, 

941 `image_url` parts with a base64 data URL become `ImageContent`, and `file` parts with inline `file_data` become 

942 `FileContent`. System, developer, and assistant messages accept `text` parts, which are joined with a newline 

943 into a single text. An assistant's refusal, given as a `refusal` part or as the `refusal` field, is stored in 

944 `meta["refusal"]` instead of the text. 

945 

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

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

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

949 encounter validation errors. 

950 

951 :param message: 

952 The OpenAI dictionary to build the ChatMessage object. 

953 :returns: 

954 The created ChatMessage object. 

955 

956 :raises ValueError: 

957 If the message dictionary is missing required fields, if `content` is neither a string nor a list, or if 

958 it contains content parts that can't be converted, such as image URLs that are not base64 data URLs or 

959 files referenced by `file_id`. 

960 """ 

961 cls._validate_openai_message(message) 

962 

963 role = message["role"] 

964 content = message.get("content") 

965 name = message.get("name") 

966 tool_calls = message.get("tool_calls") 

967 tool_call_id = message.get("tool_call_id") 

968 

969 if role == "assistant": 

970 haystack_tool_calls = None 

971 if tool_calls: 

972 haystack_tool_calls = [] 

973 for tc in tool_calls: 

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

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

976 # Some servers also send a parsed dict instead of a JSON string. 

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

978 haystack_tc = ToolCall( 

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

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

981 arguments=_parse_openai_tool_call_arguments(raw_arguments), 

982 ) 

983 haystack_tool_calls.append(haystack_tc) 

984 text = content 

985 refusals = [message["refusal"]] if message.get("refusal") else [] 

986 if isinstance(content, list): 

987 joined = cls._join_openai_text_parts(content=content, role=role) 

988 text = joined.get("text") 

989 if "refusal" in joined: 

990 refusals.append(joined["refusal"]) 

991 # ChatMessage has no refusal content, so the refusal goes to meta, from which to_openai_dict_format 

992 # sends it back. dict.fromkeys keeps a refusal sent both as the field and as a part only once. 

993 meta = {"refusal": "\n".join(dict.fromkeys(refusals))} if refusals else None 

994 return cls.from_assistant(text=text, meta=meta, name=name, tool_calls=haystack_tool_calls) 

995 

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

997 

998 if role == "user": 

999 if isinstance(content, str): 

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

1001 return cls.from_user(content_parts=cls._from_openai_content_parts(content), name=name) 

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

1003 if isinstance(content, list): 

1004 content = cls._join_openai_text_parts(content=content, role=role)["text"] 

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

1006 

1007 if isinstance(content, list): 

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

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

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

1011 return cls.from_tool( 

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

1013 )