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
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import sys
from typing import TYPE_CHECKING
from lazy_imports import LazyImporter
_import_structure = {
"openai": ["OpenAIChatGenerator"],
"openai_responses": ["OpenAIResponsesChatGenerator"],
"azure": ["AzureOpenAIChatGenerator"],
"azure_responses": ["AzureOpenAIResponsesChatGenerator"],
"fallback": ["FallbackChatGenerator"],
"llm": ["LLM"],
"mock": ["MockChatGenerator"],
}
if TYPE_CHECKING:
from .azure import AzureOpenAIChatGenerator as AzureOpenAIChatGenerator
from .azure_responses import AzureOpenAIResponsesChatGenerator as AzureOpenAIResponsesChatGenerator
from .fallback import FallbackChatGenerator as FallbackChatGenerator
from .llm import LLM as LLM
from .mock import MockChatGenerator as MockChatGenerator
from .openai import OpenAIChatGenerator as OpenAIChatGenerator
from .openai_responses import OpenAIResponsesChatGenerator as OpenAIResponsesChatGenerator
else:
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
@@ -0,0 +1,369 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from typing import Any, ClassVar
from openai.lib._pydantic import to_strict_json_schema
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI, AzureADTokenProvider, AzureOpenAI
from pydantic import BaseModel
from haystack import component, default_from_dict, default_to_dict
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses.streaming_chunk import StreamingCallbackT
from haystack.tools import (
ToolsType,
_check_duplicate_tool_names,
deserialize_tools_or_toolset_inplace,
flatten_tools_or_toolsets,
serialize_tools_or_toolset,
warm_up_tools,
)
from haystack.utils import Secret, deserialize_callable, serialize_callable
from haystack.utils.http_client import init_http_client
@component
class AzureOpenAIChatGenerator(OpenAIChatGenerator):
"""
Generates text using OpenAI's models on Azure.
It works with the gpt-4 - type models and supports streaming responses
from OpenAI API. It uses [ChatMessage](https://docs.haystack.deepset.ai/docs/chatmessage)
format in input and output.
You can customize how the text is generated by passing parameters to the
OpenAI API. Use the `**generation_kwargs` argument when you initialize
the component or when you run it. Any parameter that works with
`openai.ChatCompletion.create` will work here too.
For details on OpenAI API parameters, see
[OpenAI documentation](https://platform.openai.com/docs/api-reference/chat).
### Usage example
<!-- test-ignore -->
```python
from haystack.components.generators.chat import AzureOpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.utils import Secret
messages = [ChatMessage.from_user("What's Natural Language Processing?")]
client = AzureOpenAIChatGenerator(
azure_endpoint="<Your Azure endpoint e.g. `https://your-company.azure.openai.com/>",
api_key=Secret.from_token("<your-api-key>"),
azure_deployment="<this is a model name, e.g. gpt-4.1-mini>")
response = client.run(messages)
print(response)
```
```
{'replies':
[ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=[TextContent(text=
"Natural Language Processing (NLP) is a branch of artificial intelligence that focuses on
enabling computers to understand, interpret, and generate human language in a way that is useful.")],
_name=None,
_meta={'model': 'gpt-4.1-mini', 'index': 0, 'finish_reason': 'stop',
'usage': {'prompt_tokens': 15, 'completion_tokens': 36, 'total_tokens': 51}})]
}
```
"""
SUPPORTED_MODELS: ClassVar[list[str]] = [
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.3-codex",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.2-chat",
"gpt-5.1",
"gpt-5.1-chat",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-chat",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-audio-preview",
"gpt-realtime-1.5",
"gpt-audio-1.5",
"o1",
"o1-mini",
"o3",
"o3-mini",
"o4-mini",
"codex-mini",
"gpt-4",
"gpt-35-turbo",
"gpt-oss-120b",
"computer-use-preview",
]
"""A non-exhaustive list of chat models supported by this component.
See https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure
for the full list."""
# ruff: noqa: PLR0913
def __init__(
self,
azure_endpoint: str | Secret | None = None,
api_version: str | Secret | None = "2024-12-01-preview",
azure_deployment: str | None = "gpt-4.1-mini",
api_key: Secret | None = Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False),
azure_ad_token: Secret | None = Secret.from_env_var("AZURE_OPENAI_AD_TOKEN", strict=False),
organization: str | None = None,
streaming_callback: StreamingCallbackT | None = None,
timeout: float | None = None,
max_retries: int | None = None,
generation_kwargs: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
tools: ToolsType | None = None,
tools_strict: bool = False,
*,
azure_ad_token_provider: AzureADTokenProvider | AsyncAzureADTokenProvider | None = None,
http_client_kwargs: dict[str, Any] | None = None,
) -> None:
"""
Initialize the Azure OpenAI Chat Generator component.
:param azure_endpoint: The endpoint of the deployed model, for example `"https://example-resource.azure.openai.com/"`.
Can also be a [Secret](https://docs.haystack.deepset.ai/docs/secret-management), for example
`Secret.from_env_var("AZURE_OPENAI_ENDPOINT")`, to resolve the value from an environment variable at
runtime. This is useful to switch endpoints between environments (e.g. dev and prod) without changing the
serialized pipeline.
:param api_version: The version of the API to use. Defaults to 2024-12-01-preview.
Can also be a [Secret](https://docs.haystack.deepset.ai/docs/secret-management), for example
`Secret.from_env_var("AZURE_OPENAI_API_VERSION")`, to resolve the value from an environment variable at
runtime.
:param azure_deployment: The deployment of the model, usually the model name.
:param api_key: The API key to use for authentication.
:param azure_ad_token: [Azure Active Directory token](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id).
:param organization: Your organization ID, defaults to `None`. For help, see
[Setting up your organization](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization).
:param streaming_callback: A callback function called when a new token is received from the stream.
It accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk)
as an argument.
:param timeout: Timeout for OpenAI client calls. If not set, it defaults to either the
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
:param max_retries: Maximum number of retries to contact OpenAI after an internal error.
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
:param generation_kwargs: Other parameters to use for the model. These parameters are sent directly to
the OpenAI endpoint. For details, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/chat).
Some of the supported parameters:
- `max_completion_tokens`: An upper bound for the number of tokens that can be generated for a completion,
including visible output tokens and reasoning tokens.
- `temperature`: The sampling temperature to use. Higher values mean the model takes more risks.
Try 0.9 for more creative applications and 0 (argmax sampling) for ones with a well-defined answer.
- `top_p`: Nucleus sampling is an alternative to sampling with temperature, where the model considers
tokens with a top_p probability mass. For example, 0.1 means only the tokens comprising
the top 10% probability mass are considered.
- `n`: The number of completions to generate for each prompt. For example, with 3 prompts and n=2,
the LLM will generate two completions per prompt, resulting in 6 completions total.
- `stop`: One or more sequences after which the LLM should stop generating tokens.
- `presence_penalty`: The penalty applied if a token is already present.
Higher values make the model less likely to repeat the token.
- `frequency_penalty`: Penalty applied if a token has already been generated.
Higher values make the model less likely to repeat the token.
- `logit_bias`: Adds a logit bias to specific tokens. The keys of the dictionary are tokens, and the
values are the bias to add to that token.
- `response_format`: A JSON schema or a Pydantic model that enforces the structure of the model's response.
If provided, the output will always be validated against this
format (unless the model returns a tool call).
For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
Notes:
- This parameter accepts Pydantic models and JSON schemas for latest models starting from GPT-4o.
Older models only support basic version of structured outputs through `{"type": "json_object"}`.
For detailed information on JSON mode, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs#json-mode).
- For structured outputs with streaming,
the `response_format` must be a JSON schema and not a Pydantic model.
:param default_headers: Default headers to use for the AzureOpenAI client.
:param tools:
A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
:param azure_ad_token_provider: A function that returns an Azure Active Directory token, will be invoked on
every request.
:param http_client_kwargs:
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
"""
# We intentionally do not call super().__init__ here because we only need to instantiate the client to interact
# with the API.
# Why is this here?
# AzureOpenAI init is forcing us to use an init method that takes either base_url or azure_endpoint as not
# None init parameters. This way we accommodate the use case where env var AZURE_OPENAI_ENDPOINT is set instead
# of passing it as a parameter.
azure_endpoint = azure_endpoint or os.environ.get("AZURE_OPENAI_ENDPOINT")
# `azure_endpoint` accepts either a plain string or a `Secret`. We keep the original value on the instance for
# serialization and resolve it to a string only to validate that an endpoint was provided.
resolved_azure_endpoint = (
azure_endpoint.resolve_value() if isinstance(azure_endpoint, Secret) else azure_endpoint
)
if not resolved_azure_endpoint:
raise ValueError("Please provide an Azure endpoint or set the environment variable AZURE_OPENAI_ENDPOINT.")
if api_key is None and azure_ad_token is None:
raise ValueError("Please provide an API key or an Azure Active Directory token.")
# The check above makes mypy incorrectly infer that api_key is never None,
# which propagates the incorrect type.
self.api_key = api_key # type: ignore
self.azure_ad_token = azure_ad_token
self.generation_kwargs = generation_kwargs or {}
self.streaming_callback = streaming_callback
self.api_version = api_version
self.azure_endpoint = azure_endpoint
self.azure_deployment = azure_deployment
self.organization = organization
self.model = azure_deployment or "gpt-4.1-mini"
self.timeout = timeout
self.max_retries = max_retries
self.default_headers = default_headers or {}
self.azure_ad_token_provider = azure_ad_token_provider
self.http_client_kwargs = http_client_kwargs
_check_duplicate_tool_names(flatten_tools_or_toolsets(tools))
self.tools = tools
self.tools_strict = tools_strict
self.client: AzureOpenAI | None = None
self.async_client: AsyncAzureOpenAI | None = None
self._tools_warmed_up = False
def _client_kwargs(self) -> dict[str, Any]:
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
max_retries = (
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
)
resolved_azure_endpoint = (
self.azure_endpoint.resolve_value() if isinstance(self.azure_endpoint, Secret) else self.azure_endpoint
)
resolved_api_version = (
self.api_version.resolve_value() if isinstance(self.api_version, Secret) else self.api_version
)
return {
"api_version": resolved_api_version,
"azure_endpoint": resolved_azure_endpoint,
"azure_deployment": self.azure_deployment,
"api_key": self.api_key.resolve_value() if self.api_key is not None else None,
"azure_ad_token": self.azure_ad_token.resolve_value() if self.azure_ad_token is not None else None,
"organization": self.organization,
"timeout": timeout,
"max_retries": max_retries,
"default_headers": self.default_headers,
"azure_ad_token_provider": self.azure_ad_token_provider,
}
def _warm_up_tools(self) -> None:
if not self._tools_warmed_up:
warm_up_tools(self.tools)
self._tools_warmed_up = True
def warm_up(self) -> None:
"""
Warm up the tools and initialize the synchronous Azure OpenAI client.
"""
self._warm_up_tools()
if self.client is None:
self.client = AzureOpenAI(
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
)
async def warm_up_async(self) -> None: # noqa: RUF029
"""
Warm up the tools and initialize the asynchronous Azure OpenAI client on the serving event loop.
"""
self._warm_up_tools()
if self.async_client is None:
self.async_client = AsyncAzureOpenAI(
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
)
def close(self) -> None:
"""
Releases the synchronous Azure OpenAI client.
"""
if self.client is not None:
self.client.close()
self.client = None
async def close_async(self) -> None:
"""
Releases the asynchronous Azure OpenAI client.
"""
if self.async_client is not None:
await self.async_client.close()
self.async_client = None
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
The serialized component as a dictionary.
"""
callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
azure_ad_token_provider_name = None
if self.azure_ad_token_provider:
azure_ad_token_provider_name = serialize_callable(self.azure_ad_token_provider)
# If the response format is a Pydantic model, it's converted to openai's json schema format
# If it's already a json schema, it's left as is
generation_kwargs = self.generation_kwargs.copy()
response_format = generation_kwargs.get("response_format")
if response_format and issubclass(response_format, BaseModel):
json_schema = {
"type": "json_schema",
"json_schema": {
"name": response_format.__name__,
"strict": True,
"schema": to_strict_json_schema(response_format),
},
}
generation_kwargs["response_format"] = json_schema
return default_to_dict(
self,
azure_endpoint=self.azure_endpoint.to_dict()
if isinstance(self.azure_endpoint, Secret)
else self.azure_endpoint,
azure_deployment=self.azure_deployment,
organization=self.organization,
api_version=self.api_version.to_dict() if isinstance(self.api_version, Secret) else self.api_version,
streaming_callback=callback_name,
generation_kwargs=generation_kwargs,
timeout=self.timeout,
max_retries=self.max_retries,
api_key=self.api_key,
azure_ad_token=self.azure_ad_token,
default_headers=self.default_headers,
tools=serialize_tools_or_toolset(self.tools),
tools_strict=self.tools_strict,
azure_ad_token_provider=azure_ad_token_provider_name,
http_client_kwargs=self.http_client_kwargs,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAIChatGenerator":
"""
Deserialize this component from a dictionary.
:param data: The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
init_params = data.get("init_parameters", {})
serialized_callback_handler = init_params.get("streaming_callback")
if serialized_callback_handler:
data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
serialized_azure_ad_token_provider = init_params.get("azure_ad_token_provider")
if serialized_azure_ad_token_provider:
data["init_parameters"]["azure_ad_token_provider"] = deserialize_callable(
serialized_azure_ad_token_provider
)
return default_from_dict(cls, data)
@@ -0,0 +1,272 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from collections.abc import Awaitable, Callable
from typing import Any, ClassVar
from openai.lib._pydantic import to_strict_json_schema
from pydantic import BaseModel
from haystack import component, default_from_dict, default_to_dict
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
from haystack.dataclasses.streaming_chunk import StreamingCallbackT
from haystack.tools import ToolsType, deserialize_tools_or_toolset_inplace, serialize_tools_or_toolset
from haystack.utils import Secret, deserialize_callable, serialize_callable
@component
class AzureOpenAIResponsesChatGenerator(OpenAIResponsesChatGenerator):
"""
Completes chats using OpenAI's Responses API on Azure.
It works with the gpt-5 and o-series models and supports streaming responses
from OpenAI API. It uses [ChatMessage](https://docs.haystack.deepset.ai/docs/chatmessage)
format in input and output.
You can customize how the text is generated by passing parameters to the
OpenAI API. Use the `**generation_kwargs` argument when you initialize
the component or when you run it. Any parameter that works with
`openai.Responses.create` will work here too.
For details on OpenAI API parameters, see
[OpenAI documentation](https://platform.openai.com/docs/api-reference/responses).
### Usage example
<!-- test-ignore -->
```python
from haystack.components.generators.chat import AzureOpenAIResponsesChatGenerator
from haystack.dataclasses import ChatMessage
messages = [ChatMessage.from_user("What's Natural Language Processing?")]
client = AzureOpenAIResponsesChatGenerator(
azure_endpoint="https://example-resource.azure.openai.com/",
generation_kwargs={"reasoning": {"effort": "low", "summary": "auto"}}
)
response = client.run(messages)
print(response)
```
"""
SUPPORTED_MODELS: ClassVar[list[str]] = [
"gpt-5.4-pro",
"gpt-5.4",
"gpt-5.3-chat",
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.2",
"gpt-5.2-chat",
"gpt-5.1-codex-max",
"gpt-5.1",
"gpt-5.1-chat",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5-pro",
"gpt-5-codex",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-chat",
"gpt-4o",
"gpt-4o-mini",
"computer-use-preview",
"gpt-4.1",
"gpt-4.1-nano",
"gpt-4.1-mini",
"gpt-image-1",
"gpt-image-1-mini",
"gpt-image-1.5",
"o1",
"o3-mini",
"o3",
"o4-mini",
]
"""A non-exhaustive list of chat models supported by this component.
See https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses#model-support for the full list."""
# ruff: noqa: PLR0913
def __init__(
self,
*,
api_key: Secret | Callable[[], str] | Callable[[], Awaitable[str]] = Secret.from_env_var(
"AZURE_OPENAI_API_KEY", strict=False
),
azure_endpoint: str | None = None,
azure_deployment: str = "gpt-5-mini",
streaming_callback: StreamingCallbackT | None = None,
organization: str | None = None,
generation_kwargs: dict[str, Any] | None = None,
timeout: float | None = None,
max_retries: int | None = None,
tools: ToolsType | None = None,
tools_strict: bool = False,
http_client_kwargs: dict[str, Any] | None = None,
) -> None:
"""
Initialize the AzureOpenAIResponsesChatGenerator component.
:param api_key: The API key to use for authentication. Can be:
- A `Secret` object containing the API key.
- A `Secret` object containing the [Azure Active Directory token](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id).
- A function that returns an Azure Active Directory token.
:param azure_endpoint: The endpoint of the deployed model, for example `"https://example-resource.azure.openai.com/"`.
:param azure_deployment: The deployment of the model, usually the model name.
:param organization: Your organization ID, defaults to `None`. For help, see
[Setting up your organization](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization).
:param streaming_callback: A callback function called when a new token is received from the stream.
It accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk)
as an argument.
:param timeout: Timeout for OpenAI client calls. If not set, it defaults to either the
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
:param max_retries: Maximum number of retries to contact OpenAI after an internal error.
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
:param generation_kwargs: Other parameters to use for the model. These parameters are sent
directly to the OpenAI endpoint.
See OpenAI [documentation](https://platform.openai.com/docs/api-reference/responses) for
more details.
Some of the supported parameters:
- `temperature`: What sampling temperature to use. Higher values like 0.8 will make the output more random,
while lower values like 0.2 will make it more focused and deterministic.
- `top_p`: An alternative to sampling with temperature, called nucleus sampling, where the model
considers the results of the tokens with top_p probability mass. For example, 0.1 means only the tokens
comprising the top 10% probability mass are considered.
- `previous_response_id`: The ID of the previous response.
Use this to create multi-turn conversations.
- `text_format`: A Pydantic model that enforces the structure of the model's response.
If provided, the output will always be validated against this
format (unless the model returns a tool call).
For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
- `text`: A JSON schema that enforces the structure of the model's response.
If provided, the output will always be validated against this
format (unless the model returns a tool call).
Notes:
- Both JSON Schema and Pydantic models are supported for latest models starting from GPT-4o.
- If both are provided, `text_format` takes precedence and json schema passed to `text` is ignored.
- Currently, this component doesn't support streaming for structured outputs.
- Older models only support basic version of structured outputs through `{"type": "json_object"}`.
For detailed information on JSON mode, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs#json-mode).
- `reasoning`: A dictionary of parameters for reasoning. For example:
- `summary`: The summary of the reasoning.
- `effort`: The level of effort to put into the reasoning. Can be `low`, `medium` or `high`.
- `generate_summary`: Whether to generate a summary of the reasoning.
Note: OpenAI does not return the reasoning tokens, but we can view summary if its enabled.
For details, see the [OpenAI Reasoning documentation](https://platform.openai.com/docs/guides/reasoning).
:param tools:
A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
:param http_client_kwargs:
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
"""
azure_endpoint = azure_endpoint or os.getenv("AZURE_OPENAI_ENDPOINT")
if azure_endpoint is None:
raise ValueError(
"You must provide `azure_endpoint` or set the `AZURE_OPENAI_ENDPOINT` environment variable."
)
self._azure_endpoint = azure_endpoint
self._azure_deployment = azure_deployment
super(AzureOpenAIResponsesChatGenerator, self).__init__( # noqa: UP008
api_key=api_key, # type: ignore[arg-type]
model=self._azure_deployment,
streaming_callback=streaming_callback,
api_base_url=f"{self._azure_endpoint.rstrip('/')}/openai/v1",
organization=organization,
generation_kwargs=generation_kwargs,
timeout=timeout,
max_retries=max_retries,
tools=tools,
tools_strict=tools_strict,
http_client_kwargs=http_client_kwargs,
)
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
The serialized component as a dictionary.
"""
callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
# API key can be a secret or a callable
serialized_api_key = (
serialize_callable(self.api_key)
if callable(self.api_key)
else self.api_key.to_dict()
if isinstance(self.api_key, Secret)
else None
)
# If the text format is a Pydantic model, it's converted to openai's json schema format
# If it's already a json schema, it's left as is
generation_kwargs = self.generation_kwargs.copy()
text_format = generation_kwargs.pop("text_format", None)
if text_format and isinstance(text_format, type) and issubclass(text_format, BaseModel):
json_schema = {
"format": {
"type": "json_schema",
"name": text_format.__name__,
"strict": True,
"schema": to_strict_json_schema(text_format),
}
}
# json schema needs to be passed to text parameter instead of text_format
generation_kwargs["text"] = json_schema
# OpenAI/MCP tools are passed as list of dictionaries
serialized_tools: dict[str, Any] | list[dict[str, Any]] | None
if self.tools and isinstance(self.tools, list) and isinstance(self.tools[0], dict):
# mypy can't infer that self.tools is list[dict] here
serialized_tools = self.tools
else:
serialized_tools = serialize_tools_or_toolset(self.tools) # type: ignore[arg-type]
return default_to_dict(
self,
azure_endpoint=self._azure_endpoint,
api_key=serialized_api_key,
azure_deployment=self._azure_deployment,
streaming_callback=callback_name,
organization=self.organization,
generation_kwargs=generation_kwargs,
timeout=self.timeout,
max_retries=self.max_retries,
tools=serialized_tools,
tools_strict=self.tools_strict,
http_client_kwargs=self.http_client_kwargs,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAIResponsesChatGenerator":
"""
Deserialize this component from a dictionary.
:param data: The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
# If api_key is a str, it's a callable (Secrets are handled automatically by default_from_dict)
serialized_api_key = data["init_parameters"].get("api_key")
if isinstance(serialized_api_key, str):
data["init_parameters"]["api_key"] = deserialize_callable(serialized_api_key)
# we only deserialize the tools if they are haystack tools
# because openai tools are not serialized in the same way
tools = data["init_parameters"].get("tools")
if tools and (
isinstance(tools, dict)
and tools.get("type") == "haystack.tools.toolset.Toolset"
or isinstance(tools, list)
and tools[0].get("type") == "haystack.tools.tool.Tool"
):
deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
init_params = data.get("init_parameters", {})
serialized_callback_handler = init_params.get("streaming_callback")
if serialized_callback_handler:
data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
return default_from_dict(cls, data)
@@ -0,0 +1,257 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import Any
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.generators.chat.types import ChatGenerator
from haystack.components.generators.utils import _normalize_messages
from haystack.core.serialization import component_to_dict
from haystack.dataclasses import ChatMessage, StreamingCallbackT
from haystack.tools import ToolsType
from haystack.utils.async_utils import _execute_component_async
from haystack.utils.deserialization import deserialize_component_inplace
logger = logging.getLogger(__name__)
@component
class FallbackChatGenerator:
"""
A chat generator wrapper that tries multiple chat generators sequentially.
It forwards all parameters transparently to the underlying chat generators and returns the first successful result.
Calls chat generators sequentially until one succeeds. Falls back on any exception raised by a generator.
If all chat generators fail, it raises a RuntimeError with details.
Timeout enforcement is fully delegated to the underlying chat generators. The fallback mechanism will only
work correctly if the underlying chat generators implement proper timeout handling and raise exceptions
when timeouts occur. For predictable latency guarantees, ensure your chat generators:
- Support a `timeout` parameter in their initialization
- Implement timeout as total wall-clock time (shared deadline for both streaming and non-streaming)
- Raise timeout exceptions (e.g., TimeoutError, asyncio.TimeoutError, httpx.TimeoutException) when exceeded
Note: Most well-implemented chat generators (OpenAI, Anthropic, Cohere, etc.) support timeout parameters
with consistent semantics. For HTTP-based LLM providers, a single timeout value (e.g., `timeout=30`)
typically applies to all connection phases: connection setup, read, write, and pool. For streaming
responses, read timeout is the maximum gap between chunks. For non-streaming, it's the time limit for
receiving the complete response.
Fail over is automatically triggered when a generator raises any exception, including:
- Timeout errors (if the generator implements and raises them)
- Rate limit errors (429)
- Authentication errors (401)
- Context length errors (400)
- Server errors (500+)
- Any other exception
"""
def __init__(self, chat_generators: list[ChatGenerator]) -> None:
"""
Creates an instance of FallbackChatGenerator.
:param chat_generators: A non-empty list of chat generator components to try in order.
"""
if not chat_generators:
msg = "'chat_generators' must be a non-empty list"
raise ValueError(msg)
self.chat_generators = list(chat_generators)
def to_dict(self) -> dict[str, Any]:
"""Serialize the component, including nested chat generators."""
return default_to_dict(
self,
chat_generators=[
component_to_dict(gen, name=f"chat_generator_{idx}") for idx, gen in enumerate(self.chat_generators)
],
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> FallbackChatGenerator:
"""Rebuild the component from a serialized representation, restoring nested chat generators."""
# Reconstruct nested chat generators from their serialized dicts
init_params = data.get("init_parameters", {})
serialized = init_params.get("chat_generators") or []
deserialized: list[Any] = []
for g in serialized:
# Use the generic component deserializer available in Haystack
holder = {"component": g}
deserialize_component_inplace(holder, key="component")
deserialized.append(holder["component"])
init_params["chat_generators"] = deserialized
data["init_parameters"] = init_params
return default_from_dict(cls, data)
def warm_up(self) -> None:
"""Warm up all underlying chat generators."""
for gen in self.chat_generators:
if hasattr(gen, "warm_up"):
gen.warm_up()
async def warm_up_async(self) -> None:
"""Warm up all underlying chat generators on the serving event loop."""
for gen in self.chat_generators:
if hasattr(gen, "warm_up_async"):
await gen.warm_up_async()
elif hasattr(gen, "warm_up"):
gen.warm_up()
def close(self) -> None:
"""Release the underlying chat generators' resources."""
for gen in self.chat_generators:
if hasattr(gen, "close"):
gen.close()
async def close_async(self) -> None:
"""Release the underlying chat generators' async resources."""
for gen in self.chat_generators:
if hasattr(gen, "close_async"):
await gen.close_async()
elif hasattr(gen, "close"):
gen.close()
def _run_single_sync(
self,
gen: Any,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None,
tools: ToolsType | None,
streaming_callback: StreamingCallbackT | None,
) -> dict[str, Any]:
return gen.run(
messages=messages, generation_kwargs=generation_kwargs, tools=tools, streaming_callback=streaming_callback
)
async def _run_single_async(
self,
gen: Any,
messages: list[ChatMessage],
generation_kwargs: dict[str, Any] | None,
tools: ToolsType | None,
streaming_callback: StreamingCallbackT | None,
) -> dict[str, Any]:
return await _execute_component_async(
gen,
messages=messages,
generation_kwargs=generation_kwargs,
tools=tools,
streaming_callback=streaming_callback,
)
@component.output_types(replies=list[ChatMessage], meta=dict[str, Any])
def run(
self,
messages: list[ChatMessage] | str,
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, list[ChatMessage] | dict[str, Any]]:
"""
Execute chat generators sequentially until one succeeds.
:param messages: The conversation history as a list of ChatMessage instances.
:param generation_kwargs: Optional parameters for the chat generator (e.g., temperature, max_tokens).
:param tools: A list of Tool and/or Toolset objects, or a single Toolset for function calling capabilities.
:param streaming_callback: Optional callable for handling streaming responses.
:returns: A dictionary with:
- "replies": Generated ChatMessage instances from the first successful generator.
- "meta": Execution metadata including successful_chat_generator_index, successful_chat_generator_class,
total_attempts, failed_chat_generators, plus any metadata from the successful generator.
:raises RuntimeError: If all chat generators fail.
"""
self.warm_up()
messages = _normalize_messages(messages)
failed: list[str] = []
last_error: BaseException | None = None
for idx, gen in enumerate(self.chat_generators):
gen_name = gen.__class__.__name__
try:
result = self._run_single_sync(gen, messages, generation_kwargs, tools, streaming_callback)
replies = result.get("replies", [])
meta = dict(result.get("meta", {}))
meta.update(
{
"successful_chat_generator_index": idx,
"successful_chat_generator_class": gen_name,
"total_attempts": idx + 1,
"failed_chat_generators": failed,
}
)
return {"replies": replies, "meta": meta}
except Exception as e: # noqa: BLE001 - fallback logic should handle any exception
logger.warning(
"ChatGenerator {chat_generator} failed with error: {error}", chat_generator=gen_name, error=e
)
failed.append(gen_name)
last_error = e
failed_names = ", ".join(failed)
msg = (
f"All {len(self.chat_generators)} chat generators failed. "
f"Last error: {last_error}. Failed chat generators: [{failed_names}]"
)
raise RuntimeError(msg)
@component.output_types(replies=list[ChatMessage], meta=dict[str, Any])
async def run_async(
self,
messages: list[ChatMessage] | str,
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> dict[str, list[ChatMessage] | dict[str, Any]]:
"""
Asynchronously execute chat generators sequentially until one succeeds.
:param messages: The conversation history as a list of ChatMessage instances.
:param generation_kwargs: Optional parameters for the chat generator (e.g., temperature, max_tokens).
:param tools: A list of Tool and/or Toolset objects, or a single Toolset for function calling capabilities.
:param streaming_callback: Optional callable for handling streaming responses.
:returns: A dictionary with:
- "replies": Generated ChatMessage instances from the first successful generator.
- "meta": Execution metadata including successful_chat_generator_index, successful_chat_generator_class,
total_attempts, failed_chat_generators, plus any metadata from the successful generator.
:raises RuntimeError: If all chat generators fail.
"""
await self.warm_up_async()
messages = _normalize_messages(messages)
failed: list[str] = []
last_error: BaseException | None = None
for idx, gen in enumerate(self.chat_generators):
gen_name = gen.__class__.__name__
try:
result = await self._run_single_async(gen, messages, generation_kwargs, tools, streaming_callback)
replies = result.get("replies", [])
meta = dict(result.get("meta", {}))
meta.update(
{
"successful_chat_generator_index": idx,
"successful_chat_generator_class": gen_name,
"total_attempts": idx + 1,
"failed_chat_generators": failed,
}
)
return {"replies": replies, "meta": meta}
except Exception as e: # noqa: BLE001 - fallback logic should handle any exception
logger.warning(
"ChatGenerator {chat_generator} failed with error: {error}", chat_generator=gen_name, error=e
)
failed.append(gen_name)
last_error = e
failed_names = ", ".join(failed)
msg = (
f"All {len(self.chat_generators)} chat generators failed. "
f"Last error: {last_error}. Failed chat generators: [{failed_names}]"
)
raise RuntimeError(msg)
+205
View File
@@ -0,0 +1,205 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any, Literal
from haystack import component, logging
from haystack.components.agents.agent import Agent
from haystack.components.generators.chat.types import ChatGenerator
from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict
from haystack.dataclasses import ChatMessage, StreamingCallbackT
from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
from haystack.utils.deserialization import deserialize_component_inplace
logger = logging.getLogger(__name__)
@component
class LLM(Agent):
"""
A text generation component powered by a large language model.
The LLM component is a simplified version of the Agent that focuses solely on text generation
without tool usage. It processes messages and returns a single response from the language model.
### Usage examples
```python
from haystack.components.generators.chat import LLM
from haystack.components.generators.chat import OpenAIChatGenerator
llm = LLM(
chat_generator=OpenAIChatGenerator(),
system_prompt="You are a helpful translation assistant.",
user_prompt="Summarize the following document: {{ document }}",
required_variables=["document"],
)
result = llm.run(document="The weather is lovely today and the sun is shining. ")
print(result["last_message"].text)
```
"""
def __init__(
self,
*,
chat_generator: ChatGenerator,
system_prompt: str | None = None,
user_prompt: str | None = None,
required_variables: list[str] | Literal["*"] = "*",
streaming_callback: StreamingCallbackT | None = None,
) -> None:
"""
Initialize the LLM component.
:param chat_generator: An instance of the chat generator that the LLM should use.
:param system_prompt: System prompt for the LLM. Can be a plain string template or a Jinja2 message template.
:param user_prompt: User prompt for the LLM. This prompt is appended to the messages provided at
runtime. Can be a plain string template or a Jinja2 message template. If it contains template variables
(e.g., `{{ variable_name }}`), they become inputs to the component. If omitted or if there are no
template variables, `messages` must be provided at runtime instead.
:param required_variables:
Variables that must be provided as input to `user_prompt` or `system_prompt`.
If a variable listed as required is not provided, an exception is raised.
If set to `"*"`, all variables found in the prompt are required. Defaults to `"*"`.
Only relevant when `user_prompt` or `system_prompt` contains template variables.
:param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
:raises ValueError: If user_prompt contains template variables but required_variables is an empty list.
"""
super(LLM, self).__init__( # noqa: UP008
chat_generator=chat_generator,
system_prompt=system_prompt,
user_prompt=user_prompt,
required_variables=required_variables,
streaming_callback=streaming_callback,
)
if self._user_chat_prompt_builder is None or len(self._user_chat_prompt_builder.variables) == 0:
# This means user_prompt is empty or has no template variables.
# To ensure properly scheduling we then require messages to be passed at runtime.
component.set_input_type(self, "messages", list[ChatMessage])
else:
# user prompt was provided with variables
if isinstance(required_variables, list) and len(required_variables) == 0:
raise ValueError(
"required_variables must not be empty. Set it to '*' to require all variables, "
"or provide a non-empty list of variable names."
)
component.set_input_type(self, "messages", list[ChatMessage], None)
# The Agent base class declares `step_count` and `tool_call_counts` as outputs, but an LLM never has tools
# and always runs exactly one step — those values are uninformative, so drop them from the public surface.
# `token_usage` is still meaningful and stays exposed.
component.set_output_types(
self, messages=list[ChatMessage], last_message=ChatMessage, token_usage=dict[str, Any]
)
def to_dict(self) -> dict[str, Any]:
"""
Serialize the LLM component to a dictionary.
:return: Dictionary with serialized data.
"""
return default_to_dict(
self,
chat_generator=component_to_dict(obj=self.chat_generator, name="chat_generator"),
system_prompt=self.system_prompt,
user_prompt=self.user_prompt,
required_variables=self.required_variables,
streaming_callback=serialize_callable(self.streaming_callback) if self.streaming_callback else None,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "LLM":
"""
Deserialize the LLM from a dictionary.
:param data: Dictionary to deserialize from.
:return: Deserialized LLM instance.
"""
init_params = data.get("init_parameters", {})
deserialize_component_inplace(init_params, key="chat_generator")
if init_params.get("streaming_callback") is not None:
init_params["streaming_callback"] = deserialize_callable(init_params["streaming_callback"])
return default_from_dict(cls, data)
def run( # type: ignore[override] # `messages` is in **kwargs to allow dynamic required/optional status
self,
*,
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""
Process messages and generate a response from the language model.
:param messages: Optional list of ChatMessage objects to prepend to the conversation. Whether this is
required or optional depends on the `user_prompt` configuration: if `user_prompt` has no template
variables, `messages` must be provided. Passed via `**kwargs`.
:param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
:param generation_kwargs: Additional keyword arguments for the underlying chat generator. These parameters
will override the parameters passed during component initialization.
:param kwargs: Additional keyword arguments. These are used to fill template variables in `user_prompt` or
`system_prompt` (the keys must match template variable names).
:returns:
A dictionary with the following keys:
- "messages": List of all messages exchanged during the LLM's run.
- "last_message": The last message exchanged during the LLM's run.
- "token_usage": Token usage from the LLM call (e.g. prompt_tokens, completion_tokens). Empty if the
chat generator did not return usage data.
"""
# `messages` is intentionally omitted from the signature so the framework can treat it as required
# or optional depending on init configuration. See __init__ for details.
messages = kwargs.pop("messages", None)
result = super(LLM, self).run( # noqa: UP008
messages=messages or [],
streaming_callback=streaming_callback,
generation_kwargs=generation_kwargs,
**kwargs,
)
# Inherited Agent-internal bookkeeping that isn't useful at the LLM surface.
result.pop("step_count", None)
result.pop("tool_call_counts", None)
return result
async def run_async( # type: ignore[override] # `messages` is in **kwargs to allow dynamic required/optional status
self,
*,
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""
Asynchronously process messages and generate a response from the language model.
:param messages: Optional list of ChatMessage objects to prepend to the conversation. Whether this is
required or optional depends on the `user_prompt` configuration: if `user_prompt` has no template
variables, `messages` must be provided. Passed via `**kwargs`.
:param streaming_callback: An asynchronous callback that will be invoked when a response is streamed
from the LLM.
:param generation_kwargs: Additional keyword arguments for the underlying chat generator. These parameters
will override the parameters passed during component initialization.
:param kwargs: Additional keyword arguments. These are used to fill template variables in `user_prompt` or
`system_prompt` (the keys must match template variable names).
:returns:
A dictionary with the following keys:
- "messages": List of all messages exchanged during the LLM's run.
- "last_message": The last message exchanged during the LLM's run.
- "token_usage": Token usage from the LLM call (e.g. prompt_tokens, completion_tokens). Empty if the
chat generator did not return usage data.
"""
# `messages` is intentionally omitted from the signature so the framework can treat it as required
# or optional depending on init configuration. See __init__ for details.
messages = kwargs.pop("messages", None)
result = await super(LLM, self).run_async( # noqa: UP008
messages=messages or [],
streaming_callback=streaming_callback,
generation_kwargs=generation_kwargs,
**kwargs,
)
# Inherited Agent-internal bookkeeping that isn't useful at the LLM surface.
result.pop("step_count", None)
result.pop("tool_call_counts", None)
return result
+374
View File
@@ -0,0 +1,374 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
import re
from collections.abc import Callable, Sequence
from dataclasses import replace
from typing import Any
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.generators.utils import _normalize_messages
from haystack.dataclasses import (
ChatMessage,
ChatRole,
ComponentInfo,
FinishReason,
StreamingCallbackT,
StreamingChunk,
select_streaming_callback,
)
from haystack.dataclasses.streaming_chunk import ToolCallDelta, _invoke_streaming_callback
from haystack.tools import ToolsType
from haystack.utils import deserialize_callable, serialize_callable
logger = logging.getLogger(__name__)
# A callable that derives a response from the input messages. It receives the (normalized) list of input
# `ChatMessage` objects and returns either the text of the assistant reply or a full `ChatMessage`.
ResponseFn = Callable[[list[ChatMessage]], str | ChatMessage]
@component
class MockChatGenerator:
"""
A Chat Generator that returns predefined responses without calling any API.
It is a drop-in replacement for real Chat Generators (such as `OpenAIChatGenerator`) in tests, smoke tests, and
quick prototypes. It implements the same interface (`run`, `run_async`, streaming, serialization) but never
contacts an external service, so it is fully deterministic and free to run.
The response is selected based on how the component is configured:
- **Fixed response**: pass a single string or `ChatMessage`. The same reply is returned on every call.
Any `ChatMessage` passed as a response must have the `assistant` role.
- **Cycling responses**: pass a list of strings and/or `ChatMessage` objects. Each call returns the next item,
wrapping around to the start once the list is exhausted. This is useful to drive multi-step flows such as
Agents, where the first call returns a tool call and a later call returns the final answer.
- **Dynamic response**: pass a `response_fn` callable that receives the input messages and returns the reply.
This is useful when the reply should depend on the input, for example to echo back part of the prompt.
- **Echo (default)**: with no configuration, the component echoes back the text of the last message that has
text content. This makes it usable out of the box for quick prototyping.
Pass `ChatMessage` objects (rather than plain strings) to return tool calls or reasoning content, which is handy
for exercising tool-calling pipelines without a real model.
### Usage example
```python
from haystack.components.generators.chat import MockChatGenerator
from haystack.dataclasses import ChatMessage, ToolCall
# Fixed response
generator = MockChatGenerator(responses="Hello, this is a mock response.")
result = generator.run([ChatMessage.from_user("Hi!")])
print(result["replies"][0].text) # "Hello, this is a mock response."
# Cycling responses to drive an Agent-like loop
generator = MockChatGenerator(
responses=[
ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="search", arguments={"query": "Haystack"})]),
"Here is the final answer.",
]
)
```
"""
def __init__(
self,
responses: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
response_fn: ResponseFn | None = None,
model: str = "mock-model",
meta: dict[str, Any] | None = None,
streaming_callback: StreamingCallbackT | None = None,
) -> None:
"""
Creates an instance of MockChatGenerator.
:param responses: The predefined response(s) to return. Accepts a single string or `ChatMessage` (returned on
every call), or a non-empty list of strings and/or `ChatMessage` objects that are returned in order,
cycling back to the start once exhausted. Strings are wrapped into assistant `ChatMessage` objects, and any
`ChatMessage` passed must have the `assistant` role. Mutually exclusive with `response_fn`. If neither is
provided, the component echoes the last message with text content.
:param response_fn: An optional callable that receives the input messages and returns the reply as a string or
an assistant `ChatMessage`. Use this for input-dependent responses. Mutually exclusive with `responses`. To
support serialization, pass a named function (lambdas and nested functions cannot be serialized).
:param model: The model name reported in the response metadata. Purely cosmetic; no model is loaded.
:param meta: Additional metadata merged into the `meta` of every returned `ChatMessage`. A per-response
`ChatMessage`'s own metadata takes precedence over this value.
:param streaming_callback: An optional callback invoked with `StreamingChunk` objects reconstructed from the
predefined response. It lets the mock exercise streaming code paths without a real model.
:raises ValueError: If both `responses` and `response_fn` are provided, if `responses` is an empty list, or if
a `ChatMessage` response does not have the `assistant` role.
"""
if responses is not None and response_fn is not None:
raise ValueError("Pass either 'responses' or 'response_fn', not both.")
self._responses = self._normalize_responses(responses)
self.response_fn = response_fn
self.model = model
self.meta = meta or {}
self.streaming_callback = streaming_callback
self._call_count = 0
self._is_warmed_up = False
@staticmethod
def _normalize_responses(
responses: str | ChatMessage | Sequence[str | ChatMessage] | None,
) -> list[ChatMessage] | None:
"""Normalize the `responses` argument into a non-empty list of `ChatMessage`, or `None` for echo mode."""
if responses is None:
return None
items: list[str | ChatMessage]
if isinstance(responses, (str, ChatMessage)):
items = [responses]
elif isinstance(responses, Sequence):
items = list(responses)
else:
raise TypeError(f"'responses' must be a string, ChatMessage, or a sequence of them, got {type(responses)}.")
if len(items) == 0:
raise ValueError("'responses' must not be an empty list.")
normalized: list[ChatMessage] = []
for item in items:
if isinstance(item, str):
normalized.append(ChatMessage.from_assistant(item))
elif isinstance(item, ChatMessage):
if item.role != ChatRole.ASSISTANT:
raise ValueError(
f"Each ChatMessage response must have the 'assistant' role, got '{item.role.value}'."
)
normalized.append(item)
else:
raise TypeError(f"Each response must be a string or ChatMessage, got {type(item)}.")
return normalized
def to_dict(self) -> dict[str, Any]:
"""Serialize the component to a dictionary."""
responses = [msg.to_dict() for msg in self._responses] if self._responses is not None else None
response_fn = serialize_callable(self.response_fn) if self.response_fn is not None else None
streaming_callback = serialize_callable(self.streaming_callback) if self.streaming_callback else None
return default_to_dict(
self,
responses=responses,
response_fn=response_fn,
model=self.model,
meta=self.meta,
streaming_callback=streaming_callback,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> MockChatGenerator:
"""Deserialize the component from a dictionary."""
init_params = data.get("init_parameters", {})
responses = init_params.get("responses")
if responses is not None:
init_params["responses"] = [ChatMessage.from_dict(msg) for msg in responses]
response_fn = init_params.get("response_fn")
if response_fn:
init_params["response_fn"] = deserialize_callable(response_fn)
streaming_callback = init_params.get("streaming_callback")
if streaming_callback:
init_params["streaming_callback"] = deserialize_callable(streaming_callback)
return default_from_dict(cls, data)
def warm_up(self) -> None:
"""No-op warm up, provided for interface compatibility with real Chat Generators."""
self._is_warmed_up = True
@staticmethod
def _echo_text(messages: list[ChatMessage]) -> str | None:
"""Return the text of the last message that has text content, for echo mode."""
for message in reversed(messages):
if message.text:
return message.text
return None
@staticmethod
def _coerce_to_message(result: str | ChatMessage) -> ChatMessage:
"""Turn the output of `response_fn` into a `ChatMessage`, wrapping strings and requiring the assistant role."""
if isinstance(result, str):
return ChatMessage.from_assistant(result)
if isinstance(result, ChatMessage):
if result.role != ChatRole.ASSISTANT:
raise ValueError(f"'response_fn' must return an assistant ChatMessage, got '{result.role.value}'.")
return result
raise TypeError(f"'response_fn' must return a string or ChatMessage, got {type(result)}.")
@staticmethod
def _estimate_usage(messages: list[ChatMessage], reply: ChatMessage) -> dict[str, int]:
"""
Roughly estimate token usage as whitespace-separated word counts.
This is an approximation (not real tokenization) intended to give downstream code realistic-looking metadata.
"""
prompt_tokens = sum(len((message.text or "").split()) for message in messages)
completion_tokens = len((reply.text or "").split())
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
def _build_meta(self, messages: list[ChatMessage], base: ChatMessage) -> dict[str, Any]:
"""Build the metadata attached to the returned reply, merging defaults, init meta, and per-response meta."""
meta: dict[str, Any] = {
"model": self.model,
"index": 0,
"finish_reason": "tool_calls" if base.tool_calls else "stop",
"usage": self._estimate_usage(messages, base),
}
meta.update(self.meta)
meta.update(base.meta)
return meta
def _build_reply(self, messages: list[ChatMessage]) -> ChatMessage | None:
"""Select and finalize the reply for the given input messages. Returns `None` when there is nothing to echo."""
if self.response_fn is not None:
base = self._coerce_to_message(self.response_fn(messages))
elif self._responses is not None:
base = self._responses[self._call_count % len(self._responses)]
self._call_count += 1
else:
text = self._echo_text(messages)
if text is None:
return None
base = ChatMessage.from_assistant(text)
return replace(base, _meta=self._build_meta(messages, base))
def _make_chunks(self, reply: ChatMessage) -> list[StreamingChunk]:
"""Reconstruct streaming chunks from a finalized reply so streaming callbacks can be exercised."""
component_info = ComponentInfo.from_component(self)
chunks: list[StreamingChunk] = []
# Stream the text content word by word in content block 0.
parts = re.findall(r"\S+\s*", reply.text) if reply.text else []
for idx, part in enumerate(parts):
chunks.append(
StreamingChunk(
content=part, component_info=component_info, index=0, start=(idx == 0), meta={"model": self.model}
)
)
# Stream each tool call in its own content block.
block_index = 1 if parts else 0
for tool_call in reply.tool_calls:
chunks.append(
StreamingChunk(
content="",
component_info=component_info,
index=block_index,
start=True,
tool_calls=[
ToolCallDelta(
index=block_index,
tool_name=tool_call.tool_name,
arguments=json.dumps(tool_call.arguments),
id=tool_call.id,
)
],
meta={"model": self.model},
)
)
block_index += 1
if not chunks:
chunks.append(
StreamingChunk(content="", component_info=component_info, index=0, meta={"model": self.model})
)
finish_reason: FinishReason = "tool_calls" if reply.tool_calls else "stop"
last = chunks[-1]
chunks[-1] = replace(last, finish_reason=finish_reason, meta={**last.meta, "finish_reason": finish_reason})
return chunks
@component.output_types(replies=list[ChatMessage])
def run(
self,
messages: list[ChatMessage] | str,
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None, # noqa: ARG002
*,
tools: ToolsType | None = None, # noqa: ARG002
tools_strict: bool | None = None, # noqa: ARG002
) -> dict[str, list[ChatMessage]]:
"""
Return a predefined reply for the given messages without calling any API.
The signature mirrors `OpenAIChatGenerator.run` so the mock can be used as a positional drop-in replacement.
:param messages: The conversation history as a list of `ChatMessage` instances or a single string.
:param streaming_callback: An optional callback invoked with reconstructed `StreamingChunk` objects. Overrides
the callback set at initialization.
:param generation_kwargs: Accepted for interface compatibility and ignored.
:param tools: Accepted for interface compatibility and ignored.
:param tools_strict: Accepted for interface compatibility and ignored.
:returns: A dictionary with a single key `replies` containing the predefined reply as a list of one
`ChatMessage` (empty in echo mode when there is no message to echo).
"""
self.warm_up()
messages = _normalize_messages(messages)
streaming_callback = select_streaming_callback(
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=False
)
reply = self._build_reply(messages)
if reply is None:
return {"replies": []}
if streaming_callback is not None:
for chunk in self._make_chunks(reply):
streaming_callback(chunk)
return {"replies": [reply]}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self,
messages: list[ChatMessage] | str,
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None, # noqa: ARG002
*,
tools: ToolsType | None = None, # noqa: ARG002
tools_strict: bool | None = None, # noqa: ARG002
) -> dict[str, list[ChatMessage]]:
"""
Asynchronously return a predefined reply for the given messages without calling any API.
The signature mirrors `OpenAIChatGenerator.run_async` so the mock can be used as a positional drop-in
replacement.
:param messages: The conversation history as a list of `ChatMessage` instances or a single string.
:param streaming_callback: An optional callback invoked with reconstructed `StreamingChunk` objects. Overrides
the callback set at initialization.
:param generation_kwargs: Accepted for interface compatibility and ignored.
:param tools: Accepted for interface compatibility and ignored.
:param tools_strict: Accepted for interface compatibility and ignored.
:returns: A dictionary with a single key `replies` containing the predefined reply as a list of one
`ChatMessage` (empty in echo mode when there is no message to echo).
"""
if not self._is_warmed_up:
self.warm_up()
messages = _normalize_messages(messages)
streaming_callback = select_streaming_callback(
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=True
)
reply = self._build_reply(messages)
if reply is None:
return {"replies": []}
if streaming_callback is not None:
for chunk in self._make_chunks(reply):
await _invoke_streaming_callback(streaming_callback, chunk)
return {"replies": [reply]}
@@ -0,0 +1,795 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import json
import os
from datetime import datetime
from typing import Any, ClassVar
from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
from openai.lib._pydantic import to_strict_json_schema
from openai.types.chat import (
ChatCompletion,
ChatCompletionChunk,
ChatCompletionMessage,
ChatCompletionMessageCustomToolCall,
ParsedChatCompletion,
ParsedChatCompletionMessage,
)
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from pydantic import BaseModel
from haystack import component, default_from_dict, default_to_dict, logging
from haystack.components.generators.utils import (
_convert_streaming_chunks_to_chat_message,
_normalize_messages,
_serialize_object,
)
from haystack.dataclasses import (
ChatMessage,
ComponentInfo,
FinishReason,
StreamingCallbackT,
StreamingChunk,
SyncStreamingCallbackT,
ToolCall,
ToolCallDelta,
select_streaming_callback,
)
from haystack.dataclasses.streaming_chunk import _invoke_streaming_callback
from haystack.tools import (
ToolsType,
_check_duplicate_tool_names,
deserialize_tools_or_toolset_inplace,
flatten_tools_or_toolsets,
serialize_tools_or_toolset,
warm_up_tools,
)
from haystack.utils import Secret, deserialize_callable, serialize_callable
from haystack.utils.http_client import init_http_client
logger = logging.getLogger(__name__)
@component
class OpenAIChatGenerator:
"""
Completes chats using OpenAI's large language models (LLMs).
It works with the gpt-4 and gpt-5 series models and supports streaming responses
from OpenAI API. It uses [ChatMessage](https://docs.haystack.deepset.ai/docs/chatmessage)
format in input and output.
You can customize how the text is generated by passing parameters to the
OpenAI API. Use the `**generation_kwargs` argument when you initialize
the component or when you run it. Any parameter that works with
`openai.ChatCompletion.create` will work here too.
For details on OpenAI API parameters, see
[OpenAI documentation](https://platform.openai.com/docs/api-reference/chat).
### Usage example
```python
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
messages = [ChatMessage.from_user("What's Natural Language Processing?")]
client = OpenAIChatGenerator()
response = client.run(messages)
print(response)
```
Output:
```
{'replies':
[ChatMessage(_role=<ChatRole.ASSISTANT: 'assistant'>, _content=
[TextContent(text="Natural Language Processing (NLP) is a branch of artificial intelligence
that focuses on enabling computers to understand, interpret, and generate human language in
a way that is meaningful and useful.")],
_name=None,
_meta={'model': 'gpt-5-mini', 'index': 0, 'finish_reason': 'stop',
'usage': {'prompt_tokens': 15, 'completion_tokens': 36, 'total_tokens': 51}})
]
}
```
"""
SUPPORTED_MODELS: ClassVar[list[str]] = [
"gpt-5-mini",
"gpt-5-nano",
"gpt-5",
"gpt-5.1",
"gpt-5.2",
"gpt-5.2-pro",
"gpt-5.4",
"gpt-5-pro",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
]
"""A non-exhaustive list of chat models supported by this component.
See https://developers.openai.com/api/docs/models for the full list and snapshot IDs."""
def __init__(
self,
api_key: Secret = Secret.from_env_var("OPENAI_API_KEY"),
model: str = "gpt-5-mini",
streaming_callback: StreamingCallbackT | None = None,
api_base_url: str | None = None,
organization: str | None = None,
generation_kwargs: dict[str, Any] | None = None,
timeout: float | None = None,
max_retries: int | None = None,
tools: ToolsType | None = None,
tools_strict: bool = False,
http_client_kwargs: dict[str, Any] | None = None,
) -> None:
"""
Creates an instance of OpenAIChatGenerator. Unless specified otherwise in `model`, uses OpenAI's gpt-5-mini
Before initializing the component, you can set the 'OPENAI_TIMEOUT' and 'OPENAI_MAX_RETRIES'
environment variables to override the `timeout` and `max_retries` parameters respectively
in the OpenAI client.
:param api_key: The OpenAI API key.
You can set it with an environment variable `OPENAI_API_KEY`, or pass with this parameter
during initialization.
:param model: The name of the model to use.
:param streaming_callback: A callback function that is called when a new token is received from the stream.
The callback function accepts [StreamingChunk](https://docs.haystack.deepset.ai/docs/data-classes#streamingchunk)
as an argument.
:param api_base_url: An optional base URL.
:param organization: Your organization ID, defaults to `None`. See
[production best practices](https://platform.openai.com/docs/guides/production-best-practices/setting-up-your-organization).
:param generation_kwargs: Other parameters to use for the model. These parameters are sent directly to
the OpenAI endpoint. See OpenAI [documentation](https://platform.openai.com/docs/api-reference/chat) for
more details.
Some of the supported parameters:
- `max_completion_tokens`: An upper bound for the number of tokens that can be generated for a completion,
including visible output tokens and reasoning tokens.
- `temperature`: What sampling temperature to use. Higher values mean the model will take more risks.
Try 0.9 for more creative applications and 0 (argmax sampling) for ones with a well-defined answer.
- `top_p`: An alternative to sampling with temperature, called nucleus sampling, where the model
considers the results of the tokens with top_p probability mass. For example, 0.1 means only the tokens
comprising the top 10% probability mass are considered.
- `n`: How many completions to generate for each prompt. For example, if the LLM gets 3 prompts and n is 2,
it will generate two completions for each of the three prompts, ending up with 6 completions in total.
- `stop`: One or more sequences after which the LLM should stop generating tokens.
- `presence_penalty`: What penalty to apply if a token is already present at all. Bigger values mean
the model will be less likely to repeat the same token in the text.
- `frequency_penalty`: What penalty to apply if a token has already been generated in the text.
Bigger values mean the model will be less likely to repeat the same token in the text.
- `logit_bias`: Add a logit bias to specific tokens. The keys of the dictionary are tokens, and the
values are the bias to add to that token.
- `response_format`: A JSON schema or a Pydantic model that enforces the structure of the model's response.
If provided, the output will always be validated against this
format (unless the model returns a tool call).
For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
Notes:
- This parameter accepts Pydantic models and JSON schemas for latest models starting from GPT-4o.
Older models only support basic version of structured outputs through `{"type": "json_object"}`.
For detailed information on JSON mode, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs#json-mode).
- For structured outputs with streaming,
the `response_format` must be a JSON schema and not a Pydantic model.
:param timeout:
Timeout for OpenAI client calls. If not set, it defaults to either the
`OPENAI_TIMEOUT` environment variable, or 30 seconds.
:param max_retries:
Maximum number of retries to contact OpenAI after an internal error.
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
:param tools:
A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
:param http_client_kwargs:
A dictionary of keyword arguments to configure a custom `httpx.Client`or `httpx.AsyncClient`.
For more information, see the [HTTPX documentation](https://www.python-httpx.org/api/#client).
"""
self.api_key = api_key
self.model = model
self.generation_kwargs = generation_kwargs or {}
self.streaming_callback = streaming_callback
self.api_base_url = api_base_url
self.organization = organization
self.timeout = timeout
self.max_retries = max_retries
self.tools = tools # Store tools as-is, whether it's a list or a Toolset
self.tools_strict = tools_strict
self.http_client_kwargs = http_client_kwargs
# Check for duplicate tool names
_check_duplicate_tool_names(flatten_tools_or_toolsets(self.tools))
self.client: OpenAI | None = None
self.async_client: AsyncOpenAI | None = None
self._tools_warmed_up = False
def _client_kwargs(self) -> dict[str, Any]:
timeout = self.timeout if self.timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
max_retries = (
self.max_retries if self.max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
)
return {
"api_key": self.api_key.resolve_value(),
"organization": self.organization,
"base_url": self.api_base_url,
"timeout": timeout,
"max_retries": max_retries,
}
def _warm_up_tools(self) -> None:
if not self._tools_warmed_up:
warm_up_tools(self.tools)
self._tools_warmed_up = True
def warm_up(self) -> None:
"""
Warm up the tools and initialize the synchronous OpenAI client.
"""
self._warm_up_tools()
if self.client is None:
self.client = OpenAI(
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
)
async def warm_up_async(self) -> None: # noqa: RUF029
"""
Warm up the tools and initialize the asynchronous OpenAI client on the serving event loop.
"""
self._warm_up_tools()
if self.async_client is None:
self.async_client = AsyncOpenAI(
http_client=init_http_client(self.http_client_kwargs, async_client=True), **self._client_kwargs()
)
def close(self) -> None:
"""
Releases the synchronous OpenAI client.
"""
if self.client is not None:
self.client.close()
self.client = None
async def close_async(self) -> None:
"""
Releases the asynchronous OpenAI client.
"""
if self.async_client is not None:
await self.async_client.close()
self.async_client = None
def _get_telemetry_data(self) -> dict[str, Any]:
"""
Data that is sent to Posthog for usage analytics.
"""
return {"model": self.model}
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
The serialized component as a dictionary.
"""
callback_name = serialize_callable(self.streaming_callback) if self.streaming_callback else None
generation_kwargs = self.generation_kwargs.copy()
response_format = generation_kwargs.get("response_format")
# If the response format is a Pydantic model, it's converted to openai's json schema format
# If it's already a json schema, it's left as is
if response_format and isinstance(response_format, type) and issubclass(response_format, BaseModel):
json_schema = {
"type": "json_schema",
"json_schema": {
"name": response_format.__name__,
"strict": True,
"schema": to_strict_json_schema(response_format),
},
}
generation_kwargs["response_format"] = json_schema
return default_to_dict(
self,
model=self.model,
streaming_callback=callback_name,
api_base_url=self.api_base_url,
organization=self.organization,
generation_kwargs=generation_kwargs,
api_key=self.api_key,
timeout=self.timeout,
max_retries=self.max_retries,
tools=serialize_tools_or_toolset(self.tools),
tools_strict=self.tools_strict,
http_client_kwargs=self.http_client_kwargs,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "OpenAIChatGenerator":
"""
Deserialize this component from a dictionary.
:param data: The dictionary representation of this component.
:returns:
The deserialized component instance.
"""
deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
init_params = data.get("init_parameters", {})
serialized_callback_handler = init_params.get("streaming_callback")
if serialized_callback_handler:
data["init_parameters"]["streaming_callback"] = deserialize_callable(serialized_callback_handler)
return default_from_dict(cls, data)
@component.output_types(replies=list[ChatMessage])
def run(
self,
messages: list[ChatMessage] | str,
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None,
*,
tools: ToolsType | None = None,
tools_strict: bool | None = None,
) -> dict[str, list[ChatMessage]]:
"""
Invokes chat completion based on the provided messages and generation parameters.
:param messages:
A list of ChatMessage instances representing the input messages. If a string is provided, it is converted
to a list containing a ChatMessage with user role.
:param streaming_callback:
A callback function that is called when a new token is received from the stream.
:param generation_kwargs:
Additional keyword arguments for text generation. These parameters will
override the parameters passed during component initialization.
For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/chat/create).
:param tools:
A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
If set, it will override the `tools` parameter provided during initialization.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
If set, it will override the `tools_strict` parameter set during component initialization.
:returns:
A dictionary with the following key:
- `replies`: A list containing the generated responses as ChatMessage instances.
"""
self.warm_up()
messages = _normalize_messages(messages)
if len(messages) == 0:
return {"replies": []}
streaming_callback = select_streaming_callback(
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=False
)
chat_completion: Stream[ChatCompletionChunk] | ChatCompletion | ParsedChatCompletion
api_args = self._prepare_api_call(
messages=messages,
streaming_callback=streaming_callback,
generation_kwargs=generation_kwargs,
tools=tools,
tools_strict=tools_strict,
)
openai_endpoint = api_args.pop("openai_endpoint")
assert self.client is not None # mypy: client is built by warm_up above
openai_endpoint_method = getattr(self.client.chat.completions, openai_endpoint)
chat_completion = openai_endpoint_method(**api_args)
if streaming_callback is not None:
completions = self._handle_stream_response(
# we cannot check isinstance(chat_completion, Stream) because some observability tools wrap Stream
# and return a different type. See https://github.com/deepset-ai/haystack/issues/9014.
chat_completion, # type: ignore
streaming_callback,
)
else:
assert isinstance(chat_completion, ChatCompletion), "Unexpected response type for non-streaming request."
completions = [
_convert_chat_completion_to_chat_message(chat_completion, choice) for choice in chat_completion.choices
]
# before returning, do post-processing of the completions
for message in completions:
_check_finish_reason(message.meta)
return {"replies": completions}
@component.output_types(replies=list[ChatMessage])
async def run_async(
self,
messages: list[ChatMessage] | str,
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None,
*,
tools: ToolsType | None = None,
tools_strict: bool | None = None,
) -> dict[str, list[ChatMessage]]:
"""
Asynchronously invokes chat completion based on the provided messages and generation parameters.
This is the asynchronous version of the `run` method. It has the same parameters and return values
but can be used with `await` in async code.
:param messages:
A list of ChatMessage instances representing the input messages. If a string is provided, it is converted
to a list containing a ChatMessage with user role.
:param streaming_callback:
A callback function that is called when a new token is received from the stream. Async callbacks are
preferred; a sync callback is accepted but will run synchronously on the event loop and may block it.
:param generation_kwargs:
Additional keyword arguments for text generation. These parameters will
override the parameters passed during component initialization.
For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/chat/create).
:param tools: A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
If set, it will override the `tools` parameter provided during initialization.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
If set, it will override the `tools_strict` parameter set during component initialization.
:returns:
A dictionary with the following key:
- `replies`: A list containing the generated responses as ChatMessage instances.
"""
await self.warm_up_async()
messages = _normalize_messages(messages)
# validate and select the streaming callback
streaming_callback = select_streaming_callback(
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=True
)
chat_completion: AsyncStream[ChatCompletionChunk] | ChatCompletion | ParsedChatCompletion
if len(messages) == 0:
return {"replies": []}
api_args = self._prepare_api_call(
messages=messages,
streaming_callback=streaming_callback,
generation_kwargs=generation_kwargs,
tools=tools,
tools_strict=tools_strict,
)
openai_endpoint = api_args.pop("openai_endpoint")
assert self.async_client is not None # mypy: async_client is built by warm_up_async above
openai_endpoint_method = getattr(self.async_client.chat.completions, openai_endpoint)
chat_completion = await openai_endpoint_method(**api_args)
if streaming_callback is not None:
completions = await self._handle_async_stream_response(
# we cannot check isinstance(chat_completion, AsyncStream) because some observability tools wrap
# AsyncStream and return a different type. See https://github.com/deepset-ai/haystack/issues/9014.
chat_completion, # type: ignore
streaming_callback,
)
else:
assert isinstance(chat_completion, ChatCompletion), "Unexpected response type for non-streaming request."
completions = [
_convert_chat_completion_to_chat_message(chat_completion, choice) for choice in chat_completion.choices
]
# before returning, do post-processing of the completions
for message in completions:
_check_finish_reason(message.meta)
return {"replies": completions}
def _prepare_api_call( # noqa: PLR0913
self,
*,
messages: list[ChatMessage],
streaming_callback: StreamingCallbackT | None = None,
generation_kwargs: dict[str, Any] | None = None,
tools: ToolsType | None = None,
tools_strict: bool | None = None,
) -> dict[str, Any]:
# update generation kwargs by merging with the generation kwargs passed to the run method
generation_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
is_streaming = streaming_callback is not None
num_responses = generation_kwargs.pop("n", 1)
if is_streaming and num_responses > 1:
raise ValueError("Cannot stream multiple responses, please set n=1.")
response_format = generation_kwargs.pop("response_format", None)
# adapt ChatMessage(s) to the format expected by the OpenAI API
openai_formatted_messages = [message.to_openai_dict_format() for message in messages]
flattened_tools = flatten_tools_or_toolsets(tools or self.tools)
tools_strict = tools_strict if tools_strict is not None else self.tools_strict
_check_duplicate_tool_names(flattened_tools)
openai_tools = {}
if flattened_tools:
tool_definitions = []
for t in flattened_tools:
function_spec = {**t.tool_spec}
if tools_strict:
function_spec["strict"] = True
function_spec["parameters"] = _make_schema_strict(function_spec["parameters"])
tool_definitions.append({"type": "function", "function": function_spec})
openai_tools = {"tools": tool_definitions}
base_args = {
"model": self.model,
"messages": openai_formatted_messages,
"n": num_responses,
**openai_tools,
**generation_kwargs,
}
if response_format and not is_streaming:
# for structured outputs without streaming, we use openai's parse endpoint
# Note: `stream` cannot be passed to chat.completions.parse
# we pass a key `openai_endpoint` as a hint to the run method to use the parse endpoint
# this key will be removed before the API call is made
return {**base_args, "response_format": response_format, "openai_endpoint": "parse"}
# for structured outputs with streaming, we use openai's create endpoint
# we pass a key `openai_endpoint` as a hint to the run method to use the create endpoint
# this key will be removed before the API call is made
final_args = {**base_args, "stream": is_streaming, "openai_endpoint": "create"}
# We only set the response_format parameter if it's not None since None is not a valid value in the API.
if response_format:
final_args["response_format"] = response_format
return final_args
def _handle_stream_response(self, chat_completion: Stream, callback: SyncStreamingCallbackT) -> list[ChatMessage]:
component_info = ComponentInfo.from_component(self)
chunks: list[StreamingChunk] = []
for chunk in chat_completion:
assert len(chunk.choices) <= 1, "Streaming responses should have at most one choice."
chunk_delta = _convert_chat_completion_chunk_to_streaming_chunk(
chunk=chunk, previous_chunks=chunks, component_info=component_info
)
chunks.append(chunk_delta)
callback(chunk_delta)
return [_convert_streaming_chunks_to_chat_message(chunks=chunks)]
async def _handle_async_stream_response(
self, chat_completion: AsyncStream, callback: StreamingCallbackT
) -> list[ChatMessage]:
component_info = ComponentInfo.from_component(self)
chunks: list[StreamingChunk] = []
try:
async for chunk in chat_completion:
assert len(chunk.choices) <= 1, "Streaming responses should have at most one choice."
chunk_delta = _convert_chat_completion_chunk_to_streaming_chunk(
chunk=chunk, previous_chunks=chunks, component_info=component_info
)
chunks.append(chunk_delta)
await _invoke_streaming_callback(callback, chunk_delta)
except asyncio.CancelledError:
await asyncio.shield(chat_completion.close())
# close the stream when task is cancelled
# asyncio.shield ensures the close operation completes
# https://docs.python.org/3/library/asyncio-task.html#shielding-from-cancellation
raise # Re-raise to propagate cancellation
return [_convert_streaming_chunks_to_chat_message(chunks=chunks)]
def _make_schema_strict(schema: dict[str, Any]) -> dict[str, Any]:
"""
Recursively transform a JSON schema to be OpenAI strict-mode compliant.
Sets `additionalProperties: false` on all objects and ensures every defined
property is listed in `required`. Walks into nested properties, `$defs`,
array `items`, and `anyOf`/`oneOf`/`allOf` combinators.
See https://platform.openai.com/docs/guides/structured-outputs#supported-schemas
"""
schema = {**schema}
schema_type = schema.get("type")
if schema_type == "object" or "properties" in schema:
schema["additionalProperties"] = False
if "properties" in schema:
schema["required"] = list(schema["properties"].keys())
schema["properties"] = {k: _make_schema_strict(v) for k, v in schema["properties"].items()}
if "items" in schema:
schema["items"] = _make_schema_strict(schema["items"])
if "$defs" in schema:
schema["$defs"] = {k: _make_schema_strict(v) for k, v in schema["$defs"].items()}
for combinator in ("anyOf", "oneOf", "allOf"):
if combinator in schema:
schema[combinator] = [_make_schema_strict(s) for s in schema[combinator]]
return schema
def _check_finish_reason(meta: dict[str, Any]) -> None:
if meta["finish_reason"] == "length":
logger.warning(
"The completion for index {index} has been truncated before reaching a natural stopping point. "
"Increase the max_completion_tokens parameter to allow for longer completions.",
index=meta["index"],
finish_reason=meta["finish_reason"],
)
if meta["finish_reason"] == "content_filter":
logger.warning(
"The completion for index {index} has been truncated due to the content filter.",
index=meta["index"],
finish_reason=meta["finish_reason"],
)
def _convert_chat_completion_to_chat_message(
completion: ChatCompletion | ParsedChatCompletion, choice: Choice
) -> ChatMessage:
"""
Converts the non-streaming response from the OpenAI API to a ChatMessage.
:param completion: The completion returned by the OpenAI API.
:param choice: The choice returned by the OpenAI API.
:return: The ChatMessage.
"""
message: ChatCompletionMessage | ParsedChatCompletionMessage = choice.message
text = message.content
tool_calls = []
if message.tool_calls:
# we currently only support function tools (not custom tools)
# https://platform.openai.com/docs/guides/function-calling#custom-tools
openai_tool_calls = [tc for tc in message.tool_calls if not isinstance(tc, ChatCompletionMessageCustomToolCall)]
for openai_tc in openai_tool_calls:
arguments_str = openai_tc.function.arguments
try:
arguments = json.loads(arguments_str)
tool_calls.append(ToolCall(id=openai_tc.id, tool_name=openai_tc.function.name, arguments=arguments))
except json.JSONDecodeError:
logger.warning(
"OpenAI returned a malformed JSON string for tool call arguments. This tool call "
"will be skipped. To always generate a valid JSON, set `tools_strict` to `True`. "
"Tool call ID: {_id}, Tool name: {_name}, Arguments: {_arguments}",
_id=openai_tc.id,
_name=openai_tc.function.name,
_arguments=arguments_str,
)
logprobs = _serialize_object(choice.logprobs) if choice.logprobs else None
meta = {
"model": completion.model,
"index": choice.index,
"finish_reason": choice.finish_reason,
"usage": _serialize_object(completion.usage),
}
if logprobs:
meta["logprobs"] = logprobs
return ChatMessage.from_assistant(text=text, tool_calls=tool_calls, meta=meta)
def _convert_chat_completion_chunk_to_streaming_chunk(
chunk: ChatCompletionChunk, previous_chunks: list[StreamingChunk], component_info: ComponentInfo | None = None
) -> StreamingChunk:
"""
Converts the streaming response chunk from the OpenAI API to a StreamingChunk.
:param chunk: The chunk returned by the OpenAI API.
:param previous_chunks: A list of previously received StreamingChunks.
:param component_info: An optional `ComponentInfo` object containing information about the component that
generated the chunk, such as the component name and type.
:returns:
A StreamingChunk object representing the content of the chunk from the OpenAI API.
"""
finish_reason_mapping: dict[str, FinishReason] = {
"stop": "stop",
"length": "length",
"content_filter": "content_filter",
"tool_calls": "tool_calls",
"function_call": "tool_calls",
}
# On very first chunk so len(previous_chunks) == 0, the Choices field only provides role info (e.g. "assistant")
# Choices is empty if include_usage is set to True where the usage information is returned.
if len(chunk.choices) == 0:
return StreamingChunk(
content="",
component_info=component_info,
# Index is None since it's only set to an int when a content block is present
index=None,
finish_reason=None,
meta={
"model": chunk.model,
"received_at": datetime.now().isoformat(),
"usage": _serialize_object(chunk.usage),
},
)
choice: ChunkChoice = chunk.choices[0]
# create a list of ToolCallDelta objects from the tool calls
if choice.delta and choice.delta.tool_calls:
tool_calls_deltas = []
for tool_call in choice.delta.tool_calls:
function = tool_call.function
tool_calls_deltas.append(
ToolCallDelta(
index=tool_call.index,
id=tool_call.id,
tool_name=function.name if function else None,
arguments=function.arguments if function and function.arguments else None,
)
)
return StreamingChunk(
content=choice.delta.content or "",
component_info=component_info,
# We adopt the first tool_calls_deltas.index as the overall index of the chunk.
index=tool_calls_deltas[0].index,
tool_calls=tool_calls_deltas,
start=tool_calls_deltas[0].tool_name is not None,
finish_reason=finish_reason_mapping.get(choice.finish_reason) if choice.finish_reason else None,
meta={
"model": chunk.model,
"index": choice.index,
"tool_calls": choice.delta.tool_calls,
"finish_reason": choice.finish_reason,
"received_at": datetime.now().isoformat(),
"usage": _serialize_object(chunk.usage),
},
)
# On very first chunk the choice field only provides role info (e.g. "assistant") so we set index to None
# We set all chunks missing the content field to index of None. E.g. can happen if chunk only contains finish
# reason.
if choice.delta and (choice.delta.content is None or choice.delta.role is not None):
resolved_index = None
else:
# We set the index to be 0 since if text content is being streamed then no tool calls are being streamed
# NOTE: We may need to revisit this if OpenAI allows planning/thinking content before tool calls like
# Anthropic Claude
resolved_index = 0
# Initialize meta dictionary
meta = {
"model": chunk.model,
"index": choice.index,
"tool_calls": choice.delta.tool_calls if choice.delta and choice.delta.tool_calls else None,
"finish_reason": choice.finish_reason,
"received_at": datetime.now().isoformat(),
"usage": _serialize_object(chunk.usage),
}
# check if logprobs are present
# logprobs are returned only for text content
logprobs = _serialize_object(choice.logprobs) if choice.logprobs else None
if logprobs:
meta["logprobs"] = logprobs
content = ""
if choice.delta and choice.delta.content:
content = choice.delta.content
return StreamingChunk(
content=content,
component_info=component_info,
index=resolved_index,
# The first chunk is always a start message chunk that only contains role information, so if we reach here
# and previous_chunks is length 1 then this is the start of text content.
start=len(previous_chunks) == 1,
finish_reason=finish_reason_mapping.get(choice.finish_reason) if choice.finish_reason else None,
meta=meta,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from .protocol import ChatGenerator
__all__ = ["ChatGenerator"]
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any, Protocol
from haystack.dataclasses import ChatMessage
class ChatGenerator(Protocol):
"""
Protocol for Chat Generators.
This protocol defines the minimal interface that Chat Generators must implement.
Chat Generators are components that process a list of `ChatMessage` objects as input and generate
responses using a Language Model. They return a dictionary.
"""
def run(self, messages: list[ChatMessage]) -> dict[str, Any]:
"""
Generate messages using the underlying Language Model.
Implementing classes may accept additional optional parameters in their run method.
For example: `def run (self, messages: list[ChatMessage], param_a="default", param_b="another_default")`.
:param messages:
A list of ChatMessage instances representing the input messages.
:returns:
A dictionary.
"""
...