chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,736 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from openai import OpenAIError
from pydantic import BaseModel
import haystack.components.generators.chat.azure as azure_chat_module
from haystack import Pipeline, component
from haystack.components.generators.chat import AzureOpenAIChatGenerator
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: dict[str, Any] | None = 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_supported_models(self) -> None:
"""SUPPORTED_MODELS is a non-empty list of strings."""
models = AzureOpenAIChatGenerator.SUPPORTED_MODELS
assert isinstance(models, list)
assert len(models) > 0
assert all(isinstance(m, str) for m in models)
def test_init_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert component.api_key == Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False)
assert component.azure_deployment == "gpt-4.1-mini"
assert component.streaming_callback is None
assert not component.generation_kwargs
assert component.client is None
assert component.async_client is None
def test_init_does_not_fail_wo_api_key(self, monkeypatch):
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert component.client is None
assert component.async_client is None
def test_init_with_parameters(self, tools):
component = AzureOpenAIChatGenerator(
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,
azure_ad_token_provider=default_azure_ad_token_provider,
)
assert component.api_key == Secret.from_token("test-api-key")
assert component.azure_deployment == "gpt-4.1-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.azure_ad_token_provider is not None
assert component.max_retries is None
assert component.client is None
assert component.async_client is None
def test_init_with_0_max_retries(self, tools):
"""Tests that the max_retries init param is set correctly if equal 0"""
component = AzureOpenAIChatGenerator(
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,
azure_ad_token_provider=default_azure_ad_token_provider,
max_retries=0,
)
assert component.api_key == Secret.from_token("test-api-key")
assert component.azure_deployment == "gpt-4.1-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.azure_ad_token_provider is not None
assert component.max_retries == 0
assert component.client is None
assert component.async_client is None
def test_init_with_secret_azure_endpoint_and_api_version(self, monkeypatch):
"""`azure_endpoint` and `api_version` accept a Secret that is resolved from an environment variable."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://test-resource.azure.openai.com/")
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
component = AzureOpenAIChatGenerator(
azure_endpoint=Secret.from_env_var("AZURE_OPENAI_ENDPOINT"),
api_version=Secret.from_env_var("AZURE_OPENAI_API_VERSION"),
)
# The Secret objects are kept on the instance so they can be serialized
assert component.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
assert component.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
def test_init_fail_with_unset_secret_azure_endpoint(self, monkeypatch):
"""A Secret azure_endpoint that resolves to nothing raises the same error as a missing endpoint."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.delenv("AZURE_OPENAI_ENDPOINT", raising=False)
with pytest.raises(ValueError, match="Azure endpoint"):
AzureOpenAIChatGenerator(azure_endpoint=Secret.from_env_var("AZURE_OPENAI_ENDPOINT", strict=False))
def test_to_dict_with_secret_azure_endpoint_and_api_version(self, monkeypatch):
"""Secret `azure_endpoint` and `api_version` are serialized as Secret dictionaries."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://test-resource.azure.openai.com/")
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
component = AzureOpenAIChatGenerator(
azure_endpoint=Secret.from_env_var("AZURE_OPENAI_ENDPOINT"),
api_version=Secret.from_env_var("AZURE_OPENAI_API_VERSION"),
)
init_params = component.to_dict()["init_parameters"]
assert init_params["azure_endpoint"] == {
"type": "env_var",
"env_vars": ["AZURE_OPENAI_ENDPOINT"],
"strict": True,
}
assert init_params["api_version"] == {
"type": "env_var",
"env_vars": ["AZURE_OPENAI_API_VERSION"],
"strict": True,
}
def test_secret_azure_endpoint_and_api_version_roundtrip(self, monkeypatch):
"""Serializing and deserializing a component with Secret endpoint/version restores the Secrets."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://test-resource.azure.openai.com/")
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
component = AzureOpenAIChatGenerator(
azure_endpoint=Secret.from_env_var("AZURE_OPENAI_ENDPOINT"),
api_version=Secret.from_env_var("AZURE_OPENAI_API_VERSION"),
)
deserialized = AzureOpenAIChatGenerator.from_dict(component.to_dict())
assert deserialized.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
assert deserialized.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
deserialized.warm_up()
assert str(deserialized.client._azure_endpoint) == "https://test-resource.azure.openai.com/"
assert deserialized.client._api_version == "2024-08-01-preview"
def test_from_dict_with_secret_azure_endpoint_and_api_version(self, monkeypatch):
"""from_dict deserializes Secret azure_endpoint/api_version dicts and resolves them for the client."""
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://test-resource.azure.openai.com/")
monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
data = {
"type": "haystack.components.generators.chat.azure.AzureOpenAIChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"azure_endpoint": {"env_vars": ["AZURE_OPENAI_ENDPOINT"], "strict": True, "type": "env_var"},
"api_version": {"env_vars": ["AZURE_OPENAI_API_VERSION"], "strict": True, "type": "env_var"},
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"default_headers": {},
"tools": None,
"tools_strict": False,
"azure_ad_token_provider": None,
"http_client_kwargs": None,
},
}
generator = AzureOpenAIChatGenerator.from_dict(data)
# The Secret dicts are deserialized back into Secret objects
assert generator.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
assert generator.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
# And they are resolved to the string values the client expects
generator.warm_up()
assert str(generator.client._azure_endpoint) == "https://test-resource.azure.openai.com/"
assert generator.client._api_version == "2024-08-01-preview"
def test_to_dict_default(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
data = component.to_dict()
assert data == {
"type": "haystack.components.generators.chat.azure.AzureOpenAIChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"api_version": "2024-12-01-preview",
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"default_headers": {},
"tools": None,
"tools_strict": False,
"azure_ad_token_provider": None,
"http_client_kwargs": None,
},
}
def test_to_dict_with_parameters(self, monkeypatch, calendar_event_model):
monkeypatch.setenv("ENV_VAR", "test-api-key")
component = AzureOpenAIChatGenerator(
api_key=Secret.from_env_var("ENV_VAR", strict=False),
azure_ad_token=Secret.from_env_var("ENV_VAR1", 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,
},
azure_ad_token_provider=default_azure_ad_token_provider,
http_client_kwargs={"proxy": "http://localhost:8080"},
)
data = component.to_dict()
assert data == {
"type": "haystack.components.generators.chat.azure.AzureOpenAIChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["ENV_VAR"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["ENV_VAR1"], "strict": False, "type": "env_var"},
"api_version": "2024-12-01-preview",
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-4.1-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,
"default_headers": {},
"azure_ad_token_provider": "haystack.utils.azure.default_azure_ad_token_provider",
"http_client_kwargs": {"proxy": "http://localhost:8080"},
},
}
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.AzureOpenAIChatGenerator",
"init_parameters": {
"api_key": {"env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False, "type": "env_var"},
"azure_ad_token": {"env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False, "type": "env_var"},
"api_version": "2024-12-01-preview",
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"streaming_callback": None,
"generation_kwargs": {},
"timeout": 30.0,
"max_retries": 5,
"default_headers": {},
"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 = AzureOpenAIChatGenerator.from_dict(data)
assert isinstance(generator, AzureOpenAIChatGenerator)
assert generator.api_key == Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False)
assert generator.azure_ad_token == Secret.from_env_var("AZURE_OPENAI_AD_TOKEN", strict=False)
assert generator.api_version == "2024-12-01-preview"
assert generator.azure_endpoint == "some-non-existing-endpoint"
assert generator.azure_deployment == "gpt-4.1-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.default_headers == {}
assert generator.tools == [
Tool(name="name", description="description", parameters={"x": {"type": "string"}}, function=print)
]
assert generator.tools_strict is 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 = AzureOpenAIChatGenerator(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.AzureOpenAIChatGenerator",
"init_parameters": {
"azure_endpoint": "some-non-existing-endpoint",
"azure_deployment": "gpt-4.1-mini",
"organization": None,
"api_version": "2024-12-01-preview",
"streaming_callback": None,
"generation_kwargs": {},
"timeout": None,
"max_retries": None,
"api_key": {"type": "env_var", "env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False},
"azure_ad_token": {"type": "env_var", "env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False},
"default_headers": {},
"tools": None,
"tools_strict": False,
"azure_ad_token_provider": None,
"http_client_kwargs": None,
},
}
},
"connections": [],
}
p_str = p.dumps()
q = Pipeline.loads(p_str)
assert p.to_dict() == q.to_dict(), "Pipeline serialization/deserialization w/ AzureOpenAIChatGenerator failed."
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 = AzureOpenAIChatGenerator(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 = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=toolset)
data = component.to_dict()
deserialized_component = AzureOpenAIChatGenerator.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 = AzureOpenAIChatGenerator(organization="HaystackCI")
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "Paris" in message.text
assert "gpt-4.1-mini" in message.meta["model"]
assert message.meta["finish_reason"] == "stop"
@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 = AzureOpenAIChatGenerator(organization="HaystackCI", tools=tools)
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["finish_reason"] == "tool_calls"
@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):
class CalendarEvent(BaseModel):
event_name: str
event_date: str
event_location: str
chat_messages = [
ChatMessage.from_user("The marketing summit takes place on October12th at the Hilton Hotel downtown.")
]
component = AzureOpenAIChatGenerator(
api_version="2024-08-01-preview", generation_kwargs={"response_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["finish_reason"] == "stop"
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 = AzureOpenAIChatGenerator(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.get_weather",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
}
]
},
}
assert data["init_parameters"]["tools"] == expected_tools_data
class TestAzureOpenAIChatGeneratorAsync:
async def test_warm_up_async_builds_async_client(self, tools):
component = AzureOpenAIChatGenerator(
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 is None
await component.warm_up_async()
assert component.async_client.api_key == "test-api-key"
assert component.client is None
assert component.azure_deployment == "gpt-4.1-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):
component = AzureOpenAIChatGenerator(generation_kwargs={"n": 1})
chat_messages = [ChatMessage.from_user("What's the capital of France")]
results = await component.run_async(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "Paris" in message.text
assert "gpt-4.1-mini" in message.meta["model"]
assert message.meta["finish_reason"] == "stop"
await component.close_async()
@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):
component = AzureOpenAIChatGenerator(tools=tools)
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
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["finish_reason"] == "tool_calls"
await component.close_async()
# additional tests intentionally omitted as they are covered by test_openai.py
@pytest.fixture
def mock_azure_clients(monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake")
sync_cls = MagicMock(name="AzureOpenAI")
async_cls = MagicMock(name="AsyncAzureOpenAI")
async_cls.return_value.close = AsyncMock()
monkeypatch.setattr(azure_chat_module, "AzureOpenAI", sync_cls)
monkeypatch.setattr(azure_chat_module, "AsyncAzureOpenAI", async_cls)
return sync_cls, async_cls
class TestComponentLifecycle:
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
generator.warm_up()
assert generator.client.max_retries == 5
assert generator.client.timeout == 30.0
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
generator = AzureOpenAIChatGenerator(
api_key=Secret.from_token("fake-api-key"),
azure_endpoint="some-non-existing-endpoint",
timeout=40.0,
max_retries=1,
)
generator.warm_up()
assert generator.client.max_retries == 1
assert generator.client.timeout == 40.0
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
generator = AzureOpenAIChatGenerator(
api_key=Secret.from_token("fake-api-key"), azure_endpoint="some-non-existing-endpoint"
)
generator.warm_up()
assert generator.client.max_retries == 10
assert generator.client.timeout == 100.0
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
with pytest.raises(OpenAIError):
generator.warm_up()
def test_warm_up_warms_tools_once(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-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: x,
)
def warm_up(self):
warm_up_calls.append(self.name)
generator = AzureOpenAIChatGenerator(
azure_endpoint="some-non-existing-endpoint", tools=[MockTool("tool1"), MockTool("tool2")]
)
assert not generator._tools_warmed_up
generator.warm_up()
assert sorted(warm_up_calls) == ["tool1", "tool2"]
assert generator._tools_warmed_up
generator.warm_up()
assert sorted(warm_up_calls) == ["tool1", "tool2"]
def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
generator.warm_up()
assert generator._tools_warmed_up
def test_sync_lifecycle(self, mock_azure_clients):
sync_cls, _ = mock_azure_clients
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert generator.client is None
assert generator.async_client is None
generator.warm_up()
assert generator.client is sync_cls.return_value
assert generator.async_client is None
generator.close()
sync_cls.return_value.close.assert_called_once()
assert generator.client is None
async def test_async_lifecycle(self, mock_azure_clients):
_, async_cls = mock_azure_clients
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
await generator.warm_up_async()
assert generator.async_client is async_cls.return_value
assert generator.client is None
await generator.close_async()
async_cls.return_value.close.assert_awaited_once()
assert generator.async_client is None
async def test_close_is_safe_without_warm_up(self, mock_azure_clients):
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
generator.close()
await generator.close_async()
assert generator.client is None
assert generator.async_client is None
async def test_close_and_close_async_are_independent(self, mock_azure_clients):
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
generator.warm_up()
await generator.warm_up_async()
generator.close()
assert generator.client is None
assert generator.async_client is not None
await generator.close_async()
assert generator.async_client is None
@@ -0,0 +1,607 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
import os
from typing import Any
import pytest
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: dict[str, Any] | None = 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 TestInitialization:
def test_supported_models(self) -> None:
"""SUPPORTED_MODELS is a non-empty list of strings."""
models = AzureOpenAIResponsesChatGenerator.SUPPORTED_MODELS
assert isinstance(models, list)
assert len(models) > 0
assert all(isinstance(m, str) for m in models)
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 is None
assert component.async_client is None
assert component._azure_deployment == "gpt-5-mini"
assert component.streaming_callback is None
assert not component.generation_kwargs
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 is None
assert component.async_client is None
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_init_with_toolset(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
class TestSerDe:
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",
"text_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",
"text": {
"format": {
"type": "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_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",
"async_function": None,
"outputs_to_string": None,
"inputs_from_state": None,
"outputs_to_state": None,
},
}
]
},
}
assert data["init_parameters"]["tools"] == expected_tools_data
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 is 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 is False
assert generator.http_client_kwargs is None
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)
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()
class TestComponentLifecycle:
def test_warm_up_warms_tools_once(self, monkeypatch):
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: x,
)
def warm_up(self):
warm_up_calls.append(self.name)
component = AzureOpenAIResponsesChatGenerator(
azure_endpoint="some-non-existing-endpoint", tools=[MockTool("tool1"), MockTool("tool2")]
)
assert not component._tools_warmed_up
component.warm_up()
assert sorted(warm_up_calls) == ["tool1", "tool2"]
assert component._tools_warmed_up
component.warm_up()
assert sorted(warm_up_calls) == ["tool1", "tool2"]
def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
component.warm_up()
assert component._tools_warmed_up
def test_sync_lifecycle(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
assert component.client is None
assert component.async_client is None
component.warm_up()
assert component.client is not None
assert component.async_client is None
component.close()
assert component.client is None
async def test_async_lifecycle(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
await component.warm_up_async()
assert component.async_client is not None
assert component.client is None
await component.close_async()
assert component.async_client is None
async def test_close_is_safe_without_warm_up(self, monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
component.close()
await component.close_async()
assert component.client is None
assert component.async_client is None
@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."
),
)
class TestIntegration:
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.lower()
assert "gpt-4o-mini" in message.meta["model"]
assert message.meta["status"] == "completed"
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 "city" in tool_call.arguments
assert "paris" in tool_call.arguments["city"].lower()
assert message.meta["status"] == "completed"
def test_live_run_with_text_format(self, calendar_event_model):
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": calendar_event_model}
)
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"].lower()
assert isinstance(msg["event_date"], str)
assert isinstance(msg["event_location"], str)
assert message.meta["status"] == "completed"
# So far from documentation, responses.parse only supports BaseModel
def test_live_run_with_text_format_json_schema(self):
json_schema = {
"format": {
"type": "json_schema",
"name": "person",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1},
"age": {"type": "number", "minimum": 0, "maximum": 130},
},
"required": ["name", "age"],
"additionalProperties": False,
},
}
}
chat_messages = [ChatMessage.from_user("Jane 54 years old")]
component = AzureOpenAIResponsesChatGenerator(
azure_deployment="gpt-4o-mini", generation_kwargs={"text": json_schema}
)
results = component.run(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
msg = json.loads(message.text)
assert "jane" in msg["name"].lower()
assert msg["age"] == 54
assert message.meta["status"] == "completed"
assert message.meta["usage"]["output_tokens"] > 0
class TestAzureOpenAIResponsesChatGeneratorAsync:
async def test_warm_up_async_creates_async_client_with_expected_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 is None
await component.warm_up_async()
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.lower()
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 "city" in tool_call.arguments
assert "paris" in tool_call.arguments["city"].lower()
assert message.meta["status"] == "completed"
# additional tests intentionally omitted as they are covered by test_openai_responses.py
# and test_openai_responses_conversion.py
@@ -0,0 +1,489 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import time
from typing import Any
from unittest.mock import AsyncMock, Mock
from urllib.error import HTTPError as URLLibHTTPError
import pytest
from haystack import component, default_from_dict, default_to_dict
from haystack.components.generators.chat.fallback import FallbackChatGenerator
from haystack.core.errors import SerializationError
from haystack.dataclasses import ChatMessage, StreamingCallbackT
from haystack.tools import ToolsType
@component
class _DummySuccessGen:
def __init__(self, text: str = "ok", delay: float = 0.0, streaming_callback: StreamingCallbackT | None = None):
self.text = text
self.delay = delay
self.streaming_callback = streaming_callback
self.received_messages: list[list[ChatMessage]] = []
def to_dict(self) -> dict[str, Any]:
return default_to_dict(self, text=self.text, delay=self.delay, streaming_callback=None)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "_DummySuccessGen":
return default_from_dict(cls, data)
def run(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
self.received_messages.append(messages)
if self.delay:
time.sleep(self.delay)
if streaming_callback:
streaming_callback({"dummy": True}) # type: ignore[arg-type]
return {"replies": [ChatMessage.from_assistant(self.text)], "meta": {"dummy_meta": True}}
async def run_async(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
self.received_messages.append(messages)
if self.delay:
await asyncio.sleep(self.delay)
if streaming_callback:
await asyncio.sleep(0)
streaming_callback({"dummy": True}) # type: ignore[arg-type]
return {"replies": [ChatMessage.from_assistant(self.text)], "meta": {"dummy_meta": True}}
@component
class _DummyFailGen:
def __init__(self, exc: Exception | None = None, delay: float = 0.0):
self.exc = exc or RuntimeError("boom")
self.delay = delay
def to_dict(self) -> dict[str, Any]:
return default_to_dict(self, exc={"message": str(self.exc)}, delay=self.delay)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "_DummyFailGen":
init = data.get("init_parameters", {})
msg = None
if isinstance(init.get("exc"), dict):
msg = init.get("exc", {}).get("message")
return cls(exc=RuntimeError(msg or "boom"), delay=init.get("delay", 0.0))
def run(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
if self.delay:
time.sleep(self.delay)
raise self.exc
async def run_async(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
if self.delay:
await asyncio.sleep(self.delay)
raise self.exc
def test_init_validation():
with pytest.raises(ValueError):
FallbackChatGenerator(chat_generators=[])
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="A")])
assert len(gen.chat_generators) == 1
def test_sequential_first_success():
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="A")])
res = gen.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "A"
assert res["meta"]["successful_chat_generator_index"] == 0
assert res["meta"]["total_attempts"] == 1
def test_run_with_string_input():
inner = _DummySuccessGen()
gen = FallbackChatGenerator(chat_generators=[inner])
res = gen.run("hi")
assert inner.received_messages[0] == [ChatMessage.from_user("hi")]
assert isinstance(res["replies"][0], ChatMessage)
async def test_run_async_with_string_input():
inner = _DummySuccessGen()
gen = FallbackChatGenerator(chat_generators=[inner])
res = await gen.run_async("hi")
assert inner.received_messages[0] == [ChatMessage.from_user("hi")]
assert isinstance(res["replies"][0], ChatMessage)
def test_sequential_second_success_after_failure():
gen = FallbackChatGenerator(chat_generators=[_DummyFailGen(), _DummySuccessGen(text="B")])
res = gen.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "B"
assert res["meta"]["successful_chat_generator_index"] == 1
assert res["meta"]["failed_chat_generators"]
def test_all_fail_raises():
gen = FallbackChatGenerator(chat_generators=[_DummyFailGen(), _DummyFailGen()])
with pytest.raises(RuntimeError):
gen.run([ChatMessage.from_user("hi")])
def test_timeout_handling_sync():
slow = _DummySuccessGen(text="slow", delay=0.01)
fast = _DummySuccessGen(text="fast", delay=0.0)
gen = FallbackChatGenerator(chat_generators=[slow, fast])
res = gen.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "slow"
@pytest.mark.asyncio
async def test_timeout_handling_async():
slow = _DummySuccessGen(text="slow", delay=0.01)
fast = _DummySuccessGen(text="fast", delay=0.0)
gen = FallbackChatGenerator(chat_generators=[slow, fast])
res = await gen.run_async([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "slow"
def test_streaming_callback_forwarding_sync():
calls: list[Any] = []
def cb(x: Any) -> None:
calls.append(x)
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="A")])
_ = gen.run([ChatMessage.from_user("hi")], streaming_callback=cb)
assert calls
@pytest.mark.asyncio
async def test_streaming_callback_forwarding_async():
calls: list[Any] = []
def cb(x: Any) -> None:
calls.append(x)
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="A")])
_ = await gen.run_async([ChatMessage.from_user("hi")], streaming_callback=cb)
assert calls
def test_serialization_roundtrip():
original = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="hello")])
data = original.to_dict()
restored = FallbackChatGenerator.from_dict(data)
assert isinstance(restored, FallbackChatGenerator)
assert len(restored.chat_generators) == 1
res = restored.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "hello"
original = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="hello"), _DummySuccessGen(text="world")])
data = original.to_dict()
restored = FallbackChatGenerator.from_dict(data)
assert isinstance(restored, FallbackChatGenerator)
assert len(restored.chat_generators) == 2
res = restored.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "hello"
def test_automatic_completion_mode_without_streaming():
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="completion")])
res = gen.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "completion"
assert res["meta"]["successful_chat_generator_index"] == 0
def test_automatic_ttft_mode_with_streaming():
calls: list[Any] = []
def cb(x: Any) -> None:
calls.append(x)
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="streaming")])
res = gen.run([ChatMessage.from_user("hi")], streaming_callback=cb)
assert res["replies"][0].text == "streaming"
assert calls
@pytest.mark.asyncio
async def test_automatic_ttft_mode_with_streaming_async():
calls: list[Any] = []
def cb(x: Any) -> None:
calls.append(x)
gen = FallbackChatGenerator(chat_generators=[_DummySuccessGen(text="streaming_async")])
res = await gen.run_async([ChatMessage.from_user("hi")], streaming_callback=cb)
assert res["replies"][0].text == "streaming_async"
assert calls
def create_http_error(status_code: int, message: str) -> URLLibHTTPError:
return URLLibHTTPError("", status_code, message, {}, None)
@component
class _DummyHTTPErrorGen:
def __init__(self, text: str = "success", error: Exception | None = None):
self.text = text
self.error = error
def to_dict(self) -> dict[str, Any]:
return default_to_dict(self, text=self.text, error=str(self.error) if self.error else None)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "_DummyHTTPErrorGen":
init = data.get("init_parameters", {})
error = None
if init.get("error"):
error = RuntimeError(init["error"])
return cls(text=init.get("text", "success"), error=error)
def run(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
if self.error:
raise self.error
return {
"replies": [ChatMessage.from_assistant(self.text)],
"meta": {"error_type": type(self.error).__name__ if self.error else None},
}
def test_failover_trigger_429_rate_limit():
rate_limit_gen = _DummyHTTPErrorGen(text="rate_limited", error=create_http_error(429, "Rate limit exceeded"))
success_gen = _DummySuccessGen(text="success_after_rate_limit")
fallback = FallbackChatGenerator(chat_generators=[rate_limit_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_rate_limit"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
def test_failover_trigger_401_authentication():
auth_error_gen = _DummyHTTPErrorGen(text="auth_failed", error=create_http_error(401, "Authentication failed"))
success_gen = _DummySuccessGen(text="success_after_auth")
fallback = FallbackChatGenerator(chat_generators=[auth_error_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_auth"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
def test_failover_trigger_400_bad_request():
bad_request_gen = _DummyHTTPErrorGen(text="bad_request", error=create_http_error(400, "Context length exceeded"))
success_gen = _DummySuccessGen(text="success_after_bad_request")
fallback = FallbackChatGenerator(chat_generators=[bad_request_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_bad_request"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
def test_failover_trigger_500_server_error():
server_error_gen = _DummyHTTPErrorGen(text="server_error", error=create_http_error(500, "Internal server error"))
success_gen = _DummySuccessGen(text="success_after_server_error")
fallback = FallbackChatGenerator(chat_generators=[server_error_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_server_error"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
def test_failover_trigger_multiple_errors():
rate_limit_gen = _DummyHTTPErrorGen(text="rate_limited", error=create_http_error(429, "Rate limit exceeded"))
auth_error_gen = _DummyHTTPErrorGen(text="auth_failed", error=create_http_error(401, "Authentication failed"))
server_error_gen = _DummyHTTPErrorGen(text="server_error", error=create_http_error(500, "Internal server error"))
success_gen = _DummySuccessGen(text="success_after_all_errors")
fallback = FallbackChatGenerator(chat_generators=[rate_limit_gen, auth_error_gen, server_error_gen, success_gen])
result = fallback.run([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_all_errors"
assert result["meta"]["successful_chat_generator_index"] == 3
assert len(result["meta"]["failed_chat_generators"]) == 3
def test_failover_trigger_all_generators_fail():
rate_limit_gen = _DummyHTTPErrorGen(text="rate_limited", error=create_http_error(429, "Rate limit exceeded"))
auth_error_gen = _DummyHTTPErrorGen(text="auth_failed", error=create_http_error(401, "Authentication failed"))
server_error_gen = _DummyHTTPErrorGen(text="server_error", error=create_http_error(500, "Internal server error"))
fallback = FallbackChatGenerator(chat_generators=[rate_limit_gen, auth_error_gen, server_error_gen])
with pytest.raises(RuntimeError) as exc_info:
fallback.run([ChatMessage.from_user("test")])
error_msg = str(exc_info.value)
assert "All 3 chat generators failed" in error_msg
assert "Failed chat generators: [_DummyHTTPErrorGen, _DummyHTTPErrorGen, _DummyHTTPErrorGen]" in error_msg
@pytest.mark.asyncio
async def test_failover_trigger_429_rate_limit_async():
rate_limit_gen = _DummyHTTPErrorGen(text="rate_limited", error=create_http_error(429, "Rate limit exceeded"))
success_gen = _DummySuccessGen(text="success_after_rate_limit")
fallback = FallbackChatGenerator(chat_generators=[rate_limit_gen, success_gen])
result = await fallback.run_async([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_rate_limit"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
@pytest.mark.asyncio
async def test_failover_trigger_401_authentication_async():
auth_error_gen = _DummyHTTPErrorGen(text="auth_failed", error=create_http_error(401, "Authentication failed"))
success_gen = _DummySuccessGen(text="success_after_auth")
fallback = FallbackChatGenerator(chat_generators=[auth_error_gen, success_gen])
result = await fallback.run_async([ChatMessage.from_user("test")])
assert result["replies"][0].text == "success_after_auth"
assert result["meta"]["successful_chat_generator_index"] == 1
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
class TestComponentLifecycle:
def test_warm_up_delegates_to_every_generator(self):
gens = [Mock(spec=["run", "warm_up"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
fallback.warm_up()
for gen in gens:
gen.warm_up.assert_called_once()
async def test_warm_up_async_delegates_to_every_generator(self):
gens = [Mock(spec=["run", "warm_up_async"]) for _ in range(3)]
for gen in gens:
gen.warm_up_async = AsyncMock()
fallback = FallbackChatGenerator(chat_generators=gens)
await fallback.warm_up_async()
for gen in gens:
gen.warm_up_async.assert_awaited_once()
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
gens = [Mock(spec=["run", "warm_up"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
await fallback.warm_up_async()
for gen in gens:
gen.warm_up.assert_called_once()
def test_close_delegates_to_every_generator(self):
gens = [Mock(spec=["run", "close"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
fallback.close()
for gen in gens:
gen.close.assert_called_once()
async def test_close_async_delegates_to_every_generator(self):
gens = [Mock(spec=["run", "close_async"]) for _ in range(3)]
for gen in gens:
gen.close_async = AsyncMock()
fallback = FallbackChatGenerator(chat_generators=gens)
await fallback.close_async()
for gen in gens:
gen.close_async.assert_awaited_once()
async def test_close_async_falls_back_to_sync_close(self):
gens = [Mock(spec=["run", "close"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
await fallback.close_async()
for gen in gens:
gen.close.assert_called_once()
def test_lifecycle_is_safe_when_generators_lack_methods(self):
gens = [Mock(spec=["run"]) for _ in range(3)]
fallback = FallbackChatGenerator(chat_generators=gens)
fallback.warm_up()
fallback.close()
@component
class CustomGeneratorWithoutSerDe:
def __init__(self, text: str = "custom_ok"):
self.text = text
def run(
self,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant(self.text)], "meta": {}}
@component
class NonSerializableGenerator:
def __init__(self, non_serializable_arg: Any):
self.non_serializable_arg = non_serializable_arg
def run(self, messages: list[ChatMessage]) -> dict[str, Any]:
return {"replies": []}
def test_serialization_with_custom_generators_without_to_dict():
# 1. Test mixed chain serialization, order preservation, execution, and round-trip
gen0 = _DummySuccessGen(text="dummy_has_dict")
gen1 = CustomGeneratorWithoutSerDe(text="custom_no_dict_1")
gen2 = CustomGeneratorWithoutSerDe(text="custom_no_dict_2")
original = FallbackChatGenerator(chat_generators=[gen0, gen1, gen2])
data = original.to_dict()
# Ensure all three components are serialized and not silently omitted
assert len(data["init_parameters"]["chat_generators"]) == 3
# Reconstruct/Deserialize
restored = FallbackChatGenerator.from_dict(data)
assert isinstance(restored, FallbackChatGenerator)
assert len(restored.chat_generators) == 3
# Assert fallback order is exactly preserved
assert restored.chat_generators[0].text == "dummy_has_dict"
assert restored.chat_generators[1].text == "custom_no_dict_1"
assert restored.chat_generators[2].text == "custom_no_dict_2"
assert isinstance(restored.chat_generators[1], CustomGeneratorWithoutSerDe)
assert isinstance(restored.chat_generators[2], CustomGeneratorWithoutSerDe)
# Verify pipeline execution on the restored instance
res = restored.run([ChatMessage.from_user("hi")])
assert res["replies"][0].text == "dummy_has_dict"
# 2. Test failure path (fail loud) when a component is not serializable
non_serializable_fallback = FallbackChatGenerator(chat_generators=[NonSerializableGenerator(object())])
with pytest.raises(SerializationError, match="unsupported value of type"):
non_serializable_fallback.to_dict()
+369
View File
@@ -0,0 +1,369 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from unittest.mock import MagicMock
import pytest
from haystack import Document, Pipeline, component
from haystack.components.agents.agent import Agent
from haystack.components.generators.chat import LLM
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.joiners.branch import BranchJoiner
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.routers.conditional_router import ConditionalRouter
from haystack.core.component.types import InputSocket, OutputSocket
from haystack.dataclasses import ChatMessage
from haystack.dataclasses.chat_message import ChatRole
from haystack.dataclasses.streaming_chunk import StreamingChunk
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.tools import Tool
from haystack.tools.toolset import Toolset
def sync_streaming_callback(chunk: StreamingChunk) -> None:
pass
@component
class MockChatGeneratorWithTools:
"""A mock chat generator that accepts a tools parameter."""
def to_dict(self) -> dict[str, Any]:
return {"type": "test_llm.MockChatGeneratorWithTools", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGeneratorWithTools":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Reply with tools support")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self, messages: list[ChatMessage], tools: list[Tool] | Toolset | None = None, **kwargs
) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Async reply with tools support")]}
@component
class MockChatGenerator:
"""A mock chat generator that does NOT accept a tools parameter."""
def to_dict(self) -> dict[str, Any]:
return {"type": "test_llm.MockChatGenerator", "data": {}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGenerator":
return cls()
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Sync reply")]}
@component.output_types(replies=list[ChatMessage])
async def run_async(self, messages: list[ChatMessage], **kwargs) -> dict[str, Any]:
return {"replies": [ChatMessage.from_assistant("Async reply")]}
class TestLLM:
class TestInit:
USER_PROMPT = '{% message role="user" %}{{ query }}{% endmessage %}'
def test_is_subclass_of_agent(self):
assert issubclass(LLM, Agent)
def test_defaults(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm.chat_generator is not None
assert llm.tools == []
assert llm.system_prompt is None
assert llm.user_prompt == self.USER_PROMPT
assert llm.required_variables == "*"
assert llm.streaming_callback is None
def test_output_sockets(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm.__haystack_output__._sockets_dict == {
"messages": OutputSocket(name="messages", type=list[ChatMessage], receivers=[]),
"last_message": OutputSocket(name="last_message", type=ChatMessage, receivers=[]),
"token_usage": OutputSocket(name="token_usage", type=dict[str, Any], receivers=[]),
}
def test_detects_no_tools_support(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert llm._chat_generator_supports_tools is False
def test_detects_tools_support(self):
llm = LLM(chat_generator=MockChatGeneratorWithTools(), user_prompt=self.USER_PROMPT)
assert llm._chat_generator_supports_tools is True
def test_messages_required_when_no_prompt_variables(self):
llm = LLM(
chat_generator=MockChatGenerator(), user_prompt='{% message role="user" %}Hello world{% endmessage %}'
)
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert messages_socket.is_mandatory
def test_messages_optional_when_prompt_has_variables(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert not messages_socket.is_mandatory
def test_messages_optional_when_plain_prompt_has_variables(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt="Question: {{ query }}")
messages_socket = llm.__haystack_input__._sockets_dict["messages"]
assert isinstance(messages_socket, InputSocket)
assert not messages_socket.is_mandatory
assert "query" in llm.__haystack_input__._sockets_dict
def test_runtime_prompt_overrides_not_component_inputs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
assert "system_prompt" not in llm.__haystack_input__._sockets_dict
assert "user_prompt" not in llm.__haystack_input__._sockets_dict
def test_raises_if_required_variables_empty(self):
with pytest.raises(ValueError, match="required_variables must not be empty"):
LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT, required_variables=[])
class TestSerialization:
def test_to_dict_excludes_agent_only_params(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
user_prompt = '{% message role="user" %}{{ query }}{% endmessage %}'
llm = LLM(chat_generator=OpenAIChatGenerator(), system_prompt="You are helpful.", user_prompt=user_prompt)
serialized = llm.to_dict()
assert serialized["type"] == "haystack.components.generators.chat.llm.LLM"
assert "chat_generator" in serialized["init_parameters"]
assert serialized["init_parameters"]["system_prompt"] == "You are helpful."
agent_only_params = [
"tools",
"exit_conditions",
"max_agent_steps",
"raise_on_tool_invocation_failure",
"tool_concurrency_limit",
"tool_streaming_callback_passthrough",
"confirmation_strategies",
"state_schema",
]
for param in agent_only_params:
assert param not in serialized["init_parameters"], (
f"Agent-only param '{param}' should not be serialized"
)
def test_to_dict_includes_llm_params(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
llm = LLM(
chat_generator=OpenAIChatGenerator(),
system_prompt="Be concise.",
user_prompt='{% message role="user" %}{{ query }}{% endmessage %}',
required_variables=["query"],
)
serialized = llm.to_dict()
assert serialized["init_parameters"]["system_prompt"] == "Be concise."
assert "{{ query }}" in serialized["init_parameters"]["user_prompt"]
assert serialized["init_parameters"]["required_variables"] == ["query"]
assert serialized["init_parameters"]["streaming_callback"] is None
def test_from_dict(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
data = {
"type": "haystack.components.generators.chat.llm.LLM",
"init_parameters": {
"chat_generator": {
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
"init_parameters": {
"model": "gpt-4o-mini",
"streaming_callback": None,
"api_base_url": None,
"organization": None,
"generation_kwargs": {},
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"timeout": None,
"max_retries": None,
"tools": None,
"tools_strict": False,
"http_client_kwargs": None,
},
},
"system_prompt": "You are helpful.",
"user_prompt": '{% message role="user" %}{{ query }}{% endmessage %}',
"required_variables": "*",
"streaming_callback": None,
},
}
llm = LLM.from_dict(data)
assert isinstance(llm, LLM)
assert isinstance(llm.chat_generator, OpenAIChatGenerator)
assert llm.system_prompt == "You are helpful."
assert llm.tools == []
def test_roundtrip(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
user_prompt = '{% message role="user" %}{{ query }}{% endmessage %}'
original = LLM(
chat_generator=OpenAIChatGenerator(), system_prompt="You are a poet.", user_prompt=user_prompt
)
restored = LLM.from_dict(original.to_dict())
assert isinstance(restored, LLM)
assert isinstance(restored.chat_generator, OpenAIChatGenerator)
assert restored.system_prompt == original.system_prompt
assert restored.tools == []
class TestRun:
USER_PROMPT = '{% message role="user" %}{{ query }}{% endmessage %}'
def test_run_accepts_messages_via_kwargs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
prior_message = ChatMessage.from_user("Some prior context")
result = llm.run(query="What is 2+2?", messages=[prior_message])
assert result["last_message"].text == "Sync reply"
assert prior_message in result["messages"]
def test_run_without_messages(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
result = llm.run(query="What is 2+2?")
assert result["last_message"].text == "Sync reply"
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert any("What is 2+2?" in m.text for m in user_messages)
def test_run_with_plain_user_prompt(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt="Question: {{ query }}")
result = llm.run(query="What is 2+2?")
assert result["last_message"].text == "Sync reply"
user_messages = [m for m in result["messages"] if m.is_from(ChatRole.USER)]
assert any("Question: What is 2+2?" in m.text for m in user_messages)
@pytest.mark.asyncio
async def test_run_async_accepts_messages_via_kwargs(self):
llm = LLM(chat_generator=MockChatGenerator(), user_prompt=self.USER_PROMPT)
prior_message = ChatMessage.from_user("Some prior context")
result = await llm.run_async(query="What is 2+2?", messages=[prior_message])
assert result["last_message"].text == "Async reply"
assert prior_message in result["messages"]
class TestPipelineIntegration:
@pytest.fixture()
def document_store_with_docs(self):
store = InMemoryDocumentStore()
store.write_documents(
[
Document(content="The Eiffel Tower is located in Paris."),
Document(content="The Brandenburg Gate is in Berlin."),
Document(content="The Colosseum is in Rome."),
]
)
return store
def test_rag_pipeline(self, document_store_with_docs):
user_prompt = (
'{% message role="user" %}'
"Use the following documents to answer the question.\n"
"Documents:\n{% for doc in documents %}{{ doc.content }}\n{% endfor %}"
"Question: {{ query }}"
"{% endmessage %}"
)
llm = LLM(
chat_generator=MockChatGenerator(),
system_prompt="You are a knowledgeable assistant.",
user_prompt=user_prompt,
required_variables=["query", "documents"],
)
pipe = Pipeline()
pipe.add_component("retriever", InMemoryBM25Retriever(document_store=document_store_with_docs))
pipe.add_component("llm", llm)
pipe.connect("retriever.documents", "llm.documents")
query = "Where is the Colosseum?"
result = pipe.run(data={"retriever": {"query": query}, "llm": {"query": query}})
assert "llm" in result
llm_output = result["llm"]
assert "messages" in llm_output
assert "last_message" in llm_output
messages = llm_output["messages"]
assert messages[0].is_from(ChatRole.SYSTEM)
assert messages[0].text == "You are a knowledgeable assistant."
user_messages = [m for m in messages if m.is_from(ChatRole.USER)]
assert len(user_messages) == 1
rendered = user_messages[0].text
assert "Question: Where is the Colosseum?" in rendered
assert "Documents:" in rendered
assert "Colosseum" in rendered
assert llm_output["last_message"].is_from(ChatRole.ASSISTANT)
assert llm_output["last_message"].text == "Sync reply"
class TestLLMNotTriggeredByInjectedInput:
"""
Regression guard for the optional-messages scheduling hazard described in
https://github.com/deepset-ai/haystack/issues/11109.
When `user_prompt` contains template variables, `messages` is optional on the LLM.
An optional input with `sender=None` (i.e., injected directly via `pipeline.run`)
would flip `has_user_input()` to True and incorrectly trigger the component even
when its required inputs (e.g. `query`) never arrive.
"""
def test_llm_not_triggered_by_injected_streaming_callback(self):
@component
class Planner:
@component.output_types(messages=list[ChatMessage], last_role=str)
def run(self) -> dict:
return {"messages": [ChatMessage.from_user("hello")], "last_role": "assistant"}
chat_generator = MockChatGenerator()
llm = LLM(chat_generator=chat_generator)
chat_generator.run = MagicMock(return_value={"replies": [ChatMessage.from_assistant("x")]})
router = ConditionalRouter(
routes=[
{
"condition": "{{ last_role == 'tool' }}",
"output": "{{ messages }}",
"output_name": "processing",
"output_type": list[ChatMessage],
},
{
"condition": "{{ True }}",
"output": "{{ messages }}",
"output_name": "planning",
"output_type": list[ChatMessage],
},
],
unsafe=True,
)
pipeline = Pipeline()
pipeline.add_component("planner", Planner())
pipeline.add_component("router", router)
pipeline.add_component("branch_joiner", BranchJoiner(type_=list[ChatMessage]))
pipeline.add_component("llm", llm)
pipeline.connect("planner.messages", "router.messages")
pipeline.connect("planner.last_role", "router.last_role")
pipeline.connect("router.processing", "branch_joiner.value")
pipeline.connect("branch_joiner.value", "llm.messages")
result = pipeline.run(data={"llm": {"streaming_callback": sync_streaming_callback}})
assert "llm" not in result
chat_generator.run.assert_not_called()
@@ -0,0 +1,204 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import inspect
import pytest
from haystack import Pipeline
from haystack.components.generators.chat import MockChatGenerator
from haystack.dataclasses import ChatMessage, StreamingChunk, ToolCall
def _exclaim(messages: list[ChatMessage]) -> str:
"""Module-level response function (returns a string) used to test `response_fn` and its serialization."""
return f"{messages[-1].text}!"
def _assistant_reply(messages: list[ChatMessage]) -> ChatMessage:
"""Module-level response function that returns a full ChatMessage."""
return ChatMessage.from_assistant("canned message")
def _noop_callback(chunk: StreamingChunk) -> None:
"""Module-level streaming callback used to test init-level callback serialization."""
class TestMockChatGenerator:
@pytest.mark.parametrize(
("args", "kwargs", "exception", "match"),
[
(("a",), {"response_fn": _exclaim}, ValueError, "either 'responses' or 'response_fn'"),
(([],), {}, ValueError, "must not be an empty list"),
((123,), {}, TypeError, "must be a string, ChatMessage, or a sequence"),
(([123],), {}, TypeError, "Each response must be a string or ChatMessage"),
((ChatMessage.from_user("hi"),), {}, ValueError, "must have the 'assistant' role"),
],
)
def test_init_rejects_invalid_config(self, args, kwargs, exception, match):
with pytest.raises(exception, match=match):
MockChatGenerator(*args, **kwargs)
def test_fixed_response(self):
gen = MockChatGenerator("the same answer")
for _ in range(3):
result = gen.run([ChatMessage.from_user("anything")])
assert result["replies"][0].text == "the same answer"
def test_cycling_responses(self):
# a mix of strings and ChatMessage objects, returned in order and wrapping around
gen = MockChatGenerator(["one", ChatMessage.from_assistant("two"), "three"])
texts = [gen.run([ChatMessage.from_user("hi")])["replies"][0].text for _ in range(4)]
assert texts == ["one", "two", "three", "one"]
@pytest.mark.parametrize(
("messages", "expected"),
[
(
[ChatMessage.from_system("sys"), ChatMessage.from_user("first"), ChatMessage.from_user("second")],
"second",
),
([ChatMessage.from_system("only system")], "only system"), # falls back to the last message with text
([], None), # nothing to echo
],
)
def test_echo_default(self, messages, expected):
replies = MockChatGenerator().run(messages)["replies"]
if expected is None:
assert replies == []
else:
assert replies[0].text == expected
@pytest.mark.parametrize(("fn", "expected"), [(_exclaim, "hello!"), (_assistant_reply, "canned message")])
def test_response_fn(self, fn, expected):
result = MockChatGenerator(response_fn=fn).run([ChatMessage.from_user("hello")])
assert result["replies"][0].text == expected
@pytest.mark.parametrize(
("fn", "exception", "match"),
[
(lambda messages: 123, TypeError, "must return a string or ChatMessage"),
(lambda messages: ChatMessage.from_user("nope"), ValueError, "must return an assistant ChatMessage"),
],
)
def test_response_fn_invalid_return_raises(self, fn, exception, match):
with pytest.raises(exception, match=match):
MockChatGenerator(response_fn=fn).run([ChatMessage.from_user("hi")])
def test_string_input_is_normalized(self):
gen = MockChatGenerator(response_fn=_exclaim)
assert gen.run("plain string")["replies"][0].text == "plain string!"
def test_tool_call_response(self):
tool_call = ToolCall(tool_name="search", arguments={"query": "Haystack"})
gen = MockChatGenerator(ChatMessage.from_assistant(tool_calls=[tool_call]))
reply = gen.run([ChatMessage.from_user("search for Haystack")])["replies"][0]
assert reply.tool_calls == [tool_call]
assert reply.meta["finish_reason"] == "tool_calls"
def test_meta_defaults(self):
meta = MockChatGenerator("hello world").run([ChatMessage.from_user("a b c")])["replies"][0].meta
assert meta["model"] == "mock-model"
assert meta["finish_reason"] == "stop"
assert meta["usage"] == {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}
def test_meta_merging_precedence(self):
# init meta overrides defaults; per-response meta overrides init meta
response = ChatMessage.from_assistant("hi", meta={"custom": "from-response", "finish_reason": "length"})
gen = MockChatGenerator(response, model="custom-model", meta={"custom": "from-init", "extra": "init"})
meta = gen.run([ChatMessage.from_user("x")])["replies"][0].meta
assert meta["model"] == "custom-model"
assert meta["custom"] == "from-response"
assert meta["finish_reason"] == "length"
assert meta["extra"] == "init"
def test_does_not_mutate_stored_responses(self):
gen = MockChatGenerator("hello")
gen.run([ChatMessage.from_user("a b")])
# the stored response keeps its original (empty) meta, untouched by the per-run meta
assert gen._responses[0].meta == {}
async def test_run_async(self):
gen = MockChatGenerator(["one", "two"])
assert (await gen.run_async([ChatMessage.from_user("hi")]))["replies"][0].text == "one"
assert (await gen.run_async([ChatMessage.from_user("hi")]))["replies"][0].text == "two"
# echo mode with empty input returns no replies (async path)
assert (await MockChatGenerator().run_async([]))["replies"] == []
def test_streaming_callback_sync(self):
chunks: list[StreamingChunk] = []
result = MockChatGenerator("hello there friend").run(
[ChatMessage.from_user("hi")], streaming_callback=chunks.append
)
assert "".join(chunk.content for chunk in chunks) == "hello there friend"
assert chunks[0].start is True
assert chunks[-1].finish_reason == "stop"
# the returned reply matches the predefined response
assert result["replies"][0].text == "hello there friend"
def test_run_signature_matches_openai_order(self):
# run()/run_async() must mirror OpenAIChatGenerator's parameter order so the mock is a positional drop-in.
expected = [
("self", inspect.Parameter.POSITIONAL_OR_KEYWORD),
("messages", inspect.Parameter.POSITIONAL_OR_KEYWORD),
("streaming_callback", inspect.Parameter.POSITIONAL_OR_KEYWORD),
("generation_kwargs", inspect.Parameter.POSITIONAL_OR_KEYWORD),
("tools", inspect.Parameter.KEYWORD_ONLY),
("tools_strict", inspect.Parameter.KEYWORD_ONLY),
]
for method in ("run", "run_async"):
params = list(inspect.signature(getattr(MockChatGenerator, method)).parameters.values())
assert [(p.name, p.kind) for p in params] == expected
# passing the callback as the 2nd positional arg must be treated as streaming_callback, not generation_kwargs
chunks: list[StreamingChunk] = []
MockChatGenerator("hi").run([ChatMessage.from_user("x")], chunks.append)
assert chunks
async def test_streaming_callback_async(self):
chunks: list[StreamingChunk] = []
async def callback(chunk: StreamingChunk) -> None:
chunks.append(chunk)
await MockChatGenerator("hello world").run_async([ChatMessage.from_user("hi")], streaming_callback=callback)
assert "".join(chunk.content for chunk in chunks) == "hello world"
assert chunks[-1].finish_reason == "stop"
def test_streaming_empty_reply(self):
chunks: list[StreamingChunk] = []
MockChatGenerator("").run([ChatMessage.from_user("hi")], streaming_callback=chunks.append)
assert chunks[-1].finish_reason == "stop"
def test_streaming_callback_with_tool_call(self):
chunks: list[StreamingChunk] = []
tool_call = ToolCall(tool_name="search", arguments={"query": "x"})
gen = MockChatGenerator(ChatMessage.from_assistant(tool_calls=[tool_call]))
gen.run([ChatMessage.from_user("hi")], streaming_callback=chunks.append)
assert any(chunk.tool_calls for chunk in chunks)
assert chunks[-1].finish_reason == "tool_calls"
@pytest.mark.parametrize(
"generator",
[
MockChatGenerator(["a", ChatMessage.from_assistant("b")], model="m", meta={"k": "v"}),
MockChatGenerator(response_fn=_exclaim),
MockChatGenerator(), # echo mode
MockChatGenerator("hi", streaming_callback=_noop_callback), # serialized init-level callback
],
ids=["responses", "response_fn", "echo", "streaming_callback"],
)
def test_serialization_roundtrip(self, generator):
restored = MockChatGenerator.from_dict(generator.to_dict())
assert isinstance(restored, MockChatGenerator)
# behavior is preserved across the roundtrip
messages = [ChatMessage.from_user("hi")]
assert restored.run(messages)["replies"][0].text == generator.run(messages)["replies"][0].text
def test_in_pipeline(self):
pipeline = Pipeline()
pipeline.add_component("generator", MockChatGenerator("from the pipeline"))
restored = Pipeline.from_dict(pipeline.to_dict())
result = restored.run({"generator": {"messages": [ChatMessage.from_user("hi")]}})
assert result["generator"]["replies"][0].text == "from the pipeline"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import contextlib
import os
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openai import AsyncOpenAI, AsyncStream, OpenAIError
from openai.types.chat import (
ChatCompletion,
ChatCompletionChunk,
ChatCompletionMessage,
ChatCompletionMessageFunctionToolCall,
chat_completion_chunk,
)
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_message_function_tool_call import Function
from openai.types.completion_usage import CompletionTokensDetails, CompletionUsage, PromptTokensDetails
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage, StreamingChunk, ToolCall
from haystack.tools import Tool
from haystack.utils.auth import Secret
@pytest.fixture
def chat_messages():
return [
ChatMessage.from_system("You are a helpful assistant"),
ChatMessage.from_user("What's the capital of France"),
]
@pytest.fixture
def mock_chat_completion_chunk_with_tools(openai_mock_stream_async):
"""
Mock the OpenAI API completion chunk response and reuse it for tests
"""
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
) as mock_chat_completion_create:
completion = ChatCompletionChunk(
id="foo",
model="gpt-4",
object="chat.completion.chunk",
choices=[
chat_completion_chunk.Choice(
finish_reason="tool_calls",
logprobs=None,
index=0,
delta=chat_completion_chunk.ChoiceDelta(
role="assistant",
tool_calls=[
chat_completion_chunk.ChoiceDeltaToolCall(
index=0,
id="123",
type="function",
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
name="weather", arguments='{"city": "Paris"}'
),
)
],
),
)
],
created=int(datetime.now().timestamp()),
usage=None,
)
mock_chat_completion_create.return_value = openai_mock_stream_async(completion)
yield mock_chat_completion_create
@pytest.fixture
def tools():
tool_parameters = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
tool = Tool(
name="weather",
description="useful to determine the weather in a given location",
parameters=tool_parameters,
function=lambda x: x,
)
return [tool]
class TestOpenAIChatGeneratorAsync:
async def test_warm_up_async_should_create_async_client_with_same_args(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
api_base_url="test-base-url",
organization="test-organization",
timeout=30,
max_retries=5,
)
await component.warm_up_async()
assert isinstance(component.async_client, AsyncOpenAI)
assert component.async_client.api_key == "test-api-key"
assert component.async_client.organization == "test-organization"
assert component.async_client.base_url == "test-base-url/"
assert component.async_client.timeout == 30
assert component.async_client.max_retries == 5
@pytest.mark.asyncio
async def test_run_async(self, chat_messages, openai_mock_async_chat_completion):
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
response = await component.run_async(chat_messages)
# check that the component returns the correct ChatMessage response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
async def test_run_async_with_string_input(self, openai_mock_async_chat_completion):
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
response = await component.run_async("What's the capital of France?")
_, kwargs = openai_mock_async_chat_completion.call_args
assert kwargs["messages"] == [{"role": "user", "content": "What's the capital of France?"}]
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert isinstance(response["replies"][0], ChatMessage)
@pytest.mark.asyncio
async def test_run_with_params_async(self, chat_messages, openai_mock_async_chat_completion):
component = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
generation_kwargs={"max_completion_tokens": 10, "temperature": 0.5},
)
response = await component.run_async(chat_messages)
# check that the component calls the OpenAI API with the correct parameters
_, kwargs = openai_mock_async_chat_completion.call_args
assert kwargs["max_completion_tokens"] == 10
assert kwargs["temperature"] == 0.5
# check that the tools are not passed to the OpenAI API (the generator is initialized without tools)
assert "tools" not in kwargs
# check that the component returns the correct response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
@pytest.mark.asyncio
async def test_run_with_params_streaming_async(self, chat_messages, openai_mock_async_chat_completion_chunk):
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
component = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"), streaming_callback=streaming_callback
)
response = await component.run_async(chat_messages)
# check we called the streaming callback
assert streaming_callback_called
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
assert "Hello" in response["replies"][0].text # see openai_mock_chat_completion_chunk
@pytest.mark.asyncio
async def test_run_with_streaming_callback_in_run_method_async(
self, chat_messages, openai_mock_async_chat_completion_chunk
):
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
response = await component.run_async(chat_messages, streaming_callback=streaming_callback)
# check we called the streaming callback
assert streaming_callback_called
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
assert "Hello" in response["replies"][0].text # see openai_mock_chat_completion_chunk
@pytest.mark.asyncio
async def test_run_with_tools_async(self, tools):
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
) as mock_chat_completion_create:
completion = ChatCompletion(
id="foo",
model="gpt-4",
object="chat.completion",
choices=[
Choice(
finish_reason="tool_calls",
logprobs=None,
index=0,
message=ChatCompletionMessage(
role="assistant",
tool_calls=[
ChatCompletionMessageFunctionToolCall(
id="123",
type="function",
function=Function(name="weather", arguments='{"city": "Paris"}'),
)
],
),
)
],
created=int(datetime.now().timestamp()),
usage=CompletionUsage(
completion_tokens=40,
prompt_tokens=57,
total_tokens=97,
completion_tokens_details=CompletionTokensDetails(
accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=0, rejected_prediction_tokens=0
),
prompt_tokens_details=PromptTokensDetails(audio_tokens=0, cached_tokens=0),
),
)
mock_chat_completion_create.return_value = completion
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"), tools=tools, tools_strict=True)
response = await component.run_async([ChatMessage.from_user("What's the weather like in Paris?")])
# ensure that the tools are passed to the OpenAI API
function_spec = {**tools[0].tool_spec}
function_spec["strict"] = True
function_spec["parameters"]["additionalProperties"] = False
assert mock_chat_completion_create.call_args[1]["tools"] == [{"type": "function", "function": function_spec}]
assert len(response["replies"]) == 1
message = response["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["finish_reason"] == "tool_calls"
assert message.meta["usage"]["completion_tokens"] == 40
@pytest.mark.asyncio
async def test_run_with_tools_streaming_async(self, mock_chat_completion_chunk_with_tools, tools):
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
component = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"), streaming_callback=streaming_callback
)
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
response = await component.run_async(chat_messages, tools=tools)
# check we called the streaming callback
assert streaming_callback_called
# check that the component still returns the correct response
assert isinstance(response, dict)
assert "replies" in response
assert isinstance(response["replies"], list)
assert len(response["replies"]) == 1
assert [isinstance(reply, ChatMessage) for reply in response["replies"]]
message = response["replies"][0]
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["finish_reason"] == "tool_calls"
@pytest.mark.asyncio
async def test_async_stream_closes_on_cancellation(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
generator = OpenAIChatGenerator(
api_key=Secret.from_token("test-api-key"),
api_base_url="test-base-url",
organization="test-organization",
timeout=30,
max_retries=5,
)
# mocked the async stream that will be passed to the _handle_async_stream_response() method
mock_stream = AsyncMock(spec=AsyncStream)
mock_stream.close = AsyncMock()
async def mock_chunk_generator():
for i in range(10):
yield MagicMock(
choices=[
MagicMock(
index=0,
delta=MagicMock(content=f"chunk{i}", role=None, tool_calls=None),
finish_reason=None,
logprobs=None,
)
],
model="gpt-4",
usage=None,
)
await asyncio.sleep(0.005) # delay between chunks
mock_stream.__aiter__ = lambda _: mock_chunk_generator()
received_chunks = []
async def test_callback(chunk: StreamingChunk):
received_chunks.append(chunk)
# the task that will be cancelled
task = asyncio.create_task(generator._handle_async_stream_response(mock_stream, test_callback))
# trigger the task, process a few chunks, then cancel
await asyncio.sleep(0.01)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
mock_stream.close.assert_awaited_once()
# we received some chunks before cancellation but not all of them
assert len(received_chunks) > 0
assert len(received_chunks) < 10
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_live_run_async(self):
component = OpenAIChatGenerator(model="gpt-4.1-nano", generation_kwargs={"n": 1})
chat_messages = [ChatMessage.from_user("What's the capital of France")]
results = await component.run_async(chat_messages)
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "Paris" in message.text
assert message.meta["model"]
assert message.meta["finish_reason"] == "stop"
# Close async client; suppress RuntimeError if the event loop is already closed
with contextlib.suppress(RuntimeError):
await component.async_client.close()
@pytest.mark.asyncio
async def test_run_with_wrong_model_async(self):
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = OpenAIError("Invalid model name")
generator = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"), model="something-obviously-wrong")
generator.async_client = mock_client
with pytest.raises(OpenAIError):
await generator.run_async([ChatMessage.from_user("irrelevant")])
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_live_run_streaming_async(self):
counter = 0
responses = ""
async def callback(chunk: StreamingChunk):
nonlocal counter
nonlocal responses
counter += 1
responses += chunk.content if chunk.content else ""
component = OpenAIChatGenerator(
model="gpt-4.1-nano",
generation_kwargs={"stream_options": {"include_usage": True}},
streaming_callback=callback,
)
results = await component.run_async([ChatMessage.from_user("What's the capital of France?")])
assert len(results["replies"]) == 1
message: ChatMessage = results["replies"][0]
assert "Paris" in message.text
assert message.meta["model"]
assert message.meta["finish_reason"] == "stop"
assert counter > 1
assert "Paris" in responses
# check that the completion_start_time is set and valid ISO format
assert "completion_start_time" in message.meta
assert datetime.fromisoformat(message.meta["completion_start_time"]) <= datetime.now()
assert isinstance(message.meta["usage"], dict)
assert message.meta["usage"]["prompt_tokens"] > 0
assert message.meta["usage"]["completion_tokens"] > 0
assert message.meta["usage"]["total_tokens"] > 0
# Close async client; suppress RuntimeError if the event loop is already closed
with contextlib.suppress(RuntimeError):
await component.async_client.close()
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_live_run_with_tools_async(self, tools):
component = OpenAIChatGenerator(model="gpt-4.1-nano", tools=tools)
chat_messages = [ChatMessage.from_user("What's the weather like in Paris?")]
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"
# Check that Paris is in the city argument (case-insensitive, allowing for variations like "Paris, France")
assert "paris" in tool_call.arguments["city"].lower()
assert message.meta["finish_reason"] == "tool_calls"
# Close async client; suppress RuntimeError if the event loop is already closed
with contextlib.suppress(RuntimeError):
await component.async_client.close()
@pytest.mark.asyncio
async def test_run_with_wrapped_stream_simulation_async(self, chat_messages, openai_mock_stream_async):
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
assert isinstance(chunk, StreamingChunk)
chunk = ChatCompletionChunk(
id="id",
model="gpt-4",
object="chat.completion.chunk",
choices=[chat_completion_chunk.Choice(index=0, delta=chat_completion_chunk.ChoiceDelta(content="Hello"))],
created=int(datetime.now().timestamp()),
)
# Here we wrap the OpenAI async stream in an AsyncMock
# This is to simulate the behavior of some tools like Weave (https://github.com/wandb/weave)
# which wrap the OpenAI async stream in their own stream
wrapped_openai_async_stream = AsyncMock()
wrapped_openai_async_stream.__aiter__.return_value = iter([chunk])
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
await component.warm_up_async()
# Patch the async client's create method
with patch.object(
component.async_client.chat.completions,
"create",
return_value=wrapped_openai_async_stream,
new_callable=AsyncMock,
) as mock_create:
response = await component.run_async(chat_messages, streaming_callback=streaming_callback)
mock_create.assert_called_once()
assert streaming_callback_called
assert "replies" in response
assert "Hello" in response["replies"][0].text
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections.abc import Iterator
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openai import AsyncStream, Stream
from openai.types import Reasoning
from openai.types.chat import ChatCompletion, ChatCompletionChunk, chat_completion_chunk
from openai.types.responses import (
Response,
ResponseOutputItemAddedEvent,
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningItem,
ResponseReasoningSummaryTextDeltaEvent,
ResponseTextDeltaEvent,
ResponseUsage,
)
from openai.types.responses.response_reasoning_item import Summary
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
@pytest.fixture
def mock_auto_tokenizer():
"""
In the original mock_auto_tokenizer fixture, we were mocking the transformers.AutoTokenizer.from_pretrained
method directly, but we were not providing a return value for this method. Therefore, when from_pretrained
was called within HuggingFaceTGIChatGenerator, it returned None because that's the default behavior of a
MagicMock object when a return value isn't specified.
We will update the mock_auto_tokenizer fixture to return a MagicMock object when from_pretrained is called
in another PR. For now, we will use this fixture to mock the AutoTokenizer.from_pretrained method.
"""
with patch("transformers.AutoTokenizer.from_pretrained", autospec=True) as mock_from_pretrained:
mock_tokenizer = MagicMock()
mock_from_pretrained.return_value = mock_tokenizer
yield mock_tokenizer
class OpenAIMockStream(Stream[ChatCompletionChunk]):
def __init__(self, mock_chunk: ChatCompletionChunk, client=None, *args, **kwargs):
client = client or MagicMock()
super().__init__(client=client, *args, **kwargs) # noqa: B026
self.mock_chunk = mock_chunk
def __stream__(self) -> Iterator[ChatCompletionChunk]:
yield self.mock_chunk
class OpenAIAsyncMockStream(AsyncStream[ChatCompletionChunk]):
def __init__(self, mock_chunk: ChatCompletionChunk):
self.mock_chunk = mock_chunk
def __aiter__(self):
return self
async def __anext__(self):
# Only yield once, then stop iteration
if not hasattr(self, "_done"):
self._done = True
return self.mock_chunk
raise StopAsyncIteration
@pytest.fixture
def openai_mock_stream():
"""
Fixture that returns a function to create MockStream instances with custom chunks
"""
return OpenAIMockStream
@pytest.fixture
def openai_mock_stream_async():
"""
Fixture that returns a function to create AsyncMockStream instances with custom chunks
"""
return OpenAIAsyncMockStream
@pytest.fixture
def openai_mock_chat_completion():
"""
Mock the OpenAI API completion response and reuse it for tests
"""
with patch("openai.resources.chat.completions.Completions.create") as mock_chat_completion_create:
completion = ChatCompletion(
id="foo",
model="gpt-4",
object="chat.completion",
choices=[
{
"finish_reason": "stop",
"logprobs": None,
"index": 0,
"message": {"content": "Hello world!", "role": "assistant"},
}
],
created=int(datetime.now().timestamp()),
usage={"prompt_tokens": 57, "completion_tokens": 40, "total_tokens": 97},
)
mock_chat_completion_create.return_value = completion
yield mock_chat_completion_create
@pytest.fixture
async def openai_mock_async_chat_completion():
"""
Mock the OpenAI API completion response and reuse it for async tests
"""
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
) as mock_chat_completion_create:
completion = ChatCompletion(
id="foo",
model="gpt-4",
object="chat.completion",
choices=[
{
"finish_reason": "stop",
"logprobs": None,
"index": 0,
"message": {"content": "Hello world!", "role": "assistant"},
}
],
created=int(datetime.now().timestamp()),
usage={"prompt_tokens": 57, "completion_tokens": 40, "total_tokens": 97},
)
mock_chat_completion_create.return_value = completion
yield mock_chat_completion_create
@pytest.fixture
def openai_mock_chat_completion_chunk():
"""
Mock the OpenAI API completion chunk response and reuse it for tests
"""
with patch("openai.resources.chat.completions.Completions.create") as mock_chat_completion_create:
completion = ChatCompletionChunk(
id="foo",
model="gpt-4",
object="chat.completion.chunk",
choices=[
chat_completion_chunk.Choice(
finish_reason="stop",
logprobs=None,
index=0,
delta=chat_completion_chunk.ChoiceDelta(content="Hello", role="assistant"),
)
],
created=int(datetime.now().timestamp()),
usage=None,
)
mock_chat_completion_create.return_value = OpenAIMockStream(
completion, cast_to=None, response=None, client=None
)
yield mock_chat_completion_create
@pytest.fixture
async def openai_mock_async_chat_completion_chunk():
"""
Mock the OpenAI API completion chunk response and reuse it for async tests
"""
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock
) as mock_chat_completion_create:
completion = ChatCompletionChunk(
id="foo",
model="gpt-4",
object="chat.completion.chunk",
choices=[
chat_completion_chunk.Choice(
finish_reason="stop",
logprobs=None,
index=0,
delta=chat_completion_chunk.ChoiceDelta(content="Hello", role="assistant"),
)
],
created=int(datetime.now().timestamp()),
usage=None,
)
mock_chat_completion_create.return_value = OpenAIAsyncMockStream(completion)
yield mock_chat_completion_create
@pytest.fixture
def openai_mock_responses():
"""
Mock a fully populated non-streaming Response returned by the
OpenAI Responses API (client.responses.create).
"""
with patch("openai.resources.responses.Responses.create") as mock_create:
# Build the Response object exactly like the one you provided
mock_response = Response(
id="resp_mock_123",
created_at=float(datetime.now().timestamp()),
metadata={},
model="gpt-5-mini-2025-08-07",
object="response",
output=[
ResponseReasoningItem(
id="rs_mock_1",
type="reasoning",
summary=[
Summary(
text=(
"**Providing concise information**\n\n"
"The question is simple: the answer is Paris. "
"Its useful to mention that Paris is the capital and a major "
"city in France. Theres really no need for extra details in this "
"case, so Ill keep it concise and straightforward."
),
type="summary_text",
)
],
),
ResponseOutputMessage(
id="msg_mock_1",
role="assistant",
type="message",
status="completed",
content=[
ResponseOutputText(
text="The capital of France is Paris.", type="output_text", logprobs=None, annotations=[]
)
],
),
],
parallel_tool_calls=True,
temperature=1.0,
tool_choice="auto",
tools=[],
reasoning=Reasoning(effort="low", generate_summary=None, summary="auto"),
usage=ResponseUsage(
input_tokens=11,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens=13,
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
total_tokens=24,
),
user=None,
billing={"payer": "developer"},
prompt_cache_retention=None,
store=True,
)
mock_create.return_value = mock_response
yield mock_create
@pytest.fixture
def openai_mock_async_responses():
"""
Mock a fully populated non-streaming Response returned by the
OpenAI Responses API (client.responses.create).
"""
with patch("openai.resources.responses.AsyncResponses.create") as mock_create:
# Build the Response object exactly like the one you provided
mock_response = Response(
id="resp_mock_123",
created_at=float(datetime.now().timestamp()),
metadata={},
model="gpt-5-mini-2025-08-07",
object="response",
output=[
ResponseReasoningItem(
id="rs_mock_1",
type="reasoning",
summary=[
Summary(
text=(
"**Providing concise information**\n\n"
"The question is simple: the answer is Paris. "
"Its useful to mention that Paris is the capital and a major "
"city in France. Theres really no need for extra details in this "
"case, so Ill keep it concise and straightforward."
),
type="summary_text",
)
],
),
ResponseOutputMessage(
id="msg_mock_1",
role="assistant",
type="message",
status="completed",
content=[
ResponseOutputText(
text="The capital of France is Paris.", type="output_text", annotations=[], logprobs=None
)
],
),
],
parallel_tool_calls=True,
temperature=1.0,
tool_choice="auto",
tools=[],
reasoning=Reasoning(effort="low", generate_summary=None, summary="auto"),
usage=ResponseUsage(
input_tokens=11,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens=13,
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
total_tokens=24,
),
user=None,
billing={"payer": "developer"},
prompt_cache_retention=None,
store=True,
)
mock_create.return_value = mock_response
yield mock_create
@pytest.fixture
def openai_mock_responses_stream_text_delta():
"""
Mock the Responses API streaming text-delta event (sync)
and reuse it for tests.
"""
with patch("openai.resources.responses.Responses.create") as mock_responses_create:
event = ResponseTextDeltaEvent(
# required fields in the current SDK
content_index=0,
delta="The capital of France is Paris.",
item_id="item_1",
logprobs=[],
output_index=0,
sequence_number=0,
type="response.output_text.delta",
)
# Your OpenAIMockStream should iterate over this event
mock_responses_create.return_value = OpenAIMockStream(event, cast_to=None, response=None, client=None)
yield mock_responses_create
@pytest.fixture
async def openai_mock_async_responses_stream_text_delta():
"""
Mock the Responses API streaming text-delta event (async)
and reuse it for async tests.
"""
with patch("openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock) as mock_responses_create:
event = ResponseTextDeltaEvent(
content_index=0,
delta="Hello",
item_id="item_1",
logprobs=[],
output_index=0,
sequence_number=0,
type="response.output_text.delta",
)
mock_responses_create.return_value = OpenAIAsyncMockStream(event)
yield mock_responses_create
@pytest.fixture
def openai_mock_responses_reasoning_summary_delta():
"""
Mock a Responses API *streaming* reasoning summary text delta event (sync).
"""
with patch("openai.resources.responses.Responses.create") as mock_responses_create:
start_event = ResponseOutputItemAddedEvent(
item=ResponseReasoningItem(
id="rs_094e3f8beffcca02006928978067848190b477543eddbf32b3",
summary=[],
type="reasoning",
content=None,
encrypted_content=None,
status=None,
),
output_index=0,
sequence_number=2,
type="response.output_item.added",
)
event = ResponseReasoningSummaryTextDeltaEvent(
delta="I need to check the capital of France.",
item_id="rs_01e88f7d57f9a2f70069284d2170c48193918c04f85244cf7c",
output_index=0,
sequence_number=4,
summary_index=0,
type="response.reasoning_summary_text.delta",
obfuscation="cGcv5W5F",
)
# Create a custom stream that yields both events sequentially
class MultiEventMockStream(OpenAIMockStream):
def __init__(self, *events, **kwargs):
self.events = events
super().__init__(events[0] if events else None, **kwargs)
def __stream__(self):
yield from self.events
mock_responses_create.return_value = MultiEventMockStream(
start_event, event, cast_to=None, response=None, client=None
)
yield mock_responses_create
@@ -0,0 +1,336 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import base64
import os
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from openai import AsyncOpenAI
from openai.types import ImagesResponse
from openai.types.image import Image
import haystack.components.generators.openai_image_generator as openai_image_generator_module
from haystack.components.generators.openai_image_generator import OpenAIImageGenerator
from haystack.utils import Secret
@pytest.fixture
def mock_image_response():
with patch("openai.resources.images.Images.generate") as mock_image_generate:
image_response = ImagesResponse(
created=1630000000, data=[Image(b64_json="test-b64-json", revised_prompt="test-prompt")]
)
mock_image_generate.return_value = image_response
yield mock_image_generate
class TestOpenAIImageGenerator:
def test_init_default(self, monkeypatch):
component = OpenAIImageGenerator()
assert component.model == "gpt-image-2"
assert component.quality == "auto"
assert component.size == "1024x1024"
assert component.api_key == Secret.from_env_var("OPENAI_API_KEY")
assert component.api_base_url is None
assert component.organization is None
assert component.timeout is None
assert component.max_retries is None
assert component.http_client_kwargs is None
assert component.client is None
assert component.async_client is None
def test_init_with_params(self, monkeypatch):
component = OpenAIImageGenerator(
model="gpt-image-1",
quality="high",
size="1024x1536",
api_key=Secret.from_env_var("EXAMPLE_API_KEY"),
api_base_url="https://api.openai.com",
organization="test-org",
timeout=60,
max_retries=10,
)
assert component.model == "gpt-image-1"
assert component.quality == "high"
assert component.size == "1024x1536"
assert component.api_key == Secret.from_env_var("EXAMPLE_API_KEY")
assert component.api_base_url == "https://api.openai.com"
assert component.organization == "test-org"
assert pytest.approx(component.timeout) == 60.0
assert component.max_retries == 10
assert component.client is None
assert component.async_client is None
def test_init_max_retries_0(self, monkeypatch):
component = OpenAIImageGenerator(max_retries=0)
assert component.max_retries == 0
def test_init_invalid_quality_falls_back_to_auto(self, caplog):
component = OpenAIImageGenerator(quality="hd") # type: ignore[arg-type]
assert component.quality == "auto"
assert "Invalid quality" in caplog.text
def test_init_non_default_response_format_warns(self, caplog):
OpenAIImageGenerator(response_format="url") # type: ignore[arg-type]
assert "response_format is ignored" in caplog.text
def test_to_dict(self):
generator = OpenAIImageGenerator()
data = generator.to_dict()
assert data == {
"type": "haystack.components.generators.openai_image_generator.OpenAIImageGenerator",
"init_parameters": {
"model": "gpt-image-2",
"quality": "auto",
"size": "1024x1024",
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"api_base_url": None,
"organization": None,
"http_client_kwargs": None,
},
}
def test_to_dict_with_params(self):
generator = OpenAIImageGenerator(
model="gpt-image-1",
quality="high",
size="1024x1536",
api_key=Secret.from_env_var("EXAMPLE_API_KEY"),
api_base_url="https://api.openai.com",
organization="test-org",
timeout=60,
max_retries=10,
http_client_kwargs={"proxy": "http://localhost:8080"},
)
data = generator.to_dict()
assert data == {
"type": "haystack.components.generators.openai_image_generator.OpenAIImageGenerator",
"init_parameters": {
"model": "gpt-image-1",
"quality": "high",
"size": "1024x1536",
"api_key": {"type": "env_var", "env_vars": ["EXAMPLE_API_KEY"], "strict": True},
"api_base_url": "https://api.openai.com",
"organization": "test-org",
"http_client_kwargs": {"proxy": "http://localhost:8080"},
},
}
def test_from_dict(self):
data = {
"type": "haystack.components.generators.openai_image_generator.OpenAIImageGenerator",
"init_parameters": {
"model": "gpt-image-2",
"quality": "auto",
"size": "1024x1024",
"api_key": {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True},
"api_base_url": None,
"organization": None,
"http_client_kwargs": None,
},
}
generator = OpenAIImageGenerator.from_dict(data)
assert generator.model == "gpt-image-2"
assert generator.quality == "auto"
assert generator.size == "1024x1024"
assert generator.api_key.to_dict() == {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True}
assert generator.http_client_kwargs is None
def test_from_dict_default_params(self):
data = {
"type": "haystack.components.generators.openai_image_generator.OpenAIImageGenerator",
"init_parameters": {},
}
generator = OpenAIImageGenerator.from_dict(data)
assert generator.model == "gpt-image-2"
assert generator.quality == "auto"
assert generator.size == "1024x1024"
assert generator.api_key.to_dict() == {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True}
assert generator.api_base_url is None
assert generator.organization is None
assert generator.timeout is None
assert generator.max_retries is None
assert generator.http_client_kwargs is None
def test_run(self, mock_image_response):
generator = OpenAIImageGenerator(api_key=Secret.from_token("test-api-key"))
response = generator.run("Show me a picture of a black cat.")
assert generator.client is not None
assert isinstance(response, dict)
assert "images" in response and "revised_prompt" in response
assert response["images"] == ["test-b64-json"]
assert response["revised_prompt"] == "test-prompt"
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.slow
def test_live_run(self):
generator = OpenAIImageGenerator(model="gpt-image-1-mini", size="1024x1024", quality="low")
response = generator.run("A nice cat")
assert isinstance(response, dict)
assert isinstance(response["revised_prompt"], str)
image_str = response["images"][0]
assert isinstance(image_str, str) and image_str
decoded = base64.b64decode(image_str, validate=True)
assert decoded.startswith(b"\x89PNG\r\n\x1a\n")
class TestOpenAIImageGeneratorAsync:
def test_async_client_none_before_warm_up(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = OpenAIImageGenerator()
assert component.async_client is None
@pytest.mark.asyncio
async def test_async_client_after_warm_up_async(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
component = OpenAIImageGenerator()
await component.warm_up_async()
assert isinstance(component.async_client, AsyncOpenAI)
assert component.async_client.api_key == "test-api-key"
@pytest.mark.asyncio
async def test_run_async(self):
generator = OpenAIImageGenerator(api_key=Secret.from_token("test-api-key"))
image_response = ImagesResponse(
created=1630000000, data=[Image(b64_json="test-b64-json", revised_prompt="test-prompt")]
)
mock_async_client = Mock()
mock_async_client.images.generate = AsyncMock(return_value=image_response)
generator.async_client = mock_async_client
response = await generator.run_async("Show me a picture of a black cat.")
assert isinstance(response, dict)
assert "images" in response and "revised_prompt" in response
assert response["images"] == ["test-b64-json"]
assert response["revised_prompt"] == "test-prompt"
mock_async_client.images.generate.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_async_triggers_warm_up(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
generator = OpenAIImageGenerator()
assert generator.async_client is None
image_response = ImagesResponse(
created=1630000000, data=[Image(b64_json="test-b64-json", revised_prompt="test-prompt")]
)
with patch("openai.resources.images.AsyncImages.generate", new=AsyncMock(return_value=image_response)):
response = await generator.run_async("Show me a picture of a black cat.")
assert isinstance(generator.async_client, AsyncOpenAI)
assert response["images"] == ["test-b64-json"]
assert response["revised_prompt"] == "test-prompt"
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY", None),
reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
)
@pytest.mark.integration
@pytest.mark.slow
async def test_live_run_async(self):
generator = OpenAIImageGenerator(model="gpt-image-1-mini", size="1024x1024", quality="low")
response = await generator.run_async("A nice cat")
assert isinstance(response, dict)
assert isinstance(response["revised_prompt"], str)
image_str = response["images"][0]
assert isinstance(image_str, str) and image_str
decoded = base64.b64decode(image_str, validate=True)
assert decoded.startswith(b"\x89PNG\r\n\x1a\n")
@pytest.fixture
def mock_openai_clients(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake")
sync_cls = MagicMock(name="OpenAI")
async_cls = MagicMock(name="AsyncOpenAI")
async_cls.return_value.close = AsyncMock()
monkeypatch.setattr(openai_image_generator_module, "OpenAI", sync_cls)
monkeypatch.setattr(openai_image_generator_module, "AsyncOpenAI", async_cls)
return sync_cls, async_cls
class TestComponentLifecycle:
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
generator = OpenAIImageGenerator()
generator.warm_up()
assert generator.client.max_retries == 5
assert generator.client.timeout == 30.0
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
generator = OpenAIImageGenerator(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
generator.warm_up()
assert generator.client.max_retries == 1
assert generator.client.timeout == 40.0
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
generator = OpenAIImageGenerator(api_key=Secret.from_token("fake-api-key"))
generator.warm_up()
assert generator.client.max_retries == 10
assert generator.client.timeout == 100.0
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
generator = OpenAIImageGenerator()
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
generator.warm_up()
def test_sync_lifecycle(self, mock_openai_clients):
sync_cls, _ = mock_openai_clients
generator = OpenAIImageGenerator()
assert generator.client is None
assert generator.async_client is None
generator.warm_up()
assert generator.client is sync_cls.return_value
assert generator.async_client is None
generator.close()
sync_cls.return_value.close.assert_called_once()
assert generator.client is None
async def test_async_lifecycle(self, mock_openai_clients):
_, async_cls = mock_openai_clients
generator = OpenAIImageGenerator()
await generator.warm_up_async()
assert generator.async_client is async_cls.return_value
assert generator.client is None
await generator.close_async()
async_cls.return_value.close.assert_awaited_once()
assert generator.async_client is None
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
generator = OpenAIImageGenerator()
generator.close()
await generator.close_async()
assert generator.client is None
assert generator.async_client is None
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
generator = OpenAIImageGenerator()
generator.warm_up()
await generator.warm_up_async()
generator.close()
assert generator.client is None
assert generator.async_client is not None
await generator.close_async()
assert generator.async_client is None
+860
View File
@@ -0,0 +1,860 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from unittest.mock import call, patch
import pytest
from openai.types.chat import chat_completion_chunk
from haystack.components.generators.utils import (
_convert_streaming_chunks_to_chat_message,
_normalize_messages,
print_streaming_chunk,
)
from haystack.dataclasses import (
ChatMessage,
ComponentInfo,
ReasoningContent,
StreamingChunk,
ToolCall,
ToolCallDelta,
ToolCallResult,
)
def test_convert_streaming_chunks_to_chat_message_tool_calls_in_any_chunk():
chunks = [
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.910076",
},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0,
id="call_ZOj5l67zhZOx6jqjg7ATQwb6",
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
arguments="", name="rag_pipeline_tool"
),
type="function",
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.913919",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
start=True,
tool_calls=[
ToolCallDelta(id="call_ZOj5l67zhZOx6jqjg7ATQwb6", tool_name="rag_pipeline_tool", arguments="", index=0)
],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='{"qu')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.914439",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments='{"qu', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='ery":')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.924146",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments='ery":', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments=' "Wher')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.924420",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments=' "Wher', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments="e do")
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.944398",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments="e do", index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments="es Ma")
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.944958",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments="es Ma", index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments="rk liv")
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.945507",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments="rk liv", index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='e?"}')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.946018",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments='e?"}', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1,
id="call_STxsYY69wVOvxWqopAt3uWTB",
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments="", name="get_weather"),
type="function",
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.946578",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
start=True,
tool_calls=[
ToolCallDelta(id="call_STxsYY69wVOvxWqopAt3uWTB", tool_name="get_weather", arguments="", index=1)
],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='{"ci')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.946981",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
tool_calls=[ToolCallDelta(arguments='{"ci', index=1)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='ty": ')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.947411",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
tool_calls=[ToolCallDelta(arguments='ty": ', index=1)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='"Berli')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.947643",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
tool_calls=[ToolCallDelta(arguments='"Berli', index=1)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=1, function=chat_completion_chunk.ChoiceDeltaToolCallFunction(arguments='n"}')
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.947939",
},
component_info=ComponentInfo(name="test", type="test"),
index=1,
tool_calls=[ToolCallDelta(arguments='n"}', index=1)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": "tool_calls",
"received_at": "2025-02-19T16:02:55.948772",
},
component_info=ComponentInfo(name="test", type="test"),
finish_reason="tool_calls",
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.948772",
"usage": {
"completion_tokens": 42,
"prompt_tokens": 282,
"total_tokens": 324,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
},
},
component_info=ComponentInfo(name="test", type="test"),
),
]
# Convert chunks to a chat message
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert not result.texts
assert not result.text
# Verify both tool calls were found and processed
assert len(result.tool_calls) == 2
assert result.tool_calls[0].id == "call_ZOj5l67zhZOx6jqjg7ATQwb6"
assert result.tool_calls[0].tool_name == "rag_pipeline_tool"
assert result.tool_calls[0].arguments == {"query": "Where does Mark live?"}
assert result.tool_calls[1].id == "call_STxsYY69wVOvxWqopAt3uWTB"
assert result.tool_calls[1].tool_name == "get_weather"
assert result.tool_calls[1].arguments == {"city": "Berlin"}
# Verify meta information
assert result.meta["model"] == "gpt-4o-mini-2024-07-18"
assert result.meta["finish_reason"] == "tool_calls"
assert result.meta["index"] == 0
assert result.meta["completion_start_time"] == "2025-02-19T16:02:55.910076"
assert result.meta["usage"] == {
"completion_tokens": 42,
"prompt_tokens": 282,
"total_tokens": 324,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
}
def test_convert_streaming_chunk_to_chat_message_two_tool_calls_in_same_chunk():
chunks = [
StreamingChunk(
content="",
meta={
"model": "mistral-small-latest",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"usage": None,
},
component_info=ComponentInfo(
type="haystack_integrations.components.generators.mistral.chat.chat_generator.MistralChatGenerator",
name=None,
),
),
StreamingChunk(
content="",
meta={
"model": "mistral-small-latest",
"index": 0,
"finish_reason": "tool_calls",
"usage": {
"completion_tokens": 35,
"prompt_tokens": 77,
"total_tokens": 112,
"completion_tokens_details": None,
"prompt_tokens_details": None,
},
},
component_info=ComponentInfo(
type="haystack_integrations.components.generators.mistral.chat.chat_generator.MistralChatGenerator",
name=None,
),
index=0,
tool_calls=[
ToolCallDelta(index=0, tool_name="weather", arguments='{"city": "Paris"}', id="FL1FFlqUG"),
ToolCallDelta(index=1, tool_name="weather", arguments='{"city": "Berlin"}', id="xSuhp66iB"),
],
start=True,
finish_reason="tool_calls",
),
]
# Convert chunks to a chat message
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert not result.texts
assert not result.text
# Verify both tool calls were found and processed
assert len(result.tool_calls) == 2
assert result.tool_calls[0].id == "FL1FFlqUG"
assert result.tool_calls[0].tool_name == "weather"
assert result.tool_calls[0].arguments == {"city": "Paris"}
assert result.tool_calls[1].id == "xSuhp66iB"
assert result.tool_calls[1].tool_name == "weather"
assert result.tool_calls[1].arguments == {"city": "Berlin"}
def test_convert_streaming_chunk_to_chat_message_empty_tool_call_delta():
chunks = [
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.910076",
},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0,
id="call_ZOj5l67zhZOx6jqjg7ATQwb6",
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
arguments='{"query":', name="rag_pipeline_tool"
),
type="function",
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.913919",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
start=True,
tool_calls=[
ToolCallDelta(
id="call_ZOj5l67zhZOx6jqjg7ATQwb6", tool_name="rag_pipeline_tool", arguments='{"query":', index=0
)
],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0,
function=chat_completion_chunk.ChoiceDeltaToolCallFunction(
arguments=' "Where does Mark live?"}'
),
)
],
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.924420",
},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_calls=[ToolCallDelta(arguments=' "Where does Mark live?"}', index=0)],
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": [
chat_completion_chunk.ChoiceDeltaToolCall(
index=0, function=chat_completion_chunk.ChoiceDeltaToolCallFunction()
)
],
"finish_reason": "tool_calls",
"received_at": "2025-02-19T16:02:55.948772",
},
tool_calls=[ToolCallDelta(index=0)],
component_info=ComponentInfo(name="test", type="test"),
finish_reason="tool_calls",
index=0,
),
StreamingChunk(
content="",
meta={
"model": "gpt-4o-mini-2024-07-18",
"index": 0,
"tool_calls": None,
"finish_reason": None,
"received_at": "2025-02-19T16:02:55.948772",
"usage": {
"completion_tokens": 42,
"prompt_tokens": 282,
"total_tokens": 324,
"completion_tokens_details": {
"accepted_prediction_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
"rejected_prediction_tokens": 0,
},
"prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 0},
},
},
component_info=ComponentInfo(name="test", type="test"),
),
]
# Convert chunks to a chat message
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert not result.texts
assert not result.text
# Verify both tool calls were found and processed
assert len(result.tool_calls) == 1
assert result.tool_calls[0].id == "call_ZOj5l67zhZOx6jqjg7ATQwb6"
assert result.tool_calls[0].tool_name == "rag_pipeline_tool"
assert result.tool_calls[0].arguments == {"query": "Where does Mark live?"}
assert result.meta["finish_reason"] == "tool_calls"
def test_convert_streaming_chunk_to_chat_message_with_empty_tool_call_arguments():
chunks = [
# Message start with input tokens
StreamingChunk(
content="",
meta={
"type": "message_start",
"message": {
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [],
"model": "claude-sonnet-4-20250514",
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 25, "output_tokens": 0},
},
},
index=0,
tool_calls=[],
tool_call_result=None,
start=True,
finish_reason=None,
),
# Initial text content
StreamingChunk(
content="",
meta={"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
index=1,
tool_calls=[],
tool_call_result=None,
start=True,
finish_reason=None,
),
StreamingChunk(
content="Let me check",
meta={"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Let me check"}},
index=2,
tool_calls=[],
tool_call_result=None,
start=False,
finish_reason=None,
),
StreamingChunk(
content=" the weather",
meta={"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " the weather"}},
index=3,
tool_calls=[],
tool_call_result=None,
start=False,
finish_reason=None,
),
# Tool use content
StreamingChunk(
content="",
meta={
"type": "content_block_start",
"index": 1,
"content_block": {"type": "tool_use", "id": "toolu_123", "name": "weather", "input": {}},
},
index=5,
tool_calls=[ToolCallDelta(index=1, id="toolu_123", tool_name="weather", arguments=None)],
tool_call_result=None,
start=True,
finish_reason=None,
),
StreamingChunk(
content="",
meta={"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": ""}},
index=7,
tool_calls=[ToolCallDelta(index=1, id=None, tool_name=None, arguments="")],
tool_call_result=None,
start=False,
finish_reason=None,
),
# Final message delta
StreamingChunk(
content="",
meta={
"type": "message_delta",
"delta": {"stop_reason": "tool_use", "stop_sequence": None},
"usage": {"completion_tokens": 40},
},
index=8,
tool_calls=[],
tool_call_result=None,
start=False,
finish_reason="tool_calls",
),
]
message = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert message.texts == ["Let me check the weather"]
assert len(message.tool_calls) == 1
assert message.tool_calls[0].arguments == {}
assert message.tool_calls[0].id == "toolu_123"
assert message.tool_calls[0].tool_name == "weather"
def test_print_streaming_chunk_content_only():
chunk = StreamingChunk(
content="Hello, world!",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=True,
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [call("[ASSISTANT]\n", flush=True, end=""), call("Hello, world!", flush=True, end="")]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_tool_call():
chunk = StreamingChunk(
content="",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=True,
index=0,
tool_calls=[ToolCallDelta(id="call_123", tool_name="test_tool", arguments='{"param": "value"}', index=0)],
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [
call("[TOOL CALL]\nTool: test_tool \nArguments: ", flush=True, end=""),
call('{"param": "value"}', flush=True, end=""),
]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_tool_call_result():
chunk = StreamingChunk(
content="",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
index=0,
tool_call_result=ToolCallResult(
result="Tool execution completed successfully",
origin=ToolCall(id="call_123", tool_name="test_tool", arguments={}),
error=False,
),
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [call("[TOOL RESULT]\nTool execution completed successfully", flush=True, end="")]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_with_finish_reason():
chunk = StreamingChunk(
content="Final content.",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=True,
finish_reason="stop",
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [
call("[ASSISTANT]\n", flush=True, end=""),
call("Final content.", flush=True, end=""),
call("\n\n", flush=True, end=""),
]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_empty_chunk():
chunk = StreamingChunk(
content="", meta={"model": "test-model"}, component_info=ComponentInfo(name="test", type="test")
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
mock_print.assert_not_called()
def test_convert_streaming_chunks_to_chat_message_usage_not_in_last_chunk():
"""
Test that usage info is correctly extracted even when it's not in the last chunk.
This can happen with some API providers like Qwen3 where usage info may be returned
in a different chunk than the final one.
"""
chunks = [
StreamingChunk(
content="",
meta={"model": "qwen-plus", "index": 0, "finish_reason": None, "received_at": "2025-01-01T00:00:00.000000"},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content="Hello",
meta={"model": "qwen-plus", "index": 0, "finish_reason": None, "received_at": "2025-01-01T00:00:00.100000"},
component_info=ComponentInfo(name="test", type="test"),
index=0,
start=True,
),
StreamingChunk(
content=" world",
meta={"model": "qwen-plus", "index": 0, "finish_reason": None, "received_at": "2025-01-01T00:00:00.200000"},
component_info=ComponentInfo(name="test", type="test"),
index=0,
),
# Chunk with usage info (not the last chunk)
StreamingChunk(
content="",
meta={
"model": "qwen-plus",
"received_at": "2025-01-01T00:00:00.300000",
"usage": {"completion_tokens": 10, "prompt_tokens": 20, "total_tokens": 30},
},
component_info=ComponentInfo(name="test", type="test"),
index=None,
),
# Final chunk with finish_reason but no usage (simulating Qwen3 behavior)
StreamingChunk(
content="",
meta={
"model": "qwen-plus",
"index": 0,
"finish_reason": "stop",
"received_at": "2025-01-01T00:00:00.400000",
"usage": None, # No usage info in final chunk
},
component_info=ComponentInfo(name="test", type="test"),
finish_reason="stop",
),
]
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert result.text == "Hello world"
assert result.meta["model"] == "qwen-plus"
assert result.meta["finish_reason"] == "stop"
# Usage should be extracted from the chunk that has it, not just the last chunk
assert result.meta["usage"] == {"completion_tokens": 10, "prompt_tokens": 20, "total_tokens": 30}
def test_convert_streaming_chunks_to_chat_message_with_reasoning():
"""Test that reasoning content is correctly accumulated from streaming chunks."""
chunks = [
StreamingChunk(
content="",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:00"},
component_info=ComponentInfo(name="test", type="test"),
reasoning=ReasoningContent(reasoning_text="Let me think about this..."),
index=0,
),
StreamingChunk(
content="",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:01"},
component_info=ComponentInfo(name="test", type="test"),
reasoning=ReasoningContent(reasoning_text=" The capital of France is Paris."),
index=0,
),
StreamingChunk(
content="Paris",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:02"},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content="",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:03"},
component_info=ComponentInfo(name="test", type="test"),
finish_reason="stop",
),
]
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert result.text == "Paris"
assert result.reasoning is not None
assert isinstance(result.reasoning, ReasoningContent)
assert result.reasoning.reasoning_text == "Let me think about this... The capital of France is Paris."
assert result.meta["finish_reason"] == "stop"
def test_convert_streaming_chunks_to_chat_message_without_reasoning():
"""Test that messages without reasoning work correctly (backward compatibility)."""
chunks = [
StreamingChunk(
content="Hello",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:00"},
component_info=ComponentInfo(name="test", type="test"),
),
StreamingChunk(
content=" world",
meta={"model": "test-model", "received_at": "2025-01-01T00:00:01"},
component_info=ComponentInfo(name="test", type="test"),
finish_reason="stop",
),
]
result = _convert_streaming_chunks_to_chat_message(chunks=chunks)
assert result.text == "Hello world"
assert result.reasoning is None
def test_print_streaming_chunk_with_reasoning():
"""Test that print_streaming_chunk handles reasoning content correctly."""
chunk = StreamingChunk(
content="",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=True,
reasoning=ReasoningContent(reasoning_text="I am thinking about this question."),
index=0,
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
expected_calls = [
call("[REASONING]\n", flush=True, end=""),
call("I am thinking about this question.", flush=True, end=""),
]
mock_print.assert_has_calls(expected_calls)
def test_print_streaming_chunk_with_reasoning_continuation():
"""Test that print_streaming_chunk handles reasoning continuation correctly."""
chunk = StreamingChunk(
content="",
meta={"model": "test-model"},
component_info=ComponentInfo(name="test", type="test"),
start=False, # Not the first chunk
reasoning=ReasoningContent(reasoning_text="continued reasoning..."),
index=0,
)
with patch("builtins.print") as mock_print:
print_streaming_chunk(chunk)
# Should only print the reasoning text without the header since it's a continuation
expected_calls = [call("continued reasoning...", flush=True, end="")]
mock_print.assert_has_calls(expected_calls)
def test_normalize_messages():
assert _normalize_messages("Hello") == [ChatMessage.from_user("Hello")]
assert _normalize_messages([ChatMessage.from_user("World")]) == [ChatMessage.from_user("World")]
with pytest.raises(TypeError):
_normalize_messages(123)