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
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:
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user