mirror of
https://github.com/deepset-ai/haystack.git
synced 2026-10-12 02:12:50 +00:00
feat: add component lifecycle handling (#11675)
This commit is contained in:
@@ -877,3 +877,26 @@ store.write_documents(new_documents, policy=DuplicatePolicy.OVERWRITE)
|
||||
new_ids = {doc.id for doc in new_documents}
|
||||
store.delete_documents([doc.id for doc in old_documents if doc.id not in new_ids])
|
||||
```
|
||||
|
||||
### Components now resolve API keys at warm-up
|
||||
|
||||
**What changed:** Components that use external services now create their resources (such as API clients) during `warm_up()` instead of in `__init__`. As a consequence, a missing API key (for example, an unset environment variable behind a `Secret.from_env_var` default) is now reported at warm-up or first run rather than at construction. This affects OpenAI and Azure OpenAI components.
|
||||
|
||||
**Why:** Creating resources in `warm_up()` / `warm_up_async()` and releasing them in `close()` / `close_async()` gives components and pipelines a single, predictable resource lifecycle.
|
||||
|
||||
**How to migrate:** If you relied on construction failing for a missing API key, expect the same error at `warm_up()` (or the first `run`) instead.
|
||||
|
||||
Before (v2.x), with `OPENAI_API_KEY` unset:
|
||||
```python
|
||||
from haystack.components.embedders import OpenAITextEmbedder
|
||||
|
||||
embedder = OpenAITextEmbedder() # raised here
|
||||
```
|
||||
|
||||
After (v3.0), with `OPENAI_API_KEY` unset:
|
||||
```python
|
||||
from haystack.components.embedders import OpenAITextEmbedder
|
||||
|
||||
embedder = OpenAITextEmbedder() # no error at construction
|
||||
embedder.warm_up() # raised here
|
||||
```
|
||||
|
||||
@@ -487,7 +487,7 @@ class Agent:
|
||||
self.tool_concurrency_limit = tool_concurrency_limit
|
||||
self.tool_streaming_callback_passthrough = tool_streaming_callback_passthrough
|
||||
self._confirmation_strategies = confirmation_strategies or {}
|
||||
self._is_warmed_up = False
|
||||
self._tools_warmed_up = False
|
||||
|
||||
# --- State schema ---
|
||||
# shallow copy is sufficient: we only add a top-level "messages" key, never mutate nested values
|
||||
@@ -574,16 +574,38 @@ class Agent:
|
||||
else:
|
||||
component.set_input_type(self, name=var_name, type=Any, default=None)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the Agent.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self.chat_generator, "warm_up"):
|
||||
self.chat_generator.warm_up()
|
||||
def _warm_up_tools(self) -> None:
|
||||
"""Warm up the configured tools once."""
|
||||
if not self._tools_warmed_up:
|
||||
if self.tools:
|
||||
warm_up_tools(self.tools)
|
||||
self._is_warmed_up = True
|
||||
self._tools_warmed_up = True
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""Warm up the tools and the underlying chat generator."""
|
||||
self._warm_up_tools()
|
||||
if hasattr(self.chat_generator, "warm_up"):
|
||||
self.chat_generator.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""Warm up the tools and the underlying chat generator on the serving event loop."""
|
||||
self._warm_up_tools()
|
||||
if hasattr(self.chat_generator, "warm_up_async"):
|
||||
await self.chat_generator.warm_up_async()
|
||||
elif hasattr(self.chat_generator, "warm_up"):
|
||||
self.chat_generator.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the underlying chat generator's resources."""
|
||||
if hasattr(self.chat_generator, "close"):
|
||||
self.chat_generator.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""Release the underlying chat generator's async resources."""
|
||||
if hasattr(self.chat_generator, "close_async"):
|
||||
await self.chat_generator.close_async()
|
||||
elif hasattr(self.chat_generator, "close"):
|
||||
self.chat_generator.close()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -828,8 +850,7 @@ class Agent:
|
||||
- Any additional keys defined in the `state_schema`.
|
||||
"""
|
||||
agent_inputs = {"messages": messages, "streaming_callback": streaming_callback, **kwargs}
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
exe_context = self._initialize_fresh_execution(
|
||||
messages=messages,
|
||||
@@ -903,8 +924,7 @@ class Agent:
|
||||
- Any additional keys defined in the `state_schema`.
|
||||
"""
|
||||
agent_inputs = {"messages": messages, "streaming_callback": streaming_callback, **kwargs}
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
exe_context = self._initialize_fresh_execution(
|
||||
messages=messages,
|
||||
|
||||
@@ -137,32 +137,67 @@ class AzureOpenAIDocumentEmbedder(OpenAIDocumentEmbedder):
|
||||
self.progress_bar = progress_bar
|
||||
self.meta_fields_to_embed = meta_fields_to_embed or []
|
||||
self.embedding_separator = embedding_separator
|
||||
self.timeout = timeout if timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
self.max_retries = max_retries if max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
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
|
||||
self.raise_on_failure = raise_on_failure
|
||||
|
||||
client_args: dict[str, Any] = {
|
||||
"api_version": api_version,
|
||||
"azure_endpoint": azure_endpoint,
|
||||
"azure_deployment": azure_deployment,
|
||||
"azure_ad_token_provider": azure_ad_token_provider,
|
||||
"api_key": api_key.resolve_value() if api_key is not None else None,
|
||||
"azure_ad_token": azure_ad_token.resolve_value() if azure_ad_token is not None else None,
|
||||
"organization": organization,
|
||||
"timeout": self.timeout,
|
||||
"max_retries": self.max_retries,
|
||||
self.client: AzureOpenAI | None = None
|
||||
self.async_client: AsyncAzureOpenAI | None = None
|
||||
|
||||
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_version": self.api_version,
|
||||
"azure_endpoint": self.azure_endpoint,
|
||||
"azure_deployment": self.azure_deployment,
|
||||
"azure_ad_token_provider": self.azure_ad_token_provider,
|
||||
"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,
|
||||
}
|
||||
|
||||
self.client = AzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **client_args
|
||||
)
|
||||
self.async_client = AsyncAzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_args
|
||||
)
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous AzureOpenAI client.
|
||||
"""
|
||||
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
|
||||
"""
|
||||
Initializes the asynchronous AzureOpenAI client on the serving event loop.
|
||||
"""
|
||||
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 AzureOpenAI client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous AzureOpenAI client.
|
||||
"""
|
||||
if self.async_client is not None:
|
||||
await self.async_client.close()
|
||||
self.async_client = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -117,33 +117,68 @@ class AzureOpenAITextEmbedder(OpenAITextEmbedder):
|
||||
self.model = azure_deployment
|
||||
self.dimensions = dimensions
|
||||
self.organization = organization
|
||||
self.timeout = timeout if timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
self.max_retries = max_retries if max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.default_headers = default_headers or {}
|
||||
self.azure_ad_token_provider = azure_ad_token_provider
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"api_version": api_version,
|
||||
"azure_endpoint": azure_endpoint,
|
||||
"azure_deployment": azure_deployment,
|
||||
"azure_ad_token_provider": azure_ad_token_provider,
|
||||
"api_key": api_key.resolve_value() if api_key is not None else None,
|
||||
"azure_ad_token": azure_ad_token.resolve_value() if azure_ad_token is not None else None,
|
||||
"organization": organization,
|
||||
"timeout": self.timeout,
|
||||
"max_retries": self.max_retries,
|
||||
self.client: AzureOpenAI | None = None
|
||||
self.async_client: AsyncAzureOpenAI | None = None
|
||||
|
||||
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_version": self.api_version,
|
||||
"azure_endpoint": self.azure_endpoint,
|
||||
"azure_deployment": self.azure_deployment,
|
||||
"azure_ad_token_provider": self.azure_ad_token_provider,
|
||||
"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,
|
||||
}
|
||||
|
||||
self.client = AzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **client_kwargs
|
||||
)
|
||||
self.async_client = AsyncAzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_kwargs
|
||||
)
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous Azure OpenAI client.
|
||||
"""
|
||||
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
|
||||
"""
|
||||
Initializes the asynchronous Azure OpenAI client on the serving event loop.
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
|
||||
@@ -122,23 +122,55 @@ class OpenAIDocumentEmbedder:
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
self.raise_on_failure = raise_on_failure
|
||||
|
||||
if timeout is None:
|
||||
timeout = float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
if max_retries is None:
|
||||
max_retries = int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"api_key": api_key.resolve_value(),
|
||||
"organization": organization,
|
||||
"base_url": api_base_url,
|
||||
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,
|
||||
}
|
||||
|
||||
self.client = OpenAI(http_client=init_http_client(self.http_client_kwargs, async_client=False), **client_kwargs)
|
||||
self.async_client = AsyncOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_kwargs
|
||||
)
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous OpenAI client.
|
||||
"""
|
||||
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
|
||||
"""
|
||||
Initializes the asynchronous OpenAI client on the serving event loop.
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
@@ -218,6 +250,8 @@ class OpenAIDocumentEmbedder:
|
||||
args["dimensions"] = self.dimensions
|
||||
|
||||
try:
|
||||
# this method is invoked after warm_up, so client is not None
|
||||
assert self.client is not None
|
||||
response = self.client.embeddings.create(**args)
|
||||
except APIError as exc:
|
||||
ids = ", ".join(b[0] for b in batch)
|
||||
@@ -261,6 +295,8 @@ class OpenAIDocumentEmbedder:
|
||||
args["dimensions"] = self.dimensions
|
||||
|
||||
try:
|
||||
# this method is invoked after warm_up_async, so async_client is not None
|
||||
assert self.async_client is not None
|
||||
response = await self.async_client.embeddings.create(**args)
|
||||
except APIError as exc:
|
||||
ids = ", ".join(b[0] for b in batch)
|
||||
@@ -302,6 +338,8 @@ class OpenAIDocumentEmbedder:
|
||||
"In case you want to embed a string, please use the OpenAITextEmbedder."
|
||||
)
|
||||
|
||||
self.warm_up()
|
||||
|
||||
texts_to_embed = self._prepare_texts_to_embed(documents=documents)
|
||||
|
||||
doc_ids_to_embeddings, meta = self._embed_batch(texts_to_embed=texts_to_embed, batch_size=self.batch_size)
|
||||
@@ -334,6 +372,8 @@ class OpenAIDocumentEmbedder:
|
||||
"In case you want to embed a string, please use the OpenAITextEmbedder."
|
||||
)
|
||||
|
||||
await self.warm_up_async()
|
||||
|
||||
texts_to_embed = self._prepare_texts_to_embed(documents=documents)
|
||||
|
||||
doc_ids_to_embeddings, meta = await self._embed_batch_async(
|
||||
|
||||
@@ -97,23 +97,55 @@ class OpenAITextEmbedder:
|
||||
self.max_retries = max_retries
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
|
||||
if timeout is None:
|
||||
timeout = float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
if max_retries is None:
|
||||
max_retries = int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"api_key": api_key.resolve_value(),
|
||||
"organization": organization,
|
||||
"base_url": api_base_url,
|
||||
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,
|
||||
}
|
||||
|
||||
self.client = OpenAI(http_client=init_http_client(self.http_client_kwargs, async_client=False), **client_kwargs)
|
||||
self.async_client = AsyncOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_kwargs
|
||||
)
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous OpenAI client.
|
||||
"""
|
||||
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
|
||||
"""
|
||||
Initializes the asynchronous OpenAI client on the serving event loop.
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
@@ -184,7 +216,9 @@ class OpenAITextEmbedder:
|
||||
- `embedding`: The embedding of the input text.
|
||||
- `meta`: Information about the usage of the model.
|
||||
"""
|
||||
self.warm_up()
|
||||
create_kwargs = self._prepare_input(text=text)
|
||||
assert self.client is not None # mypy: client is built by warm_up above
|
||||
response = self.client.embeddings.create(**create_kwargs)
|
||||
return self._prepare_output(result=response)
|
||||
|
||||
@@ -204,6 +238,8 @@ class OpenAITextEmbedder:
|
||||
- `embedding`: The embedding of the input text.
|
||||
- `meta`: Information about the usage of the model.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
create_kwargs = self._prepare_input(text=text)
|
||||
assert self.async_client is not None # mypy: async_client is built by warm_up_async above
|
||||
response = await self.async_client.embeddings.create(**create_kwargs)
|
||||
return self._prepare_output(result=response)
|
||||
|
||||
@@ -111,16 +111,37 @@ class LLMEvaluator:
|
||||
generation_kwargs = {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
self._chat_generator = OpenAIChatGenerator(generation_kwargs=generation_kwargs)
|
||||
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the component by warming up the underlying chat generator.
|
||||
Warm up the underlying chat generator.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
self._is_warmed_up = True
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the underlying chat generator on the serving event loop.
|
||||
"""
|
||||
if hasattr(self._chat_generator, "warm_up_async"):
|
||||
await self._chat_generator.warm_up_async()
|
||||
elif hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's resources.
|
||||
"""
|
||||
if hasattr(self._chat_generator, "close"):
|
||||
self._chat_generator.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's async resources.
|
||||
"""
|
||||
if hasattr(self._chat_generator, "close_async"):
|
||||
await self._chat_generator.close_async()
|
||||
elif hasattr(self._chat_generator, "close"):
|
||||
self._chat_generator.close()
|
||||
|
||||
@staticmethod
|
||||
def validate_init_parameters(
|
||||
@@ -195,8 +216,7 @@ class LLMEvaluator:
|
||||
Only in the case that `raise_on_failure` is set to True and the received inputs are not lists or have
|
||||
different lengths, or if the output is not a valid JSON or doesn't contain the expected keys.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
self.validate_input_parameters(dict(self.inputs), inputs)
|
||||
|
||||
@@ -263,8 +283,7 @@ class LLMEvaluator:
|
||||
different lengths, or if the output is not a valid JSON or doesn't contain the expected keys.
|
||||
"""
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
self.validate_input_parameters(dict(self.inputs), inputs)
|
||||
|
||||
|
||||
@@ -169,16 +169,38 @@ class LLMDocumentContentExtractor:
|
||||
self._document_to_image_content = DocumentToImageContent(
|
||||
file_path_meta_field=file_path_meta_field, root_path=root_path, detail=detail, size=size
|
||||
)
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the ChatGenerator if it has a warm_up method.
|
||||
Warm up the underlying chat generator.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
self._is_warmed_up = True
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the underlying chat generator on the serving event loop.
|
||||
"""
|
||||
if hasattr(self._chat_generator, "warm_up_async"):
|
||||
await self._chat_generator.warm_up_async()
|
||||
elif hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's resources.
|
||||
"""
|
||||
if hasattr(self._chat_generator, "close"):
|
||||
self._chat_generator.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's async resources.
|
||||
"""
|
||||
if hasattr(self._chat_generator, "close_async"):
|
||||
await self._chat_generator.close_async()
|
||||
elif hasattr(self._chat_generator, "close"):
|
||||
self._chat_generator.close()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -340,8 +362,7 @@ class LLMDocumentContentExtractor:
|
||||
if not documents:
|
||||
return {"documents": [], "failed_documents": []}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
image_contents = self._document_to_image_content.run(documents=documents)["image_contents"]
|
||||
|
||||
@@ -376,8 +397,7 @@ class LLMDocumentContentExtractor:
|
||||
if not documents:
|
||||
return {"documents": [], "failed_documents": []}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
image_contents = self._document_to_image_content.run(documents=documents)["image_contents"]
|
||||
|
||||
|
||||
@@ -197,16 +197,42 @@ class LLMMetadataExtractor:
|
||||
self.expanded_range = expand_page_range(page_range) if page_range else None
|
||||
self.max_workers = max_workers
|
||||
self._chat_generator = chat_generator
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the LLM provider component.
|
||||
Warm up the underlying chat generator and splitter.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
self._is_warmed_up = True
|
||||
for inner in (self._chat_generator, self.splitter):
|
||||
if hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the underlying chat generator and splitter on the serving event loop.
|
||||
"""
|
||||
for inner in (self._chat_generator, self.splitter):
|
||||
if hasattr(inner, "warm_up_async"):
|
||||
await inner.warm_up_async()
|
||||
elif hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's and splitter's resources.
|
||||
"""
|
||||
for inner in (self._chat_generator, self.splitter):
|
||||
if hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's and splitter's async resources.
|
||||
"""
|
||||
for inner in (self._chat_generator, self.splitter):
|
||||
if hasattr(inner, "close_async"):
|
||||
await inner.close_async()
|
||||
elif hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -376,8 +402,7 @@ class LLMMetadataExtractor:
|
||||
logger.warning("No documents provided. Skipping metadata extraction.")
|
||||
return {"documents": [], "failed_documents": []}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
expanded_range = self.expanded_range
|
||||
if page_range:
|
||||
@@ -426,8 +451,7 @@ class LLMMetadataExtractor:
|
||||
logger.warning("No documents provided. Skipping metadata extraction.")
|
||||
return {"documents": [], "failed_documents": []}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
expanded_range = self.expanded_range
|
||||
if page_range:
|
||||
|
||||
@@ -149,26 +149,9 @@ class LinkContentFetcher:
|
||||
self.client_kwargs.setdefault("timeout", timeout)
|
||||
self.client_kwargs.setdefault("follow_redirects", True)
|
||||
|
||||
# Create httpx clients
|
||||
client_kwargs = {**self.client_kwargs}
|
||||
|
||||
# Optional HTTP/2 support
|
||||
if http2:
|
||||
try:
|
||||
h2_import.check()
|
||||
client_kwargs["http2"] = True
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"HTTP/2 support requested but 'h2' package is not installed. "
|
||||
"Falling back to HTTP/1.1. Install with `pip install httpx[http2]` to enable HTTP/2 support."
|
||||
)
|
||||
self.http2 = False # Update the setting to match actual capability
|
||||
|
||||
# Initialize synchronous client
|
||||
self._client = httpx.Client(**client_kwargs)
|
||||
|
||||
# Initialize asynchronous client
|
||||
self._async_client = httpx.AsyncClient(**client_kwargs)
|
||||
# httpx clients are built lazily in warm_up / warm_up_async (resource lifecycle)
|
||||
self._client: httpx.Client | None = None
|
||||
self._async_client: httpx.AsyncClient | None = None
|
||||
|
||||
# register default content handlers that extract data from the response
|
||||
self.handlers: dict[str, Callable[[httpx.Response], ByteStream]] = defaultdict(lambda: _text_content_handler)
|
||||
@@ -189,40 +172,76 @@ class LinkContentFetcher:
|
||||
after=self._switch_user_agent,
|
||||
)
|
||||
def get_response(url: str) -> httpx.Response:
|
||||
assert self._client is not None # mypy: client is built by warm_up before run
|
||||
response = self._client.get(url, headers=self._get_headers())
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
self._get_response: Callable = get_response
|
||||
|
||||
def _build_client_kwargs(self) -> dict[str, Any]:
|
||||
"""
|
||||
Build the keyword arguments used to construct the httpx clients.
|
||||
|
||||
Resolves optional HTTP/2 support, downgrading to HTTP/1.1 if the 'h2' package is not installed.
|
||||
"""
|
||||
client_kwargs = {**self.client_kwargs}
|
||||
|
||||
# Optional HTTP/2 support
|
||||
if self.http2:
|
||||
try:
|
||||
h2_import.check()
|
||||
client_kwargs["http2"] = True
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"HTTP/2 support requested but 'h2' package is not installed. "
|
||||
"Falling back to HTTP/1.1. Install with `pip install httpx[http2]` to enable HTTP/2 support."
|
||||
)
|
||||
self.http2 = False # Update the setting to match actual capability
|
||||
|
||||
return client_kwargs
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Initializes the synchronous httpx client.
|
||||
"""
|
||||
if self._client is None:
|
||||
self._client = httpx.Client(**self._build_client_kwargs())
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Initializes the asynchronous httpx client on the serving event loop.
|
||||
"""
|
||||
if self._async_client is None:
|
||||
self._async_client = httpx.AsyncClient(**self._build_client_kwargs())
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous httpx client.
|
||||
"""
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the asynchronous httpx client.
|
||||
"""
|
||||
if self._async_client is not None:
|
||||
await self._async_client.aclose()
|
||||
self._async_client = None
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""
|
||||
Build headers with precedence
|
||||
|
||||
client defaults -> component defaults -> user-provided -> rotating UA
|
||||
"""
|
||||
base = dict(self._client.headers)
|
||||
base = dict(self._client.headers) if self._client is not None else {}
|
||||
return _merge_headers(
|
||||
base, REQUEST_HEADERS, self.request_headers, {"User-Agent": self.user_agents[self.current_user_agent_idx]}
|
||||
)
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""
|
||||
Clean up resources when the component is deleted.
|
||||
|
||||
Closes both the synchronous and asynchronous HTTP clients to prevent
|
||||
resource leaks.
|
||||
"""
|
||||
try:
|
||||
# Close the synchronous client if it exists
|
||||
if hasattr(self, "_client"):
|
||||
self._client.close()
|
||||
|
||||
# There is no way to close the async client without await
|
||||
except Exception:
|
||||
# Suppress any exceptions during cleanup
|
||||
pass
|
||||
|
||||
@component.output_types(streams=list[ByteStream])
|
||||
def run(self, urls: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -241,6 +260,8 @@ class LinkContentFetcher:
|
||||
In all other scenarios, any retrieval errors are logged, and a list of successfully retrieved `ByteStream`
|
||||
objects is returned.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
streams: list[ByteStream] = []
|
||||
if not urls:
|
||||
return {"streams": streams}
|
||||
@@ -273,10 +294,13 @@ class LinkContentFetcher:
|
||||
:param urls: A list of URLs to fetch content from.
|
||||
:returns: `ByteStream` objects representing the extracted content.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
|
||||
streams: list[ByteStream] = []
|
||||
if not urls:
|
||||
return {"streams": streams}
|
||||
|
||||
assert self._async_client is not None # mypy: async_client is built by warm_up_async above
|
||||
# Create tasks for all URLs using _fetch_async directly
|
||||
tasks = [self._fetch_async(url, self._async_client) for url in urls]
|
||||
|
||||
|
||||
@@ -201,14 +201,13 @@ class AzureOpenAIChatGenerator(OpenAIChatGenerator):
|
||||
# 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` and `api_version` accept either a plain string or a `Secret`. We keep the original value
|
||||
# on the instance for serialization and resolve it to a string only when building the client.
|
||||
# `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.")
|
||||
resolved_api_version = api_version.resolve_value() if isinstance(api_version, Secret) else api_version
|
||||
|
||||
if api_key is None and azure_ad_token is None:
|
||||
raise ValueError("Please provide an API key or an Azure Active Directory token.")
|
||||
@@ -224,8 +223,8 @@ class AzureOpenAIChatGenerator(OpenAIChatGenerator):
|
||||
self.azure_deployment = azure_deployment
|
||||
self.organization = organization
|
||||
self.model = azure_deployment or "gpt-4.1-mini"
|
||||
self.timeout = timeout if timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
self.max_retries = max_retries if max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
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
|
||||
@@ -233,37 +232,74 @@ class AzureOpenAIChatGenerator(OpenAIChatGenerator):
|
||||
self.tools = tools
|
||||
self.tools_strict = tools_strict
|
||||
|
||||
client_args: dict[str, Any] = {
|
||||
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": azure_deployment,
|
||||
"api_key": api_key.resolve_value() if api_key is not None else None,
|
||||
"azure_ad_token": azure_ad_token.resolve_value() if azure_ad_token is not None else None,
|
||||
"organization": organization,
|
||||
"timeout": self.timeout,
|
||||
"max_retries": self.max_retries,
|
||||
"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": azure_ad_token_provider,
|
||||
"azure_ad_token_provider": self.azure_ad_token_provider,
|
||||
}
|
||||
|
||||
self.client = AzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **client_args
|
||||
)
|
||||
self.async_client = AsyncAzureOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_args
|
||||
)
|
||||
self._is_warmed_up = False
|
||||
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 Azure OpenAI chat generator.
|
||||
|
||||
This will warm up the tools registered in the chat generator.
|
||||
This method is idempotent and will only warm up the tools once.
|
||||
Warm up the tools and initialize the synchronous Azure OpenAI client.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
warm_up_tools(self.tools)
|
||||
self._is_warmed_up = True
|
||||
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]:
|
||||
"""
|
||||
|
||||
@@ -59,7 +59,6 @@ class FallbackChatGenerator:
|
||||
raise ValueError(msg)
|
||||
|
||||
self.chat_generators = list(chat_generators)
|
||||
self._is_warmed_up = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the component, including nested chat generators when they support serialization."""
|
||||
@@ -84,19 +83,32 @@ class FallbackChatGenerator:
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up all underlying chat generators.
|
||||
|
||||
This method calls warm_up() on each underlying generator that supports it.
|
||||
"""
|
||||
if self._is_warmed_up:
|
||||
return
|
||||
|
||||
"""Warm up all underlying chat generators."""
|
||||
for gen in self.chat_generators:
|
||||
if hasattr(gen, "warm_up") and callable(gen.warm_up):
|
||||
if hasattr(gen, "warm_up"):
|
||||
gen.warm_up()
|
||||
|
||||
self._is_warmed_up = True
|
||||
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,
|
||||
@@ -147,8 +159,7 @@ class FallbackChatGenerator:
|
||||
total_attempts, failed_chat_generators, plus any metadata from the successful generator.
|
||||
:raises RuntimeError: If all chat generators fail.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
@@ -205,8 +216,7 @@ class FallbackChatGenerator:
|
||||
total_attempts, failed_chat_generators, plus any metadata from the successful generator.
|
||||
:raises RuntimeError: If all chat generators fail.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
|
||||
@@ -209,35 +209,63 @@ class OpenAIChatGenerator:
|
||||
# Check for duplicate tool names
|
||||
_check_duplicate_tool_names(flatten_tools_or_toolsets(self.tools))
|
||||
|
||||
if timeout is None:
|
||||
timeout = float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
if max_retries is None:
|
||||
max_retries = int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
self._tools_warmed_up = False
|
||||
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"api_key": api_key.resolve_value(),
|
||||
"organization": organization,
|
||||
"base_url": api_base_url,
|
||||
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,
|
||||
}
|
||||
|
||||
self.client = OpenAI(http_client=init_http_client(self.http_client_kwargs, async_client=False), **client_kwargs)
|
||||
self.async_client = AsyncOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_kwargs
|
||||
)
|
||||
self._is_warmed_up = False
|
||||
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 OpenAI chat generator.
|
||||
|
||||
This will warm up the tools registered in the chat generator.
|
||||
This method is idempotent and will only warm up the tools once.
|
||||
Warm up the tools and initialize the synchronous OpenAI client.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
warm_up_tools(self.tools)
|
||||
self._is_warmed_up = True
|
||||
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]:
|
||||
"""
|
||||
@@ -335,8 +363,7 @@ class OpenAIChatGenerator:
|
||||
A dictionary with the following key:
|
||||
- `replies`: A list containing the generated responses as ChatMessage instances.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
@@ -356,6 +383,7 @@ class OpenAIChatGenerator:
|
||||
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)
|
||||
|
||||
@@ -416,8 +444,7 @@ class OpenAIChatGenerator:
|
||||
A dictionary with the following key:
|
||||
- `replies`: A list containing the generated responses as ChatMessage instances.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
@@ -439,6 +466,7 @@ class OpenAIChatGenerator:
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -195,40 +195,68 @@ class OpenAIResponsesChatGenerator:
|
||||
self.tools_strict = tools_strict
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
|
||||
if timeout is None:
|
||||
timeout = float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
if max_retries is None:
|
||||
max_retries = int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
self._tools_warmed_up = False
|
||||
|
||||
resolved_api_key = api_key.resolve_value() if isinstance(api_key, Secret) else api_key
|
||||
client_kwargs: dict[str, Any] = {
|
||||
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_api_key = self.api_key.resolve_value() if isinstance(self.api_key, Secret) else self.api_key
|
||||
return {
|
||||
"api_key": resolved_api_key,
|
||||
"organization": organization,
|
||||
"base_url": api_base_url,
|
||||
"organization": self.organization,
|
||||
"base_url": self.api_base_url,
|
||||
"timeout": timeout,
|
||||
"max_retries": max_retries,
|
||||
}
|
||||
|
||||
self.client = OpenAI(http_client=init_http_client(self.http_client_kwargs, async_client=False), **client_kwargs)
|
||||
self.async_client = AsyncOpenAI(
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True), **client_kwargs
|
||||
)
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the OpenAI responses chat generator.
|
||||
|
||||
This will warm up the tools registered in the chat generator.
|
||||
This method is idempotent and will only warm up the tools once.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
def _warm_up_tools(self) -> None:
|
||||
if not self._tools_warmed_up:
|
||||
is_openai_tool = isinstance(self.tools, list) and isinstance(self.tools[0], dict)
|
||||
# We only warm up Haystack tools, not OpenAI/MCP tools
|
||||
# The type ignore is needed because mypy cannot infer the type correctly
|
||||
if not is_openai_tool:
|
||||
warm_up_tools(self.tools) # type: ignore[arg-type]
|
||||
self._is_warmed_up = True
|
||||
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]:
|
||||
"""
|
||||
@@ -349,8 +377,7 @@ class OpenAIResponsesChatGenerator:
|
||||
A dictionary with the following key:
|
||||
- `replies`: A list containing the generated responses as ChatMessage instances.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
@@ -370,6 +397,7 @@ class OpenAIResponsesChatGenerator:
|
||||
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.responses, openai_endpoint)
|
||||
responses = openai_endpoint_method(**api_args)
|
||||
|
||||
@@ -423,8 +451,7 @@ class OpenAIResponsesChatGenerator:
|
||||
A dictionary with the following key:
|
||||
- `replies`: A list containing the generated responses as ChatMessage instances.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
messages = _normalize_messages(messages)
|
||||
|
||||
@@ -446,6 +473,7 @@ class OpenAIResponsesChatGenerator:
|
||||
)
|
||||
|
||||
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.responses, openai_endpoint)
|
||||
responses = await openai_endpoint_method(**api_args)
|
||||
|
||||
|
||||
@@ -80,36 +80,60 @@ class OpenAIImageGenerator:
|
||||
self.api_base_url = api_base_url
|
||||
self.organization = organization
|
||||
|
||||
self.timeout = timeout if timeout is not None else float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
|
||||
self.max_retries = max_retries if max_retries is not None else int(os.environ.get("OPENAI_MAX_RETRIES", "5"))
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.http_client_kwargs = http_client_kwargs
|
||||
|
||||
self.client: OpenAI | None = None
|
||||
self.async_client: AsyncOpenAI | None = None
|
||||
|
||||
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(self) -> None:
|
||||
"""
|
||||
Warm up the OpenAI client.
|
||||
Initializes the synchronous OpenAI client.
|
||||
"""
|
||||
if self.client is None:
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key.resolve_value(),
|
||||
organization=self.organization,
|
||||
base_url=self.api_base_url,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False),
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=False), **self._client_kwargs()
|
||||
)
|
||||
|
||||
async def warm_up_async(self) -> None: # noqa: RUF029
|
||||
"""
|
||||
Initializes the asynchronous OpenAI client on the serving event loop.
|
||||
"""
|
||||
if self.async_client is None:
|
||||
self.async_client = AsyncOpenAI(
|
||||
api_key=self.api_key.resolve_value(),
|
||||
organization=self.organization,
|
||||
base_url=self.api_base_url,
|
||||
timeout=self.timeout,
|
||||
max_retries=self.max_retries,
|
||||
http_client=init_http_client(self.http_client_kwargs, async_client=True),
|
||||
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
|
||||
|
||||
@component.output_types(images=list[str], revised_prompt=str)
|
||||
def run(
|
||||
self,
|
||||
@@ -131,8 +155,7 @@ class OpenAIImageGenerator:
|
||||
The revised prompt is the prompt that was used to generate the image, if there was any revision
|
||||
to the prompt made by OpenAI.
|
||||
"""
|
||||
if self.client is None:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
# at this point the client is initialized, but mypy doesn't know that
|
||||
assert self.client is not None
|
||||
@@ -173,8 +196,7 @@ class OpenAIImageGenerator:
|
||||
The revised prompt is the prompt that was used to generate the image, if there was any revision
|
||||
to the prompt made by OpenAI.
|
||||
"""
|
||||
if self.async_client is None:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
# at this point the client is initialized, but mypy doesn't know that
|
||||
assert self.async_client is not None
|
||||
|
||||
@@ -118,21 +118,55 @@ class EmbeddingBasedDocumentSplitter:
|
||||
self.use_split_rules = use_split_rules
|
||||
self.extend_abbreviations = extend_abbreviations
|
||||
self.sentence_splitter: SentenceSplitter | None = None
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the component by initializing the sentence splitter.
|
||||
Warm up the component by initializing the sentence splitter and the document embedder.
|
||||
"""
|
||||
self.sentence_splitter = SentenceSplitter(
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
keep_white_spaces=True,
|
||||
)
|
||||
if self.sentence_splitter is None:
|
||||
self.sentence_splitter = SentenceSplitter(
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
keep_white_spaces=True,
|
||||
)
|
||||
if hasattr(self.document_embedder, "warm_up"):
|
||||
self.document_embedder.warm_up()
|
||||
self._is_warmed_up = True
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the component on the serving event loop.
|
||||
|
||||
Initializes the sentence splitter and warms up the document embedder using its async warm-up path when
|
||||
available, falling back to the synchronous one otherwise.
|
||||
"""
|
||||
if self.sentence_splitter is None:
|
||||
self.sentence_splitter = SentenceSplitter(
|
||||
language=self.language,
|
||||
use_split_rules=self.use_split_rules,
|
||||
extend_abbreviations=self.extend_abbreviations,
|
||||
keep_white_spaces=True,
|
||||
)
|
||||
if hasattr(self.document_embedder, "warm_up_async"):
|
||||
await self.document_embedder.warm_up_async()
|
||||
elif hasattr(self.document_embedder, "warm_up"):
|
||||
self.document_embedder.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the document embedder's resources.
|
||||
"""
|
||||
if hasattr(self.document_embedder, "close"):
|
||||
self.document_embedder.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the document embedder's async resources.
|
||||
"""
|
||||
if hasattr(self.document_embedder, "close_async"):
|
||||
await self.document_embedder.close_async()
|
||||
elif hasattr(self.document_embedder, "close"):
|
||||
self.document_embedder.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
|
||||
@@ -151,8 +185,7 @@ class EmbeddingBasedDocumentSplitter:
|
||||
:raises TypeError: If the input is not a list of Documents.
|
||||
:raises ValueError: If the document content is None or empty.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)):
|
||||
raise TypeError("EmbeddingBasedDocumentSplitter expects a List of Documents as input.")
|
||||
@@ -192,8 +225,7 @@ class EmbeddingBasedDocumentSplitter:
|
||||
:raises TypeError: If the input is not a list of Documents.
|
||||
:raises ValueError: If the document content is None or empty.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
if not isinstance(documents, list) or (documents and not isinstance(documents[0], Document)):
|
||||
raise TypeError("EmbeddingBasedDocumentSplitter expects a List of Documents as input.")
|
||||
|
||||
@@ -99,6 +99,8 @@ class RecursiveDocumentSplitter:
|
||||
"""
|
||||
Warm up the sentence tokenizer and tiktoken tokenizer if needed.
|
||||
"""
|
||||
if self._is_warmed_up:
|
||||
return
|
||||
if "sentence" in self.separators:
|
||||
self.nltk_tokenizer = self._get_custom_sentence_tokenizer(self.sentence_splitter_params)
|
||||
if self.split_units == "token":
|
||||
|
||||
@@ -134,7 +134,6 @@ class QueryExpander:
|
||||
else:
|
||||
self.chat_generator = chat_generator
|
||||
|
||||
self._is_warmed_up = False
|
||||
self.prompt_template = prompt_template or DEFAULT_PROMPT_TEMPLATE
|
||||
|
||||
# Check if required variables are present in the template
|
||||
@@ -196,8 +195,7 @@ class QueryExpander:
|
||||
:raises ValueError: If n_expansions is not positive (less than or equal to 0).
|
||||
"""
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
response = {"queries": [query] if self.include_original_query else []}
|
||||
|
||||
@@ -264,8 +262,7 @@ class QueryExpander:
|
||||
:raises ValueError: If n_expansions is not positive (less than or equal to 0).
|
||||
"""
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
response = {"queries": [query] if self.include_original_query else []}
|
||||
|
||||
@@ -316,12 +313,35 @@ class QueryExpander:
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the LLM provider component.
|
||||
Warm up the underlying chat generator.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self.chat_generator, "warm_up"):
|
||||
self.chat_generator.warm_up()
|
||||
self._is_warmed_up = True
|
||||
if hasattr(self.chat_generator, "warm_up"):
|
||||
self.chat_generator.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the underlying chat generator on the serving event loop.
|
||||
"""
|
||||
if hasattr(self.chat_generator, "warm_up_async"):
|
||||
await self.chat_generator.warm_up_async()
|
||||
elif hasattr(self.chat_generator, "warm_up"):
|
||||
self.chat_generator.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's resources.
|
||||
"""
|
||||
if hasattr(self.chat_generator, "close"):
|
||||
self.chat_generator.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the underlying chat generator's async resources.
|
||||
"""
|
||||
if hasattr(self.chat_generator, "close_async"):
|
||||
await self.chat_generator.close_async()
|
||||
elif hasattr(self.chat_generator, "close"):
|
||||
self.chat_generator.close()
|
||||
|
||||
@staticmethod
|
||||
def _parse_expanded_queries(generator_response: str) -> list[str]:
|
||||
|
||||
@@ -171,16 +171,30 @@ class LLMRanker:
|
||||
self._chat_generator = _default_openai_chat_generator()
|
||||
else:
|
||||
self._chat_generator = chat_generator
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the underlying chat generator.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
self._is_warmed_up = True
|
||||
"""Warm up the underlying chat generator."""
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""Warm up the underlying chat generator on the serving event loop."""
|
||||
if hasattr(self._chat_generator, "warm_up_async"):
|
||||
await self._chat_generator.warm_up_async()
|
||||
elif hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the underlying chat generator's resources."""
|
||||
if hasattr(self._chat_generator, "close"):
|
||||
self._chat_generator.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""Release the underlying chat generator's async resources."""
|
||||
if hasattr(self._chat_generator, "close_async"):
|
||||
await self._chat_generator.close_async()
|
||||
elif hasattr(self._chat_generator, "close"):
|
||||
self._chat_generator.close()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -242,8 +256,7 @@ class LLMRanker:
|
||||
logger.warning("Empty query provided to LLMRanker. Returning documents without reranking.")
|
||||
return {"documents": fallback_documents}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
prompt = self._prompt_builder.run(query=query.strip(), documents=deduplicated_documents)
|
||||
|
||||
@@ -307,8 +320,7 @@ class LLMRanker:
|
||||
logger.warning("Empty query provided to LLMRanker. Returning documents without reranking.")
|
||||
return {"documents": fallback_documents}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
prompt = self._prompt_builder.run(query=query.strip(), documents=deduplicated_documents)
|
||||
|
||||
|
||||
@@ -85,18 +85,42 @@ class MultiQueryEmbeddingRetriever:
|
||||
self.retriever = retriever
|
||||
self.query_embedder = query_embedder
|
||||
self.max_workers = max_workers
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the query embedder and the retriever if any has a warm_up method.
|
||||
Warm up the query embedder and the retriever.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self.query_embedder, "warm_up") and callable(self.query_embedder.warm_up):
|
||||
self.query_embedder.warm_up()
|
||||
if hasattr(self.retriever, "warm_up") and callable(self.retriever.warm_up):
|
||||
self.retriever.warm_up()
|
||||
self._is_warmed_up = True
|
||||
for inner in (self.query_embedder, self.retriever):
|
||||
if hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the query embedder and the retriever on the serving event loop.
|
||||
"""
|
||||
for inner in (self.query_embedder, self.retriever):
|
||||
if hasattr(inner, "warm_up_async"):
|
||||
await inner.warm_up_async()
|
||||
elif hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the query embedder's and the retriever's resources.
|
||||
"""
|
||||
for inner in (self.query_embedder, self.retriever):
|
||||
if hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the query embedder's and the retriever's async resources.
|
||||
"""
|
||||
for inner in (self.query_embedder, self.retriever):
|
||||
if hasattr(inner, "close_async"):
|
||||
await inner.close_async()
|
||||
elif hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, queries: list[str], retriever_kwargs: dict[str, Any] | None = None) -> dict[str, list[Document]]:
|
||||
@@ -112,8 +136,7 @@ class MultiQueryEmbeddingRetriever:
|
||||
docs: list[Document] = []
|
||||
retriever_kwargs = retriever_kwargs or {}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
queries_results = executor.map(lambda query: self._run_on_thread(query, retriever_kwargs), queries)
|
||||
@@ -145,8 +168,7 @@ class MultiQueryEmbeddingRetriever:
|
||||
"""
|
||||
retriever_kwargs = retriever_kwargs or {}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
results = await asyncio.gather(*[self._run_one_async(q, retriever_kwargs) for q in queries])
|
||||
docs: list[Document] = [doc for result in results if result for doc in result]
|
||||
|
||||
@@ -67,16 +67,38 @@ class MultiQueryTextRetriever:
|
||||
"""
|
||||
self.retriever = retriever
|
||||
self.max_workers = max_workers
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the retriever if it has a warm_up method.
|
||||
Warm up the retriever.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self.retriever, "warm_up") and callable(self.retriever.warm_up):
|
||||
self.retriever.warm_up()
|
||||
self._is_warmed_up = True
|
||||
if hasattr(self.retriever, "warm_up"):
|
||||
self.retriever.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the retriever on the serving event loop.
|
||||
"""
|
||||
if hasattr(self.retriever, "warm_up_async"):
|
||||
await self.retriever.warm_up_async()
|
||||
elif hasattr(self.retriever, "warm_up"):
|
||||
self.retriever.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the retriever's resources.
|
||||
"""
|
||||
if hasattr(self.retriever, "close"):
|
||||
self.retriever.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the retriever's async resources.
|
||||
"""
|
||||
if hasattr(self.retriever, "close_async"):
|
||||
await self.retriever.close_async()
|
||||
elif hasattr(self.retriever, "close"):
|
||||
self.retriever.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, queries: list[str], retriever_kwargs: dict[str, Any] | None = None) -> dict[str, list[Document]]:
|
||||
@@ -92,8 +114,7 @@ class MultiQueryTextRetriever:
|
||||
docs: list[Document] = []
|
||||
retriever_kwargs = retriever_kwargs or {}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
queries_results = executor.map(lambda query: self._run_on_thread(query, retriever_kwargs), queries)
|
||||
@@ -125,8 +146,7 @@ class MultiQueryTextRetriever:
|
||||
"""
|
||||
retriever_kwargs = retriever_kwargs or {}
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
results = await asyncio.gather(*[self._run_one_async(q, retriever_kwargs) for q in queries])
|
||||
docs: list[Document] = [doc for result in results if result for doc in result]
|
||||
|
||||
@@ -108,7 +108,6 @@ class MultiRetriever:
|
||||
self.top_k = top_k
|
||||
self.max_workers = max_workers
|
||||
self.join_mode = join_mode
|
||||
self._is_warmed_up = False
|
||||
|
||||
def _merge_results(self, document_lists: list[list[Document]]) -> list[Document]:
|
||||
"""
|
||||
@@ -146,14 +145,39 @@ class MultiRetriever:
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the retrievers if any has a warm_up method.
|
||||
Warm up the retrievers.
|
||||
"""
|
||||
if self._is_warmed_up:
|
||||
return
|
||||
for retriever in self.retrievers.values():
|
||||
if hasattr(retriever, "warm_up") and callable(retriever.warm_up):
|
||||
if hasattr(retriever, "warm_up"):
|
||||
retriever.warm_up()
|
||||
self._is_warmed_up = True
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the retrievers on the serving event loop.
|
||||
"""
|
||||
for retriever in self.retrievers.values():
|
||||
if hasattr(retriever, "warm_up_async"):
|
||||
await retriever.warm_up_async()
|
||||
elif hasattr(retriever, "warm_up"):
|
||||
retriever.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the retrievers' resources.
|
||||
"""
|
||||
for retriever in self.retrievers.values():
|
||||
if hasattr(retriever, "close"):
|
||||
retriever.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the retrievers' async resources.
|
||||
"""
|
||||
for retriever in self.retrievers.values():
|
||||
if hasattr(retriever, "close_async"):
|
||||
await retriever.close_async()
|
||||
elif hasattr(retriever, "close"):
|
||||
retriever.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
@@ -183,8 +207,7 @@ class MultiRetriever:
|
||||
:raises ValueError:
|
||||
If any name in `active_retrievers` does not match a retriever name.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
resolved_top_k = top_k if top_k is not None else self.top_k
|
||||
resolved_filters = filters if filters is not None else self.filters
|
||||
@@ -237,8 +260,7 @@ class MultiRetriever:
|
||||
:raises ValueError:
|
||||
If any name in `active_retrievers` does not match a retriever name.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
resolved_top_k = top_k if top_k is not None else self.top_k
|
||||
resolved_filters = filters if filters is not None else self.filters
|
||||
|
||||
@@ -67,18 +67,42 @@ class TextEmbeddingRetriever:
|
||||
"""
|
||||
self.retriever = retriever
|
||||
self.text_embedder = text_embedder
|
||||
self._is_warmed_up = False
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the text embedder and the retriever if any has a warm_up method.
|
||||
Warm up the text embedder and the retriever.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self.text_embedder, "warm_up") and callable(self.text_embedder.warm_up):
|
||||
self.text_embedder.warm_up()
|
||||
if hasattr(self.retriever, "warm_up") and callable(self.retriever.warm_up):
|
||||
self.retriever.warm_up()
|
||||
self._is_warmed_up = True
|
||||
for inner in (self.text_embedder, self.retriever):
|
||||
if hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the text embedder and the retriever on the serving event loop.
|
||||
"""
|
||||
for inner in (self.text_embedder, self.retriever):
|
||||
if hasattr(inner, "warm_up_async"):
|
||||
await inner.warm_up_async()
|
||||
elif hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the text embedder's and the retriever's resources.
|
||||
"""
|
||||
for inner in (self.text_embedder, self.retriever):
|
||||
if hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the text embedder's and the retriever's async resources.
|
||||
"""
|
||||
for inner in (self.text_embedder, self.retriever):
|
||||
if hasattr(inner, "close_async"):
|
||||
await inner.close_async()
|
||||
elif hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
@@ -94,8 +118,7 @@ class TextEmbeddingRetriever:
|
||||
A dictionary containing:
|
||||
- `documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
embedding_result = self.text_embedder.run(text=query)
|
||||
result = self.retriever.run(query_embedding=embedding_result["embedding"], filters=filters, top_k=top_k)
|
||||
@@ -122,8 +145,7 @@ class TextEmbeddingRetriever:
|
||||
A dictionary containing:
|
||||
- `documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
embedding_result = await _execute_component_async(self.text_embedder, text=query)
|
||||
result = await _execute_component_async(
|
||||
|
||||
@@ -84,20 +84,34 @@ class LLMMessagesRouter:
|
||||
self._output_patterns = output_patterns
|
||||
|
||||
self._compiled_patterns = [re.compile(pattern) for pattern in output_patterns]
|
||||
self._is_warmed_up = False
|
||||
|
||||
component.set_output_types(
|
||||
self, **{"chat_generator_text": str, **dict.fromkeys(output_names + ["unmatched"], list[ChatMessage])}
|
||||
)
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the underlying LLM.
|
||||
"""
|
||||
if not self._is_warmed_up:
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
self._is_warmed_up = True
|
||||
"""Warm up the underlying chat generator."""
|
||||
if hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""Warm up the underlying chat generator on the serving event loop."""
|
||||
if hasattr(self._chat_generator, "warm_up_async"):
|
||||
await self._chat_generator.warm_up_async()
|
||||
elif hasattr(self._chat_generator, "warm_up"):
|
||||
self._chat_generator.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the underlying chat generator's resources."""
|
||||
if hasattr(self._chat_generator, "close"):
|
||||
self._chat_generator.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""Release the underlying chat generator's async resources."""
|
||||
if hasattr(self._chat_generator, "close_async"):
|
||||
await self._chat_generator.close_async()
|
||||
elif hasattr(self._chat_generator, "close"):
|
||||
self._chat_generator.close()
|
||||
|
||||
def run(self, messages: list[ChatMessage]) -> dict[str, str | list[ChatMessage]]:
|
||||
"""
|
||||
@@ -121,8 +135,7 @@ class LLMMessagesRouter:
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
self.warm_up()
|
||||
|
||||
messages_for_inference = []
|
||||
if self._system_prompt:
|
||||
@@ -168,8 +181,7 @@ class LLMMessagesRouter:
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if not self._is_warmed_up:
|
||||
self.warm_up()
|
||||
await self.warm_up_async()
|
||||
|
||||
messages_for_inference = []
|
||||
if self._system_prompt:
|
||||
|
||||
@@ -916,15 +916,60 @@ class PipelineBase: # noqa: PLW1641
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Make sure all nodes are warm.
|
||||
Make sure all components are warm.
|
||||
|
||||
It's the node's responsibility to make sure this method can be called at every `Pipeline.run()`
|
||||
It's the component's responsibility to make sure this method can be called at every `Pipeline.run()`
|
||||
without re-initializing everything.
|
||||
"""
|
||||
for node in self.graph.nodes:
|
||||
if hasattr(self.graph.nodes[node]["instance"], "warm_up"):
|
||||
logger.info("Warming up component {node}...", node=node)
|
||||
self.graph.nodes[node]["instance"].warm_up()
|
||||
for component_name in self.graph.nodes:
|
||||
if hasattr(self.graph.nodes[component_name]["instance"], "warm_up"):
|
||||
logger.info("Warming up component {component_name}...", component_name=component_name)
|
||||
self.graph.nodes[component_name]["instance"].warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Make sure all components are warm, using the async warm-up path where available.
|
||||
|
||||
Each component is warmed up with `warm_up_async` if it has one, otherwise with its sync `warm_up`.
|
||||
Both run on the event loop, never offloaded to a worker thread.
|
||||
This ensures that if an async client is created during `warm-up` (residual scenario), it binds to the loop that
|
||||
`run_async` will use.
|
||||
"""
|
||||
for component_name in self.graph.nodes:
|
||||
instance = self.graph.nodes[component_name]["instance"]
|
||||
if hasattr(instance, "warm_up_async"):
|
||||
logger.info("Warming up component {component_name}...", component_name=component_name)
|
||||
await instance.warm_up_async()
|
||||
elif hasattr(instance, "warm_up"):
|
||||
logger.info("Warming up component {component_name}...", component_name=component_name)
|
||||
instance.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release resources held by the pipeline's components by calling each component's `close` method.
|
||||
|
||||
Only the synchronous side of each component is released here; use `close_async` to release async clients.
|
||||
"""
|
||||
for component_name in self.graph.nodes:
|
||||
instance = self.graph.nodes[component_name]["instance"]
|
||||
if hasattr(instance, "close"):
|
||||
logger.info("Closing component {component_name}...", component_name=component_name)
|
||||
instance.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release resources held by the pipeline's components, using the async close path where available.
|
||||
|
||||
Each component is closed with `close_async` if it has one, otherwise with its sync `close`.
|
||||
"""
|
||||
for component_name in self.graph.nodes:
|
||||
instance = self.graph.nodes[component_name]["instance"]
|
||||
if hasattr(instance, "close_async"):
|
||||
logger.info("Closing component {component_name}...", component_name=component_name)
|
||||
await instance.close_async()
|
||||
elif hasattr(instance, "close"):
|
||||
logger.info("Closing component {component_name}...", component_name=component_name)
|
||||
instance.close()
|
||||
|
||||
@staticmethod
|
||||
def _create_component_span(
|
||||
|
||||
@@ -861,8 +861,8 @@ class Pipeline(PipelineBase):
|
||||
|
||||
pipeline_running(self) # telemetry
|
||||
|
||||
# warm up the pipeline by running each component's warm_up method
|
||||
self.warm_up()
|
||||
# warm up the pipeline by running each component's warm_up_async (or warm_up) method
|
||||
await self.warm_up_async()
|
||||
|
||||
if include_outputs_from is None:
|
||||
include_outputs_from = set()
|
||||
|
||||
@@ -67,7 +67,6 @@ class _SuperComponent:
|
||||
raise ValueError("Pipeline must be provided to SuperComponent.")
|
||||
|
||||
self.pipeline: Pipeline = pipeline
|
||||
self._warmed_up = False
|
||||
|
||||
# Determine input types based on pipeline and mapping
|
||||
pipeline_inputs = self.pipeline.inputs()
|
||||
@@ -101,9 +100,25 @@ class _SuperComponent:
|
||||
"""
|
||||
Warms up the SuperComponent by warming up the wrapped pipeline.
|
||||
"""
|
||||
if not self._warmed_up:
|
||||
self.pipeline.warm_up()
|
||||
self._warmed_up = True
|
||||
self.pipeline.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warms up the SuperComponent by warming up the wrapped pipeline on the serving event loop.
|
||||
"""
|
||||
await self.pipeline.warm_up_async()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Releases the synchronous resources held by the wrapped pipeline's components.
|
||||
"""
|
||||
self.pipeline.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Releases the async resources held by the wrapped pipeline's components.
|
||||
"""
|
||||
await self.pipeline.close_async()
|
||||
|
||||
def run(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Components and pipelines now have a consistent resource lifecycle for acquiring and releasing resources such as
|
||||
HTTP clients and connections. In addition to the existing ``warm_up``, components can now implement ``warm_up_async``
|
||||
to acquire asynchronous resources, and ``close``/``close_async`` to release them.
|
||||
``Pipeline`` now implements the same lifecycle methods. In addition to the existing ``warm_up``, ``warm_up_async()``
|
||||
runs async warm-up on the serving event loop (falling back to the synchronous ``warm_up``), while
|
||||
``close()``/``close_async()`` release synchronous and asynchronous resources respectively.
|
||||
Closing is always explicit; pipelines are never closed automatically after a run.
|
||||
upgrade:
|
||||
- |
|
||||
Components in Haystack that use external resources now consistently create those resources during warm-up rather
|
||||
than in ``__init__``. As a result, some components that previously raised errors during initialization (for example,
|
||||
because of a missing API key) now raise them during ``warm_up`` instead.
|
||||
Similar changes will be rolled out to components in Haystack Core Integrations in the future.
|
||||
@@ -970,15 +970,6 @@ class TestAgent:
|
||||
run_tool_mock.assert_called_once()
|
||||
assert run_tool_mock.call_args.kwargs["tools"] == [weather_tool]
|
||||
|
||||
def test_run_not_warmed_up(self, weather_tool):
|
||||
"""Warmup is run automatically on first run"""
|
||||
chat_generator = MockChatGeneratorWithoutRunAsync()
|
||||
chat_generator.warm_up = MagicMock()
|
||||
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], system_prompt="This is a system prompt.")
|
||||
agent.run([ChatMessage.from_user("What is the weather in Berlin?")])
|
||||
assert agent._is_warmed_up is True
|
||||
assert chat_generator.warm_up.call_count == 1
|
||||
|
||||
def test_run_no_messages(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
|
||||
chat_generator = OpenAIChatGenerator()
|
||||
@@ -1917,7 +1908,6 @@ class TestAgentUserPromptInPipeline:
|
||||
|
||||
@pytest.fixture
|
||||
def make_rag_pipeline(self, document_store_with_docs: InMemoryDocumentStore, make_agent):
|
||||
|
||||
def _factory(user_prompt: str | None = None):
|
||||
agent = make_agent(
|
||||
user_prompt=user_prompt
|
||||
@@ -2023,7 +2013,6 @@ class TestAgentWaitsForBlockedPredecessor:
|
||||
"""
|
||||
|
||||
def test_agent_waits_for_messages_when_predecessor_is_blocked(self, weather_tool):
|
||||
|
||||
@component
|
||||
class HistoryParser:
|
||||
@component.output_types(messages=list[ChatMessage])
|
||||
@@ -2328,6 +2317,70 @@ class TestAgentWarmUp:
|
||||
assert per_run_tool.was_warmed_up
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_chat_generator(self, weather_tool):
|
||||
chat_generator = MockChatGenerator()
|
||||
chat_generator.warm_up = MagicMock()
|
||||
agent = Agent(chat_generator=chat_generator, tools=[weather_tool], system_prompt="This is a system prompt.")
|
||||
|
||||
agent.warm_up()
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
chat_generator.warm_up.reset_mock()
|
||||
agent.run([ChatMessage.from_user("What is the weather in Berlin?")])
|
||||
assert agent._tools_warmed_up is True
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warm_up_async_delegates_to_chat_generator(self):
|
||||
chat_generator = MockChatGenerator()
|
||||
chat_generator.warm_up_async = AsyncMock()
|
||||
chat_generator.warm_up = MagicMock()
|
||||
agent = Agent(chat_generator=chat_generator, tools=[])
|
||||
await agent.warm_up_async()
|
||||
chat_generator.warm_up_async.assert_awaited_once()
|
||||
chat_generator.warm_up.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
chat_generator = MockChatGeneratorWithoutRunAsync()
|
||||
chat_generator.warm_up = MagicMock()
|
||||
agent = Agent(chat_generator=chat_generator, tools=[])
|
||||
await agent.warm_up_async()
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_chat_generator(self):
|
||||
chat_generator = MockChatGenerator()
|
||||
chat_generator.close = MagicMock()
|
||||
agent = Agent(chat_generator=chat_generator, tools=[])
|
||||
agent.close()
|
||||
chat_generator.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_async_delegates_to_chat_generator(self):
|
||||
chat_generator = MockChatGenerator()
|
||||
chat_generator.close_async = AsyncMock()
|
||||
agent = Agent(chat_generator=chat_generator, tools=[])
|
||||
await agent.close_async()
|
||||
chat_generator.close_async.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
chat_generator = MockChatGenerator()
|
||||
chat_generator.close = MagicMock()
|
||||
agent = Agent(chat_generator=chat_generator, tools=[])
|
||||
await agent.close_async()
|
||||
chat_generator.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
|
||||
agent = Agent(chat_generator=MockChatGeneratorWithoutRunAsync(), tools=[])
|
||||
agent.warm_up()
|
||||
await agent.warm_up_async()
|
||||
agent.close()
|
||||
await agent.close_async()
|
||||
|
||||
|
||||
class TestAgentNotTriggeredByInjectedInput:
|
||||
"""
|
||||
Regression test for https://github.com/deepset-ai/haystack/issues/11109.
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from openai import APIError
|
||||
from openai import APIError, OpenAIError
|
||||
|
||||
import haystack.components.embedders.azure_document_embedder as azure_document_embedder_module
|
||||
from haystack import Document
|
||||
from haystack.components.embedders import AzureOpenAIDocumentEmbedder
|
||||
from haystack.utils.auth import Secret
|
||||
@@ -28,9 +29,13 @@ class TestAzureOpenAIDocumentEmbedder:
|
||||
assert embedder.progress_bar is True
|
||||
assert embedder.meta_fields_to_embed == []
|
||||
assert embedder.embedding_separator == "\n"
|
||||
assert embedder.timeout is None
|
||||
assert embedder.max_retries is None
|
||||
assert embedder.default_headers == {}
|
||||
assert embedder.azure_ad_token_provider is None
|
||||
assert embedder.http_client_kwargs is None
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_init_with_0_max_retries(self, monkeypatch):
|
||||
"""Tests that the max_retries init param is set correctly if equal 0"""
|
||||
@@ -51,6 +56,8 @@ class TestAzureOpenAIDocumentEmbedder:
|
||||
assert embedder.default_headers == {}
|
||||
assert embedder.azure_ad_token_provider is None
|
||||
assert embedder.max_retries == 0
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_to_dict(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
@@ -72,8 +79,8 @@ class TestAzureOpenAIDocumentEmbedder:
|
||||
"progress_bar": True,
|
||||
"meta_fields_to_embed": [],
|
||||
"embedding_separator": "\n",
|
||||
"max_retries": 5,
|
||||
"timeout": 30.0,
|
||||
"max_retries": None,
|
||||
"timeout": None,
|
||||
"default_headers": {},
|
||||
"azure_ad_token_provider": None,
|
||||
"http_client_kwargs": None,
|
||||
@@ -208,6 +215,7 @@ class TestAzureOpenAIDocumentEmbedder:
|
||||
azure_deployment="text-embedding-ada-002",
|
||||
embedding_separator=" | ",
|
||||
)
|
||||
embedder.warm_up()
|
||||
|
||||
fake_texts_to_embed = {"1": "text1", "2": "text2"}
|
||||
|
||||
@@ -228,6 +236,7 @@ class TestAzureOpenAIDocumentEmbedder:
|
||||
azure_deployment="text-embedding-ada-002",
|
||||
raise_on_failure=True,
|
||||
)
|
||||
embedder.warm_up()
|
||||
fake_texts_to_embed = {"1": "text1", "2": "text2"}
|
||||
with patch.object(
|
||||
embedder.client.embeddings,
|
||||
@@ -276,3 +285,94 @@ class TestAzureOpenAIDocumentEmbedder:
|
||||
assert metadata["usage"]["prompt_tokens"] == 15
|
||||
assert metadata["usage"]["total_tokens"] == 15
|
||||
assert "ada" in metadata["model"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_azure_clients(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake")
|
||||
sync_cls = MagicMock(name="AzureOpenAI")
|
||||
async_cls = MagicMock(name="AsyncAzureOpenAI")
|
||||
async_cls.return_value.close = AsyncMock()
|
||||
monkeypatch.setattr(azure_document_embedder_module, "AzureOpenAI", sync_cls)
|
||||
monkeypatch.setattr(azure_document_embedder_module, "AsyncAzureOpenAI", async_cls)
|
||||
return sync_cls, async_cls
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 5
|
||||
assert embedder.client.timeout == 30.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = AzureOpenAIDocumentEmbedder(
|
||||
azure_endpoint="https://example-resource.azure.openai.com/", timeout=40.0, max_retries=1
|
||||
)
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 1
|
||||
assert embedder.client.timeout == 40.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
|
||||
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 10
|
||||
assert embedder.client.timeout == 100.0
|
||||
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
|
||||
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
assert embedder.client is None
|
||||
with pytest.raises(OpenAIError):
|
||||
embedder.warm_up()
|
||||
|
||||
def test_sync_lifecycle(self, mock_azure_clients):
|
||||
sync_cls, _ = mock_azure_clients
|
||||
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
embedder.warm_up()
|
||||
assert embedder.client is sync_cls.return_value
|
||||
assert embedder.async_client is None
|
||||
|
||||
embedder.close()
|
||||
sync_cls.return_value.close.assert_called_once()
|
||||
assert embedder.client is None
|
||||
|
||||
async def test_async_lifecycle(self, mock_azure_clients):
|
||||
_, async_cls = mock_azure_clients
|
||||
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
|
||||
await embedder.warm_up_async()
|
||||
assert embedder.async_client is async_cls.return_value
|
||||
assert embedder.client is None
|
||||
|
||||
await embedder.close_async()
|
||||
async_cls.return_value.close.assert_awaited_once()
|
||||
assert embedder.async_client is None
|
||||
|
||||
async def test_close_is_safe_without_warm_up(self, mock_azure_clients):
|
||||
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
embedder.close()
|
||||
await embedder.close_async()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
async def test_close_and_close_async_are_independent(self, mock_azure_clients):
|
||||
embedder = AzureOpenAIDocumentEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
embedder.warm_up()
|
||||
await embedder.warm_up_async()
|
||||
|
||||
embedder.close()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is not None
|
||||
|
||||
await embedder.close_async()
|
||||
assert embedder.async_client is None
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from openai import OpenAIError
|
||||
|
||||
import haystack.components.embedders.azure_text_embedder as azure_text_embedder_module
|
||||
from haystack.components.embedders import AzureOpenAITextEmbedder
|
||||
from haystack.utils.azure import default_azure_ad_token_provider
|
||||
|
||||
@@ -15,23 +18,27 @@ class TestAzureOpenAITextEmbedder:
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
|
||||
assert embedder.client.api_key == "fake-api-key"
|
||||
assert embedder.api_key.resolve_value() == "fake-api-key"
|
||||
assert embedder.azure_deployment == "text-embedding-ada-002"
|
||||
assert embedder.model == "text-embedding-ada-002"
|
||||
assert embedder.dimensions is None
|
||||
assert embedder.organization is None
|
||||
assert embedder.prefix == ""
|
||||
assert embedder.suffix == ""
|
||||
assert embedder.timeout is None
|
||||
assert embedder.max_retries is None
|
||||
assert embedder.default_headers == {}
|
||||
assert embedder.azure_ad_token_provider is None
|
||||
assert embedder.http_client_kwargs is None
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_init_with_zero_max_retries(self, monkeypatch):
|
||||
"""Tests that the max_retries init param is set correctly if equal 0"""
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/", max_retries=0)
|
||||
|
||||
assert embedder.client.api_key == "fake-api-key"
|
||||
assert embedder.api_key.resolve_value() == "fake-api-key"
|
||||
assert embedder.azure_deployment == "text-embedding-ada-002"
|
||||
assert embedder.model == "text-embedding-ada-002"
|
||||
assert embedder.dimensions is None
|
||||
@@ -41,6 +48,8 @@ class TestAzureOpenAITextEmbedder:
|
||||
assert embedder.default_headers == {}
|
||||
assert embedder.azure_ad_token_provider is None
|
||||
assert embedder.max_retries == 0
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_to_dict_default(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
@@ -56,8 +65,8 @@ class TestAzureOpenAITextEmbedder:
|
||||
"organization": None,
|
||||
"azure_endpoint": "https://example-resource.azure.openai.com/",
|
||||
"api_version": "2023-05-15",
|
||||
"max_retries": 5,
|
||||
"timeout": 30.0,
|
||||
"max_retries": None,
|
||||
"timeout": None,
|
||||
"prefix": "",
|
||||
"suffix": "",
|
||||
"default_headers": {},
|
||||
@@ -189,3 +198,94 @@ class TestAzureOpenAITextEmbedder:
|
||||
assert all(isinstance(x, float) for x in result["embedding"])
|
||||
assert result["meta"]["usage"] == {"prompt_tokens": 6, "total_tokens": 6}
|
||||
assert "ada" in result["meta"]["model"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_azure_clients(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake")
|
||||
sync_cls = MagicMock(name="AzureOpenAI")
|
||||
async_cls = MagicMock(name="AsyncAzureOpenAI")
|
||||
async_cls.return_value.close = AsyncMock()
|
||||
monkeypatch.setattr(azure_text_embedder_module, "AzureOpenAI", sync_cls)
|
||||
monkeypatch.setattr(azure_text_embedder_module, "AsyncAzureOpenAI", async_cls)
|
||||
return sync_cls, async_cls
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 5
|
||||
assert embedder.client.timeout == 30.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = AzureOpenAITextEmbedder(
|
||||
azure_endpoint="https://example-resource.azure.openai.com/", timeout=40.0, max_retries=1
|
||||
)
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 1
|
||||
assert embedder.client.timeout == 40.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 10
|
||||
assert embedder.client.timeout == 100.0
|
||||
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
assert embedder.client is None
|
||||
with pytest.raises(OpenAIError):
|
||||
embedder.warm_up()
|
||||
|
||||
def test_sync_lifecycle(self, mock_azure_clients):
|
||||
sync_cls, _ = mock_azure_clients
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
embedder.warm_up()
|
||||
assert embedder.client is sync_cls.return_value
|
||||
assert embedder.async_client is None
|
||||
|
||||
embedder.close()
|
||||
sync_cls.return_value.close.assert_called_once()
|
||||
assert embedder.client is None
|
||||
|
||||
async def test_async_lifecycle(self, mock_azure_clients):
|
||||
_, async_cls = mock_azure_clients
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
|
||||
await embedder.warm_up_async()
|
||||
assert embedder.async_client is async_cls.return_value
|
||||
assert embedder.client is None
|
||||
|
||||
await embedder.close_async()
|
||||
async_cls.return_value.close.assert_awaited_once()
|
||||
assert embedder.async_client is None
|
||||
|
||||
async def test_close_is_safe_without_warm_up(self, mock_azure_clients):
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
embedder.close()
|
||||
await embedder.close_async()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
async def test_close_and_close_async_are_independent(self, mock_azure_clients):
|
||||
embedder = AzureOpenAITextEmbedder(azure_endpoint="https://example-resource.azure.openai.com/")
|
||||
embedder.warm_up()
|
||||
await embedder.warm_up_async()
|
||||
|
||||
embedder.close()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is not None
|
||||
|
||||
await embedder.close_async()
|
||||
assert embedder.async_client is None
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from openai import APIError
|
||||
|
||||
import haystack.components.embedders.openai_document_embedder as openai_document_embedder_module
|
||||
from haystack import Document
|
||||
from haystack.components.embedders.openai_document_embedder import OpenAIDocumentEmbedder
|
||||
from haystack.utils.auth import Secret
|
||||
@@ -27,8 +28,10 @@ class TestOpenAIDocumentEmbedder:
|
||||
assert embedder.progress_bar is True
|
||||
assert embedder.meta_fields_to_embed == []
|
||||
assert embedder.embedding_separator == "\n"
|
||||
assert embedder.client.max_retries == 5
|
||||
assert embedder.client.timeout == 30.0
|
||||
assert embedder.timeout is None
|
||||
assert embedder.max_retries is None
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_init_with_parameters(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
@@ -55,8 +58,10 @@ class TestOpenAIDocumentEmbedder:
|
||||
assert embedder.progress_bar is False
|
||||
assert embedder.meta_fields_to_embed == ["test_field"]
|
||||
assert embedder.embedding_separator == " | "
|
||||
assert embedder.client.max_retries == 1
|
||||
assert embedder.client.timeout == 40.0
|
||||
assert embedder.timeout == 40.0
|
||||
assert embedder.max_retries == 1
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_init_with_parameters_and_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
@@ -81,13 +86,10 @@ class TestOpenAIDocumentEmbedder:
|
||||
assert embedder.progress_bar is False
|
||||
assert embedder.meta_fields_to_embed == ["test_field"]
|
||||
assert embedder.embedding_separator == " | "
|
||||
assert embedder.client.max_retries == 10
|
||||
assert embedder.client.timeout == 100.0
|
||||
|
||||
def test_init_fail_wo_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
OpenAIDocumentEmbedder()
|
||||
assert embedder.timeout is None
|
||||
assert embedder.max_retries is None
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_to_dict(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
@@ -214,6 +216,7 @@ class TestOpenAIDocumentEmbedder:
|
||||
|
||||
def test_embed_batch_handles_exceptions_gracefully(self, caplog):
|
||||
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake_api_key"))
|
||||
embedder.warm_up()
|
||||
fake_texts_to_embed = {"1": "text1", "2": "text2"}
|
||||
with patch.object(
|
||||
embedder.client.embeddings,
|
||||
@@ -227,6 +230,7 @@ class TestOpenAIDocumentEmbedder:
|
||||
|
||||
def test_run_handles_exceptions_gracefully(self, caplog):
|
||||
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake_api_key"), batch_size=1)
|
||||
embedder.warm_up()
|
||||
docs = [
|
||||
Document(content="I love cheese", meta={"topic": "Cuisine"}),
|
||||
Document(content="A transformer is a deep learning architecture", meta={"topic": "ML"}),
|
||||
@@ -255,6 +259,7 @@ class TestOpenAIDocumentEmbedder:
|
||||
|
||||
def test_embed_batch_raises_exception_on_failure(self):
|
||||
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake_api_key"), raise_on_failure=True)
|
||||
embedder.warm_up()
|
||||
fake_texts_to_embed = {"1": "text1", "2": "text2"}
|
||||
with patch.object(
|
||||
embedder.client.embeddings,
|
||||
@@ -277,6 +282,7 @@ class TestOpenAIDocumentEmbedder:
|
||||
embedder = OpenAIDocumentEmbedder(model=model, meta_fields_to_embed=["topic"], embedding_separator=" | ")
|
||||
|
||||
result = embedder.run(documents=docs)
|
||||
assert embedder.client is not None
|
||||
documents_with_embeddings = result["documents"]
|
||||
|
||||
assert isinstance(documents_with_embeddings, list)
|
||||
@@ -308,6 +314,7 @@ class TestOpenAIDocumentEmbedder:
|
||||
]
|
||||
|
||||
result = await embedder.run_async(documents=docs)
|
||||
assert embedder.async_client is not None
|
||||
documents_with_embeddings = result["documents"]
|
||||
|
||||
assert isinstance(documents_with_embeddings, list)
|
||||
@@ -328,4 +335,89 @@ class TestOpenAIDocumentEmbedder:
|
||||
|
||||
# Close async client; suppress RuntimeError if the event loop is already closed
|
||||
with contextlib.suppress(RuntimeError):
|
||||
await embedder.async_client.close()
|
||||
await embedder.close_async()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_clients(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake")
|
||||
sync_cls = MagicMock(name="OpenAI")
|
||||
async_cls = MagicMock(name="AsyncOpenAI")
|
||||
async_cls.return_value.close = AsyncMock()
|
||||
monkeypatch.setattr(openai_document_embedder_module, "OpenAI", sync_cls)
|
||||
monkeypatch.setattr(openai_document_embedder_module, "AsyncOpenAI", async_cls)
|
||||
return sync_cls, async_cls
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 5
|
||||
assert embedder.client.timeout == 30.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
|
||||
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 1
|
||||
assert embedder.client.timeout == 40.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
|
||||
embedder = OpenAIDocumentEmbedder(api_key=Secret.from_token("fake-api-key"))
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 10
|
||||
assert embedder.client.timeout == 100.0
|
||||
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
embedder.warm_up()
|
||||
|
||||
def test_sync_lifecycle(self, mock_openai_clients):
|
||||
sync_cls, _ = mock_openai_clients
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
embedder.warm_up()
|
||||
assert embedder.client is sync_cls.return_value
|
||||
assert embedder.async_client is None
|
||||
|
||||
embedder.close()
|
||||
sync_cls.return_value.close.assert_called_once()
|
||||
assert embedder.client is None
|
||||
|
||||
async def test_async_lifecycle(self, mock_openai_clients):
|
||||
_, async_cls = mock_openai_clients
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
|
||||
await embedder.warm_up_async()
|
||||
assert embedder.async_client is async_cls.return_value
|
||||
assert embedder.client is None
|
||||
|
||||
await embedder.close_async()
|
||||
async_cls.return_value.close.assert_awaited_once()
|
||||
assert embedder.async_client is None
|
||||
|
||||
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
embedder.close()
|
||||
await embedder.close_async()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
|
||||
embedder = OpenAIDocumentEmbedder()
|
||||
embedder.warm_up()
|
||||
await embedder.warm_up_async()
|
||||
|
||||
embedder.close()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is not None
|
||||
|
||||
await embedder.close_async()
|
||||
assert embedder.async_client is None
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from openai.types import CreateEmbeddingResponse, Embedding
|
||||
|
||||
import haystack.components.embedders.openai_text_embedder as openai_text_embedder_module
|
||||
from haystack.components.embedders.openai_text_embedder import OpenAITextEmbedder
|
||||
from haystack.utils.auth import Secret
|
||||
|
||||
@@ -17,14 +19,16 @@ class TestOpenAITextEmbedder:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = OpenAITextEmbedder()
|
||||
|
||||
assert embedder.client.api_key == "fake-api-key"
|
||||
assert embedder.api_key.resolve_value() == "fake-api-key"
|
||||
assert embedder.model == "text-embedding-ada-002"
|
||||
assert embedder.api_base_url is None
|
||||
assert embedder.organization is None
|
||||
assert embedder.prefix == ""
|
||||
assert embedder.suffix == ""
|
||||
assert embedder.client.timeout == 30
|
||||
assert embedder.client.max_retries == 5
|
||||
assert embedder.timeout is None
|
||||
assert embedder.max_retries is None
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_init_with_parameters(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
@@ -39,14 +43,16 @@ class TestOpenAITextEmbedder:
|
||||
timeout=40.0,
|
||||
max_retries=1,
|
||||
)
|
||||
assert embedder.client.api_key == "fake-api-key"
|
||||
assert embedder.api_key.resolve_value() == "fake-api-key"
|
||||
assert embedder.model == "model"
|
||||
assert embedder.api_base_url == "https://my-custom-base-url.com"
|
||||
assert embedder.organization == "fake-organization"
|
||||
assert embedder.prefix == "prefix"
|
||||
assert embedder.suffix == "suffix"
|
||||
assert embedder.client.timeout == 40.0
|
||||
assert embedder.client.max_retries == 1
|
||||
assert embedder.timeout == 40.0
|
||||
assert embedder.max_retries == 1
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_init_with_parameters_and_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
@@ -59,19 +65,16 @@ class TestOpenAITextEmbedder:
|
||||
prefix="prefix",
|
||||
suffix="suffix",
|
||||
)
|
||||
assert embedder.client.api_key == "fake-api-key"
|
||||
assert embedder.api_key.resolve_value() == "fake-api-key"
|
||||
assert embedder.model == "model"
|
||||
assert embedder.api_base_url == "https://my-custom-base-url.com"
|
||||
assert embedder.organization == "fake-organization"
|
||||
assert embedder.prefix == "prefix"
|
||||
assert embedder.suffix == "suffix"
|
||||
assert embedder.client.timeout == 100.0
|
||||
assert embedder.client.max_retries == 10
|
||||
|
||||
def test_init_fail_wo_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
OpenAITextEmbedder()
|
||||
assert embedder.timeout is None
|
||||
assert embedder.max_retries is None
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
def test_to_dict(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
@@ -138,7 +141,7 @@ class TestOpenAITextEmbedder:
|
||||
},
|
||||
}
|
||||
component = OpenAITextEmbedder.from_dict(data)
|
||||
assert component.client.api_key == "fake-api-key"
|
||||
assert component.api_key.resolve_value() == "fake-api-key"
|
||||
assert component.model == "text-embedding-ada-002"
|
||||
assert component.api_base_url == "https://my-custom-base-url.com"
|
||||
assert component.organization == "fake-organization"
|
||||
@@ -219,4 +222,89 @@ class TestOpenAITextEmbedder:
|
||||
|
||||
# Close async client; suppress RuntimeError if the event loop is already closed
|
||||
with contextlib.suppress(RuntimeError):
|
||||
await embedder.async_client.close()
|
||||
await embedder.close_async()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_clients(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake")
|
||||
sync_cls = MagicMock(name="OpenAI")
|
||||
async_cls = MagicMock(name="AsyncOpenAI")
|
||||
async_cls.return_value.close = AsyncMock()
|
||||
monkeypatch.setattr(openai_text_embedder_module, "OpenAI", sync_cls)
|
||||
monkeypatch.setattr(openai_text_embedder_module, "AsyncOpenAI", async_cls)
|
||||
return sync_cls, async_cls
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
embedder = OpenAITextEmbedder()
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 5
|
||||
assert embedder.client.timeout == 30.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
|
||||
embedder = OpenAITextEmbedder(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 1
|
||||
assert embedder.client.timeout == 40.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
|
||||
embedder = OpenAITextEmbedder(api_key=Secret.from_token("fake-api-key"))
|
||||
embedder.warm_up()
|
||||
assert embedder.client.max_retries == 10
|
||||
assert embedder.client.timeout == 100.0
|
||||
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedder = OpenAITextEmbedder()
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
embedder.warm_up()
|
||||
|
||||
def test_sync_lifecycle(self, mock_openai_clients):
|
||||
sync_cls, _ = mock_openai_clients
|
||||
embedder = OpenAITextEmbedder()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
embedder.warm_up()
|
||||
assert embedder.client is sync_cls.return_value
|
||||
assert embedder.async_client is None
|
||||
|
||||
embedder.close()
|
||||
sync_cls.return_value.close.assert_called_once()
|
||||
assert embedder.client is None
|
||||
|
||||
async def test_async_lifecycle(self, mock_openai_clients):
|
||||
_, async_cls = mock_openai_clients
|
||||
embedder = OpenAITextEmbedder()
|
||||
|
||||
await embedder.warm_up_async()
|
||||
assert embedder.async_client is async_cls.return_value
|
||||
assert embedder.client is None
|
||||
|
||||
await embedder.close_async()
|
||||
async_cls.return_value.close.assert_awaited_once()
|
||||
assert embedder.async_client is None
|
||||
|
||||
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
|
||||
embedder = OpenAITextEmbedder()
|
||||
embedder.close()
|
||||
await embedder.close_async()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is None
|
||||
|
||||
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
|
||||
embedder = OpenAITextEmbedder()
|
||||
embedder.warm_up()
|
||||
await embedder.warm_up_async()
|
||||
|
||||
embedder.close()
|
||||
assert embedder.client is None
|
||||
assert embedder.async_client is not None
|
||||
|
||||
await embedder.close_async()
|
||||
assert embedder.async_client is None
|
||||
|
||||
@@ -52,13 +52,14 @@ class TestContextRelevanceEvaluator:
|
||||
]
|
||||
|
||||
assert isinstance(component._chat_generator, OpenAIChatGenerator)
|
||||
assert component._chat_generator.client.api_key == "test-api-key"
|
||||
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
|
||||
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
|
||||
def test_init_fail_wo_openai_api_key(self, monkeypatch):
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
component = ContextRelevanceEvaluator()
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
ContextRelevanceEvaluator()
|
||||
component.warm_up()
|
||||
|
||||
def test_init_with_parameters(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
@@ -75,7 +76,7 @@ class TestContextRelevanceEvaluator:
|
||||
]
|
||||
|
||||
assert isinstance(component._chat_generator, OpenAIChatGenerator)
|
||||
assert component._chat_generator.client.api_key == "test-api-key"
|
||||
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
|
||||
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
|
||||
def test_init_with_chat_generator(self, monkeypatch):
|
||||
@@ -123,7 +124,7 @@ class TestContextRelevanceEvaluator:
|
||||
|
||||
component = ContextRelevanceEvaluator.from_dict(data)
|
||||
assert isinstance(component._chat_generator, OpenAIChatGenerator)
|
||||
assert component._chat_generator.client.api_key == "test-api-key"
|
||||
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
|
||||
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
assert component.examples == [{"inputs": {"questions": "What is football?"}, "outputs": {"score": 0}}]
|
||||
|
||||
|
||||
@@ -67,13 +67,14 @@ class TestFaithfulnessEvaluator:
|
||||
]
|
||||
|
||||
assert isinstance(component._chat_generator, OpenAIChatGenerator)
|
||||
assert component._chat_generator.client.api_key == "test-api-key"
|
||||
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
|
||||
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
|
||||
def test_init_fail_wo_openai_api_key(self, monkeypatch):
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
component = FaithfulnessEvaluator()
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
FaithfulnessEvaluator()
|
||||
component.warm_up()
|
||||
|
||||
def test_init_with_parameters(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
@@ -96,7 +97,7 @@ class TestFaithfulnessEvaluator:
|
||||
]
|
||||
|
||||
assert isinstance(component._chat_generator, OpenAIChatGenerator)
|
||||
assert component._chat_generator.client.api_key == "test-api-key"
|
||||
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
|
||||
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
|
||||
def test_init_with_chat_generator(self, monkeypatch):
|
||||
@@ -150,7 +151,7 @@ class TestFaithfulnessEvaluator:
|
||||
}
|
||||
component = FaithfulnessEvaluator.from_dict(data)
|
||||
assert isinstance(component._chat_generator, OpenAIChatGenerator)
|
||||
assert component._chat_generator.client.api_key == "test-api-key"
|
||||
assert component._chat_generator.api_key.resolve_value() == "test-api-key"
|
||||
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
assert component.examples == [
|
||||
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -30,20 +31,20 @@ class TestLLMEvaluator:
|
||||
]
|
||||
|
||||
assert isinstance(component._chat_generator, OpenAIChatGenerator)
|
||||
assert component._chat_generator.client.api_key == "test-api-key"
|
||||
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
|
||||
def test_init_fail_wo_openai_api_key(self, monkeypatch):
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
component = LLMEvaluator(
|
||||
instructions="test-instruction",
|
||||
inputs=[("predicted_answers", list[str])],
|
||||
outputs=["score"],
|
||||
examples=[
|
||||
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
LLMEvaluator(
|
||||
instructions="test-instruction",
|
||||
inputs=[("predicted_answers", list[str])],
|
||||
outputs=["score"],
|
||||
examples=[
|
||||
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
|
||||
],
|
||||
)
|
||||
component.warm_up()
|
||||
|
||||
def test_init_with_chat_generator(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
@@ -268,7 +269,6 @@ class TestLLMEvaluator:
|
||||
|
||||
component = LLMEvaluator.from_dict(data)
|
||||
assert isinstance(component._chat_generator, OpenAIChatGenerator)
|
||||
assert component._chat_generator.client.api_key == "test-api-key"
|
||||
assert component._chat_generator.generation_kwargs == {"response_format": {"type": "json_object"}, "seed": 42}
|
||||
assert component.instructions == "test-instruction"
|
||||
assert component.inputs == [("predicted_answers", list[str])]
|
||||
@@ -574,3 +574,63 @@ class TestLLMEvaluatorAsync:
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await component.run_async(questions=["question"], predicted_answers=["answer"])
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
@staticmethod
|
||||
def _make_evaluator(chat_generator):
|
||||
return LLMEvaluator(
|
||||
instructions="test-instruction",
|
||||
inputs=[("predicted_answers", list[str])],
|
||||
outputs=["score"],
|
||||
examples=[
|
||||
{"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}
|
||||
],
|
||||
chat_generator=chat_generator,
|
||||
)
|
||||
|
||||
def test_warm_up_delegates_to_chat_generator(self):
|
||||
chat_generator = Mock(spec=["run", "warm_up"])
|
||||
evaluator = self._make_evaluator(chat_generator)
|
||||
evaluator.warm_up()
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_chat_generator(self):
|
||||
chat_generator = Mock(spec=["run", "warm_up_async"])
|
||||
chat_generator.warm_up_async = AsyncMock()
|
||||
evaluator = self._make_evaluator(chat_generator)
|
||||
await evaluator.warm_up_async()
|
||||
chat_generator.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
chat_generator = Mock(spec=["run", "warm_up"])
|
||||
evaluator = self._make_evaluator(chat_generator)
|
||||
await evaluator.warm_up_async()
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_chat_generator(self):
|
||||
chat_generator = Mock(spec=["run", "close"])
|
||||
evaluator = self._make_evaluator(chat_generator)
|
||||
evaluator.close()
|
||||
chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_chat_generator(self):
|
||||
chat_generator = Mock(spec=["run", "close_async"])
|
||||
chat_generator.close_async = AsyncMock()
|
||||
evaluator = self._make_evaluator(chat_generator)
|
||||
await evaluator.close_async()
|
||||
chat_generator.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
chat_generator = Mock(spec=["run", "close"])
|
||||
evaluator = self._make_evaluator(chat_generator)
|
||||
await evaluator.close_async()
|
||||
chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
|
||||
chat_generator = Mock(spec=["run"])
|
||||
evaluator = self._make_evaluator(chat_generator)
|
||||
evaluator.warm_up()
|
||||
await evaluator.warm_up_async()
|
||||
evaluator.close()
|
||||
await evaluator.close_async()
|
||||
|
||||
@@ -130,20 +130,6 @@ class TestLLMDocumentContentExtractor:
|
||||
assert extractor.max_workers == 4
|
||||
assert component_to_dict(extractor._chat_generator, "name") == component_to_dict(chat_generator, "name")
|
||||
|
||||
def test_warm_up_with_chat_generator(self, monkeypatch):
|
||||
mock_chat_generator = Mock()
|
||||
mock_chat_generator.warm_up = Mock()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
mock_chat_generator.warm_up.assert_not_called()
|
||||
extractor.warm_up()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_warm_up_without_warm_up_method(self, monkeypatch):
|
||||
mock_chat_generator = Mock()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
extractor.warm_up()
|
||||
assert extractor._is_warmed_up is True
|
||||
|
||||
def test_run_no_documents(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
chat_generator = OpenAIChatGenerator()
|
||||
@@ -658,3 +644,51 @@ class TestLLMDocumentContentExtractorAsync:
|
||||
assert len(result["failed_documents"]) == 0
|
||||
assert len(result["documents"]) == 1
|
||||
assert len(result["documents"][0].content) > 0
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_chat_generator(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
extractor.warm_up()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_chat_generator(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up_async"])
|
||||
mock_chat_generator.warm_up_async = AsyncMock()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
await extractor.warm_up_async()
|
||||
mock_chat_generator.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
await extractor.warm_up_async()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_chat_generator(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
extractor.close()
|
||||
mock_chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_chat_generator(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close_async"])
|
||||
mock_chat_generator.close_async = AsyncMock()
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
await extractor.close_async()
|
||||
mock_chat_generator.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
await extractor.close_async()
|
||||
mock_chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
|
||||
mock_chat_generator = Mock(spec=["run"])
|
||||
extractor = LLMDocumentContentExtractor(chat_generator=mock_chat_generator)
|
||||
extractor.warm_up()
|
||||
await extractor.warm_up_async()
|
||||
extractor.close()
|
||||
await extractor.close_async()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -141,13 +141,6 @@ class TestLLMMetadataExtractor:
|
||||
assert extractor.prompt == "some prompt that was used with the LLM {{document.content}}"
|
||||
assert extractor._chat_generator.to_dict() == chat_generator.to_dict()
|
||||
|
||||
def test_warm_up_with_chat_generator(self, monkeypatch):
|
||||
mock_chat_generator = Mock()
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
|
||||
mock_chat_generator.warm_up.assert_not_called()
|
||||
extractor.warm_up()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_extract_metadata(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=OpenAIChatGenerator())
|
||||
@@ -513,3 +506,66 @@ class TestLLMMetadataExtractor:
|
||||
assert len(doc_store_docs) == 2
|
||||
assert "entities" in doc_store_docs[0].meta
|
||||
assert "entities" in doc_store_docs[1].meta
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_inner_components(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up"])
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
|
||||
extractor.splitter = Mock(spec=["run", "warm_up"])
|
||||
extractor.warm_up()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
extractor.splitter.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_inner_components(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up", "warm_up_async"])
|
||||
mock_chat_generator.warm_up_async = AsyncMock()
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
|
||||
extractor.splitter = Mock(spec=["run", "warm_up_async"])
|
||||
extractor.splitter.warm_up_async = AsyncMock()
|
||||
await extractor.warm_up_async()
|
||||
mock_chat_generator.warm_up_async.assert_awaited_once()
|
||||
extractor.splitter.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
mock_chat_generator = Mock(spec=["run", "warm_up"])
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
|
||||
extractor.splitter = Mock(spec=["run", "warm_up"])
|
||||
await extractor.warm_up_async()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
extractor.splitter.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_inner_components(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close"])
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
|
||||
extractor.splitter = Mock(spec=["run", "close"])
|
||||
extractor.close()
|
||||
mock_chat_generator.close.assert_called_once()
|
||||
extractor.splitter.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_inner_components(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close_async"])
|
||||
mock_chat_generator.close_async = AsyncMock()
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
|
||||
extractor.splitter = Mock(spec=["run", "close_async"])
|
||||
extractor.splitter.close_async = AsyncMock()
|
||||
await extractor.close_async()
|
||||
mock_chat_generator.close_async.assert_awaited_once()
|
||||
extractor.splitter.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
mock_chat_generator = Mock(spec=["run", "close"])
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
|
||||
extractor.splitter = Mock(spec=["run", "close"])
|
||||
await extractor.close_async()
|
||||
mock_chat_generator.close.assert_called_once()
|
||||
extractor.splitter.close.assert_called_once()
|
||||
|
||||
async def test_lifecycle_is_safe_when_inner_lacks_methods(self):
|
||||
mock_chat_generator = Mock(spec=["run"])
|
||||
extractor = LLMMetadataExtractor(prompt="prompt {{document.content}}", chat_generator=mock_chat_generator)
|
||||
extractor.splitter = Mock(spec=["run"])
|
||||
extractor.warm_up()
|
||||
await extractor.warm_up_async()
|
||||
extractor.close()
|
||||
await extractor.close_async()
|
||||
|
||||
@@ -57,8 +57,8 @@ class TestLinkContentFetcher:
|
||||
"video/*": _binary_content_handler,
|
||||
}
|
||||
assert hasattr(fetcher, "_get_response")
|
||||
assert hasattr(fetcher, "_client")
|
||||
assert isinstance(fetcher._client, httpx.Client)
|
||||
assert fetcher._client is None
|
||||
assert fetcher._async_client is None
|
||||
|
||||
def test_init_with_params(self):
|
||||
"""Test initialization with custom parameters"""
|
||||
@@ -183,6 +183,106 @@ class TestLinkContentFetcher:
|
||||
assert sent_headers["User-Agent"] == "ua-sync-1" # rotating UA wins
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_clients_are_none_after_init(self):
|
||||
fetcher = LinkContentFetcher()
|
||||
assert fetcher._client is None
|
||||
assert fetcher._async_client is None
|
||||
|
||||
def test_sync_lifecycle(self):
|
||||
with patch("haystack.components.fetchers.link_content.httpx.Client") as ClientMock:
|
||||
client_instance = ClientMock.return_value
|
||||
fetcher = LinkContentFetcher()
|
||||
|
||||
fetcher.warm_up()
|
||||
assert fetcher._client is client_instance
|
||||
assert fetcher._async_client is None
|
||||
ClientMock.assert_called_once()
|
||||
|
||||
fetcher.close()
|
||||
client_instance.close.assert_called_once()
|
||||
assert fetcher._client is None
|
||||
|
||||
def test_warm_up_is_idempotent(self):
|
||||
with patch("haystack.components.fetchers.link_content.httpx.Client") as ClientMock:
|
||||
fetcher = LinkContentFetcher()
|
||||
fetcher.warm_up()
|
||||
fetcher.warm_up()
|
||||
ClientMock.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_lifecycle(self):
|
||||
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient") as AsyncClientMock:
|
||||
async_client_instance = AsyncClientMock.return_value
|
||||
async_client_instance.aclose = AsyncMock()
|
||||
fetcher = LinkContentFetcher()
|
||||
|
||||
await fetcher.warm_up_async()
|
||||
assert fetcher._async_client is async_client_instance
|
||||
assert fetcher._client is None
|
||||
AsyncClientMock.assert_called_once()
|
||||
|
||||
await fetcher.close_async()
|
||||
async_client_instance.aclose.assert_awaited_once()
|
||||
assert fetcher._async_client is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warm_up_async_is_idempotent(self):
|
||||
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient") as AsyncClientMock:
|
||||
fetcher = LinkContentFetcher()
|
||||
await fetcher.warm_up_async()
|
||||
await fetcher.warm_up_async()
|
||||
AsyncClientMock.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_is_safe_without_warm_up(self):
|
||||
fetcher = LinkContentFetcher()
|
||||
fetcher.close()
|
||||
await fetcher.close_async()
|
||||
assert fetcher._client is None
|
||||
assert fetcher._async_client is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_and_close_async_are_independent(self):
|
||||
with (
|
||||
patch("haystack.components.fetchers.link_content.httpx.Client") as ClientMock,
|
||||
patch("haystack.components.fetchers.link_content.httpx.AsyncClient") as AsyncClientMock,
|
||||
):
|
||||
client_instance = ClientMock.return_value
|
||||
async_client_instance = AsyncClientMock.return_value
|
||||
async_client_instance.aclose = AsyncMock()
|
||||
|
||||
fetcher = LinkContentFetcher()
|
||||
fetcher.warm_up()
|
||||
await fetcher.warm_up_async()
|
||||
|
||||
fetcher.close()
|
||||
assert fetcher._client is None
|
||||
assert fetcher._async_client is async_client_instance
|
||||
async_client_instance.aclose.assert_not_awaited()
|
||||
|
||||
await fetcher.close_async()
|
||||
assert fetcher._async_client is None
|
||||
client_instance.close.assert_called_once()
|
||||
|
||||
def test_run_self_heals(self):
|
||||
with patch("haystack.components.fetchers.link_content.httpx.Client.get") as mock_get:
|
||||
mock_response = Mock(status_code=200, text="ok", headers={"Content-Type": "text/plain"})
|
||||
mock_get.return_value = mock_response
|
||||
fetcher = LinkContentFetcher()
|
||||
fetcher.run(urls=["https://www.example.com"])
|
||||
assert fetcher._client is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_self_heals(self):
|
||||
with patch("haystack.components.fetchers.link_content.httpx.AsyncClient.get") as mock_get:
|
||||
mock_response = Mock(status_code=200, text="ok", headers={"Content-Type": "text/plain"})
|
||||
mock_get.return_value = mock_response
|
||||
fetcher = LinkContentFetcher()
|
||||
await fetcher.run_async(urls=["https://www.example.com"])
|
||||
assert fetcher._async_client is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky(reruns=3, reruns_delay=5)
|
||||
@pytest.mark.integration
|
||||
class TestLinkContentFetcherIntegration:
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from openai import OpenAIError
|
||||
from pydantic import BaseModel
|
||||
|
||||
import haystack.components.generators.chat.azure as azure_chat_module
|
||||
from haystack import Pipeline, component
|
||||
from haystack.components.generators.chat import AzureOpenAIChatGenerator
|
||||
from haystack.components.generators.utils import print_streaming_chunk
|
||||
@@ -86,16 +87,19 @@ class TestAzureOpenAIChatGenerator:
|
||||
def test_init_default(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.api_key == Secret.from_env_var("AZURE_OPENAI_API_KEY", strict=False)
|
||||
assert component.azure_deployment == "gpt-4.1-mini"
|
||||
assert component.streaming_callback is None
|
||||
assert not component.generation_kwargs
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_init_fail_wo_api_key(self, monkeypatch):
|
||||
def test_init_does_not_fail_wo_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
|
||||
with pytest.raises(OpenAIError):
|
||||
AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_init_with_parameters(self, tools):
|
||||
component = AzureOpenAIChatGenerator(
|
||||
@@ -107,14 +111,16 @@ class TestAzureOpenAIChatGenerator:
|
||||
tools_strict=True,
|
||||
azure_ad_token_provider=default_azure_ad_token_provider,
|
||||
)
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.api_key == Secret.from_token("test-api-key")
|
||||
assert component.azure_deployment == "gpt-4.1-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
|
||||
assert component.tools == tools
|
||||
assert component.tools_strict
|
||||
assert component.azure_ad_token_provider is not None
|
||||
assert component.max_retries == 5
|
||||
assert component.max_retries is None
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_init_with_0_max_retries(self, tools):
|
||||
"""Tests that the max_retries init param is set correctly if equal 0"""
|
||||
@@ -128,7 +134,7 @@ class TestAzureOpenAIChatGenerator:
|
||||
azure_ad_token_provider=default_azure_ad_token_provider,
|
||||
max_retries=0,
|
||||
)
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.api_key == Secret.from_token("test-api-key")
|
||||
assert component.azure_deployment == "gpt-4.1-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
|
||||
@@ -136,6 +142,8 @@ class TestAzureOpenAIChatGenerator:
|
||||
assert component.tools_strict
|
||||
assert component.azure_ad_token_provider is not None
|
||||
assert component.max_retries == 0
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_init_with_secret_azure_endpoint_and_api_version(self, monkeypatch):
|
||||
"""`azure_endpoint` and `api_version` accept a Secret that is resolved from an environment variable."""
|
||||
@@ -149,11 +157,6 @@ class TestAzureOpenAIChatGenerator:
|
||||
# The Secret objects are kept on the instance so they can be serialized
|
||||
assert component.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
|
||||
assert component.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
|
||||
# The clients receive the resolved string values
|
||||
assert str(component.client._azure_endpoint) == "https://test-resource.azure.openai.com/"
|
||||
assert component.client._api_version == "2024-08-01-preview"
|
||||
assert str(component.async_client._azure_endpoint) == "https://test-resource.azure.openai.com/"
|
||||
assert component.async_client._api_version == "2024-08-01-preview"
|
||||
|
||||
def test_init_fail_with_unset_secret_azure_endpoint(self, monkeypatch):
|
||||
"""A Secret azure_endpoint that resolves to nothing raises the same error as a missing endpoint."""
|
||||
@@ -195,6 +198,7 @@ class TestAzureOpenAIChatGenerator:
|
||||
deserialized = AzureOpenAIChatGenerator.from_dict(component.to_dict())
|
||||
assert deserialized.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
|
||||
assert deserialized.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
|
||||
deserialized.warm_up()
|
||||
assert str(deserialized.client._azure_endpoint) == "https://test-resource.azure.openai.com/"
|
||||
assert deserialized.client._api_version == "2024-08-01-preview"
|
||||
|
||||
@@ -214,8 +218,8 @@ class TestAzureOpenAIChatGenerator:
|
||||
"organization": None,
|
||||
"streaming_callback": None,
|
||||
"generation_kwargs": {},
|
||||
"timeout": 30.0,
|
||||
"max_retries": 5,
|
||||
"timeout": None,
|
||||
"max_retries": None,
|
||||
"default_headers": {},
|
||||
"tools": None,
|
||||
"tools_strict": False,
|
||||
@@ -228,6 +232,7 @@ class TestAzureOpenAIChatGenerator:
|
||||
assert generator.azure_endpoint == Secret.from_env_var("AZURE_OPENAI_ENDPOINT")
|
||||
assert generator.api_version == Secret.from_env_var("AZURE_OPENAI_API_VERSION")
|
||||
# And they are resolved to the string values the client expects
|
||||
generator.warm_up()
|
||||
assert str(generator.client._azure_endpoint) == "https://test-resource.azure.openai.com/"
|
||||
assert generator.client._api_version == "2024-08-01-preview"
|
||||
|
||||
@@ -246,8 +251,8 @@ class TestAzureOpenAIChatGenerator:
|
||||
"organization": None,
|
||||
"streaming_callback": None,
|
||||
"generation_kwargs": {},
|
||||
"timeout": 30.0,
|
||||
"max_retries": 5,
|
||||
"timeout": None,
|
||||
"max_retries": None,
|
||||
"default_headers": {},
|
||||
"tools": None,
|
||||
"tools_strict": False,
|
||||
@@ -389,8 +394,8 @@ class TestAzureOpenAIChatGenerator:
|
||||
"api_version": "2024-12-01-preview",
|
||||
"streaming_callback": None,
|
||||
"generation_kwargs": {},
|
||||
"timeout": 30.0,
|
||||
"max_retries": 5,
|
||||
"timeout": None,
|
||||
"max_retries": None,
|
||||
"api_key": {"type": "env_var", "env_vars": ["AZURE_OPENAI_API_KEY"], "strict": False},
|
||||
"azure_ad_token": {"type": "env_var", "env_vars": ["AZURE_OPENAI_AD_TOKEN"], "strict": False},
|
||||
"default_headers": {},
|
||||
@@ -531,113 +536,9 @@ class TestAzureOpenAIChatGenerator:
|
||||
}
|
||||
assert data["init_parameters"]["tools"] == expected_tools_data
|
||||
|
||||
def test_warm_up_with_tools(self, monkeypatch):
|
||||
"""Test that warm_up() calls warm_up on tools and is idempotent."""
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
# Create a mock tool that tracks if warm_up() was called
|
||||
class MockTool(Tool):
|
||||
warm_up_call_count = 0 # Class variable to track calls
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="mock_tool",
|
||||
description="A mock tool for testing",
|
||||
parameters={"x": {"type": "string"}},
|
||||
function=lambda x: x,
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
MockTool.warm_up_call_count += 1
|
||||
|
||||
# Reset the class variable before test
|
||||
MockTool.warm_up_call_count = 0
|
||||
mock_tool = MockTool()
|
||||
|
||||
# Create AzureOpenAIChatGenerator with the mock tool
|
||||
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=[mock_tool])
|
||||
|
||||
# Verify initial state - warm_up not called yet
|
||||
assert MockTool.warm_up_call_count == 0
|
||||
assert not component._is_warmed_up
|
||||
|
||||
# Call warm_up() on the generator
|
||||
component.warm_up()
|
||||
|
||||
# Assert that the tool's warm_up() was called
|
||||
assert MockTool.warm_up_call_count == 1
|
||||
assert component._is_warmed_up
|
||||
|
||||
# Call warm_up() again and verify it's idempotent (only warms up once)
|
||||
component.warm_up()
|
||||
|
||||
# The tool's warm_up should still only have been called once
|
||||
assert MockTool.warm_up_call_count == 1
|
||||
assert component._is_warmed_up
|
||||
|
||||
def test_warm_up_with_no_tools(self, monkeypatch):
|
||||
"""Test that warm_up() works when no tools are provided."""
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
component = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
|
||||
# Verify initial state
|
||||
assert not component._is_warmed_up
|
||||
assert component.tools is None
|
||||
|
||||
# Call warm_up() - should not raise an error
|
||||
component.warm_up()
|
||||
|
||||
# Verify the component is warmed up
|
||||
assert component._is_warmed_up
|
||||
|
||||
# Call warm_up() again - should be idempotent
|
||||
component.warm_up()
|
||||
assert component._is_warmed_up
|
||||
|
||||
def test_warm_up_with_multiple_tools(self, monkeypatch):
|
||||
"""Test that warm_up() works with multiple tools."""
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
# Track warm_up calls
|
||||
warm_up_calls = []
|
||||
|
||||
class MockTool(Tool):
|
||||
def __init__(self, tool_name):
|
||||
super().__init__(
|
||||
name=tool_name,
|
||||
description=f"Mock tool {tool_name}",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
function=lambda x: f"{tool_name} result: {x}",
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
warm_up_calls.append(self.name)
|
||||
|
||||
mock_tool1 = MockTool("tool1")
|
||||
mock_tool2 = MockTool("tool2")
|
||||
|
||||
# Use a LIST of tools, not a Toolset
|
||||
component = AzureOpenAIChatGenerator(
|
||||
azure_endpoint="some-non-existing-endpoint", tools=[mock_tool1, mock_tool2]
|
||||
)
|
||||
|
||||
# Call warm_up()
|
||||
component.warm_up()
|
||||
|
||||
# Assert that both tools' warm_up() were called
|
||||
assert "tool1" in warm_up_calls
|
||||
assert "tool2" in warm_up_calls
|
||||
assert component._is_warmed_up
|
||||
|
||||
# Test idempotency - warm_up should not call tools again
|
||||
initial_count = len(warm_up_calls)
|
||||
component.warm_up()
|
||||
assert len(warm_up_calls) == initial_count
|
||||
|
||||
|
||||
class TestAzureOpenAIChatGeneratorAsync:
|
||||
def test_init_should_also_create_async_client_with_same_args(self, tools):
|
||||
async def test_warm_up_async_builds_async_client(self, tools):
|
||||
component = AzureOpenAIChatGenerator(
|
||||
api_key=Secret.from_token("test-api-key"),
|
||||
azure_endpoint="some-non-existing-endpoint",
|
||||
@@ -646,7 +547,10 @@ class TestAzureOpenAIChatGeneratorAsync:
|
||||
tools=tools,
|
||||
tools_strict=True,
|
||||
)
|
||||
assert component.async_client is None
|
||||
await component.warm_up_async()
|
||||
assert component.async_client.api_key == "test-api-key"
|
||||
assert component.client is None
|
||||
assert component.azure_deployment == "gpt-4.1-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
|
||||
@@ -672,9 +576,7 @@ class TestAzureOpenAIChatGeneratorAsync:
|
||||
assert "Paris" in message.text
|
||||
assert "gpt-4.1-mini" in message.meta["model"]
|
||||
assert message.meta["finish_reason"] == "stop"
|
||||
# Close async client; suppress RuntimeError if the event loop is already closed
|
||||
with contextlib.suppress(RuntimeError):
|
||||
await component.async_client.close()
|
||||
await component.close_async()
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(
|
||||
@@ -702,8 +604,133 @@ class TestAzureOpenAIChatGeneratorAsync:
|
||||
assert tool_call.arguments == {"city": "Paris"}
|
||||
assert message.meta["finish_reason"] == "tool_calls"
|
||||
|
||||
# Close async client; suppress RuntimeError if the event loop is already closed
|
||||
with contextlib.suppress(RuntimeError):
|
||||
await component.async_client.close()
|
||||
await component.close_async()
|
||||
|
||||
# additional tests intentionally omitted as they are covered by test_openai.py
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_azure_clients(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake")
|
||||
sync_cls = MagicMock(name="AzureOpenAI")
|
||||
async_cls = MagicMock(name="AsyncAzureOpenAI")
|
||||
async_cls.return_value.close = AsyncMock()
|
||||
monkeypatch.setattr(azure_chat_module, "AzureOpenAI", sync_cls)
|
||||
monkeypatch.setattr(azure_chat_module, "AsyncAzureOpenAI", async_cls)
|
||||
return sync_cls, async_cls
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 5
|
||||
assert generator.client.timeout == 30.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
|
||||
generator = AzureOpenAIChatGenerator(
|
||||
api_key=Secret.from_token("fake-api-key"),
|
||||
azure_endpoint="some-non-existing-endpoint",
|
||||
timeout=40.0,
|
||||
max_retries=1,
|
||||
)
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 1
|
||||
assert generator.client.timeout == 40.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
|
||||
generator = AzureOpenAIChatGenerator(
|
||||
api_key=Secret.from_token("fake-api-key"), azure_endpoint="some-non-existing-endpoint"
|
||||
)
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 10
|
||||
assert generator.client.timeout == 100.0
|
||||
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_AD_TOKEN", raising=False)
|
||||
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
with pytest.raises(OpenAIError):
|
||||
generator.warm_up()
|
||||
|
||||
def test_warm_up_warms_tools_once(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
warm_up_calls = []
|
||||
|
||||
class MockTool(Tool):
|
||||
def __init__(self, tool_name):
|
||||
super().__init__(
|
||||
name=tool_name,
|
||||
description=f"Mock tool {tool_name}",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
function=lambda x: x,
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
warm_up_calls.append(self.name)
|
||||
|
||||
generator = AzureOpenAIChatGenerator(
|
||||
azure_endpoint="some-non-existing-endpoint", tools=[MockTool("tool1"), MockTool("tool2")]
|
||||
)
|
||||
assert not generator._tools_warmed_up
|
||||
|
||||
generator.warm_up()
|
||||
assert sorted(warm_up_calls) == ["tool1", "tool2"]
|
||||
assert generator._tools_warmed_up
|
||||
|
||||
generator.warm_up()
|
||||
assert sorted(warm_up_calls) == ["tool1", "tool2"]
|
||||
|
||||
def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake-api-key")
|
||||
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
generator.warm_up()
|
||||
assert generator._tools_warmed_up
|
||||
|
||||
def test_sync_lifecycle(self, mock_azure_clients):
|
||||
sync_cls, _ = mock_azure_clients
|
||||
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
assert generator.client is None
|
||||
assert generator.async_client is None
|
||||
|
||||
generator.warm_up()
|
||||
assert generator.client is sync_cls.return_value
|
||||
assert generator.async_client is None
|
||||
|
||||
generator.close()
|
||||
sync_cls.return_value.close.assert_called_once()
|
||||
assert generator.client is None
|
||||
|
||||
async def test_async_lifecycle(self, mock_azure_clients):
|
||||
_, async_cls = mock_azure_clients
|
||||
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
|
||||
await generator.warm_up_async()
|
||||
assert generator.async_client is async_cls.return_value
|
||||
assert generator.client is None
|
||||
|
||||
await generator.close_async()
|
||||
async_cls.return_value.close.assert_awaited_once()
|
||||
assert generator.async_client is None
|
||||
|
||||
async def test_close_is_safe_without_warm_up(self, mock_azure_clients):
|
||||
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
generator.close()
|
||||
await generator.close_async()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is None
|
||||
|
||||
async def test_close_and_close_async_are_independent(self, mock_azure_clients):
|
||||
generator = AzureOpenAIChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
generator.warm_up()
|
||||
await generator.warm_up_async()
|
||||
|
||||
generator.close()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is not None
|
||||
|
||||
await generator.close_async()
|
||||
assert generator.async_client is None
|
||||
|
||||
@@ -7,7 +7,6 @@ import os
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from openai import OpenAIError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from haystack import Pipeline, component
|
||||
@@ -85,17 +84,12 @@ class TestInitialization:
|
||||
def test_init_default(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
assert component._azure_deployment == "gpt-5-mini"
|
||||
assert component.streaming_callback is None
|
||||
assert not component.generation_kwargs
|
||||
|
||||
def test_init_fail_wo_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with pytest.raises(OpenAIError):
|
||||
AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
|
||||
def test_init_fail_wo_azure_endpoint(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_OPENAI_ENDPOINT", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
@@ -110,7 +104,8 @@ class TestInitialization:
|
||||
tools=tools,
|
||||
tools_strict=True,
|
||||
)
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
assert component._azure_deployment == "gpt-5-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
|
||||
@@ -385,66 +380,8 @@ class TestSerDe:
|
||||
assert p.to_dict() == q.to_dict()
|
||||
|
||||
|
||||
class TestWarmUp:
|
||||
def test_warm_up_with_tools(self, monkeypatch):
|
||||
"""Test that warm_up() calls warm_up on tools and is idempotent."""
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
# Create a mock tool that tracks if warm_up() was called
|
||||
class MockTool(Tool):
|
||||
warm_up_call_count = 0 # Class variable to track calls
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="mock_tool",
|
||||
description="A mock tool for testing",
|
||||
parameters={"x": {"type": "string"}},
|
||||
function=lambda x: x,
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
MockTool.warm_up_call_count += 1
|
||||
|
||||
# Reset the class variable before test
|
||||
MockTool.warm_up_call_count = 0
|
||||
mock_tool = MockTool()
|
||||
|
||||
# Create AzureOpenAIChatGenerator with the mock tool
|
||||
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint", tools=[mock_tool])
|
||||
|
||||
# Verify initial state - warm_up not called yet
|
||||
assert MockTool.warm_up_call_count == 0
|
||||
assert not component._is_warmed_up
|
||||
|
||||
# Call warm_up() on the generator
|
||||
component.warm_up()
|
||||
|
||||
# Assert that the tool's warm_up() was called
|
||||
assert MockTool.warm_up_call_count == 1
|
||||
assert component._is_warmed_up
|
||||
|
||||
# Call warm_up() again and verify it's idempotent (only warms up once)
|
||||
component.warm_up()
|
||||
|
||||
# The tool's warm_up should still only have been called once
|
||||
assert MockTool.warm_up_call_count == 1
|
||||
assert component._is_warmed_up
|
||||
|
||||
def test_warm_up_with_no_tools(self, monkeypatch):
|
||||
"""Test that warm_up() works when no tools are provided."""
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
|
||||
# Verify initial state
|
||||
assert not component._is_warmed_up
|
||||
assert component.tools is None
|
||||
|
||||
# Verify the component is warmed up
|
||||
component.warm_up()
|
||||
assert component._is_warmed_up
|
||||
|
||||
def test_warm_up_with_multiple_tools(self, monkeypatch):
|
||||
"""Test that warm_up() works with multiple tools."""
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_warms_tools_once(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
warm_up_calls = []
|
||||
|
||||
@@ -454,27 +391,61 @@ class TestWarmUp:
|
||||
name=tool_name,
|
||||
description=f"Mock tool {tool_name}",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
function=lambda x: f"{tool_name} result: {x}",
|
||||
function=lambda x: x,
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
warm_up_calls.append(self.name)
|
||||
|
||||
# Use a LIST of tools, not a Toolset
|
||||
component = AzureOpenAIResponsesChatGenerator(
|
||||
azure_endpoint="some-non-existing-endpoint", tools=[MockTool("tool1"), MockTool("tool2")]
|
||||
)
|
||||
assert not component._tools_warmed_up
|
||||
|
||||
# Assert that both tools' warm_up() were called
|
||||
component.warm_up()
|
||||
assert "tool1" in warm_up_calls
|
||||
assert "tool2" in warm_up_calls
|
||||
assert component._is_warmed_up
|
||||
assert sorted(warm_up_calls) == ["tool1", "tool2"]
|
||||
assert component._tools_warmed_up
|
||||
|
||||
# Test idempotency - warm_up should not call tools again
|
||||
initial_count = len(warm_up_calls)
|
||||
component.warm_up()
|
||||
assert len(warm_up_calls) == initial_count
|
||||
assert sorted(warm_up_calls) == ["tool1", "tool2"]
|
||||
|
||||
def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
component.warm_up()
|
||||
assert component._tools_warmed_up
|
||||
|
||||
def test_sync_lifecycle(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
component.warm_up()
|
||||
assert component.client is not None
|
||||
assert component.async_client is None
|
||||
|
||||
component.close()
|
||||
assert component.client is None
|
||||
|
||||
async def test_async_lifecycle(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
|
||||
await component.warm_up_async()
|
||||
assert component.async_client is not None
|
||||
assert component.client is None
|
||||
|
||||
await component.close_async()
|
||||
assert component.async_client is None
|
||||
|
||||
async def test_close_is_safe_without_warm_up(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
component = AzureOpenAIResponsesChatGenerator(azure_endpoint="some-non-existing-endpoint")
|
||||
component.close()
|
||||
await component.close_async()
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -565,7 +536,7 @@ class TestIntegration:
|
||||
|
||||
|
||||
class TestAzureOpenAIResponsesChatGeneratorAsync:
|
||||
def test_init_should_also_create_async_client_with_same_args(self, tools):
|
||||
async def test_warm_up_async_creates_async_client_with_expected_args(self, tools):
|
||||
component = AzureOpenAIResponsesChatGenerator(
|
||||
api_key=Secret.from_token("test-api-key"),
|
||||
azure_endpoint="some-non-existing-endpoint",
|
||||
@@ -574,6 +545,10 @@ class TestAzureOpenAIResponsesChatGeneratorAsync:
|
||||
tools=tools,
|
||||
tools_strict=True,
|
||||
)
|
||||
assert component.async_client is None
|
||||
|
||||
await component.warm_up_async()
|
||||
|
||||
assert component.async_client.api_key == "test-api-key"
|
||||
assert component._azure_deployment == "gpt-5-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from urllib.error import HTTPError as URLLibHTTPError
|
||||
|
||||
import pytest
|
||||
@@ -375,69 +376,55 @@ async def test_failover_trigger_401_authentication_async():
|
||||
assert result["meta"]["failed_chat_generators"] == ["_DummyHTTPErrorGen"]
|
||||
|
||||
|
||||
@component
|
||||
class _DummyGenWithWarmUp:
|
||||
"""Dummy generator that tracks warm_up calls."""
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_every_generator(self):
|
||||
gens = [Mock(spec=["run", "warm_up"]) for _ in range(3)]
|
||||
fallback = FallbackChatGenerator(chat_generators=gens)
|
||||
fallback.warm_up()
|
||||
for gen in gens:
|
||||
gen.warm_up.assert_called_once()
|
||||
|
||||
def __init__(self, text: str = "ok"):
|
||||
self.text = text
|
||||
self.warm_up_called = False
|
||||
async def test_warm_up_async_delegates_to_every_generator(self):
|
||||
gens = [Mock(spec=["run", "warm_up_async"]) for _ in range(3)]
|
||||
for gen in gens:
|
||||
gen.warm_up_async = AsyncMock()
|
||||
fallback = FallbackChatGenerator(chat_generators=gens)
|
||||
await fallback.warm_up_async()
|
||||
for gen in gens:
|
||||
gen.warm_up_async.assert_awaited_once()
|
||||
|
||||
def warm_up(self) -> None:
|
||||
self.warm_up_called = True
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
gens = [Mock(spec=["run", "warm_up"]) for _ in range(3)]
|
||||
fallback = FallbackChatGenerator(chat_generators=gens)
|
||||
await fallback.warm_up_async()
|
||||
for gen in gens:
|
||||
gen.warm_up.assert_called_once()
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
tools: ToolsType | None = None,
|
||||
streaming_callback: StreamingCallbackT | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {"replies": [ChatMessage.from_assistant(self.text)], "meta": {}}
|
||||
def test_close_delegates_to_every_generator(self):
|
||||
gens = [Mock(spec=["run", "close"]) for _ in range(3)]
|
||||
fallback = FallbackChatGenerator(chat_generators=gens)
|
||||
fallback.close()
|
||||
for gen in gens:
|
||||
gen.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_every_generator(self):
|
||||
gens = [Mock(spec=["run", "close_async"]) for _ in range(3)]
|
||||
for gen in gens:
|
||||
gen.close_async = AsyncMock()
|
||||
fallback = FallbackChatGenerator(chat_generators=gens)
|
||||
await fallback.close_async()
|
||||
for gen in gens:
|
||||
gen.close_async.assert_awaited_once()
|
||||
|
||||
def test_warm_up_delegates_to_generators():
|
||||
"""Test that warm_up() is called on each underlying generator."""
|
||||
gen1 = _DummyGenWithWarmUp(text="A")
|
||||
gen2 = _DummyGenWithWarmUp(text="B")
|
||||
gen3 = _DummyGenWithWarmUp(text="C")
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
gens = [Mock(spec=["run", "close"]) for _ in range(3)]
|
||||
fallback = FallbackChatGenerator(chat_generators=gens)
|
||||
await fallback.close_async()
|
||||
for gen in gens:
|
||||
gen.close.assert_called_once()
|
||||
|
||||
fallback = FallbackChatGenerator(chat_generators=[gen1, gen2, gen3])
|
||||
fallback.warm_up()
|
||||
|
||||
assert gen1.warm_up_called
|
||||
assert gen2.warm_up_called
|
||||
assert gen3.warm_up_called
|
||||
|
||||
|
||||
def test_warm_up_with_no_warm_up_method():
|
||||
"""Test that warm_up() handles generators without warm_up() gracefully."""
|
||||
gen1 = _DummySuccessGen(text="A")
|
||||
gen2 = _DummySuccessGen(text="B")
|
||||
|
||||
fallback = FallbackChatGenerator(chat_generators=[gen1, gen2])
|
||||
# Should not raise any error
|
||||
fallback.warm_up()
|
||||
|
||||
# Verify generators still work
|
||||
result = fallback.run([ChatMessage.from_user("test")])
|
||||
assert result["replies"][0].text == "A"
|
||||
|
||||
|
||||
def test_warm_up_mixed_generators():
|
||||
"""Test warm_up() with a mix of generators with and without warm_up()."""
|
||||
gen1 = _DummyGenWithWarmUp(text="A")
|
||||
gen2 = _DummySuccessGen(text="B")
|
||||
gen3 = _DummyGenWithWarmUp(text="C")
|
||||
gen4 = _DummyFailGen()
|
||||
|
||||
fallback = FallbackChatGenerator(chat_generators=[gen1, gen2, gen3, gen4])
|
||||
fallback.warm_up()
|
||||
|
||||
# Only generators with warm_up() should have been called
|
||||
assert gen1.warm_up_called
|
||||
assert gen3.warm_up_called
|
||||
|
||||
# Verify the fallback still works correctly
|
||||
result = fallback.run([ChatMessage.from_user("test")])
|
||||
assert result["replies"][0].text == "A"
|
||||
def test_lifecycle_is_safe_when_generators_lack_methods(self):
|
||||
gens = [Mock(spec=["run"]) for _ in range(3)]
|
||||
fallback = FallbackChatGenerator(chat_generators=gens)
|
||||
fallback.warm_up()
|
||||
fallback.close()
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import OpenAIError
|
||||
@@ -29,6 +29,7 @@ from openai.types.chat.chat_completion_message_function_tool_call import Functio
|
||||
from openai.types.completion_usage import CompletionTokensDetails, CompletionUsage, PromptTokensDetails
|
||||
from pydantic import BaseModel
|
||||
|
||||
import haystack.components.generators.chat.openai as openai_chat_module
|
||||
from haystack import component
|
||||
from haystack.components.generators.chat.openai import (
|
||||
OpenAIChatGenerator,
|
||||
@@ -197,20 +198,17 @@ class TestOpenAIChatGenerator:
|
||||
def test_init_default(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
component = OpenAIChatGenerator()
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.api_key.resolve_value() == "test-api-key"
|
||||
assert component.model == "gpt-5-mini"
|
||||
assert component.streaming_callback is None
|
||||
assert not component.generation_kwargs
|
||||
assert component.client.timeout == 30
|
||||
assert component.client.max_retries == 5
|
||||
assert component.timeout is None
|
||||
assert component.max_retries is None
|
||||
assert component.tools is None
|
||||
assert not component.tools_strict
|
||||
assert component.http_client_kwargs is None
|
||||
|
||||
def test_init_fail_wo_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
OpenAIChatGenerator()
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_init_fail_with_duplicate_tool_names(self, monkeypatch, tools):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
@@ -235,15 +233,17 @@ class TestOpenAIChatGenerator:
|
||||
tools_strict=True,
|
||||
http_client_kwargs={"proxy": "http://example.com:8080", "verify": False},
|
||||
)
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.api_key.resolve_value() == "test-api-key"
|
||||
assert component.model == "gpt-5-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
|
||||
assert component.client.timeout == 40.0
|
||||
assert component.client.max_retries == 1
|
||||
assert component.timeout == 40.0
|
||||
assert component.max_retries == 1
|
||||
assert component.tools == [tool]
|
||||
assert component.tools_strict
|
||||
assert component.http_client_kwargs == {"proxy": "http://example.com:8080", "verify": False}
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_init_with_parameters_and_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
@@ -254,12 +254,14 @@ class TestOpenAIChatGenerator:
|
||||
api_base_url="test-base-url",
|
||||
generation_kwargs={"max_completion_tokens": 10, "some_test_param": "test-params"},
|
||||
)
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.api_key.resolve_value() == "test-api-key"
|
||||
assert component.model == "gpt-5-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
assert component.generation_kwargs == {"max_completion_tokens": 10, "some_test_param": "test-params"}
|
||||
assert component.client.timeout == 100.0
|
||||
assert component.client.max_retries == 10
|
||||
assert component.timeout is None
|
||||
assert component.max_retries is None
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_to_dict_default(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
@@ -420,11 +422,11 @@ class TestOpenAIChatGenerator:
|
||||
Tool(name="name", description="description", parameters={"x": {"type": "string"}}, function=print)
|
||||
]
|
||||
assert component.tools_strict
|
||||
assert component.client.timeout == 100.0
|
||||
assert component.client.max_retries == 10
|
||||
assert component.timeout == 100.0
|
||||
assert component.max_retries == 10
|
||||
assert component.http_client_kwargs == {"proxy": "http://example.com:8080", "verify": False}
|
||||
|
||||
def test_from_dict_fail_wo_env_var(self, monkeypatch):
|
||||
def test_from_dict_wo_env_var_does_not_fail(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
data = {
|
||||
"type": "haystack.components.generators.chat.openai.OpenAIChatGenerator",
|
||||
@@ -438,8 +440,9 @@ class TestOpenAIChatGenerator:
|
||||
"tools": None,
|
||||
},
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
OpenAIChatGenerator.from_dict(data)
|
||||
component = OpenAIChatGenerator.from_dict(data)
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_run(self, chat_messages, openai_mock_chat_completion):
|
||||
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
|
||||
@@ -574,6 +577,7 @@ class TestOpenAIChatGenerator:
|
||||
wrapped_openai_stream.__iter__.return_value = iter([chunk])
|
||||
|
||||
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
|
||||
component.warm_up()
|
||||
|
||||
with patch.object(
|
||||
component.client.chat.completions, "create", return_value=wrapped_openai_stream
|
||||
@@ -1218,112 +1222,6 @@ class TestOpenAIChatGenerator:
|
||||
assert len(deserialized.tools) == 2
|
||||
assert all(isinstance(ts, Toolset) for ts in deserialized.tools)
|
||||
|
||||
def test_warm_up_with_tools(self, monkeypatch):
|
||||
"""Test that warm_up() calls warm_up on tools and is idempotent."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
# Create a mock tool that tracks if warm_up() was called
|
||||
class MockTool(Tool):
|
||||
warm_up_call_count = 0 # Class variable to track calls
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="mock_tool",
|
||||
description="A mock tool for testing",
|
||||
parameters={"x": {"type": "string"}},
|
||||
function=lambda x: x,
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
MockTool.warm_up_call_count += 1
|
||||
|
||||
# Reset the class variable before test
|
||||
MockTool.warm_up_call_count = 0
|
||||
mock_tool = MockTool()
|
||||
|
||||
# Create OpenAIChatGenerator with the mock tool
|
||||
component = OpenAIChatGenerator(tools=[mock_tool])
|
||||
|
||||
# Verify initial state - warm_up not called yet
|
||||
assert MockTool.warm_up_call_count == 0
|
||||
assert not component._is_warmed_up
|
||||
|
||||
# Call warm_up() on the generator
|
||||
component.warm_up()
|
||||
|
||||
# Assert that the tool's warm_up() was called
|
||||
assert MockTool.warm_up_call_count == 1
|
||||
assert component._is_warmed_up
|
||||
|
||||
# Call warm_up() again and verify it's idempotent (only warms up once)
|
||||
component.warm_up()
|
||||
|
||||
# The tool's warm_up should still only have been called once
|
||||
assert MockTool.warm_up_call_count == 1
|
||||
assert component._is_warmed_up
|
||||
|
||||
def test_warm_up_with_no_tools(self, monkeypatch):
|
||||
"""Test that warm_up() works when no tools are provided."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
component = OpenAIChatGenerator()
|
||||
|
||||
# Verify initial state
|
||||
assert not component._is_warmed_up
|
||||
assert component.tools is None
|
||||
|
||||
# Call warm_up() - should not raise an error
|
||||
component.warm_up()
|
||||
|
||||
# Verify the component is warmed up
|
||||
assert component._is_warmed_up
|
||||
|
||||
# Call warm_up() again - should be idempotent
|
||||
component.warm_up()
|
||||
assert component._is_warmed_up
|
||||
|
||||
def test_warm_up_with_multiple_tools(self, monkeypatch):
|
||||
"""Test that warm_up() works with multiple tools."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
from haystack.tools import Tool
|
||||
|
||||
# Track warm_up calls
|
||||
warm_up_calls = []
|
||||
|
||||
class MockTool(Tool):
|
||||
def __init__(self, tool_name):
|
||||
super().__init__(
|
||||
name=tool_name,
|
||||
description=f"Mock tool {tool_name}",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
function=lambda x: f"{tool_name} result: {x}",
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
warm_up_calls.append(self.name)
|
||||
|
||||
mock_tool1 = MockTool("tool1")
|
||||
mock_tool2 = MockTool("tool2")
|
||||
|
||||
# Use a LIST of tools, not a Toolset
|
||||
component = OpenAIChatGenerator(tools=[mock_tool1, mock_tool2])
|
||||
|
||||
# Call warm_up()
|
||||
component.warm_up()
|
||||
|
||||
# Assert that both tools' warm_up() were called
|
||||
assert "tool1" in warm_up_calls
|
||||
assert "tool2" in warm_up_calls
|
||||
assert component._is_warmed_up
|
||||
|
||||
# Track count
|
||||
call_count = len(warm_up_calls)
|
||||
|
||||
# Verify idempotency
|
||||
component.warm_up()
|
||||
assert len(warm_up_calls) == call_count
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_completion_chunks():
|
||||
@@ -1761,6 +1659,123 @@ def streaming_chunks():
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_clients(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake")
|
||||
sync_cls = MagicMock(name="OpenAI")
|
||||
async_cls = MagicMock(name="AsyncOpenAI")
|
||||
async_cls.return_value.close = AsyncMock()
|
||||
monkeypatch.setattr(openai_chat_module, "OpenAI", sync_cls)
|
||||
monkeypatch.setattr(openai_chat_module, "AsyncOpenAI", async_cls)
|
||||
return sync_cls, async_cls
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
generator = OpenAIChatGenerator()
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 5
|
||||
assert generator.client.timeout == 30.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
|
||||
generator = OpenAIChatGenerator(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 1
|
||||
assert generator.client.timeout == 40.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
|
||||
generator = OpenAIChatGenerator(api_key=Secret.from_token("fake-api-key"))
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 10
|
||||
assert generator.client.timeout == 100.0
|
||||
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
generator = OpenAIChatGenerator()
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
generator.warm_up()
|
||||
|
||||
def test_warm_up_warms_tools_once(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
warm_up_calls = []
|
||||
|
||||
class MockTool(Tool):
|
||||
def __init__(self, tool_name):
|
||||
super().__init__(
|
||||
name=tool_name,
|
||||
description=f"Mock tool {tool_name}",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
function=lambda x: x,
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
warm_up_calls.append(self.name)
|
||||
|
||||
generator = OpenAIChatGenerator(tools=[MockTool("tool1"), MockTool("tool2")])
|
||||
assert not generator._tools_warmed_up
|
||||
|
||||
generator.warm_up()
|
||||
assert sorted(warm_up_calls) == ["tool1", "tool2"]
|
||||
assert generator._tools_warmed_up
|
||||
|
||||
generator.warm_up()
|
||||
assert sorted(warm_up_calls) == ["tool1", "tool2"]
|
||||
|
||||
def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
generator = OpenAIChatGenerator()
|
||||
generator.warm_up()
|
||||
assert generator._tools_warmed_up
|
||||
|
||||
def test_sync_lifecycle(self, mock_openai_clients):
|
||||
sync_cls, _ = mock_openai_clients
|
||||
generator = OpenAIChatGenerator()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is None
|
||||
|
||||
generator.warm_up()
|
||||
assert generator.client is sync_cls.return_value
|
||||
assert generator.async_client is None
|
||||
|
||||
generator.close()
|
||||
sync_cls.return_value.close.assert_called_once()
|
||||
assert generator.client is None
|
||||
|
||||
async def test_async_lifecycle(self, mock_openai_clients):
|
||||
_, async_cls = mock_openai_clients
|
||||
generator = OpenAIChatGenerator()
|
||||
|
||||
await generator.warm_up_async()
|
||||
assert generator.async_client is async_cls.return_value
|
||||
assert generator.client is None
|
||||
|
||||
await generator.close_async()
|
||||
async_cls.return_value.close.assert_awaited_once()
|
||||
assert generator.async_client is None
|
||||
|
||||
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
|
||||
generator = OpenAIChatGenerator()
|
||||
generator.close()
|
||||
await generator.close_async()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is None
|
||||
|
||||
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
|
||||
generator = OpenAIChatGenerator()
|
||||
generator.warm_up()
|
||||
await generator.warm_up_async()
|
||||
|
||||
generator.close()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is not None
|
||||
|
||||
await generator.close_async()
|
||||
assert generator.async_client is None
|
||||
|
||||
|
||||
class TestChatCompletionChunkConversion:
|
||||
def test_convert_chat_completion_chunk_to_streaming_chunk(self, chat_completion_chunks, streaming_chunks):
|
||||
previous_chunks = []
|
||||
|
||||
@@ -88,7 +88,7 @@ def tools():
|
||||
|
||||
|
||||
class TestOpenAIChatGeneratorAsync:
|
||||
def test_init_should_also_create_async_client_with_same_args(self, monkeypatch):
|
||||
async def test_warm_up_async_should_create_async_client_with_same_args(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
component = OpenAIChatGenerator(
|
||||
api_key=Secret.from_token("test-api-key"),
|
||||
@@ -97,6 +97,7 @@ class TestOpenAIChatGeneratorAsync:
|
||||
timeout=30,
|
||||
max_retries=5,
|
||||
)
|
||||
await component.warm_up_async()
|
||||
|
||||
assert isinstance(component.async_client, AsyncOpenAI)
|
||||
assert component.async_client.api_key == "test-api-key"
|
||||
@@ -477,6 +478,7 @@ class TestOpenAIChatGeneratorAsync:
|
||||
wrapped_openai_async_stream.__aiter__.return_value = iter([chunk])
|
||||
|
||||
component = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
|
||||
await component.warm_up_async()
|
||||
|
||||
# Patch the async client's create method
|
||||
with patch.object(
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI, OpenAIError
|
||||
from pydantic import BaseModel
|
||||
|
||||
import haystack.components.generators.chat.openai_responses as openai_responses_module
|
||||
from haystack import component
|
||||
from haystack.components.agents import Agent
|
||||
from haystack.components.generators.chat.openai_responses import OpenAIResponsesChatGenerator
|
||||
@@ -112,21 +113,18 @@ class TestInitialization:
|
||||
def test_init_default(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
component = OpenAIResponsesChatGenerator()
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
assert component.api_key == Secret.from_env_var("OPENAI_API_KEY")
|
||||
assert component.model == "gpt-5-mini"
|
||||
assert component.streaming_callback is None
|
||||
assert not component.generation_kwargs
|
||||
assert component.client.timeout == 30
|
||||
assert component.client.max_retries == 5
|
||||
assert component.timeout is None
|
||||
assert component.max_retries is None
|
||||
assert component.tools is None
|
||||
assert not component.tools_strict
|
||||
assert component.http_client_kwargs is None
|
||||
|
||||
def test_init_fail_wo_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
OpenAIResponsesChatGenerator()
|
||||
|
||||
def test_init_with_parameters(self, monkeypatch):
|
||||
tool = Tool(name="name", description="description", parameters={"x": {"type": "string"}}, function=lambda x: x)
|
||||
|
||||
@@ -144,12 +142,14 @@ class TestInitialization:
|
||||
tools_strict=True,
|
||||
http_client_kwargs={"proxy": "http://example.com:8080", "verify": False},
|
||||
)
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
assert component.api_key == Secret.from_token("test-api-key")
|
||||
assert component.model == "gpt-4o-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
assert component.generation_kwargs == {"max_tokens": 10, "some_test_param": "test-params"}
|
||||
assert component.client.timeout == 40.0
|
||||
assert component.client.max_retries == 1
|
||||
assert component.timeout == 40.0
|
||||
assert component.max_retries == 1
|
||||
assert component.tools == [tool]
|
||||
assert component.tools_strict
|
||||
assert component.http_client_kwargs == {"proxy": "http://example.com:8080", "verify": False}
|
||||
@@ -164,12 +164,14 @@ class TestInitialization:
|
||||
api_base_url="test-base-url",
|
||||
generation_kwargs={"max_tokens": 10, "some_test_param": "test-params"},
|
||||
)
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
assert component.api_key == Secret.from_token("test-api-key")
|
||||
assert component.model == "gpt-4o-mini"
|
||||
assert component.streaming_callback is print_streaming_chunk
|
||||
assert component.generation_kwargs == {"max_tokens": 10, "some_test_param": "test-params"}
|
||||
assert component.client.timeout == 100.0
|
||||
assert component.client.max_retries == 10
|
||||
assert component.timeout is None
|
||||
assert component.max_retries is None
|
||||
|
||||
def test_init_with_toolset(self, tools, monkeypatch):
|
||||
"""Test that the OpenAIChatGenerator can be initialized with a Toolset."""
|
||||
@@ -310,11 +312,11 @@ class TestSerDe:
|
||||
Tool(name="name", description="description", parameters={"x": {"type": "string"}}, function=print)
|
||||
]
|
||||
assert component.tools_strict
|
||||
assert component.client.timeout == 100.0
|
||||
assert component.client.max_retries == 10
|
||||
assert component.timeout == 100.0
|
||||
assert component.max_retries == 10
|
||||
assert component.http_client_kwargs == {"proxy": "http://example.com:8080", "verify": False}
|
||||
|
||||
def test_from_dict_fail_wo_env_var(self, monkeypatch):
|
||||
def test_from_dict_wo_env_var_does_not_fail(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
data = {
|
||||
"type": "haystack.components.generators.chat.openai_responses.OpenAIResponsesChatGenerator",
|
||||
@@ -328,8 +330,10 @@ class TestSerDe:
|
||||
"tools": None,
|
||||
},
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
OpenAIResponsesChatGenerator.from_dict(data)
|
||||
component = OpenAIResponsesChatGenerator.from_dict(data)
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
assert component.api_key == Secret.from_env_var("OPENAI_API_KEY")
|
||||
|
||||
def test_from_dict_with_toolset(self, tools, monkeypatch):
|
||||
"""Test that the OpenAIChatGenerator can be deserialized from a dictionary with a Toolset."""
|
||||
@@ -345,68 +349,80 @@ class TestSerDe:
|
||||
assert all(isinstance(tool, Tool) for tool in deserialized_component.tools)
|
||||
|
||||
|
||||
class TestWarmUp:
|
||||
def test_warm_up_with_tools(self, monkeypatch):
|
||||
"""Test that warm_up() calls warm_up on tools and is idempotent."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
@pytest.fixture
|
||||
def mock_openai_clients(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake")
|
||||
sync_cls = MagicMock(name="OpenAI")
|
||||
async_cls = MagicMock(name="AsyncOpenAI")
|
||||
async_cls.return_value.close = AsyncMock()
|
||||
monkeypatch.setattr(openai_responses_module, "OpenAI", sync_cls)
|
||||
monkeypatch.setattr(openai_responses_module, "AsyncOpenAI", async_cls)
|
||||
return sync_cls, async_cls
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
generator = OpenAIResponsesChatGenerator()
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 5
|
||||
assert generator.client.timeout == 30.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
|
||||
generator = OpenAIResponsesChatGenerator(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 1
|
||||
assert generator.client.timeout == 40.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
|
||||
generator = OpenAIResponsesChatGenerator(api_key=Secret.from_token("fake-api-key"))
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 10
|
||||
assert generator.client.timeout == 100.0
|
||||
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
generator = OpenAIResponsesChatGenerator()
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
generator.warm_up()
|
||||
|
||||
def test_warm_up_warms_tools_once(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
warm_up_calls = []
|
||||
|
||||
# Create a mock tool that tracks if warm_up() was called
|
||||
class MockTool(Tool):
|
||||
warm_up_call_count = 0 # Class variable to track calls
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, tool_name):
|
||||
super().__init__(
|
||||
name="mock_tool",
|
||||
description="A mock tool for testing",
|
||||
parameters={"x": {"type": "string"}},
|
||||
name=tool_name,
|
||||
description=f"Mock tool {tool_name}",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
function=lambda x: x,
|
||||
)
|
||||
|
||||
def warm_up(self):
|
||||
MockTool.warm_up_call_count += 1
|
||||
warm_up_calls.append(self.name)
|
||||
|
||||
# Reset the class variable before test
|
||||
MockTool.warm_up_call_count = 0
|
||||
mock_tool = MockTool()
|
||||
generator = OpenAIResponsesChatGenerator(tools=[MockTool("tool1"), MockTool("tool2")])
|
||||
assert not generator._tools_warmed_up
|
||||
|
||||
# Create OpenAIChatGenerator with the mock tool
|
||||
component = OpenAIResponsesChatGenerator(tools=[mock_tool])
|
||||
generator.warm_up()
|
||||
assert sorted(warm_up_calls) == ["tool1", "tool2"]
|
||||
assert generator._tools_warmed_up
|
||||
|
||||
# Verify initial state - warm_up not called yet
|
||||
assert MockTool.warm_up_call_count == 0
|
||||
assert not component._is_warmed_up
|
||||
generator.warm_up()
|
||||
assert sorted(warm_up_calls) == ["tool1", "tool2"]
|
||||
|
||||
# Call warm_up() on the generator
|
||||
component.warm_up()
|
||||
def test_warm_up_with_no_tools_does_not_raise(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
generator = OpenAIResponsesChatGenerator()
|
||||
generator.warm_up()
|
||||
assert generator._tools_warmed_up
|
||||
|
||||
# Assert that the tool's warm_up() was called
|
||||
assert MockTool.warm_up_call_count == 1
|
||||
assert component._is_warmed_up
|
||||
|
||||
component.warm_up()
|
||||
|
||||
# The tool's warm_up should still only have been called once
|
||||
assert MockTool.warm_up_call_count == 1
|
||||
assert component._is_warmed_up
|
||||
|
||||
def test_warm_up_with_no_tools(self, monkeypatch):
|
||||
"""Test that warm_up() works when no tools are provided."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
component = OpenAIResponsesChatGenerator()
|
||||
|
||||
# Verify initial state
|
||||
assert not component._is_warmed_up
|
||||
assert component.tools is None
|
||||
|
||||
# Verify the component is warmed up
|
||||
component.warm_up()
|
||||
assert component._is_warmed_up
|
||||
|
||||
def test_warm_up_with_openai_tools(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
component = OpenAIResponsesChatGenerator(
|
||||
def test_warm_up_with_openai_tools_does_not_raise(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
generator = OpenAIResponsesChatGenerator(
|
||||
tools=[
|
||||
{"type": "web_search_preview"},
|
||||
{
|
||||
@@ -418,49 +434,53 @@ class TestWarmUp:
|
||||
},
|
||||
]
|
||||
)
|
||||
generator.warm_up()
|
||||
assert generator._tools_warmed_up
|
||||
|
||||
# Make sure the component can still be warmed up even when using openai tools
|
||||
assert not component._is_warmed_up
|
||||
component.warm_up()
|
||||
assert component._is_warmed_up
|
||||
def test_sync_lifecycle(self, mock_openai_clients):
|
||||
sync_cls, _ = mock_openai_clients
|
||||
generator = OpenAIResponsesChatGenerator()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is None
|
||||
|
||||
def test_warm_up_with_multiple_tools(self, monkeypatch):
|
||||
"""Test that warm_up() works with multiple tools."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
generator.warm_up()
|
||||
assert generator.client is sync_cls.return_value
|
||||
assert generator.async_client is None
|
||||
|
||||
from haystack.tools import Tool
|
||||
generator.close()
|
||||
sync_cls.return_value.close.assert_called_once()
|
||||
assert generator.client is None
|
||||
|
||||
# Track warm_up calls
|
||||
warm_up_calls = []
|
||||
async def test_async_lifecycle(self, mock_openai_clients):
|
||||
_, async_cls = mock_openai_clients
|
||||
generator = OpenAIResponsesChatGenerator()
|
||||
|
||||
class MockTool(Tool):
|
||||
def __init__(self, tool_name):
|
||||
super().__init__(
|
||||
name=tool_name,
|
||||
description=f"Mock tool {tool_name}",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
function=lambda x: f"{tool_name} result: {x}",
|
||||
)
|
||||
await generator.warm_up_async()
|
||||
assert generator.async_client is async_cls.return_value
|
||||
assert generator.client is None
|
||||
|
||||
def warm_up(self):
|
||||
warm_up_calls.append(self.name)
|
||||
await generator.close_async()
|
||||
async_cls.return_value.close.assert_awaited_once()
|
||||
assert generator.async_client is None
|
||||
|
||||
mock_tool1 = MockTool("tool1")
|
||||
mock_tool2 = MockTool("tool2")
|
||||
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
|
||||
generator = OpenAIResponsesChatGenerator()
|
||||
generator.close()
|
||||
await generator.close_async()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is None
|
||||
|
||||
# Use a LIST of tools, not a Toolset
|
||||
component = OpenAIResponsesChatGenerator(tools=[mock_tool1, mock_tool2])
|
||||
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
|
||||
generator = OpenAIResponsesChatGenerator()
|
||||
generator.warm_up()
|
||||
await generator.warm_up_async()
|
||||
|
||||
# Assert that both tools' warm_up() were called
|
||||
component.warm_up()
|
||||
assert "tool1" in warm_up_calls
|
||||
assert "tool2" in warm_up_calls
|
||||
assert component._is_warmed_up
|
||||
generator.close()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is not None
|
||||
|
||||
# Verify idempotency
|
||||
call_count = len(warm_up_calls)
|
||||
component.warm_up()
|
||||
assert len(warm_up_calls) == call_count
|
||||
await generator.close_async()
|
||||
assert generator.async_client is None
|
||||
|
||||
|
||||
class TestRun:
|
||||
@@ -975,7 +995,7 @@ class TestIntegration:
|
||||
|
||||
|
||||
class TestOpenAIResponsesChatGeneratorAsync:
|
||||
def test_init_should_also_create_async_client_with_same_args(self, monkeypatch):
|
||||
async def test_warm_up_async_creates_async_client_with_expected_args(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
component = OpenAIResponsesChatGenerator(
|
||||
api_key=Secret.from_token("test-api-key"),
|
||||
@@ -984,6 +1004,9 @@ class TestOpenAIResponsesChatGeneratorAsync:
|
||||
timeout=30,
|
||||
max_retries=5,
|
||||
)
|
||||
assert component.async_client is None
|
||||
|
||||
await component.warm_up_async()
|
||||
|
||||
assert isinstance(component.async_client, AsyncOpenAI)
|
||||
assert component.async_client.api_key == "test-api-key"
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
import base64
|
||||
import os
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types import ImagesResponse
|
||||
from openai.types.image import Image
|
||||
|
||||
import haystack.components.generators.openai_image_generator as openai_image_generator_module
|
||||
from haystack.components.generators.openai_image_generator import OpenAIImageGenerator
|
||||
from haystack.utils import Secret
|
||||
|
||||
@@ -34,9 +35,11 @@ class TestOpenAIImageGenerator:
|
||||
assert component.api_key == Secret.from_env_var("OPENAI_API_KEY")
|
||||
assert component.api_base_url is None
|
||||
assert component.organization is None
|
||||
assert pytest.approx(component.timeout) == 30.0
|
||||
assert component.max_retries == 5
|
||||
assert component.timeout is None
|
||||
assert component.max_retries is None
|
||||
assert component.http_client_kwargs is None
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_init_with_params(self, monkeypatch):
|
||||
component = OpenAIImageGenerator(
|
||||
@@ -57,11 +60,10 @@ class TestOpenAIImageGenerator:
|
||||
assert component.organization == "test-org"
|
||||
assert pytest.approx(component.timeout) == 60.0
|
||||
assert component.max_retries == 10
|
||||
assert component.client is None
|
||||
assert component.async_client is None
|
||||
|
||||
def test_init_max_retries_0(self, monkeypatch):
|
||||
"""
|
||||
Test that the max_retries parameter is taken into account even if it is 0.
|
||||
"""
|
||||
component = OpenAIImageGenerator(max_retries=0)
|
||||
assert component.max_retries == 0
|
||||
|
||||
@@ -74,14 +76,6 @@ class TestOpenAIImageGenerator:
|
||||
OpenAIImageGenerator(response_format="url") # type: ignore[arg-type]
|
||||
assert "response_format is ignored" in caplog.text
|
||||
|
||||
def test_warm_up(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
component = OpenAIImageGenerator()
|
||||
component.warm_up()
|
||||
assert component.client.api_key == "test-api-key"
|
||||
assert component.client.timeout == 30
|
||||
assert component.client.max_retries == 5
|
||||
|
||||
def test_to_dict(self):
|
||||
generator = OpenAIImageGenerator()
|
||||
data = generator.to_dict()
|
||||
@@ -156,13 +150,14 @@ class TestOpenAIImageGenerator:
|
||||
assert generator.api_key.to_dict() == {"type": "env_var", "env_vars": ["OPENAI_API_KEY"], "strict": True}
|
||||
assert generator.api_base_url is None
|
||||
assert generator.organization is None
|
||||
assert pytest.approx(generator.timeout) == 30.0
|
||||
assert generator.max_retries == 5
|
||||
assert generator.timeout is None
|
||||
assert generator.max_retries is None
|
||||
assert generator.http_client_kwargs is None
|
||||
|
||||
def test_run(self, mock_image_response):
|
||||
generator = OpenAIImageGenerator(api_key=Secret.from_token("test-api-key"))
|
||||
response = generator.run("Show me a picture of a black cat.")
|
||||
assert generator.client is not None
|
||||
assert isinstance(response, dict)
|
||||
assert "images" in response and "revised_prompt" in response
|
||||
assert response["images"] == ["test-b64-json"]
|
||||
@@ -193,17 +188,17 @@ class TestOpenAIImageGeneratorAsync:
|
||||
component = OpenAIImageGenerator()
|
||||
assert component.async_client is None
|
||||
|
||||
def test_async_client_after_warm_up(self, monkeypatch):
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_client_after_warm_up_async(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
component = OpenAIImageGenerator()
|
||||
component.warm_up()
|
||||
await component.warm_up_async()
|
||||
assert isinstance(component.async_client, AsyncOpenAI)
|
||||
assert component.async_client.api_key == "test-api-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async(self):
|
||||
generator = OpenAIImageGenerator(api_key=Secret.from_token("test-api-key"))
|
||||
generator.warm_up()
|
||||
|
||||
image_response = ImagesResponse(
|
||||
created=1630000000, data=[Image(b64_json="test-b64-json", revised_prompt="test-prompt")]
|
||||
@@ -254,3 +249,88 @@ class TestOpenAIImageGeneratorAsync:
|
||||
|
||||
decoded = base64.b64decode(image_str, validate=True)
|
||||
assert decoded.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_clients(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake")
|
||||
sync_cls = MagicMock(name="OpenAI")
|
||||
async_cls = MagicMock(name="AsyncOpenAI")
|
||||
async_cls.return_value.close = AsyncMock()
|
||||
monkeypatch.setattr(openai_image_generator_module, "OpenAI", sync_cls)
|
||||
monkeypatch.setattr(openai_image_generator_module, "AsyncOpenAI", async_cls)
|
||||
return sync_cls, async_cls
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_uses_default_timeout_and_max_retries(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
||||
generator = OpenAIImageGenerator()
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 5
|
||||
assert generator.client.timeout == 30.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_parameters(self):
|
||||
generator = OpenAIImageGenerator(api_key=Secret.from_token("fake-api-key"), timeout=40.0, max_retries=1)
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 1
|
||||
assert generator.client.timeout == 40.0
|
||||
|
||||
def test_warm_up_uses_timeout_and_max_retries_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_TIMEOUT", "100")
|
||||
monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
|
||||
generator = OpenAIImageGenerator(api_key=Secret.from_token("fake-api-key"))
|
||||
generator.warm_up()
|
||||
assert generator.client.max_retries == 10
|
||||
assert generator.client.timeout == 100.0
|
||||
|
||||
def test_key_resolved_at_warm_up_not_init(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
generator = OpenAIImageGenerator()
|
||||
with pytest.raises(ValueError, match="None of the .* environment variables are set"):
|
||||
generator.warm_up()
|
||||
|
||||
def test_sync_lifecycle(self, mock_openai_clients):
|
||||
sync_cls, _ = mock_openai_clients
|
||||
generator = OpenAIImageGenerator()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is None
|
||||
|
||||
generator.warm_up()
|
||||
assert generator.client is sync_cls.return_value
|
||||
assert generator.async_client is None
|
||||
|
||||
generator.close()
|
||||
sync_cls.return_value.close.assert_called_once()
|
||||
assert generator.client is None
|
||||
|
||||
async def test_async_lifecycle(self, mock_openai_clients):
|
||||
_, async_cls = mock_openai_clients
|
||||
generator = OpenAIImageGenerator()
|
||||
|
||||
await generator.warm_up_async()
|
||||
assert generator.async_client is async_cls.return_value
|
||||
assert generator.client is None
|
||||
|
||||
await generator.close_async()
|
||||
async_cls.return_value.close.assert_awaited_once()
|
||||
assert generator.async_client is None
|
||||
|
||||
async def test_close_is_safe_without_warm_up(self, mock_openai_clients):
|
||||
generator = OpenAIImageGenerator()
|
||||
generator.close()
|
||||
await generator.close_async()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is None
|
||||
|
||||
async def test_close_and_close_async_are_independent(self, mock_openai_clients):
|
||||
generator = OpenAIImageGenerator()
|
||||
generator.warm_up()
|
||||
await generator.warm_up_async()
|
||||
|
||||
generator.close()
|
||||
assert generator.client is None
|
||||
assert generator.async_client is not None
|
||||
|
||||
await generator.close_async()
|
||||
assert generator.async_client is None
|
||||
|
||||
@@ -53,45 +53,10 @@ class TestEmbeddingBasedDocumentSplitter:
|
||||
with pytest.raises(ValueError, match="max_length must be greater than min_length"):
|
||||
EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder, min_length=100, max_length=50)
|
||||
|
||||
def test_warm_up(self):
|
||||
mock_embedder = Mock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch(
|
||||
"haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"
|
||||
) as mock_splitter_class:
|
||||
mock_splitter = Mock()
|
||||
mock_splitter_class.return_value = mock_splitter
|
||||
|
||||
splitter.warm_up()
|
||||
|
||||
assert splitter.sentence_splitter == mock_splitter
|
||||
mock_splitter_class.assert_called_once()
|
||||
|
||||
def test_run_not_warmed_up(self):
|
||||
mock_embedder = Mock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch.object(splitter, "warm_up", wraps=splitter.warm_up) as mock_warm_up:
|
||||
splitter.run(documents=[])
|
||||
assert splitter._is_warmed_up
|
||||
mock_warm_up.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_not_warmed_up_async(self) -> None:
|
||||
mock_embedder = AsyncMock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch.object(splitter, "warm_up", wraps=splitter.warm_up) as mock_warm_up:
|
||||
await splitter.run_async(documents=[])
|
||||
assert splitter._is_warmed_up
|
||||
mock_warm_up.assert_called_once()
|
||||
|
||||
def test_run_invalid_input(self):
|
||||
mock_embedder = Mock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
splitter.sentence_splitter = Mock()
|
||||
splitter._is_warmed_up = True
|
||||
|
||||
with pytest.raises(TypeError, match="expects a List of Documents"):
|
||||
splitter.run(documents="not a list")
|
||||
@@ -101,7 +66,6 @@ class TestEmbeddingBasedDocumentSplitter:
|
||||
mock_embedder = AsyncMock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
splitter.sentence_splitter = AsyncMock()
|
||||
splitter._is_warmed_up = True
|
||||
|
||||
with pytest.raises(TypeError, match="expects a List of Documents"):
|
||||
await splitter.run_async(documents="not a list")
|
||||
@@ -110,7 +74,6 @@ class TestEmbeddingBasedDocumentSplitter:
|
||||
mock_embedder = Mock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
splitter.sentence_splitter = Mock()
|
||||
splitter._is_warmed_up = True
|
||||
|
||||
with pytest.raises(ValueError, match="content for document ID"):
|
||||
splitter.run(documents=[Document(content=None)])
|
||||
@@ -120,7 +83,6 @@ class TestEmbeddingBasedDocumentSplitter:
|
||||
mock_embedder = AsyncMock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
splitter.sentence_splitter = AsyncMock()
|
||||
splitter._is_warmed_up = True
|
||||
|
||||
with pytest.raises(ValueError, match="content for document ID"):
|
||||
await splitter.run_async(documents=[Document(content=None)])
|
||||
@@ -129,7 +91,6 @@ class TestEmbeddingBasedDocumentSplitter:
|
||||
mock_embedder = Mock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
splitter.sentence_splitter = Mock()
|
||||
splitter._is_warmed_up = True
|
||||
|
||||
result = splitter.run(documents=[Document(content="")])
|
||||
assert result["documents"] == []
|
||||
@@ -139,7 +100,6 @@ class TestEmbeddingBasedDocumentSplitter:
|
||||
mock_embedder = AsyncMock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
splitter.sentence_splitter = AsyncMock()
|
||||
splitter._is_warmed_up = True
|
||||
|
||||
result = await splitter.run_async(documents=[Document(content="")])
|
||||
assert result["documents"] == []
|
||||
@@ -777,3 +737,97 @@ Artificial intelligence is transforming education by enabling personalized learn
|
||||
assert split_doc.meta["page_number"] == 3
|
||||
if i in [9, 10]:
|
||||
assert split_doc.meta["page_number"] == 4
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_builds_splitter_and_delegates_to_embedder(self):
|
||||
mock_embedder = Mock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch(
|
||||
"haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"
|
||||
) as mock_splitter_class:
|
||||
splitter.warm_up()
|
||||
|
||||
assert splitter.sentence_splitter is mock_splitter_class.return_value
|
||||
mock_splitter_class.assert_called_once()
|
||||
mock_embedder.warm_up.assert_called_once()
|
||||
|
||||
def test_warm_up_builds_splitter_once(self):
|
||||
mock_embedder = Mock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch(
|
||||
"haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"
|
||||
) as mock_splitter_class:
|
||||
splitter.warm_up()
|
||||
first_splitter = splitter.sentence_splitter
|
||||
splitter.warm_up()
|
||||
|
||||
mock_splitter_class.assert_called_once()
|
||||
assert splitter.sentence_splitter is first_splitter
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warm_up_async_delegates_to_embedder_async(self) -> None:
|
||||
mock_embedder = Mock()
|
||||
mock_embedder.warm_up_async = AsyncMock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch("haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"):
|
||||
await splitter.warm_up_async()
|
||||
|
||||
mock_embedder.warm_up_async.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self) -> None:
|
||||
mock_embedder = Mock(spec=["warm_up"])
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch("haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"):
|
||||
await splitter.warm_up_async()
|
||||
|
||||
mock_embedder.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_embedder(self):
|
||||
mock_embedder = Mock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
splitter.close()
|
||||
|
||||
mock_embedder.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_async_delegates_to_embedder(self) -> None:
|
||||
mock_embedder = Mock()
|
||||
mock_embedder.close_async = AsyncMock()
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
await splitter.close_async()
|
||||
|
||||
mock_embedder.close_async.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_async_falls_back_to_sync_close(self) -> None:
|
||||
mock_embedder = Mock(spec=["close"])
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
await splitter.close_async()
|
||||
|
||||
mock_embedder.close.assert_called_once()
|
||||
|
||||
def test_lifecycle_is_safe_when_embedder_lacks_methods(self):
|
||||
mock_embedder = Mock(spec=[])
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch("haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"):
|
||||
splitter.warm_up()
|
||||
splitter.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_is_safe_when_embedder_lacks_methods_async(self) -> None:
|
||||
mock_embedder = Mock(spec=[])
|
||||
splitter = EmbeddingBasedDocumentSplitter(document_embedder=mock_embedder)
|
||||
|
||||
with patch("haystack.components.preprocessors.embedding_based_document_splitter.SentenceSplitter"):
|
||||
await splitter.warm_up_async()
|
||||
await splitter.close_async()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from pytest import LogCaptureFixture
|
||||
@@ -998,3 +999,39 @@ def test_recursive_splitter_generates_unique_ids_and_correct_meta():
|
||||
for idx, chunk in enumerate(chunks):
|
||||
assert chunk.meta["parent_id"] == source_doc.id
|
||||
assert chunk.meta["split_id"] == idx
|
||||
|
||||
|
||||
def test_warm_up_is_idempotent_sentence(monkeypatch):
|
||||
splitter = RecursiveDocumentSplitter(separators=["sentence", " "])
|
||||
|
||||
calls = []
|
||||
original = RecursiveDocumentSplitter._get_custom_sentence_tokenizer
|
||||
|
||||
def spy(params):
|
||||
calls.append(params)
|
||||
return original(params)
|
||||
|
||||
monkeypatch.setattr(RecursiveDocumentSplitter, "_get_custom_sentence_tokenizer", staticmethod(spy))
|
||||
|
||||
splitter.warm_up()
|
||||
first_tokenizer = splitter.nltk_tokenizer
|
||||
splitter.warm_up()
|
||||
|
||||
assert len(calls) == 1
|
||||
assert splitter.nltk_tokenizer is first_tokenizer
|
||||
|
||||
|
||||
def test_warm_up_is_idempotent_token(monkeypatch):
|
||||
import haystack.components.preprocessors.recursive_splitter as mod
|
||||
|
||||
sentinel = object()
|
||||
get_encoding = Mock(return_value=sentinel)
|
||||
monkeypatch.setattr(mod.tiktoken, "get_encoding", get_encoding)
|
||||
|
||||
splitter = RecursiveDocumentSplitter(split_unit="token", split_length=10)
|
||||
|
||||
splitter.warm_up()
|
||||
splitter.warm_up()
|
||||
|
||||
assert get_encoding.call_count == 1
|
||||
assert splitter.tiktoken_tokenizer is sentinel
|
||||
|
||||
@@ -18,13 +18,6 @@ def mock_chat_generator():
|
||||
return Mock(spec=OpenAIChatGenerator)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_generator_with_warm_up():
|
||||
mock_generator = Mock(spec=OpenAIChatGenerator)
|
||||
mock_generator.warm_up = lambda: None
|
||||
return mock_generator
|
||||
|
||||
|
||||
class TestQueryExpander:
|
||||
def test_init_default_generator(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key-12345")
|
||||
@@ -42,20 +35,13 @@ class TestQueryExpander:
|
||||
assert expander.n_expansions == 3
|
||||
assert expander.chat_generator is mock_chat_generator
|
||||
|
||||
def test_run_warm_up(self, mock_chat_generator_with_warm_up):
|
||||
expander = QueryExpander(chat_generator=mock_chat_generator_with_warm_up)
|
||||
mock_chat_generator_with_warm_up.run.return_value = {"queries": ["test query"]}
|
||||
def test_run_warms_up_chat_generator(self, mock_chat_generator):
|
||||
expander = QueryExpander(chat_generator=mock_chat_generator)
|
||||
mock_chat_generator.run.return_value = {"replies": [ChatMessage.from_assistant("1. test query")]}
|
||||
|
||||
expander.warm_up()
|
||||
expander.run("test query")
|
||||
|
||||
assert expander._is_warmed_up is True
|
||||
assert expander.run("test query") == {"queries": ["test query"]}
|
||||
|
||||
def test_warm_up(self, mock_chat_generator):
|
||||
expander = QueryExpander(chat_generator=mock_chat_generator)
|
||||
expander.warm_up()
|
||||
assert expander._is_warmed_up is True
|
||||
mock_chat_generator.warm_up.assert_called()
|
||||
|
||||
def test_init_negative_expansions_raises_error(self):
|
||||
with pytest.raises(ValueError, match="n_expansions must be positive"):
|
||||
@@ -492,3 +478,45 @@ class TestQueryExpanderIntegration:
|
||||
|
||||
# Should be different from original
|
||||
assert query not in result["queries"]
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_chat_generator(self, mock_chat_generator):
|
||||
expander = QueryExpander(chat_generator=mock_chat_generator)
|
||||
expander.warm_up()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_chat_generator(self, mock_chat_generator):
|
||||
mock_chat_generator.warm_up_async = AsyncMock()
|
||||
expander = QueryExpander(chat_generator=mock_chat_generator)
|
||||
await expander.warm_up_async()
|
||||
mock_chat_generator.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
chat_generator = Mock(spec=["run", "warm_up"])
|
||||
expander = QueryExpander(chat_generator=chat_generator)
|
||||
await expander.warm_up_async()
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_chat_generator(self, mock_chat_generator):
|
||||
expander = QueryExpander(chat_generator=mock_chat_generator)
|
||||
expander.close()
|
||||
mock_chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_chat_generator(self, mock_chat_generator):
|
||||
mock_chat_generator.close_async = AsyncMock()
|
||||
expander = QueryExpander(chat_generator=mock_chat_generator)
|
||||
await expander.close_async()
|
||||
mock_chat_generator.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
chat_generator = Mock(spec=["run", "close"])
|
||||
expander = QueryExpander(chat_generator=chat_generator)
|
||||
await expander.close_async()
|
||||
chat_generator.close.assert_called_once()
|
||||
|
||||
def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
|
||||
chat_generator = Mock(spec=["run"])
|
||||
expander = QueryExpander(chat_generator=chat_generator)
|
||||
expander.warm_up()
|
||||
expander.close()
|
||||
|
||||
@@ -437,3 +437,45 @@ class TestLLMRankerAsync:
|
||||
assert result["documents"]
|
||||
assert result["documents"][0].id == "doc-berlin"
|
||||
assert len(result["documents"]) <= 2
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_chat_generator(self, mock_chat_generator):
|
||||
ranker = LLMRanker(chat_generator=mock_chat_generator)
|
||||
ranker.warm_up()
|
||||
mock_chat_generator.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_chat_generator(self, mock_chat_generator):
|
||||
mock_chat_generator.warm_up_async = AsyncMock()
|
||||
ranker = LLMRanker(chat_generator=mock_chat_generator)
|
||||
await ranker.warm_up_async()
|
||||
mock_chat_generator.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
chat_generator = Mock(spec=["run", "warm_up"])
|
||||
ranker = LLMRanker(chat_generator=chat_generator)
|
||||
await ranker.warm_up_async()
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_chat_generator(self, mock_chat_generator):
|
||||
ranker = LLMRanker(chat_generator=mock_chat_generator)
|
||||
ranker.close()
|
||||
mock_chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_chat_generator(self, mock_chat_generator):
|
||||
mock_chat_generator.close_async = AsyncMock()
|
||||
ranker = LLMRanker(chat_generator=mock_chat_generator)
|
||||
await ranker.close_async()
|
||||
mock_chat_generator.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
chat_generator = Mock(spec=["run", "close"])
|
||||
ranker = LLMRanker(chat_generator=chat_generator)
|
||||
await ranker.close_async()
|
||||
chat_generator.close.assert_called_once()
|
||||
|
||||
def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
|
||||
chat_generator = Mock(spec=["run"])
|
||||
ranker = LLMRanker(chat_generator=chat_generator)
|
||||
ranker.warm_up()
|
||||
ranker.close()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import ANY
|
||||
from unittest.mock import ANY, AsyncMock, Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -280,3 +280,66 @@ class TestMultiQueryEmbeddingRetriever:
|
||||
# assert there are not duplicates
|
||||
ids = [doc.id for doc in results["multiquery_retriever"]["documents"]]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_inner_components(self):
|
||||
query_embedder = Mock(spec=["run", "warm_up"])
|
||||
retriever = Mock(spec=["run", "warm_up"])
|
||||
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
|
||||
component.warm_up()
|
||||
query_embedder.warm_up.assert_called_once()
|
||||
retriever.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_inner_components(self):
|
||||
query_embedder = Mock(spec=["run", "warm_up_async"])
|
||||
query_embedder.warm_up_async = AsyncMock()
|
||||
retriever = Mock(spec=["run", "warm_up_async"])
|
||||
retriever.warm_up_async = AsyncMock()
|
||||
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
|
||||
await component.warm_up_async()
|
||||
query_embedder.warm_up_async.assert_awaited_once()
|
||||
retriever.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
query_embedder = Mock(spec=["run", "warm_up"])
|
||||
retriever = Mock(spec=["run", "warm_up"])
|
||||
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
|
||||
await component.warm_up_async()
|
||||
query_embedder.warm_up.assert_called_once()
|
||||
retriever.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_inner_components(self):
|
||||
query_embedder = Mock(spec=["run", "close"])
|
||||
retriever = Mock(spec=["run", "close"])
|
||||
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
|
||||
component.close()
|
||||
query_embedder.close.assert_called_once()
|
||||
retriever.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_inner_components(self):
|
||||
query_embedder = Mock(spec=["run", "close_async"])
|
||||
query_embedder.close_async = AsyncMock()
|
||||
retriever = Mock(spec=["run", "close_async"])
|
||||
retriever.close_async = AsyncMock()
|
||||
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
|
||||
await component.close_async()
|
||||
query_embedder.close_async.assert_awaited_once()
|
||||
retriever.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
query_embedder = Mock(spec=["run", "close"])
|
||||
retriever = Mock(spec=["run", "close"])
|
||||
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
|
||||
await component.close_async()
|
||||
query_embedder.close.assert_called_once()
|
||||
retriever.close.assert_called_once()
|
||||
|
||||
async def test_lifecycle_is_safe_when_inner_components_lack_methods(self):
|
||||
query_embedder = Mock(spec=["run"])
|
||||
retriever = Mock(spec=["run"])
|
||||
component = MultiQueryEmbeddingRetriever(retriever=retriever, query_embedder=query_embedder)
|
||||
component.warm_up()
|
||||
await component.warm_up_async()
|
||||
component.close()
|
||||
await component.close_async()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from unittest.mock import ANY
|
||||
from unittest.mock import ANY, AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -192,3 +192,51 @@ class TestMultiQueryTextRetriever:
|
||||
# assert there are not duplicates
|
||||
contents = [doc.content for doc in results["multiquery_retriever"]["documents"]]
|
||||
assert len(contents) == len(set(contents))
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_retriever(self):
|
||||
retriever = Mock(spec=["run", "warm_up"])
|
||||
component = MultiQueryTextRetriever(retriever=retriever)
|
||||
component.warm_up()
|
||||
retriever.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_retriever(self):
|
||||
retriever = Mock(spec=["run", "warm_up_async"])
|
||||
retriever.warm_up_async = AsyncMock()
|
||||
component = MultiQueryTextRetriever(retriever=retriever)
|
||||
await component.warm_up_async()
|
||||
retriever.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
retriever = Mock(spec=["run", "warm_up"])
|
||||
component = MultiQueryTextRetriever(retriever=retriever)
|
||||
await component.warm_up_async()
|
||||
retriever.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_retriever(self):
|
||||
retriever = Mock(spec=["run", "close"])
|
||||
component = MultiQueryTextRetriever(retriever=retriever)
|
||||
component.close()
|
||||
retriever.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_retriever(self):
|
||||
retriever = Mock(spec=["run", "close_async"])
|
||||
retriever.close_async = AsyncMock()
|
||||
component = MultiQueryTextRetriever(retriever=retriever)
|
||||
await component.close_async()
|
||||
retriever.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
retriever = Mock(spec=["run", "close"])
|
||||
component = MultiQueryTextRetriever(retriever=retriever)
|
||||
await component.close_async()
|
||||
retriever.close.assert_called_once()
|
||||
|
||||
async def test_lifecycle_is_safe_when_retriever_lacks_methods(self):
|
||||
retriever = Mock(spec=["run"])
|
||||
component = MultiQueryTextRetriever(retriever=retriever)
|
||||
component.warm_up()
|
||||
await component.warm_up_async()
|
||||
component.close()
|
||||
await component.close_async()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import ANY
|
||||
from unittest.mock import ANY, AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -491,3 +491,66 @@ class TestMultiRetrieverExperimental:
|
||||
@pytest.mark.filterwarnings("always::haystack.utils.experimental.ExperimentalWarning")
|
||||
def test_experimental_attribute_is_set(self):
|
||||
assert getattr(MultiRetriever, "__experimental__", False) is True
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_all_retrievers(self):
|
||||
a = Mock(spec=["run", "warm_up"])
|
||||
b = Mock(spec=["run", "warm_up"])
|
||||
retriever = MultiRetriever(retrievers={"a": a, "b": b})
|
||||
retriever.warm_up()
|
||||
a.warm_up.assert_called_once()
|
||||
b.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_all_retrievers(self):
|
||||
a = Mock(spec=["run", "warm_up_async"])
|
||||
a.warm_up_async = AsyncMock()
|
||||
b = Mock(spec=["run", "warm_up_async"])
|
||||
b.warm_up_async = AsyncMock()
|
||||
retriever = MultiRetriever(retrievers={"a": a, "b": b})
|
||||
await retriever.warm_up_async()
|
||||
a.warm_up_async.assert_awaited_once()
|
||||
b.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
a = Mock(spec=["run", "warm_up"])
|
||||
b = Mock(spec=["run", "warm_up"])
|
||||
retriever = MultiRetriever(retrievers={"a": a, "b": b})
|
||||
await retriever.warm_up_async()
|
||||
a.warm_up.assert_called_once()
|
||||
b.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_all_retrievers(self):
|
||||
a = Mock(spec=["run", "close"])
|
||||
b = Mock(spec=["run", "close"])
|
||||
retriever = MultiRetriever(retrievers={"a": a, "b": b})
|
||||
retriever.close()
|
||||
a.close.assert_called_once()
|
||||
b.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_all_retrievers(self):
|
||||
a = Mock(spec=["run", "close_async"])
|
||||
a.close_async = AsyncMock()
|
||||
b = Mock(spec=["run", "close_async"])
|
||||
b.close_async = AsyncMock()
|
||||
retriever = MultiRetriever(retrievers={"a": a, "b": b})
|
||||
await retriever.close_async()
|
||||
a.close_async.assert_awaited_once()
|
||||
b.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
a = Mock(spec=["run", "close"])
|
||||
b = Mock(spec=["run", "close"])
|
||||
retriever = MultiRetriever(retrievers={"a": a, "b": b})
|
||||
await retriever.close_async()
|
||||
a.close.assert_called_once()
|
||||
b.close.assert_called_once()
|
||||
|
||||
async def test_lifecycle_is_safe_when_retrievers_lack_methods(self):
|
||||
a = Mock(spec=["run"])
|
||||
b = Mock(spec=["run"])
|
||||
retriever = MultiRetriever(retrievers={"a": a, "b": b})
|
||||
retriever.warm_up()
|
||||
await retriever.warm_up_async()
|
||||
retriever.close()
|
||||
await retriever.close_async()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import ANY
|
||||
from unittest.mock import ANY, AsyncMock, Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -189,3 +189,66 @@ class TestTextEmbeddingRetriever:
|
||||
result = retriever.run(query="energy", top_k=2)
|
||||
assert "documents" in result
|
||||
assert len(result["documents"]) <= 2
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def test_warm_up_delegates_to_inner_components(self):
|
||||
text_embedder = Mock(spec=["run", "warm_up"])
|
||||
retriever = Mock(spec=["run", "warm_up"])
|
||||
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
|
||||
component.warm_up()
|
||||
text_embedder.warm_up.assert_called_once()
|
||||
retriever.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_inner_components(self):
|
||||
text_embedder = Mock(spec=["run", "warm_up_async"])
|
||||
text_embedder.warm_up_async = AsyncMock()
|
||||
retriever = Mock(spec=["run", "warm_up_async"])
|
||||
retriever.warm_up_async = AsyncMock()
|
||||
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
|
||||
await component.warm_up_async()
|
||||
text_embedder.warm_up_async.assert_awaited_once()
|
||||
retriever.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
text_embedder = Mock(spec=["run", "warm_up"])
|
||||
retriever = Mock(spec=["run", "warm_up"])
|
||||
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
|
||||
await component.warm_up_async()
|
||||
text_embedder.warm_up.assert_called_once()
|
||||
retriever.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_inner_components(self):
|
||||
text_embedder = Mock(spec=["run", "close"])
|
||||
retriever = Mock(spec=["run", "close"])
|
||||
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
|
||||
component.close()
|
||||
text_embedder.close.assert_called_once()
|
||||
retriever.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_inner_components(self):
|
||||
text_embedder = Mock(spec=["run", "close_async"])
|
||||
text_embedder.close_async = AsyncMock()
|
||||
retriever = Mock(spec=["run", "close_async"])
|
||||
retriever.close_async = AsyncMock()
|
||||
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
|
||||
await component.close_async()
|
||||
text_embedder.close_async.assert_awaited_once()
|
||||
retriever.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
text_embedder = Mock(spec=["run", "close"])
|
||||
retriever = Mock(spec=["run", "close"])
|
||||
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
|
||||
await component.close_async()
|
||||
text_embedder.close.assert_called_once()
|
||||
retriever.close.assert_called_once()
|
||||
|
||||
async def test_lifecycle_is_safe_when_inner_components_lack_methods(self):
|
||||
text_embedder = Mock(spec=["run"])
|
||||
retriever = Mock(spec=["run"])
|
||||
component = TextEmbeddingRetriever(retriever=retriever, text_embedder=text_embedder)
|
||||
component.warm_up()
|
||||
await component.warm_up_async()
|
||||
component.close()
|
||||
await component.close_async()
|
||||
|
||||
@@ -47,7 +47,6 @@ class TestLLMMessagesRouter:
|
||||
assert router._output_names == ["safe", "unsafe"]
|
||||
assert router._output_patterns == ["safe", "unsafe"]
|
||||
assert router._compiled_patterns == [re.compile(pattern) for pattern in ["safe", "unsafe"]]
|
||||
assert router._is_warmed_up is False
|
||||
|
||||
def test_init_errors(self):
|
||||
chat_generator = MockChatGenerator()
|
||||
@@ -63,23 +62,6 @@ class TestLLMMessagesRouter:
|
||||
chat_generator=chat_generator, output_names=["name1", "name2"], output_patterns=["pattern1"]
|
||||
)
|
||||
|
||||
def test_warm_up_with_unwarmable_chat_generator(self):
|
||||
chat_generator = MockChatGenerator()
|
||||
router = LLMMessagesRouter(
|
||||
chat_generator=chat_generator, output_names=["safe", "unsafe"], output_patterns=["safe", "unsafe"]
|
||||
)
|
||||
router.warm_up()
|
||||
assert router._is_warmed_up is True
|
||||
|
||||
def test_warm_up_with_warmable_chat_generator(self):
|
||||
chat_generator = Mock()
|
||||
router = LLMMessagesRouter(
|
||||
chat_generator=chat_generator, output_names=["safe", "unsafe"], output_patterns=["safe", "unsafe"]
|
||||
)
|
||||
router.warm_up()
|
||||
assert router._is_warmed_up is True
|
||||
assert router._chat_generator.warm_up.call_count == 1
|
||||
|
||||
def test_run_input_errors(self):
|
||||
router = LLMMessagesRouter(
|
||||
chat_generator=MockChatGenerator(), output_names=["safe", "unsafe"], output_patterns=["safe", "unsafe"]
|
||||
@@ -99,8 +81,6 @@ class TestLLMMessagesRouter:
|
||||
router.run([ChatMessage.from_user("Hello")])
|
||||
|
||||
def test_run_no_warm_up_with_warmable_chat_generator(self):
|
||||
"""Warm up is run automatically if not done before."""
|
||||
|
||||
def mock_run(messages):
|
||||
return {"replies": [ChatMessage.from_assistant("safe")]}
|
||||
|
||||
@@ -111,7 +91,6 @@ class TestLLMMessagesRouter:
|
||||
)
|
||||
router.run([ChatMessage.from_user("Hello")])
|
||||
assert chat_generator.warm_up.call_count == 1
|
||||
assert router._is_warmed_up is True
|
||||
|
||||
def test_run(self):
|
||||
router = LLMMessagesRouter(
|
||||
@@ -315,3 +294,54 @@ class TestLLMMessagesRouterAsync:
|
||||
assert result["chat_generator_text"].lower() == "safe"
|
||||
assert "unsafe" not in result
|
||||
assert "unmatched" not in result
|
||||
|
||||
|
||||
class TestComponentLifecycle:
|
||||
def _make_router(self, chat_generator):
|
||||
return LLMMessagesRouter(
|
||||
chat_generator=chat_generator, output_names=["safe", "unsafe"], output_patterns=["safe", "unsafe"]
|
||||
)
|
||||
|
||||
def test_warm_up_delegates_to_chat_generator(self):
|
||||
chat_generator = Mock()
|
||||
router = self._make_router(chat_generator)
|
||||
router.warm_up()
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
async def test_warm_up_async_delegates_to_chat_generator(self):
|
||||
chat_generator = Mock()
|
||||
chat_generator.warm_up_async = AsyncMock()
|
||||
router = self._make_router(chat_generator)
|
||||
await router.warm_up_async()
|
||||
chat_generator.warm_up_async.assert_awaited_once()
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up(self):
|
||||
chat_generator = Mock(spec=["run", "warm_up"])
|
||||
router = self._make_router(chat_generator)
|
||||
await router.warm_up_async()
|
||||
chat_generator.warm_up.assert_called_once()
|
||||
|
||||
def test_close_delegates_to_chat_generator(self):
|
||||
chat_generator = Mock()
|
||||
router = self._make_router(chat_generator)
|
||||
router.close()
|
||||
chat_generator.close.assert_called_once()
|
||||
|
||||
async def test_close_async_delegates_to_chat_generator(self):
|
||||
chat_generator = Mock()
|
||||
chat_generator.close_async = AsyncMock()
|
||||
router = self._make_router(chat_generator)
|
||||
await router.close_async()
|
||||
chat_generator.close_async.assert_awaited_once()
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close(self):
|
||||
chat_generator = Mock(spec=["run", "close"])
|
||||
router = self._make_router(chat_generator)
|
||||
await router.close_async()
|
||||
chat_generator.close.assert_called_once()
|
||||
|
||||
def test_lifecycle_is_safe_when_chat_generator_lacks_methods(self):
|
||||
chat_generator = Mock(spec=["run"])
|
||||
router = self._make_router(chat_generator)
|
||||
router.warm_up()
|
||||
router.close()
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Pipeline, component
|
||||
|
||||
|
||||
@component
|
||||
class LifecycleRecorder:
|
||||
"""Records every lifecycle method called, so tests can assert which ones the pipeline picks."""
|
||||
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def warm_up(self):
|
||||
self.events.append("warm_up")
|
||||
|
||||
async def warm_up_async(self):
|
||||
self.events.append("warm_up_async")
|
||||
|
||||
@component.output_types(value=int)
|
||||
def run(self):
|
||||
self.events.append("run")
|
||||
return {"value": 1}
|
||||
|
||||
@component.output_types(value=int)
|
||||
async def run_async(self):
|
||||
self.events.append("run_async")
|
||||
return {"value": 1}
|
||||
|
||||
def close(self):
|
||||
self.events.append("close")
|
||||
|
||||
async def close_async(self):
|
||||
self.events.append("close_async")
|
||||
|
||||
|
||||
@component
|
||||
class SyncOnlyRecorder:
|
||||
"""Implements only the synchronous warm_up and close, to exercise the async fallbacks."""
|
||||
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def warm_up(self):
|
||||
self.events.append("warm_up")
|
||||
|
||||
@component.output_types(value=int)
|
||||
def run(self):
|
||||
return {"value": 1}
|
||||
|
||||
@component.output_types(value=int)
|
||||
async def run_async(self):
|
||||
return {"value": 1}
|
||||
|
||||
def close(self):
|
||||
self.events.append("close")
|
||||
|
||||
|
||||
@component
|
||||
class BareComponent:
|
||||
"""Implements no lifecycle method at all."""
|
||||
|
||||
@component.output_types(value=int)
|
||||
def run(self):
|
||||
return {"value": 1}
|
||||
|
||||
@component.output_types(value=int)
|
||||
async def run_async(self):
|
||||
return {"value": 1}
|
||||
|
||||
|
||||
class LoopBoundAsyncClient:
|
||||
"""Mimics a real async client (aiohttp): binds to the loop it is created on and refuses any other."""
|
||||
|
||||
def __init__(self):
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
async def use(self):
|
||||
if asyncio.get_running_loop() is not self._loop:
|
||||
raise RuntimeError("async client used on a different event loop than the one it was created on")
|
||||
|
||||
|
||||
@component
|
||||
class AsyncClientComponent:
|
||||
"""Creates a loop-bound async client in warm_up_async and uses it in run_async."""
|
||||
|
||||
def __init__(self):
|
||||
self.client: LoopBoundAsyncClient | None = None
|
||||
|
||||
async def warm_up_async(self):
|
||||
if self.client is None:
|
||||
self.client = LoopBoundAsyncClient()
|
||||
|
||||
@component.output_types(value=int)
|
||||
async def run_async(self):
|
||||
assert self.client is not None
|
||||
await self.client.use()
|
||||
return {"value": 1}
|
||||
|
||||
@component.output_types(value=int)
|
||||
def run(self):
|
||||
return {"value": 1}
|
||||
|
||||
|
||||
async def test_run_async_uses_warm_up_async():
|
||||
"""When a component implements warm_up_async, run_async uses it and does not also call its sync warm_up."""
|
||||
rec = LifecycleRecorder()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("rec", rec)
|
||||
await pipe.run_async({"rec": {}})
|
||||
assert "warm_up_async" in rec.events
|
||||
assert "warm_up" not in rec.events
|
||||
|
||||
|
||||
async def test_warm_up_async_falls_back_to_sync_warm_up():
|
||||
"""A component with only the sync warm_up is still warmed by run_async through that method."""
|
||||
rec = SyncOnlyRecorder()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("rec", rec)
|
||||
await pipe.run_async({"rec": {}})
|
||||
assert rec.events == ["warm_up"]
|
||||
|
||||
|
||||
def test_sync_run_uses_sync_warm_up():
|
||||
"""The sync run path warms components via the sync warm_up, never warm_up_async."""
|
||||
rec = LifecycleRecorder()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("rec", rec)
|
||||
pipe.run({"rec": {}})
|
||||
assert "warm_up" in rec.events
|
||||
assert "warm_up_async" not in rec.events
|
||||
|
||||
|
||||
def test_pipeline_close_calls_sync_close_only():
|
||||
"""Pipeline.close() calls each component's sync close, never close_async."""
|
||||
rec = LifecycleRecorder()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("rec", rec)
|
||||
pipe.close()
|
||||
assert "close" in rec.events
|
||||
assert "close_async" not in rec.events
|
||||
|
||||
|
||||
async def test_pipeline_close_async_calls_async_close_only():
|
||||
"""When a component implements close_async, Pipeline.close_async() uses it and does not also call its sync close."""
|
||||
rec = LifecycleRecorder()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("rec", rec)
|
||||
await pipe.close_async()
|
||||
assert "close_async" in rec.events
|
||||
assert "close" not in rec.events
|
||||
|
||||
|
||||
async def test_close_async_falls_back_to_sync_close():
|
||||
"""A component with only the sync close is still released by close_async through that method."""
|
||||
rec = SyncOnlyRecorder()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("rec", rec)
|
||||
await pipe.close_async()
|
||||
assert rec.events == ["close"]
|
||||
|
||||
|
||||
async def test_run_does_not_auto_close():
|
||||
"""Running a pipeline (sync or async) never closes components; closing is always explicit."""
|
||||
rec = LifecycleRecorder()
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("rec", rec)
|
||||
pipe.run({"rec": {}})
|
||||
await pipe.run_async({"rec": {}})
|
||||
assert "close" not in rec.events
|
||||
assert "close_async" not in rec.events
|
||||
|
||||
|
||||
async def test_lifecycle_methods_are_optional():
|
||||
"""A component without lifecycle methods works: every call is hasattr-guarded and skipped."""
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("bare", BareComponent())
|
||||
await pipe.warm_up_async()
|
||||
pipe.close()
|
||||
await pipe.close_async()
|
||||
await pipe.run_async({"bare": {}})
|
||||
|
||||
|
||||
def test_loop_bound_client_rejects_other_loop():
|
||||
"""The fake client raises when used from a different loop.
|
||||
|
||||
This ensures the affinity test below enforces loop binding.
|
||||
"""
|
||||
|
||||
async def _make_loop_bound_client():
|
||||
return LoopBoundAsyncClient()
|
||||
|
||||
client = asyncio.run(_make_loop_bound_client())
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(client.use())
|
||||
|
||||
|
||||
async def test_async_client_bound_to_run_loop():
|
||||
"""warm_up_async creates the async client on the loop run_async uses, so it stays usable there."""
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("client_component", AsyncClientComponent())
|
||||
await pipe.warm_up_async()
|
||||
# Would raise if warm_up_async had bound the client to a different loop than run_async
|
||||
await pipe.run_async({"client_component": {}})
|
||||
@@ -3,7 +3,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -467,3 +467,38 @@ class TestSuperComponent:
|
||||
|
||||
result = await deserialized_super_component.run_async()
|
||||
assert result == {"output": "Hello world"}
|
||||
|
||||
|
||||
class TestSuperComponentLifecycle:
|
||||
def test_warm_up_delegates_to_pipeline(self, sample_super_component):
|
||||
with patch.object(sample_super_component.pipeline, "warm_up") as mock_warm_up:
|
||||
sample_super_component.warm_up()
|
||||
mock_warm_up.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_and_async_warm_up_are_independent(self, sample_super_component):
|
||||
with (
|
||||
patch.object(sample_super_component.pipeline, "warm_up") as mock_warm_up,
|
||||
patch.object(sample_super_component.pipeline, "warm_up_async", new=AsyncMock()) as mock_warm_up_async,
|
||||
):
|
||||
sample_super_component.warm_up()
|
||||
mock_warm_up.assert_called_once()
|
||||
await sample_super_component.warm_up_async()
|
||||
mock_warm_up_async.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warm_up_async_delegates_to_pipeline(self, sample_super_component):
|
||||
with patch.object(sample_super_component.pipeline, "warm_up_async", new=AsyncMock()) as mock_warm_up_async:
|
||||
await sample_super_component.warm_up_async()
|
||||
mock_warm_up_async.assert_awaited_once()
|
||||
|
||||
def test_close_delegates_to_pipeline(self, sample_super_component):
|
||||
with patch.object(sample_super_component.pipeline, "close") as mock_close:
|
||||
sample_super_component.close()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_async_delegates_to_pipeline(self, sample_super_component):
|
||||
with patch.object(sample_super_component.pipeline, "close_async", new=AsyncMock()) as mock_close_async:
|
||||
await sample_super_component.close_async()
|
||||
mock_close_async.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user