diff --git a/haystack/components/generators/chat/openai_responses.py b/haystack/components/generators/chat/openai_responses.py index cf381c056ec..d0a804610bc 100644 --- a/haystack/components/generators/chat/openai_responses.py +++ b/haystack/components/generators/chat/openai_responses.py @@ -228,7 +228,7 @@ def _client_kwargs(self) -> dict[str, Any]: 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) + is_openai_tool = isinstance(self.tools, list) and bool(self.tools) 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: @@ -539,7 +539,10 @@ def _prepare_api_call( # noqa: PLR0913 function_spec = {**t.tool_spec} if not tools_strict: function_spec["strict"] = False - function_spec["parameters"]["additionalProperties"] = False + # Copy the parameters schema before editing it. tool_spec exposes + # Tool.parameters by reference, so mutating it here would permanently alter + # the user's Tool (and any other generator that shares the same Tool instance). + function_spec["parameters"] = {**function_spec["parameters"], "additionalProperties": False} tool_definitions.append({"type": "function", **function_spec}) openai_tools = {"tools": tool_definitions} diff --git a/releasenotes/notes/fix-openai-responses-tool-schema-mutation-e500ad2c85715f8c.yaml b/releasenotes/notes/fix-openai-responses-tool-schema-mutation-e500ad2c85715f8c.yaml new file mode 100644 index 00000000000..966c99af279 --- /dev/null +++ b/releasenotes/notes/fix-openai-responses-tool-schema-mutation-e500ad2c85715f8c.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + ``OpenAIResponsesChatGenerator`` no longer mutates the ``parameters`` schema of the ``Tool`` + objects passed to it. Previously every run wrote ``additionalProperties: False`` into the + user's live ``Tool.parameters``, silently altering the tool for any other generator that + shared the same ``Tool`` instance and making serialization round trips unstable. + - | + ``OpenAIResponsesChatGenerator`` no longer raises ``IndexError`` when it is warmed up with an + empty ``tools`` list. diff --git a/test/components/generators/chat/test_openai_responses.py b/test/components/generators/chat/test_openai_responses.py index 69855986d5c..6b09d719ca4 100644 --- a/test/components/generators/chat/test_openai_responses.py +++ b/test/components/generators/chat/test_openai_responses.py @@ -420,6 +420,14 @@ def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch): generator.warm_up() assert generator._tools_warmed_up + def test_warm_up_with_empty_tools_list_does_not_raise(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key") + # An empty list is a valid ``tools`` value (e.g. when built programmatically); warming up + # must not index ``tools[0]`` unguarded. + generator = OpenAIResponsesChatGenerator(tools=[]) + generator.warm_up() + assert generator._tools_warmed_up + def test_warm_up_with_openai_tools_does_not_raise(self, monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key") generator = OpenAIResponsesChatGenerator( @@ -493,6 +501,21 @@ def test_run_fail_with_duplicate_tool_names(self, monkeypatch, tools): component = OpenAIResponsesChatGenerator(tools=duplicate_tools) component.run(chat_messages) + def test_prepare_api_call_does_not_mutate_tool_parameters(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + parameters = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]} + tool = Tool(name="weather", description="get the weather", parameters=parameters, function=print) + + generator = OpenAIResponsesChatGenerator(tools_strict=False) + api_args = generator._prepare_api_call(messages=[ChatMessage.from_user("hi")], tools=[tool]) + + # The tool's own schema must not be modified in place, otherwise the same Tool passed to + # another generator (or serialized) would carry an additionalProperties flag it never had. + assert "additionalProperties" not in tool.parameters + assert tool.parameters == {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]} + # ...while the payload actually sent to the API still carries additionalProperties=False. + assert api_args["tools"][0]["parameters"]["additionalProperties"] is False + def test_run_with_wrong_model(self): mock_client = MagicMock() mock_client.responses.create.side_effect = OpenAIError("Invalid model name")