chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import sys
from typing import TYPE_CHECKING
from lazy_imports import LazyImporter
_import_structure = {
"answer": ["Answer", "ExtractedAnswer", "GeneratedAnswer"],
"breakpoints": ["Breakpoint", "PipelineSnapshot", "PipelineState"],
"byte_stream": ["ByteStream"],
"chat_message": ["ChatMessage", "ChatRole", "ReasoningContent", "TextContent", "ToolCall", "ToolCallResult"],
"image_content": ["ImageContent"],
"file_content": ["FileContent"],
"document": ["Document"],
"skill_info": ["SkillInfo"],
"sparse_embedding": ["SparseEmbedding"],
"streaming_chunk": [
"AsyncStreamingCallbackT",
"ComponentInfo",
"FinishReason",
"StreamingCallbackT",
"StreamingChunk",
"SyncStreamingCallbackT",
"ToolCallDelta",
"select_streaming_callback",
],
}
if TYPE_CHECKING:
from .answer import Answer as Answer
from .answer import ExtractedAnswer as ExtractedAnswer
from .answer import GeneratedAnswer as GeneratedAnswer
from .breakpoints import Breakpoint as Breakpoint
from .breakpoints import PipelineSnapshot as PipelineSnapshot
from .breakpoints import PipelineState as PipelineState
from .byte_stream import ByteStream as ByteStream
from .chat_message import ChatMessage as ChatMessage
from .chat_message import ChatRole as ChatRole
from .chat_message import ReasoningContent as ReasoningContent
from .chat_message import TextContent as TextContent
from .chat_message import ToolCall as ToolCall
from .chat_message import ToolCallResult as ToolCallResult
from .document import Document as Document
from .file_content import FileContent as FileContent
from .image_content import ImageContent as ImageContent
from .skill_info import SkillInfo as SkillInfo
from .sparse_embedding import SparseEmbedding as SparseEmbedding
from .streaming_chunk import AsyncStreamingCallbackT as AsyncStreamingCallbackT
from .streaming_chunk import ComponentInfo as ComponentInfo
from .streaming_chunk import FinishReason as FinishReason
from .streaming_chunk import StreamingCallbackT as StreamingCallbackT
from .streaming_chunk import StreamingChunk as StreamingChunk
from .streaming_chunk import SyncStreamingCallbackT as SyncStreamingCallbackT
from .streaming_chunk import ToolCallDelta as ToolCallDelta
from .streaming_chunk import select_streaming_callback as select_streaming_callback
else:
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
+156
View File
@@ -0,0 +1,156 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from dataclasses import asdict, dataclass, field
from typing import Any, Optional, Protocol, runtime_checkable
from haystack.core.serialization import default_from_dict, default_to_dict
from haystack.dataclasses import ChatMessage, Document
from haystack.utils.dataclasses import _warn_on_inplace_mutation
@runtime_checkable
@dataclass
class Answer(Protocol):
data: Any
query: str
meta: dict[str, Any]
def to_dict(self) -> dict[str, Any]: # noqa: D102
...
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Answer": # noqa: D102
...
@_warn_on_inplace_mutation
@dataclass
class ExtractedAnswer:
"""
Holds an answer extracted by an extractive Reader (query, score, text, and optional document/context).
"""
query: str
score: float
data: str | None = None
document: Document | None = None
context: str | None = None
document_offset: Optional["Span"] = None
context_offset: Optional["Span"] = None
meta: dict[str, Any] = field(default_factory=dict)
@_warn_on_inplace_mutation
@dataclass
class Span:
start: int
end: int
def to_dict(self) -> dict[str, Any]:
"""
Serialize the object to a dictionary.
:returns:
Serialized dictionary representation of the object.
"""
document = self.document.to_dict(flatten=False) if self.document is not None else None
document_offset = asdict(self.document_offset) if self.document_offset is not None else None
context_offset = asdict(self.context_offset) if self.context_offset is not None else None
return default_to_dict(
self,
data=self.data,
query=self.query,
document=document,
context=self.context,
score=self.score,
document_offset=document_offset,
context_offset=context_offset,
meta=self.meta,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ExtractedAnswer":
"""
Deserialize the object from a dictionary.
:param data:
Dictionary representation of the object.
:returns:
Deserialized object.
"""
# Shallow-copy the init parameters so `from_dict` stays side-effect free: the nested
# replacements below otherwise mutate the caller's dict in place, corrupting it for reuse
# (a second deserialization of the same dict would then receive already-parsed objects).
init_params = data.get("init_parameters", {})
new_params = dict(init_params)
if (doc := init_params.get("document")) is not None:
new_params["document"] = Document.from_dict(doc)
if (offset := init_params.get("document_offset")) is not None:
new_params["document_offset"] = ExtractedAnswer.Span(**offset)
if (offset := init_params.get("context_offset")) is not None:
new_params["context_offset"] = ExtractedAnswer.Span(**offset)
return default_from_dict(cls, {**data, "init_parameters": new_params})
@_warn_on_inplace_mutation
@dataclass
class GeneratedAnswer:
"""
Holds a generated answer from a Generator (answer text, query, referenced documents, and metadata).
"""
data: str
query: str
documents: list[Document]
meta: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""
Serialize the object to a dictionary.
:returns:
Serialized dictionary representation of the object.
"""
documents = [doc.to_dict(flatten=False) for doc in self.documents]
# Serialize ChatMessage objects to dicts
meta = self.meta
all_messages = meta.get("all_messages")
# all_messages is either a list of ChatMessage objects or a list of strings
if all_messages and isinstance(all_messages[0], ChatMessage):
meta = {**meta, "all_messages": [msg.to_dict() for msg in all_messages]}
return default_to_dict(self, data=self.data, query=self.query, documents=documents, meta=meta)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "GeneratedAnswer":
"""
Deserialize the object from a dictionary.
:param data:
Dictionary representation of the object.
:returns:
Deserialized object.
"""
# Shallow-copy the init parameters so `from_dict` stays side-effect free: the nested
# replacements below otherwise mutate the caller's dict in place, corrupting it for reuse
# (a second deserialization of the same dict would then receive already-parsed objects).
init_params = data.get("init_parameters", {})
new_params = dict(init_params)
if (documents := init_params.get("documents")) is not None:
new_params["documents"] = [Document.from_dict(d) for d in documents]
# Shallow-copy `meta` before touching `all_messages` so the caller's nested dict is
# left untouched as well.
meta = dict(init_params.get("meta", {}))
if (all_messages := meta.get("all_messages")) and isinstance(all_messages[0], dict):
meta["all_messages"] = [ChatMessage.from_dict(m) for m in all_messages]
new_params["meta"] = meta
return default_from_dict(cls, {**data, "init_parameters": new_params})
+148
View File
@@ -0,0 +1,148 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import Any
from haystack.utils.dataclasses import _warn_on_inplace_mutation
@dataclass(frozen=True)
class Breakpoint:
"""
A dataclass to hold a breakpoint for a component.
:param component_name: The name of the component where the breakpoint is set.
:param visit_count: The number of times the component must be visited before the breakpoint is triggered.
:param snapshot_file_path: Optional path to store a snapshot of the pipeline when the breakpoint is hit.
This is useful for debugging purposes, allowing you to inspect the state of the pipeline at the time of the
breakpoint and to resume execution from that point.
"""
component_name: str
visit_count: int = 0
snapshot_file_path: str | None = None
def to_dict(self) -> dict[str, Any]:
"""
Convert the Breakpoint to a dictionary representation.
:return: A dictionary containing the component name, visit count, and debug path.
"""
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "Breakpoint":
"""
Populate the Breakpoint from a dictionary representation.
:param data: A dictionary containing the component name, visit count, and debug path.
:return: An instance of Breakpoint.
"""
return cls(**data)
@_warn_on_inplace_mutation
@dataclass
class PipelineState:
"""
A dataclass to hold the state of the pipeline at a specific point in time.
:param component_visits: A dictionary mapping component names to their visit counts.
:param inputs: The inputs processed by the pipeline at the time of the snapshot.
:param pipeline_outputs: Dictionary containing the final outputs of the pipeline up to the breakpoint.
"""
inputs: dict[str, Any]
component_visits: dict[str, int]
pipeline_outputs: dict[str, Any]
def to_dict(self) -> dict[str, Any]:
"""
Convert the PipelineState to a dictionary representation.
:return: A dictionary containing the inputs, component visits,
and pipeline outputs.
"""
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "PipelineState":
"""
Populate the PipelineState from a dictionary representation.
:param data: A dictionary containing the inputs, component visits,
and pipeline outputs.
:return: An instance of PipelineState.
"""
return cls(**data)
@_warn_on_inplace_mutation
@dataclass
class PipelineSnapshot:
"""
A dataclass to hold a snapshot of the pipeline at a specific point in time.
:param original_input_data: The original input data provided to the pipeline.
:param ordered_component_names: A list of component names in the order they were visited.
:param pipeline_state: The state of the pipeline at the time of the snapshot.
:param break_point: The breakpoint that triggered the snapshot.
:param timestamp: A timestamp indicating when the snapshot was taken.
:param include_outputs_from: Set of component names whose outputs should be included in the pipeline results.
"""
original_input_data: dict[str, Any]
ordered_component_names: list[str]
pipeline_state: PipelineState
break_point: Breakpoint
timestamp: datetime | None = None
include_outputs_from: set[str] = field(default_factory=set)
def __post_init__(self) -> None:
# Validate consistency between component_visits and ordered_component_names
components_in_state = set(self.pipeline_state.component_visits.keys())
components_in_order = set(self.ordered_component_names)
if components_in_state != components_in_order:
raise ValueError(
f"Inconsistent state: components in PipelineState.component_visits {components_in_state} "
f"do not match components in PipelineSnapshot.ordered_component_names {components_in_order}"
)
def to_dict(self) -> dict[str, Any]:
"""
Convert the PipelineSnapshot to a dictionary representation.
:return: A dictionary containing the pipeline state, timestamp, breakpoint, agent snapshot, original input data,
ordered component names, include_outputs_from, and pipeline outputs.
"""
return {
"pipeline_state": self.pipeline_state.to_dict(),
"break_point": self.break_point.to_dict(),
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
"original_input_data": self.original_input_data,
"ordered_component_names": self.ordered_component_names,
"include_outputs_from": list(self.include_outputs_from),
}
@classmethod
def from_dict(cls, data: dict) -> "PipelineSnapshot":
"""
Populate the PipelineSnapshot from a dictionary representation.
:param data: A dictionary containing the pipeline state, timestamp, breakpoint, agent snapshot, original input
data, ordered component names, include_outputs_from, and pipeline outputs.
"""
include_outputs_from = set(data.get("include_outputs_from", []))
return cls(
pipeline_state=PipelineState.from_dict(data=data["pipeline_state"]),
break_point=Breakpoint.from_dict(data=data["break_point"]),
timestamp=datetime.fromisoformat(data["timestamp"]) if data.get("timestamp") else None,
original_input_data=data.get("original_input_data", {}),
ordered_component_names=data.get("ordered_component_names", []),
include_outputs_from=include_outputs_from,
)
+124
View File
@@ -0,0 +1,124 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from haystack.utils.dataclasses import _warn_on_inplace_mutation
from haystack.utils.misc import _guess_mime_type
@_warn_on_inplace_mutation
@dataclass(repr=False)
class ByteStream:
"""
Base data class representing a binary object in the Haystack API.
:param data: The binary data stored in Bytestream.
:param meta: Additional metadata to be stored with the ByteStream.
:param mime_type: The mime type of the binary data.
"""
data: bytes
meta: dict[str, Any] = field(default_factory=dict, hash=False)
mime_type: str | None = field(default=None)
def to_file(self, destination_path: Path) -> None:
"""
Write the ByteStream to a file. Note: the metadata will be lost.
:param destination_path: The path to write the ByteStream to.
"""
with open(destination_path, "wb") as fd:
fd.write(self.data)
@classmethod
def from_file_path(
cls,
filepath: Path,
mime_type: str | None = None,
meta: dict[str, Any] | None = None,
guess_mime_type: bool = False,
) -> "ByteStream":
"""
Create a ByteStream from the contents read from a file.
:param filepath: A valid path to a file.
:param mime_type: The mime type of the file.
:param meta: Additional metadata to be stored with the ByteStream.
:param guess_mime_type: Whether to guess the mime type from the file.
"""
if not mime_type and guess_mime_type:
mime_type = _guess_mime_type(filepath)
with open(filepath, "rb") as fd:
return cls(data=fd.read(), mime_type=mime_type, meta=meta or {})
@classmethod
def from_string(
cls, text: str, encoding: str = "utf-8", mime_type: str | None = None, meta: dict[str, Any] | None = None
) -> "ByteStream":
"""
Create a ByteStream encoding a string.
:param text: The string to encode
:param encoding: The encoding used to convert the string into bytes
:param mime_type: The mime type of the file.
:param meta: Additional metadata to be stored with the ByteStream.
"""
return cls(data=text.encode(encoding), mime_type=mime_type, meta=meta or {})
def to_string(self, encoding: str = "utf-8") -> str:
"""
Convert the ByteStream to a string, metadata will not be included.
:param encoding: The encoding used to convert the bytes to a string. Defaults to "utf-8".
:returns: The string representation of the ByteStream.
:raises UnicodeDecodeError: If the ByteStream data cannot be decoded with the specified encoding.
"""
return self.data.decode(encoding)
def __repr__(self) -> str:
"""
Return a string representation of the ByteStream, truncating the data to 100 bytes.
"""
fields = []
truncated_data = self.data[:100] + b"..." if len(self.data) > 100 else self.data
fields.append(f"data={truncated_data!r}")
fields.append(f"meta={self.meta!r}")
fields.append(f"mime_type={self.mime_type!r}")
fields_str = ", ".join(fields)
return f"{self.__class__.__name__}({fields_str})"
def to_dict(self) -> dict[str, Any]:
"""
Convert the ByteStream to a dictionary representation.
:returns: A dictionary with keys 'data', 'meta', and 'mime_type'.
"""
# Note: The data is converted to a list of integers for serialization since JSON does not support bytes
# directly.
return {"data": list(self.data), "meta": self.meta, "mime_type": self.mime_type}
def _to_trace_dict(self) -> dict[str, Any]:
"""
Convert the ByteStream to a dictionary representation for tracing.
Binary data is replaced with a placeholder string to avoid sending large payloads to the tracing backend.
:returns:
Serialized version of the object only for tracing purposes.
"""
return {"data": f"Binary data ({len(self.data)} bytes)", "meta": self.meta, "mime_type": self.mime_type}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ByteStream":
"""
Create a ByteStream from a dictionary representation.
:param data: A dictionary with keys 'data', 'meta', and 'mime_type'.
:returns: A ByteStream instance.
"""
return ByteStream(data=bytes(data["data"]), meta=data.get("meta", {}), mime_type=data.get("mime_type"))
+816
View File
@@ -0,0 +1,816 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
from collections.abc import Sequence
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any
from haystack import logging
from haystack.dataclasses.file_content import FileContent
from haystack.dataclasses.image_content import ImageContent
from haystack.utils.dataclasses import _warn_on_inplace_mutation
logger = logging.getLogger(__name__)
class ChatRole(str, Enum):
"""
Enumeration representing the roles within a chat.
"""
#: The user role. A message from the user contains only text.
USER = "user"
#: The system role. A message from the system contains only text.
SYSTEM = "system"
#: The assistant role. A message from the assistant can contain text and Tool calls. It can also store metadata.
ASSISTANT = "assistant"
#: The tool role. A message from a tool contains the result of a Tool invocation.
TOOL = "tool"
@staticmethod
def from_str(string: str) -> "ChatRole":
"""
Convert a string to a ChatRole enum.
"""
enum_map = {e.value: e for e in ChatRole}
role = enum_map.get(string)
if role is None:
msg = f"Unknown chat role '{string}'. Supported roles are: {list(enum_map.keys())}"
raise ValueError(msg)
return role
@_warn_on_inplace_mutation
@dataclass
class TextContent:
"""
The textual content of a chat message.
:param text: The text content of the message.
"""
text: str
def to_dict(self) -> dict[str, Any]:
"""
Convert TextContent into a dictionary.
"""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "TextContent":
"""
Create a TextContent from a dictionary.
"""
return TextContent(**data)
@_warn_on_inplace_mutation
@dataclass
class ToolCall:
"""
Represents a Tool call prepared by the model, usually contained in an assistant message.
:param id: The ID of the Tool call.
:param tool_name: The name of the Tool to call.
:param arguments: The arguments to call the Tool with.
:param extra: Dictionary of extra information about the Tool call. Use to store provider-specific
information. To avoid serialization issues, values should be JSON serializable.
"""
tool_name: str
arguments: dict[str, Any]
id: str | None = None # noqa: A003
extra: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
"""
Convert ToolCall into a dictionary.
:returns: A dictionary with keys 'tool_name', 'arguments', 'id', and 'extra'.
"""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ToolCall":
"""
Creates a new ToolCall object from a dictionary.
:param data:
The dictionary to build the ToolCall object.
:returns:
The created object.
"""
return ToolCall(**data)
ToolCallResultContentT = str | Sequence[TextContent | ImageContent | FileContent]
@_warn_on_inplace_mutation
@dataclass
class ToolCallResult:
"""
Represents the result of a Tool invocation.
:param result: The result of the Tool invocation.
:param origin: The Tool call that produced this result.
:param error: Whether the Tool invocation resulted in an error.
"""
result: ToolCallResultContentT
origin: ToolCall
error: bool
def to_dict(self) -> dict[str, Any]:
"""
Converts ToolCallResult into a dictionary.
:returns: A dictionary with keys 'result', 'origin', and 'error'.
"""
serialized = asdict(self)
if isinstance(self.result, list):
if not all(isinstance(part, (TextContent, ImageContent, FileContent)) for part in self.result):
raise ValueError(
"ToolCallResult result must be a string or a list of TextContent, ImageContent, or FileContent"
)
serialized["result"] = [_serialize_content_part(part) for part in self.result]
return serialized
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ToolCallResult":
"""
Creates a ToolCallResult from a dictionary.
:param data:
The dictionary to build the ToolCallResult object.
:returns:
The created object.
"""
if not all(x in data for x in ["result", "origin", "error"]):
raise ValueError(
"Fields `result`, `origin`, `error` are required for ToolCallResult deserialization. "
f"Received dictionary with keys {list(data.keys())}"
)
result = data["result"]
if isinstance(result, list):
result = [_deserialize_content_part(part) for part in result]
return ToolCallResult(result=result, origin=ToolCall.from_dict(data["origin"]), error=data["error"])
@_warn_on_inplace_mutation
@dataclass
class ReasoningContent:
"""
Represents the optional reasoning content prepared by the model, usually contained in an assistant message.
:param reasoning_text: The reasoning text produced by the model.
:param extra: Dictionary of extra information about the reasoning content. Use to store provider-specific
information. To avoid serialization issues, values should be JSON serializable.
"""
reasoning_text: str
extra: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""
Convert ReasoningContent into a dictionary.
:returns: A dictionary with keys 'reasoning_text', and 'extra'.
"""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ReasoningContent":
"""
Creates a new ReasoningContent object from a dictionary.
:param data:
The dictionary to build the ReasoningContent object.
:returns:
The created object.
"""
return ReasoningContent(**data)
ChatMessageContentT = TextContent | ToolCall | ToolCallResult | ImageContent | ReasoningContent | FileContent
_CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS: dict[type[ChatMessageContentT], str] = {
TextContent: "text",
ToolCall: "tool_call",
ToolCallResult: "tool_call_result",
ImageContent: "image",
ReasoningContent: "reasoning",
FileContent: "file",
}
def _deserialize_content_part(part: dict[str, Any]) -> ChatMessageContentT:
"""
Deserialize a single content part of a serialized ChatMessage.
:param part:
A dictionary representing a single content part of a serialized ChatMessage.
:returns:
A ChatMessageContentT object.
:raises ValueError:
If the part is not a valid ChatMessageContentT object.
"""
# handle flat text format separately
if "text" in part:
return TextContent.from_dict(part)
for cls, serialization_key in _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.items():
if serialization_key in part:
return cls.from_dict(part[serialization_key])
# NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
msg = (
f"Unsupported content part in the serialized ChatMessage: {part}. "
"The `content` field of the serialized ChatMessage must be a list of dictionaries, where each dictionary "
"contains one of these keys: 'text', 'image', 'file', 'reasoning', 'tool_call', or 'tool_call_result'. "
"Valid formats: [{'text': 'Hello'}, {'image': {'base64_image': '...', ...}}, "
"{'file': {'base64_data': '...', ...}}, {'reasoning': {'reasoning_text': 'I think...', 'extra': {...}}}, "
"{'tool_call': {'tool_name': 'search', 'arguments': {}, 'id': 'call_123'}}, "
"{'tool_call_result': {'result': 'data', 'origin': {...}, 'error': false}}]"
)
raise ValueError(msg)
def _serialize_content_part(part: ChatMessageContentT) -> dict[str, Any]:
"""
Serialize a single content part of a ChatMessage.
:param part:
A ChatMessageContentT object.
:returns:
A dictionary representing the content part.
:raises TypeError:
If the part is not a valid ChatMessageContentT object.
"""
serialization_key = _CONTENT_PART_CLASSES_TO_SERIALIZATION_KEYS.get(type(part))
if serialization_key is None:
raise TypeError(f"Unsupported type in ChatMessage content: `{type(part).__name__}` for `{part}`.")
# handle flat text format separately
if isinstance(part, TextContent):
return part.to_dict()
return {serialization_key: part.to_dict()}
@_warn_on_inplace_mutation
@dataclass
class ChatMessage:
"""
Represents a message in a LLM chat conversation.
Use the `from_assistant`, `from_user`, `from_system`, and `from_tool` class methods to create a ChatMessage.
"""
_role: ChatRole
_content: Sequence[ChatMessageContentT]
_name: str | None = None
_meta: dict[str, Any] = field(default_factory=dict, hash=False)
def __len__(self) -> int:
return len(self._content)
@property
def role(self) -> ChatRole:
"""
Returns the role of the entity sending the message.
"""
return self._role
@property
def meta(self) -> dict[str, Any]:
"""
Returns the metadata associated with the message.
"""
return self._meta
@property
def name(self) -> str | None:
"""
Returns the name associated with the message.
"""
return self._name
@property
def texts(self) -> list[str]:
"""
Returns the list of all texts contained in the message.
"""
return [content.text for content in self._content if isinstance(content, TextContent)]
@property
def text(self) -> str | None:
"""
Returns the first text contained in the message.
"""
if texts := self.texts:
return texts[0]
return None
@property
def tool_calls(self) -> list[ToolCall]:
"""
Returns the list of all Tool calls contained in the message.
"""
return [content for content in self._content if isinstance(content, ToolCall)]
@property
def tool_call(self) -> ToolCall | None:
"""
Returns the first Tool call contained in the message.
"""
if tool_calls := self.tool_calls:
return tool_calls[0]
return None
@property
def tool_call_results(self) -> list[ToolCallResult]:
"""
Returns the list of all Tool call results contained in the message.
"""
return [content for content in self._content if isinstance(content, ToolCallResult)]
@property
def tool_call_result(self) -> ToolCallResult | None:
"""
Returns the first Tool call result contained in the message.
"""
if tool_call_results := self.tool_call_results:
return tool_call_results[0]
return None
@property
def images(self) -> list[ImageContent]:
"""
Returns the list of all images contained in the message.
"""
return [content for content in self._content if isinstance(content, ImageContent)]
@property
def image(self) -> ImageContent | None:
"""
Returns the first image contained in the message.
"""
if images := self.images:
return images[0]
return None
@property
def files(self) -> list[FileContent]:
"""
Returns the list of all files contained in the message.
"""
return [content for content in self._content if isinstance(content, FileContent)]
@property
def file(self) -> FileContent | None:
"""
Returns the first file contained in the message.
"""
if files := self.files:
return files[0]
return None
@property
def reasonings(self) -> list[ReasoningContent]:
"""
Returns the list of all reasoning contents contained in the message.
"""
return [content for content in self._content if isinstance(content, ReasoningContent)]
@property
def reasoning(self) -> ReasoningContent | None:
"""
Returns the first reasoning content contained in the message.
"""
if reasonings := self.reasonings:
return reasonings[0]
return None
def is_from(self, role: ChatRole | str) -> bool:
"""
Check if the message is from a specific role.
:param role: The role to check against.
:returns: True if the message is from the specified role, False otherwise.
"""
if isinstance(role, str):
role = ChatRole.from_str(role)
return self._role == role
@classmethod
def from_user(
cls,
text: str | None = None,
meta: dict[str, Any] | None = None,
name: str | None = None,
*,
content_parts: Sequence[TextContent | str | ImageContent | FileContent] | None = None,
) -> "ChatMessage":
"""
Create a message from the user.
:param text: The text content of the message. Specify this or content_parts.
:param meta: Additional metadata associated with the message.
:param name: An optional name for the participant. This field is only supported by OpenAI.
:param content_parts: A list of content parts to include in the message. Specify this or text.
:returns: A new ChatMessage instance.
:raises ValueError: If neither or both of text and content_parts are provided, or if content_parts is empty.
:raises TypeError: If a content part is not a str, TextContent, ImageContent, or FileContent.
"""
if text is None and content_parts is None:
raise ValueError("Either text or content_parts must be provided.")
if text is not None and content_parts is not None:
raise ValueError("Only one of text or content_parts can be provided.")
content: list[TextContent | ImageContent | FileContent] = []
if text is not None:
content = [TextContent(text=text)]
elif content_parts is not None:
for part in content_parts:
if isinstance(part, str):
content.append(TextContent(text=part))
elif isinstance(part, (TextContent, ImageContent, FileContent)):
content.append(part)
else:
raise TypeError(f"The user message must contain only text or image parts. Unsupported part: {part}")
if len(content) == 0:
raise ValueError("The user message must contain at least one content part (text, image, file).")
return cls(_role=ChatRole.USER, _content=content, _meta=meta or {}, _name=name)
@classmethod
def from_system(cls, text: str, meta: dict[str, Any] | None = None, name: str | None = None) -> "ChatMessage":
"""
Create a message from the system.
:param text: The text content of the message.
:param meta: Additional metadata associated with the message.
:param name: An optional name for the participant. This field is only supported by OpenAI.
:returns: A new ChatMessage instance.
"""
return cls(_role=ChatRole.SYSTEM, _content=[TextContent(text=text)], _meta=meta or {}, _name=name)
@classmethod
def from_assistant(
cls,
text: str | None = None,
meta: dict[str, Any] | None = None,
name: str | None = None,
tool_calls: list[ToolCall] | None = None,
*,
reasoning: str | ReasoningContent | None = None,
) -> "ChatMessage":
"""
Create a message from the assistant.
:param text: The text content of the message.
:param meta: Additional metadata associated with the message.
:param name: An optional name for the participant. This field is only supported by OpenAI.
:param tool_calls: The Tool calls to include in the message.
:param reasoning: The reasoning content to include in the message.
:returns: A new ChatMessage instance.
:raises TypeError: If `reasoning` is not a string or ReasoningContent object.
"""
content: list[ChatMessageContentT] = []
if reasoning:
if isinstance(reasoning, str):
content.append(ReasoningContent(reasoning_text=reasoning))
elif isinstance(reasoning, ReasoningContent):
content.append(reasoning)
else:
raise TypeError(f"reasoning must be a string or a ReasoningContent object, got {type(reasoning)}")
if text is not None:
content.append(TextContent(text=text))
if tool_calls:
content.extend(tool_calls)
return cls(_role=ChatRole.ASSISTANT, _content=content, _meta=meta or {}, _name=name)
@classmethod
def from_tool(
cls,
tool_result: ToolCallResultContentT,
origin: ToolCall,
error: bool = False,
meta: dict[str, Any] | None = None,
) -> "ChatMessage":
"""
Create a message from a Tool.
:param tool_result: The result of the Tool invocation.
:param origin: The Tool call that produced this result.
:param error: Whether the Tool invocation resulted in an error.
:param meta: Additional metadata associated with the message.
:returns: A new ChatMessage instance.
"""
return cls(
_role=ChatRole.TOOL,
_content=[ToolCallResult(result=tool_result, origin=origin, error=error)],
_meta=meta or {},
)
def to_dict(self) -> dict[str, Any]:
"""
Converts ChatMessage into a dictionary.
:returns:
Serialized version of the object.
"""
serialized: dict[str, Any] = {}
serialized["role"] = self._role.value
serialized["meta"] = self._meta
serialized["name"] = self._name
serialized["content"] = [_serialize_content_part(part) for part in self._content]
return serialized
def _to_trace_dict(self) -> dict[str, Any]:
"""
Convert the ChatMessage to a dictionary representation for tracing.
For Image Content objects, the base64_image is replaced with a placeholder string to avoid sending large
payloads to the tracing backend.
:returns:
Serialized version of the object only for tracing purposes.
"""
serialized: dict[str, Any] = {}
serialized["role"] = self._role.value
serialized["meta"] = self._meta
serialized["name"] = self._name
serialized["content"] = []
for part in self._content:
serialized_part = _serialize_content_part(part)
if isinstance(part, ImageContent):
serialized_part["image"] = part._to_trace_dict()
elif isinstance(part, FileContent):
serialized_part["file"] = part._to_trace_dict()
serialized["content"].append(serialized_part)
return serialized
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ChatMessage":
"""
Creates a new ChatMessage object from a dictionary.
:param data:
The dictionary to build the ChatMessage object.
:returns:
The created object.
:raises ValueError: If the `role` field is missing from the dictionary.
:raises TypeError: If the `content` field is not a list or string.
"""
# NOTE: this verbose error message provides guidance to LLMs when creating invalid messages during agent runs
if "role" not in data and "_role" not in data:
raise ValueError(
"The `role` field is required in the message dictionary. "
f"Expected a dictionary with 'role' field containing one of: {[role.value for role in ChatRole]}. "
f"Common roles are 'user' (for user messages) and 'assistant' (for AI responses). "
f"Received dictionary with keys: {list(data.keys())}"
)
if "content" in data:
init_params: dict[str, Any] = {
"_role": ChatRole(data["role"]),
"_name": data.get("name"),
"_meta": data.get("meta") or {},
}
if isinstance(data["content"], list):
# current format - the serialized `content` field is a list of dictionaries
init_params["_content"] = [_deserialize_content_part(part) for part in data["content"]]
elif isinstance(data["content"], str):
# pre 2.9.0 format - the `content` field is a string
init_params["_content"] = [TextContent(text=data["content"])]
else:
raise TypeError(f"Unsupported content type in serialized ChatMessage: `{(data['content'])}`")
return cls(**init_params)
if "_content" in data:
# format for versions >=2.9.0 and <2.12.0 - the serialized `_content` field is a list of dictionaries
return cls(
_role=ChatRole(data["_role"]),
_content=[_deserialize_content_part(part) for part in data["_content"]],
_name=data.get("_name"),
_meta=data.get("_meta") or {},
)
raise ValueError(f"Missing 'content' or '_content' in serialized ChatMessage: `{data}`")
def to_openai_dict_format(self, require_tool_call_ids: bool = True) -> dict[str, Any]:
"""
Convert a ChatMessage to the dictionary format expected by OpenAI's Chat Completions API.
:param require_tool_call_ids:
If True (default), enforces that each Tool Call includes a non-null `id` attribute.
Set to False to allow Tool Calls without `id`, which may be suitable for shallow OpenAI-compatible APIs.
:returns:
The ChatMessage in the format expected by OpenAI's Chat Completions API.
:raises ValueError:
If the message format is invalid, or if `require_tool_call_ids` is True and any Tool Call is missing an
`id` attribute.
"""
if not self.texts and not self.tool_calls and not self.tool_call_results and not self.images and not self.files:
raise ValueError(
"A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, "
"`ToolCallResult`, `ImageContent`, or `FileContent`."
)
if len(self.tool_call_results) > 0 and len(self._content) > 1:
raise ValueError(
"For OpenAI compatibility, a `ChatMessage` with a `ToolCallResult` cannot contain any other content."
)
openai_msg: dict[str, Any] = {"role": self._role.value}
if self._name is not None:
openai_msg["name"] = self._name
if openai_msg["role"] == "user":
return self._user_message_to_openai(openai_msg)
if self.tool_call_results:
return self._tool_result_message_to_openai(openai_msg, require_tool_call_ids)
return self._system_assistant_message_to_openai(openai_msg, require_tool_call_ids)
def _user_message_to_openai(self, openai_msg: dict[str, Any]) -> dict[str, Any]:
"""Build OpenAI dict for a user message."""
if len(self._content) == 1 and isinstance(self._content[0], TextContent):
openai_msg["content"] = self.text
return openai_msg
content = []
for part in self._content:
if isinstance(part, TextContent):
content.append({"type": "text", "text": part.text})
elif isinstance(part, ImageContent):
image_item: dict[str, Any] = {
"type": "image_url",
# If no MIME type is provided, default to JPEG.
# OpenAI API appears to tolerate MIME type mismatches.
"image_url": {"url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}"},
}
if part.detail:
image_item["image_url"]["detail"] = part.detail
content.append(image_item)
elif isinstance(part, FileContent):
file_item: dict[str, Any] = {
"type": "file",
"file": {
"file_data": f"data:{part.mime_type or 'application/pdf'};base64,{part.base64_data}",
# Filename is optional but if not provided, OpenAI expects a file_id of a previous file upload.
# We use a dummy filename.
"filename": part.filename or "filename",
},
}
content.append(file_item)
openai_msg["content"] = content
return openai_msg
def _tool_result_message_to_openai(self, openai_msg: dict[str, Any], require_tool_call_ids: bool) -> dict[str, Any]:
"""Build OpenAI dict for a tool result message."""
result = self.tool_call_results[0]
if isinstance(result.result, str):
openai_msg["content"] = result.result
# OpenAI Chat Completions API does not support multimodal tool results
elif isinstance(result.result, list) and all(isinstance(part, TextContent) for part in result.result):
openai_msg["content"] = [{"type": "text", "text": part.text} for part in result.result]
else:
raise ValueError(
f"Unsupported tool result: {result}. If you need to pass images in tool results, "
"use OpenAI Responses API instead."
)
if result.origin.id is not None:
openai_msg["tool_call_id"] = result.origin.id
elif require_tool_call_ids:
raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
# OpenAI does not provide a way to communicate errors in tool invocations, so we ignore the error field
return openai_msg
def _system_assistant_message_to_openai(
self, openai_msg: dict[str, Any], require_tool_call_ids: bool
) -> dict[str, Any]:
"""Build OpenAI dict for system and assistant messages."""
# OpenAI Chat Completions API does not support reasoning content, so we ignore it
if self.texts:
openai_msg["content"] = self.texts[0]
if self.tool_calls:
openai_tool_calls = []
for tc in self.tool_calls:
openai_tool_call = {
"type": "function",
# We disable ensure_ascii so special chars like emojis are not converted
"function": {"name": tc.tool_name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)},
}
if tc.id is not None:
openai_tool_call["id"] = tc.id
elif require_tool_call_ids:
raise ValueError("`ToolCall` must have a non-null `id` attribute to be used with OpenAI.")
openai_tool_calls.append(openai_tool_call)
openai_msg["tool_calls"] = openai_tool_calls
return openai_msg
@staticmethod
def _validate_openai_message(message: dict[str, Any]) -> None:
"""
Validate that a message dictionary follows OpenAI's Chat API format.
:param message: The message dictionary to validate
:raises ValueError: If the message format is invalid
"""
if "role" not in message:
raise ValueError("The `role` field is required in the message dictionary.")
role = message["role"]
content = message.get("content")
tool_calls = message.get("tool_calls")
if role not in ["assistant", "user", "system", "developer", "tool"]:
raise ValueError(f"Unsupported role: {role}")
if role == "assistant":
if not content and not tool_calls:
raise ValueError("For assistant messages, either `content` or `tool_calls` must be present.")
if tool_calls:
for tc in tool_calls:
if "function" not in tc:
raise ValueError("Tool calls must contain the `function` field")
elif not content:
raise ValueError(f"The `content` field is required for {role} messages.")
@classmethod
def from_openai_dict_format(cls, message: dict[str, Any]) -> "ChatMessage":
"""
Create a ChatMessage from a dictionary in the format expected by OpenAI's Chat API.
NOTE: While OpenAI's API requires `tool_call_id` in both tool calls and tool messages, this method
accepts messages without it to support shallow OpenAI-compatible APIs.
If you plan to use the resulting ChatMessage with OpenAI, you must include `tool_call_id` or you'll
encounter validation errors.
:param message:
The OpenAI dictionary to build the ChatMessage object.
:returns:
The created ChatMessage object.
:raises ValueError:
If the message dictionary is missing required fields.
"""
cls._validate_openai_message(message)
role = message["role"]
content = message.get("content")
name = message.get("name")
tool_calls = message.get("tool_calls")
tool_call_id = message.get("tool_call_id")
if role == "assistant":
haystack_tool_calls = None
if tool_calls:
haystack_tool_calls = []
for tc in tool_calls:
haystack_tc = ToolCall(
id=tc.get("id"),
tool_name=tc["function"]["name"],
arguments=json.loads(tc["function"]["arguments"]),
)
haystack_tool_calls.append(haystack_tc)
return cls.from_assistant(text=content, name=name, tool_calls=haystack_tool_calls)
assert content is not None # ensured by _validate_openai_message, but we need to make mypy happy
if role == "user":
return cls.from_user(text=content, name=name)
if role in ["system", "developer"]:
return cls.from_system(text=content, name=name)
if isinstance(content, list):
if not all("text" in el for el in content):
raise ValueError("To be used with OpenAI, tool results must be a string or a list of TextContent")
content = [TextContent(text=el["text"]) for el in content]
return cls.from_tool(
tool_result=content, origin=ToolCall(id=tool_call_id, tool_name="", arguments={}), error=False
)
+192
View File
@@ -0,0 +1,192 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import hashlib
import json
from dataclasses import asdict, dataclass, field, fields
from typing import Any
from numpy import ndarray
from haystack.dataclasses.byte_stream import ByteStream
from haystack.dataclasses.sparse_embedding import SparseEmbedding
from haystack.utils.dataclasses import _warn_on_inplace_mutation
LEGACY_FIELDS = ["content_type", "id_hash_keys", "dataframe"]
class _BackwardCompatible(type):
"""
Metaclass that handles Document backward compatibility.
"""
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
"""
Called before Document.__init__, handles legacy fields.
Embedding was stored as NumPy arrays in 1.x, so we convert it to a list of floats.
Other legacy fields are removed.
"""
### Conversion from 1.x Document ###
content = kwargs.get("content")
if content and not isinstance(content, str):
raise ValueError("The `content` field must be a string or None.")
# Embedding were stored as NumPy arrays in 1.x, so we convert it to the new type
if isinstance(embedding := kwargs.get("embedding"), ndarray):
kwargs["embedding"] = embedding.tolist()
# Remove legacy fields
for field_name in LEGACY_FIELDS:
kwargs.pop(field_name, None)
return super().__call__(*args, **kwargs)
@_warn_on_inplace_mutation
@dataclass
class Document(metaclass=_BackwardCompatible): # noqa: PLW1641
"""
Base data class containing some data to be queried.
Can contain text snippets and file paths to images or audios. Documents can be sorted by score and saved
to/from dictionary and JSON.
:param id: Unique identifier for the document. When not set, it's generated based on the Document fields' values.
:param content: Text of the document, if the document contains text.
:param blob: Binary data associated with the document, if the document has any binary data associated with it.
:param meta: Additional custom metadata for the document. Must be JSON-serializable.
:param score: Score of the document. Used for ranking, usually assigned by retrievers.
:param embedding: dense vector representation of the document.
:param sparse_embedding: sparse vector representation of the document.
"""
id: str = field(default="")
content: str | None = field(default=None)
blob: ByteStream | None = field(default=None)
meta: dict[str, Any] = field(default_factory=dict)
score: float | None = field(default=None)
embedding: list[float] | None = field(default=None)
sparse_embedding: SparseEmbedding | None = field(default=None)
def __repr__(self) -> str:
fields = []
if self.content is not None:
fields.append(
f"content: '{self.content}'" if len(self.content) < 100 else f"content: '{self.content[:100]}...'"
)
if self.blob is not None:
fields.append(f"blob: {len(self.blob.data)} bytes")
if len(self.meta) > 0:
fields.append(f"meta: {self.meta}")
if self.score is not None:
fields.append(f"score: {self.score}")
if self.embedding is not None:
fields.append(f"embedding: vector of size {len(self.embedding)}")
if self.sparse_embedding is not None:
fields.append(f"sparse_embedding: vector with {len(self.sparse_embedding.indices)} non-zero elements")
fields_str = ", ".join(fields)
return f"{self.__class__.__name__}(id={self.id}, {fields_str})"
def __eq__(self, other: object) -> bool:
"""
Compares Documents for equality.
Two Documents are considered equals if their dictionary representation is identical.
"""
if type(self) != type(other):
return False
return self.to_dict() == other.to_dict()
def __post_init__(self) -> None:
"""
Generate the ID based on the init parameters.
"""
# Generate an id only if not explicitly set
self.id = self.id or self._create_id()
def _create_id(self) -> str:
"""
Creates a hash of the given content that acts as the document's ID.
"""
text = self.content or None
dataframe = None # this allows the ID creation to remain unchanged even if the dataframe field has been removed
blob = self.blob.data if self.blob is not None else None
mime_type = self.blob.mime_type if self.blob is not None else None
# Sort keys so meta order doesn't affect the ID. Keep "{}" for empty meta so existing IDs stay stable.
meta = json.dumps(self.meta, sort_keys=True, default=str) if self.meta else "{}"
embedding = self.embedding if self.embedding is not None else None
sparse_embedding = self.sparse_embedding.to_dict() if self.sparse_embedding is not None else ""
data = f"{text}{dataframe}{blob!r}{mime_type}{meta}{embedding}{sparse_embedding}"
return hashlib.sha256(data.encode("utf-8")).hexdigest()
def to_dict(self, flatten: bool = True) -> dict[str, Any]:
"""
Converts Document into a dictionary.
`blob` field is converted to a JSON-serializable type.
:param flatten:
Whether to flatten `meta` field or not. Defaults to `True` to be backward-compatible with Haystack 1.x.
"""
data = asdict(self)
# Use `ByteStream` and `SparseEmbedding`'s to_dict methods to convert them to JSON-serializable types.
if self.blob is not None:
data["blob"] = self.blob.to_dict()
if self.sparse_embedding is not None:
data["sparse_embedding"] = self.sparse_embedding.to_dict()
if flatten:
meta = data.pop("meta")
return {**meta, **data}
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Document":
"""
Creates a new Document object from a dictionary.
The `blob` field is converted to its original type.
"""
data = data.copy()
if blob := data.get("blob"):
data["blob"] = ByteStream.from_dict(blob)
if sparse_embedding := data.get("sparse_embedding"):
data["sparse_embedding"] = SparseEmbedding.from_dict(sparse_embedding)
# Store metadata for a moment while we try un-flattening allegedly flatten metadata.
# We don't expect both a `meta=` keyword and flatten metadata keys so we'll raise a
# ValueError later if this is the case.
meta = data.pop("meta", {})
# Unflatten metadata if it was flattened. We assume any keyword argument that's not
# a document field is a metadata key. We treat legacy fields as document fields
# for backward compatibility.
flatten_meta = {}
document_fields = LEGACY_FIELDS + [f.name for f in fields(cls)]
for key in list(data.keys()):
if key not in document_fields:
flatten_meta[key] = data.pop(key)
# We don't support passing both flatten keys and the `meta` keyword parameter
if meta and flatten_meta:
raise ValueError(
"You can pass either the 'meta' parameter or flattened metadata keys as keyword arguments, "
"but currently you're passing both. Pass either the 'meta' parameter or flattened metadata keys."
)
# Finally put back all the metadata
return cls(**data, meta={**meta, **flatten_meta})
@property
def content_type(self) -> str:
"""
Returns the type of the content for the document.
This is necessary to keep backward compatibility with 1.x.
"""
if self.content is not None:
return "text"
raise ValueError("Content is not set.")
+189
View File
@@ -0,0 +1,189 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import base64
import mimetypes
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
import filetype
from haystack import logging
from haystack.utils.dataclasses import _warn_on_inplace_mutation
logger = logging.getLogger(__name__)
@_warn_on_inplace_mutation
@dataclass
class FileContent:
"""
The file content of a chat message.
:param base64_data: A base64 string representing the file.
:param mime_type: The MIME type of the file (e.g. "application/pdf").
Providing this value is recommended, as most LLM providers require it.
If not provided, the MIME type is guessed from the base64 string, which can be slow and not always reliable.
:param filename: Optional filename of the file. Some LLM providers use this information.
:param extra: Dictionary of extra information about the file. Can be used to store provider-specific information.
To avoid serialization issues, values should be JSON serializable.
:param validation: If True (default), a validation process is performed:
- Check whether the base64 string is valid;
- Guess the MIME type if not provided.
Set to False to skip validation and speed up initialization.
"""
base64_data: str
mime_type: str | None = None
filename: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
validation: bool = True
def __post_init__(self) -> None:
if not self.validation:
return
try:
decoded_data = base64.b64decode(self.base64_data, validate=True)
except Exception as e:
raise ValueError("The base64 string is not valid") from e
# mime_type is an important information, so we try to guess it if not provided
if not self.mime_type:
guess = filetype.guess(decoded_data)
if guess:
self.mime_type = guess.mime
else:
msg = (
"Failed to guess the MIME type of the file. Omitting the MIME type may result in "
"processing errors or incorrect handling of the file by LLM providers."
)
logger.warning(msg)
def __repr__(self) -> str:
"""
Return a string representation of the FileContent, truncating the base64_data to 100 bytes.
"""
fields = []
truncated_data = self.base64_data[:100] + "..." if len(self.base64_data) > 100 else self.base64_data
fields.append(f"base64_data={truncated_data!r}")
fields.append(f"mime_type={self.mime_type!r}")
fields.append(f"filename={self.filename!r}")
fields.append(f"extra={self.extra!r}")
fields_str = ", ".join(fields)
return f"{self.__class__.__name__}({fields_str})"
def to_dict(self) -> dict[str, Any]:
"""
Convert FileContent into a dictionary.
"""
return asdict(self)
def _to_trace_dict(self) -> dict[str, Any]:
"""
Convert the FileContent to a dictionary representation for tracing.
The base64_data is replaced with a placeholder string to avoid sending large payloads to the tracing backend.
:returns:
Serialized version of the object only for tracing purposes.
"""
data = self.to_dict()
data["base64_data"] = f"Base64 string ({len(self.base64_data)} characters)"
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "FileContent":
"""
Create an FileContent from a dictionary.
"""
return FileContent(**data)
@classmethod
def from_file_path(
cls, file_path: str | Path, *, filename: str | None = None, extra: dict[str, Any] | None = None
) -> "FileContent":
"""
Create an FileContent object from a file path.
:param file_path:
The path to the file.
:param filename:
Optional file name. Some LLM providers use this information. If not provided, the filename is extracted
from the file path.
:param extra:
Dictionary of extra information about the file. Can be used to store provider-specific information.
To avoid serialization issues, values should be JSON serializable.
:returns:
An FileContent object.
"""
if isinstance(file_path, str):
file_path = Path(file_path)
mime_type = mimetypes.guess_type(file_path.as_posix())[0]
filename = filename or file_path.name
with open(file_path, "rb") as f:
data = f.read()
return cls(
base64_data=base64.b64encode(data).decode("utf-8"),
mime_type=mime_type,
filename=filename,
extra=extra or {},
validation=False,
)
@classmethod
def from_url(
cls,
url: str,
*,
retry_attempts: int = 2,
timeout: int = 10,
filename: str | None = None,
extra: dict[str, Any] | None = None,
) -> "FileContent":
"""
Create an FileContent object from a URL. The file is downloaded and converted to a base64 string.
:param url:
The URL of the file.
:param retry_attempts:
The number of times to retry to fetch the URL's content.
:param timeout:
Timeout in seconds for the request.
:param filename:
Optional filename of the file. Some LLM providers use this information. If not provided, the filename is
extracted from the URL.
:param extra:
Dictionary of extra information about the file. Can be used to store provider-specific information.
To avoid serialization issues, values should be JSON serializable.
:returns:
An FileContent object.
"""
from haystack.components.fetchers.link_content import LinkContentFetcher
fetcher = LinkContentFetcher(raise_on_failure=True, retry_attempts=retry_attempts, timeout=timeout)
bytestream = fetcher.run(urls=[url])["streams"][0]
mime_type = bytestream.mime_type
data = bytestream.data
if not filename:
filename = os.path.basename(unquote(urlparse(url).path))
return cls(
base64_data=base64.b64encode(data).decode("utf-8"),
mime_type=mime_type,
filename=filename,
extra=extra or {},
validation=False,
)
+259
View File
@@ -0,0 +1,259 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import base64
from dataclasses import asdict, dataclass, field
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
import filetype
from haystack import logging
from haystack.lazy_imports import LazyImport
from haystack.utils import is_in_jupyter
from haystack.utils.dataclasses import _warn_on_inplace_mutation
with LazyImport("The 'show' method requires the 'PIL' library. Run 'pip install pillow'") as pillow_import:
from PIL import Image
logger = logging.getLogger(__name__)
# NOTE: We have to rely on this since our util functions are using the bytestream object.
# We could change this to use the file path instead, where the file extension is used to determine the format.
# This is a mapping of image formats to their MIME types.
# from PIL import Image
# Image.init() # <- Must force all plugins to initialize to get this mapping
# print(Image.MIME)
FORMAT_TO_MIME = {
"BMP": "image/bmp",
"DIB": "image/bmp",
"PCX": "image/x-pcx",
"EPS": "application/postscript",
"GIF": "image/gif",
"PNG": "image/png",
"JPEG2000": "image/jp2",
"ICNS": "image/icns",
"ICO": "image/x-icon",
"JPEG": "image/jpeg",
"MPEG": "video/mpeg",
"TIFF": "image/tiff",
"MPO": "image/mpo",
"PALM": "image/palm",
"PDF": "application/pdf",
"PPM": "image/x-portable-anymap",
"PSD": "image/vnd.adobe.photoshop",
"SGI": "image/sgi",
"TGA": "image/x-tga",
"WEBP": "image/webp",
"XBM": "image/xbm",
"XPM": "image/xpm",
}
MIME_TO_FORMAT = {v: k for k, v in FORMAT_TO_MIME.items()}
# Adding some common MIME types that are not in the PIL mapping
MIME_TO_FORMAT["image/jpg"] = "JPEG"
IMAGE_MIME_TYPES = set(MIME_TO_FORMAT.keys())
@_warn_on_inplace_mutation
@dataclass
class ImageContent:
"""
The image content of a chat message.
:param base64_image: A base64 string representing the image.
:param mime_type: The MIME type of the image (e.g. "image/png", "image/jpeg").
Providing this value is recommended, as most LLM providers require it.
If not provided, the MIME type is guessed from the base64 string, which can be slow and not always reliable.
:param detail: Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
:param meta: Optional metadata for the image.
:param validation: If True (default), a validation process is performed:
- Check whether the base64 string is valid;
- Guess the MIME type if not provided;
- Check if the MIME type is a valid image MIME type.
Set to False to skip validation and speed up initialization.
"""
base64_image: str
mime_type: str | None = None
detail: Literal["auto", "high", "low"] | None = None
meta: dict[str, Any] = field(default_factory=dict)
validation: bool = True
def __post_init__(self) -> None:
if not self.validation:
return
try:
decoded_image = base64.b64decode(self.base64_image, validate=True)
except Exception as e:
raise ValueError("The base64 string is not valid") from e
# mime_type is an important information, so we try to guess it if not provided
if not self.mime_type:
guess = filetype.guess(decoded_image)
if guess:
self.mime_type = guess.mime
else:
msg = (
"Failed to guess the MIME type of the image. Omitting the MIME type may result in "
"processing errors or incorrect handling of the image by LLM providers."
)
logger.warning(msg)
if self.mime_type and self.mime_type not in IMAGE_MIME_TYPES:
raise ValueError(f"{self.mime_type} is not a valid image MIME type.")
def __repr__(self) -> str:
"""
Return a string representation of the ImageContent, truncating the base64_image to 100 bytes.
"""
fields = []
truncated_data = self.base64_image[:100] + "..." if len(self.base64_image) > 100 else self.base64_image
fields.append(f"base64_image={truncated_data!r}")
fields.append(f"mime_type={self.mime_type!r}")
fields.append(f"detail={self.detail!r}")
fields.append(f"meta={self.meta!r}")
fields_str = ", ".join(fields)
return f"{self.__class__.__name__}({fields_str})"
def show(self) -> None:
"""
Shows the image.
"""
pillow_import.check()
image_bytes = BytesIO(base64.b64decode(self.base64_image))
image = Image.open(image_bytes)
if is_in_jupyter():
# ipython is not a core dependency so we cannot import it at the module level
from IPython.display import display
display(image)
else:
image.show()
def to_dict(self) -> dict[str, Any]:
"""
Convert ImageContent into a dictionary.
"""
return asdict(self)
def _to_trace_dict(self) -> dict[str, Any]:
"""
Convert the ImageContent to a dictionary representation for tracing.
The base64_image is replaced with a placeholder string to avoid sending large payloads to the tracing backend.
:returns:
Serialized version of the object only for tracing purposes.
"""
data = self.to_dict()
data["base64_image"] = f"Base64 string ({len(self.base64_image)} characters)"
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ImageContent":
"""
Create an ImageContent from a dictionary.
"""
return ImageContent(**data)
@classmethod
def from_file_path(
cls,
file_path: str | Path,
*,
size: tuple[int, int] | None = None,
detail: Literal["auto", "high", "low"] | None = None,
meta: dict[str, Any] | None = None,
) -> "ImageContent":
"""
Create an ImageContent object from a file path.
It exposes similar functionality as the `ImageFileToImageContent` component. For PDF to ImageContent conversion,
use the `PDFToImageContent` component.
:param file_path:
The path to the image file. PDF files are not supported. For PDF to ImageContent conversion, use the
`PDFToImageContent` component.
:param size:
If provided, resizes the image to fit within the specified dimensions (width, height) while
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
when working with models that have resolution constraints or when transmitting images to remote services.
:param detail:
Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
:param meta:
Additional metadata for the image.
:returns:
An ImageContent object.
"""
# to avoid a circular import
from haystack.components.converters.image import ImageFileToImageContent
converter = ImageFileToImageContent(size=size, detail=detail)
result = converter.run(sources=[file_path], meta=[meta] if meta else None)
return result["image_contents"][0]
@classmethod
def from_url(
cls,
url: str,
*,
retry_attempts: int = 2,
timeout: int = 10,
size: tuple[int, int] | None = None,
detail: Literal["auto", "high", "low"] | None = None,
meta: dict[str, Any] | None = None,
) -> "ImageContent":
"""
Create an ImageContent object from a URL. The image is downloaded and converted to a base64 string.
For PDF to ImageContent conversion, use the `PDFToImageContent` component.
:param url:
The URL of the image. PDF files are not supported. For PDF to ImageContent conversion, use the
`PDFToImageContent` component.
:param retry_attempts:
The number of times to retry to fetch the URL's content.
:param timeout:
Timeout in seconds for the request.
:param size:
If provided, resizes the image to fit within the specified dimensions (width, height) while
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
when working with models that have resolution constraints or when transmitting images to remote services.
:param detail:
Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
:param meta:
Additional metadata for the image.
:raises ValueError:
If the URL does not point to an image or if it points to a PDF file.
:returns:
An ImageContent object.
"""
# to avoid circular imports
from haystack.components.converters.image import ImageFileToImageContent
from haystack.components.fetchers.link_content import LinkContentFetcher
fetcher = LinkContentFetcher(raise_on_failure=True, retry_attempts=retry_attempts, timeout=timeout)
bytestream = fetcher.run(urls=[url])["streams"][0]
if bytestream.mime_type not in IMAGE_MIME_TYPES:
msg = f"The URL does not point to an image. The MIME type of the URL is {bytestream.mime_type}."
raise ValueError(msg)
if bytestream.mime_type == "application/pdf":
raise ValueError(
"PDF files are not supported. "
"For PDF to ImageContent conversion, use the `PDFToImageContent` component."
)
converter = ImageFileToImageContent(size=size, detail=detail)
result = converter.run(sources=[bytestream], meta=[meta] if meta else None)
return result["image_contents"][0]
+21
View File
@@ -0,0 +1,21 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass
@dataclass
class SkillInfo:
"""
Lightweight metadata describing a skill.
This is what a `SkillStore` returns when listing its skills, keeping the catalog cheap; the full skill
content (the instructions body and bundled files) is fetched on demand.
:param name: The skill's name, used to look it up.
:param description: A short description of when to use the skill. Shown to the agent up front.
"""
name: str
description: str
+52
View File
@@ -0,0 +1,52 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from dataclasses import asdict, dataclass
from typing import Any
from haystack.utils.dataclasses import _warn_on_inplace_mutation
@_warn_on_inplace_mutation
@dataclass
class SparseEmbedding:
"""
Class representing a sparse embedding.
:param indices: List of indices of non-zero elements in the embedding.
:param values: List of values of non-zero elements in the embedding.
"""
indices: list[int]
values: list[float]
def __post_init__(self) -> None:
"""
Checks if the indices and values lists are of the same length.
Raises a ValueError if they are not.
"""
if len(self.indices) != len(self.values):
raise ValueError("Length of indices and values must be the same.")
def to_dict(self) -> dict[str, Any]:
"""
Convert the SparseEmbedding object to a dictionary.
:returns:
Serialized sparse embedding.
"""
return asdict(self)
@classmethod
def from_dict(cls, sparse_embedding_dict: dict[str, Any]) -> "SparseEmbedding":
"""
Deserializes the sparse embedding from a dictionary.
:param sparse_embedding_dict:
Dictionary to deserialize from.
:returns:
Deserialized sparse embedding.
"""
return cls(**sparse_embedding_dict)
+280
View File
@@ -0,0 +1,280 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import inspect
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field
from typing import Any, Literal, overload
from haystack import logging
from haystack.core.component import Component
from haystack.dataclasses.chat_message import ReasoningContent, ToolCallResult
from haystack.utils.dataclasses import _warn_on_inplace_mutation
logger = logging.getLogger(__name__)
# Type alias for standard finish_reason values following OpenAI's convention
# plus Haystack-specific value ("tool_call_results")
FinishReason = Literal["stop", "length", "tool_calls", "content_filter", "tool_call_results"]
@_warn_on_inplace_mutation
@dataclass
class ToolCallDelta:
"""
Represents a Tool call prepared by the model, usually contained in an assistant message.
:param index: The index of the Tool call in the list of Tool calls.
:param tool_name: The name of the Tool to call.
:param arguments: Either the full arguments in JSON format or a delta of the arguments.
:param id: The ID of the Tool call.
:param extra: Dictionary of extra information about the Tool call. Use to store provider-specific
information. To avoid serialization issues, values should be JSON serializable.
"""
index: int
tool_name: str | None = field(default=None)
arguments: str | None = field(default=None)
id: str | None = field(default=None)
extra: dict[str, Any] | None = field(default=None)
def to_dict(self) -> dict[str, Any]:
"""
Returns a dictionary representation of the ToolCallDelta.
:returns: A dictionary with keys 'index', 'tool_name', 'arguments', 'id', and 'extra'.
"""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ToolCallDelta":
"""
Creates a ToolCallDelta from a serialized representation.
:param data: Dictionary containing ToolCallDelta's attributes.
:returns: A ToolCallDelta instance.
"""
return ToolCallDelta(**data)
@_warn_on_inplace_mutation
@dataclass
class ComponentInfo:
"""
The `ComponentInfo` class encapsulates information about a component.
:param type: The type of the component.
:param name: The name of the component assigned when adding it to a pipeline.
"""
type: str
name: str | None = field(default=None)
@classmethod
def from_component(cls, component: Component) -> "ComponentInfo":
"""
Create a `ComponentInfo` object from a `Component` instance.
:param component:
The `Component` instance.
:returns:
The `ComponentInfo` object with the type and name of the given component.
"""
component_type = f"{component.__class__.__module__}.{component.__class__.__name__}"
component_name = getattr(component, "__component_name__", None)
return cls(type=component_type, name=component_name)
def to_dict(self) -> dict[str, Any]:
"""
Returns a dictionary representation of ComponentInfo.
:returns: A dictionary with keys 'type' and 'name'.
"""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ComponentInfo":
"""
Creates a ComponentInfo from a serialized representation.
:param data: Dictionary containing ComponentInfo's attributes.
:returns: A ComponentInfo instance.
"""
return ComponentInfo(**data)
@_warn_on_inplace_mutation
@dataclass
class StreamingChunk:
"""
The `StreamingChunk` class encapsulates a segment of streamed content along with associated metadata.
This structure facilitates the handling and processing of streamed data in a systematic manner.
:param content: The content of the message chunk as a string.
:param meta: A dictionary containing metadata related to the message chunk.
:param component_info: A `ComponentInfo` object containing information about the component that generated the chunk,
such as the component name and type.
:param index: An optional integer index representing which content block this chunk belongs to.
:param tool_calls: An optional list of ToolCallDelta object representing a tool call associated with the message
chunk.
:param tool_call_result: An optional ToolCallResult object representing the result of a tool call.
:param start: A boolean indicating whether this chunk marks the start of a content block.
:param finish_reason: An optional value indicating the reason the generation finished.
Standard values follow OpenAI's convention: "stop", "length", "tool_calls", "content_filter",
plus Haystack-specific value "tool_call_results".
:param reasoning: An optional ReasoningContent object representing the reasoning content associated
with the message chunk.
"""
content: str
meta: dict[str, Any] = field(default_factory=dict, hash=False)
component_info: ComponentInfo | None = field(default=None)
index: int | None = field(default=None)
tool_calls: list[ToolCallDelta] | None = field(default=None)
tool_call_result: ToolCallResult | None = field(default=None)
start: bool = field(default=False)
finish_reason: FinishReason | None = field(default=None)
reasoning: ReasoningContent | None = field(default=None)
def __post_init__(self) -> None:
fields_set = sum(bool(x) for x in (self.content, self.tool_calls, self.tool_call_result, self.reasoning))
if fields_set > 1:
raise ValueError(
"Only one of `content`, `tool_call`, `tool_call_result` or `reasoning` may be set in a StreamingChunk. "
f"Got content: '{self.content}', tool_call: '{self.tool_calls}', "
f"tool_call_result: '{self.tool_call_result}', reasoning: '{self.reasoning}'."
)
# NOTE: We don't enforce this for self.content otherwise it would be a breaking change
if (self.tool_calls or self.tool_call_result or self.reasoning) and self.index is None:
raise ValueError("If `tool_call`, `tool_call_result` or `reasoning` is set, `index` must also be set.")
def to_dict(self) -> dict[str, Any]:
"""
Returns a dictionary representation of the StreamingChunk.
:returns: Serialized dictionary representation of the calling object.
"""
return {
"content": self.content,
"meta": self.meta,
"component_info": self.component_info.to_dict() if self.component_info else None,
"index": self.index,
"tool_calls": [tc.to_dict() for tc in self.tool_calls] if self.tool_calls else None,
"tool_call_result": self.tool_call_result.to_dict() if self.tool_call_result else None,
"start": self.start,
"finish_reason": self.finish_reason,
"reasoning": self.reasoning.to_dict() if self.reasoning else None,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "StreamingChunk":
"""
Creates a deserialized StreamingChunk instance from a serialized representation.
:param data: Dictionary containing the StreamingChunk's attributes.
:returns: A StreamingChunk instance.
"""
if "content" not in data:
raise ValueError("Missing required field `content` in StreamingChunk deserialization.")
return StreamingChunk(
content=data["content"],
meta=data.get("meta", {}),
component_info=ComponentInfo.from_dict(data["component_info"]) if data.get("component_info") else None,
index=data.get("index"),
tool_calls=[ToolCallDelta.from_dict(tc) for tc in data["tool_calls"]] if data.get("tool_calls") else None,
tool_call_result=ToolCallResult.from_dict(data["tool_call_result"])
if data.get("tool_call_result")
else None,
start=data.get("start", False),
finish_reason=data.get("finish_reason"),
reasoning=ReasoningContent.from_dict(data["reasoning"]) if data.get("reasoning") else None,
)
SyncStreamingCallbackT = Callable[[StreamingChunk], None]
AsyncStreamingCallbackT = Callable[[StreamingChunk], Awaitable[None]]
StreamingCallbackT = SyncStreamingCallbackT | AsyncStreamingCallbackT
def _is_callable_async_compatible(func: Callable) -> bool:
"""
Returns if the given callable is usable inside a component's `run_async` method.
:param func:
The callable to check.
:returns:
True if the callable is compatible, False otherwise.
"""
return inspect.iscoroutinefunction(func)
@overload
def select_streaming_callback(
init_callback: StreamingCallbackT | None,
runtime_callback: StreamingCallbackT | None,
requires_async: Literal[False],
) -> SyncStreamingCallbackT | None: ...
@overload
def select_streaming_callback(
init_callback: StreamingCallbackT | None, runtime_callback: StreamingCallbackT | None, requires_async: Literal[True]
) -> StreamingCallbackT | None: ...
def select_streaming_callback(
init_callback: StreamingCallbackT | None, runtime_callback: StreamingCallbackT | None, requires_async: bool
) -> StreamingCallbackT | None:
"""
Picks the correct streaming callback given an optional initial and runtime callback.
The runtime callback takes precedence over the initial callback.
In an async context (`requires_async=True`), a sync callback is accepted but emits a warning: it will run inline on
the event loop and may block it. In a sync context (`requires_async=False`), an async callback is rejected because
there is no way to await it.
:param init_callback:
The initial callback.
:param runtime_callback:
The runtime callback.
:param requires_async:
Whether the selected callback will be invoked from an async context.
:returns:
The selected callback.
"""
if init_callback is not None:
if requires_async and not _is_callable_async_compatible(init_callback):
logger.warning(
"A sync streaming callback was provided at initialization for use in an async context. "
"It will run synchronously on the event loop and may block it."
)
if not requires_async and _is_callable_async_compatible(init_callback):
raise ValueError("The init callback cannot be a coroutine.")
if runtime_callback is not None:
if requires_async and not _is_callable_async_compatible(runtime_callback):
logger.warning(
"A sync streaming callback was provided at runtime for use in an async context. "
"It will run synchronously on the event loop and may block it."
)
if not requires_async and _is_callable_async_compatible(runtime_callback):
raise ValueError("The runtime callback cannot be a coroutine.")
return runtime_callback or init_callback
async def _invoke_streaming_callback(callback: StreamingCallbackT, chunk: StreamingChunk) -> None:
"""
Invokes a streaming callback in an async context, handling both sync and async callbacks.
:param callback: The streaming callback to invoke.
:param chunk: The streaming chunk to pass to the callback.
"""
result = callback(chunk)
if inspect.isawaitable(result):
await result