Files
wehub-resource-sync c56bef871b
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
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

1009 lines
46 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
import os
from datetime import datetime
from typing import Any, ClassVar
from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
from openai.lib._pydantic import to_strict_json_schema
from openai.types.responses import ParsedResponse, Response, ResponseOutputRefusal, ResponseStreamEvent
from pydantic import BaseModel
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.generators.utils import _normalize_messages, _serialize_object
from haystack.dataclasses import (
ChatMessage,
ComponentInfo,
FileContent,
ImageContent,
ReasoningContent,
StreamingCallbackT,
StreamingChunk,
SyncStreamingCallbackT,
TextContent,
ToolCall,
ToolCallDelta,
select_streaming_callback,
)
from haystack.dataclasses.streaming_chunk import _invoke_streaming_callback
from haystack.tools import (
ToolsType,
_check_duplicate_tool_names,
deserialize_tools_or_toolset_inplace,
flatten_tools_or_toolsets,
serialize_tools_or_toolset,
warm_up_tools,
)
from haystack.utils import Secret, deserialize_callable, serialize_callable
from haystack.utils.http_client import init_http_client
logger = logging.getLogger(__name__)
@component
class OpenAIResponsesChatGenerator:
"""
Completes chats using OpenAI's Responses API.
It works with the gpt-4 and o-series models and supports streaming responses
from OpenAI API. It uses [ChatMessage](https://docs.haystack.deepset.ai/docs/chatmessage)
format in input and output.
You can customize how the text is generated by passing parameters to the
OpenAI API. Use the `**generation_kwargs` argument when you initialize
the component or when you run it. Any parameter that works with
`openai.Responses.create` will work here too.
For details on OpenAI API parameters, see
[OpenAI documentation](https://platform.openai.com/docs/api-reference/responses).
### Usage example
```python
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
from haystack.dataclasses import ChatMessage
messages = [ChatMessage.from_user("What's Natural Language Processing?")]
client = OpenAIResponsesChatGenerator(generation_kwargs={"reasoning": {"effort": "low", "summary": "auto"}})
response = client.run(messages)
print(response)
```
"""
SUPPORTED_MODELS: ClassVar[list[str]] = [
"gpt-5-mini",
"gpt-5-nano",
"gpt-5",
"gpt-5.1",
"gpt-5.2",
"gpt-5.2-pro",
"gpt-5.4",
"gpt-5-pro",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"o1",
"o1-mini",
"o1-pro",
"o3",
"o3-mini",
"o3-pro",
"o4-mini",
]
"""A non-exhaustive list of chat models supported by this component.
See https://platform.openai.com/docs/models for the full list and snapshot IDs."""
def __init__(
self,
*,
api_key: Secret = Secret.from_env_var("OPENAI_API_KEY"),
model: str = "gpt-5-mini",
streaming_callback: StreamingCallbackT | None = None,
api_base_url: str | None = None,
organization: str | None = None,
generation_kwargs: dict[str, Any] | None = None,
timeout: float | None = None,
max_retries: int | None = None,
tools: ToolsType | list[dict] | None = None,
tools_strict: bool = False,
http_client_kwargs: dict[str, Any] | None = None,
) -> None:
"""
Creates an instance of OpenAIResponsesChatGenerator. Uses OpenAI's gpt-5-mini by default.
Before initializing the component, you can set the 'OPENAI_TIMEOUT' and 'OPENAI_MAX_RETRIES'
environment variables to override the `timeout` and `max_retries` parameters respectively
in the OpenAI client.
:param api_key: The OpenAI API key.
You can set it with an environment variable `OPENAI_API_KEY`, or pass with this parameter
during initialization.
:param model: The name of the model to use.
:param streaming_callback: A callback function that is called when a new token is received from the stream.
The callback function accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk)
as an argument.
:param api_base_url: An optional base URL.
:param organization: Your organization ID, defaults to `None`. See
[production best practices](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization).
:param generation_kwargs: Other parameters to use for the model. These parameters are sent
directly to the OpenAI endpoint.
See OpenAI [documentation](https://platform.openai.com/docs/api-reference/responses) for
more details.
Some of the supported parameters:
- `temperature`: What sampling temperature to use. Higher values like 0.8 will make the output more random,
while lower values like 0.2 will make it more focused and deterministic.
- `top_p`: An alternative to sampling with temperature, called nucleus sampling, where the model
considers the results of the tokens with top_p probability mass. For example, 0.1 means only the tokens
comprising the top 10% probability mass are considered.
- `previous_response_id`: The ID of the previous response.
Use this to create multi-turn conversations.
- `text_format`: A Pydantic model that enforces the structure of the model's response.
If provided, the output will always be validated against this
format (unless the model returns a tool call).
For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
- `text`: A JSON schema that enforces the structure of the model's response.
If provided, the output will always be validated against this
format (unless the model returns a tool call).
Notes:
- Both JSON Schema and Pydantic models are supported for latest models starting from GPT-4o.
- If both are provided, `text_format` takes precedence and json schema passed to `text` is ignored.
- Currently, this component doesn't support streaming for structured outputs.
- Older models only support basic version of structured outputs through `{"type": "json_object"}`.
For detailed information on JSON mode, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs#json-mode).
- `reasoning`: A dictionary of parameters for reasoning. For example:
- `summary`: The summary of the reasoning.
- `effort`: The level of effort to put into the reasoning. Can be `low`, `medium` or `high`.
- `generate_summary`: Whether to generate a summary of the reasoning.
Note: OpenAI does not return the reasoning tokens, but we can view summary if its enabled.
For details, see the [OpenAI Reasoning documentation](https://platform.openai.com/docs/guides/reasoning).
:param timeout:
Timeout for OpenAI client calls. If not set, it defaults to either the
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
:param max_retries:
Maximum number of retries to contact OpenAI after an internal error.
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
:param tools:
The tools that the model can use to prepare calls. This parameter can accept either a
mixed list of Haystack `Tool` objects and Haystack `Toolset`. Or you can pass a dictionary of
OpenAI/MCP tool definitions.
Note: You cannot pass OpenAI/MCP tools and Haystack tools together.
For details on tool support, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses/create#responses-create-tools).
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `False`, the model may not exactly
follow the schema provided in the `parameters` field of the tool definition. In Response API, tool calls
are strict by default.
:param http_client_kwargs:
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
"""
self.api_key = api_key
self.model = model
self.generation_kwargs = generation_kwargs or {}
self.streaming_callback = streaming_callback
self.api_base_url = api_base_url
self.organization = organization
self.timeout = timeout
self.max_retries = max_retries
self.tools = tools # Store tools as-is, whether it's a list or a Toolset
self.tools_strict = tools_strict
self.http_client_kwargs = http_client_kwargs
self.client: OpenAI | None = None
self.async_client: AsyncOpenAI | None = None
self._tools_warmed_up = False
def _client_kwargs(self) -> dict[str, Any]:
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
max_retries = (
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
)
resolved_api_key = self.api_key.resolve_value() if isinstance(self.api_key, Secret) else self.api_key
return {
"api_key": resolved_api_key,
"organization": self.organization,
"base_url": self.api_base_url,
"timeout": timeout,
"max_retries": max_retries,
}
def _warm_up_tools(self) -> None:
if not self._tools_warmed_up:
is_openai_tool = isinstance(self.tools, list) and isinstance(self.tools[0], dict)
# We only warm up Haystack tools, not OpenAI/MCP tools
# The type ignore is needed because mypy cannot infer the type correctly
if not is_openai_tool:
warm_up_tools(self.tools) # type: ignore[arg-type]
self._tools_warmed_up = True
def warm_up(self) -> None:
"""
Warm up the tools and initialize the synchronous OpenAI client.
"""
self._warm_up_tools()
if self.client is None:
self.client = OpenAI(
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
)
async def warm_up_async(self) -> None: # noqa: RUF029
"""
Warm up the tools and initialize the asynchronous OpenAI client on the serving event loop.
"""
self._warm_up_tools()
if self.async_client is None:
self.async_client = AsyncOpenAI(
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
)
def close(self) -> None:
"""
Releases the synchronous OpenAI client.
"""
if self.client is not None:
self.client.close()
self.client = None
async def close_async(self) -> None:
"""
Releases the asynchronous OpenAI client.
"""
if self.async_client is not None:
await self.async_client.close()
self.async_client = None
def _get_telemetry_data(self) -> dict[str, Any]:
"""
Data that is sent to Posthog for usage analytics.
"""
return {"model": self.model}
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
The serialized component as a dictionary.
"""
callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
generation_kwargs = self.generation_kwargs.copy()
text_format = generation_kwargs.pop("text_format", None)
# If the response format is a Pydantic model, it's converted to openai's json schema format
# If it's already a json schema, it's left as is
if text_format and isinstance(text_format, type) and issubclass(text_format, BaseModel):
json_schema = {
"format": {
"type": "json_schema",
"name": text_format.__name__,
"strict": True,
"schema": to_strict_json_schema(text_format),
}
}
# json schema needs to be passed to text parameter instead of text_format
generation_kwargs["text"] = json_schema
# OpenAI/MCP tools are passed as list of dictionaries
serialized_tools: dict[str, Any] | list[dict[str, Any]] | None
if self.tools and isinstance(self.tools, list) and isinstance(self.tools[0], dict):
# mypy can't infer that self.tools is list[dict] here
serialized_tools = self.tools
else:
serialized_tools = serialize_tools_or_toolset(self.tools) # type: ignore[arg-type]
return default_to_dict(
self,
model=self.model,
streaming_callback=callback_name,
api_base_url=self.api_base_url,
organization=self.organization,
generation_kwargs=generation_kwargs,
api_key=self.api_key,
timeout=self.timeout,
max_retries=self.max_retries,
tools=serialized_tools,
tools_strict=self.tools_strict,
http_client_kwargs=self.http_client_kwargs,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "OpenAIResponsesChatGenerator":
"""
Deserialize this component from a dictionary.
:param data: The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
# we only deserialize the tools if they are haystack tools
# because openai tools are not serialized in the same way
tools = data["init_parameters"].get("tools")
if tools and (
isinstance(tools, dict)
and tools.get("type") == "haystack.tools.toolset.Toolset"
or isinstance(tools, list)
and tools[0].get("type") == "haystack.tools.tool.Tool"
):
deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
init_params = data.get("init_parameters", {})
serialized_callback_handler = init_params.get("streaming_callback")
if serialized_callback_handler:
data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
return default_from_dict(cls, data)
@component.output_types(replies=list[ChatMessage])
def run(
self,
messages: list[ChatMessage] | str,
*,
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | list[dict] | None = None,
tools_strict: bool | None = None,
) -> dict[str, list[ChatMessage]]:
"""
Invokes response generation based on the provided messages and generation parameters.
:param messages:
A list of ChatMessage instances representing the input messages.
:param streaming_callback:
A callback function that is called when a new token is received from the stream.
:param generation_kwargs:
Additional keyword arguments for text generation. These parameters will
override the parameters passed during component initialization.
For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses/create).
:param tools:
The tools that the model can use to prepare calls. If set, it will override the
`tools` parameter set during component initialization. This parameter can accept either a
mixed list of Haystack `Tool` objects and Haystack `Toolset`. Or you can pass a dictionary of
OpenAI/MCP tool definitions.
Note: You cannot pass OpenAI/MCP tools and Haystack tools together.
For details on tool support, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses/create#responses-create-tools).
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `False`, the model may not exactly
follow the schema provided in the `parameters` field of the tool definition. In Response API, tool calls
are strict by default.
If set, it will override the `tools_strict` parameter set during component initialization.
:returns:
A dictionary with the following key:
- `replies`: A list containing the generated responses as ChatMessage instances.
"""
self.warm_up()
messages = _normalize_messages(messages)
if len(messages) == 0:
return {"replies": []}
streaming_callback = select_streaming_callback(
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=False
)
responses: Stream[ResponseStreamEvent] | Response
api_args = self._prepare_api_call(
messages=messages,
streaming_callback=streaming_callback,
generation_kwargs=generation_kwargs,
tools=tools,
tools_strict=tools_strict,
)
openai_endpoint = api_args.pop("openai_endpoint")
assert self.client is not None # mypy: client is built by warm_up above
openai_endpoint_method = getattr(self.client.responses, openai_endpoint)
responses = openai_endpoint_method(**api_args)
if streaming_callback is not None:
response_output = self._handle_stream_response(
responses, # type: ignore
streaming_callback,
)
else:
assert isinstance(responses, Response), "Unexpected response type for non-streaming request."
response_output = [_convert_response_to_chat_message(responses)]
return {"replies": response_output}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self,
messages: list[ChatMessage] | str,
*,
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | list[dict] | None = None,
tools_strict: bool | None = None,
) -> dict[str, list[ChatMessage]]:
"""
Asynchronously invokes response generation based on the provided messages and generation parameters.
This is the asynchronous version of the `run` method. It has the same parameters and return values
but can be used with `await` in async code.
:param messages:
A list of ChatMessage instances representing the input messages.
:param streaming_callback:
A callback function that is called when a new token is received from the stream. Async callbacks are
preferred; a sync callback is accepted but will run synchronously on the event loop and may block it.
:param generation_kwargs:
Additional keyword arguments for text generation. These parameters will
override the parameters passed during component initialization.
For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses/create).
:param tools:
A list of tools or a Toolset for which the model can prepare calls. If set, it will override the
`tools` parameter set during component initialization. This parameter can accept either a list of
mixed list of Haystack `Tool` objects and Haystack `Toolset`. Or you can pass a dictionary of
OpenAI/MCP tool definitions.
Note: You cannot pass OpenAI/MCP tools and Haystack tools together.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
If set, it will override the `tools_strict` parameter set during component initialization.
:returns:
A dictionary with the following key:
- `replies`: A list containing the generated responses as ChatMessage instances.
"""
await self.warm_up_async()
messages = _normalize_messages(messages)
# validate and select the streaming callback
streaming_callback = select_streaming_callback(
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=True
)
responses: AsyncStream[ResponseStreamEvent] | Response
if len(messages) == 0:
return {"replies": []}
api_args = self._prepare_api_call(
messages=messages,
streaming_callback=streaming_callback,
generation_kwargs=generation_kwargs,
tools=tools,
tools_strict=tools_strict,
)
openai_endpoint = api_args.pop("openai_endpoint")
assert self.async_client is not None # mypy: async_client is built by warm_up_async above
openai_endpoint_method = getattr(self.async_client.responses, openai_endpoint)
responses = await openai_endpoint_method(**api_args)
if streaming_callback is not None:
response_output = await self._handle_async_stream_response(
responses, # type: ignore
streaming_callback,
)
else:
assert isinstance(responses, Response), "Unexpected response type for non-streaming request."
response_output = [_convert_response_to_chat_message(responses)]
return {"replies": response_output}
def _prepare_api_call( # noqa: PLR0913
self,
*,
messages: list[ChatMessage],
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | list[dict] | None = None,
tools_strict: bool | None = None,
) -> dict[str, Any]:
# update generation kwargs by merging with the generation kwargs passed to the run method
generation_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
generation_kwargs = self._resolve_flattened_kwargs(generation_kwargs)
# adapt ChatMessage(s) to the format expected by the OpenAI API
openai_formatted_messages: list[dict[str, Any]] = []
for message in messages:
openai_formatted_messages.extend(_convert_chat_message_to_responses_api_format(message))
tools = tools or self.tools
tools_strict = tools_strict if tools_strict is not None else self.tools_strict
openai_tools = {}
# Build tool definitions
if tools:
tool_definitions: list[Any] = []
if isinstance(tools, list) and isinstance(tools[0], dict):
# Predefined OpenAI/MCP-style tools
tool_definitions = tools
# Convert all tool objects to the correct OpenAI-compatible structure
else:
# mypy can't infer that tools is ToolsType here
flattened_tools = flatten_tools_or_toolsets(tools) # type: ignore[arg-type]
_check_duplicate_tool_names(flattened_tools)
for t in flattened_tools:
function_spec = {**t.tool_spec}
if not tools_strict:
function_spec["strict"] = False
function_spec["parameters"]["additionalProperties"] = False
tool_definitions.append({"type": "function", **function_spec})
openai_tools = {"tools": tool_definitions}
base_args = {"model": self.model, "input": openai_formatted_messages, **openai_tools, **generation_kwargs}
# if `text_format` is provided, we use the `parse` endpoint for response type parsing
if generation_kwargs.get("text_format"):
# if both `text_format` and `text` are provided, `text_format` takes precedence
# and json schema passed to `text` is ignored
return {**base_args, "stream": streaming_callback is not None, "openai_endpoint": "parse"}
# we pass a key `openai_endpoint` as a hint to the run method to use the create or parse endpoint
# this key will be removed before the API call is made
return {**base_args, "stream": streaming_callback is not None, "openai_endpoint": "create"}
def _resolve_flattened_kwargs(self, generation_kwargs: dict[str, Any]) -> dict[str, Any]:
generation_kwargs = generation_kwargs.copy()
reasoning_effort = generation_kwargs.pop("reasoning_effort", None)
if reasoning_effort is not None:
reasoning = generation_kwargs.setdefault("reasoning", {})
reasoning["effort"] = reasoning_effort
reasoning_summary = generation_kwargs.pop("reasoning_summary", None)
if reasoning_summary is not None:
reasoning = generation_kwargs.setdefault("reasoning", {})
reasoning["summary"] = reasoning_summary
verbosity = generation_kwargs.pop("verbosity", None)
if verbosity is not None:
text = generation_kwargs.setdefault("text", {})
text["verbosity"] = verbosity
return generation_kwargs
def _handle_stream_response(self, responses: Stream, callback: SyncStreamingCallbackT) -> list[ChatMessage]:
component_info = ComponentInfo.from_component(self)
chunks: list[StreamingChunk] = []
for openai_chunk in responses:
chunk_delta = _convert_response_chunk_to_streaming_chunk(
chunk=openai_chunk, previous_chunks=chunks, component_info=component_info
)
chunks.append(chunk_delta)
callback(chunk_delta)
chat_message = _convert_streaming_chunks_to_chat_message(chunks=chunks)
return [chat_message]
async def _handle_async_stream_response(
self, responses: AsyncStream, callback: StreamingCallbackT
) -> list[ChatMessage]:
component_info = ComponentInfo.from_component(self)
chunks: list[StreamingChunk] = []
async for openai_chunk in responses:
chunk_delta = _convert_response_chunk_to_streaming_chunk(
chunk=openai_chunk, previous_chunks=chunks, component_info=component_info
)
chunks.append(chunk_delta)
await _invoke_streaming_callback(callback, chunk_delta)
chat_message = _convert_streaming_chunks_to_chat_message(chunks=chunks)
return [chat_message]
def _convert_response_to_chat_message(responses: Response | ParsedResponse) -> ChatMessage:
"""
Converts the non-streaming response from the OpenAI API to a ChatMessage.
:param responses: The responses returned by the OpenAI API.
:returns: The ChatMessage.
"""
tool_calls = []
reasoning = None
logprobs: list[dict] = []
for output in responses.output:
if isinstance(output, ResponseOutputRefusal):
logger.warning("OpenAI returned a refusal output: {output}", output=output)
continue
if output.type == "message":
for content in output.content:
if hasattr(content, "logprobs") and content.logprobs is not None:
logprobs.append(_serialize_object(content.logprobs))
if output.type == "reasoning":
# openai doesn't return the reasoning tokens, but we can view summary if its enabled
# https://platform.openai.com/docs/guides/reasoning#reasoning-summaries
summaries = output.summary
extra = output.to_dict()
# we dont need the summary in the extra
extra.pop("summary")
if output.content:
logger.warning(
"OpenAI returned a non-empty 'content' field on a reasoning item ({_id}). "
"The content is preserved in ReasoningContent.extra['content'] but is NOT "
"reflected in ReasoningContent.reasoning_text.",
_id=output.id,
)
reasoning_text = "\n".join([summary.text for summary in summaries if summaries])
reasoning = ReasoningContent(reasoning_text=reasoning_text, extra=extra)
elif output.type == "function_call":
try:
arguments = json.loads(output.arguments)
tool_calls.append(
ToolCall(
id=output.id, tool_name=output.name, arguments=arguments, extra={"call_id": output.call_id}
)
)
except json.JSONDecodeError:
logger.warning(
"The LLM provider returned a malformed JSON string for tool call arguments. This tool call "
"will be skipped. To always generate a valid JSON, set `tools_strict` to `True`. "
"Tool call ID: {_id}, Tool name: {_name}, Arguments: {_arguments}",
_id=output.id,
_name=output.name,
_arguments=output.arguments,
)
arguments = {}
# we save the response as dict because it contains resp_id etc.
meta = responses.to_dict()
# remove output from meta because it contains toolcalls, reasoning, text etc.
meta.pop("output")
if logprobs:
meta["logprobs"] = logprobs
return ChatMessage.from_assistant(
text=responses.output_text if responses.output_text else None,
reasoning=reasoning,
tool_calls=tool_calls,
meta=meta,
)
def _convert_response_chunk_to_streaming_chunk( # noqa: PLR0911
chunk: ResponseStreamEvent, previous_chunks: list[StreamingChunk], component_info: ComponentInfo | None = None
) -> StreamingChunk:
"""
Converts the streaming response chunk from the OpenAI Responses API to a StreamingChunk.
:param chunk: The chunk returned by the OpenAI Responses API.
:param previous_chunks: A list of previously received StreamingChunks.
:param component_info: An optional `ComponentInfo` object containing information about the component that
generated the chunk, such as the component name and type.
:returns:
A StreamingChunk object representing the content of the chunk from the OpenAI Responses API.
"""
if chunk.type == "response.output_item.added":
# Responses API always returns reasoning chunks even if there is no summary
if chunk.item.type == "reasoning":
reasoning = ReasoningContent(reasoning_text="", extra=chunk.item.to_dict())
return StreamingChunk(
content="",
component_info=component_info,
index=chunk.output_index,
reasoning=reasoning,
start=True,
meta={"received_at": datetime.now().isoformat()},
)
# the function name is only streamed at the start and end of the function call
if chunk.item.type == "function_call":
tool_call = ToolCallDelta(
index=chunk.output_index, id=chunk.item.id, tool_name=chunk.item.name, extra=chunk.item.to_dict()
)
return StreamingChunk(
content="",
component_info=component_info,
index=chunk.output_index,
tool_calls=[tool_call],
start=True,
meta={"received_at": datetime.now().isoformat()},
)
elif chunk.type == "response.output_item.done":
# The done event carries the completed reasoning item, which includes encrypted_content
# when include=["reasoning.encrypted_content"] was requested. Without this handler the
# event falls through to the generic default and reasoning=None, so encrypted_content
# is never available for multi-turn conversations.
if chunk.item.type == "reasoning":
if chunk.item.content:
logger.warning(
"OpenAI returned a non-empty 'content' field on a reasoning item ({_id}). "
"This field is currently undocumented and was never observed in practice. "
"The content is preserved in ReasoningContent.extra['content'] but is NOT "
"reflected in ReasoningContent.reasoning_text. Please report this at "
"https://github.com/deepset-ai/haystack/issues so we can update the mapping.",
_id=chunk.item.id,
)
reasoning = ReasoningContent(reasoning_text="", extra=chunk.item.to_dict())
return StreamingChunk(
content="",
component_info=component_info,
index=chunk.output_index,
reasoning=reasoning,
meta={"received_at": datetime.now().isoformat()},
)
elif chunk.type == "response.completed":
# This means a full response is finished
# If there are tool_calls present in the final output we mark finish_reason as tool_calls otherwise it's
# marked as stop
return StreamingChunk(
content="",
component_info=component_info,
finish_reason="tool_calls" if any(o.type == "function_call" for o in chunk.response.output) else "stop",
meta={**chunk.to_dict(), "received_at": datetime.now().isoformat()},
)
elif chunk.type == "response.output_text.delta":
# Start is determined by checking if this is the first text delta event of a new output_index
# 1) Check if all previous chunks have different output_index
# 2) If any chunks do have the same output_index, check if they have content
# If none of them have content, this is the start of a new text output
start = all(c.index != chunk.output_index for c in previous_chunks) or all(
c.content == "" for c in previous_chunks if c.index == chunk.output_index
)
return StreamingChunk(
content=chunk.delta,
component_info=component_info,
index=chunk.output_index,
start=start,
meta={**chunk.to_dict(), "received_at": datetime.now().isoformat()},
)
elif chunk.type == "response.reasoning_summary_text.delta":
# We remove the delta from the extra because it is already in the reasoning_text
# Remaining information needs to be saved for chat message
extra = chunk.to_dict()
extra.pop("delta")
reasoning = ReasoningContent(reasoning_text=chunk.delta, extra=extra)
return StreamingChunk(
content="",
component_info=component_info,
index=chunk.output_index,
reasoning=reasoning,
meta={"received_at": datetime.now().isoformat()},
)
# the function arguments are streamed in parts
# function name is not passed in these chunks
elif chunk.type == "response.function_call_arguments.delta":
arguments = chunk.delta
extra = chunk.to_dict()
extra.pop("delta")
# in delta of tool calls there is no call_id so we use the item_id which is the function call id
tool_call = ToolCallDelta(index=chunk.output_index, id=chunk.item_id, arguments=arguments, extra=extra)
return StreamingChunk(
content="",
component_info=component_info,
index=chunk.output_index,
tool_calls=[tool_call],
meta={"received_at": datetime.now().isoformat()},
)
# we return rest of the chunk as is
return StreamingChunk(
content="",
component_info=component_info,
index=getattr(chunk, "output_index", None),
meta={**chunk.to_dict(), "received_at": datetime.now().isoformat()},
)
def _convert_streaming_chunks_to_chat_message(chunks: list[StreamingChunk]) -> ChatMessage:
"""
Connects the streaming chunks into a single ChatMessage.
:param chunks: The list of all `StreamingChunk` objects.
:returns: The ChatMessage.
"""
# Get the full text by concatenating all text chunks
text = "".join([chunk.content for chunk in chunks])
logprobs = []
for chunk in chunks:
if chunk.meta.get("logprobs"):
logprobs.append(chunk.meta.get("logprobs"))
# Gather reasoning information if present
reasoning_id = None
reasoning_text = ""
for chunk in chunks:
if chunk.reasoning:
reasoning_text += chunk.reasoning.reasoning_text
if chunk.reasoning.extra.get("id"):
reasoning_id = chunk.reasoning.extra.get("id")
# Process tool calls if present in any chunk
tool_call_data: dict[str, dict[str, Any]] = {} # Track tool calls by id
for chunk in chunks:
if chunk.tool_calls:
for tool_call in chunk.tool_calls:
# here the tool_call.id is fc_id not call_id
assert tool_call.id is not None
# We use the tool call id to track the tool call across chunks
if tool_call.id not in tool_call_data:
tool_call_data[tool_call.id] = {"name": "", "arguments": ""}
if tool_call.arguments is not None:
tool_call_data[tool_call.id]["arguments"] += tool_call.arguments
# We capture the tool name from one of the chunks
if tool_call.tool_name is not None:
tool_call_data[tool_call.id]["name"] = tool_call.tool_name
# We capture the call_id from one of the chunks
if tool_call.extra and "call_id" in tool_call.extra:
tool_call_data[tool_call.id]["extra"] = {"call_id": tool_call.extra["call_id"]}
# Convert accumulated tool call data into ToolCall objects
tool_calls = []
sorted_keys = sorted(tool_call_data.keys())
for key in sorted_keys:
tool_call_dict = tool_call_data[key]
try:
arguments = json.loads(tool_call_dict.get("arguments", "{}")) if tool_call_dict.get("arguments") else {}
extra: dict[str, Any] = tool_call_dict.get("extra", {})
tool_calls.append(ToolCall(id=key, tool_name=tool_call_dict["name"], arguments=arguments, extra=extra))
except json.JSONDecodeError:
logger.warning(
"The LLM provider returned a malformed JSON string for tool call arguments. This tool call "
"will be skipped. To always generate a valid JSON, set `tools_strict` to `True`. "
"Tool call ID: {_id}, Tool name: {_name}, Arguments: {_arguments}",
_id=key,
_name=tool_call_dict["name"],
_arguments=tool_call_dict["arguments"],
)
# We dump the entire final response into meta to be consistent with non-streaming response
final_response = chunks[-1].meta.get("response") or {}
final_response.pop("output", None)
if logprobs:
final_response["logprobs"] = logprobs
# Add reasoning content if id is available
# Note: the API expects a reasoning id even if there is no reasoning text
# function calls without reasoning ids are not supported by the API
reasoning = None
if reasoning_id:
# Preserve all extra fields from streaming chunks (e.g. encrypted_content) while ensuring id and
# type are present
reasoning_extra = {}
for chunk in chunks:
if chunk.reasoning and chunk.reasoning.extra:
reasoning_extra.update(chunk.reasoning.extra)
# Ensure id and type are always set, but don't override if already present
reasoning_extra.setdefault("id", reasoning_id)
reasoning_extra.setdefault("type", "reasoning")
reasoning = ReasoningContent(reasoning_text=reasoning_text, extra=reasoning_extra)
return ChatMessage.from_assistant(
text=text or None, tool_calls=tool_calls, meta=final_response, reasoning=reasoning
)
def _convert_chat_message_to_responses_api_format(message: ChatMessage) -> list[dict[str, Any]]:
"""
Convert a ChatMessage to the dictionary format expected by OpenAI's Responses API.
:param message: The ChatMessage to convert to OpenAI's Responses API format.
:returns:
The ChatMessage in the format expected by OpenAI's Responses API.
:raises ValueError:
If the message format is invalid.
"""
def convert_part(part: Any) -> dict[str, str | None]:
if isinstance(part, TextContent):
return {"type": "input_text", "text": part.text}
if isinstance(part, ImageContent):
return {
"type": "input_image",
# If no MIME type is provided, default to JPEG. OpenAI API appears to tolerate MIME type mismatches.
"image_url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}",
}
if isinstance(part, FileContent):
return {
"type": "input_file",
# Filename is optional but if not provided, OpenAI expects a file_id of a previous file upload.
# We use a dummy filename to avoid this issue.
"filename": part.filename or "filename",
"file_data": f"data:{part.mime_type or 'application/pdf'};base64,{part.base64_data}",
}
raise ValueError(f"Unsupported content type: {type(part)}")
text_contents = message.texts
tool_calls = message.tool_calls
tool_call_results = message.tool_call_results
images = message.images
reasonings = message.reasonings
files = message.files
if not any([text_contents, tool_calls, tool_call_results, images, reasonings, files]):
raise ValueError(
"""A `ChatMessage` must contain at least one `TextContent`, `ToolCall`, `ToolCallResult`,
`ImageContent`, `FileContent`, or `ReasoningContent`."""
)
if len(tool_call_results) > 0 and len(message._content) > 1:
raise ValueError(
"For OpenAI compatibility, a `ChatMessage` with a `ToolCallResult` cannot contain any other content."
)
formatted_messages: list[dict[str, Any]] = []
openai_msg: dict[str, Any] = {"role": message._role.value}
if message._name is not None:
openai_msg["name"] = message._name
# user message
if message._role.value == "user":
content = [convert_part(part) for part in message._content]
openai_msg["content"] = content
return [openai_msg]
# tool message
if tool_call_results:
formatted_tool_results = []
for result in tool_call_results:
if result.origin.id is not None:
# Handle multimodal tool results (list of TextContent/ImageContent/FileContent)
if isinstance(result.result, list):
output_content = [convert_part(part) for part in result.result]
elif isinstance(result.result, str):
output_content = [{"type": "input_text", "text": result.result}]
else:
raise ValueError(f"Unsupported tool result: {result.result}")
tool_result = {
"type": "function_call_output",
"call_id": result.origin.extra.get("call_id") if result.origin.extra else "",
"output": output_content,
}
formatted_tool_results.append(tool_result)
formatted_messages.extend(formatted_tool_results)
# Note: the API expects a reasoning id even if there is no reasoning text
# function calls without reasoning ids are not supported by the API
if reasonings:
formatted_reasonings = []
for reasoning in reasonings:
# Streaming events (e.g. response.reasoning_summary_text.delta) store event-level
# fields like item_id, output_index, summary_index, event_id, sequence_number into
# reasoning.extra. Those are not valid reasoning input item fields and the API
# rejects them with "Unknown parameter" when sent back in subsequent turns.
# Valid fields per ResponseReasoningItem schema: id, type, summary (handled separately),
# content, encrypted_content, status.
_valid_reasoning_fields = {"id", "type", "encrypted_content", "status", "content"}
filtered_extra = {k: v for k, v in reasoning.extra.items() if k in _valid_reasoning_fields}
reasoning_item = {"summary": [], **filtered_extra}
if reasoning.reasoning_text:
reasoning_item["summary"] = [{"text": reasoning.reasoning_text, "type": "summary_text"}]
formatted_reasonings.append(reasoning_item)
formatted_messages.extend(formatted_reasonings)
if tool_calls:
formatted_tool_calls = []
for tc in tool_calls:
openai_tool_call = {
"type": "function_call",
# We disable ensure_ascii so special chars like emojis are not converted
"name": tc.tool_name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False),
"id": tc.id,
"call_id": tc.extra.get("call_id") if tc.extra else "",
}
formatted_tool_calls.append(openai_tool_call)
formatted_messages.extend(formatted_tool_calls)
# system and assistant messages
if text_contents:
openai_msg["content"] = " ".join(text_contents)
formatted_messages.append(openai_msg)
return formatted_messages