Coverage for haystack/components/generators/chat/openai_responses.py: 92%
365 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 09:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 09:42 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import json
6import os
7from datetime import datetime
8from typing import Any, ClassVar
10from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
11from openai.lib._pydantic import to_strict_json_schema
12from openai.types.responses import ParsedResponse, Response, ResponseOutputRefusal, ResponseStreamEvent
13from pydantic import BaseModel
15from haystack import component, default_from_dict, default_to_dict, logging
16from haystack.components.generators.utils import _normalize_messages, _serialize_object
17from haystack.dataclasses import (
18 ChatMessage,
19 ChatRole,
20 ComponentInfo,
21 FileContent,
22 ImageContent,
23 ReasoningContent,
24 StreamingCallbackT,
25 StreamingChunk,
26 SyncStreamingCallbackT,
27 TextContent,
28 ToolCall,
29 ToolCallDelta,
30 select_streaming_callback,
31)
32from haystack.dataclasses.chat_message import _parse_openai_tool_call_arguments
33from haystack.dataclasses.streaming_chunk import FinishReason, _invoke_streaming_callback
34from haystack.tools import (
35 ToolsType,
36 _check_duplicate_tool_names,
37 deserialize_tools_or_toolset_inplace,
38 flatten_tools_or_toolsets,
39 serialize_tools_or_toolset,
40)
41from haystack.utils import Secret, deserialize_callable, serialize_callable
42from haystack.utils.http_client import init_http_client
44logger = logging.getLogger(__name__)
46_INCOMPLETE_REASON_MAPPING: dict[str, FinishReason] = {
47 "max_output_tokens": "length",
48 "content_filter": "content_filter",
49}
52def _get_response_finish_reason(response: Response | ParsedResponse) -> FinishReason:
53 """Return the normalized finish reason for a terminal OpenAI Responses API response."""
54 if (
55 response.status == "incomplete"
56 and response.incomplete_details is not None
57 and response.incomplete_details.reason is not None
58 ):
59 finish_reason = _INCOMPLETE_REASON_MAPPING.get(response.incomplete_details.reason)
60 if finish_reason is not None:
61 return finish_reason
62 if any(output.type == "function_call" for output in response.output):
63 return "tool_calls"
64 return "stop"
67@component
68class OpenAIResponsesChatGenerator:
69 """
70 Completes chats using OpenAI's Responses API.
72 It works with OpenAI's GPT and o-series models and supports streaming responses
73 from OpenAI API. It uses [ChatMessage](https://docs.haystack.deepset.ai/docs/chatmessage)
74 format in input and output.
76 You can customize how the text is generated by passing parameters to the
77 OpenAI API. Use the `**generation_kwargs` argument when you initialize
78 the component or when you run it. Any parameter that works with
79 `openai.Responses.create` will work here too.
81 For details on OpenAI API parameters, see
82 [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses).
84 ### Usage example
86 ```python
87 from haystack.components.generators.chat import OpenAIResponsesChatGenerator
88 from haystack.dataclasses import ChatMessage
90 messages = [ChatMessage.from_user("What's Natural Language Processing?")]
92 client = OpenAIResponsesChatGenerator(generation_kwargs={"reasoning": {"effort": "low", "summary": "auto"}})
93 response = client.run(messages)
94 print(response)
95 ```
96 """
98 SUPPORTED_MODELS: ClassVar[list[str]] = [
99 "gpt-5-mini",
100 "gpt-5-nano",
101 "gpt-5",
102 "gpt-5.1",
103 "gpt-5.2",
104 "gpt-5.2-pro",
105 "gpt-5.4",
106 "gpt-5-pro",
107 "gpt-4.1",
108 "gpt-4.1-mini",
109 "gpt-4.1-nano",
110 "gpt-4o",
111 "gpt-4o-mini",
112 "o1",
113 "o1-mini",
114 "o1-pro",
115 "o3",
116 "o3-mini",
117 "o3-pro",
118 "o4-mini",
119 ]
120 """A non-exhaustive list of chat models supported by this component.
121 See https://platform.openai.com/docs/models for the full list and snapshot IDs."""
123 def __init__(
124 self,
125 *,
126 api_key: Secret = Secret.from_env_var("OPENAI_API_KEY"),
127 model: str = "gpt-5-mini",
128 streaming_callback: StreamingCallbackT | None = None,
129 api_base_url: str | None = None,
130 organization: str | None = None,
131 generation_kwargs: dict[str, Any] | None = None,
132 timeout: float | None = None,
133 max_retries: int | None = None,
134 tools: ToolsType | list[dict] | None = None,
135 tools_strict: bool = False,
136 http_client_kwargs: dict[str, Any] | None = None,
137 ) -> None:
138 """
139 Creates an instance of OpenAIResponsesChatGenerator. Uses OpenAI's gpt-5-mini by default.
141 Before initializing the component, you can set the 'OPENAI_TIMEOUT' and 'OPENAI_MAX_RETRIES'
142 environment variables to override the `timeout` and `max_retries` parameters respectively
143 in the OpenAI client.
145 :param api_key: The OpenAI API key.
146 You can set it with an environment variable `OPENAI_API_KEY`, or pass with this parameter
147 during initialization.
148 :param model: The name of the model to use.
149 :param streaming_callback: A callback function that is called when a new token is received from the stream.
150 The callback function accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk)
151 as an argument.
152 :param api_base_url: An optional base URL.
153 :param organization: Your organization ID, defaults to `None`. See
154 [production best practices](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization).
155 :param generation_kwargs: Other parameters to use for the model. These parameters are sent
156 directly to the OpenAI endpoint.
157 See OpenAI [documentation](https://platform.openai.com/docs/api-reference/responses) for
158 more details.
159 Some of the supported parameters:
160 - `temperature`: What sampling temperature to use. Higher values like 0.8 will make the output more random,
161 while lower values like 0.2 will make it more focused and deterministic.
162 - `top_p`: An alternative to sampling with temperature, called nucleus sampling, where the model
163 considers the results of the tokens with top_p probability mass. For example, 0.1 means only the tokens
164 comprising the top 10% probability mass are considered.
165 - `previous_response_id`: The ID of the previous response.
166 Use this to create multi-turn conversations.
167 - `text_format`: A Pydantic model that enforces the structure of the model's response.
168 If provided, the output will always be validated against this
169 format (unless the model returns a tool call).
170 For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
171 - `text`: A JSON schema that enforces the structure of the model's response.
172 If provided, the output will always be validated against this
173 format (unless the model returns a tool call).
174 Notes:
175 - Both JSON Schema and Pydantic models are supported for latest models starting from GPT-4o.
176 - If both are provided, `text_format` takes precedence and json schema passed to `text` is ignored.
177 - Currently, this component doesn't support streaming for structured outputs.
178 - Older models only support basic version of structured outputs through `{"type": "json_object"}`.
179 For detailed information on JSON mode, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs#json-mode).
180 - `reasoning`: A dictionary of parameters for reasoning. For example:
181 - `summary`: The summary of the reasoning.
182 - `effort`: The level of effort to put into the reasoning. Can be `low`, `medium` or `high`.
183 - `generate_summary`: Whether to generate a summary of the reasoning.
184 - `mode`: The reasoning mode. Can be `standard`, or `pro`. Supported since GPT-5.6.
185 Note: OpenAI does not return the reasoning tokens, but we can view summary if its enabled.
186 If a provider returns the raw reasoning as `reasoning_text` content instead,
187 it is mapped to `ReasoningContent.reasoning_text` when no summary is returned.
188 For details, see the [OpenAI Reasoning documentation](https://platform.openai.com/docs/guides/reasoning).
189 - `include`: Specify additional output data to include in the model response. Supported values are:
190 - web_search_call.action.sources: Include the sources of the web search tool call.
191 - code_interpreter_call.outputs: Includes the outputs of python code execution in code interpreter tool
192 call items.
193 - computer_call_output.output.image_url: Include image urls from the computer call output.
194 - file_search_call.results: Include the search results of the file search tool call.
195 - message.input_image.image_url: Include image urls from the input message.
196 - message.output_text.logprobs: Include logprobs with assistant messages.
197 - reasoning.encrypted_content: Includes an encrypted version of reasoning tokens in reasoning item
198 outputs. This enables reasoning items to be used in multi-turn conversations when using the
199 Responses API statelessly (like when the store parameter is set to false, or when an organization
200 is enrolled in the zero data retention program).
201 :param timeout:
202 Timeout for OpenAI client calls. If not set, it defaults to either the
203 `OPENAI_TIMEOUT` environment variable, or 30 seconds.
204 :param max_retries:
205 Maximum number of retries to contact OpenAI after an internal error.
206 If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
207 :param tools:
208 The tools that the model can use to prepare calls. This parameter can accept either a
209 mixed list of Haystack `Tool` objects and Haystack `Toolset`. Or you can pass a dictionary of
210 OpenAI/MCP tool definitions.
211 Note: You cannot pass OpenAI/MCP tools and Haystack tools together.
212 For details on tool support, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses/create#responses-create-tools).
213 :param tools_strict:
214 Whether to enable strict schema adherence for tool calls. If set to `False`, the model may not exactly
215 follow the schema provided in the `parameters` field of the tool definition. In Response API, tool calls
216 are strict by default.
217 :param http_client_kwargs:
218 A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
219 For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
221 """
222 self.api_key = api_key
223 self.model = model
224 self.generation_kwargs = generation_kwargs or {}
225 self.streaming_callback = streaming_callback
226 self.api_base_url = api_base_url
227 self.organization = organization
228 self.timeout = timeout
229 self.max_retries = max_retries
230 self.tools = tools # Store tools as-is, whether it's a list or a Toolset
231 self.tools_strict = tools_strict
232 self.http_client_kwargs = http_client_kwargs
234 self.client: OpenAI | None = None
235 self.async_client: AsyncOpenAI | None = None
237 def _client_kwargs(self) -> dict[str, Any]:
238 timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
239 max_retries = (
240 self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
241 )
242 resolved_api_key = self.api_key.resolve_value() if isinstance(self.api_key, Secret) else self.api_key
243 return {
244 "api_key": resolved_api_key,
245 "organization": self.organization,
246 "base_url": self.api_base_url,
247 "timeout": timeout,
248 "max_retries": max_retries,
249 }
251 def warm_up(self) -> None:
252 """
253 Initialize the synchronous OpenAI client.
254 """
255 if self.client is None:
256 # openai>=3 annotates http_client as httpx2, but legacy httpx clients are supported at runtime.
257 # https://github.com/openai/openai-python/blob/main/httpx2.md
258 http_client = init_http_client(self.http_client_kwargs, async_client=False)
259 self.client = OpenAI(
260 http_client=http_client, # type: ignore[arg-type]
261 **self._client_kwargs(),
262 )
264 async def warm_up_async(self) -> None: # noqa: RUF029
265 """
266 Initialize the asynchronous OpenAI client on the serving event loop.
267 """
268 if self.async_client is None:
269 # openai>=3 annotates http_client as httpx2, but legacy httpx clients are supported at runtime.
270 # https://github.com/openai/openai-python/blob/main/httpx2.md
271 http_client = init_http_client(self.http_client_kwargs, async_client=True)
272 self.async_client = AsyncOpenAI(
273 http_client=http_client, # type: ignore[arg-type]
274 **self._client_kwargs(),
275 )
277 def close(self) -> None:
278 """
279 Releases the synchronous OpenAI client.
280 """
281 if self.client is not None:
282 self.client.close()
283 self.client = None
285 async def close_async(self) -> None:
286 """
287 Releases the asynchronous OpenAI client.
288 """
289 if self.async_client is not None:
290 await self.async_client.close()
291 self.async_client = None
293 def _get_telemetry_data(self) -> dict[str, Any]:
294 """
295 Data that is sent to Posthog for usage analytics.
296 """
297 return {"model": self.model}
299 def to_dict(self) -> dict[str, Any]:
300 """
301 Serialize this component to a dictionary.
303 :returns:
304 The serialized component as a dictionary.
305 """
306 callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
307 generation_kwargs = self.generation_kwargs.copy()
308 text_format = generation_kwargs.pop("text_format", None)
310 # If the response format is a Pydantic model, it's converted to openai's json schema format
311 # If it's already a json schema, it's left as is
312 if text_format and isinstance(text_format, type) and issubclass(text_format, BaseModel):
313 json_schema = {
314 "format": {
315 "type": "json_schema",
316 "name": text_format.__name__,
317 "strict": True,
318 "schema": to_strict_json_schema(text_format),
319 }
320 }
321 # json schema needs to be passed to text parameter instead of text_format
322 generation_kwargs["text"] = json_schema
324 # OpenAI/MCP tools are passed as list of dictionaries
325 serialized_tools: dict[str, Any] | list[dict[str, Any]] | None
326 if self.tools and isinstance(self.tools, list) and isinstance(self.tools[0], dict):
327 # mypy can't infer that self.tools is list[dict] here
328 serialized_tools = self.tools
329 else:
330 serialized_tools = serialize_tools_or_toolset(self.tools) # type: ignore[arg-type]
332 return default_to_dict(
333 self,
334 model=self.model,
335 streaming_callback=callback_name,
336 api_base_url=self.api_base_url,
337 organization=self.organization,
338 generation_kwargs=generation_kwargs,
339 api_key=self.api_key,
340 timeout=self.timeout,
341 max_retries=self.max_retries,
342 tools=serialized_tools,
343 tools_strict=self.tools_strict,
344 http_client_kwargs=self.http_client_kwargs,
345 )
347 @classmethod
348 def from_dict(cls, data: dict[str, Any]) -> "OpenAIResponsesChatGenerator":
349 """
350 Deserialize this component from a dictionary.
352 :param data: The dictionary representation of this component.
353 :returns:
354 The deserialized component instance.
355 """
356 # we only deserialize the tools if they are haystack tools
357 # because openai tools are not serialized in the same way
358 tools = data.get("init_parameters", {}).get("tools")
359 if tools and (
360 isinstance(tools, dict)
361 and "type" in tools
362 and "data" in tools
363 or isinstance(tools, list)
364 and isinstance(tools[0], dict)
365 and "type" in tools[0]
366 and "data" in tools[0]
367 ):
368 deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
370 init_params = data.get("init_parameters", {})
371 serialized_callback_handler = init_params.get("streaming_callback")
373 if serialized_callback_handler:
374 data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
375 return default_from_dict(cls, data)
377 @component.output_types(replies=list[ChatMessage])
378 def run(
379 self,
380 messages: list[ChatMessage] | str,
381 *,
382 streaming_callback: StreamingCallbackT | None = None,
383 generation_kwargs: dict[str, Any] | None = None,
384 tools: ToolsType | list[dict] | None = None,
385 tools_strict: bool | None = None,
386 ) -> dict[str, list[ChatMessage]]:
387 """
388 Invokes response generation based on the provided messages and generation parameters.
390 :param messages:
391 A list of ChatMessage instances representing the input messages.
392 :param streaming_callback:
393 A callback function that is called when a new token is received from the stream.
394 :param generation_kwargs:
395 Additional keyword arguments for text generation. These are merged per key with the
396 `generation_kwargs` passed at initialization: keys provided here take precedence, keys set
397 only at initialization are kept.
398 For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses/create).
399 :param tools:
400 The tools that the model can use to prepare calls. If set, it will override the
401 `tools` parameter set during component initialization. This parameter can accept either a
402 mixed list of Haystack `Tool` objects and Haystack `Toolset`. Or you can pass a dictionary of
403 OpenAI/MCP tool definitions.
404 Note: You cannot pass OpenAI/MCP tools and Haystack tools together.
405 For details on tool support, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses/create#responses-create-tools).
406 :param tools_strict:
407 Whether to enable strict schema adherence for tool calls. If set to `False`, the model may not exactly
408 follow the schema provided in the `parameters` field of the tool definition. In Response API, tool calls
409 are strict by default.
410 If set, it will override the `tools_strict` parameter set during component initialization.
412 :returns:
413 A dictionary with the following key:
414 - `replies`: A list containing the generated responses as ChatMessage instances.
415 """
416 self.warm_up()
418 messages = _normalize_messages(messages)
420 if len(messages) == 0:
421 return {"replies": []}
423 streaming_callback = select_streaming_callback(
424 init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=False
425 )
426 responses: Stream[ResponseStreamEvent] | Response
428 api_args = self._prepare_api_call(
429 messages=messages,
430 streaming_callback=streaming_callback,
431 generation_kwargs=generation_kwargs,
432 tools=tools,
433 tools_strict=tools_strict,
434 )
435 openai_endpoint = api_args.pop("openai_endpoint")
436 assert self.client is not None # mypy: client is built by warm_up above
437 openai_endpoint_method = getattr(self.client.responses, openai_endpoint)
438 responses = openai_endpoint_method(**api_args)
440 if streaming_callback is not None:
441 response_output = self._handle_stream_response(
442 responses, # type: ignore
443 streaming_callback,
444 )
445 else:
446 assert isinstance(responses, Response), "Unexpected response type for non-streaming request."
447 response_output = [_convert_response_to_chat_message(responses)]
448 return {"replies": response_output}
450 @component.output_types(replies=list[ChatMessage])
451 async def run_async(
452 self,
453 messages: list[ChatMessage] | str,
454 *,
455 streaming_callback: StreamingCallbackT | None = None,
456 generation_kwargs: dict[str, Any] | None = None,
457 tools: ToolsType | list[dict] | None = None,
458 tools_strict: bool | None = None,
459 ) -> dict[str, list[ChatMessage]]:
460 """
461 Asynchronously invokes response generation based on the provided messages and generation parameters.
463 This is the asynchronous version of the `run` method. It has the same parameters and return values
464 but can be used with `await` in async code.
466 :param messages:
467 A list of ChatMessage instances representing the input messages.
468 :param streaming_callback:
469 A callback function that is called when a new token is received from the stream. Async callbacks are
470 preferred; a sync callback is accepted but will run synchronously on the event loop and may block it.
471 :param generation_kwargs:
472 Additional keyword arguments for text generation. These are merged per key with the
473 `generation_kwargs` passed at initialization: keys provided here take precedence, keys set
474 only at initialization are kept.
475 For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/responses/create).
476 :param tools:
477 A list of tools or a Toolset for which the model can prepare calls. If set, it will override the
478 `tools` parameter set during component initialization. This parameter can accept either a list of
479 mixed list of Haystack `Tool` objects and Haystack `Toolset`. Or you can pass a dictionary of
480 OpenAI/MCP tool definitions.
481 Note: You cannot pass OpenAI/MCP tools and Haystack tools together.
482 :param tools_strict:
483 Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
484 the schema provided in the `parameters` field of the tool definition, but this may increase latency.
485 If set, it will override the `tools_strict` parameter set during component initialization.
487 :returns:
488 A dictionary with the following key:
489 - `replies`: A list containing the generated responses as ChatMessage instances.
490 """
491 await self.warm_up_async()
493 messages = _normalize_messages(messages)
495 # validate and select the streaming callback
496 streaming_callback = select_streaming_callback(
497 init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=True
498 )
499 responses: AsyncStream[ResponseStreamEvent] | Response
501 if len(messages) == 0:
502 return {"replies": []}
504 api_args = self._prepare_api_call(
505 messages=messages,
506 streaming_callback=streaming_callback,
507 generation_kwargs=generation_kwargs,
508 tools=tools,
509 tools_strict=tools_strict,
510 )
512 openai_endpoint = api_args.pop("openai_endpoint")
513 assert self.async_client is not None # mypy: async_client is built by warm_up_async above
514 openai_endpoint_method = getattr(self.async_client.responses, openai_endpoint)
515 responses = await openai_endpoint_method(**api_args)
517 if streaming_callback is not None:
518 response_output = await self._handle_async_stream_response(
519 responses, # type: ignore
520 streaming_callback,
521 )
523 else:
524 assert isinstance(responses, Response), "Unexpected response type for non-streaming request."
525 response_output = [_convert_response_to_chat_message(responses)]
526 return {"replies": response_output}
528 def _prepare_api_call( # noqa: PLR0913
529 self,
530 *,
531 messages: list[ChatMessage],
532 streaming_callback: StreamingCallbackT | None = None,
533 generation_kwargs: dict[str, Any] | None = None,
534 tools: ToolsType | list[dict] | None = None,
535 tools_strict: bool | None = None,
536 ) -> dict[str, Any]:
537 # update generation kwargs by merging with the generation kwargs passed to the run method
538 generation_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
539 generation_kwargs = self._resolve_flattened_kwargs(generation_kwargs)
541 # adapt ChatMessage(s) to the format expected by the OpenAI API
542 openai_formatted_messages: list[dict[str, Any]] = []
543 for message in messages:
544 openai_formatted_messages.extend(_convert_chat_message_to_responses_api_format(message))
546 tools = tools if tools is not None else self.tools
547 tools_strict = tools_strict if tools_strict is not None else self.tools_strict
549 openai_tools = {}
550 # Build tool definitions
551 if tools:
552 tool_definitions: list[Any] = []
553 if isinstance(tools, list) and isinstance(tools[0], dict):
554 # Predefined OpenAI/MCP-style tools
555 tool_definitions = tools
557 # Convert all tool objects to the correct OpenAI-compatible structure
558 else:
559 # mypy can't infer that tools is ToolsType here
560 flattened_tools = flatten_tools_or_toolsets(tools) # type: ignore[arg-type]
561 _check_duplicate_tool_names(flattened_tools)
562 for t in flattened_tools:
563 function_spec = {**t.tool_spec}
564 if not tools_strict:
565 function_spec["strict"] = False
566 # Copy the parameters schema before editing it. tool_spec exposes
567 # Tool.parameters by reference, so mutating it here would permanently alter
568 # the user's Tool (and any other generator that shares the same Tool instance).
569 function_spec["parameters"] = {**function_spec["parameters"], "additionalProperties": False}
570 tool_definitions.append({"type": "function", **function_spec})
572 openai_tools = {"tools": tool_definitions}
574 base_args = {"model": self.model, "input": openai_formatted_messages, **openai_tools, **generation_kwargs}
576 # if `text_format` is provided, we use the `parse` endpoint for response type parsing
577 if generation_kwargs.get("text_format"):
578 # if both `text_format` and `text` are provided, `text_format` takes precedence
579 # and json schema passed to `text` is ignored
580 return {**base_args, "stream": streaming_callback is not None, "openai_endpoint": "parse"}
581 # we pass a key `openai_endpoint` as a hint to the run method to use the create or parse endpoint
582 # this key will be removed before the API call is made
583 return {**base_args, "stream": streaming_callback is not None, "openai_endpoint": "create"}
585 def _resolve_flattened_kwargs(self, generation_kwargs: dict[str, Any]) -> dict[str, Any]:
586 generation_kwargs = generation_kwargs.copy()
588 # avoid mutating the caller's dict
589 reasoning_overrides = {}
590 reasoning_effort = generation_kwargs.pop("reasoning_effort", None)
591 if reasoning_effort is not None:
592 reasoning_overrides["effort"] = reasoning_effort
594 reasoning_summary = generation_kwargs.pop("reasoning_summary", None)
595 if reasoning_summary is not None:
596 reasoning_overrides["summary"] = reasoning_summary
598 reasoning_mode = generation_kwargs.pop("reasoning_mode", None)
599 if reasoning_mode is not None:
600 reasoning_overrides["mode"] = reasoning_mode
602 if reasoning_overrides:
603 generation_kwargs["reasoning"] = {**generation_kwargs.get("reasoning", {}), **reasoning_overrides}
605 include_reasoning_encrypted_content = generation_kwargs.pop("include_reasoning_encrypted_content", None)
606 if include_reasoning_encrypted_content is True:
607 include = generation_kwargs.get("include", [])
608 if "reasoning.encrypted_content" not in include:
609 generation_kwargs["include"] = [*include, "reasoning.encrypted_content"]
611 verbosity = generation_kwargs.pop("verbosity", None)
612 if verbosity is not None:
613 generation_kwargs["text"] = {**generation_kwargs.get("text", {}), "verbosity": verbosity}
615 return generation_kwargs
617 def _handle_stream_response(self, responses: Stream, callback: SyncStreamingCallbackT) -> list[ChatMessage]:
618 component_info = ComponentInfo.from_component(self)
619 chunks: list[StreamingChunk] = []
621 for openai_chunk in responses:
622 chunk_delta = _convert_response_chunk_to_streaming_chunk(
623 chunk=openai_chunk, previous_chunks=chunks, component_info=component_info
624 )
625 chunks.append(chunk_delta)
626 callback(chunk_delta)
627 chat_message = _convert_streaming_chunks_to_chat_message(chunks=chunks)
628 return [chat_message]
630 async def _handle_async_stream_response(
631 self, responses: AsyncStream, callback: StreamingCallbackT
632 ) -> list[ChatMessage]:
633 component_info = ComponentInfo.from_component(self)
634 chunks: list[StreamingChunk] = []
635 async for openai_chunk in responses:
636 chunk_delta = _convert_response_chunk_to_streaming_chunk(
637 chunk=openai_chunk, previous_chunks=chunks, component_info=component_info
638 )
639 chunks.append(chunk_delta)
640 await _invoke_streaming_callback(callback, chunk_delta)
641 chat_message = _convert_streaming_chunks_to_chat_message(chunks=chunks)
642 return [chat_message]
645def _convert_response_to_chat_message(responses: Response | ParsedResponse) -> ChatMessage:
646 """
647 Converts the non-streaming response from the OpenAI API to a ChatMessage.
649 :param responses: The responses returned by the OpenAI API.
650 :returns: The ChatMessage.
651 """
653 tool_calls = []
654 reasoning = None
655 logprobs: list[dict] = []
656 for output in responses.output:
657 if isinstance(output, ResponseOutputRefusal):
658 logger.warning("OpenAI returned a refusal output: {output}", output=output)
659 continue
661 if output.type == "message":
662 for content in output.content:
663 if hasattr(content, "logprobs") and content.logprobs is not None:
664 logprobs.append(_serialize_object(content.logprobs))
666 if output.type == "reasoning":
667 extra = output.to_dict()
668 # we dont need the summary in the extra
669 extra.pop("summary")
670 # OpenAI returns reasoning summaries, other providers may return raw reasoning as `reasoning_text` content
671 reasoning_text = "\n".join(summary.text for summary in output.summary)
672 if not reasoning_text:
673 reasoning_text = "\n".join(part.text for part in output.content or [] if part.type == "reasoning_text")
674 reasoning = ReasoningContent(reasoning_text=reasoning_text, extra=extra)
676 elif output.type == "function_call":
677 try:
678 arguments = _parse_openai_tool_call_arguments(output.arguments)
679 tool_calls.append(
680 ToolCall(
681 id=output.id, tool_name=output.name, arguments=arguments, extra={"call_id": output.call_id}
682 )
683 )
684 except json.JSONDecodeError:
685 logger.warning(
686 "The LLM provider returned a malformed JSON string for tool call arguments. This tool call "
687 "will be skipped. To always generate a valid JSON, set `tools_strict` to `True`. "
688 "Tool call ID: {_id}, Tool name: {_name}, Arguments: {_arguments}",
689 _id=output.id,
690 _name=output.name,
691 _arguments=output.arguments,
692 )
693 arguments = {}
695 # we save the response as dict because it contains resp_id etc.
696 meta = responses.to_dict()
698 # remove output from meta because it contains toolcalls, reasoning, text etc.
699 meta.pop("output")
700 meta["finish_reason"] = _get_response_finish_reason(responses)
702 if logprobs:
703 meta["logprobs"] = logprobs
705 return ChatMessage.from_assistant(
706 text=responses.output_text if responses.output_text else None,
707 reasoning=reasoning,
708 tool_calls=tool_calls,
709 meta=meta,
710 )
713def _convert_response_chunk_to_streaming_chunk( # noqa: PLR0911
714 chunk: ResponseStreamEvent, previous_chunks: list[StreamingChunk], component_info: ComponentInfo | None = None
715) -> StreamingChunk:
716 """
717 Converts the streaming response chunk from the OpenAI Responses API to a StreamingChunk.
719 :param chunk: The chunk returned by the OpenAI Responses API.
720 :param previous_chunks: A list of previously received StreamingChunks.
721 :param component_info: An optional `ComponentInfo` object containing information about the component that
722 generated the chunk, such as the component name and type.
723 :returns:
724 A StreamingChunk object representing the content of the chunk from the OpenAI Responses API.
725 """
726 if chunk.type == "response.output_item.added":
727 # Responses API always returns reasoning chunks even if there is no summary
728 if chunk.item.type == "reasoning":
729 reasoning = ReasoningContent(reasoning_text="", extra=chunk.item.to_dict())
730 return StreamingChunk(
731 content="",
732 component_info=component_info,
733 index=chunk.output_index,
734 reasoning=reasoning,
735 start=True,
736 meta={"received_at": datetime.now().isoformat()},
737 )
739 # the function name is only streamed at the start and end of the function call
740 if chunk.item.type == "function_call":
741 tool_call = ToolCallDelta(
742 index=chunk.output_index, id=chunk.item.id, tool_name=chunk.item.name, extra=chunk.item.to_dict()
743 )
744 return StreamingChunk(
745 content="",
746 component_info=component_info,
747 index=chunk.output_index,
748 tool_calls=[tool_call],
749 start=True,
750 meta={"received_at": datetime.now().isoformat()},
751 )
753 elif chunk.type == "response.output_item.done":
754 # The done event carries the completed reasoning item, which includes encrypted_content
755 # when include=["reasoning.encrypted_content"] was requested. Without this handler the
756 # event falls through to the generic default and reasoning=None, so encrypted_content
757 # is never available for multi-turn conversations.
758 if chunk.item.type == "reasoning":
759 # The completed item repeats the reasoning text already streamed as deltas, so we only keep its fields
760 reasoning = ReasoningContent(reasoning_text="", extra=chunk.item.to_dict())
761 return StreamingChunk(
762 content="",
763 component_info=component_info,
764 index=chunk.output_index,
765 reasoning=reasoning,
766 meta={"received_at": datetime.now().isoformat()},
767 )
769 elif chunk.type == "response.completed" or chunk.type == "response.incomplete":
770 # This means a full response is finished.
771 return StreamingChunk(
772 content="",
773 component_info=component_info,
774 finish_reason=_get_response_finish_reason(chunk.response),
775 meta={**chunk.to_dict(), "received_at": datetime.now().isoformat()},
776 )
778 elif chunk.type == "response.output_text.delta":
779 # Start is determined by checking if this is the first text delta event of a new output_index
780 # 1) Check if all previous chunks have different output_index
781 # 2) If any chunks do have the same output_index, check if they have content
782 # If none of them have content, this is the start of a new text output
783 start = all(c.index != chunk.output_index for c in previous_chunks) or all(
784 c.content == "" for c in previous_chunks if c.index == chunk.output_index
785 )
786 return StreamingChunk(
787 content=chunk.delta,
788 component_info=component_info,
789 index=chunk.output_index,
790 start=start,
791 meta={**chunk.to_dict(), "received_at": datetime.now().isoformat()},
792 )
794 # Unlike non-streaming, where raw reasoning text is only a fallback when there is no summary, summary and raw
795 # reasoning deltas are both streamed as they arrive and end up concatenated in the final reasoning_text.
796 # In practice this rarely matters, because providers usually send only one kind.
797 elif chunk.type in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
798 # We remove the delta from the extra because it is already in the reasoning_text
799 # Remaining information needs to be saved for chat message
800 extra = chunk.to_dict()
801 extra.pop("delta")
802 reasoning = ReasoningContent(reasoning_text=chunk.delta, extra=extra)
803 return StreamingChunk(
804 content="",
805 component_info=component_info,
806 index=chunk.output_index,
807 reasoning=reasoning,
808 meta={"received_at": datetime.now().isoformat()},
809 )
811 # the function arguments are streamed in parts
812 # function name is not passed in these chunks
813 elif chunk.type == "response.function_call_arguments.delta":
814 arguments = chunk.delta
815 extra = chunk.to_dict()
816 extra.pop("delta")
817 # in delta of tool calls there is no call_id so we use the item_id which is the function call id
818 tool_call = ToolCallDelta(index=chunk.output_index, id=chunk.item_id, arguments=arguments, extra=extra)
819 return StreamingChunk(
820 content="",
821 component_info=component_info,
822 index=chunk.output_index,
823 tool_calls=[tool_call],
824 meta={"received_at": datetime.now().isoformat()},
825 )
827 # we return rest of the chunk as is
828 return StreamingChunk(
829 content="",
830 component_info=component_info,
831 index=getattr(chunk, "output_index", None),
832 meta={**chunk.to_dict(), "received_at": datetime.now().isoformat()},
833 )
836def _convert_streaming_chunks_to_chat_message(chunks: list[StreamingChunk]) -> ChatMessage:
837 """
838 Connects the streaming chunks into a single ChatMessage.
840 :param chunks: The list of all `StreamingChunk` objects.
842 :returns: The ChatMessage.
843 """
845 # Get the full text by concatenating all text chunks
846 text = "".join([chunk.content for chunk in chunks])
847 logprobs = []
848 for chunk in chunks:
849 if chunk.meta.get("logprobs"):
850 logprobs.append(chunk.meta.get("logprobs"))
852 # Gather reasoning information if present
853 reasoning_id = None
854 reasoning_text = ""
855 for chunk in chunks:
856 if chunk.reasoning:
857 reasoning_text += chunk.reasoning.reasoning_text
858 if chunk.reasoning.extra.get("id"):
859 reasoning_id = chunk.reasoning.extra.get("id")
861 # Process tool calls if present in any chunk
862 tool_call_data: dict[str, dict[str, Any]] = {} # Track tool calls by id
863 for chunk in chunks:
864 if chunk.tool_calls:
865 for tool_call in chunk.tool_calls:
866 # here the tool_call.id is fc_id not call_id
867 assert tool_call.id is not None
868 # We use the tool call id to track the tool call across chunks
869 if tool_call.id not in tool_call_data:
870 tool_call_data[tool_call.id] = {"name": "", "arguments": ""}
872 if tool_call.arguments is not None:
873 tool_call_data[tool_call.id]["arguments"] += tool_call.arguments
875 # We capture the tool name from one of the chunks
876 if tool_call.tool_name is not None:
877 tool_call_data[tool_call.id]["name"] = tool_call.tool_name
879 # We capture the call_id from one of the chunks
880 if tool_call.extra and "call_id" in tool_call.extra:
881 tool_call_data[tool_call.id]["extra"] = {"call_id": tool_call.extra["call_id"]}
883 # Convert accumulated tool call data into ToolCall objects
884 tool_calls = []
885 sorted_keys = sorted(tool_call_data.keys())
886 for key in sorted_keys:
887 tool_call_dict = tool_call_data[key]
888 try:
889 arguments = json.loads(tool_call_dict.get("arguments", "{}")) if tool_call_dict.get("arguments") else {}
890 extra: dict[str, Any] = tool_call_dict.get("extra", {})
891 tool_calls.append(ToolCall(id=key, tool_name=tool_call_dict["name"], arguments=arguments, extra=extra))
892 except json.JSONDecodeError:
893 logger.warning(
894 "The LLM provider returned a malformed JSON string for tool call arguments. This tool call "
895 "will be skipped. To always generate a valid JSON, set `tools_strict` to `True`. "
896 "Tool call ID: {_id}, Tool name: {_name}, Arguments: {_arguments}",
897 _id=key,
898 _name=tool_call_dict["name"],
899 _arguments=tool_call_dict["arguments"],
900 )
902 # We dump the entire final response into meta to be consistent with non-streaming response. The event carrying
903 # the final response (and its usage data) is not guaranteed to be the last event in the stream, so scan for it.
904 responses = [chunk.meta["response"] for chunk in chunks if chunk.meta.get("response")]
905 response_with_usage = next(
906 (response for response in reversed(responses) if response.get("usage") is not None), None
907 )
908 final_response = (response_with_usage or responses[-1]).copy() if responses else {}
909 final_response.pop("output", None)
911 # finish_reason can appear in different places so we look for the last one
912 finish_reasons = [chunk.finish_reason for chunk in chunks if chunk.finish_reason]
913 if finish_reasons:
914 final_response["finish_reason"] = finish_reasons[-1]
915 if logprobs:
916 final_response["logprobs"] = logprobs
918 # Add reasoning content if id is available
919 # Note: the API expects a reasoning id even if there is no reasoning text
920 # function calls without reasoning ids are not supported by the API
921 reasoning = None
922 if reasoning_id:
923 # Preserve all extra fields from streaming chunks (e.g. encrypted_content) while ensuring id and
924 # type are present
925 reasoning_extra = {}
926 for chunk in chunks:
927 if chunk.reasoning and chunk.reasoning.extra:
928 reasoning_extra.update(chunk.reasoning.extra)
929 # Ensure id and type are always set, but don't override if already present
930 reasoning_extra.setdefault("id", reasoning_id)
931 reasoning_extra.setdefault("type", "reasoning")
932 reasoning = ReasoningContent(reasoning_text=reasoning_text, extra=reasoning_extra)
934 return ChatMessage.from_assistant(
935 text=text or None, tool_calls=tool_calls, meta=final_response, reasoning=reasoning
936 )
939def _convert_chat_message_to_responses_api_format(message: ChatMessage) -> list[dict[str, Any]]:
940 """
941 Convert a ChatMessage to the dictionary format expected by OpenAI's Responses API.
943 :param message: The ChatMessage to convert to OpenAI's Responses API format.
944 :returns:
945 The ChatMessage in the format expected by OpenAI's Responses API.
947 :raises ValueError:
948 If the message format is invalid.
949 """
951 def convert_part(part: Any) -> dict[str, str | None]:
952 if isinstance(part, TextContent):
953 return {"type": "input_text", "text": part.text}
954 if isinstance(part, ImageContent):
955 return {
956 "type": "input_image",
957 # If no MIME type is provided, default to JPEG. OpenAI API appears to tolerate MIME type mismatches.
958 "image_url": f"data:{part.mime_type or 'image/jpeg'};base64,{part.base64_image}",
959 }
960 if isinstance(part, FileContent):
961 return {
962 "type": "input_file",
963 # Filename is optional but if not provided, OpenAI expects a file_id of a previous file upload.
964 # We use a dummy filename to avoid this issue.
965 "filename": part.filename or "filename",
966 "file_data": f"data:{part.mime_type or 'application/pdf'};base64,{part.base64_data}",
967 }
968 raise ValueError(f"Unsupported content type: {type(part)}")
970 text_contents = message.texts
971 tool_calls = message.tool_calls
972 tool_call_results = message.tool_call_results
973 images = message.images
974 reasonings = message.reasonings
975 files = message.files
977 has_content = any([text_contents, tool_calls, tool_call_results, images, reasonings, files])
978 # We convert an assistant message with no content part into a message with empty content, which the API accepts
979 if not has_content and not message.is_from(ChatRole.ASSISTANT):
980 raise ValueError(
981 f"A `ChatMessage` from `{message._role.value}` must contain at least one `TextContent`, `ToolCall`, "
982 "`ToolCallResult`, `ImageContent`, `FileContent`, or `ReasoningContent`. Only assistant messages can "
983 "be empty."
984 )
985 if len(tool_call_results) > 0 and len(message._content) > 1:
986 raise ValueError(
987 "For OpenAI compatibility, a `ChatMessage` with a `ToolCallResult` cannot contain any other content."
988 )
990 formatted_messages: list[dict[str, Any]] = []
991 openai_msg: dict[str, Any] = {"role": message._role.value}
992 if message._name is not None:
993 openai_msg["name"] = message._name
995 # user message
996 if message._role.value == "user":
997 content = [convert_part(part) for part in message._content]
998 openai_msg["content"] = content
999 return [openai_msg]
1001 # tool message
1002 if tool_call_results:
1003 formatted_tool_results = []
1004 for result in tool_call_results:
1005 if result.origin.id is not None:
1006 # Handle multimodal tool results (list of TextContent/ImageContent/FileContent)
1007 if isinstance(result.result, list):
1008 output_content = [convert_part(part) for part in result.result]
1009 elif isinstance(result.result, str):
1010 output_content = [{"type": "input_text", "text": result.result}]
1011 else:
1012 raise ValueError(f"Unsupported tool result: {result.result}")
1013 tool_result = {
1014 "type": "function_call_output",
1015 "call_id": result.origin.extra.get("call_id") if result.origin.extra else "",
1016 "output": output_content,
1017 }
1018 formatted_tool_results.append(tool_result)
1019 formatted_messages.extend(formatted_tool_results)
1021 # Note: the API expects a reasoning id even if there is no reasoning text
1022 # function calls without reasoning ids are not supported by the API
1023 if reasonings:
1024 formatted_reasonings = []
1025 for reasoning in reasonings:
1026 # Streaming events (e.g. response.reasoning_summary_text.delta) store event-level
1027 # fields like item_id, output_index, summary_index, event_id, sequence_number into
1028 # reasoning.extra. Those are not valid reasoning input item fields and the API
1029 # rejects them with "Unknown parameter" when sent back in subsequent turns.
1030 # Valid fields per ResponseReasoningItem schema: id, type, summary (handled separately),
1031 # content, encrypted_content, status.
1032 _valid_reasoning_fields = {"id", "type", "encrypted_content", "status", "content"}
1033 filtered_extra = {k: v for k, v in reasoning.extra.items() if k in _valid_reasoning_fields}
1034 reasoning_item = {"summary": [], **filtered_extra}
1035 # Raw reasoning text is already sent back in `content`, repeating it as a summary would duplicate it
1036 if reasoning.reasoning_text and not filtered_extra.get("content"):
1037 reasoning_item["summary"] = [{"text": reasoning.reasoning_text, "type": "summary_text"}]
1038 formatted_reasonings.append(reasoning_item)
1039 formatted_messages.extend(formatted_reasonings)
1041 if tool_calls:
1042 formatted_tool_calls = []
1043 for tc in tool_calls:
1044 openai_tool_call = {
1045 "type": "function_call",
1046 # We disable ensure_ascii so special chars like emojis are not converted
1047 "name": tc.tool_name,
1048 "arguments": json.dumps(tc.arguments, ensure_ascii=False),
1049 "id": tc.id,
1050 "call_id": tc.extra.get("call_id") if tc.extra else "",
1051 }
1053 formatted_tool_calls.append(openai_tool_call)
1054 formatted_messages.extend(formatted_tool_calls)
1056 # system and assistant messages
1057 if text_contents or not formatted_messages:
1058 openai_msg["content"] = " ".join(text_contents)
1059 formatted_messages.append(openai_msg)
1061 return formatted_messages