Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions livekit-agents/livekit/agents/llm/_provider_format/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ def to_chat_ctx(
"args": json.loads(msg.arguments or "{}"),
}
}
# Inject thought_signature if available (Gemini 3 multi-turn function calling)
if thought_signatures and (sig := thought_signatures.get(msg.call_id)):
# Inject thought_signature if available (Gemini 2.5+/3 multi-turn function
# calling). thought_signatures is only non-None when the target model
# requires one; if none was ever captured for this call (e.g. replayed from
# a thinking-disabled model, a non-Gemini fallback, or a different LLM
# instance), fall back to Google's documented bypass sentinel instead of
# omitting the field, which trips a hard "missing thought_signature" 400.
if thought_signatures is not None:
sig = thought_signatures.get(msg.call_id) or b"skip_thought_signature_validator"
fc_part["thought_signature"] = sig
parts.append(fc_part)
elif msg.type == "function_call_output":
Expand Down
72 changes: 66 additions & 6 deletions livekit-agents/livekit/agents/llm/fallback_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def __init__(

self._llm_instances = llm
self._attempt_timeout = attempt_timeout
self._current_llm_index: int = 0
self._max_retry_per_llm = max_retry_per_llm
self._retry_interval = retry_interval
self._retry_on_chunk_sent = retry_on_chunk_sent
Expand All @@ -84,6 +85,59 @@ def model(self) -> str:
def provider(self) -> str:
return "livekit"

def switch_to_next(self, *, only_if_available: bool = True) -> bool:
"""
Move pointer to next LLM in order.

Args:
only_if_available: if True, skip unavailable LLMs

Returns:
True if switched, False otherwise
"""

n = len(self._llm_instances)
start = self._current_llm_index

for offset in range(1, n + 1):
i = (start + offset) % n
status = self._status[i]

if not only_if_available or status.available:
prev = self._current_llm_index
self._current_llm_index = i

logger.info(
f"Manual switch LLM from "
f"{self._llm_instances[prev].label} "
f"to {self._llm_instances[i].label}"
)

return True
Comment on lines +102 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 No-op provider switches report success

switch_to_next eventually reselects the starting provider. It returns True when no alternative is available, although the pointer never changes.

Learn more

The loop includes offset == n, which wraps i back to start. If the current provider is available, that final iteration always succeeds even though no movement occurred. The STT implementation has the same contract violation in its manual switch.

Example: An adapter has one provider, or provider 0 is current while every other provider is unavailable. Calling switch_to_next() logs a switch from provider 0 to itself and returns True; callers cannot detect that failover was impossible.

Recommended fix: Iterate offsets 1..n-1 only. Return False when no distinct eligible provider exists.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return False

def _ordered_indices(self) -> list[int]:
n = len(self._llm_instances)
return (
[self._current_llm_index]
+ list(range(self._current_llm_index + 1, n))
+ list(range(0, self._current_llm_index))
)

def _mark_failed(self, idx: int) -> None:
self._status[idx].available = False

for i in self._ordered_indices()[1:]:
if self._status[i].available:
self._current_llm_index = i
logger.info(
f"Auto switch LLM from "
f"{self._llm_instances[idx].label} "
f"to {self._llm_instances[i].label}"
)
return
Comment on lines +131 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Late failures displace healthy providers

When an older request fails after the pointer moves, _mark_failed excludes the current provider. LLM, STT, and TTS adapters rotate again unnecessarily.

Learn more

The adapter-wide current pointer is shared by concurrent requests. _mark_failed(idx) computes candidates from the pointer's latest value rather than from idx. Its slice omits that current value, so a failure from an older request can replace a healthy provider selected by another request. The same implementation exists in the STT adapter and the TTS adapter.

Example: Request A starts on provider 0. Another failure moves the current pointer to healthy provider 1. When request A later reports provider 0 failed, _mark_failed(0) starts after provider 1 and moves the pointer to provider 2.

Recommended fix: Always mark idx unavailable, but advance the pointer only when idx == _current_*_index or the current provider is unavailable. Apply the same synchronization rule to all three adapters.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


def chat(
self,
*,
Expand Down Expand Up @@ -239,12 +293,17 @@ async def _recover_llm_task(llm: LLM) -> None:
async def _run(self) -> None:
start_time = time.time()

all_failed = all(not llm_status.available for llm_status in self._fallback_adapter._status)
adapter = self._fallback_adapter

all_failed = all(not llm_status.available for llm_status in 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]
indices = range(len(adapter._llm_instances)) if all_failed else adapter._ordered_indices()

for i in indices:
llm = adapter._llm_instances[i]
llm_status = adapter._status[i]
if llm_status.available or all_failed:
text_sent: str = ""
tool_calls_sent: list[str] = []
Expand All @@ -261,8 +320,8 @@ async def _run(self) -> None:
return
except Exception: # exceptions already logged inside _try_generate
if llm_status.available:
llm_status.available = False
self._fallback_adapter.emit(
adapter._mark_failed(i)
adapter.emit(
"llm_availability_changed",
AvailabilityChangedEvent(llm=llm, available=False),
)
Expand All @@ -282,7 +341,8 @@ async def _run(self) -> None:
extra=extra,
)

self._try_recovery(llm)
if not llm_status.available:
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
Expand Down
Loading