Files
2026-07-13 13:39:38 +08:00

299 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import dataclasses
import time
from collections.abc import AsyncIterable
from dataclasses import dataclass
from typing import Any, ClassVar, Literal
from .._exceptions import APIConnectionError, APIError
from ..log import logger
from ..types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr
from .chat_context import ChatContext
from .llm import LLM, ChatChunk, LLMStream
from .tool_context import Tool, ToolChoice
DEFAULT_FALLBACK_API_CONNECT_OPTIONS = APIConnectOptions(
max_retry=0, timeout=DEFAULT_API_CONNECT_OPTIONS.timeout
)
@dataclass
class _LLMStatus:
available: bool
recovering_task: asyncio.Task[None] | None
@dataclass
class AvailabilityChangedEvent:
llm: LLM
available: bool
class FallbackAdapter(
LLM[Literal["llm_availability_changed"]],
):
"""Agent Fallback Adapter for LLM. Manages multiple STT instances with automatic fallback
when the primary provider fails.
"""
def __init__(
self,
llm: list[LLM],
*,
attempt_timeout: float = 5.0,
# use fallback instead of retrying
max_retry_per_llm: int = 0,
retry_interval: float = 0.5,
retry_on_chunk_sent: bool = False,
) -> None:
"""FallbackAdapter is an LLM that can fallback to a different LLM if the current LLM fails.
Args:
llm (list[LLM]): List of LLM instances to fallback to.
attempt_timeout (float, optional): Timeout for each LLM attempt. Defaults to 5.0.
max_retry_per_llm (int, optional): Internal retries per LLM. Defaults to 0, which means no
internal retries, the failed LLM will be skipped and the next LLM will be used.
retry_interval (float, optional): Interval between retries. Defaults to 0.5.
retry_on_chunk_sent (bool, optional): Whether to retry when a LLM failed after chunks
are sent. Defaults to False.
Raises:
ValueError: If no LLM instances are provided.
"""
if len(llm) < 1:
raise ValueError("at least one LLM instance must be provided.")
super().__init__()
self._llm_instances = llm
self._attempt_timeout = attempt_timeout
self._max_retry_per_llm = max_retry_per_llm
self._retry_interval = retry_interval
self._retry_on_chunk_sent = retry_on_chunk_sent
self._status = [
_LLMStatus(available=True, recovering_task=None) for _ in self._llm_instances
]
for llm_instance in self._llm_instances:
llm_instance.on("metrics_collected", self._on_metrics_collected)
@property
def model(self) -> str:
return "FallbackAdapter"
@property
def provider(self) -> str:
return "livekit"
def chat(
self,
*,
chat_ctx: ChatContext,
tools: list[Tool] | None = None,
conn_options: APIConnectOptions = DEFAULT_FALLBACK_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LLMStream:
return FallbackLLMStream(
llm=self,
conn_options=conn_options,
chat_ctx=chat_ctx,
tools=tools or [],
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
extra_kwargs=extra_kwargs,
)
async def aclose(self) -> None:
for llm_instance in self._llm_instances:
llm_instance.off("metrics_collected", self._on_metrics_collected)
def _on_metrics_collected(self, *args: Any, **kwargs: Any) -> None:
self.emit("metrics_collected", *args, **kwargs)
class FallbackLLMStream(LLMStream):
_llm_request_span_name: ClassVar[str] = "llm_fallback_adapter"
def __init__(
self,
llm: FallbackAdapter,
*,
chat_ctx: ChatContext,
tools: list[Tool],
conn_options: APIConnectOptions,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> None:
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
self._fallback_adapter = llm
self._parallel_tool_calls = parallel_tool_calls
self._tool_choice = tool_choice
self._extra_kwargs = extra_kwargs
self._current_stream: LLMStream | None = None
@property
def chat_ctx(self) -> ChatContext:
if self._current_stream is None:
return self._chat_ctx
return self._current_stream.chat_ctx
@property
def tools(self) -> list[Tool]:
if self._current_stream is None:
return self._tools
return self._current_stream.tools
async def _try_generate(
self, *, llm: LLM, check_recovery: bool = False
) -> AsyncIterable[ChatChunk]:
"""
Try to generate with the given LLM.
Args:
llm: The LLM instance to generate with
check_recovery: When True, indicates this is a background recovery check and the
result will not be used. Recovery checks verify if a previously
failed LLM has become available again.
"""
try:
async with llm.chat(
chat_ctx=self._chat_ctx,
tools=self._tools,
parallel_tool_calls=self._parallel_tool_calls,
tool_choice=self._tool_choice,
extra_kwargs=self._extra_kwargs,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=self._fallback_adapter._max_retry_per_llm,
timeout=self._fallback_adapter._attempt_timeout,
retry_interval=self._fallback_adapter._retry_interval,
),
) as stream:
should_set_current = not check_recovery
async for chunk in stream:
if should_set_current:
should_set_current = False
self._current_stream = stream
yield chunk
except asyncio.TimeoutError:
if check_recovery:
logger.warning(f"{llm.label} recovery timed out")
raise
logger.warning(
f"{llm.label} timed out, switching to next LLM",
)
raise
except APIError as e:
if check_recovery:
logger.warning(
"%s recovery failed: %s",
llm.label,
e,
)
raise
logger.warning(
"%s failed, switching to next LLM: %s",
llm.label,
e,
)
raise
except Exception:
if check_recovery:
logger.exception(
f"{llm.label} recovery unexpected error",
)
raise
logger.exception(
f"{llm.label} unexpected error, switching to next LLM",
)
raise
def _try_recovery(self, llm: LLM) -> None:
llm_status = self._fallback_adapter._status[
self._fallback_adapter._llm_instances.index(llm)
]
if llm_status.recovering_task is None or llm_status.recovering_task.done():
async def _recover_llm_task(llm: LLM) -> None:
try:
async for _ in self._try_generate(llm=llm, check_recovery=True):
pass
llm_status.available = True
logger.info(f"llm.FallbackAdapter, {llm.label} recovered")
self._fallback_adapter.emit(
"llm_availability_changed",
AvailabilityChangedEvent(llm=llm, available=True),
)
except Exception:
return
llm_status.recovering_task = asyncio.create_task(_recover_llm_task(llm))
async def _run(self) -> None:
start_time = time.time()
all_failed = all(not llm_status.available for llm_status in self._fallback_adapter._status)
if all_failed:
logger.error("all LLMs are unavailable, retrying..")
for i, llm in enumerate(self._fallback_adapter._llm_instances):
llm_status = self._fallback_adapter._status[i]
if llm_status.available or all_failed:
text_sent: str = ""
tool_calls_sent: list[str] = []
try:
async for result in self._try_generate(llm=llm, check_recovery=False):
if result.delta:
if result.delta.content:
text_sent += result.delta.content
for tool_call in result.delta.tool_calls:
tool_calls_sent.append(tool_call.name)
self._event_ch.send_nowait(result)
return
except Exception: # exceptions already logged inside _try_generate
if llm_status.available:
llm_status.available = False
self._fallback_adapter.emit(
"llm_availability_changed",
AvailabilityChangedEvent(llm=llm, available=False),
)
if text_sent or tool_calls_sent:
extra = {"text_sent": text_sent, "tool_calls_sent": tool_calls_sent}
if not self._fallback_adapter._retry_on_chunk_sent:
logger.error(
f"{llm.label} failed after sending chunk, skip retrying. "
"Set `retry_on_chunk_sent` to `True` to enable retrying after chunks are sent.",
extra=extra,
)
raise
logger.warning(
f"{llm.label} failed after sending chunk, retrying..",
extra=extra,
)
self._try_recovery(llm)
raise APIConnectionError(
f"all LLMs failed ({[llm.label for llm in self._fallback_adapter._llm_instances]}) after {time.time() - start_time} seconds" # noqa: E501
)
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[ChatChunk]) -> None:
return