diff --git a/haystack/components/generators/chat/openai_responses.py b/haystack/components/generators/chat/openai_responses.py index 1254cf66078..cf381c056ec 100644 --- a/haystack/components/generators/chat/openai_responses.py +++ b/haystack/components/generators/chat/openai_responses.py @@ -160,8 +160,21 @@ def __init__( - `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. + - `mode`: The reasoning mode. Can be `standard`, or `pro`. Supported since GPT-5.6. 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). + - `include`: Specify additional output data to include in the model response. Supported values are: + - web_search_call.action.sources: Include the sources of the web search tool call. + - code_interpreter_call.outputs: Includes the outputs of python code execution in code interpreter tool + call items. + - computer_call_output.output.image_url: Include image urls from the computer call output. + - file_search_call.results: Include the search results of the file search tool call. + - message.input_image.image_url: Include image urls from the input message. + - message.output_text.logprobs: Include logprobs with assistant messages. + - reasoning.encrypted_content: Includes an encrypted version of reasoning tokens in reasoning item + outputs. This enables reasoning items to be used in multi-turn conversations when using the + Responses API statelessly (like when the store parameter is set to false, or when an organization + is enrolled in the zero data retention program). :param timeout: Timeout for OpenAI client calls. If not set, it defaults to either the `OPENAI_TIMEOUT` environment variable, or 30 seconds. @@ -545,20 +558,32 @@ def _prepare_api_call( # noqa: PLR0913 def _resolve_flattened_kwargs(self, generation_kwargs: dict[str, Any]) -> dict[str, Any]: generation_kwargs = generation_kwargs.copy() + # avoid mutating the caller's dict + reasoning_overrides = {} reasoning_effort = generation_kwargs.pop("reasoning_effort", None) if reasoning_effort is not None: - reasoning = generation_kwargs.setdefault("reasoning", {}) - reasoning["effort"] = reasoning_effort + reasoning_overrides["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 + reasoning_overrides["summary"] = reasoning_summary + + reasoning_mode = generation_kwargs.pop("reasoning_mode", None) + if reasoning_mode is not None: + reasoning_overrides["mode"] = reasoning_mode + + if reasoning_overrides: + generation_kwargs["reasoning"] = {**generation_kwargs.get("reasoning", {}), **reasoning_overrides} + + include_reasoning_encrypted_content = generation_kwargs.pop("include_reasoning_encrypted_content", None) + if include_reasoning_encrypted_content is True: + include = generation_kwargs.get("include", []) + if "reasoning.encrypted_content" not in include: + generation_kwargs["include"] = [*include, "reasoning.encrypted_content"] verbosity = generation_kwargs.pop("verbosity", None) if verbosity is not None: - text = generation_kwargs.setdefault("text", {}) - text["verbosity"] = verbosity + generation_kwargs["text"] = {**generation_kwargs.get("text", {}), "verbosity": verbosity} return generation_kwargs diff --git a/releasenotes/notes/add-reasoning-mode-openai-responses-8f1c2a6b7d3e4f5a.yaml b/releasenotes/notes/add-reasoning-mode-openai-responses-8f1c2a6b7d3e4f5a.yaml new file mode 100644 index 00000000000..7d8e0b0483a --- /dev/null +++ b/releasenotes/notes/add-reasoning-mode-openai-responses-8f1c2a6b7d3e4f5a.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Added a ``reasoning_mode`` flattened generation kwarg to ``OpenAIResponsesChatGenerator``, which is merged into the + ``reasoning`` dictionary sent to the OpenAI Responses API. This lets you set the reasoning mode (for example + ``standard`` or ``pro``, supported since GPT-5.6) without having to construct the nested ``reasoning`` dict yourself. + - | + Added an ``include_reasoning_encrypted_content`` flattened generation kwarg to ``OpenAIResponsesChatGenerator``. + Setting it to ``True`` appends ``reasoning.encrypted_content`` to the ``include`` list sent to the OpenAI + Responses API, so reasoning items can be reused across multi-turn conversations when the Responses API is used + statelessly (for example when ``store`` is ``False`` or zero data retention is enabled). diff --git a/test/components/generators/chat/test_openai_responses.py b/test/components/generators/chat/test_openai_responses.py index 3984b23df01..69855986d5c 100644 --- a/test/components/generators/chat/test_openai_responses.py +++ b/test/components/generators/chat/test_openai_responses.py @@ -535,14 +535,82 @@ def test_run_with_flattened_generation_kwargs(self, openai_mock_responses, monke monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") chat_messages = [ChatMessage.from_user("What's the capital of France")] component = OpenAIResponsesChatGenerator( - model="gpt-4", - generation_kwargs={"reasoning_effort": "low", "reasoning_summary": "auto", "verbosity": "low"}, + model="gpt-5.6-luna", + generation_kwargs={ + "reasoning_effort": "low", + "reasoning_summary": "auto", + "reasoning_mode": "pro", + "verbosity": "low", + }, ) results = component.run(chat_messages) assert len(results["replies"]) == 1 - assert openai_mock_responses.call_args.kwargs["reasoning"] == {"effort": "low", "summary": "auto"} + assert openai_mock_responses.call_args.kwargs["reasoning"] == { + "effort": "low", + "summary": "auto", + "mode": "pro", + } assert openai_mock_responses.call_args.kwargs["text"] == {"verbosity": "low"} + def test_run_with_reasoning_mode_only(self, openai_mock_responses, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + chat_messages = [ChatMessage.from_user("What's the capital of France")] + component = OpenAIResponsesChatGenerator(model="gpt-5.6-luna", generation_kwargs={"reasoning_mode": "standard"}) + results = component.run(chat_messages) + assert len(results["replies"]) == 1 + assert openai_mock_responses.call_args.kwargs["reasoning"] == {"mode": "standard"} + + def test_run_with_reasoning_mode_merges_with_existing_reasoning_dict(self, openai_mock_responses, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + chat_messages = [ChatMessage.from_user("What's the capital of France")] + component = OpenAIResponsesChatGenerator( + model="gpt-5.6-luna", generation_kwargs={"reasoning": {"effort": "high"}, "reasoning_mode": "pro"} + ) + results = component.run(chat_messages) + assert len(results["replies"]) == 1 + assert openai_mock_responses.call_args.kwargs["reasoning"] == {"effort": "high", "mode": "pro"} + + def test_run_with_include_reasoning_encrypted_content(self, openai_mock_responses, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + chat_messages = [ChatMessage.from_user("What's the capital of France")] + component = OpenAIResponsesChatGenerator( + model="gpt-5.6-luna", generation_kwargs={"include_reasoning_encrypted_content": True} + ) + results = component.run(chat_messages) + assert len(results["replies"]) == 1 + assert openai_mock_responses.call_args.kwargs["include"] == ["reasoning.encrypted_content"] + + def test_run_with_include_reasoning_encrypted_content_merges_with_existing_include_list( + self, openai_mock_responses, monkeypatch + ): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + chat_messages = [ChatMessage.from_user("What's the capital of France")] + component = OpenAIResponsesChatGenerator( + model="gpt-5.6-luna", + generation_kwargs={ + "include": ["message.output_text.logprobs"], + "include_reasoning_encrypted_content": True, + }, + ) + results = component.run(chat_messages) + assert len(results["replies"]) == 1 + assert openai_mock_responses.call_args.kwargs["include"] == [ + "message.output_text.logprobs", + "reasoning.encrypted_content", + ] + + def test_run_with_include_reasoning_encrypted_content_false_does_not_set_include( + self, openai_mock_responses, monkeypatch + ): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + chat_messages = [ChatMessage.from_user("What's the capital of France")] + component = OpenAIResponsesChatGenerator( + model="gpt-5.6-luna", generation_kwargs={"include_reasoning_encrypted_content": False} + ) + results = component.run(chat_messages) + assert len(results["replies"]) == 1 + assert "include" not in openai_mock_responses.call_args.kwargs + def test_run_with_params_streaming(self, openai_mock_responses_stream_text_delta): streaming_callback_called = False