diff --git a/docs/pydoc/config/generators_api.yml b/docs/pydoc/config/generators_api.yml index c69ee4c97a..3e14204d31 100644 --- a/docs/pydoc/config/generators_api.yml +++ b/docs/pydoc/config/generators_api.yml @@ -9,6 +9,7 @@ loaders: "openai", "openai_dalle", "chat/azure", + "chat/azure_responses", "chat/hugging_face_local", "chat/hugging_face_api", "chat/openai", diff --git a/docs/pydoc/config_docusaurus/generators_api.yml b/docs/pydoc/config_docusaurus/generators_api.yml index 799d5820fc..70ed5b17f7 100644 --- a/docs/pydoc/config_docusaurus/generators_api.yml +++ b/docs/pydoc/config_docusaurus/generators_api.yml @@ -9,6 +9,7 @@ loaders: "openai", "openai_dalle", "chat/azure", + "chat/azure_responses", "chat/hugging_face_local", "chat/hugging_face_api", "chat/openai", diff --git a/haystack/components/generators/chat/__init__.py b/haystack/components/generators/chat/__init__.py index 4679d10337..e45b62fbb0 100644 --- a/haystack/components/generators/chat/__init__.py +++ b/haystack/components/generators/chat/__init__.py @@ -11,6 +11,7 @@ "openai": ["OpenAIChatGenerator"], "openai_responses": ["OpenAIResponsesChatGenerator"], "azure": ["AzureOpenAIChatGenerator"], + "azure_responses": ["AzureOpenAIResponsesChatGenerator"], "hugging_face_local": ["HuggingFaceLocalChatGenerator"], "hugging_face_api": ["HuggingFaceAPIChatGenerator"], "fallback": ["FallbackChatGenerator"], @@ -18,6 +19,7 @@ if TYPE_CHECKING: from .azure import AzureOpenAIChatGenerator as AzureOpenAIChatGenerator + from .azure_responses import AzureOpenAIResponsesChatGenerator as AzureOpenAIResponsesChatGenerator from .fallback import FallbackChatGenerator as FallbackChatGenerator from .hugging_face_api import HuggingFaceAPIChatGenerator as HuggingFaceAPIChatGenerator from .hugging_face_local import HuggingFaceLocalChatGenerator as HuggingFaceLocalChatGenerator diff --git a/haystack/components/generators/chat/azure_responses.py b/haystack/components/generators/chat/azure_responses.py new file mode 100644 index 0000000000..06649cc27e --- /dev/null +++ b/haystack/components/generators/chat/azure_responses.py @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: 2022-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import os +from typing import Any, Awaitable, Callable, Optional, Union + +from openai.lib._pydantic import to_strict_json_schema +from pydantic import BaseModel + +from haystack import component, default_from_dict, default_to_dict +from haystack.components.generators.chat import OpenAIResponsesChatGenerator +from haystack.dataclasses.streaming_chunk import StreamingCallbackT +from haystack.tools import ToolsType, deserialize_tools_or_toolset_inplace, serialize_tools_or_toolset +from haystack.utils import Secret, deserialize_callable, deserialize_secrets_inplace, serialize_callable + + +@component +class AzureOpenAIResponsesChatGenerator(OpenAIResponsesChatGenerator): + """ + Completes chats using OpenAI's Responses API on Azure. + + It works with the gpt-5 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 AzureOpenAIResponsesChatGenerator + from haystack.dataclasses import ChatMessage + + messages = [ChatMessage.from_user("What's Natural Language Processing?")] + + client = AzureOpenAIResponsesChatGenerator( + azure_endpoint="https://example-resource.azure.openai.com/", + generation_kwargs={"reasoning": {"effort": "low", "summary": "auto"}} + ) + response = client.run(messages) + print(response) + ``` + """ + + # ruff: noqa: PLR0913 + def __init__( + self, + *, + api_key: Union[Secret, Callable[[], str], Callable[[], Awaitable[str]]] = Secret.from_env_var( + "AZURE_OPENAI_API_KEY", strict=False + ), + azure_endpoint: Optional[str] = None, + azure_deployment: str = "gpt-5-mini", + streaming_callback: Optional[StreamingCallbackT] = None, + organization: Optional[str] = None, + generation_kwargs: Optional[dict[str, Any]] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + tools: Optional[ToolsType] = None, + tools_strict: bool = False, + http_client_kwargs: Optional[dict[str, Any]] = None, + ): + """ + Initialize the AzureOpenAIResponsesChatGenerator component. + + :param api_key: The API key to use for authentication. Can be: + - A `Secret` object containing the API key. + - A `Secret` object containing the [Azure Active Directory token](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id). + - A function that returns an Azure Active Directory token. + :param azure_endpoint: The endpoint of the deployed model, for example `"https://example-resource.azure.openai.com/"`. + :param azure_deployment: The deployment of the model, usually the model name. + :param organization: Your organization ID, defaults to `None`. For help, see + [Setting up your organization](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization). + :param streaming_callback: A callback function called when a new token is received from the stream. + It accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk) + as an argument. + :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 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 JSON schema or 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). + Notes: + - This parameter accepts Pydantic models and JSON schemas for latest models starting from GPT-4o. + 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). + - For structured outputs with streaming, + the `text_format` must be a JSON schema and not a Pydantic model. + - `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 tools: + A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls. + :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. + :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). + """ + azure_endpoint = azure_endpoint or os.getenv("AZURE_OPENAI_ENDPOINT") + if azure_endpoint is None: + raise ValueError( + "You must provide `azure_endpoint` or set the `AZURE_OPENAI_ENDPOINT` environment variable." + ) + self._azure_endpoint = azure_endpoint + self._azure_deployment = azure_deployment + super(AzureOpenAIResponsesChatGenerator, self).__init__( + api_key=api_key, # type: ignore[arg-type] + model=self._azure_deployment, + streaming_callback=streaming_callback, + api_base_url=f"{self._azure_endpoint.rstrip('/')}/openai/v1", + organization=organization, + generation_kwargs=generation_kwargs, + timeout=timeout, + max_retries=max_retries, + tools=tools, + tools_strict=tools_strict, + http_client_kwargs=http_client_kwargs, + ) + + 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 + + # API key can be a secret or a callable + serialized_api_key = ( + serialize_callable(self.api_key) + if callable(self.api_key) + else self.api_key.to_dict() + if isinstance(self.api_key, Secret) + else 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 + generation_kwargs = self.generation_kwargs.copy() + response_format = generation_kwargs.get("response_format") + if response_format and issubclass(response_format, BaseModel): + json_schema = { + "type": "json_schema", + "json_schema": { + "name": response_format.__name__, + "strict": True, + "schema": to_strict_json_schema(response_format), + }, + } + generation_kwargs["response_format"] = json_schema + + # OpenAI/MCP tools are passed as list of dictionaries + serialized_tools: Union[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 # type: ignore[assignment] + else: + serialized_tools = serialize_tools_or_toolset(self.tools) # type: ignore[arg-type] + + return default_to_dict( + self, + azure_endpoint=self._azure_endpoint, + api_key=serialized_api_key, + azure_deployment=self._azure_deployment, + streaming_callback=callback_name, + organization=self.organization, + generation_kwargs=generation_kwargs, + 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]) -> "AzureOpenAIResponsesChatGenerator": + """ + Deserialize this component from a dictionary. + + :param data: The dictionary representation of this component. + :returns: + The deserialized component instance. + """ + serialized_api_key = data["init_parameters"].get("api_key") + # If it's a dict most likely a Secret + if isinstance(serialized_api_key, dict): + deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"]) + # If it's a str, most likely a callable + elif isinstance(serialized_api_key, str): + data["init_parameters"]["api_key"] = deserialize_callable(serialized_api_key) + + # 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) diff --git a/releasenotes/notes/add-azure-responses-api-1b2c990a060b09f5.yaml b/releasenotes/notes/add-azure-responses-api-1b2c990a060b09f5.yaml new file mode 100644 index 0000000000..c3e8ca7a59 --- /dev/null +++ b/releasenotes/notes/add-azure-responses-api-1b2c990a060b09f5.yaml @@ -0,0 +1,24 @@ +--- +features: + - | + Added the `AzureOpenAIResponsesChatGenerator`, a new component that integrates Azure OpenAI's Responses API into Haystack. + This unlocks several advanced capabilities from the Responses API: + - Allowing retrieval of concise summaries of the model's reasoning process. + - Allowing the use of native OpenAI or MCP tool formats, along with Haystack Tool objects and Toolset instances. + + Example with reasoning and web search tool: + ```python + from haystack.components.generators.chat import AzureOpenAIResponsesChatGenerator + from haystack.dataclasses import ChatMessage + + chat_generator = AzureOpenAIResponsesChatGenerator( + azure_endpoint="https://example-resource.azure.openai.com/", + azure_deployment="gpt-5-mini", + generation_kwargs={"reasoning": {"effort": "low", "summary": "auto"}}, + ) + + response = chat_generator.run( + messages=[ChatMessage.from_user("What's Natural Language Processing?")] + ) + print(response["replies"][0].text) + ``` diff --git a/test/components/generators/chat/test_azure_responses.py b/test/components/generators/chat/test_azure_responses.py new file mode 100644 index 0000000000..5cdff353f2 --- /dev/null +++ b/test/components/generators/chat/test_azure_responses.py @@ -0,0 +1,598 @@ +# SPDX-FileCopyrightText: 2022-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import json +import os +from typing import Any, Optional + +import pytest +from openai import OpenAIError +from pydantic import BaseModel + +from haystack import Pipeline, component +from haystack.components.generators.chat import AzureOpenAIResponsesChatGenerator +from haystack.components.generators.utils import print_streaming_chunk +from haystack.dataclasses import ChatMessage, ToolCall +from haystack.tools import ComponentTool, Tool +from haystack.tools.toolset import Toolset +from haystack.utils.auth import Secret +from haystack.utils.azure import default_azure_ad_token_provider + + +class CalendarEvent(BaseModel): + event_name: str + event_date: str + event_location: str + + +@pytest.fixture +def calendar_event_model(): + return CalendarEvent + + +def get_weather(city: str) -> dict[str, Any]: + weather_info = { + "Berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"}, + "Paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"}, + "Rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"}, + } + return weather_info.get(city, {"weather": "unknown", "temperature": 0, "unit": "celsius"}) + + +@component +class MessageExtractor: + @component.output_types(messages=list[str], meta=dict[str, Any]) + def run(self, messages: list[ChatMessage], meta: Optional[dict[str, Any]] = None) -> dict[str, Any]: + """ + Extracts the text content of ChatMessage objects + + :param messages: List of Haystack ChatMessage objects + :param meta: Optional metadata to include in the response. + :returns: + A dictionary with keys "messages" and "meta". + """ + if meta is None: + meta = {} + return {"messages": [m.text for m in messages], "meta": meta} + + +@pytest.fixture +def tools(): + weather_tool = Tool( + name="weather", + description="useful to determine the weather in a given location", + parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + function=get_weather, + ) + # We add a tool that has a more complex parameter signature + message_extractor_tool = ComponentTool( + component=MessageExtractor(), + name="message_extractor", + description="Useful for returning the text content of ChatMessage objects", + ) + return [weather_tool, message_extractor_tool] + + +class TestAzureOpenAIChatGenerator: + def test_init_default(self, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint") + assert component.client.api_key == "test-api-key" + assert component._azure_deployment == "gpt-5-mini" + assert component.streaming_callback is None + assert not component.generation_kwargs + + def test_init_fail_wo_api_key(self, monkeypatch): + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + with pytest.raises(OpenAIError): + AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint") + + def test_init_fail_wo_azure_endpoint(self, monkeypatch): + monkeypatch.delenv("AZURE_OPENAI_ENDPOINT", raising=False) + with pytest.raises(ValueError): + AzureOpenAIResponsesChatGenerator() + + def test_init_with_parameters(self, tools): + component = AzureOpenAIResponsesChatGenerator( + api_key=Secret.from_token("test-api-key"), + azure_endpoint="some-non-existing-endpoint", + streaming_callback=print_streaming_chunk, + generation_kwargs={"max_completion_tokens": 10, "some_test_param": "test-params"}, + tools=tools, + tools_strict=True, + ) + assert component.client.api_key == "test-api-key" + assert component._azure_deployment == "gpt-5-mini" + assert component.streaming_callback is print_streaming_chunk + assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"} + assert component.tools == tools + assert component.tools_strict + assert component.max_retries is None + + def test_to_dict_default(self, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint") + data = component.to_dict() + assert data == { + "type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator", + "init_parameters": { + "api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"}, + "azure_endpoint": "some-non-existing-endpoint", + "azure_deployment": "gpt-5-mini", + "organization": None, + "streaming_callback": None, + "generation_kwargs": {}, + "timeout": None, + "max_retries": None, + "tools": None, + "tools_strict": False, + "http_client_kwargs": None, + }, + } + + def test_to_dict_with_parameters(self, monkeypatch, calendar_event_model): + monkeypatch.setenv("ENV_VAR", "test-api-key") + component = AzureOpenAIResponsesChatGenerator( + api_key=Secret.from_env_var("ENV_VAR", strict=False), + azure_endpoint="some-non-existing-endpoint", + streaming_callback=print_streaming_chunk, + timeout=2.5, + max_retries=10, + generation_kwargs={ + "max_completion_tokens": 10, + "some_test_param": "test-params", + "response_format": calendar_event_model, + }, + http_client_kwargs={"proxy": "http://localhost:8080"}, + ) + data = component.to_dict() + assert data == { + "type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator", + "init_parameters": { + "api_key": {"env_vars": ["ENV_VAR"], "strict": False, "type": "env_var"}, + "azure_endpoint": "some-non-existing-endpoint", + "azure_deployment": "gpt-5-mini", + "organization": None, + "streaming_callback": "haystack.components.generators.utils.print_streaming_chunk", + "timeout": 2.5, + "max_retries": 10, + "generation_kwargs": { + "max_completion_tokens": 10, + "some_test_param": "test-params", + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "CalendarEvent", + "strict": True, + "schema": { + "properties": { + "event_name": {"title": "Event Name", "type": "string"}, + "event_date": {"title": "Event Date", "type": "string"}, + "event_location": {"title": "Event Location", "type": "string"}, + }, + "required": ["event_name", "event_date", "event_location"], + "title": "CalendarEvent", + "type": "object", + "additionalProperties": False, + }, + }, + }, + }, + "tools": None, + "tools_strict": False, + "http_client_kwargs": {"proxy": "http://localhost:8080"}, + }, + } + + def test_to_dict_with_ad_token_provider(self): + component = AzureOpenAIResponsesChatGenerator( + api_key=default_azure_ad_token_provider, azure_endpoint="some-non-existing-endpoint" + ) + data = component.to_dict() + assert data == { + "type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator", + "init_parameters": { + "api_key": "haystack.utils.azure.default_azure_ad_token_provider", + "azure_endpoint": "some-non-existing-endpoint", + "azure_deployment": "gpt-5-mini", + "organization": None, + "streaming_callback": None, + "generation_kwargs": {}, + "timeout": None, + "max_retries": None, + "tools": None, + "tools_strict": False, + "http_client_kwargs": None, + }, + } + + def test_from_dict(self, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + monkeypatch.setenv("AZURE_OPENAI_AD_TOKEN", "test-ad-token") + data = { + "type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator", + "init_parameters": { + "api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"}, + "azure_endpoint": "some-non-existing-endpoint", + "azure_deployment": "gpt-5-mini", + "organization": None, + "streaming_callback": None, + "generation_kwargs": {}, + "timeout": 30.0, + "max_retries": 5, + "tools": [ + { + "type": "haystack.tools.tool.Tool", + "data": { + "description": "description", + "function": "builtins.print", + "name": "name", + "parameters": {"x": {"type": "string"}}, + }, + } + ], + "tools_strict": False, + "http_client_kwargs": None, + }, + } + + generator = AzureOpenAIResponsesChatGenerator.from_dict(data) + assert isinstance(generator, AzureOpenAIResponsesChatGenerator) + + assert generator.api_key == Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False) + assert generator._azure_endpoint == "some-non-existing-endpoint" + assert generator._azure_deployment == "gpt-5-mini" + assert generator.organization is None + assert generator.streaming_callback is None + assert generator.generation_kwargs == {} + assert generator.timeout == 30.0 + assert generator.max_retries == 5 + assert generator.tools == [ + Tool(name="name", description="description", parameters={"x": {"type": "string"}}, function=print) + ] + assert generator.tools_strict == False + assert generator.http_client_kwargs is None + + def test_from_dict_with_ad_token_provider(self): + data = { + "type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator", + "init_parameters": { + "api_key": "haystack.utils.azure.default_azure_ad_token_provider", + "azure_endpoint": "some-non-existing-endpoint", + "azure_deployment": "gpt-5-mini", + "organization": None, + "streaming_callback": None, + "generation_kwargs": {}, + "timeout": None, + "max_retries": None, + "tools": None, + "tools_strict": False, + "http_client_kwargs": None, + }, + } + + generator = AzureOpenAIResponsesChatGenerator.from_dict(data) + assert isinstance(generator, AzureOpenAIResponsesChatGenerator) + + assert generator.api_key == default_azure_ad_token_provider + assert generator._azure_endpoint == "some-non-existing-endpoint" + assert generator._azure_deployment == "gpt-5-mini" + assert generator.organization is None + assert generator.streaming_callback is None + assert generator.generation_kwargs == {} + assert generator.timeout is None + assert generator.max_retries is None + assert generator.tools is None + assert generator.tools_strict == False + assert generator.http_client_kwargs is None + + def test_pipeline_serialization_deserialization(self, tmp_path, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + generator = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint") + p = Pipeline() + p.add_component(instance=generator, name="generator") + + assert p.to_dict() == { + "metadata": {}, + "max_runs_per_component": 100, + "connection_type_validation": True, + "components": { + "generator": { + "type": "haystack.components.generators.chat.azure_responses.AzureOpenAIResponsesChatGenerator", + "init_parameters": { + "azure_endpoint": "some-non-existing-endpoint", + "azure_deployment": "gpt-5-mini", + "organization": None, + "streaming_callback": None, + "generation_kwargs": {}, + "timeout": None, + "max_retries": None, + "api_key": {"type": "env_var", "env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False}, + "tools": None, + "tools_strict": False, + "http_client_kwargs": None, + }, + } + }, + "connections": [], + } + p_str = p.dumps() + q = Pipeline.loads(p_str) + assert p.to_dict() == q.to_dict() + + def test_azure_chat_generator_with_toolset_initialization(self, tools, monkeypatch): + """Test that the AzureOpenAIChatGenerator can be initialized with a Toolset.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + toolset = Toolset(tools) + generator = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset) + assert generator.tools == toolset + + def test_from_dict_with_toolset(self, tools, monkeypatch): + """Test that the AzureOpenAIChatGenerator can be deserialized from a dictionary with a Toolset.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + toolset = Toolset(tools) + component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset) + data = component.to_dict() + + deserialized_component = AzureOpenAIResponsesChatGenerator.from_dict(data) + + assert isinstance(deserialized_component.tools, Toolset) + assert len(deserialized_component.tools) == len(tools) + assert all(isinstance(tool, Tool) for tool in deserialized_component.tools) + + @pytest.mark.integration + @pytest.mark.skipif( + not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None), + reason=( + "Please export env variables called AZURE_OPENAI_API_KEY containing " + "the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing " + "the Azure OpenAI endpoint URL to run this test." + ), + ) + def test_live_run(self): + chat_messages = [ChatMessage.from_user("What's the capital of France")] + component = AzureOpenAIResponsesChatGenerator(azure_deployment="gpt-4o-mini") + results = component.run(chat_messages) + assert len(results["replies"]) == 1 + message: ChatMessage = results["replies"][0] + assert "Paris" in message.text + assert "gpt-4o-mini" in message.meta["model"] + assert message.meta["status"] == "completed" + + @pytest.mark.integration + @pytest.mark.skipif( + not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None), + reason=( + "Please export env variables called AZURE_OPENAI_API_KEY containing " + "the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing " + "the Azure OpenAI endpoint URL to run this test." + ), + ) + def test_live_run_with_tools(self, tools): + chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")] + component = AzureOpenAIResponsesChatGenerator( + organization="HaystackCI", tools=tools, azure_deployment="gpt-4o-mini" + ) + results = component.run(chat_messages) + assert len(results["replies"]) == 1 + message = results["replies"][0] + + assert not message.texts + assert not message.text + assert message.tool_calls + tool_call = message.tool_call + assert isinstance(tool_call, ToolCall) + assert tool_call.tool_name == "weather" + assert tool_call.arguments == {"city": "Paris"} + assert message.meta["status"] == "completed" + + @pytest.mark.skipif( + not os.environ.get("AZURE_OPENAI_API_KEY", None), + reason="Export an env var called AZURE_OPENAI_API_KEY containing the Azure OpenAI API key to run this test.", + ) + @pytest.mark.integration + def test_live_run_with_response_format(self): + chat_messages = [ + ChatMessage.from_user("The marketing summit takes place on October12th at the Hilton Hotel downtown.") + ] + component = AzureOpenAIResponsesChatGenerator( + azure_deployment="gpt-4o-mini", generation_kwargs={"text_format": CalendarEvent} + ) + results = component.run(chat_messages) + assert len(results["replies"]) == 1 + message: ChatMessage = results["replies"][0] + msg = json.loads(message.text) + assert "Marketing Summit" in msg["event_name"] + assert isinstance(msg["event_date"], str) + assert isinstance(msg["event_location"], str) + assert message.meta["status"] == "completed" + + def test_to_dict_with_toolset(self, tools, monkeypatch): + """Test that the AzureOpenAIChatGenerator can be serialized to a dictionary with a Toolset.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + toolset = Toolset(tools[:1]) + component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset) + data = component.to_dict() + + expected_tools_data = { + "type": "haystack.tools.toolset.Toolset", + "data": { + "tools": [ + { + "type": "haystack.tools.tool.Tool", + "data": { + "name": "weather", + "description": "useful to determine the weather in a given location", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + "function": "generators.chat.test_azure_responses.get_weather", + "outputs_to_string": None, + "inputs_from_state": None, + "outputs_to_state": None, + }, + } + ] + }, + } + assert data["init_parameters"]["tools"] == expected_tools_data + + def test_warm_up_with_tools(self, monkeypatch): + """Test that warm_up() calls warm_up on tools and is idempotent.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + + # Create a mock tool that tracks if warm_up() was called + class MockTool(Tool): + warm_up_call_count = 0 # Class variable to track calls + + def __init__(self): + super().__init__( + name="mock_tool", + description="A mock tool for testing", + parameters={"x": {"type": "string"}}, + function=lambda x: x, + ) + + def warm_up(self): + MockTool.warm_up_call_count += 1 + + # Reset the class variable before test + MockTool.warm_up_call_count = 0 + mock_tool = MockTool() + + # Create AzureOpenAIChatGenerator with the mock tool + component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=[mock_tool]) + + # Verify initial state - warm_up not called yet + assert MockTool.warm_up_call_count == 0 + assert not component._is_warmed_up + + # Call warm_up() on the generator + component.warm_up() + + # Assert that the tool's warm_up() was called + assert MockTool.warm_up_call_count == 1 + assert component._is_warmed_up + + # Call warm_up() again and verify it's idempotent (only warms up once) + component.warm_up() + + # The tool's warm_up should still only have been called once + assert MockTool.warm_up_call_count == 1 + assert component._is_warmed_up + + def test_warm_up_with_no_tools(self, monkeypatch): + """Test that warm_up() works when no tools are provided.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint") + + # Verify initial state + assert not component._is_warmed_up + assert component.tools is None + + # Verify the component is warmed up + component.warm_up() + assert component._is_warmed_up + + def test_warm_up_with_multiple_tools(self, monkeypatch): + """Test that warm_up() works with multiple tools.""" + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + warm_up_calls = [] + + class MockTool(Tool): + def __init__(self, tool_name): + super().__init__( + name=tool_name, + description=f"Mock tool {tool_name}", + parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}, + function=lambda x: f"{tool_name} result: {x}", + ) + + def warm_up(self): + warm_up_calls.append(self.name) + + # Use a LIST of tools, not a Toolset + component = AzureOpenAIResponsesChatGenerator( + azure_endpoint="some-non-existing-endpoint", tools=[MockTool("tool1"), MockTool("tool2")] + ) + + # Assert that both tools' warm_up() were called + component.warm_up() + assert "tool1" in warm_up_calls + assert "tool2" in warm_up_calls + assert component._is_warmed_up + + # Test idempotency - warm_up should not call tools again + initial_count = len(warm_up_calls) + component.warm_up() + assert len(warm_up_calls) == initial_count + + +class TestAzureOpenAIChatGeneratorAsync: + def test_init_should_also_create_async_client_with_same_args(self, tools): + component = AzureOpenAIResponsesChatGenerator( + api_key=Secret.from_token("test-api-key"), + azure_endpoint="some-non-existing-endpoint", + streaming_callback=print_streaming_chunk, + generation_kwargs={"max_completion_tokens": 10, "some_test_param": "test-params"}, + tools=tools, + tools_strict=True, + ) + assert component.async_client.api_key == "test-api-key" + assert component._azure_deployment == "gpt-5-mini" + assert component.streaming_callback is print_streaming_chunk + assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"} + assert component.tools == tools + assert component.tools_strict + + @pytest.mark.integration + @pytest.mark.skipif( + not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None), + reason=( + "Please export env variables called AZURE_OPENAI_API_KEY containing " + "the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing " + "the Azure OpenAI endpoint URL to run this test." + ), + ) + @pytest.mark.asyncio + async def test_live_run_async(self): + chat_messages = [ChatMessage.from_user("What's the capital of France")] + component = AzureOpenAIResponsesChatGenerator(azure_deployment="gpt-4o-mini") + results = await component.run_async(chat_messages) + assert len(results["replies"]) == 1 + message: ChatMessage = results["replies"][0] + assert "Paris" in message.text + assert "gpt-4o-mini" in message.meta["model"] + assert message.meta["status"] == "completed" + + @pytest.mark.integration + @pytest.mark.skipif( + not os.environ.get("AZURE_OPENAI_API_KEY", None) or not os.environ.get("AZURE_OPENAI_ENDPOINT", None), + reason=( + "Please export env variables called AZURE_OPENAI_API_KEY containing " + "the Azure OpenAI key, AZURE_OPENAI_ENDPOINT containing " + "the Azure OpenAI endpoint URL to run this test." + ), + ) + @pytest.mark.asyncio + async def test_live_run_with_tools_async(self, tools): + chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")] + component = AzureOpenAIResponsesChatGenerator(tools=tools, azure_deployment="gpt-4o-mini") + results = await component.run_async(chat_messages) + assert len(results["replies"]) == 1 + message = results["replies"][0] + + assert not message.texts + assert not message.text + assert message.tool_calls + tool_call = message.tool_call + assert isinstance(tool_call, ToolCall) + assert tool_call.tool_name == "weather" + assert tool_call.arguments == {"city": "Paris"} + assert message.meta["status"] == "completed" + + # additional tests intentionally omitted as they are covered by test_openai.py