Coverage for haystack/dataclasses/streaming_chunk.py: 99%
81 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import inspect
6from collections.abc import Awaitable, Callable
7from dataclasses import asdict, dataclass, field
8from typing import Any, Literal, overload
10from haystack import logging
11from haystack.core.component import Component
12from haystack.dataclasses.chat_message import ReasoningContent, ToolCallResult
13from haystack.utils.dataclasses import _warn_on_inplace_mutation
15logger = logging.getLogger(__name__)
17# Type alias for standard finish_reason values following OpenAI's convention
18# plus Haystack-specific value ("tool_call_results")
19FinishReason = Literal["stop", "length", "tool_calls", "content_filter", "tool_call_results"]
22@_warn_on_inplace_mutation
23@dataclass
24class ToolCallDelta:
25 """
26 Represents a Tool call prepared by the model, usually contained in an assistant message.
28 :param index: The index of the Tool call in the list of Tool calls.
29 :param tool_name: The name of the Tool to call.
30 :param arguments: Either the full arguments in JSON format or a delta of the arguments.
31 :param id: The ID of the Tool call.
32 :param extra: Dictionary of extra information about the Tool call. Use to store provider-specific
33 information. To avoid serialization issues, values should be JSON serializable.
34 """
36 index: int
37 tool_name: str | None = field(default=None)
38 arguments: str | None = field(default=None)
39 id: str | None = field(default=None)
40 extra: dict[str, Any] | None = field(default=None)
42 def to_dict(self) -> dict[str, Any]:
43 """
44 Returns a dictionary representation of the ToolCallDelta.
46 :returns: A dictionary with keys 'index', 'tool_name', 'arguments', 'id', and 'extra'.
47 """
48 return asdict(self)
50 @classmethod
51 def from_dict(cls, data: dict[str, Any]) -> "ToolCallDelta":
52 """
53 Creates a ToolCallDelta from a serialized representation.
55 :param data: Dictionary containing ToolCallDelta's attributes.
56 :returns: A ToolCallDelta instance.
57 """
58 return ToolCallDelta(**data)
61@_warn_on_inplace_mutation
62@dataclass
63class ComponentInfo:
64 """
65 The `ComponentInfo` class encapsulates information about a component.
67 :param type: The type of the component.
68 :param name: The name of the component assigned when adding it to a pipeline.
70 """
72 type: str
73 name: str | None = field(default=None)
75 @classmethod
76 def from_component(cls, component: Component) -> "ComponentInfo":
77 """
78 Create a `ComponentInfo` object from a `Component` instance.
80 :param component:
81 The `Component` instance.
82 :returns:
83 The `ComponentInfo` object with the type and name of the given component.
84 """
85 component_type = f"{component.__class__.__module__}.{component.__class__.__name__}"
86 component_name = getattr(component, "__component_name__", None)
87 return cls(type=component_type, name=component_name)
89 def to_dict(self) -> dict[str, Any]:
90 """
91 Returns a dictionary representation of ComponentInfo.
93 :returns: A dictionary with keys 'type' and 'name'.
94 """
95 return asdict(self)
97 @classmethod
98 def from_dict(cls, data: dict[str, Any]) -> "ComponentInfo":
99 """
100 Creates a ComponentInfo from a serialized representation.
102 :param data: Dictionary containing ComponentInfo's attributes.
103 :returns: A ComponentInfo instance.
104 """
105 return ComponentInfo(**data)
108@_warn_on_inplace_mutation
109@dataclass
110class StreamingChunk:
111 """
112 The `StreamingChunk` class encapsulates a segment of streamed content along with associated metadata.
114 This structure facilitates the handling and processing of streamed data in a systematic manner.
116 :param content: The content of the message chunk as a string.
117 :param meta: A dictionary containing metadata related to the message chunk.
118 :param component_info: A `ComponentInfo` object containing information about the component that generated the chunk,
119 such as the component name and type.
120 :param index: An optional integer index representing which content block this chunk belongs to.
121 :param tool_calls: An optional list of ToolCallDelta object representing a tool call associated with the message
122 chunk.
123 :param tool_call_result: An optional ToolCallResult object representing the result of a tool call.
124 :param start: A boolean indicating whether this chunk marks the start of a content block.
125 :param finish_reason: An optional value indicating the reason the generation finished.
126 Standard values follow OpenAI's convention: "stop", "length", "tool_calls", "content_filter",
127 plus Haystack-specific value "tool_call_results".
128 :param reasoning: An optional ReasoningContent object representing the reasoning content associated
129 with the message chunk.
130 """
132 content: str
133 meta: dict[str, Any] = field(default_factory=dict, hash=False)
134 component_info: ComponentInfo | None = field(default=None)
135 index: int | None = field(default=None)
136 tool_calls: list[ToolCallDelta] | None = field(default=None)
137 tool_call_result: ToolCallResult | None = field(default=None)
138 start: bool = field(default=False)
139 finish_reason: FinishReason | None = field(default=None)
140 reasoning: ReasoningContent | None = field(default=None)
142 def __post_init__(self) -> None:
143 fields_set = sum(bool(x) for x in (self.content, self.tool_calls, self.tool_call_result, self.reasoning))
144 if fields_set > 1:
145 raise ValueError(
146 "Only one of `content`, `tool_call`, `tool_call_result` or `reasoning` may be set in a StreamingChunk. "
147 f"Got content: '{self.content}', tool_call: '{self.tool_calls}', "
148 f"tool_call_result: '{self.tool_call_result}', reasoning: '{self.reasoning}'."
149 )
151 # NOTE: We don't enforce this for self.content otherwise it would be a breaking change
152 if (self.tool_calls or self.tool_call_result or self.reasoning) and self.index is None:
153 raise ValueError("If `tool_call`, `tool_call_result` or `reasoning` is set, `index` must also be set.")
155 def to_dict(self) -> dict[str, Any]:
156 """
157 Returns a dictionary representation of the StreamingChunk.
159 :returns: Serialized dictionary representation of the calling object.
160 """
161 return {
162 "content": self.content,
163 "meta": self.meta,
164 "component_info": self.component_info.to_dict() if self.component_info else None,
165 "index": self.index,
166 "tool_calls": [tc.to_dict() for tc in self.tool_calls] if self.tool_calls else None,
167 "tool_call_result": self.tool_call_result.to_dict() if self.tool_call_result else None,
168 "start": self.start,
169 "finish_reason": self.finish_reason,
170 "reasoning": self.reasoning.to_dict() if self.reasoning else None,
171 }
173 @classmethod
174 def from_dict(cls, data: dict[str, Any]) -> "StreamingChunk":
175 """
176 Creates a deserialized StreamingChunk instance from a serialized representation.
178 :param data: Dictionary containing the StreamingChunk's attributes.
179 :returns: A StreamingChunk instance.
180 """
181 if "content" not in data:
182 raise ValueError("Missing required field `content` in StreamingChunk deserialization.")
184 return StreamingChunk(
185 content=data["content"],
186 meta=data.get("meta", {}),
187 component_info=ComponentInfo.from_dict(data["component_info"]) if data.get("component_info") else None,
188 index=data.get("index"),
189 tool_calls=[ToolCallDelta.from_dict(tc) for tc in data["tool_calls"]] if data.get("tool_calls") else None,
190 tool_call_result=ToolCallResult.from_dict(data["tool_call_result"])
191 if data.get("tool_call_result")
192 else None,
193 start=data.get("start", False),
194 finish_reason=data.get("finish_reason"),
195 reasoning=ReasoningContent.from_dict(data["reasoning"]) if data.get("reasoning") else None,
196 )
199SyncStreamingCallbackT = Callable[[StreamingChunk], None]
200AsyncStreamingCallbackT = Callable[[StreamingChunk], Awaitable[None]]
202StreamingCallbackT = SyncStreamingCallbackT | AsyncStreamingCallbackT
205def _is_callable_async_compatible(func: Callable) -> bool:
206 """
207 Returns if the given callable is usable inside a component's `run_async` method.
209 :param func:
210 The callable to check.
211 :returns:
212 True if the callable is compatible, False otherwise.
213 """
214 return inspect.iscoroutinefunction(func)
217@overload
218def select_streaming_callback(
219 init_callback: StreamingCallbackT | None,
220 runtime_callback: StreamingCallbackT | None,
221 requires_async: Literal[False],
222) -> SyncStreamingCallbackT | None: ...
223@overload
224def select_streaming_callback(
225 init_callback: StreamingCallbackT | None, runtime_callback: StreamingCallbackT | None, requires_async: Literal[True]
226) -> StreamingCallbackT | None: ...
229def select_streaming_callback(
230 init_callback: StreamingCallbackT | None, runtime_callback: StreamingCallbackT | None, requires_async: bool
231) -> StreamingCallbackT | None:
232 """
233 Picks the correct streaming callback given an optional initial and runtime callback.
235 The runtime callback takes precedence over the initial callback.
237 In an async context (`requires_async=True`), a sync callback is accepted but emits a warning: it will run inline on
238 the event loop and may block it. In a sync context (`requires_async=False`), an async callback is rejected because
239 there is no way to await it.
241 :param init_callback:
242 The initial callback.
243 :param runtime_callback:
244 The runtime callback.
245 :param requires_async:
246 Whether the selected callback will be invoked from an async context.
247 :returns:
248 The selected callback.
249 """
250 if init_callback is not None:
251 if requires_async and not _is_callable_async_compatible(init_callback):
252 logger.warning(
253 "A sync streaming callback was provided at initialization for use in an async context. "
254 "It will run synchronously on the event loop and may block it."
255 )
256 if not requires_async and _is_callable_async_compatible(init_callback):
257 raise ValueError("The init callback cannot be a coroutine.")
259 if runtime_callback is not None:
260 if requires_async and not _is_callable_async_compatible(runtime_callback):
261 logger.warning(
262 "A sync streaming callback was provided at runtime for use in an async context. "
263 "It will run synchronously on the event loop and may block it."
264 )
265 if not requires_async and _is_callable_async_compatible(runtime_callback):
266 raise ValueError("The runtime callback cannot be a coroutine.")
268 return runtime_callback or init_callback
271async def _invoke_streaming_callback(callback: StreamingCallbackT, chunk: StreamingChunk) -> None:
272 """
273 Invokes a streaming callback in an async context, handling both sync and async callbacks.
275 :param callback: The streaming callback to invoke.
276 :param chunk: The streaming chunk to pass to the callback.
277 """
278 result = callback(chunk)
279 if inspect.isawaitable(result):
280 await result