-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat: Add FallbackChatGenerator #9859
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
eec35ce
Add FallbackChatGenerator
vblagoje ed5d790
Update licence files
vblagoje 6d32b0b
Use typing.Optional/Union for Python 3.9 compat
vblagoje 8fb9cd7
Use the right logger
vblagoje 2787e83
Lint fix
vblagoje afbdb32
PR review
vblagoje 47c997a
Rewrite release note
vblagoje 0400d56
Add FallbackChatGenerator to docs
vblagoje 3e3eea2
Update haystack/components/generators/chat/fallback.py
vblagoje dcc81cb
Rename generator -> chat_generators
vblagoje ad5ded5
Lint
vblagoje 9b69417
Rename generators -> chat_generators in meta, docs, tests
vblagoje c3fd39c
Update haystack/components/generators/chat/fallback.py
vblagoje f97d951
Update pydocs
vblagoje f20ff2b
Minor pydocs fix
vblagoje File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| from typing import Any, Union | ||
|
|
||
| from haystack import component, default_from_dict, default_to_dict, logging | ||
| from haystack.components.generators.chat.types import ChatGenerator | ||
| from haystack.dataclasses import ChatMessage, StreamingCallbackT | ||
| from haystack.tools import Tool, Toolset | ||
| from haystack.utils.deserialization import deserialize_component_inplace | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @component | ||
| class FallbackChatGenerator: | ||
| """ | ||
| A chat generator wrapper that tries multiple chat generators sequentially. | ||
|
|
||
| It forwards all parameters transparently to the underlying chat generators and returns the first successful result. | ||
| Calls chat generators sequentially until one succeeds. Falls back on any exception raised by a generator. | ||
| If all chat generators fail, it raises a RuntimeError with details. | ||
|
|
||
| Timeout enforcement is fully delegated to the underlying chat generators. The fallback mechanism will only | ||
| work correctly if the underlying chat generators implement proper timeout handling and raise exceptions | ||
| when timeouts occur. For predictable latency guarantees, ensure your chat generators: | ||
| - Support a `timeout` parameter in their initialization | ||
| - Implement timeout as total wall-clock time (shared deadline for both streaming and non-streaming) | ||
| - Raise timeout exceptions (e.g., TimeoutError, asyncio.TimeoutError, httpx.TimeoutException) when exceeded | ||
|
|
||
| Note: Most well-implemented chat generators (OpenAI, Anthropic, Cohere, etc.) support timeout parameters | ||
| with consistent semantics. For HTTP-based LLM providers, a single timeout value (e.g., `timeout=30`) | ||
| typically applies to all connection phases: connection setup, read, write, and pool. For streaming | ||
| responses, read timeout is the maximum gap between chunks. For non-streaming, it's the time limit for | ||
| receiving the complete response. | ||
|
|
||
| Failover is automatically triggered when a generator raises any exception, including: | ||
| - Timeout errors (if the generator implements and raises them) | ||
| - Rate limit errors (429) | ||
| - Authentication errors (401) | ||
| - Context length errors (400) | ||
| - Server errors (500+) | ||
| - Any other exception | ||
| """ | ||
|
|
||
| def __init__(self, chat_generators: list[ChatGenerator]): | ||
| """ | ||
| Creates an instance of FallbackChatGenerator. | ||
|
|
||
| :param chat_generators: A non-empty list of chat generator components to try in order. | ||
| """ | ||
| if not chat_generators: | ||
| msg = "'chat_generators' must be a non-empty list" | ||
| raise ValueError(msg) | ||
|
|
||
| self.chat_generators = list(chat_generators) | ||
|
|
||
| def to_dict(self) -> dict[str, Any]: | ||
| """Serialize the component, including nested chat generators when they support serialization.""" | ||
| return default_to_dict( | ||
| self, chat_generators=[gen.to_dict() for gen in self.chat_generators if hasattr(gen, "to_dict")] | ||
| ) | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, data: dict[str, Any]) -> FallbackChatGenerator: | ||
| """Rebuild the component from a serialized representation, restoring nested chat generators.""" | ||
| # Reconstruct nested chat generators from their serialized dicts | ||
| init_params = data.get("init_parameters", {}) | ||
| serialized = init_params.get("chat_generators") or [] | ||
| deserialized: list[Any] = [] | ||
| for g in serialized: | ||
| # Use the generic component deserializer available in Haystack | ||
| holder = {"component": g} | ||
| deserialize_component_inplace(holder, key="component") | ||
| deserialized.append(holder["component"]) | ||
| init_params["chat_generators"] = deserialized | ||
| data["init_parameters"] = init_params | ||
| return default_from_dict(cls, data) | ||
|
|
||
| def _run_single_sync( # pylint: disable=too-many-positional-arguments | ||
| self, | ||
| gen: Any, | ||
| messages: list[ChatMessage], | ||
| generation_kwargs: Union[dict[str, Any], None], | ||
| tools: Union[list[Tool], Toolset, None], | ||
|
vblagoje marked this conversation as resolved.
|
||
| streaming_callback: Union[StreamingCallbackT, None], | ||
| ) -> dict[str, Any]: | ||
| return gen.run( | ||
| messages=messages, generation_kwargs=generation_kwargs, tools=tools, streaming_callback=streaming_callback | ||
| ) | ||
|
|
||
| async def _run_single_async( # pylint: disable=too-many-positional-arguments | ||
| self, | ||
| gen: Any, | ||
| messages: list[ChatMessage], | ||
| generation_kwargs: Union[dict[str, Any], None], | ||
| tools: Union[list[Tool], Toolset, None], | ||
| streaming_callback: Union[StreamingCallbackT, None], | ||
| ) -> dict[str, Any]: | ||
| if hasattr(gen, "run_async") and callable(gen.run_async): | ||
| return await gen.run_async( | ||
| messages=messages, | ||
| generation_kwargs=generation_kwargs, | ||
| tools=tools, | ||
| streaming_callback=streaming_callback, | ||
| ) | ||
| return await asyncio.to_thread( | ||
| gen.run, | ||
| messages=messages, | ||
| generation_kwargs=generation_kwargs, | ||
| tools=tools, | ||
| streaming_callback=streaming_callback, | ||
| ) | ||
|
|
||
| @component.output_types(replies=list[ChatMessage], meta=dict[str, Any]) | ||
| def run( | ||
| self, | ||
| messages: list[ChatMessage], | ||
| generation_kwargs: Union[dict[str, Any], None] = None, | ||
| tools: Union[list[Tool], Toolset, None] = None, | ||
| streaming_callback: Union[StreamingCallbackT, None] = None, | ||
| ) -> dict[str, Any]: | ||
|
Comment on lines
+120
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lets explain these parameters and return instance in docstrings. |
||
| """ | ||
| Execute chat generators sequentially until one succeeds. | ||
|
|
||
| :param messages: The conversation history as a list of ChatMessage instances. | ||
| :param generation_kwargs: Optional parameters for the chat generator (e.g., temperature, max_tokens). | ||
| :param tools: Optional Tool instances or Toolset for function calling capabilities. | ||
| :param streaming_callback: Optional callable for handling streaming responses. | ||
| :returns: A dictionary with: | ||
| - "replies": Generated ChatMessage instances from the first successful generator. | ||
| - "meta": Execution metadata including successful_chat_generator_index, successful_chat_generator_class, | ||
| total_attempts, failed_chat_generators, plus any metadata from the successful generator. | ||
| :raises RuntimeError: If all chat generators fail. | ||
| """ | ||
| failed: list[str] = [] | ||
| last_error: Union[BaseException, None] = None | ||
|
|
||
| for idx, gen in enumerate(self.chat_generators): | ||
| gen_name = gen.__class__.__name__ | ||
| try: | ||
| result = self._run_single_sync(gen, messages, generation_kwargs, tools, streaming_callback) | ||
| replies = result.get("replies", []) | ||
| meta = dict(result.get("meta", {})) | ||
| meta.update( | ||
| { | ||
| "successful_chat_generator_index": idx, | ||
| "successful_chat_generator_class": gen_name, | ||
| "total_attempts": idx + 1, | ||
| "failed_chat_generators": failed, | ||
| } | ||
| ) | ||
| return {"replies": replies, "meta": meta} | ||
| except Exception as e: # noqa: BLE001 - fallback logic should handle any exception | ||
| logger.warning( | ||
| "ChatGenerator {chat_generator} failed with error: {error}", chat_generator=gen_name, error=e | ||
| ) | ||
| failed.append(gen_name) | ||
| last_error = e | ||
|
|
||
| failed_names = ", ".join(failed) | ||
| msg = ( | ||
| f"All {len(self.chat_generators)} chat generators failed. " | ||
| f"Last error: {last_error}. Failed chat generators: [{failed_names}]" | ||
| ) | ||
| raise RuntimeError(msg) | ||
|
|
||
| @component.output_types(replies=list[ChatMessage], meta=dict[str, Any]) | ||
| async def run_async( | ||
| self, | ||
| messages: list[ChatMessage], | ||
| generation_kwargs: Union[dict[str, Any], None] = None, | ||
| tools: Union[list[Tool], Toolset, None] = None, | ||
| streaming_callback: Union[StreamingCallbackT, None] = None, | ||
| ) -> dict[str, Any]: | ||
| """ | ||
| Asynchronously execute chat generators sequentially until one succeeds. | ||
|
|
||
| :param messages: The conversation history as a list of ChatMessage instances. | ||
| :param generation_kwargs: Optional parameters for the chat generator (e.g., temperature, max_tokens). | ||
| :param tools: Optional Tool instances or Toolset for function calling capabilities. | ||
| :param streaming_callback: Optional callable for handling streaming responses. | ||
| :returns: A dictionary with: | ||
| - "replies": Generated ChatMessage instances from the first successful generator. | ||
| - "meta": Execution metadata including successful_chat_generator_index, successful_chat_generator_class, | ||
| total_attempts, failed_chat_generators, plus any metadata from the successful generator. | ||
| :raises RuntimeError: If all chat generators fail. | ||
| """ | ||
| failed: list[str] = [] | ||
| last_error: Union[BaseException, None] = None | ||
|
|
||
| for idx, gen in enumerate(self.chat_generators): | ||
| gen_name = gen.__class__.__name__ | ||
| try: | ||
| result = await self._run_single_async(gen, messages, generation_kwargs, tools, streaming_callback) | ||
| replies = result.get("replies", []) | ||
| meta = dict(result.get("meta", {})) | ||
| meta.update( | ||
| { | ||
| "successful_chat_generator_index": idx, | ||
| "successful_chat_generator_class": gen_name, | ||
| "total_attempts": idx + 1, | ||
| "failed_chat_generators": failed, | ||
| } | ||
| ) | ||
| return {"replies": replies, "meta": meta} | ||
| except Exception as e: # noqa: BLE001 - fallback logic should handle any exception | ||
| logger.warning( | ||
| "ChatGenerator {chat_generator} failed with error: {error}", chat_generator=gen_name, error=e | ||
| ) | ||
| failed.append(gen_name) | ||
| last_error = e | ||
|
|
||
| failed_names = ", ".join(failed) | ||
| msg = ( | ||
| f"All {len(self.chat_generators)} chat generators failed. " | ||
| f"Last error: {last_error}. Failed chat generators: [{failed_names}]" | ||
| ) | ||
| raise RuntimeError(msg) | ||
6 changes: 6 additions & 0 deletions
6
releasenotes/notes/add-fallback-chat-generator-ffe557ca01fcdaca.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| highlights: > | ||
| Introduced `FallbackChatGenerator` that tries multiple chat providers one by one, improving reliability in production and making sure you get answers even when some provider fails. | ||
| features: | ||
| - | | ||
| Added `FallbackChatGenerator` that automatically retries different chat generators and returns first successful response with detailed information about which providers were tried. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.